From 02db0bcc2e6b00e965a5efa2c346d9f863290957 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 12 May 2023 16:21:22 -0400 Subject: [PATCH 01/13] Closes #11766: Remove obsolete custom ChoiceField and MultipleChoiceField classes --- docs/plugins/development/forms.md | 13 ------------- docs/release-notes/version-3.6.md | 7 +++++++ mkdocs.yml | 1 + netbox/utilities/forms/fields/fields.py | 23 ----------------------- 4 files changed, 8 insertions(+), 36 deletions(-) create mode 100644 docs/release-notes/version-3.6.md diff --git a/docs/plugins/development/forms.md b/docs/plugins/development/forms.md index 51f6c70de..31751855e 100644 --- a/docs/plugins/development/forms.md +++ b/docs/plugins/development/forms.md @@ -165,19 +165,6 @@ In addition to the [form fields provided by Django](https://docs.djangoproject.c options: members: false -## Choice Fields - -!!! warning "Obsolete Fields" - NetBox's custom `ChoiceField` and `MultipleChoiceField` classes are no longer necessary thanks to improvements made to the user interface. Django's native form fields can be used instead. These custom field classes will be removed in NetBox v3.6. - -::: utilities.forms.fields.ChoiceField - options: - members: false - -::: utilities.forms.fields.MultipleChoiceField - options: - members: false - ## Dynamic Object Fields ::: utilities.forms.fields.DynamicModelChoiceField diff --git a/docs/release-notes/version-3.6.md b/docs/release-notes/version-3.6.md new file mode 100644 index 000000000..45a2acb73 --- /dev/null +++ b/docs/release-notes/version-3.6.md @@ -0,0 +1,7 @@ +# NetBox v3.6 + +## v3.6.0 (FUTURE) + +### Other Changes + +* [#11766](https://github.com/netbox-community/netbox/issues/11766) - Remove obsolete custom `ChoiceField` and `MultipleChoiceField` classes diff --git a/mkdocs.yml b/mkdocs.yml index f7da976c3..6be33d592 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -273,6 +273,7 @@ nav: - git Cheat Sheet: 'development/git-cheat-sheet.md' - Release Notes: - Summary: 'release-notes/index.md' + - Version 3.6: 'release-notes/version-3.6.md' - Version 3.5: 'release-notes/version-3.5.md' - Version 3.4: 'release-notes/version-3.4.md' - Version 3.3: 'release-notes/version-3.3.md' diff --git a/netbox/utilities/forms/fields/fields.py b/netbox/utilities/forms/fields/fields.py index cb8c14d6d..c1e1e481c 100644 --- a/netbox/utilities/forms/fields/fields.py +++ b/netbox/utilities/forms/fields/fields.py @@ -11,13 +11,11 @@ from utilities.forms import widgets from utilities.validators import EnhancedURLValidator __all__ = ( - 'ChoiceField', 'ColorField', 'CommentField', 'JSONField', 'LaxURLField', 'MACAddressField', - 'MultipleChoiceField', 'SlugField', 'TagFilterField', ) @@ -128,24 +126,3 @@ class MACAddressField(forms.Field): raise forms.ValidationError(self.error_messages['invalid'], code='invalid') return value - - -# -# Choice fields -# - -class ChoiceField(forms.ChoiceField): - """ - Previously used to override Django's built-in `ChoiceField` to use NetBox's now-obsolete `StaticSelect` widget. - """ - # TODO: Remove in v3.6 - pass - - -class MultipleChoiceField(forms.MultipleChoiceField): - """ - Previously used to override Django's built-in `MultipleChoiceField` to use NetBox's now-obsolete - `StaticSelectMultiple` widget. - """ - # TODO: Remove in v3.6 - pass From 4208b79514a022137144b6088750777999d55e17 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 16 May 2023 09:35:27 -0400 Subject: [PATCH 02/13] Closes #12320: Remove obsolete fields napalm_driver and napalm_args from Platform --- docs/release-notes/version-3.6.md | 5 +++++ netbox/dcim/api/serializers.py | 4 ++-- netbox/dcim/filtersets.py | 2 +- netbox/dcim/forms/bulk_edit.py | 8 ++------ netbox/dcim/forms/bulk_import.py | 2 +- netbox/dcim/forms/model_forms.py | 9 ++------- .../migrations/0173_remove_napalm_fields.py | 19 +++++++++++++++++++ netbox/dcim/models/devices.py | 17 ++--------------- netbox/dcim/search.py | 1 - netbox/dcim/tables/devices.py | 6 +++--- netbox/dcim/tests/test_filtersets.py | 10 +++------- netbox/dcim/tests/test_views.py | 3 --- netbox/templates/dcim/platform.html | 15 --------------- 13 files changed, 40 insertions(+), 61 deletions(-) create mode 100644 netbox/dcim/migrations/0173_remove_napalm_fields.py diff --git a/docs/release-notes/version-3.6.md b/docs/release-notes/version-3.6.md index 45a2acb73..c4e6847d3 100644 --- a/docs/release-notes/version-3.6.md +++ b/docs/release-notes/version-3.6.md @@ -2,6 +2,11 @@ ## v3.6.0 (FUTURE) +### Breaking Changes + +* The `napalm_driver` and `napalm_args` fields (which were deprecated in v3.5) have been removed from the platform model. + ### Other Changes * [#11766](https://github.com/netbox-community/netbox/issues/11766) - Remove obsolete custom `ChoiceField` and `MultipleChoiceField` classes +* [#12320](https://github.com/netbox-community/netbox/issues/12320) - Remove obsolete fields `napalm_driver` and `napalm_args` from Platform diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index 3f6d55da7..894a3f4f9 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -635,8 +635,8 @@ class PlatformSerializer(NetBoxModelSerializer): class Meta: model = Platform fields = [ - 'id', 'url', 'display', 'name', 'slug', 'manufacturer', 'config_template', 'napalm_driver', 'napalm_args', - 'description', 'tags', 'custom_fields', 'created', 'last_updated', 'device_count', 'virtualmachine_count', + 'id', 'url', 'display', 'name', 'slug', 'manufacturer', 'config_template', 'description', 'tags', + 'custom_fields', 'created', 'last_updated', 'device_count', 'virtualmachine_count', ] diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index fccaa72f0..1f142d97f 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -811,7 +811,7 @@ class PlatformFilterSet(OrganizationalModelFilterSet): class Meta: model = Platform - fields = ['id', 'name', 'slug', 'napalm_driver', 'description'] + fields = ['id', 'name', 'slug', 'description'] class DeviceFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilterSet, LocalConfigContextFilterSet): diff --git a/netbox/dcim/forms/bulk_edit.py b/netbox/dcim/forms/bulk_edit.py index 6ed483c79..f64a9768a 100644 --- a/netbox/dcim/forms/bulk_edit.py +++ b/netbox/dcim/forms/bulk_edit.py @@ -471,10 +471,6 @@ class PlatformBulkEditForm(NetBoxModelBulkEditForm): queryset=Manufacturer.objects.all(), required=False ) - napalm_driver = forms.CharField( - max_length=50, - required=False - ) config_template = DynamicModelChoiceField( queryset=ConfigTemplate.objects.all(), required=False @@ -486,9 +482,9 @@ class PlatformBulkEditForm(NetBoxModelBulkEditForm): model = Platform fieldsets = ( - (None, ('manufacturer', 'config_template', 'napalm_driver', 'description')), + (None, ('manufacturer', 'config_template', 'description')), ) - nullable_fields = ('manufacturer', 'config_template', 'napalm_driver', 'description') + nullable_fields = ('manufacturer', 'config_template', 'description') class DeviceBulkEditForm(NetBoxModelBulkEditForm): diff --git a/netbox/dcim/forms/bulk_import.py b/netbox/dcim/forms/bulk_import.py index de7575acb..c8f13e213 100644 --- a/netbox/dcim/forms/bulk_import.py +++ b/netbox/dcim/forms/bulk_import.py @@ -365,7 +365,7 @@ class PlatformImportForm(NetBoxModelImportForm): class Meta: model = Platform fields = ( - 'name', 'slug', 'manufacturer', 'config_template', 'napalm_driver', 'napalm_args', 'description', 'tags', + 'name', 'slug', 'manufacturer', 'config_template', 'description', 'tags', ) diff --git a/netbox/dcim/forms/model_forms.py b/netbox/dcim/forms/model_forms.py index 219216045..8379fd085 100644 --- a/netbox/dcim/forms/model_forms.py +++ b/netbox/dcim/forms/model_forms.py @@ -360,19 +360,14 @@ class PlatformForm(NetBoxModelForm): ) fieldsets = ( - ('Platform', ( - 'name', 'slug', 'manufacturer', 'config_template', 'napalm_driver', 'napalm_args', 'description', 'tags', - )), + ('Platform', ('name', 'slug', 'manufacturer', 'config_template', 'description', 'tags')), ) class Meta: model = Platform fields = [ - 'name', 'slug', 'manufacturer', 'config_template', 'napalm_driver', 'napalm_args', 'description', 'tags', + 'name', 'slug', 'manufacturer', 'config_template', 'description', 'tags', ] - widgets = { - 'napalm_args': forms.Textarea(), - } class DeviceForm(TenancyForm, NetBoxModelForm): diff --git a/netbox/dcim/migrations/0173_remove_napalm_fields.py b/netbox/dcim/migrations/0173_remove_napalm_fields.py new file mode 100644 index 000000000..61c7c5695 --- /dev/null +++ b/netbox/dcim/migrations/0173_remove_napalm_fields.py @@ -0,0 +1,19 @@ +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0172_larger_power_draw_values'), + ] + + operations = [ + migrations.RemoveField( + model_name='platform', + name='napalm_args', + ), + migrations.RemoveField( + model_name='platform', + name='napalm_driver', + ), + ] diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py index 85a5d6870..a908a6ab6 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -432,9 +432,8 @@ class DeviceRole(OrganizationalModel): class Platform(OrganizationalModel): """ - Platform refers to the software or firmware running on a Device. For example, "Cisco IOS-XR" or "Juniper Junos". - NetBox uses Platforms to determine how to interact with devices when pulling inventory data or other information by - specifying a NAPALM driver. + Platform refers to the software or firmware running on a Device. For example, "Cisco IOS-XR" or "Juniper Junos". A + Platform may optionally be associated with a particular Manufacturer. """ manufacturer = models.ForeignKey( to='dcim.Manufacturer', @@ -451,18 +450,6 @@ class Platform(OrganizationalModel): blank=True, null=True ) - napalm_driver = models.CharField( - max_length=50, - blank=True, - verbose_name='NAPALM driver', - help_text=_('The name of the NAPALM driver to use when interacting with devices') - ) - napalm_args = models.JSONField( - blank=True, - null=True, - verbose_name='NAPALM arguments', - help_text=_('Additional arguments to pass when initiating the NAPALM driver (JSON format)') - ) def get_absolute_url(self): return reverse('dcim:platform', args=[self.pk]) diff --git a/netbox/dcim/search.py b/netbox/dcim/search.py index bae4f030f..f70c729f4 100644 --- a/netbox/dcim/search.py +++ b/netbox/dcim/search.py @@ -172,7 +172,6 @@ class PlatformIndex(SearchIndex): fields = ( ('name', 100), ('slug', 110), - ('napalm_driver', 300), ('description', 500), ) diff --git a/netbox/dcim/tables/devices.py b/netbox/dcim/tables/devices.py index db2655d27..a0238a1fb 100644 --- a/netbox/dcim/tables/devices.py +++ b/netbox/dcim/tables/devices.py @@ -137,11 +137,11 @@ class PlatformTable(NetBoxTable): class Meta(NetBoxTable.Meta): model = models.Platform fields = ( - 'pk', 'id', 'name', 'manufacturer', 'device_count', 'vm_count', 'slug', 'config_template', 'napalm_driver', - 'napalm_args', 'description', 'tags', 'actions', 'created', 'last_updated', + 'pk', 'id', 'name', 'manufacturer', 'device_count', 'vm_count', 'slug', 'config_template', 'description', + 'tags', 'actions', 'created', 'last_updated', ) default_columns = ( - 'pk', 'name', 'manufacturer', 'device_count', 'vm_count', 'napalm_driver', 'description', + 'pk', 'name', 'manufacturer', 'device_count', 'vm_count', 'description', ) diff --git a/netbox/dcim/tests/test_filtersets.py b/netbox/dcim/tests/test_filtersets.py index 346b35005..4b82e87bd 100644 --- a/netbox/dcim/tests/test_filtersets.py +++ b/netbox/dcim/tests/test_filtersets.py @@ -1498,9 +1498,9 @@ class PlatformTestCase(TestCase, ChangeLoggedFilterSetTests): Manufacturer.objects.bulk_create(manufacturers) platforms = ( - Platform(name='Platform 1', slug='platform-1', manufacturer=manufacturers[0], napalm_driver='driver-1', description='A'), - Platform(name='Platform 2', slug='platform-2', manufacturer=manufacturers[1], napalm_driver='driver-2', description='B'), - Platform(name='Platform 3', slug='platform-3', manufacturer=manufacturers[2], napalm_driver='driver-3', description='C'), + Platform(name='Platform 1', slug='platform-1', manufacturer=manufacturers[0], description='A'), + Platform(name='Platform 2', slug='platform-2', manufacturer=manufacturers[1], description='B'), + Platform(name='Platform 3', slug='platform-3', manufacturer=manufacturers[2], description='C'), ) Platform.objects.bulk_create(platforms) @@ -1516,10 +1516,6 @@ class PlatformTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'description': ['A', 'B']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - def test_napalm_driver(self): - params = {'napalm_driver': ['driver-1', 'driver-2']} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - def test_manufacturer(self): manufacturers = Manufacturer.objects.all()[:2] params = {'manufacturer_id': [manufacturers[0].pk, manufacturers[1].pk]} diff --git a/netbox/dcim/tests/test_views.py b/netbox/dcim/tests/test_views.py index c0cfca2e7..4bcb8df53 100644 --- a/netbox/dcim/tests/test_views.py +++ b/netbox/dcim/tests/test_views.py @@ -1609,8 +1609,6 @@ class PlatformTestCase(ViewTestCases.OrganizationalObjectViewTestCase): 'name': 'Platform X', 'slug': 'platform-x', 'manufacturer': manufacturer.pk, - 'napalm_driver': 'junos', - 'napalm_args': None, 'description': 'A new platform', 'tags': [t.pk for t in tags], } @@ -1630,7 +1628,6 @@ class PlatformTestCase(ViewTestCases.OrganizationalObjectViewTestCase): ) cls.bulk_edit_data = { - 'napalm_driver': 'ios', 'description': 'New description', } diff --git a/netbox/templates/dcim/platform.html b/netbox/templates/dcim/platform.html index 80fdbd945..56c59d21c 100644 --- a/netbox/templates/dcim/platform.html +++ b/netbox/templates/dcim/platform.html @@ -53,26 +53,11 @@ title="This field has been deprecated, and will be removed in NetBox v3.6." > - {{ object.napalm_driver|placeholder }} {% include 'inc/panels/tags.html' %} -
-
- NAPALM Arguments - -
-
-
{{ object.napalm_args|json }}
-
-
{% plugin_left_page object %}
From 4f76dcd2ea856fa9411dbf287696501f59d6a6c1 Mon Sep 17 00:00:00 2001 From: Arthur Hanson Date: Wed, 14 Jun 2023 11:18:50 -0700 Subject: [PATCH 03/13] 11305 Add GPS coordinates to device (#12782) * 11305 add lat/long to devices * 11305 update docs * 11305 update tests --- docs/models/dcim/device.md | 4 ++++ netbox/dcim/api/serializers.py | 7 +++--- netbox/dcim/filtersets.py | 2 +- netbox/dcim/forms/bulk_import.py | 5 +++-- netbox/dcim/forms/model_forms.py | 6 ++--- .../0174_device_latitude_device_longitude.py | 22 +++++++++++++++++++ netbox/dcim/models/devices.py | 14 ++++++++++++ netbox/dcim/tables/devices.py | 6 ++--- netbox/dcim/tests/test_filtersets.py | 14 +++++++++--- netbox/dcim/tests/test_views.py | 2 ++ netbox/templates/dcim/device.html | 17 ++++++++++++++ netbox/templates/dcim/device_edit.html | 2 ++ 12 files changed, 86 insertions(+), 15 deletions(-) create mode 100644 netbox/dcim/migrations/0174_device_latitude_device_longitude.py diff --git a/docs/models/dcim/device.md b/docs/models/dcim/device.md index 8f97b920b..2216e351c 100644 --- a/docs/models/dcim/device.md +++ b/docs/models/dcim/device.md @@ -61,6 +61,10 @@ If installed in a rack, this field indicates the base rack unit in which the dev !!! tip Devices with a height of more than one rack unit should be set to the lowest-numbered rack unit that they occupy. +### Latitude & Longitude + +GPS coordinates of the device for geolocation. + ### Status The device's operational status. diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index 894a3f4f9..3a3065acc 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -673,9 +673,10 @@ class DeviceSerializer(NetBoxModelSerializer): model = Device fields = [ 'id', 'url', 'display', 'name', 'device_type', 'device_role', 'tenant', 'platform', 'serial', 'asset_tag', - 'site', 'location', 'rack', 'position', 'face', 'parent_device', 'status', 'airflow', 'primary_ip', - 'primary_ip4', 'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'description', - 'comments', 'config_template', 'local_context_data', 'tags', 'custom_fields', 'created', 'last_updated', + 'site', 'location', 'rack', 'position', 'face', 'latitude', 'longitude', 'parent_device', 'status', 'airflow', + 'primary_ip', 'primary_ip4', 'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', + 'description', 'comments', 'config_template', 'local_context_data', 'tags', 'custom_fields', 'created', + 'last_updated', ] @extend_schema_field(NestedDeviceSerializer) diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index d159e9b73..e87a37847 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -999,7 +999,7 @@ class DeviceFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilter class Meta: model = Device - fields = ['id', 'asset_tag', 'face', 'position', 'airflow', 'vc_position', 'vc_priority'] + fields = ['id', 'asset_tag', 'face', 'position', 'latitude', 'longitude', 'airflow', 'vc_position', 'vc_priority'] def search(self, queryset, name, value): if not value.strip(): diff --git a/netbox/dcim/forms/bulk_import.py b/netbox/dcim/forms/bulk_import.py index c8f13e213..e3e97ab73 100644 --- a/netbox/dcim/forms/bulk_import.py +++ b/netbox/dcim/forms/bulk_import.py @@ -478,8 +478,9 @@ class DeviceImportForm(BaseDeviceImportForm): class Meta(BaseDeviceImportForm.Meta): fields = [ 'name', 'device_role', 'tenant', 'manufacturer', 'device_type', 'platform', 'serial', 'asset_tag', 'status', - 'site', 'location', 'rack', 'position', 'face', 'parent', 'device_bay', 'airflow', 'virtual_chassis', - 'vc_position', 'vc_priority', 'cluster', 'description', 'config_template', 'comments', 'tags', + 'site', 'location', 'rack', 'position', 'face', 'latitude', 'longitude', 'parent', 'device_bay', 'airflow', + 'virtual_chassis', 'vc_position', 'vc_priority', 'cluster', 'description', 'config_template', 'comments', + 'tags', ] def __init__(self, data=None, *args, **kwargs): diff --git a/netbox/dcim/forms/model_forms.py b/netbox/dcim/forms/model_forms.py index 8379fd085..56542d70c 100644 --- a/netbox/dcim/forms/model_forms.py +++ b/netbox/dcim/forms/model_forms.py @@ -449,9 +449,9 @@ class DeviceForm(TenancyForm, NetBoxModelForm): model = Device fields = [ 'name', 'device_role', 'device_type', 'serial', 'asset_tag', 'site', 'rack', 'location', 'position', 'face', - 'status', 'airflow', 'platform', 'primary_ip4', 'primary_ip6', 'cluster', 'tenant_group', 'tenant', - 'virtual_chassis', 'vc_position', 'vc_priority', 'description', 'config_template', 'comments', 'tags', - 'local_context_data' + 'latitude', 'longitude', 'status', 'airflow', 'platform', 'primary_ip4', 'primary_ip6', 'cluster', + 'tenant_group', 'tenant', 'virtual_chassis', 'vc_position', 'vc_priority', 'description', 'config_template', + 'comments', 'tags', 'local_context_data' ] def __init__(self, *args, **kwargs): diff --git a/netbox/dcim/migrations/0174_device_latitude_device_longitude.py b/netbox/dcim/migrations/0174_device_latitude_device_longitude.py new file mode 100644 index 000000000..f9f72f9f8 --- /dev/null +++ b/netbox/dcim/migrations/0174_device_latitude_device_longitude.py @@ -0,0 +1,22 @@ +# Generated by Django 4.1.9 on 2023-05-31 22:13 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ('dcim', '0173_remove_napalm_fields'), + ] + + operations = [ + migrations.AddField( + model_name='device', + name='latitude', + field=models.DecimalField(blank=True, decimal_places=6, max_digits=8, null=True), + ), + migrations.AddField( + model_name='device', + name='longitude', + field=models.DecimalField(blank=True, decimal_places=6, max_digits=9, null=True), + ), + ] diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py index a908a6ab6..30fafef94 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -624,6 +624,20 @@ class Device(PrimaryModel, ConfigContextModel): blank=True, null=True ) + latitude = models.DecimalField( + max_digits=8, + decimal_places=6, + blank=True, + null=True, + help_text=_("GPS coordinate in decimal format (xx.yyyyyy)") + ) + longitude = models.DecimalField( + max_digits=9, + decimal_places=6, + blank=True, + null=True, + help_text=_("GPS coordinate in decimal format (xx.yyyyyy)") + ) # Generic relations contacts = GenericRelation( diff --git a/netbox/dcim/tables/devices.py b/netbox/dcim/tables/devices.py index a0238a1fb..a5862da68 100644 --- a/netbox/dcim/tables/devices.py +++ b/netbox/dcim/tables/devices.py @@ -236,9 +236,9 @@ class DeviceTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable): fields = ( 'pk', 'id', 'name', 'status', 'tenant', 'tenant_group', 'device_role', 'manufacturer', 'device_type', 'platform', 'serial', 'asset_tag', 'region', 'site_group', 'site', 'location', 'rack', 'parent_device', - 'device_bay_position', 'position', 'face', 'airflow', 'primary_ip', 'primary_ip4', 'primary_ip6', 'cluster', - 'virtual_chassis', 'vc_position', 'vc_priority', 'description', 'config_template', 'comments', 'contacts', - 'tags', 'created', 'last_updated', + 'device_bay_position', 'position', 'face', 'latitude', 'longitude', 'airflow', 'primary_ip', 'primary_ip4', + 'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'description', 'config_template', + 'comments', 'contacts', 'tags', 'created', 'last_updated', ) default_columns = ( 'pk', 'name', 'status', 'tenant', 'site', 'location', 'rack', 'device_role', 'manufacturer', 'device_type', diff --git a/netbox/dcim/tests/test_filtersets.py b/netbox/dcim/tests/test_filtersets.py index bd0931d5a..aa6860a16 100644 --- a/netbox/dcim/tests/test_filtersets.py +++ b/netbox/dcim/tests/test_filtersets.py @@ -1638,9 +1638,9 @@ class DeviceTestCase(TestCase, ChangeLoggedFilterSetTests): Tenant.objects.bulk_create(tenants) devices = ( - Device(name='Device 1', device_type=device_types[0], device_role=device_roles[0], platform=platforms[0], tenant=tenants[0], serial='ABC', asset_tag='1001', site=sites[0], location=locations[0], rack=racks[0], position=1, face=DeviceFaceChoices.FACE_FRONT, status=DeviceStatusChoices.STATUS_ACTIVE, cluster=clusters[0], local_context_data={"foo": 123}), - Device(name='Device 2', device_type=device_types[1], device_role=device_roles[1], platform=platforms[1], tenant=tenants[1], serial='DEF', asset_tag='1002', site=sites[1], location=locations[1], rack=racks[1], position=2, face=DeviceFaceChoices.FACE_FRONT, status=DeviceStatusChoices.STATUS_STAGED, airflow=DeviceAirflowChoices.AIRFLOW_FRONT_TO_REAR, cluster=clusters[1]), - Device(name='Device 3', device_type=device_types[2], device_role=device_roles[2], platform=platforms[2], tenant=tenants[2], serial='GHI', asset_tag='1003', site=sites[2], location=locations[2], rack=racks[2], position=3, face=DeviceFaceChoices.FACE_REAR, status=DeviceStatusChoices.STATUS_FAILED, airflow=DeviceAirflowChoices.AIRFLOW_REAR_TO_FRONT, cluster=clusters[2]), + Device(name='Device 1', device_type=device_types[0], device_role=device_roles[0], platform=platforms[0], tenant=tenants[0], serial='ABC', asset_tag='1001', site=sites[0], location=locations[0], rack=racks[0], position=1, face=DeviceFaceChoices.FACE_FRONT, latitude=10, longitude=10, status=DeviceStatusChoices.STATUS_ACTIVE, cluster=clusters[0], local_context_data={"foo": 123}), + Device(name='Device 2', device_type=device_types[1], device_role=device_roles[1], platform=platforms[1], tenant=tenants[1], serial='DEF', asset_tag='1002', site=sites[1], location=locations[1], rack=racks[1], position=2, face=DeviceFaceChoices.FACE_FRONT, latitude=20, longitude=20, status=DeviceStatusChoices.STATUS_STAGED, airflow=DeviceAirflowChoices.AIRFLOW_FRONT_TO_REAR, cluster=clusters[1]), + Device(name='Device 3', device_type=device_types[2], device_role=device_roles[2], platform=platforms[2], tenant=tenants[2], serial='GHI', asset_tag='1003', site=sites[2], location=locations[2], rack=racks[2], position=3, face=DeviceFaceChoices.FACE_REAR, latitude=30, longitude=30, status=DeviceStatusChoices.STATUS_FAILED, airflow=DeviceAirflowChoices.AIRFLOW_REAR_TO_FRONT, cluster=clusters[2]), ) Device.objects.bulk_create(devices) @@ -1721,6 +1721,14 @@ class DeviceTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'position': [1, 2]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_latitude(self): + params = {'latitude': [10, 20]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_longitude(self): + params = {'longitude': [10, 20]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_vc_position(self): params = {'vc_position': [1, 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 4bcb8df53..a327d6400 100644 --- a/netbox/dcim/tests/test_views.py +++ b/netbox/dcim/tests/test_views.py @@ -1696,6 +1696,8 @@ class DeviceTestCase(ViewTestCases.PrimaryObjectViewTestCase): 'rack': racks[1].pk, 'position': 1, 'face': DeviceFaceChoices.FACE_FRONT, + 'latitude': Decimal('35.780000'), + 'longitude': Decimal('-78.642000'), 'status': DeviceStatusChoices.STATUS_PLANNED, 'primary_ip4': None, 'primary_ip6': None, diff --git a/netbox/templates/dcim/device.html b/netbox/templates/dcim/device.html index b0e67269c..68fa84a24 100644 --- a/netbox/templates/dcim/device.html +++ b/netbox/templates/dcim/device.html @@ -76,6 +76,23 @@ {% endif %} + + GPS Coordinates + + {% if object.latitude and object.longitude %} + {% if config.MAPS_URL %} + + {% endif %} + {{ object.latitude }}, {{ object.longitude }} + {% else %} + {{ ''|placeholder }} + {% endif %} + + Tenant diff --git a/netbox/templates/dcim/device_edit.html b/netbox/templates/dcim/device_edit.html index 17780b513..2dbe1e3c5 100644 --- a/netbox/templates/dcim/device_edit.html +++ b/netbox/templates/dcim/device_edit.html @@ -53,6 +53,8 @@ {% else %} {% render_field form.face %} {% render_field form.position %} + {% render_field form.latitude %} + {% render_field form.longitude %} {% endif %}
From b4a315604610e9016fd0a5046782e4e81c1d0ba5 Mon Sep 17 00:00:00 2001 From: Arthur Date: Wed, 31 May 2023 13:47:22 -0700 Subject: [PATCH 04/13] 9077 audit alters_data=True --- netbox/core/models/data.py | 1 + netbox/dcim/models/cables.py | 2 ++ netbox/dcim/models/device_component_templates.py | 9 +++++++++ netbox/extras/models/configs.py | 2 ++ netbox/extras/models/models.py | 2 ++ netbox/extras/models/staging.py | 1 + netbox/netbox/models/features.py | 4 ++++ 7 files changed, 21 insertions(+) diff --git a/netbox/core/models/data.py b/netbox/core/models/data.py index a8ac4e8f1..2af697f60 100644 --- a/netbox/core/models/data.py +++ b/netbox/core/models/data.py @@ -200,6 +200,7 @@ class DataSource(JobsMixin, PrimaryModel): # Emit the post_sync signal post_sync.send(sender=self.__class__, instance=self) + sync.alters_data = True def _walk(self, root): """ diff --git a/netbox/dcim/models/cables.py b/netbox/dcim/models/cables.py index af69c440e..b2786719c 100644 --- a/netbox/dcim/models/cables.py +++ b/netbox/dcim/models/cables.py @@ -359,6 +359,7 @@ class CableTermination(ChangeLoggedModel): # Circuit terminations elif getattr(self.termination, 'site', None): self._site = self.termination.site + cache_related_objects.alters_data = True def to_objectchange(self, action): objectchange = super().to_objectchange(action) @@ -637,6 +638,7 @@ class CablePath(models.Model): self.save() else: self.delete() + retrace.alters_data = True def _get_path(self): """ diff --git a/netbox/dcim/models/device_component_templates.py b/netbox/dcim/models/device_component_templates.py index 6a89655b2..0355d7028 100644 --- a/netbox/dcim/models/device_component_templates.py +++ b/netbox/dcim/models/device_component_templates.py @@ -213,6 +213,7 @@ class ConsoleServerPortTemplate(ModularComponentTemplateModel): type=self.type, **kwargs ) + instantiate.do_not_call_in_templates = True def to_yaml(self): return { @@ -256,6 +257,7 @@ class PowerPortTemplate(ModularComponentTemplateModel): allocated_draw=self.allocated_draw, **kwargs ) + instantiate.do_not_call_in_templates = True def clean(self): super().clean() @@ -330,6 +332,7 @@ class PowerOutletTemplate(ModularComponentTemplateModel): feed_leg=self.feed_leg, **kwargs ) + instantiate.do_not_call_in_templates = True def to_yaml(self): return { @@ -413,6 +416,7 @@ class InterfaceTemplate(ModularComponentTemplateModel): poe_type=self.poe_type, **kwargs ) + instantiate.do_not_call_in_templates = True def to_yaml(self): return { @@ -507,6 +511,7 @@ class FrontPortTemplate(ModularComponentTemplateModel): rear_port_position=self.rear_port_position, **kwargs ) + instantiate.do_not_call_in_templates = True def to_yaml(self): return { @@ -550,6 +555,7 @@ class RearPortTemplate(ModularComponentTemplateModel): positions=self.positions, **kwargs ) + instantiate.do_not_call_in_templates = True def to_yaml(self): return { @@ -581,6 +587,7 @@ class ModuleBayTemplate(ComponentTemplateModel): label=self.label, position=self.position ) + instantiate.do_not_call_in_templates = True def to_yaml(self): return { @@ -603,6 +610,7 @@ class DeviceBayTemplate(ComponentTemplateModel): name=self.name, label=self.label ) + instantiate.do_not_call_in_templates = True def clean(self): if self.device_type and self.device_type.subdevice_role != SubdeviceRoleChoices.ROLE_PARENT: @@ -696,3 +704,4 @@ class InventoryItemTemplate(MPTTModel, ComponentTemplateModel): part_id=self.part_id, **kwargs ) + instantiate.do_not_call_in_templates = True diff --git a/netbox/extras/models/configs.py b/netbox/extras/models/configs.py index 632323af0..ee9f7cfda 100644 --- a/netbox/extras/models/configs.py +++ b/netbox/extras/models/configs.py @@ -146,6 +146,7 @@ class ConfigContext(SyncedDataMixin, CloningMixin, ChangeLoggedModel): Synchronize context data from the designated DataFile (if any). """ self.data = self.data_file.get_data() + sync_data.alters_data = True class ConfigContextModel(models.Model): @@ -236,6 +237,7 @@ class ConfigTemplate(SyncedDataMixin, ExportTemplatesMixin, TagsMixin, ChangeLog Synchronize template content from the designated DataFile (if any). """ self.template_code = self.data_file.data_as_string + sync_data.alters_data = True def render(self, context=None): """ diff --git a/netbox/extras/models/models.py b/netbox/extras/models/models.py index 9433ab6b0..969fd22e0 100644 --- a/netbox/extras/models/models.py +++ b/netbox/extras/models/models.py @@ -362,6 +362,7 @@ class ExportTemplate(SyncedDataMixin, CloningMixin, ExportTemplatesMixin, Change Synchronize template content from the designated DataFile (if any). """ self.template_code = self.data_file.data_as_string + sync_data.alters_data = True def render(self, queryset): """ @@ -625,6 +626,7 @@ class ConfigRevision(models.Model): """ cache.set('config', self.data, None) cache.set('config_version', self.pk, None) + activate.alters_data = True @admin.display(boolean=True) def is_active(self): diff --git a/netbox/extras/models/staging.py b/netbox/extras/models/staging.py index b46d6a7bc..3d1c149bc 100644 --- a/netbox/extras/models/staging.py +++ b/netbox/extras/models/staging.py @@ -112,3 +112,4 @@ class StagedChange(ChangeLoggedModel): instance = self.model.objects.get(pk=self.object_id) logger.info(f'Deleting {self.model._meta.verbose_name} {instance}') instance.delete() + apply.alters_data = True diff --git a/netbox/netbox/models/features.py b/netbox/netbox/models/features.py index 8bacba534..8d79dd6bc 100644 --- a/netbox/netbox/models/features.py +++ b/netbox/netbox/models/features.py @@ -71,6 +71,7 @@ class ChangeLoggingMixin(models.Model): `_prechange_snapshot` on the instance. """ self._prechange_snapshot = self.serialize_object() + snapshot.alters_data = True def to_objectchange(self, action): """ @@ -244,6 +245,7 @@ class CustomFieldsMixin(models.Model): """ for cf in self.custom_fields: self.custom_field_data[cf.name] = cf.default + populate_custom_field_defaults.alters_data = True def clean(self): super().clean() @@ -419,6 +421,7 @@ class SyncedDataMixin(models.Model): self.data_synced = None super().clean() + clean.alters_data = True def save(self, *args, **kwargs): from core.models import AutoSyncRecord @@ -466,6 +469,7 @@ class SyncedDataMixin(models.Model): self.data_synced = timezone.now() if save: self.save() + sync.alters_data = True def sync_data(self): """ From bace24b68e4f38456f275802e64a6b595c69e263 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 20 Jun 2023 15:04:10 -0400 Subject: [PATCH 05/13] 12180 available objects api (#12935) * Introduce AvailableObjectsView and refactor 'available objects' API views * Restore advisory PostgreSQL locks * Move get_next_available_prefix() * Apply OpenAPI decorators for get() and post() --- netbox/ipam/api/serializers.py | 2 + netbox/ipam/api/views.py | 447 +++++++++++++++------------------ netbox/ipam/utils.py | 22 +- 3 files changed, 226 insertions(+), 245 deletions(-) diff --git a/netbox/ipam/api/serializers.py b/netbox/ipam/api/serializers.py index 064452667..f59850aa2 100644 --- a/netbox/ipam/api/serializers.py +++ b/netbox/ipam/api/serializers.py @@ -58,6 +58,7 @@ class AvailableASNSerializer(serializers.Serializer): Representation of an ASN which does not exist in the database. """ asn = serializers.IntegerField(read_only=True) + description = serializers.CharField(required=False) def to_representation(self, asn): rir = NestedRIRSerializer(self.context['range'].rir, context={ @@ -432,6 +433,7 @@ class AvailableIPSerializer(serializers.Serializer): family = serializers.IntegerField(read_only=True) address = serializers.CharField(read_only=True) vrf = NestedVRFSerializer(read_only=True) + description = serializers.CharField(required=False) def to_representation(self, instance): if self.context.get('vrf'): diff --git a/netbox/ipam/api/views.py b/netbox/ipam/api/views.py index f432e0e6b..c895a706b 100644 --- a/netbox/ipam/api/views.py +++ b/netbox/ipam/api/views.py @@ -3,7 +3,9 @@ from django.db import transaction from django.shortcuts import get_object_or_404 from django_pglocks import advisory_lock from drf_spectacular.utils import extend_schema +from netaddr import IPSet from rest_framework import status +from rest_framework.exceptions import ValidationError from rest_framework.response import Response from rest_framework.routers import APIRootView from rest_framework.views import APIView @@ -12,10 +14,12 @@ from circuits.models import Provider from dcim.models import Site from ipam import filtersets from ipam.models import * +from ipam.utils import get_next_available_prefix from netbox.api.viewsets import NetBoxModelViewSet from netbox.api.viewsets.mixins import ObjectValidationMixin from netbox.config import get_config from netbox.constants import ADVISORY_LOCK_KEYS +from utilities.api import get_serializer_for_model from utilities.utils import count_related from . import serializers from ipam.models import L2VPN, L2VPNTermination @@ -207,237 +211,233 @@ def get_results_limit(request): return limit -class AvailableASNsView(ObjectValidationMixin, APIView): - queryset = ASN.objects.all() +class AvailableObjectsView(ObjectValidationMixin, APIView): + """ + Return a list of dicts representing child objects that have not yet been created for a parent object. + """ + read_serializer_class = None + write_serializer_class = None + advisory_lock_key = None + + def get_parent(self, request, pk): + """ + Return the parent object. + """ + raise NotImplemented() + + def get_available_objects(self, parent, limit=None): + """ + Return all available objects for the parent. + """ + raise NotImplemented() + + def get_extra_context(self, parent): + """ + Return any extra context data for the serializer. + """ + return {} + + def check_sufficient_available(self, requested_objects, available_objects): + """ + Check if there exist a sufficient number of available objects to satisfy the request. + """ + return len(requested_objects) <= len(available_objects) + + def prep_object_data(self, requested_objects, available_objects, parent): + """ + Prepare data by setting any programmatically determined object attributes (e.g. next available VLAN ID) + on the request data. + """ + return requested_objects - @extend_schema(methods=["get"], responses={200: serializers.AvailableASNSerializer(many=True)}) def get(self, request, pk): - asnrange = get_object_or_404(ASNRange.objects.restrict(request.user), pk=pk) + parent = self.get_parent(request, pk) limit = get_results_limit(request) + available_objects = self.get_available_objects(parent, limit) - available_asns = asnrange.get_available_asns()[:limit] - - serializer = serializers.AvailableASNSerializer(available_asns, many=True, context={ + serializer = self.read_serializer_class(available_objects, many=True, context={ 'request': request, - 'range': asnrange, + **self.get_extra_context(parent), }) return Response(serializer.data) - @extend_schema(methods=["post"], responses={201: serializers.ASNSerializer(many=True)}) - @advisory_lock(ADVISORY_LOCK_KEYS['available-asns']) def post(self, request, pk): self.queryset = self.queryset.restrict(request.user, 'add') - asnrange = get_object_or_404(ASNRange.objects.restrict(request.user), pk=pk) + parent = self.get_parent(request, pk) - # Normalize to a list of objects - requested_asns = request.data if isinstance(request.data, list) else [request.data] + # Normalize request data to a list of objects + requested_objects = request.data if isinstance(request.data, list) else [request.data] - # Determine if the requested number of IPs is available - available_asns = asnrange.get_available_asns() - if len(available_asns) < len(requested_asns): - return Response( - { - "detail": f"An insufficient number of ASNs are available within {asnrange} " - f"({len(requested_asns)} requested, {len(available_asns)} available)" - }, - status=status.HTTP_409_CONFLICT - ) - - # Assign ASNs from the list of available IPs and copy VRF assignment from the parent - for i, requested_asn in enumerate(requested_asns): - requested_asn.update({ - 'rir': asnrange.rir.pk, - 'range': asnrange.pk, - 'asn': available_asns[i], - }) - - # Initialize the serializer with a list or a single object depending on what was requested - context = {'request': request} - if isinstance(request.data, list): - serializer = serializers.ASNSerializer(data=requested_asns, many=True, context=context) - else: - serializer = serializers.ASNSerializer(data=requested_asns[0], context=context) - - # Create the new IP address(es) - if serializer.is_valid(): - try: - with transaction.atomic(): - created = serializer.save() - self._validate_objects(created) - except ObjectDoesNotExist: - raise PermissionDenied() - return Response(serializer.data, status=status.HTTP_201_CREATED) - - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - - def get_serializer_class(self): - if self.request.method == "GET": - return serializers.AvailableASNSerializer - - return serializers.ASNSerializer - - -class AvailablePrefixesView(ObjectValidationMixin, APIView): - queryset = Prefix.objects.all() - - @extend_schema(methods=["get"], responses={200: serializers.AvailablePrefixSerializer(many=True)}) - def get(self, request, pk): - prefix = get_object_or_404(Prefix.objects.restrict(request.user), pk=pk) - available_prefixes = prefix.get_available_prefixes() - - serializer = serializers.AvailablePrefixSerializer(available_prefixes.iter_cidrs(), many=True, context={ + # Serialize and validate the request data + serializer = self.write_serializer_class(data=requested_objects, many=True, context={ 'request': request, - 'vrf': prefix.vrf, + **self.get_extra_context(parent), }) - - return Response(serializer.data) - - @extend_schema(methods=["post"], responses={201: serializers.PrefixSerializer(many=True)}) - @advisory_lock(ADVISORY_LOCK_KEYS['available-prefixes']) - def post(self, request, pk): - self.queryset = self.queryset.restrict(request.user, 'add') - prefix = get_object_or_404(Prefix.objects.restrict(request.user), pk=pk) - available_prefixes = prefix.get_available_prefixes() - - # Validate Requested Prefixes' length - serializer = serializers.PrefixLengthSerializer( - data=request.data if isinstance(request.data, list) else [request.data], - many=True, - context={ - 'request': request, - 'prefix': prefix, - } - ) if not serializer.is_valid(): return Response( serializer.errors, status=status.HTTP_400_BAD_REQUEST ) - requested_prefixes = serializer.validated_data - # Allocate prefixes to the requested objects based on availability within the parent - for i, requested_prefix in enumerate(requested_prefixes): + with advisory_lock(ADVISORY_LOCK_KEYS[self.advisory_lock_key]): + available_objects = self.get_available_objects(parent) - # Find the first available prefix equal to or larger than the requested size - for available_prefix in available_prefixes.iter_cidrs(): - if requested_prefix['prefix_length'] >= available_prefix.prefixlen: - allocated_prefix = '{}/{}'.format(available_prefix.network, requested_prefix['prefix_length']) - requested_prefix['prefix'] = allocated_prefix - requested_prefix['vrf'] = prefix.vrf.pk if prefix.vrf else None - break - else: + # Determine if the requested number of objects is available + if not self.check_sufficient_available(serializer.validated_data, available_objects): return Response( - { - "detail": "Insufficient space is available to accommodate the requested prefix size(s)" - }, + {"detail": f"Insufficient resources are available to satisfy the request"}, status=status.HTTP_409_CONFLICT ) - # Remove the allocated prefix from the list of available prefixes - available_prefixes.remove(allocated_prefix) + # Prepare object data for deserialization + requested_objects = self.prep_object_data(serializer.validated_data, available_objects, parent) - # Initialize the serializer with a list or a single object depending on what was requested - context = {'request': request} - if isinstance(request.data, list): - serializer = serializers.PrefixSerializer(data=requested_prefixes, many=True, context=context) - else: - serializer = serializers.PrefixSerializer(data=requested_prefixes[0], context=context) + # Initialize the serializer with a list or a single object depending on what was requested + serializer_class = get_serializer_for_model(self.queryset.model) + context = {'request': request} + if isinstance(request.data, list): + serializer = serializer_class(data=requested_objects, many=True, context=context) + else: + serializer = serializer_class(data=requested_objects[0], context=context) - # Create the new Prefix(es) - if serializer.is_valid(): + if not serializer.is_valid(): + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + # Create the new IP address(es) try: with transaction.atomic(): created = serializer.save() self._validate_objects(created) except ObjectDoesNotExist: raise PermissionDenied() - return Response(serializer.data, status=status.HTTP_201_CREATED) - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - - def get_serializer_class(self): - if self.request.method == "GET": - return serializers.AvailablePrefixSerializer - - return serializers.PrefixLengthSerializer + return Response(serializer.data, status=status.HTTP_201_CREATED) -class AvailableIPAddressesView(ObjectValidationMixin, APIView): - queryset = IPAddress.objects.all() +class AvailableASNsView(AvailableObjectsView): + queryset = ASN.objects.all() + read_serializer_class = serializers.AvailableASNSerializer + write_serializer_class = serializers.AvailableASNSerializer + advisory_lock_key = 'available-asns' def get_parent(self, request, pk): - raise NotImplemented() + return get_object_or_404(ASNRange.objects.restrict(request.user), pk=pk) - @extend_schema(methods=["get"], responses={200: serializers.AvailableIPSerializer(many=True)}) + def get_available_objects(self, parent, limit=None): + return parent.get_available_asns()[:limit] + + def get_extra_context(self, parent): + return { + 'range': parent, + } + + def prep_object_data(self, requested_objects, available_objects, parent): + for i, request_data in enumerate(requested_objects): + request_data.update({ + 'rir': parent.rir.pk, + 'range': parent.pk, + 'asn': available_objects[i], + }) + + return requested_objects + + @extend_schema(methods=["get"], responses={200: serializers.AvailableASNSerializer(many=True)}) def get(self, request, pk): - parent = self.get_parent(request, pk) - limit = get_results_limit(request) + return super().get(request, pk) + @extend_schema(methods=["post"], responses={201: serializers.AvailableASNSerializer(many=True)}) + def post(self, request, pk): + return super().post(request, pk) + + +class AvailablePrefixesView(AvailableObjectsView): + queryset = Prefix.objects.all() + read_serializer_class = serializers.AvailablePrefixSerializer + write_serializer_class = serializers.PrefixLengthSerializer + advisory_lock_key = 'available-prefixes' + + def get_parent(self, request, pk): + return get_object_or_404(Prefix.objects.restrict(request.user), pk=pk) + + def get_available_objects(self, parent, limit=None): + return parent.get_available_prefixes().iter_cidrs() + + def check_sufficient_available(self, requested_objects, available_objects): + available_prefixes = IPSet(available_objects) + for requested_object in requested_objects: + if not get_next_available_prefix(available_prefixes, requested_object['prefix_length']): + return False + return True + + def get_extra_context(self, parent): + return { + 'prefix': parent, + 'vrf': parent.vrf, + } + + def prep_object_data(self, requested_objects, available_objects, parent): + available_prefixes = IPSet(available_objects) + for i, request_data in enumerate(requested_objects): + + # Find the first available prefix equal to or larger than the requested size + if allocated_prefix := get_next_available_prefix(available_prefixes, request_data['prefix_length']): + request_data.update({ + 'prefix': allocated_prefix, + 'vrf': parent.vrf.pk if parent.vrf else None, + }) + else: + raise ValidationError("Insufficient space is available to accommodate the requested prefix size(s)") + + return requested_objects + + @extend_schema(methods=["get"], responses={200: serializers.AvailablePrefixSerializer(many=True)}) + def get(self, request, pk): + return super().get(request, pk) + + @extend_schema(methods=["post"], responses={201: serializers.PrefixSerializer(many=True)}) + def post(self, request, pk): + return super().post(request, pk) + + +class AvailableIPAddressesView(AvailableObjectsView): + queryset = IPAddress.objects.all() + read_serializer_class = serializers.AvailableIPSerializer + write_serializer_class = serializers.AvailableIPSerializer + advisory_lock_key = 'available-ips' + + def get_available_objects(self, parent, limit=None): # Calculate available IPs within the parent ip_list = [] for index, ip in enumerate(parent.get_available_ips(), start=1): ip_list.append(ip) if index == limit: break - serializer = serializers.AvailableIPSerializer(ip_list, many=True, context={ - 'request': request, + return ip_list + + def get_extra_context(self, parent): + return { 'parent': parent, 'vrf': parent.vrf, - }) + } - return Response(serializer.data) + def prep_object_data(self, requested_objects, available_objects, parent): + available_ips = iter(available_objects) + for i, request_data in enumerate(requested_objects): + request_data.update({ + 'address': f'{next(available_ips)}/{parent.mask_length}', + 'vrf': parent.vrf.pk if parent.vrf else None, + }) + + return requested_objects + + @extend_schema(methods=["get"], responses={200: serializers.AvailableIPSerializer(many=True)}) + def get(self, request, pk): + return super().get(request, pk) @extend_schema(methods=["post"], responses={201: serializers.IPAddressSerializer(many=True)}) - @advisory_lock(ADVISORY_LOCK_KEYS['available-ips']) def post(self, request, pk): - self.queryset = self.queryset.restrict(request.user, 'add') - parent = self.get_parent(request, pk) - - # Normalize to a list of objects - requested_ips = request.data if isinstance(request.data, list) else [request.data] - - # Determine if the requested number of IPs is available - available_ips = parent.get_available_ips() - if available_ips.size < len(requested_ips): - return Response( - { - "detail": f"An insufficient number of IP addresses are available within {parent} " - f"({len(requested_ips)} requested, {len(available_ips)} available)" - }, - status=status.HTTP_409_CONFLICT - ) - - # Assign addresses from the list of available IPs and copy VRF assignment from the parent - available_ips = iter(available_ips) - for requested_ip in requested_ips: - requested_ip['address'] = f'{next(available_ips)}/{parent.mask_length}' - requested_ip['vrf'] = parent.vrf.pk if parent.vrf else None - - # Initialize the serializer with a list or a single object depending on what was requested - context = {'request': request} - if isinstance(request.data, list): - serializer = serializers.IPAddressSerializer(data=requested_ips, many=True, context=context) - else: - serializer = serializers.IPAddressSerializer(data=requested_ips[0], context=context) - - # Create the new IP address(es) - if serializer.is_valid(): - try: - with transaction.atomic(): - created = serializer.save() - self._validate_objects(created) - except ObjectDoesNotExist: - raise PermissionDenied() - return Response(serializer.data, status=status.HTTP_201_CREATED) - - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - - def get_serializer_class(self): - if self.request.method == "GET": - return serializers.AvailableIPSerializer - - return serializers.IPAddressSerializer + return super().post(request, pk) class PrefixAvailableIPAddressesView(AvailableIPAddressesView): @@ -452,77 +452,36 @@ class IPRangeAvailableIPAddressesView(AvailableIPAddressesView): return get_object_or_404(IPRange.objects.restrict(request.user), pk=pk) -class AvailableVLANsView(ObjectValidationMixin, APIView): +class AvailableVLANsView(AvailableObjectsView): queryset = VLAN.objects.all() + read_serializer_class = serializers.AvailableVLANSerializer + write_serializer_class = serializers.CreateAvailableVLANSerializer + advisory_lock_key = 'available-vlans' + + def get_parent(self, request, pk): + return get_object_or_404(VLANGroup.objects.restrict(request.user), pk=pk) + + def get_available_objects(self, parent, limit=None): + return parent.get_available_vids()[:limit] + + def get_extra_context(self, parent): + return { + 'group': parent, + } + + def prep_object_data(self, requested_objects, available_objects, parent): + for i, request_data in enumerate(requested_objects): + request_data.update({ + 'vid': available_objects.pop(0), + 'group': parent.pk, + }) + + return requested_objects @extend_schema(methods=["get"], responses={200: serializers.AvailableVLANSerializer(many=True)}) def get(self, request, pk): - vlangroup = get_object_or_404(VLANGroup.objects.restrict(request.user), pk=pk) - limit = get_results_limit(request) - - available_vlans = vlangroup.get_available_vids()[:limit] - serializer = serializers.AvailableVLANSerializer(available_vlans, many=True, context={ - 'request': request, - 'group': vlangroup, - }) - - return Response(serializer.data) + return super().get(request, pk) @extend_schema(methods=["post"], responses={201: serializers.VLANSerializer(many=True)}) - @advisory_lock(ADVISORY_LOCK_KEYS['available-vlans']) def post(self, request, pk): - self.queryset = self.queryset.restrict(request.user, 'add') - vlangroup = get_object_or_404(VLANGroup.objects.restrict(request.user), pk=pk) - available_vlans = vlangroup.get_available_vids() - many = isinstance(request.data, list) - - # Validate requested VLANs - serializer = serializers.CreateAvailableVLANSerializer( - data=request.data if many else [request.data], - many=True, - context={ - 'request': request, - 'group': vlangroup, - } - ) - if not serializer.is_valid(): - return Response( - serializer.errors, - status=status.HTTP_400_BAD_REQUEST - ) - - requested_vlans = serializer.validated_data - - for i, requested_vlan in enumerate(requested_vlans): - try: - requested_vlan['vid'] = available_vlans.pop(0) - requested_vlan['group'] = vlangroup.pk - except IndexError: - return Response({ - "detail": "The requested number of VLANs is not available" - }, status=status.HTTP_409_CONFLICT) - - # Initialize the serializer with a list or a single object depending on what was requested - context = {'request': request} - if many: - serializer = serializers.VLANSerializer(data=requested_vlans, many=True, context=context) - else: - serializer = serializers.VLANSerializer(data=requested_vlans[0], context=context) - - # Create the new VLAN(s) - if serializer.is_valid(): - try: - with transaction.atomic(): - created = serializer.save() - self._validate_objects(created) - except ObjectDoesNotExist: - raise PermissionDenied() - return Response(serializer.data, status=status.HTTP_201_CREATED) - - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - - def get_serializer_class(self): - if self.request.method == "GET": - return serializers.AvailableVLANSerializer - - return serializers.VLANSerializer + return super().post(request, pk) diff --git a/netbox/ipam/utils.py b/netbox/ipam/utils.py index 93a40e5a0..f54c7d41d 100644 --- a/netbox/ipam/utils.py +++ b/netbox/ipam/utils.py @@ -1,7 +1,15 @@ import netaddr from .constants import * -from .models import ASN, Prefix, VLAN +from .models import Prefix, VLAN + +__all__ = ( + 'add_available_ipaddresses', + 'add_available_vlans', + 'add_requested_prefixes', + 'get_next_available_prefix', + 'rebuild_prefixes', +) def add_requested_prefixes(parent, prefix_list, show_available=True, show_assigned=True): @@ -184,3 +192,15 @@ def rebuild_prefixes(vrf): # Final flush of any remaining Prefixes Prefix.objects.bulk_update(update_queue, ['_depth', '_children']) + + +def get_next_available_prefix(ipset, prefix_size): + """ + Given a prefix length, allocate the next available prefix from an IPSet. + """ + for available_prefix in ipset.iter_cidrs(): + if prefix_size >= available_prefix.prefixlen: + allocated_prefix = f"{available_prefix.network}/{prefix_size}" + ipset.remove(allocated_prefix) + return allocated_prefix + return None From 518fd8cca62db9c558dfc72a440c94c052448e82 Mon Sep 17 00:00:00 2001 From: Arthur Hanson Date: Thu, 22 Jun 2023 05:26:50 -0700 Subject: [PATCH 06/13] 12794 change User ref to get_user_model (#12905) * 12794 change User ref to get_user_model * 12794 call get_user_model once in tests * 12794 call get_user_model once in tests * 12794 use settings.AUTH_USER_MODEL for FK reference --- netbox/core/forms/filtersets.py | 4 ++-- netbox/core/management/commands/nbshell.py | 4 ++-- netbox/core/models/jobs.py | 4 ++-- netbox/dcim/filtersets.py | 6 +++--- netbox/dcim/forms/bulk_edit.py | 4 ++-- netbox/dcim/forms/filtersets.py | 4 ++-- netbox/dcim/forms/model_forms.py | 4 ++-- netbox/dcim/models/racks.py | 4 ++-- netbox/dcim/tests/test_api.py | 5 ++++- netbox/dcim/tests/test_filtersets.py | 5 ++++- netbox/dcim/tests/test_views.py | 5 ++++- netbox/extras/api/serializers.py | 4 ++-- netbox/extras/filtersets.py | 14 +++++++------- netbox/extras/forms/filtersets.py | 6 +++--- netbox/extras/management/commands/runscript.py | 4 +++- netbox/extras/models/change_logging.py | 4 ++-- netbox/extras/models/models.py | 6 +++--- netbox/extras/tests/test_api.py | 5 ++++- netbox/extras/tests/test_filtersets.py | 5 ++++- netbox/extras/tests/test_views.py | 5 ++++- netbox/netbox/tests/test_authentication.py | 6 +++++- netbox/users/api/nested_serializers.py | 5 +++-- netbox/users/api/serializers.py | 7 ++++--- netbox/users/api/views.py | 5 +++-- netbox/users/filtersets.py | 13 +++++++------ netbox/users/graphql/schema.py | 5 +++-- netbox/users/graphql/types.py | 7 ++++--- netbox/users/tests/test_api.py | 6 +++++- netbox/users/tests/test_filtersets.py | 6 +++++- netbox/users/tests/test_models.py | 5 ++++- netbox/users/tests/test_preferences.py | 5 ++++- netbox/utilities/testing/api.py | 5 ++++- netbox/utilities/testing/base.py | 4 ++-- netbox/utilities/testing/utils.py | 5 +++-- 34 files changed, 117 insertions(+), 69 deletions(-) diff --git a/netbox/core/forms/filtersets.py b/netbox/core/forms/filtersets.py index 7c3f2ab09..d8624f6b6 100644 --- a/netbox/core/forms/filtersets.py +++ b/netbox/core/forms/filtersets.py @@ -1,5 +1,5 @@ from django import forms -from django.contrib.auth.models import User +from django.contrib.auth import get_user_model from django.contrib.contenttypes.models import ContentType from django.utils.translation import gettext as _ @@ -105,7 +105,7 @@ class JobFilterForm(SavedFiltersMixin, FilterForm): widget=DateTimePicker() ) user = DynamicModelMultipleChoiceField( - queryset=User.objects.all(), + queryset=get_user_model().objects.all(), required=False, label=_('User'), widget=APISelectMultiple( diff --git a/netbox/core/management/commands/nbshell.py b/netbox/core/management/commands/nbshell.py index 04a67eb49..674a878c7 100644 --- a/netbox/core/management/commands/nbshell.py +++ b/netbox/core/management/commands/nbshell.py @@ -5,7 +5,7 @@ import sys from django import get_version from django.apps import apps from django.conf import settings -from django.contrib.auth.models import User +from django.contrib.auth import get_user_model from django.contrib.contenttypes.models import ContentType from django.core.management.base import BaseCommand @@ -60,7 +60,7 @@ class Command(BaseCommand): # Additional objects to include namespace['ContentType'] = ContentType - namespace['User'] = User + namespace['User'] = get_user_model() # Load convenience commands namespace.update({ diff --git a/netbox/core/models/jobs.py b/netbox/core/models/jobs.py index a91e75e61..9be06bd6d 100644 --- a/netbox/core/models/jobs.py +++ b/netbox/core/models/jobs.py @@ -1,7 +1,7 @@ import uuid import django_rq -from django.contrib.auth.models import User +from django.conf import settings from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.models import ContentType from django.core.validators import MinValueValidator @@ -69,7 +69,7 @@ class Job(models.Model): blank=True ) user = models.ForeignKey( - to=User, + to=settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, related_name='+', blank=True, diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index e87a37847..e53ea8079 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -1,5 +1,5 @@ import django_filters -from django.contrib.auth.models import User +from django.contrib.auth import get_user_model from django.utils.translation import gettext as _ from extras.filtersets import LocalConfigContextFilterSet @@ -395,12 +395,12 @@ class RackReservationFilterSet(NetBoxModelFilterSet, TenancyFilterSet): label=_('Location (slug)'), ) user_id = django_filters.ModelMultipleChoiceFilter( - queryset=User.objects.all(), + queryset=get_user_model().objects.all(), label=_('User (ID)'), ) user = django_filters.ModelMultipleChoiceFilter( field_name='user__username', - queryset=User.objects.all(), + queryset=get_user_model().objects.all(), to_field_name='username', label=_('User (name)'), ) diff --git a/netbox/dcim/forms/bulk_edit.py b/netbox/dcim/forms/bulk_edit.py index 11cfd685d..309370bfd 100644 --- a/netbox/dcim/forms/bulk_edit.py +++ b/netbox/dcim/forms/bulk_edit.py @@ -1,6 +1,6 @@ from django import forms from django.conf import settings -from django.contrib.auth.models import User +from django.contrib.auth import get_user_model from django.utils.translation import gettext as _ from timezone_field import TimeZoneFormField @@ -322,7 +322,7 @@ class RackBulkEditForm(NetBoxModelBulkEditForm): class RackReservationBulkEditForm(NetBoxModelBulkEditForm): user = forms.ModelChoiceField( - queryset=User.objects.order_by( + queryset=get_user_model().objects.order_by( 'username' ), required=False diff --git a/netbox/dcim/forms/filtersets.py b/netbox/dcim/forms/filtersets.py index 4edee6014..0a4a22a70 100644 --- a/netbox/dcim/forms/filtersets.py +++ b/netbox/dcim/forms/filtersets.py @@ -1,5 +1,5 @@ from django import forms -from django.contrib.auth.models import User +from django.contrib.auth import get_user_model from django.utils.translation import gettext as _ from dcim.choices import * @@ -376,7 +376,7 @@ class RackReservationFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): label=_('Rack') ) user_id = DynamicModelMultipleChoiceField( - queryset=User.objects.all(), + queryset=get_user_model().objects.all(), required=False, label=_('User'), widget=APISelectMultiple( diff --git a/netbox/dcim/forms/model_forms.py b/netbox/dcim/forms/model_forms.py index 56542d70c..eda302736 100644 --- a/netbox/dcim/forms/model_forms.py +++ b/netbox/dcim/forms/model_forms.py @@ -1,5 +1,5 @@ from django import forms -from django.contrib.auth.models import User +from django.contrib.auth import get_user_model from django.contrib.contenttypes.models import ContentType from django.utils.translation import gettext as _ from timezone_field import TimeZoneFormField @@ -236,7 +236,7 @@ class RackReservationForm(TenancyForm, NetBoxModelForm): help_text=_("Comma-separated list of numeric unit IDs. A range may be specified using a hyphen.") ) user = forms.ModelChoiceField( - queryset=User.objects.order_by( + queryset=get_user_model().objects.order_by( 'username' ) ) diff --git a/netbox/dcim/models/racks.py b/netbox/dcim/models/racks.py index d73c8e27b..5ac223c45 100644 --- a/netbox/dcim/models/racks.py +++ b/netbox/dcim/models/racks.py @@ -1,7 +1,7 @@ import decimal from functools import cached_property -from django.contrib.auth.models import User +from django.conf import settings from django.contrib.contenttypes.fields import GenericRelation from django.contrib.postgres.fields import ArrayField from django.core.exceptions import ValidationError @@ -505,7 +505,7 @@ class RackReservation(PrimaryModel): null=True ) user = models.ForeignKey( - to=User, + to=settings.AUTH_USER_MODEL, on_delete=models.PROTECT ) description = models.CharField( diff --git a/netbox/dcim/tests/test_api.py b/netbox/dcim/tests/test_api.py index af15e1343..ecaf32a06 100644 --- a/netbox/dcim/tests/test_api.py +++ b/netbox/dcim/tests/test_api.py @@ -1,4 +1,4 @@ -from django.contrib.auth.models import User +from django.contrib.auth import get_user_model from django.test import override_settings from django.urls import reverse from rest_framework import status @@ -14,6 +14,9 @@ from wireless.choices import WirelessChannelChoices from wireless.models import WirelessLAN +User = get_user_model() + + class AppTest(APITestCase): def test_root(self): diff --git a/netbox/dcim/tests/test_filtersets.py b/netbox/dcim/tests/test_filtersets.py index aa6860a16..a1e684cb9 100644 --- a/netbox/dcim/tests/test_filtersets.py +++ b/netbox/dcim/tests/test_filtersets.py @@ -1,4 +1,4 @@ -from django.contrib.auth.models import User +from django.contrib.auth import get_user_model from django.test import TestCase from dcim.choices import * @@ -12,6 +12,9 @@ from virtualization.models import Cluster, ClusterType from wireless.choices import WirelessChannelChoices, WirelessRoleChoices +User = get_user_model() + + class DeviceComponentFilterSetTests: def test_device_type(self): diff --git a/netbox/dcim/tests/test_views.py b/netbox/dcim/tests/test_views.py index a327d6400..23683ddce 100644 --- a/netbox/dcim/tests/test_views.py +++ b/netbox/dcim/tests/test_views.py @@ -6,7 +6,7 @@ except ImportError: from backports.zoneinfo import ZoneInfo import yaml -from django.contrib.auth.models import User +from django.contrib.auth import get_user_model from django.contrib.contenttypes.models import ContentType from django.test import override_settings from django.urls import reverse @@ -22,6 +22,9 @@ from utilities.testing import ViewTestCases, create_tags, create_test_device, po from wireless.models import WirelessLAN +User = get_user_model() + + class RegionTestCase(ViewTestCases.OrganizationalObjectViewTestCase): model = Region diff --git a/netbox/extras/api/serializers.py b/netbox/extras/api/serializers.py index cbe4ed56d..a02e933ba 100644 --- a/netbox/extras/api/serializers.py +++ b/netbox/extras/api/serializers.py @@ -1,4 +1,4 @@ -from django.contrib.auth.models import User +from django.contrib.auth import get_user_model from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ObjectDoesNotExist from rest_framework import serializers @@ -256,7 +256,7 @@ class JournalEntrySerializer(NetBoxModelSerializer): assigned_object = serializers.SerializerMethodField(read_only=True) created_by = serializers.PrimaryKeyRelatedField( allow_null=True, - queryset=User.objects.all(), + queryset=get_user_model().objects.all(), required=False, default=serializers.CurrentUserDefault() ) diff --git a/netbox/extras/filtersets.py b/netbox/extras/filtersets.py index 5253ae7b0..2cbaca5f7 100644 --- a/netbox/extras/filtersets.py +++ b/netbox/extras/filtersets.py @@ -1,5 +1,5 @@ import django_filters -from django.contrib.auth.models import User +from django.contrib.auth import get_user_model from django.contrib.contenttypes.models import ContentType from django.db.models import Q from django.utils.translation import gettext as _ @@ -159,12 +159,12 @@ class SavedFilterFilterSet(BaseFilterSet): ) content_types = ContentTypeFilter() user_id = django_filters.ModelMultipleChoiceFilter( - queryset=User.objects.all(), + queryset=get_user_model().objects.all(), label=_('User (ID)'), ) user = django_filters.ModelMultipleChoiceFilter( field_name='user__username', - queryset=User.objects.all(), + queryset=get_user_model().objects.all(), to_field_name='username', label=_('User (name)'), ) @@ -223,12 +223,12 @@ class JournalEntryFilterSet(NetBoxModelFilterSet): queryset=ContentType.objects.all() ) created_by_id = django_filters.ModelMultipleChoiceFilter( - queryset=User.objects.all(), + queryset=get_user_model().objects.all(), label=_('User (ID)'), ) created_by = django_filters.ModelMultipleChoiceFilter( field_name='created_by__username', - queryset=User.objects.all(), + queryset=get_user_model().objects.all(), to_field_name='username', label=_('User (name)'), ) @@ -510,12 +510,12 @@ class ObjectChangeFilterSet(BaseFilterSet): queryset=ContentType.objects.all() ) user_id = django_filters.ModelMultipleChoiceFilter( - queryset=User.objects.all(), + queryset=get_user_model().objects.all(), label=_('User (ID)'), ) user = django_filters.ModelMultipleChoiceFilter( field_name='user__username', - queryset=User.objects.all(), + queryset=get_user_model().objects.all(), to_field_name='username', label=_('User name'), ) diff --git a/netbox/extras/forms/filtersets.py b/netbox/extras/forms/filtersets.py index fae15d041..53de81ba2 100644 --- a/netbox/extras/forms/filtersets.py +++ b/netbox/extras/forms/filtersets.py @@ -1,5 +1,5 @@ from django import forms -from django.contrib.auth.models import User +from django.contrib.auth import get_user_model from django.contrib.contenttypes.models import ContentType from django.utils.translation import gettext as _ @@ -385,7 +385,7 @@ class JournalEntryFilterForm(NetBoxModelFilterSetForm): widget=DateTimePicker() ) created_by_id = DynamicModelMultipleChoiceField( - queryset=User.objects.all(), + queryset=get_user_model().objects.all(), required=False, label=_('User'), widget=APISelectMultiple( @@ -429,7 +429,7 @@ class ObjectChangeFilterForm(SavedFiltersMixin, FilterForm): required=False ) user_id = DynamicModelMultipleChoiceField( - queryset=User.objects.all(), + queryset=get_user_model().objects.all(), required=False, label=_('User'), widget=APISelectMultiple( diff --git a/netbox/extras/management/commands/runscript.py b/netbox/extras/management/commands/runscript.py index b42e9b47d..d9a9f41ae 100644 --- a/netbox/extras/management/commands/runscript.py +++ b/netbox/extras/management/commands/runscript.py @@ -4,7 +4,7 @@ import sys import traceback import uuid -from django.contrib.auth.models import User +from django.contrib.auth import get_user_model from django.core.management.base import BaseCommand, CommandError from django.db import transaction @@ -63,6 +63,8 @@ class Command(BaseCommand): logger.info(f"Script completed in {job.duration}") + User = get_user_model() + # Params script = options['script'] loglevel = options['loglevel'] diff --git a/netbox/extras/models/change_logging.py b/netbox/extras/models/change_logging.py index e2b118b84..54d72cdd8 100644 --- a/netbox/extras/models/change_logging.py +++ b/netbox/extras/models/change_logging.py @@ -1,4 +1,4 @@ -from django.contrib.auth.models import User +from django.conf import settings from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.models import ContentType from django.db import models @@ -24,7 +24,7 @@ class ObjectChange(models.Model): db_index=True ) user = models.ForeignKey( - to=User, + to=settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, related_name='changes', blank=True, diff --git a/netbox/extras/models/models.py b/netbox/extras/models/models.py index e95c0aff3..6c7aac08d 100644 --- a/netbox/extras/models/models.py +++ b/netbox/extras/models/models.py @@ -3,7 +3,7 @@ import urllib.parse from django.conf import settings from django.contrib import admin -from django.contrib.auth.models import User +from django.conf import settings from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.models import ContentType from django.core.cache import cache @@ -419,7 +419,7 @@ class SavedFilter(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel): blank=True ) user = models.ForeignKey( - to=User, + to=settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, blank=True, null=True @@ -560,7 +560,7 @@ class JournalEntry(CustomFieldsMixin, CustomLinksMixin, TagsMixin, ExportTemplat fk_field='assigned_object_id' ) created_by = models.ForeignKey( - to=User, + to=settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, blank=True, null=True diff --git a/netbox/extras/tests/test_api.py b/netbox/extras/tests/test_api.py index b59481a36..4c48aa73e 100644 --- a/netbox/extras/tests/test_api.py +++ b/netbox/extras/tests/test_api.py @@ -1,6 +1,6 @@ import datetime -from django.contrib.auth.models import User +from django.contrib.auth import get_user_model from django.contrib.contenttypes.models import ContentType from django.urls import reverse from django.utils.timezone import make_aware @@ -15,6 +15,9 @@ from extras.scripts import BooleanVar, IntegerVar, Script, StringVar from utilities.testing import APITestCase, APIViewTestCases +User = get_user_model() + + class AppTest(APITestCase): def test_root(self): diff --git a/netbox/extras/tests/test_filtersets.py b/netbox/extras/tests/test_filtersets.py index e77afd20e..992643530 100644 --- a/netbox/extras/tests/test_filtersets.py +++ b/netbox/extras/tests/test_filtersets.py @@ -1,7 +1,7 @@ import uuid from datetime import datetime, timezone -from django.contrib.auth.models import User +from django.contrib.auth import get_user_model from django.contrib.contenttypes.models import ContentType from django.test import TestCase @@ -18,6 +18,9 @@ from utilities.testing import BaseFilterSetTests, ChangeLoggedFilterSetTests, cr from virtualization.models import Cluster, ClusterGroup, ClusterType +User = get_user_model() + + class CustomFieldTestCase(TestCase, BaseFilterSetTests): queryset = CustomField.objects.all() filterset = CustomFieldFilterSet diff --git a/netbox/extras/tests/test_views.py b/netbox/extras/tests/test_views.py index ef8e87489..3dcb90875 100644 --- a/netbox/extras/tests/test_views.py +++ b/netbox/extras/tests/test_views.py @@ -1,7 +1,7 @@ import urllib.parse import uuid -from django.contrib.auth.models import User +from django.contrib.auth import get_user_model from django.contrib.contenttypes.models import ContentType from django.urls import reverse @@ -11,6 +11,9 @@ from extras.models import * from utilities.testing import ViewTestCases, TestCase +User = get_user_model() + + class CustomFieldTestCase(ViewTestCases.PrimaryObjectViewTestCase): model = CustomField diff --git a/netbox/netbox/tests/test_authentication.py b/netbox/netbox/tests/test_authentication.py index 4e46996b5..1804087d1 100644 --- a/netbox/netbox/tests/test_authentication.py +++ b/netbox/netbox/tests/test_authentication.py @@ -1,7 +1,8 @@ import datetime from django.conf import settings -from django.contrib.auth.models import Group, User +from django.contrib.auth import get_user_model +from django.contrib.auth.models import Group from django.contrib.contenttypes.models import ContentType from django.test import Client from django.test.utils import override_settings @@ -16,6 +17,9 @@ from utilities.testing import TestCase from utilities.testing.api import APITestCase +User = get_user_model() + + class TokenAuthenticationTestCase(APITestCase): @override_settings(LOGIN_REQUIRED=True, EXEMPT_VIEW_PERMISSIONS=['*']) diff --git a/netbox/users/api/nested_serializers.py b/netbox/users/api/nested_serializers.py index 3510184ae..5e15fa41a 100644 --- a/netbox/users/api/nested_serializers.py +++ b/netbox/users/api/nested_serializers.py @@ -1,4 +1,5 @@ -from django.contrib.auth.models import Group, User +from django.contrib.auth import get_user_model +from django.contrib.auth.models import Group from django.contrib.contenttypes.models import ContentType from drf_spectacular.utils import extend_schema_field from drf_spectacular.types import OpenApiTypes @@ -28,7 +29,7 @@ class NestedUserSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='users-api:user-detail') class Meta: - model = User + model = get_user_model() fields = ['id', 'url', 'display', 'username'] @extend_schema_field(OpenApiTypes.STR) diff --git a/netbox/users/api/serializers.py b/netbox/users/api/serializers.py index 1b975791f..1f4bf4ea0 100644 --- a/netbox/users/api/serializers.py +++ b/netbox/users/api/serializers.py @@ -1,5 +1,6 @@ from django.conf import settings -from django.contrib.auth.models import Group, User +from django.contrib.auth import get_user_model +from django.contrib.auth.models import Group from django.contrib.contenttypes.models import ContentType from drf_spectacular.utils import extend_schema_field from drf_spectacular.types import OpenApiTypes @@ -30,7 +31,7 @@ class UserSerializer(ValidatedModelSerializer): ) class Meta: - model = User + model = get_user_model() fields = ( 'id', 'url', 'display', 'username', 'password', 'first_name', 'last_name', 'email', 'is_staff', 'is_active', 'date_joined', 'groups', @@ -124,7 +125,7 @@ class ObjectPermissionSerializer(ValidatedModelSerializer): many=True ) users = SerializedPKRelatedField( - queryset=User.objects.all(), + queryset=get_user_model().objects.all(), serializer=NestedUserSerializer, required=False, many=True diff --git a/netbox/users/api/views.py b/netbox/users/api/views.py index 04b3ae336..4a8e1b154 100644 --- a/netbox/users/api/views.py +++ b/netbox/users/api/views.py @@ -1,5 +1,6 @@ from django.contrib.auth import authenticate -from django.contrib.auth.models import Group, User +from django.contrib.auth import get_user_model +from django.contrib.auth.models import Group from django.db.models import Count from drf_spectacular.utils import extend_schema from drf_spectacular.types import OpenApiTypes @@ -32,7 +33,7 @@ class UsersRootView(APIRootView): # class UserViewSet(NetBoxModelViewSet): - queryset = RestrictedQuerySet(model=User).prefetch_related('groups').order_by('username') + queryset = RestrictedQuerySet(model=get_user_model()).prefetch_related('groups').order_by('username') serializer_class = serializers.UserSerializer filterset_class = filtersets.UserFilterSet diff --git a/netbox/users/filtersets.py b/netbox/users/filtersets.py index 4ae9df89a..44ad98cc2 100644 --- a/netbox/users/filtersets.py +++ b/netbox/users/filtersets.py @@ -1,5 +1,6 @@ import django_filters -from django.contrib.auth.models import Group, User +from django.contrib.auth import get_user_model +from django.contrib.auth.models import Group from django.db.models import Q from django.utils.translation import gettext as _ @@ -47,7 +48,7 @@ class UserFilterSet(BaseFilterSet): ) class Meta: - model = User + model = get_user_model() fields = ['id', 'username', 'first_name', 'last_name', 'email', 'is_staff', 'is_active'] def search(self, queryset, name, value): @@ -68,12 +69,12 @@ class TokenFilterSet(BaseFilterSet): ) user_id = django_filters.ModelMultipleChoiceFilter( field_name='user', - queryset=User.objects.all(), + queryset=get_user_model().objects.all(), label=_('User'), ) user = django_filters.ModelMultipleChoiceFilter( field_name='user__username', - queryset=User.objects.all(), + queryset=get_user_model().objects.all(), to_field_name='username', label=_('User (name)'), ) @@ -116,12 +117,12 @@ class ObjectPermissionFilterSet(BaseFilterSet): ) user_id = django_filters.ModelMultipleChoiceFilter( field_name='users', - queryset=User.objects.all(), + queryset=get_user_model().objects.all(), label=_('User'), ) user = django_filters.ModelMultipleChoiceFilter( field_name='users__username', - queryset=User.objects.all(), + queryset=get_user_model().objects.all(), to_field_name='username', label=_('User (name)'), ) diff --git a/netbox/users/graphql/schema.py b/netbox/users/graphql/schema.py index 3b04d8418..f033a535a 100644 --- a/netbox/users/graphql/schema.py +++ b/netbox/users/graphql/schema.py @@ -1,6 +1,7 @@ import graphene -from django.contrib.auth.models import Group, User +from django.contrib.auth import get_user_model +from django.contrib.auth.models import Group from netbox.graphql.fields import ObjectField, ObjectListField from .types import * from utilities.graphql_optimizer import gql_query_optimizer @@ -17,4 +18,4 @@ class UsersQuery(graphene.ObjectType): user_list = ObjectListField(UserType) def resolve_user_list(root, info, **kwargs): - return gql_query_optimizer(User.objects.all(), info) + return gql_query_optimizer(get_user_model().objects.all(), info) diff --git a/netbox/users/graphql/types.py b/netbox/users/graphql/types.py index d948686c6..4254f1791 100644 --- a/netbox/users/graphql/types.py +++ b/netbox/users/graphql/types.py @@ -1,4 +1,5 @@ -from django.contrib.auth.models import Group, User +from django.contrib.auth import get_user_model +from django.contrib.auth.models import Group from graphene_django import DjangoObjectType from users import filtersets @@ -25,7 +26,7 @@ class GroupType(DjangoObjectType): class UserType(DjangoObjectType): class Meta: - model = User + model = get_user_model() fields = ( 'id', 'username', 'password', 'first_name', 'last_name', 'email', 'is_staff', 'is_active', 'date_joined', 'groups', @@ -34,4 +35,4 @@ class UserType(DjangoObjectType): @classmethod def get_queryset(cls, queryset, info): - return RestrictedQuerySet(model=User).restrict(info.context.user, 'view') + return RestrictedQuerySet(model=get_user_model()).restrict(info.context.user, 'view') diff --git a/netbox/users/tests/test_api.py b/netbox/users/tests/test_api.py index 281f656d2..2de243775 100644 --- a/netbox/users/tests/test_api.py +++ b/netbox/users/tests/test_api.py @@ -1,4 +1,5 @@ -from django.contrib.auth.models import Group, User +from django.contrib.auth import get_user_model +from django.contrib.auth.models import Group from django.contrib.contenttypes.models import ContentType from django.urls import reverse @@ -7,6 +8,9 @@ from utilities.testing import APIViewTestCases, APITestCase from utilities.utils import deepmerge +User = get_user_model() + + class AppTest(APITestCase): def test_root(self): diff --git a/netbox/users/tests/test_filtersets.py b/netbox/users/tests/test_filtersets.py index 33ed7e7ba..d632687ef 100644 --- a/netbox/users/tests/test_filtersets.py +++ b/netbox/users/tests/test_filtersets.py @@ -1,6 +1,7 @@ import datetime -from django.contrib.auth.models import Group, User +from django.contrib.auth import get_user_model +from django.contrib.auth.models import Group from django.contrib.contenttypes.models import ContentType from django.test import TestCase from django.utils.timezone import make_aware @@ -10,6 +11,9 @@ from users.models import ObjectPermission, Token from utilities.testing import BaseFilterSetTests +User = get_user_model() + + class UserTestCase(TestCase, BaseFilterSetTests): queryset = User.objects.all() filterset = filtersets.UserFilterSet diff --git a/netbox/users/tests/test_models.py b/netbox/users/tests/test_models.py index 7a2337f33..791ea8fb4 100644 --- a/netbox/users/tests/test_models.py +++ b/netbox/users/tests/test_models.py @@ -1,7 +1,10 @@ -from django.contrib.auth.models import User +from django.contrib.auth import get_user_model from django.test import TestCase +User = get_user_model() + + class UserConfigTest(TestCase): @classmethod diff --git a/netbox/users/tests/test_preferences.py b/netbox/users/tests/test_preferences.py index f1e947d67..203a67bdd 100644 --- a/netbox/users/tests/test_preferences.py +++ b/netbox/users/tests/test_preferences.py @@ -1,4 +1,4 @@ -from django.contrib.auth.models import User +from django.contrib.auth import get_user_model from django.test import override_settings from django.test.client import RequestFactory from django.urls import reverse @@ -16,6 +16,9 @@ DEFAULT_USER_PREFERENCES = { } +User = get_user_model() + + class UserPreferencesTest(TestCase): user_permissions = ['dcim.view_site'] diff --git a/netbox/utilities/testing/api.py b/netbox/utilities/testing/api.py index 7f24c86b8..8cfe1cdd7 100644 --- a/netbox/utilities/testing/api.py +++ b/netbox/utilities/testing/api.py @@ -2,7 +2,7 @@ import inspect import json from django.conf import settings -from django.contrib.auth.models import User +from django.contrib.auth import get_user_model from django.contrib.contenttypes.models import ContentType from django.urls import reverse from django.test import override_settings @@ -26,6 +26,9 @@ __all__ = ( ) +User = get_user_model() + + # # REST/GraphQL API Tests # diff --git a/netbox/utilities/testing/base.py b/netbox/utilities/testing/base.py index 04ceca1e2..76a9fac06 100644 --- a/netbox/utilities/testing/base.py +++ b/netbox/utilities/testing/base.py @@ -1,6 +1,6 @@ import json -from django.contrib.auth.models import User +from django.contrib.auth import get_user_model from django.contrib.contenttypes.models import ContentType from django.contrib.postgres.fields import ArrayField from django.core.exceptions import FieldDoesNotExist @@ -27,7 +27,7 @@ class TestCase(_TestCase): def setUp(self): # Create the test user and assign permissions - self.user = User.objects.create_user(username='testuser') + self.user = get_user_model().objects.create_user(username='testuser') self.add_permissions(*self.user_permissions) # Initialize the test client diff --git a/netbox/utilities/testing/utils.py b/netbox/utilities/testing/utils.py index 52ccd002d..87fc3319c 100644 --- a/netbox/utilities/testing/utils.py +++ b/netbox/utilities/testing/utils.py @@ -2,7 +2,8 @@ import logging import re from contextlib import contextmanager -from django.contrib.auth.models import Permission, User +from django.contrib.auth import get_user_model +from django.contrib.auth.models import Permission from django.utils.text import slugify from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site @@ -63,7 +64,7 @@ def create_test_user(username='testuser', permissions=None): """ Create a User with the given permissions. """ - user = User.objects.create_user(username=username) + user = get_user_model().objects.create_user(username=username) if permissions is None: permissions = () for perm_name in permissions: From eff4a3741c05b5d3fa6dc216f19c329291bbe23e Mon Sep 17 00:00:00 2001 From: Arthur Hanson Date: Thu, 22 Jun 2023 06:09:01 -0700 Subject: [PATCH 07/13] 12175 rack with starting unit > 1 (#12778) * 12175 add rack starting unit * 12175 rack starting unit to svg * verify devices can still fit if change rack starting_unit * 12175 fix migration * 12175 fix typo and test * 12175 fix test * 12175 fix max height calc display * Misc cleanup & fixes --------- Co-authored-by: Jeremy Stretch --- netbox/dcim/constants.py | 2 ++ netbox/dcim/forms/model_forms.py | 4 +-- .../migrations/0174_rack_starting_unit.py | 17 +++++++++ netbox/dcim/models/racks.py | 35 ++++++++++++------- netbox/dcim/svg/racks.py | 6 ++-- netbox/dcim/tests/test_views.py | 1 + netbox/templates/dcim/rack.html | 6 ++++ netbox/templates/dcim/rack_edit.html | 1 + 8 files changed, 55 insertions(+), 17 deletions(-) create mode 100644 netbox/dcim/migrations/0174_rack_starting_unit.py diff --git a/netbox/dcim/constants.py b/netbox/dcim/constants.py index b3c065b5a..303fc2344 100644 --- a/netbox/dcim/constants.py +++ b/netbox/dcim/constants.py @@ -17,6 +17,8 @@ RACK_ELEVATION_BORDER_WIDTH = 2 RACK_ELEVATION_DEFAULT_LEGEND_WIDTH = 30 RACK_ELEVATION_DEFAULT_MARGIN_WIDTH = 15 +RACK_STARTING_UNIT_DEFAULT = 1 + # # RearPorts diff --git a/netbox/dcim/forms/model_forms.py b/netbox/dcim/forms/model_forms.py index eda302736..04f976d94 100644 --- a/netbox/dcim/forms/model_forms.py +++ b/netbox/dcim/forms/model_forms.py @@ -221,8 +221,8 @@ class RackForm(TenancyForm, NetBoxModelForm): model = Rack fields = [ 'site', 'location', 'name', 'facility_id', 'tenant_group', 'tenant', 'status', 'role', 'serial', - 'asset_tag', 'type', 'width', 'u_height', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit', - 'mounting_depth', 'weight', 'max_weight', 'weight_unit', 'description', 'comments', 'tags', + 'asset_tag', 'type', 'width', 'u_height', 'starting_unit', 'desc_units', 'outer_width', 'outer_depth', + 'outer_unit', 'mounting_depth', 'weight', 'max_weight', 'weight_unit', 'description', 'comments', 'tags', ] diff --git a/netbox/dcim/migrations/0174_rack_starting_unit.py b/netbox/dcim/migrations/0174_rack_starting_unit.py new file mode 100644 index 000000000..e32738660 --- /dev/null +++ b/netbox/dcim/migrations/0174_rack_starting_unit.py @@ -0,0 +1,17 @@ +# Generated by Django 4.1.9 on 2023-05-31 15:47 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ('dcim', '0174_device_latitude_device_longitude'), + ] + + operations = [ + migrations.AddField( + model_name='rack', + name='starting_unit', + field=models.PositiveSmallIntegerField(default=1), + ), + ] diff --git a/netbox/dcim/models/racks.py b/netbox/dcim/models/racks.py index 5ac223c45..6d3c15eee 100644 --- a/netbox/dcim/models/racks.py +++ b/netbox/dcim/models/racks.py @@ -129,6 +129,11 @@ class Rack(PrimaryModel, WeightMixin): validators=[MinValueValidator(1), MaxValueValidator(RACK_U_HEIGHT_MAX)], help_text=_('Height in rack units') ) + starting_unit = models.PositiveSmallIntegerField( + default=RACK_STARTING_UNIT_DEFAULT, + verbose_name='Starting unit', + help_text=_('Starting unit for rack') + ) desc_units = models.BooleanField( default=False, verbose_name='Descending units', @@ -228,20 +233,24 @@ class Rack(PrimaryModel, WeightMixin): raise ValidationError("Must specify a unit when setting a maximum weight") if self.pk: - # Validate that Rack is tall enough to house the installed Devices - top_device = Device.objects.filter( - rack=self - ).exclude( - position__isnull=True - ).order_by('-position').first() - if top_device: - min_height = top_device.position + top_device.device_type.u_height - 1 + mounted_devices = Device.objects.filter(rack=self).exclude(position__isnull=True).order_by('position') + + # Validate that Rack is tall enough to house the highest mounted Device + if top_device := mounted_devices.last(): + min_height = top_device.position + top_device.device_type.u_height - self.starting_unit if self.u_height < min_height: raise ValidationError({ - 'u_height': "Rack must be at least {}U tall to house currently installed devices.".format( - min_height - ) + 'u_height': f"Rack must be at least {min_height}U tall to house currently installed devices." }) + + # Validate that the Rack's starting unit is less than or equal to the position of the lowest mounted Device + if last_device := mounted_devices.first(): + if self.starting_unit > last_device.position: + raise ValidationError({ + 'starting_unit': f"Rack unit numbering must begin at {last_device.position} or less to house " + f"currently installed devices." + }) + # Validate that Rack was assigned a Location of its same site, if applicable if self.location: if self.location.site != self.site: @@ -269,8 +278,8 @@ class Rack(PrimaryModel, WeightMixin): Return a list of unit numbers, top to bottom. """ if self.desc_units: - return drange(decimal.Decimal(1.0), self.u_height + 1, 0.5) - return drange(self.u_height + decimal.Decimal(0.5), 0.5, -0.5) + return drange(decimal.Decimal(self.starting_unit), self.u_height + self.starting_unit, 0.5) + return drange(self.u_height + decimal.Decimal(0.5) + self.starting_unit - 1, 0.5 + self.starting_unit - 1, -0.5) def get_status_color(self): return RackStatusChoices.colors.get(self.status) diff --git a/netbox/dcim/svg/racks.py b/netbox/dcim/svg/racks.py index 9c317ea16..6333abcf1 100644 --- a/netbox/dcim/svg/racks.py +++ b/netbox/dcim/svg/racks.py @@ -150,9 +150,9 @@ class RackElevationSVG: x = self.legend_width + RACK_ELEVATION_BORDER_WIDTH y = RACK_ELEVATION_BORDER_WIDTH if self.rack.desc_units: - y += int((position - 1) * self.unit_height) + y += int((position - self.rack.starting_unit) * self.unit_height) else: - y += int((self.rack.u_height - position + 1) * self.unit_height) - int(height * self.unit_height) + y += int((self.rack.u_height - position + self.rack.starting_unit) * self.unit_height) - int(height * self.unit_height) return x, y @@ -237,6 +237,7 @@ class RackElevationSVG: start_y = ru * self.unit_height + RACK_ELEVATION_BORDER_WIDTH position_coordinates = (self.legend_width / 2, start_y + self.unit_height / 2 + RACK_ELEVATION_BORDER_WIDTH) unit = ru + 1 if self.rack.desc_units else self.rack.u_height - ru + unit = unit + self.rack.starting_unit - 1 self.drawing.add( Text(str(unit), position_coordinates, class_='unit') ) @@ -278,6 +279,7 @@ class RackElevationSVG: for ru in range(0, self.rack.u_height): unit = ru + 1 if self.rack.desc_units else self.rack.u_height - ru + unit = unit + self.rack.starting_unit - 1 y_offset = RACK_ELEVATION_BORDER_WIDTH + ru * self.unit_height text_coords = ( x_offset + self.unit_width / 2, diff --git a/netbox/dcim/tests/test_views.py b/netbox/dcim/tests/test_views.py index 23683ddce..cca6b3f02 100644 --- a/netbox/dcim/tests/test_views.py +++ b/netbox/dcim/tests/test_views.py @@ -392,6 +392,7 @@ class RackTestCase(ViewTestCases.PrimaryObjectViewTestCase): 'outer_width': 500, 'outer_depth': 500, 'outer_unit': RackDimensionUnitChoices.UNIT_MILLIMETER, + 'starting_unit': 1, 'weight': 100, 'max_weight': 2000, 'weight_unit': WeightUnitChoices.UNIT_POUND, diff --git a/netbox/templates/dcim/rack.html b/netbox/templates/dcim/rack.html index 52b5d4bfe..01aeacff1 100644 --- a/netbox/templates/dcim/rack.html +++ b/netbox/templates/dcim/rack.html @@ -101,6 +101,12 @@ Height {{ object.u_height }}U ({% if object.desc_units %}descending{% else %}ascending{% endif %}) + + Starting Unit + + {{ object.starting_unit }} + + Outer Width diff --git a/netbox/templates/dcim/rack_edit.html b/netbox/templates/dcim/rack_edit.html index 4bbd72405..a1ebb7531 100644 --- a/netbox/templates/dcim/rack_edit.html +++ b/netbox/templates/dcim/rack_edit.html @@ -71,6 +71,7 @@ {% render_field form.mounting_depth %} {% render_field form.desc_units %} + {% render_field form.starting_unit %} {% if form.custom_fields %} From 9fa1411d746e018c68c93a6b463c06886a90d0f9 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 22 Jun 2023 10:55:12 -0400 Subject: [PATCH 08/13] Changelog for #9077, #11305, #12175, #12180, #12794 --- docs/release-notes/version-3.6.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/release-notes/version-3.6.md b/docs/release-notes/version-3.6.md index c4e6847d3..1ef23aa47 100644 --- a/docs/release-notes/version-3.6.md +++ b/docs/release-notes/version-3.6.md @@ -6,7 +6,15 @@ * The `napalm_driver` and `napalm_args` fields (which were deprecated in v3.5) have been removed from the platform model. +### Enhancements + +* [#11305](https://github.com/netbox-community/netbox/issues/11305) - Add GPS coordinate fields to the device model +* [#12175](https://github.com/netbox-community/netbox/issues/12175) - Permit racks to start numbering at values greater than one + ### Other Changes +* [#9077](https://github.com/netbox-community/netbox/issues/9077) - Prevent the errant execution of dangerous instance methods in Django templates * [#11766](https://github.com/netbox-community/netbox/issues/11766) - Remove obsolete custom `ChoiceField` and `MultipleChoiceField` classes +* [#12180](https://github.com/netbox-community/netbox/issues/12180) - All API endpoints for available objects (e.g. IP addresses) now inherit from a common parent view +* [#12794](https://github.com/netbox-community/netbox/issues/12794) - Avoid direct imports of Django's stock user model * [#12320](https://github.com/netbox-community/netbox/issues/12320) - Remove obsolete fields `napalm_driver` and `napalm_args` from Platform From 48b2ab3587896889e914d446cdd422be50c25c78 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 22 Jun 2023 12:27:21 -0400 Subject: [PATCH 09/13] Closes #12964: Raise minimum PostgreSQL version from 11 to 12 --- docs/configuration/required-parameters.md | 2 +- docs/installation/1-postgresql.md | 6 +++--- docs/installation/index.md | 2 +- docs/installation/upgrading.md | 4 ++-- docs/introduction.md | 2 +- docs/release-notes/version-3.6.md | 2 ++ 6 files changed, 10 insertions(+), 8 deletions(-) diff --git a/docs/configuration/required-parameters.md b/docs/configuration/required-parameters.md index 1eba265bf..012d85762 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 11 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 12 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 1fccd0270..bc1bbf22c 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 11 or later required" - NetBox requires PostgreSQL 11 or later. Please note that MySQL and other relational databases are **not** supported. +!!! warning "PostgreSQL 12 or later required" + NetBox requires PostgreSQL 12 or later. Please note that MySQL and other relational databases are **not** supported. ## Installation @@ -35,7 +35,7 @@ This section entails the installation and configuration of a local PostgreSQL da sudo systemctl enable postgresql ``` -Before continuing, verify that you have installed PostgreSQL 11 or later: +Before continuing, verify that you have installed PostgreSQL 12 or later: ```no-highlight psql -V diff --git a/docs/installation/index.md b/docs/installation/index.md index 375c0a2b5..da50fa5fa 100644 --- a/docs/installation/index.md +++ b/docs/installation/index.md @@ -18,7 +18,7 @@ The following sections detail how to set up a new instance of NetBox: | Dependency | Minimum Version | |------------|-----------------| | Python | 3.8 | -| PostgreSQL | 11 | +| PostgreSQL | 12 | | 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 27401c3cf..a81d8c954 100644 --- a/docs/installation/upgrading.md +++ b/docs/installation/upgrading.md @@ -15,12 +15,12 @@ Prior to upgrading your NetBox instance, be sure to carefully review all [releas ## 2. Update Dependencies to Required Versions -NetBox v3.0 and later require the following: +NetBox requires the following dependencies: | Dependency | Minimum Version | |------------|-----------------| | Python | 3.8 | -| PostgreSQL | 11 | +| PostgreSQL | 12 | | Redis | 4.0 | ## 3. Install the Latest Release diff --git a/docs/introduction.md b/docs/introduction.md index 640147395..8f62d842a 100644 --- a/docs/introduction.md +++ b/docs/introduction.md @@ -75,5 +75,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 11+ | +| Database | PostgreSQL 12+ | | Task queuing | Redis/django-rq | diff --git a/docs/release-notes/version-3.6.md b/docs/release-notes/version-3.6.md index 1ef23aa47..dc5280670 100644 --- a/docs/release-notes/version-3.6.md +++ b/docs/release-notes/version-3.6.md @@ -4,6 +4,7 @@ ### Breaking Changes +* PostgreSQL 11 is no longer supported (due to adopting Django 4.2). NetBox v3.6 requires PostgreSQL 12 or later. * The `napalm_driver` and `napalm_args` fields (which were deprecated in v3.5) have been removed from the platform model. ### Enhancements @@ -18,3 +19,4 @@ * [#12180](https://github.com/netbox-community/netbox/issues/12180) - All API endpoints for available objects (e.g. IP addresses) now inherit from a common parent view * [#12794](https://github.com/netbox-community/netbox/issues/12794) - Avoid direct imports of Django's stock user model * [#12320](https://github.com/netbox-community/netbox/issues/12320) - Remove obsolete fields `napalm_driver` and `napalm_args` from Platform +* [#12964](https://github.com/netbox-community/netbox/issues/12964) - Drop support for PostgreSQL From 148278a74afd0ccb292558774015aa42ad922374 Mon Sep 17 00:00:00 2001 From: Arthur Hanson Date: Thu, 22 Jun 2023 11:04:24 -0700 Subject: [PATCH 10/13] 12591 config params admin (#12904) * 12591 initial commit * 12591 detail view * 12591 add/edit view * 12591 edit button * 12591 base views and forms * 12591 form cleanup * 12591 form cleanup * 12591 form cleanup * 12591 review changes * 12591 move check for restrictedqueryset * 12591 restore view * 12591 restore page styling * 12591 remove admin * Remove edit view for ConfigRevision instances * Order ConfigRevisions by creation time * Correct permission name * Use RestrictedQuerySet for ConfigRevision * Fix redirect URL --------- Co-authored-by: Jeremy Stretch --- netbox/extras/admin.py | 129 +---------- netbox/extras/filtersets.py | 25 +++ netbox/extras/forms/__init__.py | 1 - netbox/extras/forms/config.py | 82 ------- netbox/extras/forms/filtersets.py | 7 + netbox/extras/forms/model_forms.py | 102 ++++++++- .../0093_configrevision_ordering.py | 17 ++ netbox/extras/models/models.py | 8 + netbox/extras/tables/tables.py | 24 +++ netbox/extras/urls.py | 9 +- netbox/extras/views.py | 69 ++++++ netbox/netbox/navigation/menu.py | 17 ++ .../admin/extras/configrevision/restore.html | 37 ---- netbox/templates/extras/configrevision.html | 200 ++++++++++++++++++ .../extras/configrevision_restore.html | 88 ++++++++ netbox/templates/generic/object.html | 4 +- 16 files changed, 567 insertions(+), 252 deletions(-) delete mode 100644 netbox/extras/forms/config.py create mode 100644 netbox/extras/migrations/0093_configrevision_ordering.py delete mode 100644 netbox/templates/admin/extras/configrevision/restore.html create mode 100644 netbox/templates/extras/configrevision.html create mode 100644 netbox/templates/extras/configrevision_restore.html diff --git a/netbox/extras/admin.py b/netbox/extras/admin.py index 6d1b14370..6e82ffc75 100644 --- a/netbox/extras/admin.py +++ b/netbox/extras/admin.py @@ -1,129 +1,2 @@ -from django.contrib import admin -from django.shortcuts import get_object_or_404, redirect -from django.template.response import TemplateResponse -from django.urls import path, reverse -from django.utils.html import format_html - -from netbox.config import get_config, PARAMS +# TODO: Removing this import triggers an import loop due to how form mixins are currently organized from .forms import ConfigRevisionForm -from .models import ConfigRevision - - -@admin.register(ConfigRevision) -class ConfigRevisionAdmin(admin.ModelAdmin): - fieldsets = [ - ('Rack Elevations', { - 'fields': ('RACK_ELEVATION_DEFAULT_UNIT_HEIGHT', 'RACK_ELEVATION_DEFAULT_UNIT_WIDTH'), - }), - ('Power', { - 'fields': ('POWERFEED_DEFAULT_VOLTAGE', 'POWERFEED_DEFAULT_AMPERAGE', 'POWERFEED_DEFAULT_MAX_UTILIZATION') - }), - ('IPAM', { - 'fields': ('ENFORCE_GLOBAL_UNIQUE', 'PREFER_IPV4'), - }), - ('Security', { - 'fields': ('ALLOWED_URL_SCHEMES',), - }), - ('Banners', { - 'fields': ('BANNER_LOGIN', 'BANNER_MAINTENANCE', 'BANNER_TOP', 'BANNER_BOTTOM'), - 'classes': ('monospace',), - }), - ('Pagination', { - 'fields': ('PAGINATE_COUNT', 'MAX_PAGE_SIZE'), - }), - ('Validation', { - 'fields': ('CUSTOM_VALIDATORS',), - 'classes': ('monospace',), - }), - ('User Preferences', { - 'fields': ('DEFAULT_USER_PREFERENCES',), - }), - ('Miscellaneous', { - 'fields': ('MAINTENANCE_MODE', 'GRAPHQL_ENABLED', 'CHANGELOG_RETENTION', 'JOB_RETENTION', 'MAPS_URL'), - }), - ('Config Revision', { - 'fields': ('comment',), - }) - ] - form = ConfigRevisionForm - list_display = ('id', 'is_active', 'created', 'comment', 'restore_link') - ordering = ('-id',) - readonly_fields = ('data',) - - def get_changeform_initial_data(self, request): - """ - Populate initial form data from the most recent ConfigRevision. - """ - latest_revision = ConfigRevision.objects.last() - initial = latest_revision.data if latest_revision else {} - initial.update(super().get_changeform_initial_data(request)) - - return initial - - # Permissions - - def has_add_permission(self, request): - # Only superusers may modify the configuration. - return request.user.is_superuser - - def has_change_permission(self, request, obj=None): - # ConfigRevisions cannot be modified once created. - return False - - def has_delete_permission(self, request, obj=None): - # Only inactive ConfigRevisions may be deleted (must be superuser). - return request.user.is_superuser and ( - obj is None or not obj.is_active() - ) - - # List display methods - - def restore_link(self, obj): - if obj.is_active(): - return '' - return format_html( - 'Restore', - url=reverse('admin:extras_configrevision_restore', args=(obj.pk,)) - ) - restore_link.short_description = "Actions" - - # URLs - - def get_urls(self): - urls = [ - path('/restore/', self.admin_site.admin_view(self.restore), name='extras_configrevision_restore'), - ] - - return urls + super().get_urls() - - # Views - - def restore(self, request, pk): - # Get the ConfigRevision being restored - candidate_config = get_object_or_404(ConfigRevision, pk=pk) - - if request.method == 'POST': - candidate_config.activate() - self.message_user(request, f"Restored configuration revision #{pk}") - - return redirect(reverse('admin:extras_configrevision_changelist')) - - # Get the current ConfigRevision - config_version = get_config().version - current_config = ConfigRevision.objects.filter(pk=config_version).first() - - params = [] - for param in PARAMS: - params.append(( - param.name, - current_config.data.get(param.name, None), - candidate_config.data.get(param.name, None) - )) - - context = self.admin_site.each_context(request) - context.update({ - 'object': candidate_config, - 'params': params, - }) - - return TemplateResponse(request, 'admin/extras/configrevision/restore.html', context) diff --git a/netbox/extras/filtersets.py b/netbox/extras/filtersets.py index 2cbaca5f7..6120e2a51 100644 --- a/netbox/extras/filtersets.py +++ b/netbox/extras/filtersets.py @@ -16,6 +16,7 @@ from .models import * __all__ = ( 'ConfigContextFilterSet', + 'ConfigRevisionFilterSet', 'ConfigTemplateFilterSet', 'ContentTypeFilterSet', 'CustomFieldFilterSet', @@ -557,3 +558,27 @@ class ContentTypeFilterSet(django_filters.FilterSet): Q(app_label__icontains=value) | Q(model__icontains=value) ) + + +# +# ConfigRevisions +# + +class ConfigRevisionFilterSet(BaseFilterSet): + q = django_filters.CharFilter( + method='search', + label=_('Search'), + ) + + class Meta: + model = ConfigRevision + fields = [ + 'id', + ] + + def search(self, queryset, name, value): + if not value.strip(): + return queryset + return queryset.filter( + Q(comment__icontains=value) + ) diff --git a/netbox/extras/forms/__init__.py b/netbox/extras/forms/__init__.py index 0825c9ca7..e203bee46 100644 --- a/netbox/extras/forms/__init__.py +++ b/netbox/extras/forms/__init__.py @@ -4,5 +4,4 @@ from .bulk_edit import * from .bulk_import import * from .misc import * from .mixins import * -from .config import * from .scripts import * diff --git a/netbox/extras/forms/config.py b/netbox/extras/forms/config.py deleted file mode 100644 index 4a7dba614..000000000 --- a/netbox/extras/forms/config.py +++ /dev/null @@ -1,82 +0,0 @@ -from django import forms -from django.conf import settings - -from netbox.config import get_config, PARAMS - -__all__ = ( - 'ConfigRevisionForm', -) - - -EMPTY_VALUES = ('', None, [], ()) - - -class FormMetaclass(forms.models.ModelFormMetaclass): - - def __new__(mcs, name, bases, attrs): - - # Emulate a declared field for each supported configuration parameter - param_fields = {} - for param in PARAMS: - field_kwargs = { - 'required': False, - 'label': param.label, - 'help_text': param.description, - } - field_kwargs.update(**param.field_kwargs) - param_fields[param.name] = param.field(**field_kwargs) - attrs.update(param_fields) - - return super().__new__(mcs, name, bases, attrs) - - -class ConfigRevisionForm(forms.BaseModelForm, metaclass=FormMetaclass): - """ - Form for creating a new ConfigRevision. - """ - class Meta: - widgets = { - 'comment': forms.Textarea(), - } - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - # Append current parameter values to form field help texts and check for static configurations - config = get_config() - for param in PARAMS: - value = getattr(config, param.name) - is_static = hasattr(settings, param.name) - if value: - help_text = self.fields[param.name].help_text - if help_text: - help_text += '
' # Line break - help_text += f'Current value: {value}' - if is_static: - help_text += ' (defined statically)' - elif value == param.default: - help_text += ' (default)' - self.fields[param.name].help_text = help_text - if is_static: - self.fields[param.name].disabled = True - - def save(self, commit=True): - instance = super().save(commit=False) - - # Populate JSON data on the instance - instance.data = self.render_json() - - if commit: - instance.save() - - return instance - - def render_json(self): - json = {} - - # Iterate through each field and populate non-empty values - for field_name in self.declared_fields: - if field_name in self.cleaned_data and self.cleaned_data[field_name] not in EMPTY_VALUES: - json[field_name] = self.cleaned_data[field_name] - - return json diff --git a/netbox/extras/forms/filtersets.py b/netbox/extras/forms/filtersets.py index 53de81ba2..bcf2e7863 100644 --- a/netbox/extras/forms/filtersets.py +++ b/netbox/extras/forms/filtersets.py @@ -18,6 +18,7 @@ from .mixins import SavedFiltersMixin __all__ = ( 'ConfigContextFilterForm', + 'ConfigRevisionFilterForm', 'ConfigTemplateFilterForm', 'CustomFieldFilterForm', 'CustomLinkFilterForm', @@ -444,3 +445,9 @@ class ObjectChangeFilterForm(SavedFiltersMixin, FilterForm): api_url='/api/extras/content-types/', ) ) + + +class ConfigRevisionFilterForm(SavedFiltersMixin, FilterForm): + fieldsets = ( + (None, ('q', 'filter_id')), + ) diff --git a/netbox/extras/forms/model_forms.py b/netbox/extras/forms/model_forms.py index 2f617b682..621052c96 100644 --- a/netbox/extras/forms/model_forms.py +++ b/netbox/extras/forms/model_forms.py @@ -1,6 +1,7 @@ import json from django import forms +from django.conf import settings from django.db.models import Q from django.contrib.contenttypes.models import ContentType from django.utils.translation import gettext as _ @@ -10,17 +11,20 @@ from dcim.models import DeviceRole, DeviceType, Location, Platform, Region, Site from extras.choices import * from extras.models import * from extras.utils import FeatureQuery +from netbox.config import get_config, PARAMS from netbox.forms import NetBoxModelForm from tenancy.models import Tenant, TenantGroup -from utilities.forms import BootstrapMixin, add_blank_choice +from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES, BootstrapMixin, add_blank_choice from utilities.forms.fields import ( CommentField, ContentTypeChoiceField, ContentTypeMultipleChoiceField, DynamicModelMultipleChoiceField, JSONField, SlugField, ) from virtualization.models import Cluster, ClusterGroup, ClusterType + __all__ = ( 'ConfigContextForm', + 'ConfigRevisionForm', 'ConfigTemplateForm', 'CustomFieldForm', 'CustomLinkForm', @@ -374,3 +378,99 @@ class JournalEntryForm(NetBoxModelForm): 'assigned_object_type': forms.HiddenInput, 'assigned_object_id': forms.HiddenInput, } + + +EMPTY_VALUES = ('', None, [], ()) + + +class ConfigFormMetaclass(forms.models.ModelFormMetaclass): + + def __new__(mcs, name, bases, attrs): + + # Emulate a declared field for each supported configuration parameter + param_fields = {} + for param in PARAMS: + field_kwargs = { + 'required': False, + 'label': param.label, + 'help_text': param.description, + } + field_kwargs.update(**param.field_kwargs) + param_fields[param.name] = param.field(**field_kwargs) + attrs.update(param_fields) + + return super().__new__(mcs, name, bases, attrs) + + +class ConfigRevisionForm(BootstrapMixin, forms.ModelForm, metaclass=ConfigFormMetaclass): + """ + Form for creating a new ConfigRevision. + """ + + fieldsets = ( + ('Rack Elevations', ('RACK_ELEVATION_DEFAULT_UNIT_HEIGHT', 'RACK_ELEVATION_DEFAULT_UNIT_WIDTH')), + ('Power', ('POWERFEED_DEFAULT_VOLTAGE', 'POWERFEED_DEFAULT_AMPERAGE', 'POWERFEED_DEFAULT_MAX_UTILIZATION')), + ('IPAM', ('ENFORCE_GLOBAL_UNIQUE', 'PREFER_IPV4')), + ('Security', ('ALLOWED_URL_SCHEMES',)), + ('Banners', ('BANNER_LOGIN', 'BANNER_MAINTENANCE', 'BANNER_TOP', 'BANNER_BOTTOM')), + ('Pagination', ('PAGINATE_COUNT', 'MAX_PAGE_SIZE')), + ('Validation', ('CUSTOM_VALIDATORS',)), + ('User Preferences', ('DEFAULT_USER_PREFERENCES',)), + ('Miscellaneous', ('MAINTENANCE_MODE', 'GRAPHQL_ENABLED', 'CHANGELOG_RETENTION', 'JOB_RETENTION', 'MAPS_URL')), + ('Config Revision', ('comment',)) + ) + + class Meta: + model = ConfigRevision + fields = '__all__' + widgets = { + 'BANNER_LOGIN': forms.Textarea(attrs={'class': 'font-monospace'}), + 'BANNER_MAINTENANCE': forms.Textarea(attrs={'class': 'font-monospace'}), + 'BANNER_TOP': forms.Textarea(attrs={'class': 'font-monospace'}), + 'BANNER_BOTTOM': forms.Textarea(attrs={'class': 'font-monospace'}), + 'CUSTOM_VALIDATORS': forms.Textarea(attrs={'class': 'font-monospace'}), + 'comment': forms.Textarea(), + } + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # Append current parameter values to form field help texts and check for static configurations + config = get_config() + for param in PARAMS: + value = getattr(config, param.name) + is_static = hasattr(settings, param.name) + if value: + help_text = self.fields[param.name].help_text + if help_text: + help_text += '
' # Line break + help_text += f'Current value: {value}' + if is_static: + help_text += ' (defined statically)' + elif value == param.default: + help_text += ' (default)' + self.fields[param.name].help_text = help_text + self.fields[param.name].initial = value + if is_static: + self.fields[param.name].disabled = True + + def save(self, commit=True): + instance = super().save(commit=False) + + # Populate JSON data on the instance + instance.data = self.render_json() + + if commit: + instance.save() + + return instance + + def render_json(self): + json = {} + + # Iterate through each field and populate non-empty values + for field_name in self.declared_fields: + if field_name in self.cleaned_data and self.cleaned_data[field_name] not in EMPTY_VALUES: + json[field_name] = self.cleaned_data[field_name] + + return json diff --git a/netbox/extras/migrations/0093_configrevision_ordering.py b/netbox/extras/migrations/0093_configrevision_ordering.py new file mode 100644 index 000000000..a4e875e6d --- /dev/null +++ b/netbox/extras/migrations/0093_configrevision_ordering.py @@ -0,0 +1,17 @@ +# Generated by Django 4.1.9 on 2023-06-22 14:14 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('extras', '0092_delete_jobresult'), + ] + + operations = [ + migrations.AlterModelOptions( + name='configrevision', + options={'ordering': ['-created']}, + ), + ] diff --git a/netbox/extras/models/models.py b/netbox/extras/models/models.py index 6c7aac08d..0cbc7a1de 100644 --- a/netbox/extras/models/models.py +++ b/netbox/extras/models/models.py @@ -612,6 +612,11 @@ class ConfigRevision(models.Model): verbose_name='Configuration data' ) + objects = RestrictedQuerySet.as_manager() + + class Meta: + ordering = ['-created'] + def __str__(self): return f'Config revision #{self.pk} ({self.created})' @@ -620,6 +625,9 @@ class ConfigRevision(models.Model): return self.data[item] return super().__getattribute__(item) + def get_absolute_url(self): + return reverse('extras:configrevision', args=[self.pk]) + def activate(self): """ Cache the configuration data. diff --git a/netbox/extras/tables/tables.py b/netbox/extras/tables/tables.py index 9e4924532..e41bc9126 100644 --- a/netbox/extras/tables/tables.py +++ b/netbox/extras/tables/tables.py @@ -9,6 +9,7 @@ from .template_code import * __all__ = ( 'ConfigContextTable', + 'ConfigRevisionTable', 'ConfigTemplateTable', 'CustomFieldTable', 'CustomLinkTable', @@ -30,6 +31,29 @@ IMAGEATTACHMENT_IMAGE = ''' {% endif %} ''' +REVISION_BUTTONS = """ +{% if not record.is_active %} + + + +{% endif %} +""" + + +class ConfigRevisionTable(NetBoxTable): + is_active = columns.BooleanColumn() + actions = columns.ActionsColumn( + actions=('delete',), + extra_buttons=REVISION_BUTTONS + ) + + class Meta(NetBoxTable.Meta): + model = ConfigRevision + fields = ( + 'pk', 'id', 'is_active', 'created', 'comment', + ) + default_columns = ('pk', 'id', 'is_active', 'created', 'comment') + class CustomFieldTable(NetBoxTable): name = tables.Column( diff --git a/netbox/extras/urls.py b/netbox/extras/urls.py index c4fc3d938..b3909391a 100644 --- a/netbox/extras/urls.py +++ b/netbox/extras/urls.py @@ -85,6 +85,13 @@ urlpatterns = [ path('journal-entries/import/', views.JournalEntryBulkImportView.as_view(), name='journalentry_import'), path('journal-entries//', include(get_model_urls('extras', 'journalentry'))), + # Config revisions + path('config-revisions/', views.ConfigRevisionListView.as_view(), name='configrevision_list'), + path('config-revisions/add/', views.ConfigRevisionEditView.as_view(), name='configrevision_add'), + path('config-revisions/delete/', views.ConfigRevisionBulkDeleteView.as_view(), name='configrevision_bulk_delete'), + path('config-revisions//restore/', views.ConfigRevisionRestoreView.as_view(), name='configrevision_restore'), + path('config-revisions//', include(get_model_urls('extras', 'configrevision'))), + # Change logging path('changelog/', views.ObjectChangeListView.as_view(), name='objectchange_list'), path('changelog//', include(get_model_urls('extras', 'objectchange'))), @@ -114,5 +121,5 @@ urlpatterns = [ path('scripts///jobs/', views.ScriptJobsView.as_view(), name='script_jobs'), # Markdown - path('render/markdown/', views.RenderMarkdownView.as_view(), name="render_markdown") + path('render/markdown/', views.RenderMarkdownView.as_view(), name="render_markdown"), ] diff --git a/netbox/extras/views.py b/netbox/extras/views.py index 6cbadf09d..9e02b5019 100644 --- a/netbox/extras/views.py +++ b/netbox/extras/views.py @@ -14,6 +14,7 @@ from core.models import Job from core.tables import JobTable from extras.dashboard.forms import DashboardWidgetAddForm, DashboardWidgetForm from extras.dashboard.utils import get_widget_class +from netbox.config import get_config, PARAMS from netbox.views import generic from utilities.forms import ConfirmationForm, get_field_value from utilities.htmx import is_htmx @@ -1176,6 +1177,74 @@ class ScriptResultView(ContentTypePermissionRequiredMixin, View): }) +# +# Config Revisions +# + +class ConfigRevisionListView(generic.ObjectListView): + queryset = ConfigRevision.objects.all() + filterset = filtersets.ConfigRevisionFilterSet + filterset_form = forms.ConfigRevisionFilterForm + table = tables.ConfigRevisionTable + + +@register_model_view(ConfigRevision) +class ConfigRevisionView(generic.ObjectView): + queryset = ConfigRevision.objects.all() + + +class ConfigRevisionEditView(generic.ObjectEditView): + queryset = ConfigRevision.objects.all() + form = forms.ConfigRevisionForm + + +@register_model_view(ConfigRevision, 'delete') +class ConfigRevisionDeleteView(generic.ObjectDeleteView): + queryset = ConfigRevision.objects.all() + + +class ConfigRevisionBulkDeleteView(generic.BulkDeleteView): + queryset = ConfigRevision.objects.all() + filterset = filtersets.ConfigRevisionFilterSet + table = tables.ConfigRevisionTable + + +class ConfigRevisionRestoreView(ContentTypePermissionRequiredMixin, View): + + def get_required_permission(self): + return 'extras.configrevision_edit' + + def get(self, request, pk): + candidate_config = get_object_or_404(ConfigRevision, pk=pk) + + # Get the current ConfigRevision + config_version = get_config().version + current_config = ConfigRevision.objects.filter(pk=config_version).first() + + params = [] + for param in PARAMS: + params.append(( + param.name, + current_config.data.get(param.name, None), + candidate_config.data.get(param.name, None) + )) + + return render(request, 'extras/configrevision_restore.html', { + 'object': candidate_config, + 'params': params, + }) + + def post(self, request, pk): + if not request.user.has_perm('extras.configrevision_edit'): + return HttpResponseForbidden() + + candidate_config = get_object_or_404(ConfigRevision, pk=pk) + candidate_config.activate() + messages.success(request, f"Restored configuration revision #{pk}") + + return redirect(candidate_config.get_absolute_url()) + + # # Markdown # diff --git a/netbox/netbox/navigation/menu.py b/netbox/netbox/navigation/menu.py index e009f62f1..100de16da 100644 --- a/netbox/netbox/navigation/menu.py +++ b/netbox/netbox/navigation/menu.py @@ -346,6 +346,22 @@ OPERATIONS_MENU = Menu( ), ) +ADMIN_MENU = Menu( + label=_('Admin'), + icon_class='mdi mdi-account-multiple', + groups=( + MenuGroup( + label=_('Configuration'), + items=( + MenuItem( + link='extras:configrevision_list', + link_text=_('Config Revisions'), + permissions=['extras.view_configrevision'] + ), + ), + ), + ), +) MENUS = [ ORGANIZATION_MENU, @@ -360,6 +376,7 @@ MENUS = [ PROVISIONING_MENU, CUSTOMIZATION_MENU, OPERATIONS_MENU, + ADMIN_MENU, ] # diff --git a/netbox/templates/admin/extras/configrevision/restore.html b/netbox/templates/admin/extras/configrevision/restore.html deleted file mode 100644 index 4a0eb81a6..000000000 --- a/netbox/templates/admin/extras/configrevision/restore.html +++ /dev/null @@ -1,37 +0,0 @@ -{% extends "admin/base_site.html" %} -{% load static %} - -{% block content %} -

Restore configuration #{{ object.pk }} from {{ object.created }}?

- - - - - - - - - - - - {% for param, current, new in params %} - - - - - - - {% endfor %} - -
ParameterCurrent ValueNew Value
{{ param }}{{ current }}{{ new }}{% if current != new %}*{% endif %}
- -
- {% csrf_token %} -
- - Cancel -
-
-{% endblock content %} - - diff --git a/netbox/templates/extras/configrevision.html b/netbox/templates/extras/configrevision.html new file mode 100644 index 000000000..1c7eeb2dd --- /dev/null +++ b/netbox/templates/extras/configrevision.html @@ -0,0 +1,200 @@ +{% extends 'generic/object.html' %} +{% load buttons %} +{% load custom_links %} +{% load helpers %} +{% load perms %} +{% load plugins %} +{% load static %} + +{% block breadcrumbs %} +{% endblock %} + +{% block controls %} +
+
+ {% plugin_buttons object %} +
+
+ {% custom_links object %} +
+
+{% endblock controls %} + +{% block content %} +
+
+
+
Rack Elevation
+
+ + + + + + + + + +
Rack elevation default unit height:{{ object.data.RACK_ELEVATION_DEFAULT_UNIT_HEIGHT }}
Rack elevation default unit width:{{ object.data.RACK_ELEVATION_DEFAULT_UNIT_WIDTH }}
+
+
+ +
+
Power
+
+ + + + + + + + + + + + + +
Powerfeed default voltage:{{ object.data.POWERFEED_DEFAULT_VOLTAGE }}
Powerfeed default amperage:{{ object.data.POWERFEED_DEFAULT_AMPERAGE }}
Powerfeed default max utilization:{{ object.data.POWERFEED_DEFAULT_MAX_UTILIZATION }}
+
+
+ +
+
IPAM
+
+ + + + + + + + + +
IPAM enforce global unique:{{ object.data.ENFORCE_GLOBAL_UNIQUE }}
IPAM prefer IPV4:{{ object.data.PREFER_IPV4 }}
+
+
+ +
+
Security
+
+ + + + + +
Allowed URL schemes:{{ object.data.ALLOWED_URL_SCHEMES }}
+
+
+ +
+
Banners
+
+ + + + + + + + + + + + + + + + + +
Login banner:{{ object.data.BANNER_LOGIN }}
Maintenance banner:{{ object.data.BANNER_MAINTENANCE }}
Top banner:{{ object.data.BANNER_TOP }}
Bottom banner:{{ object.data.BANNER_BOTTOM }}
+
+
+ + +
+
+ +
+
Pagination
+
+ + + + + + + + + +
Paginate count:{{ object.data.PAGINATE_COUNT }}
Max page size:{{ object.data.MAX_PAGE_SIZE }}
+
+
+ +
+
Validation
+
+ + + + + +
Custom validators:{{ object.data.CUSTOM_VALIDATORS }}
+
+
+ +
+
User Preferences
+
+ + + + + +
Default user preferences:{{ object.data.DEFAULT_USER_PREFERENCES }}
+
+
+ +
+
Miscellaneous
+
+ + + + + + + + + + + + + + + + + + + + + +
Maintenance mode:{{ object.data.MAINTENANCE_MODE }}
GraphQL enabled:{{ object.data.GRAPHQL_ENABLED }}
Changelog retention:{{ object.data.CHANGELOG_RETENTION }}
Job retention:{{ object.data.JOB_RETENTION }}
Maps URL:{{ object.data.MAPS_URL }}
+
+
+ +
+
Config Revision
+
+ + + + + +
Comment:{{ object.comment }}
+
+
+ +
+
+{% endblock %} diff --git a/netbox/templates/extras/configrevision_restore.html b/netbox/templates/extras/configrevision_restore.html new file mode 100644 index 000000000..ac22f8cbd --- /dev/null +++ b/netbox/templates/extras/configrevision_restore.html @@ -0,0 +1,88 @@ +{% extends 'base/layout.html' %} +{% load helpers %} +{% load buttons %} +{% load perms %} +{% load static %} + +{% block title %}Restore: {{ object }}{% endblock %} + +{% block subtitle %} +
+ Created {{ object.created|annotated_date }} +
+{% endblock %} + +{% block header %} +
+ +
+ {{ block.super }} +{% endblock header %} + +{% block controls %} +
+
+ {% if request.user|can_delete:job %} + {% delete_button job %} + {% endif %} +
+
+{% endblock controls %} + +{% block tabs %} + +{% endblock %} + +{% block content %} +
+
+ + + + + + + + + + + {% for param, current, new in params %} + + + + + + + {% endfor %} + +
ParameterCurrent ValueNew Value
{{ param }}{{ current }}{{ new }}{% if current != new %}*{% endif %}
+
+
+ +
+ {% csrf_token %} +
+
+
+ + Cancel +
+
+
+
+ +{% endblock content %} + +{% block modals %} +{% endblock modals %} diff --git a/netbox/templates/generic/object.html b/netbox/templates/generic/object.html index d3a617455..ebbeb2dfc 100644 --- a/netbox/templates/generic/object.html +++ b/netbox/templates/generic/object.html @@ -38,7 +38,7 @@ Context: {{ block.super }} -{% endblock %} +{% endblock header %} {% block title %}{{ object }}{% endblock %} @@ -48,7 +48,7 @@ Context: · Updated {{ object.last_updated|timesince }} ago -{% endblock %} +{% endblock subtitle %} {% block controls %} {# Clone/Edit/Delete Buttons #} From 69b818ed33e0779e147af3824db49f0d8c9b7440 Mon Sep 17 00:00:00 2001 From: Arthur Hanson Date: Fri, 23 Jun 2023 07:38:08 -0700 Subject: [PATCH 11/13] 12237 update to Django 4.2 / psycopg3 (#12916) * 12237 upgrade django and psycopg * 12237 add migration * 12237 rename migration * 12237 update requirements * 12237 fix migration * Update base requirements --------- Co-authored-by: Jeremy Stretch --- base_requirements.txt | 6 +++--- netbox/core/models/data.py | 4 +++- .../migrations/0093_tagged_item_indexes.py | 17 +++++++++++++++++ netbox/extras/models/tags.py | 4 +--- requirements.txt | 4 ++-- 5 files changed, 26 insertions(+), 9 deletions(-) create mode 100644 netbox/extras/migrations/0093_tagged_item_indexes.py diff --git a/base_requirements.txt b/base_requirements.txt index 40e0224e2..2d8055049 100644 --- a/base_requirements.txt +++ b/base_requirements.txt @@ -8,7 +8,7 @@ boto3 # The Python web framework on which NetBox is built # https://docs.djangoproject.com/en/stable/releases/ -Django<4.2 +Django<5.0 # Django middleware which permits cross-domain API requests # https://github.com/adamchainz/django-cors-headers/blob/main/CHANGELOG.rst @@ -121,8 +121,8 @@ netaddr Pillow # PostgreSQL database adapter for Python -# https://www.psycopg.org/docs/news.html -psycopg2-binary +# https://github.com/psycopg/psycopg/blob/master/docs/news.rst +psycopg[binary,pool] # YAML rendering library # https://github.com/yaml/pyyaml/blob/master/CHANGES diff --git a/netbox/core/models/data.py b/netbox/core/models/data.py index 2af697f60..a2a20f858 100644 --- a/netbox/core/models/data.py +++ b/netbox/core/models/data.py @@ -290,8 +290,10 @@ class DataFile(models.Model): @property def data_as_string(self): + if not self.data: + return None try: - return self.data.tobytes().decode('utf-8') + return bytes(self.data, 'utf-8') except UnicodeDecodeError: return None diff --git a/netbox/extras/migrations/0093_tagged_item_indexes.py b/netbox/extras/migrations/0093_tagged_item_indexes.py new file mode 100644 index 000000000..6e24e362b --- /dev/null +++ b/netbox/extras/migrations/0093_tagged_item_indexes.py @@ -0,0 +1,17 @@ +# Generated by Django 4.2.2 on 2023-06-14 23:26 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ('extras', '0093_configrevision_ordering'), + ] + + operations = [ + migrations.RenameIndex( + model_name='taggeditem', + new_name='extras_tagg_content_717743_idx', + old_fields=('content_type', 'object_id'), + ), + ] diff --git a/netbox/extras/models/tags.py b/netbox/extras/models/tags.py index 066c0fd78..31128ad54 100644 --- a/netbox/extras/models/tags.py +++ b/netbox/extras/models/tags.py @@ -61,6 +61,4 @@ class TaggedItem(GenericTaggedItemBase): ) class Meta: - index_together = ( - ("content_type", "object_id") - ) + indexes = [models.Index(fields=["content_type", "object_id"])] diff --git a/requirements.txt b/requirements.txt index e6e56ce56..2ffcd852b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,6 @@ bleach==6.0.0 boto3==1.26.156 -Django==4.1.9 +Django==4.2.2 django-cors-headers==4.1.0 django-debug-toolbar==4.1.0 django-filter==23.2 @@ -27,7 +27,7 @@ mkdocs-material==9.1.16 mkdocstrings[python-legacy]==0.22.0 netaddr==0.8.0 Pillow==9.5.0 -psycopg2-binary==2.9.6 +psycopg[binary,pool]==3.1.9 PyYAML==6.0 sentry-sdk==1.25.1 social-auth-app-django==5.2.0 From 1056e513b13968238610e39310cc3d4a7fb967f5 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 23 Jun 2023 14:08:14 -0400 Subject: [PATCH 12/13] Closes #11541: Support for limiting tag assignments by object type (#12982) * Initial work on #11541 * Merge migrations * Limit tags by object type during assignment * Add tests for object type validation * Fix form field parameters --- docs/models/extras/tag.md | 8 +++++++ netbox/extras/api/serializers.py | 8 ++++++- netbox/extras/filtersets.py | 10 +++++++- netbox/extras/forms/filtersets.py | 5 ++++ netbox/extras/forms/model_forms.py | 9 ++++++-- .../migrations/0093_tagged_item_indexes.py | 17 -------------- .../migrations/0094_tag_object_types.py | 23 +++++++++++++++++++ netbox/extras/models/tags.py | 13 ++++++++++- netbox/extras/signals.py | 21 ++++++++++++++++- netbox/extras/tables/tables.py | 6 ++++- netbox/extras/tests/test_filtersets.py | 18 +++++++++++++++ netbox/extras/tests/test_models.py | 18 +++++++++++++++ netbox/netbox/forms/base.py | 7 ++++++ netbox/templates/extras/tag.html | 20 +++++++++++++--- 14 files changed, 156 insertions(+), 27 deletions(-) delete mode 100644 netbox/extras/migrations/0093_tagged_item_indexes.py create mode 100644 netbox/extras/migrations/0094_tag_object_types.py diff --git a/docs/models/extras/tag.md b/docs/models/extras/tag.md index 97ebd9d72..684be582e 100644 --- a/docs/models/extras/tag.md +++ b/docs/models/extras/tag.md @@ -15,3 +15,11 @@ A unique URL-friendly identifier. (This value will be used for filtering.) This ### Color The color to use when displaying the tag in the NetBox UI. + +### Object Types + +!!! info "This feature was introduced in NetBox v3.6." + +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. + +If no object types are specified, the tag will be assignable to any type of object. diff --git a/netbox/extras/api/serializers.py b/netbox/extras/api/serializers.py index a02e933ba..c71e840d5 100644 --- a/netbox/extras/api/serializers.py +++ b/netbox/extras/api/serializers.py @@ -196,12 +196,18 @@ class SavedFilterSerializer(ValidatedModelSerializer): class TagSerializer(ValidatedModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='extras-api:tag-detail') + object_types = ContentTypeField( + queryset=ContentType.objects.filter(FeatureQuery('custom_fields').get_query()), + many=True, + required=False + ) tagged_items = serializers.IntegerField(read_only=True) class Meta: model = Tag fields = [ - 'id', 'url', 'display', 'name', 'slug', 'color', 'description', 'tagged_items', 'created', 'last_updated', + 'id', 'url', 'display', 'name', 'slug', 'color', 'description', 'object_types', 'tagged_items', 'created', + 'last_updated', ] diff --git a/netbox/extras/filtersets.py b/netbox/extras/filtersets.py index 6120e2a51..acb0aa359 100644 --- a/netbox/extras/filtersets.py +++ b/netbox/extras/filtersets.py @@ -258,10 +258,13 @@ class TagFilterSet(ChangeLoggedModelFilterSet): content_type_id = MultiValueNumberFilter( method='_content_type_id' ) + for_object_type_id = MultiValueNumberFilter( + method='_for_object_type' + ) class Meta: model = Tag - fields = ['id', 'name', 'slug', 'color', 'description'] + fields = ['id', 'name', 'slug', 'color', 'description', 'object_types'] def search(self, queryset, name, value): if not value.strip(): @@ -298,6 +301,11 @@ class TagFilterSet(ChangeLoggedModelFilterSet): return queryset.filter(extras_taggeditem_items__content_type__in=content_types).distinct() + def _for_object_type(self, queryset, name, values): + return queryset.filter( + Q(object_types__id__in=values) | Q(object_types__isnull=True) + ) + class ConfigContextFilterSet(ChangeLoggedModelFilterSet): q = django_filters.CharFilter( diff --git a/netbox/extras/forms/filtersets.py b/netbox/extras/forms/filtersets.py index bcf2e7863..56e9c8dfb 100644 --- a/netbox/extras/forms/filtersets.py +++ b/netbox/extras/forms/filtersets.py @@ -245,6 +245,11 @@ class TagFilterForm(SavedFiltersMixin, FilterForm): required=False, label=_('Tagged object type') ) + for_object_type_id = ContentTypeChoiceField( + queryset=ContentType.objects.filter(FeatureQuery('tags').get_query()), + required=False, + label=_('Allowed object type') + ) class ConfigContextFilterForm(SavedFiltersMixin, FilterForm): diff --git a/netbox/extras/forms/model_forms.py b/netbox/extras/forms/model_forms.py index 621052c96..f8aa982bc 100644 --- a/netbox/extras/forms/model_forms.py +++ b/netbox/extras/forms/model_forms.py @@ -204,15 +204,20 @@ class WebhookForm(BootstrapMixin, forms.ModelForm): class TagForm(BootstrapMixin, forms.ModelForm): slug = SlugField() + object_types = ContentTypeMultipleChoiceField( + queryset=ContentType.objects.all(), + limit_choices_to=FeatureQuery('tags'), + required=False + ) fieldsets = ( - ('Tag', ('name', 'slug', 'color', 'description')), + ('Tag', ('name', 'slug', 'color', 'description', 'object_types')), ) class Meta: model = Tag fields = [ - 'name', 'slug', 'color', 'description' + 'name', 'slug', 'color', 'description', 'object_types', ] diff --git a/netbox/extras/migrations/0093_tagged_item_indexes.py b/netbox/extras/migrations/0093_tagged_item_indexes.py deleted file mode 100644 index 6e24e362b..000000000 --- a/netbox/extras/migrations/0093_tagged_item_indexes.py +++ /dev/null @@ -1,17 +0,0 @@ -# Generated by Django 4.2.2 on 2023-06-14 23:26 - -from django.db import migrations - - -class Migration(migrations.Migration): - dependencies = [ - ('extras', '0093_configrevision_ordering'), - ] - - operations = [ - migrations.RenameIndex( - model_name='taggeditem', - new_name='extras_tagg_content_717743_idx', - old_fields=('content_type', 'object_id'), - ), - ] diff --git a/netbox/extras/migrations/0094_tag_object_types.py b/netbox/extras/migrations/0094_tag_object_types.py new file mode 100644 index 000000000..944ef64b2 --- /dev/null +++ b/netbox/extras/migrations/0094_tag_object_types.py @@ -0,0 +1,23 @@ +from django.db import migrations, models +import extras.utils + + +class Migration(migrations.Migration): + + dependencies = [ + ('contenttypes', '0002_remove_content_type_name'), + ('extras', '0093_configrevision_ordering'), + ] + + operations = [ + migrations.AddField( + model_name='tag', + name='object_types', + field=models.ManyToManyField(blank=True, limit_choices_to=extras.utils.FeatureQuery('tags'), related_name='+', to='contenttypes.contenttype'), + ), + migrations.RenameIndex( + model_name='taggeditem', + new_name='extras_tagg_content_717743_idx', + old_fields=('content_type', 'object_id'), + ), + ] diff --git a/netbox/extras/models/tags.py b/netbox/extras/models/tags.py index 31128ad54..f54b3d0fe 100644 --- a/netbox/extras/models/tags.py +++ b/netbox/extras/models/tags.py @@ -1,9 +1,13 @@ from django.conf import settings +from django.contrib.contenttypes.models import ContentType +from django.core.exceptions import ValidationError from django.db import models from django.urls import reverse from django.utils.text import slugify +from django.utils.translation import gettext as _ from taggit.models import TagBase, GenericTaggedItemBase +from extras.utils import FeatureQuery from netbox.models import ChangeLoggedModel from netbox.models.features import CloningMixin, ExportTemplatesMixin from utilities.choices import ColorChoices @@ -30,9 +34,16 @@ class Tag(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel, TagBase): max_length=200, blank=True, ) + object_types = models.ManyToManyField( + to=ContentType, + related_name='+', + limit_choices_to=FeatureQuery('tags'), + blank=True, + help_text=_("The object type(s) to which this this tag can be applied.") + ) clone_fields = ( - 'color', 'description', + 'color', 'description', 'object_types', ) class Meta: diff --git a/netbox/extras/signals.py b/netbox/extras/signals.py index 4972d9e85..d6550309f 100644 --- a/netbox/extras/signals.py +++ b/netbox/extras/signals.py @@ -10,8 +10,9 @@ from extras.validators import CustomValidator from netbox.config import get_config from netbox.context import current_request, webhooks_queue from netbox.signals import post_clean +from utilities.exceptions import AbortRequest from .choices import ObjectChangeActionChoices -from .models import ConfigRevision, CustomField, ObjectChange +from .models import ConfigRevision, CustomField, ObjectChange, TaggedItem from .webhooks import enqueue_object, get_snapshots, serialize_for_webhook # @@ -207,3 +208,21 @@ def update_config(sender, instance, **kwargs): Update the cached NetBox configuration when a new ConfigRevision is created. """ instance.activate() + + +# +# Tags +# + +@receiver(m2m_changed, sender=TaggedItem) +def validate_assigned_tags(sender, instance, action, model, pk_set, **kwargs): + """ + Validate that any Tags being assigned to the instance are not restricted to non-applicable object types. + """ + if action != 'pre_add': + return + ct = ContentType.objects.get_for_model(instance) + # Retrieve any applied Tags that are restricted to certain object_types + for tag in model.objects.filter(pk__in=pk_set, object_types__isnull=False).prefetch_related('object_types'): + if ct not in tag.object_types.all(): + raise AbortRequest(f"Tag {tag} cannot be assigned to {ct.model} objects.") diff --git a/netbox/extras/tables/tables.py b/netbox/extras/tables/tables.py index e41bc9126..35d53d1a6 100644 --- a/netbox/extras/tables/tables.py +++ b/netbox/extras/tables/tables.py @@ -210,10 +210,14 @@ class TagTable(NetBoxTable): linkify=True ) color = columns.ColorColumn() + object_types = columns.ContentTypesColumn() class Meta(NetBoxTable.Meta): model = Tag - fields = ('pk', 'id', 'name', 'items', 'slug', 'color', 'description', 'created', 'last_updated', 'actions') + fields = ( + 'pk', 'id', 'name', 'items', 'slug', 'color', 'description', 'object_types', 'created', 'last_updated', + 'actions', + ) default_columns = ('pk', 'name', 'items', 'slug', 'color', 'description') diff --git a/netbox/extras/tests/test_filtersets.py b/netbox/extras/tests/test_filtersets.py index 992643530..7dff14cc0 100644 --- a/netbox/extras/tests/test_filtersets.py +++ b/netbox/extras/tests/test_filtersets.py @@ -821,6 +821,10 @@ class TagTestCase(TestCase, ChangeLoggedFilterSetTests): @classmethod def setUpTestData(cls): + content_types = { + 'site': ContentType.objects.get_by_natural_key('dcim', 'site'), + 'provider': ContentType.objects.get_by_natural_key('circuits', 'provider'), + } tags = ( Tag(name='Tag 1', slug='tag-1', color='ff0000', description='foobar1'), @@ -828,6 +832,8 @@ class TagTestCase(TestCase, ChangeLoggedFilterSetTests): Tag(name='Tag 3', slug='tag-3', color='0000ff'), ) Tag.objects.bulk_create(tags) + tags[0].object_types.add(content_types['site']) + tags[1].object_types.add(content_types['provider']) # Apply some tags so we can filter by content type site = Site.objects.create(name='Site 1', slug='site-1') @@ -860,6 +866,18 @@ class TagTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'content_type_id': [site_ct, provider_ct]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_object_types(self): + params = {'for_object_type_id': [ContentType.objects.get_by_natural_key('dcim', 'site').pk]} + self.assertEqual( + list(self.filterset(params, self.queryset).qs.values_list('name', flat=True)), + ['Tag 1', 'Tag 3'] + ) + params = {'for_object_type_id': [ContentType.objects.get_by_natural_key('circuits', 'provider').pk]} + self.assertEqual( + list(self.filterset(params, self.queryset).qs.values_list('name', flat=True)), + ['Tag 2', 'Tag 3'] + ) + class ObjectChangeTestCase(TestCase, BaseFilterSetTests): queryset = ObjectChange.objects.all() diff --git a/netbox/extras/tests/test_models.py b/netbox/extras/tests/test_models.py index 0ac63c086..0d1dc0e51 100644 --- a/netbox/extras/tests/test_models.py +++ b/netbox/extras/tests/test_models.py @@ -1,8 +1,10 @@ +from django.contrib.contenttypes.models import ContentType from django.test import TestCase from dcim.models import Device, DeviceRole, DeviceType, Location, Manufacturer, Platform, Region, Site, SiteGroup from extras.models import ConfigContext, Tag from tenancy.models import Tenant, TenantGroup +from utilities.exceptions import AbortRequest from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine @@ -14,6 +16,22 @@ class TagTest(TestCase): self.assertEqual(tag.slug, 'testing-unicode-台灣') + def test_object_type_validation(self): + region = Region.objects.create(name='Region 1', slug='region-1') + sitegroup = SiteGroup.objects.create(name='Site Group 1', slug='site-group-1') + + # Create a Tag that can only be applied to Regions + tag = Tag.objects.create(name='Tag 1', slug='tag-1') + tag.object_types.add(ContentType.objects.get_by_natural_key('dcim', 'region')) + + # Apply the Tag to a Region + region.tags.add(tag) + self.assertIn(tag, region.tags.all()) + + # Apply the Tag to a SiteGroup + with self.assertRaises(AbortRequest): + sitegroup.tags.add(tag) + class ConfigContextTest(TestCase): """ diff --git a/netbox/netbox/forms/base.py b/netbox/netbox/forms/base.py index 83c238e0f..88cec405f 100644 --- a/netbox/netbox/forms/base.py +++ b/netbox/netbox/forms/base.py @@ -31,6 +31,13 @@ class NetBoxModelForm(BootstrapMixin, CustomFieldsMixin, forms.ModelForm): required=False ) + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # Limit tags to those applicable to the object type + if (ct := self._get_content_type()) and hasattr(self.fields['tags'].widget, 'add_query_param'): + self.fields['tags'].widget.add_query_param('for_object_type_id', ct.pk) + def _get_content_type(self): return ContentType.objects.get_for_model(self._meta.model) diff --git a/netbox/templates/extras/tag.html b/netbox/templates/extras/tag.html index 6e4c5aee9..e5aa5cc75 100644 --- a/netbox/templates/extras/tag.html +++ b/netbox/templates/extras/tag.html @@ -43,9 +43,23 @@
-
- Tagged Item Types -
+
Allowed Object Types
+
+ + {% for ct in object.object_types.all %} + + + + {% empty %} + + + + {% endfor %} +
{{ ct }}
Any
+
+
+
+
Tagged Item Types
{% for object_type in object_types %} From 6e222f8dce8dcef928729ea0f1e846cc8fe6ad05 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 29 Jun 2023 14:36:11 -0400 Subject: [PATCH 13/13] Closes #8248: User bookmarks (#13035) * Initial work on #8248 * Add tests * Fix tests * Add feature query for bookmarks * Add BookmarksWidget * Correct generic relation name * Add docs for bookmarks * Remove inheritance from ChangeLoggedModel --- docs/features/customization.md | 4 ++ docs/models/extras/bookmark.md | 13 ++++ mkdocs.yml | 1 + netbox/extras/api/nested_serializers.py | 9 +++ netbox/extras/api/serializers.py | 25 +++++++ netbox/extras/api/urls.py | 1 + netbox/extras/api/views.py | 11 +++ netbox/extras/choices.py | 17 ++++- netbox/extras/dashboard/widgets.py | 41 +++++++++++ netbox/extras/filtersets.py | 21 ++++++ netbox/extras/forms/model_forms.py | 14 +++- netbox/extras/migrations/0095_bookmarks.py | 34 +++++++++ netbox/extras/models/models.py | 40 ++++++++++- netbox/extras/tables/tables.py | 16 +++++ netbox/extras/tests/test_api.py | 52 ++++++++++++++ netbox/extras/tests/test_filtersets.py | 71 +++++++++++++++++++ netbox/extras/tests/test_views.py | 48 +++++++++++++ netbox/extras/urls.py | 7 +- netbox/extras/views.py | 29 ++++++++ netbox/netbox/models/__init__.py | 1 + netbox/netbox/models/features.py | 16 +++++ .../extras/dashboard/widgets/bookmarks.html | 9 +++ netbox/templates/generic/object.html | 3 + netbox/templates/inc/profile_button.html | 5 ++ netbox/templates/users/base.html | 3 + netbox/templates/users/bookmarks.html | 34 +++++++++ netbox/users/urls.py | 1 + netbox/users/views.py | 22 +++++- .../utilities/templates/buttons/bookmark.html | 15 ++++ netbox/utilities/templatetags/buttons.py | 34 ++++++++- 30 files changed, 590 insertions(+), 7 deletions(-) create mode 100644 docs/models/extras/bookmark.md create mode 100644 netbox/extras/migrations/0095_bookmarks.py create mode 100644 netbox/templates/extras/dashboard/widgets/bookmarks.html create mode 100644 netbox/templates/users/bookmarks.html create mode 100644 netbox/utilities/templates/buttons/bookmark.html diff --git a/docs/features/customization.md b/docs/features/customization.md index abce4bcba..1fbace3c5 100644 --- a/docs/features/customization.md +++ b/docs/features/customization.md @@ -18,6 +18,10 @@ The `tag` filter can be specified multiple times to match only objects which hav GET /api/dcim/devices/?tag=monitored&tag=deprecated ``` +## Bookmarks + +Users can bookmark their most commonly visited objects for convenient access. Bookmarks are listed under a user's profile, and can be displayed with custom filtering and ordering on the user's personal dashboard. + ## Custom Fields While NetBox provides a rather extensive data model out of the box, the need may arise to store certain additional data associated with NetBox objects. For example, you might need to record the invoice ID alongside an installed device, or record an approving authority when creating a new IP prefix. NetBox administrators can create custom fields on built-in objects to meet these needs. diff --git a/docs/models/extras/bookmark.md b/docs/models/extras/bookmark.md new file mode 100644 index 000000000..1fd006be9 --- /dev/null +++ b/docs/models/extras/bookmark.md @@ -0,0 +1,13 @@ +# Bookmarks + +A user can bookmark individual objects for convenient access. Bookmarks are listed under a user's profile and can be displayed using a dashboard widget. + +## Fields + +### User + +The user to whom the bookmark belongs. + +### Object + +The bookmarked object. diff --git a/mkdocs.yml b/mkdocs.yml index 6be33d592..cde4a0acd 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -206,6 +206,7 @@ nav: - VirtualChassis: 'models/dcim/virtualchassis.md' - VirtualDeviceContext: 'models/dcim/virtualdevicecontext.md' - Extras: + - Bookmark: 'models/extras/bookmark.md' - Branch: 'models/extras/branch.md' - ConfigContext: 'models/extras/configcontext.md' - ConfigTemplate: 'models/extras/configtemplate.md' diff --git a/netbox/extras/api/nested_serializers.py b/netbox/extras/api/nested_serializers.py index 29ef67943..4271e1748 100644 --- a/netbox/extras/api/nested_serializers.py +++ b/netbox/extras/api/nested_serializers.py @@ -4,6 +4,7 @@ from extras import models from netbox.api.serializers import NestedTagSerializer, WritableNestedSerializer __all__ = [ + 'NestedBookmarkSerializer', 'NestedConfigContextSerializer', 'NestedConfigTemplateSerializer', 'NestedCustomFieldSerializer', @@ -73,6 +74,14 @@ class NestedSavedFilterSerializer(WritableNestedSerializer): fields = ['id', 'url', 'display', 'name', 'slug'] +class NestedBookmarkSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='extras-api:bookmark-detail') + + class Meta: + model = models.Bookmark + fields = ['id', 'url', 'display', 'object_id', 'object_type'] + + class NestedImageAttachmentSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='extras-api:imageattachment-detail') diff --git a/netbox/extras/api/serializers.py b/netbox/extras/api/serializers.py index c71e840d5..f28a5c411 100644 --- a/netbox/extras/api/serializers.py +++ b/netbox/extras/api/serializers.py @@ -31,6 +31,7 @@ from virtualization.models import Cluster, ClusterGroup, ClusterType from .nested_serializers import * __all__ = ( + 'BookmarkSerializer', 'ConfigContextSerializer', 'ConfigTemplateSerializer', 'ContentTypeSerializer', @@ -190,6 +191,30 @@ class SavedFilterSerializer(ValidatedModelSerializer): ] +# +# Bookmarks +# + +class BookmarkSerializer(ValidatedModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='extras-api:bookmark-detail') + object_type = ContentTypeField( + queryset=ContentType.objects.filter(FeatureQuery('bookmarks').get_query()), + ) + object = serializers.SerializerMethodField(read_only=True) + user = NestedUserSerializer() + + class Meta: + model = Bookmark + fields = [ + 'id', 'url', 'display', 'object_type', 'object_id', 'object', 'user', 'created', + ] + + @extend_schema_field(serializers.JSONField(allow_null=True)) + def get_object(self, instance): + serializer = get_serializer_for_model(instance.object, prefix=NESTED_SERIALIZER_PREFIX) + return serializer(instance.object, context={'request': self.context['request']}).data + + # # Tags # diff --git a/netbox/extras/api/urls.py b/netbox/extras/api/urls.py index 80dc56ae1..6e610097f 100644 --- a/netbox/extras/api/urls.py +++ b/netbox/extras/api/urls.py @@ -12,6 +12,7 @@ router.register('custom-fields', views.CustomFieldViewSet) router.register('custom-links', views.CustomLinkViewSet) router.register('export-templates', views.ExportTemplateViewSet) router.register('saved-filters', views.SavedFilterViewSet) +router.register('bookmarks', views.BookmarkViewSet) router.register('tags', views.TagViewSet) router.register('image-attachments', views.ImageAttachmentViewSet) router.register('journal-entries', views.JournalEntryViewSet) diff --git a/netbox/extras/api/views.py b/netbox/extras/api/views.py index 3f796d7f8..3c7e6bfcc 100644 --- a/netbox/extras/api/views.py +++ b/netbox/extras/api/views.py @@ -93,6 +93,17 @@ class SavedFilterViewSet(NetBoxModelViewSet): filterset_class = filtersets.SavedFilterFilterSet +# +# Bookmarks +# + +class BookmarkViewSet(NetBoxModelViewSet): + metadata_class = ContentTypeMetadata + queryset = Bookmark.objects.all() + serializer_class = serializers.BookmarkSerializer + filterset_class = filtersets.BookmarkFilterSet + + # # Tags # diff --git a/netbox/extras/choices.py b/netbox/extras/choices.py index 63bdbf7db..a8dc40bf0 100644 --- a/netbox/extras/choices.py +++ b/netbox/extras/choices.py @@ -79,6 +79,21 @@ class CustomLinkButtonClassChoices(ButtonColorChoices): (LINK, 'Link'), ) + +# +# Bookmarks +# + +class BookmarkOrderingChoices(ChoiceSet): + + ORDERING_NEWEST = '-created' + ORDERING_OLDEST = 'created' + + CHOICES = ( + (ORDERING_NEWEST, 'Newest'), + (ORDERING_OLDEST, 'Oldest'), + ) + # # ObjectChanges # @@ -98,7 +113,7 @@ class ObjectChangeActionChoices(ChoiceSet): # -# Jounral entries +# Journal entries # class JournalEntryKindChoices(ChoiceSet): diff --git a/netbox/extras/dashboard/widgets.py b/netbox/extras/dashboard/widgets.py index b3a4d090c..3b9ce6c46 100644 --- a/netbox/extras/dashboard/widgets.py +++ b/netbox/extras/dashboard/widgets.py @@ -15,6 +15,7 @@ from django.template.loader import render_to_string from django.urls import NoReverseMatch, resolve, reverse from django.utils.translation import gettext as _ +from extras.choices import BookmarkOrderingChoices from extras.utils import FeatureQuery from utilities.forms import BootstrapMixin from utilities.permissions import get_permission_for_model @@ -23,6 +24,7 @@ from utilities.utils import content_type_identifier, content_type_name, get_view from .utils import register_widget __all__ = ( + 'BookmarksWidget', 'DashboardWidget', 'NoteWidget', 'ObjectCountsWidget', @@ -318,3 +320,42 @@ class RSSFeedWidget(DashboardWidget): return { 'feed': feed, } + + +@register_widget +class BookmarksWidget(DashboardWidget): + default_title = _('Bookmarks') + default_config = { + 'order_by': BookmarkOrderingChoices.ORDERING_NEWEST, + } + description = _('Show your personal bookmarks') + template_name = 'extras/dashboard/widgets/bookmarks.html' + + class ConfigForm(WidgetConfigForm): + object_types = forms.MultipleChoiceField( + # TODO: Restrict the choices by FeatureQuery('bookmarks') + choices=get_content_type_labels, + required=False + ) + order_by = forms.ChoiceField( + choices=BookmarkOrderingChoices + ) + max_items = forms.IntegerField( + min_value=1, + required=False + ) + + def render(self, request): + from extras.models import Bookmark + + bookmarks = Bookmark.objects.filter(user=request.user).order_by(self.config['order_by']) + if object_types := self.config.get('object_types'): + models = get_models_from_content_types(object_types) + conent_types = ContentType.objects.get_for_models(*models).values() + bookmarks = bookmarks.filter(object_type__in=conent_types) + if max_items := self.config.get('max_items'): + bookmarks = bookmarks[:max_items] + + return render_to_string(self.template_name, { + 'bookmarks': bookmarks, + }) diff --git a/netbox/extras/filtersets.py b/netbox/extras/filtersets.py index acb0aa359..ef094c2d0 100644 --- a/netbox/extras/filtersets.py +++ b/netbox/extras/filtersets.py @@ -15,6 +15,7 @@ from .filters import TagFilter from .models import * __all__ = ( + 'BookmarkFilterSet', 'ConfigContextFilterSet', 'ConfigRevisionFilterSet', 'ConfigTemplateFilterSet', @@ -199,6 +200,26 @@ class SavedFilterFilterSet(BaseFilterSet): return queryset.filter(Q(enabled=False) | Q(Q(shared=False) & ~Q(user=user))) +class BookmarkFilterSet(BaseFilterSet): + created = django_filters.DateTimeFilter() + object_type_id = MultiValueNumberFilter() + object_type = ContentTypeFilter() + user_id = django_filters.ModelMultipleChoiceFilter( + queryset=get_user_model().objects.all(), + label=_('User (ID)'), + ) + user = django_filters.ModelMultipleChoiceFilter( + field_name='user__username', + queryset=get_user_model().objects.all(), + to_field_name='username', + label=_('User (name)'), + ) + + class Meta: + model = Bookmark + fields = ['id', 'object_id'] + + class ImageAttachmentFilterSet(BaseFilterSet): q = django_filters.CharFilter( method='search', diff --git a/netbox/extras/forms/model_forms.py b/netbox/extras/forms/model_forms.py index f8aa982bc..354d2a51a 100644 --- a/netbox/extras/forms/model_forms.py +++ b/netbox/extras/forms/model_forms.py @@ -14,7 +14,7 @@ from extras.utils import FeatureQuery from netbox.config import get_config, PARAMS from netbox.forms import NetBoxModelForm from tenancy.models import Tenant, TenantGroup -from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES, BootstrapMixin, add_blank_choice +from utilities.forms import BootstrapMixin, add_blank_choice from utilities.forms.fields import ( CommentField, ContentTypeChoiceField, ContentTypeMultipleChoiceField, DynamicModelMultipleChoiceField, JSONField, SlugField, @@ -23,6 +23,7 @@ from virtualization.models import Cluster, ClusterGroup, ClusterType __all__ = ( + 'BookmarkForm', 'ConfigContextForm', 'ConfigRevisionForm', 'ConfigTemplateForm', @@ -169,6 +170,17 @@ class SavedFilterForm(BootstrapMixin, forms.ModelForm): super().__init__(*args, initial=initial, **kwargs) +class BookmarkForm(BootstrapMixin, forms.ModelForm): + object_type = ContentTypeChoiceField( + queryset=ContentType.objects.all(), + limit_choices_to=FeatureQuery('bookmarks').get_query() + ) + + class Meta: + model = Bookmark + fields = ('object_type', 'object_id') + + class WebhookForm(BootstrapMixin, forms.ModelForm): content_types = ContentTypeMultipleChoiceField( queryset=ContentType.objects.all(), diff --git a/netbox/extras/migrations/0095_bookmarks.py b/netbox/extras/migrations/0095_bookmarks.py new file mode 100644 index 000000000..54c14c496 --- /dev/null +++ b/netbox/extras/migrations/0095_bookmarks.py @@ -0,0 +1,34 @@ +# Generated by Django 4.1.9 on 2023-06-29 14:07 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('contenttypes', '0002_remove_content_type_name'), + ('extras', '0094_tag_object_types'), + ] + + operations = [ + migrations.CreateModel( + name='Bookmark', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)), + ('created', models.DateTimeField(auto_now_add=True)), + ('object_id', models.PositiveBigIntegerField()), + ('object_type', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='contenttypes.contenttype')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ('created', 'pk'), + }, + ), + migrations.AddConstraint( + model_name='bookmark', + constraint=models.UniqueConstraint(fields=('object_type', 'object_id', 'user'), name='extras_bookmark_unique_per_object_and_user'), + ), + ] diff --git a/netbox/extras/models/models.py b/netbox/extras/models/models.py index 0cbc7a1de..20bf87903 100644 --- a/netbox/extras/models/models.py +++ b/netbox/extras/models/models.py @@ -1,7 +1,6 @@ import json import urllib.parse -from django.conf import settings from django.contrib import admin from django.conf import settings from django.contrib.contenttypes.fields import GenericForeignKey @@ -29,6 +28,7 @@ from utilities.querysets import RestrictedQuerySet from utilities.utils import clean_html, render_jinja2 __all__ = ( + 'Bookmark', 'ConfigRevision', 'CustomLink', 'ExportTemplate', @@ -595,6 +595,44 @@ class JournalEntry(CustomFieldsMixin, CustomLinksMixin, TagsMixin, ExportTemplat return JournalEntryKindChoices.colors.get(self.kind) +class Bookmark(models.Model): + """ + An object bookmarked by a User. + """ + created = models.DateTimeField( + auto_now_add=True + ) + object_type = models.ForeignKey( + to=ContentType, + on_delete=models.PROTECT + ) + object_id = models.PositiveBigIntegerField() + object = GenericForeignKey( + ct_field='object_type', + fk_field='object_id' + ) + user = models.ForeignKey( + to=settings.AUTH_USER_MODEL, + on_delete=models.PROTECT + ) + + objects = RestrictedQuerySet.as_manager() + + class Meta: + ordering = ('created', 'pk') + constraints = ( + models.UniqueConstraint( + fields=('object_type', 'object_id', 'user'), + name='%(app_label)s_%(class)s_unique_per_object_and_user' + ), + ) + + def __str__(self): + if self.object: + return str(self.object) + return super().__str__() + + class ConfigRevision(models.Model): """ An atomic revision of NetBox's configuration. diff --git a/netbox/extras/tables/tables.py b/netbox/extras/tables/tables.py index 35d53d1a6..6cb363c01 100644 --- a/netbox/extras/tables/tables.py +++ b/netbox/extras/tables/tables.py @@ -8,6 +8,7 @@ from netbox.tables import NetBoxTable, columns from .template_code import * __all__ = ( + 'BookmarkTable', 'ConfigContextTable', 'ConfigRevisionTable', 'ConfigTemplateTable', @@ -167,6 +168,21 @@ class SavedFilterTable(NetBoxTable): ) +class BookmarkTable(NetBoxTable): + object_type = columns.ContentTypeColumn() + object = tables.Column( + linkify=True + ) + actions = columns.ActionsColumn( + actions=('delete',) + ) + + class Meta(NetBoxTable.Meta): + model = Bookmark + fields = ('pk', 'object', 'object_type', 'created') + default_columns = ('object', 'object_type', 'created') + + class WebhookTable(NetBoxTable): name = tables.Column( linkify=True diff --git a/netbox/extras/tests/test_api.py b/netbox/extras/tests/test_api.py index 4c48aa73e..e09d4de78 100644 --- a/netbox/extras/tests/test_api.py +++ b/netbox/extras/tests/test_api.py @@ -268,6 +268,58 @@ class SavedFilterTest(APIViewTestCases.APIViewTestCase): savedfilter.content_types.set([site_ct]) +class BookmarkTest( + APIViewTestCases.GetObjectViewTestCase, + APIViewTestCases.ListObjectsViewTestCase, + APIViewTestCases.CreateObjectViewTestCase, + APIViewTestCases.DeleteObjectViewTestCase +): + model = Bookmark + brief_fields = ['display', 'id', 'object_id', 'object_type', 'url'] + + @classmethod + def setUpTestData(cls): + sites = ( + Site(name='Site 1', slug='site-1'), + Site(name='Site 2', slug='site-2'), + Site(name='Site 3', slug='site-3'), + Site(name='Site 4', slug='site-4'), + Site(name='Site 5', slug='site-5'), + Site(name='Site 6', slug='site-6'), + ) + Site.objects.bulk_create(sites) + + def setUp(self): + super().setUp() + + sites = Site.objects.all() + + bookmarks = ( + Bookmark(object=sites[0], user=self.user), + Bookmark(object=sites[1], user=self.user), + Bookmark(object=sites[2], user=self.user), + ) + Bookmark.objects.bulk_create(bookmarks) + + self.create_data = [ + { + 'object_type': 'dcim.site', + 'object_id': sites[3].pk, + 'user': self.user.pk, + }, + { + 'object_type': 'dcim.site', + 'object_id': sites[4].pk, + 'user': self.user.pk, + }, + { + 'object_type': 'dcim.site', + 'object_id': sites[5].pk, + 'user': self.user.pk, + }, + ] + + class ExportTemplateTest(APIViewTestCases.APIViewTestCase): model = ExportTemplate brief_fields = ['display', 'id', 'name', 'url'] diff --git a/netbox/extras/tests/test_filtersets.py b/netbox/extras/tests/test_filtersets.py index 7dff14cc0..b4b216244 100644 --- a/netbox/extras/tests/test_filtersets.py +++ b/netbox/extras/tests/test_filtersets.py @@ -365,6 +365,77 @@ class SavedFilterTestCase(TestCase, BaseFilterSetTests): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) +class BookmarkTestCase(TestCase, BaseFilterSetTests): + queryset = Bookmark.objects.all() + filterset = BookmarkFilterSet + + @classmethod + def setUpTestData(cls): + content_types = ContentType.objects.filter(model__in=['site', 'rack', 'device']) + + users = ( + User(username='User 1'), + User(username='User 2'), + User(username='User 3'), + ) + User.objects.bulk_create(users) + + sites = ( + Site(name='Site 1', slug='site-1'), + Site(name='Site 2', slug='site-2'), + Site(name='Site 3', slug='site-3'), + ) + Site.objects.bulk_create(sites) + + tenants = ( + Tenant(name='Tenant 1', slug='tenant-1'), + Tenant(name='Tenant 2', slug='tenant-2'), + Tenant(name='Tenant 3', slug='tenant-3'), + ) + Tenant.objects.bulk_create(tenants) + + bookmarks = ( + Bookmark( + object=sites[0], + user=users[0], + ), + Bookmark( + object=sites[1], + user=users[1], + ), + Bookmark( + object=sites[2], + user=users[2], + ), + Bookmark( + object=tenants[0], + user=users[0], + ), + Bookmark( + object=tenants[1], + user=users[1], + ), + Bookmark( + object=tenants[2], + user=users[2], + ), + ) + Bookmark.objects.bulk_create(bookmarks) + + def test_object_type(self): + params = {'object_type': 'dcim.site'} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3) + params = {'object_type_id': [ContentType.objects.get_for_model(Site).pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3) + + def test_user(self): + users = User.objects.filter(username__startswith='User') + params = {'user': [users[0].username, users[1].username]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + params = {'user_id': [users[0].pk, users[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + + class ExportTemplateTestCase(TestCase, BaseFilterSetTests): queryset = ExportTemplate.objects.all() filterset = ExportTemplateFilterSet diff --git a/netbox/extras/tests/test_views.py b/netbox/extras/tests/test_views.py index 3dcb90875..57efc5be7 100644 --- a/netbox/extras/tests/test_views.py +++ b/netbox/extras/tests/test_views.py @@ -181,6 +181,54 @@ class SavedFilterTestCase(ViewTestCases.PrimaryObjectViewTestCase): } +class BookmarkTestCase( + ViewTestCases.DeleteObjectViewTestCase, + ViewTestCases.ListObjectsViewTestCase, + ViewTestCases.BulkDeleteObjectsViewTestCase +): + model = Bookmark + + @classmethod + def setUpTestData(cls): + site_ct = ContentType.objects.get_for_model(Site) + sites = ( + Site(name='Site 1', slug='site-1'), + Site(name='Site 2', slug='site-2'), + Site(name='Site 3', slug='site-3'), + Site(name='Site 4', slug='site-4'), + ) + Site.objects.bulk_create(sites) + + cls.form_data = { + 'object_type': site_ct.pk, + 'object_id': sites[3].pk, + } + + def setUp(self): + super().setUp() + + sites = Site.objects.all() + user = self.user + + bookmarks = ( + Bookmark(object=sites[0], user=user), + Bookmark(object=sites[1], user=user), + Bookmark(object=sites[2], user=user), + ) + Bookmark.objects.bulk_create(bookmarks) + + def _get_url(self, action, instance=None): + if action == 'list': + return reverse('users:bookmarks') + return super()._get_url(action, instance) + + def test_list_objects_anonymous(self): + return + + def test_list_objects_with_constrained_permission(self): + return + + class ExportTemplateTestCase(ViewTestCases.PrimaryObjectViewTestCase): model = ExportTemplate diff --git a/netbox/extras/urls.py b/netbox/extras/urls.py index b3909391a..086537b99 100644 --- a/netbox/extras/urls.py +++ b/netbox/extras/urls.py @@ -1,4 +1,4 @@ -from django.urls import include, path, re_path +from django.urls import include, path from extras import views from utilities.urls import get_model_urls @@ -40,6 +40,11 @@ urlpatterns = [ path('saved-filters/delete/', views.SavedFilterBulkDeleteView.as_view(), name='savedfilter_bulk_delete'), path('saved-filters//', include(get_model_urls('extras', 'savedfilter'))), + # Bookmarks + path('bookmarks/add/', views.BookmarkCreateView.as_view(), name='bookmark_add'), + path('bookmarks/delete/', views.BookmarkBulkDeleteView.as_view(), name='bookmark_bulk_delete'), + path('bookmarks//', include(get_model_urls('extras', 'bookmark'))), + # Webhooks path('webhooks/', views.WebhookListView.as_view(), name='webhook_list'), path('webhooks/add/', views.WebhookEditView.as_view(), name='webhook_add'), diff --git a/netbox/extras/views.py b/netbox/extras/views.py index 9e02b5019..e3ba9c0c3 100644 --- a/netbox/extras/views.py +++ b/netbox/extras/views.py @@ -237,6 +237,35 @@ class SavedFilterBulkDeleteView(SavedFilterMixin, generic.BulkDeleteView): table = tables.SavedFilterTable +# +# Bookmarks +# + +class BookmarkCreateView(generic.ObjectEditView): + form = forms.BookmarkForm + + def get_queryset(self, request): + return Bookmark.objects.filter(user=request.user) + + def alter_object(self, obj, request, url_args, url_kwargs): + obj.user = request.user + return obj + + +@register_model_view(Bookmark, 'delete') +class BookmarkDeleteView(generic.ObjectDeleteView): + + def get_queryset(self, request): + return Bookmark.objects.filter(user=request.user) + + +class BookmarkBulkDeleteView(generic.BulkDeleteView): + table = tables.BookmarkTable + + def get_queryset(self, request): + return Bookmark.objects.filter(user=request.user) + + # # Webhooks # diff --git a/netbox/netbox/models/__init__.py b/netbox/netbox/models/__init__.py index c0f679e4f..21ca0087b 100644 --- a/netbox/netbox/models/__init__.py +++ b/netbox/netbox/models/__init__.py @@ -18,6 +18,7 @@ __all__ = ( class NetBoxFeatureSet( + BookmarksMixin, ChangeLoggingMixin, CustomFieldsMixin, CustomLinksMixin, diff --git a/netbox/netbox/models/features.py b/netbox/netbox/models/features.py index 8d79dd6bc..e07857145 100644 --- a/netbox/netbox/models/features.py +++ b/netbox/netbox/models/features.py @@ -22,6 +22,7 @@ from utilities.utils import serialize_object from utilities.views import register_model_view __all__ = ( + 'BookmarksMixin', 'ChangeLoggingMixin', 'CloningMixin', 'CustomFieldsMixin', @@ -304,6 +305,20 @@ class ExportTemplatesMixin(models.Model): abstract = True +class BookmarksMixin(models.Model): + """ + Enables support for user bookmarks. + """ + bookmarks = GenericRelation( + to='extras.Bookmark', + content_type_field='object_type', + object_id_field='object_id' + ) + + class Meta: + abstract = True + + class JobsMixin(models.Model): """ Enables support for job results. @@ -480,6 +495,7 @@ class SyncedDataMixin(models.Model): FEATURES_MAP = { + 'bookmarks': BookmarksMixin, 'custom_fields': CustomFieldsMixin, 'custom_links': CustomLinksMixin, 'export_templates': ExportTemplatesMixin, diff --git a/netbox/templates/extras/dashboard/widgets/bookmarks.html b/netbox/templates/extras/dashboard/widgets/bookmarks.html new file mode 100644 index 000000000..2189cc55f --- /dev/null +++ b/netbox/templates/extras/dashboard/widgets/bookmarks.html @@ -0,0 +1,9 @@ +{% if bookmarks %} +
+ {% for bookmark in bookmarks %} + + {{ bookmark.object }} + + {% endfor %} +
+{% endif %} diff --git a/netbox/templates/generic/object.html b/netbox/templates/generic/object.html index ebbeb2dfc..76ceb9f35 100644 --- a/netbox/templates/generic/object.html +++ b/netbox/templates/generic/object.html @@ -59,6 +59,9 @@ Context: {# Extra buttons #} {% block extra_controls %}{% endblock %} + {% if perms.extras.add_bookmark %} + {% bookmark_button object %} + {% endif %} {% if request.user|can_add:object %} {% clone_button object %} {% endif %} diff --git a/netbox/templates/inc/profile_button.html b/netbox/templates/inc/profile_button.html index b63b25464..932b91275 100644 --- a/netbox/templates/inc/profile_button.html +++ b/netbox/templates/inc/profile_button.html @@ -23,6 +23,11 @@ Profile +
  • + + Bookmarks + +
  • Preferences diff --git a/netbox/templates/users/base.html b/netbox/templates/users/base.html index 58861ee90..e07e28ced 100644 --- a/netbox/templates/users/base.html +++ b/netbox/templates/users/base.html @@ -5,6 +5,9 @@
  • + diff --git a/netbox/templates/users/bookmarks.html b/netbox/templates/users/bookmarks.html new file mode 100644 index 000000000..66f367a1c --- /dev/null +++ b/netbox/templates/users/bookmarks.html @@ -0,0 +1,34 @@ +{% extends 'users/base.html' %} +{% load buttons %} +{% load helpers %} +{% load render_table from django_tables2 %} + +{% block title %}Bookmarks{% endblock %} + +{% block content %} + +
    + {% csrf_token %} + + + {# Table #} +
    +
    +
    +
    + {% include 'htmx/table.html' %} +
    +
    +
    +
    + + {# Form buttons #} +
    +
    + {% if 'bulk_delete' in actions %} + {% bulk_delete_button model query_params=request.GET %} + {% endif %} +
    +
    + +{% endblock %} diff --git a/netbox/users/urls.py b/netbox/users/urls.py index ed1c21c02..7cb1f3435 100644 --- a/netbox/users/urls.py +++ b/netbox/users/urls.py @@ -8,6 +8,7 @@ urlpatterns = [ # User path('profile/', views.ProfileView.as_view(), name='profile'), + path('bookmarks/', views.BookmarkListView.as_view(), name='bookmarks'), path('preferences/', views.UserConfigView.as_view(), name='preferences'), path('password/', views.ChangePasswordView.as_view(), name='change_password'), diff --git a/netbox/users/views.py b/netbox/users/views.py index a82620914..4dcdaebab 100644 --- a/netbox/users/views.py +++ b/netbox/users/views.py @@ -15,10 +15,11 @@ from django.views.decorators.debug import sensitive_post_parameters from django.views.generic import View from social_core.backends.utils import load_backends -from extras.models import ObjectChange -from extras.tables import ObjectChangeTable +from extras.models import Bookmark, ObjectChange +from extras.tables import BookmarkTable, ObjectChangeTable from netbox.authentication import get_auth_backend_display, get_saml_idps from netbox.config import get_config +from netbox.views.generic import ObjectListView from utilities.forms import ConfirmationForm from utilities.views import register_model_view from .forms import LoginForm, PasswordChangeForm, TokenForm, UserConfigForm @@ -228,6 +229,23 @@ class ChangePasswordView(LoginRequiredMixin, View): }) +# +# Bookmarks +# + +class BookmarkListView(LoginRequiredMixin, ObjectListView): + table = BookmarkTable + template_name = 'users/bookmarks.html' + + def get_queryset(self, request): + return Bookmark.objects.filter(user=request.user) + + def get_extra_context(self, request): + return { + 'active_tab': 'bookmarks', + } + + # # API tokens # diff --git a/netbox/utilities/templates/buttons/bookmark.html b/netbox/utilities/templates/buttons/bookmark.html new file mode 100644 index 000000000..b11d1e82e --- /dev/null +++ b/netbox/utilities/templates/buttons/bookmark.html @@ -0,0 +1,15 @@ +
    + {% csrf_token %} + {% for field, value in form_data.items %} + + {% endfor %} + {% if bookmark %} + + {% else %} + + {% endif %} + diff --git a/netbox/utilities/templatetags/buttons.py b/netbox/utilities/templatetags/buttons.py index 1556b29a0..828af3b43 100644 --- a/netbox/utilities/templatetags/buttons.py +++ b/netbox/utilities/templatetags/buttons.py @@ -2,11 +2,12 @@ from django import template from django.contrib.contenttypes.models import ContentType from django.urls import NoReverseMatch, reverse -from extras.models import ExportTemplate +from extras.models import Bookmark, ExportTemplate from utilities.utils import get_viewname, prepare_cloned_fields __all__ = ( 'add_button', + 'bookmark_button', 'bulk_delete_button', 'bulk_edit_button', 'clone_button', @@ -24,6 +25,37 @@ register = template.Library() # Instance buttons # +@register.inclusion_tag('buttons/bookmark.html', takes_context=True) +def bookmark_button(context, instance): + # Check if this user has already bookmarked the object + content_type = ContentType.objects.get_for_model(instance) + bookmark = Bookmark.objects.filter( + object_type=content_type, + object_id=instance.pk, + user=context['request'].user + ).first() + + # Compile form URL & data + if bookmark: + form_url = reverse('extras:bookmark_delete', kwargs={'pk': bookmark.pk}) + form_data = { + 'confirm': 'true', + } + else: + form_url = reverse('extras:bookmark_add') + form_data = { + 'object_type': content_type.pk, + 'object_id': instance.pk, + } + + return { + 'bookmark': bookmark, + 'form_url': form_url, + 'form_data': form_data, + 'return_url': instance.get_absolute_url(), + } + + @register.inclusion_tag('buttons/clone.html') def clone_button(instance): url = reverse(get_viewname(instance, 'add'))