From ea61a540cdaf7f479e3709ad895aff50f671bdcb Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 2 Nov 2022 11:00:09 -0400 Subject: [PATCH 01/14] Closes #10816: Pass the current request when instantiating a FilterSet within UI views --- docs/release-notes/version-3.4.md | 1 + netbox/netbox/views/generic/bulk_views.py | 9 +++++---- netbox/netbox/views/generic/object_views.py | 2 +- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/docs/release-notes/version-3.4.md b/docs/release-notes/version-3.4.md index fb4a6ed32..3783cc967 100644 --- a/docs/release-notes/version-3.4.md +++ b/docs/release-notes/version-3.4.md @@ -56,6 +56,7 @@ A new `PluginMenu` class has been introduced, which enables a plugin to inject a * [#9046](https://github.com/netbox-community/netbox/issues/9046) - Remove legacy contact fields from provider model * [#10358](https://github.com/netbox-community/netbox/issues/10358) - Raise minimum required PostgreSQL version from 10 to 11 * [#10699](https://github.com/netbox-community/netbox/issues/10699) - Remove custom `import_object()` function +* [#10816](https://github.com/netbox-community/netbox/issues/10816) - Pass the current request when instantiating a FilterSet within UI views ### REST API Changes diff --git a/netbox/netbox/views/generic/bulk_views.py b/netbox/netbox/views/generic/bulk_views.py index 5d7b4eff0..df7cfdf67 100644 --- a/netbox/netbox/views/generic/bulk_views.py +++ b/netbox/netbox/views/generic/bulk_views.py @@ -126,7 +126,7 @@ class ObjectListView(BaseMultiObjectView, ActionsMixin, TableMixin): content_type = ContentType.objects.get_for_model(model) if self.filterset: - self.queryset = self.filterset(request.GET, self.queryset).qs + self.queryset = self.filterset(request.GET, self.queryset, request=request).qs # Determine the available actions actions = self.get_permitted_actions(request.user) @@ -544,7 +544,7 @@ class BulkEditView(GetReturnURLMixin, BaseMultiObjectView): # If we are editing *all* objects in the queryset, replace the PK list with all matched objects. if request.POST.get('_all') and self.filterset is not None: - pk_list = self.filterset(request.GET, self.queryset.values_list('pk', flat=True)).qs + pk_list = self.filterset(request.GET, self.queryset.values_list('pk', flat=True), request=request).qs else: pk_list = request.POST.getlist('pk') @@ -741,7 +741,7 @@ class BulkDeleteView(GetReturnURLMixin, BaseMultiObjectView): if request.POST.get('_all'): qs = model.objects.all() if self.filterset is not None: - qs = self.filterset(request.GET, qs).qs + qs = self.filterset(request.GET, qs, request=request).qs pk_list = qs.only('pk').values_list('pk', flat=True) else: pk_list = [int(pk) for pk in request.POST.getlist('pk')] @@ -828,7 +828,8 @@ class BulkComponentCreateView(GetReturnURLMixin, BaseMultiObjectView): # Are we editing *all* objects in the queryset or just a selected subset? if request.POST.get('_all') and self.filterset is not None: - pk_list = [obj.pk for obj in self.filterset(request.GET, self.parent_model.objects.only('pk')).qs] + queryset = self.filterset(request.GET, self.parent_model.objects.only('pk'), request=request).qs + pk_list = [obj.pk for obj in queryset] else: pk_list = [int(pk) for pk in request.POST.getlist('pk')] diff --git a/netbox/netbox/views/generic/object_views.py b/netbox/netbox/views/generic/object_views.py index 3f5a9f614..0d122a41a 100644 --- a/netbox/netbox/views/generic/object_views.py +++ b/netbox/netbox/views/generic/object_views.py @@ -126,7 +126,7 @@ class ObjectChildrenView(ObjectView, ActionsMixin, TableMixin): child_objects = self.get_children(request, instance) if self.filterset: - child_objects = self.filterset(request.GET, child_objects).qs + child_objects = self.filterset(request.GET, child_objects, request=request).qs # Determine the available actions actions = self.get_permitted_actions(request.user, model=self.child_model) From 484efdaf75f267a43f9321b938fda1bc967b9e53 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 2 Nov 2022 12:27:53 -0400 Subject: [PATCH 02/14] Closes #9623: Implement saved filters (#10801) * Initial work on saved filters * Return only enabled/shared filters * Add tests * Clean up filtering of usable SavedFilters --- netbox/circuits/forms/filtersets.py | 6 +- netbox/dcim/forms/filtersets.py | 50 +++++------ netbox/extras/api/nested_serializers.py | 9 ++ netbox/extras/api/serializers.py | 20 +++++ netbox/extras/api/urls.py | 26 +----- netbox/extras/api/views.py | 12 +++ netbox/extras/filtersets.py | 50 +++++++++++ netbox/extras/forms/__init__.py | 2 +- netbox/extras/forms/bulk_edit.py | 29 ++++++- netbox/extras/forms/bulk_import.py | 14 +++ netbox/extras/forms/filtersets.py | 61 +++++++++---- .../forms/{customfields.py => mixins.py} | 14 +++ netbox/extras/forms/model_forms.py | 30 +++++++ netbox/extras/graphql/schema.py | 3 + netbox/extras/graphql/types.py | 9 ++ netbox/extras/migrations/0083_savedfilter.py | 36 ++++++++ netbox/extras/models/__init__.py | 1 + netbox/extras/models/models.py | 66 +++++++++++++- netbox/extras/tables/tables.py | 42 ++++----- netbox/extras/tests/test_api.py | 69 ++++++++++++++- netbox/extras/tests/test_filtersets.py | 86 +++++++++++++++++++ netbox/extras/tests/test_views.py | 52 +++++++++++ netbox/extras/urls.py | 8 ++ netbox/extras/views.py | 69 ++++++++++++++- netbox/ipam/forms/filtersets.py | 33 +++---- netbox/netbox/filtersets.py | 23 ++++- netbox/netbox/forms/base.py | 13 ++- netbox/netbox/navigation/menu.py | 1 + netbox/netbox/views/generic/bulk_views.py | 9 +- netbox/templates/extras/savedfilter.html | 70 +++++++++++++++ netbox/templates/generic/object_list.html | 2 +- netbox/tenancy/forms/filtersets.py | 2 +- .../templates/helpers/applied_filters.html | 5 ++ netbox/utilities/templatetags/helpers.py | 17 +++- netbox/utilities/testing/base.py | 8 +- netbox/virtualization/forms/filtersets.py | 8 +- netbox/wireless/forms/filtersets.py | 4 +- 37 files changed, 821 insertions(+), 138 deletions(-) rename netbox/extras/forms/{customfields.py => mixins.py} (84%) create mode 100644 netbox/extras/migrations/0083_savedfilter.py create mode 100644 netbox/templates/extras/savedfilter.html diff --git a/netbox/circuits/forms/filtersets.py b/netbox/circuits/forms/filtersets.py index 29410ffdf..9ad825299 100644 --- a/netbox/circuits/forms/filtersets.py +++ b/netbox/circuits/forms/filtersets.py @@ -20,7 +20,7 @@ __all__ = ( class ProviderFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm): model = Provider fieldsets = ( - (None, ('q', 'tag')), + (None, ('q', 'filter', 'tag')), ('Location', ('region_id', 'site_group_id', 'site_id')), ('ASN', ('asn',)), ('Contacts', ('contact', 'contact_role', 'contact_group')), @@ -59,7 +59,7 @@ class ProviderFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm): class ProviderNetworkFilterForm(NetBoxModelFilterSetForm): model = ProviderNetwork fieldsets = ( - (None, ('q', 'tag')), + (None, ('q', 'filter', 'tag')), ('Attributes', ('provider_id', 'service_id')), ) provider_id = DynamicModelMultipleChoiceField( @@ -82,7 +82,7 @@ class CircuitTypeFilterForm(NetBoxModelFilterSetForm): class CircuitFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilterSetForm): model = Circuit fieldsets = ( - (None, ('q', 'tag')), + (None, ('q', 'filter', 'tag')), ('Provider', ('provider_id', 'provider_network_id')), ('Attributes', ('type_id', 'status', 'install_date', 'termination_date', 'commit_rate')), ('Location', ('region_id', 'site_group_id', 'site_id')), diff --git a/netbox/dcim/forms/filtersets.py b/netbox/dcim/forms/filtersets.py index 818da83e1..905a898df 100644 --- a/netbox/dcim/forms/filtersets.py +++ b/netbox/dcim/forms/filtersets.py @@ -116,7 +116,7 @@ class DeviceComponentFilterForm(NetBoxModelFilterSetForm): class RegionFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm): model = Region fieldsets = ( - (None, ('q', 'tag', 'parent_id')), + (None, ('q', 'filter', 'tag', 'parent_id')), ('Contacts', ('contact', 'contact_role', 'contact_group')) ) parent_id = DynamicModelMultipleChoiceField( @@ -130,7 +130,7 @@ class RegionFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm): class SiteGroupFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm): model = SiteGroup fieldsets = ( - (None, ('q', 'tag', 'parent_id')), + (None, ('q', 'filter', 'tag', 'parent_id')), ('Contacts', ('contact', 'contact_role', 'contact_group')) ) parent_id = DynamicModelMultipleChoiceField( @@ -144,7 +144,7 @@ class SiteGroupFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm): class SiteFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilterSetForm): model = Site fieldsets = ( - (None, ('q', 'tag')), + (None, ('q', 'filter', 'tag')), ('Attributes', ('status', 'region_id', 'group_id', 'asn_id')), ('Tenant', ('tenant_group_id', 'tenant_id')), ('Contacts', ('contact', 'contact_role', 'contact_group')), @@ -174,7 +174,7 @@ class SiteFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilte class LocationFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilterSetForm): model = Location fieldsets = ( - (None, ('q', 'tag')), + (None, ('q', 'filter', 'tag')), ('Attributes', ('region_id', 'site_group_id', 'site_id', 'parent_id', 'status')), ('Tenant', ('tenant_group_id', 'tenant_id')), ('Contacts', ('contact', 'contact_role', 'contact_group')), @@ -222,7 +222,7 @@ class RackRoleFilterForm(NetBoxModelFilterSetForm): class RackFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilterSetForm): model = Rack fieldsets = ( - (None, ('q', 'tag')), + (None, ('q', 'filter', 'tag')), ('Location', ('region_id', 'site_group_id', 'site_id', 'location_id')), ('Function', ('status', 'role_id')), ('Hardware', ('type', 'width', 'serial', 'asset_tag')), @@ -306,7 +306,7 @@ class RackElevationFilterForm(RackFilterForm): class RackReservationFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): model = RackReservation fieldsets = ( - (None, ('q', 'tag')), + (None, ('q', 'filter', 'tag')), ('User', ('user_id',)), ('Rack', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')), ('Tenant', ('tenant_group_id', 'tenant_id')), @@ -362,7 +362,7 @@ class RackReservationFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): class ManufacturerFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm): model = Manufacturer fieldsets = ( - (None, ('q', 'tag')), + (None, ('q', 'filter', 'tag')), ('Contacts', ('contact', 'contact_role', 'contact_group')) ) tag = TagFilterField(model) @@ -371,7 +371,7 @@ class ManufacturerFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm): class DeviceTypeFilterForm(NetBoxModelFilterSetForm): model = DeviceType fieldsets = ( - (None, ('q', 'tag')), + (None, ('q', 'filter', 'tag')), ('Hardware', ('manufacturer_id', 'part_number', 'subdevice_role', 'airflow')), ('Images', ('has_front_image', 'has_rear_image')), ('Components', ( @@ -486,7 +486,7 @@ class DeviceTypeFilterForm(NetBoxModelFilterSetForm): class ModuleTypeFilterForm(NetBoxModelFilterSetForm): model = ModuleType fieldsets = ( - (None, ('q', 'tag')), + (None, ('q', 'filter', 'tag')), ('Hardware', ('manufacturer_id', 'part_number')), ('Components', ( 'console_ports', 'console_server_ports', 'power_ports', 'power_outlets', 'interfaces', @@ -578,7 +578,7 @@ class DeviceFilterForm( ): model = Device fieldsets = ( - (None, ('q', 'tag')), + (None, ('q', 'filter', 'tag')), ('Location', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')), ('Operation', ('status', 'role_id', 'airflow', 'serial', 'asset_tag', 'mac_address')), ('Hardware', ('manufacturer_id', 'device_type_id', 'platform_id')), @@ -731,7 +731,7 @@ class DeviceFilterForm( class ModuleFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, NetBoxModelFilterSetForm): model = Module fieldsets = ( - (None, ('q', 'tag')), + (None, ('q', 'filter', 'tag')), ('Hardware', ('manufacturer_id', 'module_type_id', 'serial', 'asset_tag')), ) manufacturer_id = DynamicModelMultipleChoiceField( @@ -761,7 +761,7 @@ class ModuleFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, NetBoxMo class VirtualChassisFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): model = VirtualChassis fieldsets = ( - (None, ('q', 'tag')), + (None, ('q', 'filter', 'tag')), ('Location', ('region_id', 'site_group_id', 'site_id')), ('Tenant', ('tenant_group_id', 'tenant_id')), ) @@ -790,7 +790,7 @@ class VirtualChassisFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): class CableFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): model = Cable fieldsets = ( - (None, ('q', 'tag')), + (None, ('q', 'filter', 'tag')), ('Location', ('site_id', 'location_id', 'rack_id', 'device_id')), ('Attributes', ('type', 'status', 'color', 'length', 'length_unit')), ('Tenant', ('tenant_group_id', 'tenant_id')), @@ -862,7 +862,7 @@ class CableFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): class PowerPanelFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm): model = PowerPanel fieldsets = ( - (None, ('q', 'tag')), + (None, ('q', 'filter', 'tag')), ('Location', ('region_id', 'site_group_id', 'site_id', 'location_id')), ('Contacts', ('contact', 'contact_role', 'contact_group')), ) @@ -900,7 +900,7 @@ class PowerPanelFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm): class PowerFeedFilterForm(NetBoxModelFilterSetForm): model = PowerFeed fieldsets = ( - (None, ('q', 'tag')), + (None, ('q', 'filter', 'tag')), ('Location', ('region_id', 'site_group_id', 'site_id', 'power_panel_id', 'rack_id')), ('Attributes', ('status', 'type', 'supply', 'phase', 'voltage', 'amperage', 'max_utilization')), ) @@ -1002,7 +1002,7 @@ class PathEndpointFilterForm(CabledFilterForm): class ConsolePortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm): model = ConsolePort fieldsets = ( - (None, ('q', 'tag')), + (None, ('q', 'filter', 'tag')), ('Attributes', ('name', 'label', 'type', 'speed')), ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')), ('Connection', ('cabled', 'connected', 'occupied')), @@ -1021,7 +1021,7 @@ class ConsolePortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm): class ConsoleServerPortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm): model = ConsoleServerPort fieldsets = ( - (None, ('q', 'tag')), + (None, ('q', 'filter', 'tag')), ('Attributes', ('name', 'label', 'type', 'speed')), ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')), ('Connection', ('cabled', 'connected', 'occupied')), @@ -1040,7 +1040,7 @@ class ConsoleServerPortFilterForm(PathEndpointFilterForm, DeviceComponentFilterF class PowerPortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm): model = PowerPort fieldsets = ( - (None, ('q', 'tag')), + (None, ('q', 'filter', 'tag')), ('Attributes', ('name', 'label', 'type')), ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')), ('Connection', ('cabled', 'connected', 'occupied')), @@ -1055,7 +1055,7 @@ class PowerPortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm): class PowerOutletFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm): model = PowerOutlet fieldsets = ( - (None, ('q', 'tag')), + (None, ('q', 'filter', 'tag')), ('Attributes', ('name', 'label', 'type')), ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')), ('Connection', ('cabled', 'connected', 'occupied')), @@ -1070,7 +1070,7 @@ class PowerOutletFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm): class InterfaceFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm): model = Interface fieldsets = ( - (None, ('q', 'tag')), + (None, ('q', 'filter', 'tag')), ('Attributes', ('name', 'label', 'kind', 'type', 'speed', 'duplex', 'enabled', 'mgmt_only')), ('Addressing', ('vrf_id', 'mac_address', 'wwn')), ('PoE', ('poe_mode', 'poe_type')), @@ -1159,7 +1159,7 @@ class InterfaceFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm): class FrontPortFilterForm(CabledFilterForm, DeviceComponentFilterForm): fieldsets = ( - (None, ('q', 'tag')), + (None, ('q', 'filter', 'tag')), ('Attributes', ('name', 'label', 'type', 'color')), ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')), ('Cable', ('cabled', 'occupied')), @@ -1178,7 +1178,7 @@ class FrontPortFilterForm(CabledFilterForm, DeviceComponentFilterForm): class RearPortFilterForm(CabledFilterForm, DeviceComponentFilterForm): model = RearPort fieldsets = ( - (None, ('q', 'tag')), + (None, ('q', 'filter', 'tag')), ('Attributes', ('name', 'label', 'type', 'color')), ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')), ('Cable', ('cabled', 'occupied')), @@ -1196,7 +1196,7 @@ class RearPortFilterForm(CabledFilterForm, DeviceComponentFilterForm): class ModuleBayFilterForm(DeviceComponentFilterForm): model = ModuleBay fieldsets = ( - (None, ('q', 'tag')), + (None, ('q', 'filter', 'tag')), ('Attributes', ('name', 'label', 'position')), ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')), ) @@ -1209,7 +1209,7 @@ class ModuleBayFilterForm(DeviceComponentFilterForm): class DeviceBayFilterForm(DeviceComponentFilterForm): model = DeviceBay fieldsets = ( - (None, ('q', 'tag')), + (None, ('q', 'filter', 'tag')), ('Attributes', ('name', 'label')), ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')), ) @@ -1219,7 +1219,7 @@ class DeviceBayFilterForm(DeviceComponentFilterForm): class InventoryItemFilterForm(DeviceComponentFilterForm): model = InventoryItem fieldsets = ( - (None, ('q', 'tag')), + (None, ('q', 'filter', 'tag')), ('Attributes', ('name', 'label', 'role_id', 'manufacturer_id', 'serial', 'asset_tag', 'discovered')), ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')), ) diff --git a/netbox/extras/api/nested_serializers.py b/netbox/extras/api/nested_serializers.py index 44dfe7cbc..dce062b84 100644 --- a/netbox/extras/api/nested_serializers.py +++ b/netbox/extras/api/nested_serializers.py @@ -13,6 +13,7 @@ __all__ = [ 'NestedImageAttachmentSerializer', 'NestedJobResultSerializer', 'NestedJournalEntrySerializer', + 'NestedSavedFilterSerializer', 'NestedTagSerializer', # Defined in netbox.api.serializers 'NestedWebhookSerializer', ] @@ -58,6 +59,14 @@ class NestedExportTemplateSerializer(WritableNestedSerializer): fields = ['id', 'url', 'display', 'name'] +class NestedSavedFilterSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='extras-api:savedfilter-detail') + + class Meta: + model = models.SavedFilter + fields = ['id', 'url', 'display', 'name'] + + 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 ac025ff16..1afb8fa8f 100644 --- a/netbox/extras/api/serializers.py +++ b/netbox/extras/api/serializers.py @@ -39,6 +39,7 @@ __all__ = ( 'ReportDetailSerializer', 'ReportSerializer', 'ReportInputSerializer', + 'SavedFilterSerializer', 'ScriptDetailSerializer', 'ScriptInputSerializer', 'ScriptLogMessageSerializer', @@ -149,6 +150,25 @@ class ExportTemplateSerializer(ValidatedModelSerializer): ] +# +# Saved filters +# + +class SavedFilterSerializer(ValidatedModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='extras-api:savedfilter-detail') + content_types = ContentTypeField( + queryset=ContentType.objects.all(), + many=True + ) + + class Meta: + model = SavedFilter + fields = [ + 'id', 'url', 'display', 'content_types', 'name', 'description', 'user', 'weight', + 'enabled', 'shared', 'parameters', 'created', 'last_updated', + ] + + # # Tags # diff --git a/netbox/extras/api/urls.py b/netbox/extras/api/urls.py index bcad6b77c..91067d40d 100644 --- a/netbox/extras/api/urls.py +++ b/netbox/extras/api/urls.py @@ -5,43 +5,19 @@ from . import views router = NetBoxRouter() router.APIRootView = views.ExtrasRootView -# Webhooks router.register('webhooks', views.WebhookViewSet) - -# Custom fields router.register('custom-fields', views.CustomFieldViewSet) - -# Custom links router.register('custom-links', views.CustomLinkViewSet) - -# Export templates router.register('export-templates', views.ExportTemplateViewSet) - -# Tags +router.register('saved-filters', views.SavedFilterViewSet) router.register('tags', views.TagViewSet) - -# Image attachments router.register('image-attachments', views.ImageAttachmentViewSet) - -# Journal entries router.register('journal-entries', views.JournalEntryViewSet) - -# Config contexts router.register('config-contexts', views.ConfigContextViewSet) - -# Reports router.register('reports', views.ReportViewSet, basename='report') - -# Scripts router.register('scripts', views.ScriptViewSet, basename='script') - -# Change logging router.register('object-changes', views.ObjectChangeViewSet) - -# Job Results router.register('job-results', views.JobResultViewSet) - -# ContentTypes router.register('content-types', views.ContentTypeViewSet) app_name = 'extras-api' diff --git a/netbox/extras/api/views.py b/netbox/extras/api/views.py index 62a011530..ab111b0ec 100644 --- a/netbox/extras/api/views.py +++ b/netbox/extras/api/views.py @@ -1,4 +1,5 @@ from django.contrib.contenttypes.models import ContentType +from django.db.models import Q from django.http import Http404 from django_rq.queues import get_connection from rest_framework import status @@ -98,6 +99,17 @@ class ExportTemplateViewSet(NetBoxModelViewSet): filterset_class = filtersets.ExportTemplateFilterSet +# +# Saved filters +# + +class SavedFilterViewSet(NetBoxModelViewSet): + metadata_class = ContentTypeMetadata + queryset = SavedFilter.objects.all() + serializer_class = serializers.SavedFilterSerializer + filterset_class = filtersets.SavedFilterFilterSet + + # # Tags # diff --git a/netbox/extras/filtersets.py b/netbox/extras/filtersets.py index 22fe6537e..6010c733a 100644 --- a/netbox/extras/filtersets.py +++ b/netbox/extras/filtersets.py @@ -23,6 +23,7 @@ __all__ = ( 'JournalEntryFilterSet', 'LocalConfigContextFilterSet', 'ObjectChangeFilterSet', + 'SavedFilterFilterSet', 'TagFilterSet', 'WebhookFilterSet', ) @@ -138,6 +139,55 @@ class ExportTemplateFilterSet(BaseFilterSet): ) +class SavedFilterFilterSet(BaseFilterSet): + q = django_filters.CharFilter( + method='search', + label='Search', + ) + content_type_id = MultiValueNumberFilter( + field_name='content_types__id' + ) + content_types = ContentTypeFilter() + user_id = django_filters.ModelMultipleChoiceFilter( + queryset=User.objects.all(), + label='User (ID)', + ) + user = django_filters.ModelMultipleChoiceFilter( + field_name='user__username', + queryset=User.objects.all(), + to_field_name='username', + label='User (name)', + ) + usable = django_filters.BooleanFilter( + method='_usable' + ) + + class Meta: + model = SavedFilter + fields = ['id', 'content_types', 'name', 'description', 'enabled', 'shared', 'weight'] + + def search(self, queryset, name, value): + if not value.strip(): + return queryset + return queryset.filter( + Q(name__icontains=value) | + Q(description__icontains=value) + ) + + def _usable(self, queryset, name, value): + """ + Return only SavedFilters that are both enabled and are shared (or belong to the current user). + """ + user = self.request.user if self.request else None + if not user or user.is_anonymous: + if value: + return queryset.filter(enabled=True, shared=True) + return queryset.filter(Q(enabled=False) | Q(shared=False)) + if value: + return queryset.filter(enabled=True).filter(Q(shared=True) | Q(user=user)) + return queryset.filter(Q(enabled=False) | Q(Q(shared=False) & ~Q(user=user))) + + class ImageAttachmentFilterSet(BaseFilterSet): q = django_filters.CharFilter( method='search', diff --git a/netbox/extras/forms/__init__.py b/netbox/extras/forms/__init__.py index d2f2fb015..af0f7cf43 100644 --- a/netbox/extras/forms/__init__.py +++ b/netbox/extras/forms/__init__.py @@ -2,6 +2,6 @@ from .model_forms import * from .filtersets import * from .bulk_edit import * from .bulk_import import * -from .customfields import * +from .mixins import * from .config import * from .scripts import * diff --git a/netbox/extras/forms/bulk_edit.py b/netbox/extras/forms/bulk_edit.py index df17324ec..a061d9784 100644 --- a/netbox/extras/forms/bulk_edit.py +++ b/netbox/extras/forms/bulk_edit.py @@ -1,11 +1,9 @@ from django import forms -from django.contrib.contenttypes.models import ContentType from extras.choices import * from extras.models import * -from extras.utils import FeatureQuery from utilities.forms import ( - add_blank_choice, BulkEditForm, BulkEditNullBooleanSelect, ColorField, ContentTypeChoiceField, StaticSelect, + add_blank_choice, BulkEditForm, BulkEditNullBooleanSelect, ColorField, StaticSelect, ) __all__ = ( @@ -14,6 +12,7 @@ __all__ = ( 'CustomLinkBulkEditForm', 'ExportTemplateBulkEditForm', 'JournalEntryBulkEditForm', + 'SavedFilterBulkEditForm', 'TagBulkEditForm', 'WebhookBulkEditForm', ) @@ -96,6 +95,30 @@ class ExportTemplateBulkEditForm(BulkEditForm): nullable_fields = ('description', 'mime_type', 'file_extension') +class SavedFilterBulkEditForm(BulkEditForm): + pk = forms.ModelMultipleChoiceField( + queryset=SavedFilter.objects.all(), + widget=forms.MultipleHiddenInput + ) + description = forms.CharField( + max_length=200, + required=False + ) + weight = forms.IntegerField( + required=False + ) + enabled = forms.NullBooleanField( + required=False, + widget=BulkEditNullBooleanSelect() + ) + shared = forms.NullBooleanField( + required=False, + widget=BulkEditNullBooleanSelect() + ) + + nullable_fields = ('description',) + + class WebhookBulkEditForm(BulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=Webhook.objects.all(), diff --git a/netbox/extras/forms/bulk_import.py b/netbox/extras/forms/bulk_import.py index ee638015b..0f5974698 100644 --- a/netbox/extras/forms/bulk_import.py +++ b/netbox/extras/forms/bulk_import.py @@ -12,6 +12,7 @@ __all__ = ( 'CustomFieldCSVForm', 'CustomLinkCSVForm', 'ExportTemplateCSVForm', + 'SavedFilterCSVForm', 'TagCSVForm', 'WebhookCSVForm', ) @@ -81,6 +82,19 @@ class ExportTemplateCSVForm(CSVModelForm): ) +class SavedFilterCSVForm(CSVModelForm): + content_types = CSVMultipleContentTypeField( + queryset=ContentType.objects.all(), + help_text="One or more assigned object types" + ) + + class Meta: + model = SavedFilter + fields = ( + 'name', 'content_types', 'description', 'weight', 'enabled', 'shared', 'parameters', + ) + + class WebhookCSVForm(CSVModelForm): content_types = CSVMultipleContentTypeField( queryset=ContentType.objects.all(), diff --git a/netbox/extras/forms/filtersets.py b/netbox/extras/forms/filtersets.py index a164a3d95..479367ff0 100644 --- a/netbox/extras/forms/filtersets.py +++ b/netbox/extras/forms/filtersets.py @@ -15,6 +15,7 @@ from utilities.forms import ( StaticSelect, TagFilterField, ) from virtualization.models import Cluster, ClusterGroup, ClusterType +from .mixins import SavedFiltersMixin __all__ = ( 'ConfigContextFilterForm', @@ -25,14 +26,15 @@ __all__ = ( 'JournalEntryFilterForm', 'LocalConfigContextFilterForm', 'ObjectChangeFilterForm', + 'SavedFilterFilterForm', 'TagFilterForm', 'WebhookFilterForm', ) -class CustomFieldFilterForm(FilterForm): +class CustomFieldFilterForm(SavedFiltersMixin, FilterForm): fieldsets = ( - (None, ('q',)), + (None, ('q', 'filter')), ('Attributes', ('type', 'content_type_id', 'group_name', 'weight', 'required', 'ui_visibility')), ) content_type_id = ContentTypeMultipleChoiceField( @@ -66,9 +68,9 @@ class CustomFieldFilterForm(FilterForm): ) -class JobResultFilterForm(FilterForm): +class JobResultFilterForm(SavedFiltersMixin, FilterForm): fieldsets = ( - (None, ('q',)), + (None, ('q', 'filter')), ('Attributes', ('obj_type', 'status')), ('Creation', ('created__before', 'created__after', 'completed__before', 'completed__after', 'scheduled_time__before', 'scheduled_time__after', 'user')), @@ -118,9 +120,9 @@ class JobResultFilterForm(FilterForm): ) -class CustomLinkFilterForm(FilterForm): +class CustomLinkFilterForm(SavedFiltersMixin, FilterForm): fieldsets = ( - (None, ('q',)), + (None, ('q', 'filter')), ('Attributes', ('content_types', 'enabled', 'new_window', 'weight')), ) content_types = ContentTypeMultipleChoiceField( @@ -145,9 +147,9 @@ class CustomLinkFilterForm(FilterForm): ) -class ExportTemplateFilterForm(FilterForm): +class ExportTemplateFilterForm(SavedFiltersMixin, FilterForm): fieldsets = ( - (None, ('q',)), + (None, ('q', 'filter')), ('Attributes', ('content_types', 'mime_type', 'file_extension', 'as_attachment')), ) content_types = ContentTypeMultipleChoiceField( @@ -170,9 +172,36 @@ class ExportTemplateFilterForm(FilterForm): ) -class WebhookFilterForm(FilterForm): +class SavedFilterFilterForm(SavedFiltersMixin, FilterForm): fieldsets = ( - (None, ('q',)), + (None, ('q', 'filter')), + ('Attributes', ('content_types', 'enabled', 'shared', 'weight')), + ) + content_types = ContentTypeMultipleChoiceField( + queryset=ContentType.objects.all(), + limit_choices_to=FeatureQuery('export_templates'), + required=False + ) + enabled = forms.NullBooleanField( + required=False, + widget=StaticSelect( + choices=BOOLEAN_WITH_BLANK_CHOICES + ) + ) + shared = forms.NullBooleanField( + required=False, + widget=StaticSelect( + choices=BOOLEAN_WITH_BLANK_CHOICES + ) + ) + weight = forms.IntegerField( + required=False + ) + + +class WebhookFilterForm(SavedFiltersMixin, FilterForm): + fieldsets = ( + (None, ('q', 'filter')), ('Attributes', ('content_type_id', 'http_method', 'enabled')), ('Events', ('type_create', 'type_update', 'type_delete')), ) @@ -213,7 +242,7 @@ class WebhookFilterForm(FilterForm): ) -class TagFilterForm(FilterForm): +class TagFilterForm(SavedFiltersMixin, FilterForm): model = Tag content_type_id = ContentTypeMultipleChoiceField( queryset=ContentType.objects.filter(FeatureQuery('tags').get_query()), @@ -222,9 +251,9 @@ class TagFilterForm(FilterForm): ) -class ConfigContextFilterForm(FilterForm): +class ConfigContextFilterForm(SavedFiltersMixin, FilterForm): fieldsets = ( - (None, ('q', 'tag_id')), + (None, ('q', 'filter', 'tag_id')), ('Location', ('region_id', 'site_group_id', 'site_id', 'location_id')), ('Device', ('device_type_id', 'platform_id', 'role_id')), ('Cluster', ('cluster_type_id', 'cluster_group_id', 'cluster_id')), @@ -311,7 +340,7 @@ class LocalConfigContextFilterForm(forms.Form): class JournalEntryFilterForm(NetBoxModelFilterSetForm): model = JournalEntry fieldsets = ( - (None, ('q', 'tag')), + (None, ('q', 'filter', 'tag')), ('Creation', ('created_before', 'created_after', 'created_by_id')), ('Attributes', ('assigned_object_type_id', 'kind')) ) @@ -349,10 +378,10 @@ class JournalEntryFilterForm(NetBoxModelFilterSetForm): tag = TagFilterField(model) -class ObjectChangeFilterForm(FilterForm): +class ObjectChangeFilterForm(SavedFiltersMixin, FilterForm): model = ObjectChange fieldsets = ( - (None, ('q',)), + (None, ('q', 'filter')), ('Time', ('time_before', 'time_after')), ('Attributes', ('action', 'user_id', 'changed_object_type_id')), ) diff --git a/netbox/extras/forms/customfields.py b/netbox/extras/forms/mixins.py similarity index 84% rename from netbox/extras/forms/customfields.py rename to netbox/extras/forms/mixins.py index 40d068450..2b64d1a74 100644 --- a/netbox/extras/forms/customfields.py +++ b/netbox/extras/forms/mixins.py @@ -1,10 +1,13 @@ from django.contrib.contenttypes.models import ContentType +from django import forms from extras.models import * from extras.choices import CustomFieldVisibilityChoices +from utilities.forms.fields import DynamicModelMultipleChoiceField __all__ = ( 'CustomFieldsMixin', + 'SavedFiltersMixin', ) @@ -57,3 +60,14 @@ class CustomFieldsMixin: if customfield.group_name not in self.custom_field_groups: self.custom_field_groups[customfield.group_name] = [] self.custom_field_groups[customfield.group_name].append(field_name) + + +class SavedFiltersMixin(forms.Form): + filter = DynamicModelMultipleChoiceField( + queryset=SavedFilter.objects.all(), + required=False, + label='Saved Filter', + query_params={ + 'usable': True, + } + ) diff --git a/netbox/extras/forms/model_forms.py b/netbox/extras/forms/model_forms.py index 7ff4f3e27..97e80100a 100644 --- a/netbox/extras/forms/model_forms.py +++ b/netbox/extras/forms/model_forms.py @@ -1,5 +1,6 @@ from django import forms from django.contrib.contenttypes.models import ContentType +from django.http import QueryDict from dcim.models import DeviceRole, DeviceType, Location, Platform, Region, Site, SiteGroup from extras.choices import * @@ -20,6 +21,7 @@ __all__ = ( 'ExportTemplateForm', 'ImageAttachmentForm', 'JournalEntryForm', + 'SavedFilterForm', 'TagForm', 'WebhookForm', ) @@ -108,6 +110,34 @@ class ExportTemplateForm(BootstrapMixin, forms.ModelForm): } +class SavedFilterForm(BootstrapMixin, forms.ModelForm): + content_types = ContentTypeMultipleChoiceField( + queryset=ContentType.objects.all() + ) + + fieldsets = ( + ('Saved Filter', ('name', 'content_types', 'description', 'weight', 'enabled', 'shared')), + ('Parameters', ('parameters',)), + ) + + class Meta: + model = SavedFilter + exclude = ('user',) + widgets = { + 'parameters': forms.Textarea(attrs={'class': 'font-monospace'}), + } + + def __init__(self, *args, initial=None, **kwargs): + + # Convert any parameters delivered via initial data to a dictionary + if initial and 'parameters' in initial: + if type(initial['parameters']) is str: + # TODO: Make a utility function for this + initial['parameters'] = dict(QueryDict(initial['parameters']).lists()) + + super().__init__(*args, initial=initial, **kwargs) + + class WebhookForm(BootstrapMixin, forms.ModelForm): content_types = ContentTypeMultipleChoiceField( queryset=ContentType.objects.all(), diff --git a/netbox/extras/graphql/schema.py b/netbox/extras/graphql/schema.py index 3073976e8..0c3113879 100644 --- a/netbox/extras/graphql/schema.py +++ b/netbox/extras/graphql/schema.py @@ -20,6 +20,9 @@ class ExtrasQuery(graphene.ObjectType): image_attachment = ObjectField(ImageAttachmentType) image_attachment_list = ObjectListField(ImageAttachmentType) + saved_filter = ObjectField(SavedFilterType) + saved_filter_list = ObjectListField(SavedFilterType) + journal_entry = ObjectField(JournalEntryType) journal_entry_list = ObjectListField(JournalEntryType) diff --git a/netbox/extras/graphql/types.py b/netbox/extras/graphql/types.py index 3be7b371e..b5d4dffce 100644 --- a/netbox/extras/graphql/types.py +++ b/netbox/extras/graphql/types.py @@ -10,6 +10,7 @@ __all__ = ( 'ImageAttachmentType', 'JournalEntryType', 'ObjectChangeType', + 'SavedFilterType', 'TagType', 'WebhookType', ) @@ -71,6 +72,14 @@ class ObjectChangeType(BaseObjectType): filterset_class = filtersets.ObjectChangeFilterSet +class SavedFilterType(ObjectType): + + class Meta: + model = models.SavedFilter + exclude = ('content_types', ) + filterset_class = filtersets.SavedFilterFilterSet + + class TagType(ObjectType): class Meta: diff --git a/netbox/extras/migrations/0083_savedfilter.py b/netbox/extras/migrations/0083_savedfilter.py new file mode 100644 index 000000000..6bae7ccde --- /dev/null +++ b/netbox/extras/migrations/0083_savedfilter.py @@ -0,0 +1,36 @@ +# Generated by Django 4.1.1 on 2022-10-27 18:18 + +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', '0082_exporttemplate_content_types'), + ] + + operations = [ + migrations.CreateModel( + name='SavedFilter', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)), + ('created', models.DateTimeField(auto_now_add=True, null=True)), + ('last_updated', models.DateTimeField(auto_now=True, null=True)), + ('name', models.CharField(max_length=100, unique=True)), + ('description', models.CharField(blank=True, max_length=200)), + ('weight', models.PositiveSmallIntegerField(default=100)), + ('enabled', models.BooleanField(default=True)), + ('shared', models.BooleanField(default=True)), + ('parameters', models.JSONField()), + ('content_types', models.ManyToManyField(related_name='saved_filters', to='contenttypes.contenttype')), + ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ('weight', 'name'), + }, + ), + ] diff --git a/netbox/extras/models/__init__.py b/netbox/extras/models/__init__.py index e3a4be3fe..6d2bf288c 100644 --- a/netbox/extras/models/__init__.py +++ b/netbox/extras/models/__init__.py @@ -18,6 +18,7 @@ __all__ = ( 'JournalEntry', 'ObjectChange', 'Report', + 'SavedFilter', 'Script', 'Tag', 'TaggedItem', diff --git a/netbox/extras/models/models.py b/netbox/extras/models/models.py index a8b2f2647..4b4e7c0cf 100644 --- a/netbox/extras/models/models.py +++ b/netbox/extras/models/models.py @@ -8,7 +8,7 @@ from django.contrib.contenttypes.models import ContentType from django.core.cache import cache from django.core.validators import ValidationError from django.db import models -from django.http import HttpResponse +from django.http import HttpResponse, QueryDict from django.urls import reverse from django.utils import timezone from django.utils.formats import date_format @@ -34,6 +34,7 @@ __all__ = ( 'JobResult', 'JournalEntry', 'Report', + 'SavedFilter', 'Script', 'Webhook', ) @@ -350,6 +351,69 @@ class ExportTemplate(ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel): return response +class SavedFilter(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel): + """ + A set of predefined keyword parameters that can be reused to filter for specific objects. + """ + content_types = models.ManyToManyField( + to=ContentType, + related_name='saved_filters', + help_text='The object type(s) to which this filter applies.' + ) + name = models.CharField( + max_length=100, + unique=True + ) + description = models.CharField( + max_length=200, + blank=True + ) + user = models.ForeignKey( + to=User, + on_delete=models.SET_NULL, + blank=True, + null=True + ) + weight = models.PositiveSmallIntegerField( + default=100 + ) + enabled = models.BooleanField( + default=True + ) + shared = models.BooleanField( + default=True + ) + parameters = models.JSONField() + + clone_fields = ( + 'enabled', 'weight', + ) + + class Meta: + ordering = ('weight', 'name') + + def __str__(self): + return self.name + + def get_absolute_url(self): + return reverse('extras:savedfilter', args=[self.pk]) + + def clean(self): + super().clean() + + # Verify that `parameters` is a JSON object + if type(self.parameters) is not dict: + raise ValidationError( + {'parameters': 'Filter parameters must be stored as a dictionary of keyword arguments.'} + ) + + @property + def url_params(self): + qd = QueryDict(mutable=True) + qd.update(self.parameters) + return qd.urlencode() + + class ImageAttachment(WebhooksMixin, ChangeLoggedModel): """ An uploaded image which is associated with an object. diff --git a/netbox/extras/tables/tables.py b/netbox/extras/tables/tables.py index 4b4acb235..da4241e69 100644 --- a/netbox/extras/tables/tables.py +++ b/netbox/extras/tables/tables.py @@ -13,16 +13,13 @@ __all__ = ( 'ExportTemplateTable', 'JournalEntryTable', 'ObjectChangeTable', + 'SavedFilterTable', 'TaggedItemTable', 'TagTable', 'WebhookTable', ) -# -# Custom fields -# - class CustomFieldTable(NetBoxTable): name = tables.Column( linkify=True @@ -40,10 +37,6 @@ class CustomFieldTable(NetBoxTable): default_columns = ('pk', 'name', 'content_types', 'label', 'group_name', 'type', 'required', 'description') -# -# Custom fields -# - class JobResultTable(NetBoxTable): name = tables.Column( linkify=True @@ -61,10 +54,6 @@ class JobResultTable(NetBoxTable): default_columns = ('pk', 'id', 'name', 'obj_type', 'status', 'created', 'completed', 'user',) -# -# Custom links -# - class CustomLinkTable(NetBoxTable): name = tables.Column( linkify=True @@ -82,10 +71,6 @@ class CustomLinkTable(NetBoxTable): default_columns = ('pk', 'name', 'content_types', 'enabled', 'group_name', 'button_class', 'new_window') -# -# Export templates -# - class ExportTemplateTable(NetBoxTable): name = tables.Column( linkify=True @@ -104,9 +89,24 @@ class ExportTemplateTable(NetBoxTable): ) -# -# Webhooks -# +class SavedFilterTable(NetBoxTable): + name = tables.Column( + linkify=True + ) + content_types = columns.ContentTypesColumn() + enabled = columns.BooleanColumn() + shared = columns.BooleanColumn() + + class Meta(NetBoxTable.Meta): + model = SavedFilter + fields = ( + 'pk', 'id', 'name', 'content_types', 'description', 'user', 'weight', 'enabled', 'shared', + 'created', 'last_updated', + ) + default_columns = ( + 'pk', 'name', 'content_types', 'user', 'description', 'enabled', 'shared', + ) + class WebhookTable(NetBoxTable): name = tables.Column( @@ -139,10 +139,6 @@ class WebhookTable(NetBoxTable): ) -# -# Tags -# - class TagTable(NetBoxTable): name = tables.Column( linkify=True diff --git a/netbox/extras/tests/test_api.py b/netbox/extras/tests/test_api.py index 42246b651..045391ea8 100644 --- a/netbox/extras/tests/test_api.py +++ b/netbox/extras/tests/test_api.py @@ -3,7 +3,6 @@ from unittest import skipIf from django.contrib.auth.models import User from django.contrib.contenttypes.models import ContentType -from django.test import override_settings from django.urls import reverse from django.utils.timezone import make_aware from django_rq.queues import get_connection @@ -17,7 +16,6 @@ from extras.reports import Report from extras.scripts import BooleanVar, IntegerVar, Script, StringVar from utilities.testing import APITestCase, APIViewTestCases - rq_worker_running = Worker.count(get_connection('default')) @@ -192,6 +190,73 @@ class CustomLinkTest(APIViewTestCases.APIViewTestCase): custom_link.content_types.set([site_ct]) +class SavedFilterTest(APIViewTestCases.APIViewTestCase): + model = SavedFilter + brief_fields = ['display', 'id', 'name', 'url'] + create_data = [ + { + 'content_types': ['dcim.site'], + 'name': 'Saved Filter 4', + 'weight': 100, + 'enabled': True, + 'shared': True, + 'parameters': {'status': ['active']}, + }, + { + 'content_types': ['dcim.site'], + 'name': 'Saved Filter 5', + 'weight': 200, + 'enabled': True, + 'shared': True, + 'parameters': {'status': ['planned']}, + }, + { + 'content_types': ['dcim.site'], + 'name': 'Saved Filter 6', + 'weight': 300, + 'enabled': True, + 'shared': True, + 'parameters': {'status': ['retired']}, + }, + ] + bulk_update_data = { + 'weight': 1000, + 'enabled': False, + 'shared': False, + } + + @classmethod + def setUpTestData(cls): + site_ct = ContentType.objects.get_for_model(Site) + + saved_filters = ( + SavedFilter( + name='Saved Filter 1', + weight=100, + enabled=True, + shared=True, + parameters={'status': ['active']} + ), + SavedFilter( + name='Saved Filter 2', + weight=200, + enabled=True, + shared=True, + parameters={'status': ['planned']} + ), + SavedFilter( + name='Saved Filter 3', + weight=300, + enabled=True, + shared=True, + parameters={'status': ['retired']} + ), + ) + SavedFilter.objects.bulk_create(saved_filters) + for i, savedfilter in enumerate(saved_filters): + savedfilter.content_types.set([site_ct]) + + 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 dd1fdb6b3..140f05906 100644 --- a/netbox/extras/tests/test_filtersets.py +++ b/netbox/extras/tests/test_filtersets.py @@ -222,6 +222,92 @@ class CustomLinkTestCase(TestCase, BaseFilterSetTests): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) +class SavedFilterTestCase(TestCase, BaseFilterSetTests): + queryset = SavedFilter.objects.all() + filterset = SavedFilterFilterSet + + @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) + + saved_filters = ( + SavedFilter( + name='Saved Filter 1', + user=users[0], + weight=100, + enabled=True, + shared=True, + parameters={'status': ['active']} + ), + SavedFilter( + name='Saved Filter 2', + user=users[1], + weight=200, + enabled=True, + shared=True, + parameters={'status': ['planned']} + ), + SavedFilter( + name='Saved Filter 3', + user=users[2], + weight=300, + enabled=False, + shared=False, + parameters={'status': ['retired']} + ), + ) + SavedFilter.objects.bulk_create(saved_filters) + for i, savedfilter in enumerate(saved_filters): + savedfilter.content_types.set([content_types[i]]) + + def test_name(self): + params = {'name': ['Saved Filter 1', 'Saved Filter 2']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_content_types(self): + params = {'content_types': 'dcim.site'} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + params = {'content_type_id': [ContentType.objects.get_for_model(Site).pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + + 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(), 2) + params = {'user_id': [users[0].pk, users[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_weight(self): + params = {'weight': [100, 200]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_enabled(self): + params = {'enabled': True} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'enabled': False} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + + def test_shared(self): + params = {'enabled': True} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'enabled': False} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + + def test_usable(self): + # Filtering for an anonymous user + params = {'usable': True} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'usable': False} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + + 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 85e5aea5e..175ffb9ca 100644 --- a/netbox/extras/tests/test_views.py +++ b/netbox/extras/tests/test_views.py @@ -107,6 +107,58 @@ class CustomLinkTestCase(ViewTestCases.PrimaryObjectViewTestCase): } +class SavedFilterTestCase(ViewTestCases.PrimaryObjectViewTestCase): + model = SavedFilter + + @classmethod + def setUpTestData(cls): + site_ct = ContentType.objects.get_for_model(Site) + + users = ( + User(username='User 1'), + User(username='User 2'), + User(username='User 3'), + ) + User.objects.bulk_create(users) + + saved_filters = ( + SavedFilter(name='Saved Filter 1', user=users[0], weight=100, parameters={'status': ['active']}), + SavedFilter(name='Saved Filter 2', user=users[1], weight=200, parameters={'status': ['planned']}), + SavedFilter(name='Saved Filter 3', user=users[2], weight=300, parameters={'status': ['retired']}), + ) + SavedFilter.objects.bulk_create(saved_filters) + for i, savedfilter in enumerate(saved_filters): + savedfilter.content_types.set([site_ct]) + + cls.form_data = { + 'name': 'Saved Filter X', + 'content_types': [site_ct.pk], + 'description': 'Foo', + 'weight': 1000, + 'enabled': True, + 'shared': True, + 'parameters': '{"foo": 123}', + } + + cls.csv_data = ( + 'name,content_types,weight,enabled,shared,parameters', + 'Saved Filter 4,dcim.device,400,True,True,{"foo": "a"}', + 'Saved Filter 5,dcim.device,500,True,True,{"foo": "b"}', + 'Saved Filter 6,dcim.device,600,True,True,{"foo": "c"}', + ) + + cls.csv_update_data = ( + "id,name", + f"{saved_filters[0].pk},Saved Filter 7", + f"{saved_filters[1].pk},Saved Filter 8", + f"{saved_filters[2].pk},Saved Filter 9", + ) + + cls.bulk_edit_data = { + 'weight': 999, + } + + class ExportTemplateTestCase(ViewTestCases.PrimaryObjectViewTestCase): model = ExportTemplate diff --git a/netbox/extras/urls.py b/netbox/extras/urls.py index 0640904f2..f41a45f5a 100644 --- a/netbox/extras/urls.py +++ b/netbox/extras/urls.py @@ -31,6 +31,14 @@ urlpatterns = [ path('export-templates/delete/', views.ExportTemplateBulkDeleteView.as_view(), name='exporttemplate_bulk_delete'), path('export-templates//', include(get_model_urls('extras', 'exporttemplate'))), + # Saved filters + path('saved-filters/', views.SavedFilterListView.as_view(), name='savedfilter_list'), + path('saved-filters/add/', views.SavedFilterEditView.as_view(), name='savedfilter_add'), + path('saved-filters/import/', views.SavedFilterBulkImportView.as_view(), name='savedfilter_import'), + path('saved-filters/edit/', views.SavedFilterBulkEditView.as_view(), name='savedfilter_bulk_edit'), + path('saved-filters/delete/', views.SavedFilterBulkDeleteView.as_view(), name='savedfilter_bulk_delete'), + path('saved-filters//', include(get_model_urls('extras', 'savedfilter'))), + # 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 c042c248a..4a1350bde 100644 --- a/netbox/extras/views.py +++ b/netbox/extras/views.py @@ -9,7 +9,6 @@ from django_rq.queues import get_connection from rq import Worker from netbox.views import generic -from utilities.forms import ConfirmationForm from utilities.htmx import is_htmx from utilities.utils import copy_safe_request, count_related, get_viewname, normalize_querydict, shallow_compare_dict from utilities.views import ContentTypePermissionRequiredMixin, register_model_view @@ -159,6 +158,74 @@ class ExportTemplateBulkDeleteView(generic.BulkDeleteView): table = tables.ExportTemplateTable +# +# Saved filters +# + +class SavedFilterMixin: + + def get_queryset(self, request): + """ + Return only shared SavedFilters, or those owned by the current user, unless + this is a superuser. + """ + queryset = SavedFilter.objects.all() + user = request.user + if user.is_superuser: + return queryset + if user.is_anonymous: + return queryset.filter(shared=True) + return queryset.filter( + Q(shared=True) | Q(user=user) + ) + + +class SavedFilterListView(SavedFilterMixin, generic.ObjectListView): + filterset = filtersets.SavedFilterFilterSet + filterset_form = forms.SavedFilterFilterForm + table = tables.SavedFilterTable + + +@register_model_view(SavedFilter) +class SavedFilterView(SavedFilterMixin, generic.ObjectView): + queryset = SavedFilter.objects.all() + + +@register_model_view(SavedFilter, 'edit') +class SavedFilterEditView(SavedFilterMixin, generic.ObjectEditView): + queryset = SavedFilter.objects.all() + form = forms.SavedFilterForm + + def alter_object(self, obj, request, url_args, url_kwargs): + if not obj.pk: + obj.user = request.user + return obj + + +@register_model_view(SavedFilter, 'delete') +class SavedFilterDeleteView(SavedFilterMixin, generic.ObjectDeleteView): + queryset = SavedFilter.objects.all() + + +class SavedFilterBulkImportView(SavedFilterMixin, generic.BulkImportView): + queryset = SavedFilter.objects.all() + model_form = forms.SavedFilterCSVForm + table = tables.SavedFilterTable + + +class SavedFilterBulkEditView(SavedFilterMixin, generic.BulkEditView): + queryset = SavedFilter.objects.all() + filterset = filtersets.SavedFilterFilterSet + table = tables.SavedFilterTable + form = forms.SavedFilterBulkEditForm + + +class SavedFilterBulkDeleteView(SavedFilterMixin, generic.BulkDeleteView): + queryset = SavedFilter.objects.all() + filterset = filtersets.SavedFilterFilterSet + table = tables.SavedFilterTable + + # # Webhooks # diff --git a/netbox/ipam/forms/filtersets.py b/netbox/ipam/forms/filtersets.py index a2ff7085b..7d277b33b 100644 --- a/netbox/ipam/forms/filtersets.py +++ b/netbox/ipam/forms/filtersets.py @@ -1,6 +1,5 @@ from django import forms from django.contrib.contenttypes.models import ContentType -from django.db.models import Q from django.utils.translation import gettext as _ from dcim.models import Location, Rack, Region, Site, SiteGroup, Device @@ -11,7 +10,7 @@ from netbox.forms import NetBoxModelFilterSetForm from tenancy.forms import TenancyFilterForm from utilities.forms import ( add_blank_choice, ContentTypeMultipleChoiceField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, - MultipleChoiceField, StaticSelect, TagFilterField, BOOLEAN_WITH_BLANK_CHOICES, APISelectMultiple, + MultipleChoiceField, StaticSelect, TagFilterField, BOOLEAN_WITH_BLANK_CHOICES, ) from virtualization.models import VirtualMachine @@ -46,7 +45,7 @@ IPADDRESS_MASK_LENGTH_CHOICES = add_blank_choice([ class VRFFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): model = VRF fieldsets = ( - (None, ('q', 'tag')), + (None, ('q', 'filter', 'tag')), ('Route Targets', ('import_target_id', 'export_target_id')), ('Tenant', ('tenant_group_id', 'tenant_id')), ) @@ -66,7 +65,7 @@ class VRFFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): class RouteTargetFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): model = RouteTarget fieldsets = ( - (None, ('q', 'tag')), + (None, ('q', 'filter', 'tag')), ('VRF', ('importing_vrf_id', 'exporting_vrf_id')), ('Tenant', ('tenant_group_id', 'tenant_id')), ) @@ -98,7 +97,7 @@ class RIRFilterForm(NetBoxModelFilterSetForm): class AggregateFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): model = Aggregate fieldsets = ( - (None, ('q', 'tag')), + (None, ('q', 'filter', 'tag')), ('Attributes', ('family', 'rir_id')), ('Tenant', ('tenant_group_id', 'tenant_id')), ) @@ -119,7 +118,7 @@ class AggregateFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): class ASNFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): model = ASN fieldsets = ( - (None, ('q', 'tag')), + (None, ('q', 'filter', 'tag')), ('Assignment', ('rir_id', 'site_id')), ('Tenant', ('tenant_group_id', 'tenant_id')), ) @@ -144,7 +143,7 @@ class RoleFilterForm(NetBoxModelFilterSetForm): class PrefixFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): model = Prefix fieldsets = ( - (None, ('q', 'tag')), + (None, ('q', 'filter', 'tag')), ('Addressing', ('within_include', 'family', 'status', 'role_id', 'mask_length', 'is_pool', 'mark_utilized')), ('VRF', ('vrf_id', 'present_in_vrf_id')), ('Location', ('region_id', 'site_group_id', 'site_id')), @@ -233,7 +232,7 @@ class PrefixFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): class IPRangeFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): model = IPRange fieldsets = ( - (None, ('q', 'tag')), + (None, ('q', 'filter', 'tag')), ('Attriubtes', ('family', 'vrf_id', 'status', 'role_id')), ('Tenant', ('tenant_group_id', 'tenant_id')), ) @@ -265,7 +264,7 @@ class IPRangeFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): class IPAddressFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): model = IPAddress fieldsets = ( - (None, ('q', 'tag')), + (None, ('q', 'filter', 'tag')), ('Attributes', ('parent', 'family', 'status', 'role', 'mask_length', 'assigned_to_interface')), ('VRF', ('vrf_id', 'present_in_vrf_id')), ('Tenant', ('tenant_group_id', 'tenant_id')), @@ -334,7 +333,7 @@ class IPAddressFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): class FHRPGroupFilterForm(NetBoxModelFilterSetForm): model = FHRPGroup fieldsets = ( - (None, ('q', 'tag')), + (None, ('q', 'filter', 'tag')), ('Attributes', ('name', 'protocol', 'group_id')), ('Authentication', ('auth_type', 'auth_key')), ) @@ -364,7 +363,7 @@ class FHRPGroupFilterForm(NetBoxModelFilterSetForm): class VLANGroupFilterForm(NetBoxModelFilterSetForm): fieldsets = ( - (None, ('q', 'tag')), + (None, ('q', 'filter', 'tag')), ('Location', ('region', 'sitegroup', 'site', 'location', 'rack')), ('VLAN ID', ('min_vid', 'max_vid')), ) @@ -412,7 +411,7 @@ class VLANGroupFilterForm(NetBoxModelFilterSetForm): class VLANFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): model = VLAN fieldsets = ( - (None, ('q', 'tag')), + (None, ('q', 'filter', 'tag')), ('Location', ('region_id', 'site_group_id', 'site_id')), ('Attributes', ('group_id', 'status', 'role_id', 'vid')), ('Tenant', ('tenant_group_id', 'tenant_id')), @@ -465,7 +464,7 @@ class VLANFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): class ServiceTemplateFilterForm(NetBoxModelFilterSetForm): model = ServiceTemplate fieldsets = ( - (None, ('q', 'tag')), + (None, ('q', 'filter', 'tag')), ('Attributes', ('protocol', 'port')), ) protocol = forms.ChoiceField( @@ -486,7 +485,7 @@ class ServiceFilterForm(ServiceTemplateFilterForm): class L2VPNFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): model = L2VPN fieldsets = ( - (None, ('q', 'tag')), + (None, ('q', 'filter', 'tag')), ('Attributes', ('type', 'import_target_id', 'export_target_id')), ('Tenant', ('tenant_group_id', 'tenant_id')), ) @@ -511,8 +510,10 @@ class L2VPNFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): class L2VPNTerminationFilterForm(NetBoxModelFilterSetForm): model = L2VPNTermination fieldsets = ( - (None, ('l2vpn_id', )), - ('Assigned Object', ('assigned_object_type_id', 'region_id', 'site_id', 'device_id', 'virtual_machine_id', 'vlan_id')), + (None, ('filter', 'l2vpn_id',)), + ('Assigned Object', ( + 'assigned_object_type_id', 'region_id', 'site_id', 'device_id', 'virtual_machine_id', 'vlan_id', + )), ) l2vpn_id = DynamicModelChoiceField( queryset=L2VPN.objects.all(), diff --git a/netbox/netbox/filtersets.py b/netbox/netbox/filtersets.py index 6a8f5d0d3..02ccdca50 100644 --- a/netbox/netbox/filtersets.py +++ b/netbox/netbox/filtersets.py @@ -4,10 +4,11 @@ from django.contrib.contenttypes.models import ContentType from django.db import models from django_filters.exceptions import FieldLookupError from django_filters.utils import get_model_field, resolve_field +from django.shortcuts import get_object_or_404 from extras.choices import CustomFieldFilterLogicChoices from extras.filters import TagFilter -from extras.models import CustomField +from extras.models import CustomField, SavedFilter from utilities.constants import ( FILTER_CHAR_BASED_LOOKUP_MAP, FILTER_NEGATION_LOOKUP_MAP, FILTER_TREENODE_NEGATION_LOOKUP_MAP, FILTER_NUMERIC_BASED_LOOKUP_MAP @@ -80,12 +81,28 @@ class BaseFilterSet(django_filters.FilterSet): }, }) - def __init__(self, *args, **kwargs): + def __init__(self, data=None, *args, **kwargs): # bit of a hack for #9231 - extras.lookup.Empty is registered in apps.ready # however FilterSet Factory is setup before this which creates the # initial filters. This recreates the filters so Empty is picked up correctly. self.base_filters = self.__class__.get_filters() - super().__init__(*args, **kwargs) + + # Apply any referenced SavedFilters + if data and 'filter' in data: + data = data.copy() # Get a mutable copy + saved_filters = SavedFilter.objects.filter(pk__in=data.pop('filter')) + for sf in saved_filters: + for key, value in sf.parameters.items(): + # QueryDicts are... fun + if type(value) not in (list, tuple): + value = [value] + if key in data: + for v in value: + data.appendlist(key, v) + else: + data.setlist(key, value) + + super().__init__(data, *args, **kwargs) @staticmethod def _get_filter_lookup_dict(existing_filter): diff --git a/netbox/netbox/forms/base.py b/netbox/netbox/forms/base.py index 2cbc67971..564e254a3 100644 --- a/netbox/netbox/forms/base.py +++ b/netbox/netbox/forms/base.py @@ -3,7 +3,7 @@ from django.contrib.contenttypes.models import ContentType from django.db.models import Q from extras.choices import CustomFieldFilterLogicChoices, CustomFieldTypeChoices, CustomFieldVisibilityChoices -from extras.forms.customfields import CustomFieldsMixin +from extras.forms.mixins import CustomFieldsMixin, SavedFiltersMixin from extras.models import CustomField, Tag from utilities.forms import BootstrapMixin, CSVModelForm from utilities.forms.fields import DynamicModelMultipleChoiceField @@ -114,7 +114,7 @@ class NetBoxModelBulkEditForm(BootstrapMixin, CustomFieldsMixin, forms.Form): self.nullable_fields = (*self.nullable_fields, *nullable_custom_fields) -class NetBoxModelFilterSetForm(BootstrapMixin, CustomFieldsMixin, forms.Form): +class NetBoxModelFilterSetForm(BootstrapMixin, CustomFieldsMixin, SavedFiltersMixin, forms.Form): """ Base form for FilerSet forms. These are used to filter object lists in the NetBox UI. Note that the corresponding FilterSet *must* provide a `q` filter. @@ -129,6 +129,15 @@ class NetBoxModelFilterSetForm(BootstrapMixin, CustomFieldsMixin, forms.Form): label='Search' ) + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # Limit saved filters to those applicable to the form's model + content_type = ContentType.objects.get_for_model(self.model) + self.fields['filter'].widget.add_query_params({ + 'content_type_id': content_type.pk, + }) + def _get_custom_fields(self, content_type): return super()._get_custom_fields(content_type).exclude( Q(filter_logic=CustomFieldFilterLogicChoices.FILTER_DISABLED) | diff --git a/netbox/netbox/navigation/menu.py b/netbox/netbox/navigation/menu.py index 65c2ec7fc..68551827c 100644 --- a/netbox/netbox/navigation/menu.py +++ b/netbox/netbox/navigation/menu.py @@ -278,6 +278,7 @@ OTHER_MENU = Menu( get_model_item('extras', 'customfield', 'Custom Fields'), get_model_item('extras', 'customlink', 'Custom Links'), get_model_item('extras', 'exporttemplate', 'Export Templates'), + get_model_item('extras', 'savedfilter', 'Saved Filters'), ), ), MenuGroup( diff --git a/netbox/netbox/views/generic/bulk_views.py b/netbox/netbox/views/generic/bulk_views.py index df7cfdf67..5ab9e6da0 100644 --- a/netbox/netbox/views/generic/bulk_views.py +++ b/netbox/netbox/views/generic/bulk_views.py @@ -4,17 +4,17 @@ from copy import deepcopy from django.contrib import messages from django.contrib.contenttypes.models import ContentType -from django.core.exceptions import FieldDoesNotExist, ValidationError, ObjectDoesNotExist +from django.core.exceptions import FieldDoesNotExist, ValidationError from django.db import transaction, IntegrityError from django.db.models import ManyToManyField, ProtectedError from django.db.models.fields.reverse_related import ManyToManyRel -from django.forms import Form, ModelMultipleChoiceField, MultipleHiddenInput, model_to_dict +from django.forms import Form, ModelMultipleChoiceField, MultipleHiddenInput from django.http import HttpResponse from django.shortcuts import get_object_or_404, redirect, render -from django_tables2.export import TableExport from django.utils.safestring import mark_safe +from django_tables2.export import TableExport -from extras.models import ExportTemplate +from extras.models import ExportTemplate, SavedFilter from extras.signals import clear_webhooks from utilities.error_handlers import handle_protectederror from utilities.exceptions import AbortRequest, PermissionsViolation @@ -330,7 +330,6 @@ class BulkImportView(GetReturnURLMixin, BaseMultiObjectView): return headers, records def _update_objects(self, form, request, headers, records): - from utilities.forms import CSVModelChoiceField updated_objs = [] ids = [int(record["id"]) for record in records] diff --git a/netbox/templates/extras/savedfilter.html b/netbox/templates/extras/savedfilter.html new file mode 100644 index 000000000..4372481aa --- /dev/null +++ b/netbox/templates/extras/savedfilter.html @@ -0,0 +1,70 @@ +{% extends 'generic/object.html' %} +{% load helpers %} +{% load plugins %} + +{% block content %} +
+
+
+
Saved Filter
+
+ + + + + + + + + + + + + + + + + + + + + + + + + +
Name{{ object.name }}
Description{{ object.description|placeholder }}
User{{ object.user|placeholder }}
Enabled{% checkmark object.enabled %}
Shared{% checkmark object.shared %}
Weight{{ object.weight }}
+
+
+
+
Assigned Models
+
+ + {% for ct in object.content_types.all %} + + + + {% endfor %} +
{{ ct }}
+
+
+ {% plugin_left_page object %} +
+
+
+
+ Parameters +
+
+
{{ object.parameters }}
+
+
+ {% plugin_right_page object %} +
+
+
+
+ {% plugin_full_width_page object %} +
+
+{% endblock %} diff --git a/netbox/templates/generic/object_list.html b/netbox/templates/generic/object_list.html index 60eba6097..c58565c31 100644 --- a/netbox/templates/generic/object_list.html +++ b/netbox/templates/generic/object_list.html @@ -64,7 +64,7 @@ Context: {# Applied filters #} {% if filter_form %} - {% applied_filters filter_form request.GET %} + {% applied_filters model filter_form request.GET %} {% endif %} {# "Select all" form #} diff --git a/netbox/tenancy/forms/filtersets.py b/netbox/tenancy/forms/filtersets.py index 02589d733..f840a2177 100644 --- a/netbox/tenancy/forms/filtersets.py +++ b/netbox/tenancy/forms/filtersets.py @@ -31,7 +31,7 @@ class TenantGroupFilterForm(NetBoxModelFilterSetForm): class TenantFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm): model = Tenant fieldsets = ( - (None, ('q', 'tag', 'group_id')), + (None, ('q', 'filter', 'tag', 'group_id')), ('Contacts', ('contact', 'contact_role', 'contact_group')) ) group_id = DynamicModelMultipleChoiceField( diff --git a/netbox/utilities/templates/helpers/applied_filters.html b/netbox/utilities/templates/helpers/applied_filters.html index 4f22a7c9a..3cf8fe425 100644 --- a/netbox/utilities/templates/helpers/applied_filters.html +++ b/netbox/utilities/templates/helpers/applied_filters.html @@ -10,5 +10,10 @@ Clear all {% endif %} + {% if save_link %} + + Save + + {% endif %} {% endif %} diff --git a/netbox/utilities/templatetags/helpers.py b/netbox/utilities/templatetags/helpers.py index 9789724ee..ed2e39041 100644 --- a/netbox/utilities/templatetags/helpers.py +++ b/netbox/utilities/templatetags/helpers.py @@ -1,9 +1,11 @@ import datetime import decimal +from urllib.parse import quote from typing import Dict, Any from django import template from django.conf import settings +from django.contrib.contenttypes.models import ContentType from django.template.defaultfilters import date from django.urls import NoReverseMatch, reverse from django.utils import timezone @@ -278,12 +280,13 @@ def table_config_form(table, table_name=None): } -@register.inclusion_tag('helpers/applied_filters.html') -def applied_filters(form, query_params): +@register.inclusion_tag('helpers/applied_filters.html', takes_context=True) +def applied_filters(context, model, form, query_params): """ Display the active filters for a given filter form. """ - form.is_valid() + user = context['request'].user + form.is_valid() # Ensure cleaned_data has been set applied_filters = [] for filter_name in form.changed_data: @@ -305,6 +308,14 @@ def applied_filters(form, query_params): 'link_text': f'{bound_field.label}: {display_value}', }) + save_link = None + if user.has_perm('extras.add_savedfilter') and 'filter' not in context['request'].GET: + content_type = ContentType.objects.get_for_model(model).pk + parameters = context['request'].GET.urlencode() + url = reverse('extras:savedfilter_add') + save_link = f"{url}?content_types={content_type}¶meters={quote(parameters)}" + return { 'applied_filters': applied_filters, + 'save_link': save_link, } diff --git a/netbox/utilities/testing/base.py b/netbox/utilities/testing/base.py index 499a5e2e7..04ceca1e2 100644 --- a/netbox/utilities/testing/base.py +++ b/netbox/utilities/testing/base.py @@ -1,8 +1,10 @@ +import json + from django.contrib.auth.models import User from django.contrib.contenttypes.models import ContentType from django.contrib.postgres.fields import ArrayField from django.core.exceptions import FieldDoesNotExist -from django.db.models import ManyToManyField +from django.db.models import ManyToManyField, JSONField from django.forms.models import model_to_dict from django.test import Client, TestCase as _TestCase from netaddr import IPNetwork @@ -132,6 +134,10 @@ class ModelTestCase(TestCase): if type(instance._meta.get_field(key)) is ArrayField: model_dict[key] = ','.join([str(v) for v in value]) + # JSON + if type(instance._meta.get_field(key)) is JSONField and value is not None: + model_dict[key] = json.dumps(value) + return model_dict # diff --git a/netbox/virtualization/forms/filtersets.py b/netbox/virtualization/forms/filtersets.py index 4b8ff6d21..62fa4002e 100644 --- a/netbox/virtualization/forms/filtersets.py +++ b/netbox/virtualization/forms/filtersets.py @@ -30,7 +30,7 @@ class ClusterGroupFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm): model = ClusterGroup tag = TagFilterField(model) fieldsets = ( - (None, ('q', 'tag')), + (None, ('q', 'filter', 'tag')), ('Contacts', ('contact', 'contact_role', 'contact_group')), ) @@ -38,7 +38,7 @@ class ClusterGroupFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm): class ClusterFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilterSetForm): model = Cluster fieldsets = ( - (None, ('q', 'tag')), + (None, ('q', 'filter', 'tag')), ('Attributes', ('group_id', 'type_id', 'status')), ('Location', ('region_id', 'site_group_id', 'site_id')), ('Tenant', ('tenant_group_id', 'tenant_id')), @@ -90,7 +90,7 @@ class VirtualMachineFilterForm( ): model = VirtualMachine fieldsets = ( - (None, ('q', 'tag')), + (None, ('q', 'filter', 'tag')), ('Cluster', ('cluster_group_id', 'cluster_type_id', 'cluster_id', 'device_id')), ('Location', ('region_id', 'site_group_id', 'site_id')), ('Attributes', ('status', 'role_id', 'platform_id', 'mac_address', 'has_primary_ip', 'local_context_data')), @@ -175,7 +175,7 @@ class VirtualMachineFilterForm( class VMInterfaceFilterForm(NetBoxModelFilterSetForm): model = VMInterface fieldsets = ( - (None, ('q', 'tag')), + (None, ('q', 'filter', 'tag')), ('Virtual Machine', ('cluster_id', 'virtual_machine_id')), ('Attributes', ('enabled', 'mac_address', 'vrf_id')), ) diff --git a/netbox/wireless/forms/filtersets.py b/netbox/wireless/forms/filtersets.py index 9e8808e17..d7a6aac6e 100644 --- a/netbox/wireless/forms/filtersets.py +++ b/netbox/wireless/forms/filtersets.py @@ -28,7 +28,7 @@ class WirelessLANGroupFilterForm(NetBoxModelFilterSetForm): class WirelessLANFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): model = WirelessLAN fieldsets = ( - (None, ('q', 'tag')), + (None, ('q', 'filter', 'tag')), ('Attributes', ('ssid', 'group_id',)), ('Tenant', ('tenant_group_id', 'tenant_id')), ('Authentication', ('auth_type', 'auth_cipher', 'auth_psk')), @@ -62,7 +62,7 @@ class WirelessLANFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): class WirelessLinkFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): model = WirelessLink fieldsets = ( - (None, ('q', 'tag')), + (None, ('q', 'filter', 'tag')), ('Attributes', ('ssid', 'status',)), ('Tenant', ('tenant_group_id', 'tenant_id')), ('Authentication', ('auth_type', 'auth_cipher', 'auth_psk')), From 816fedb78dccf49c311a1bec1926d19f127a7091 Mon Sep 17 00:00:00 2001 From: Arthur Hanson Date: Wed, 2 Nov 2022 09:45:00 -0700 Subject: [PATCH 03/14] 8853 Prevent the retrieval of API tokens after creation (#10645) * 8853 hide api token * 8853 hide key on edit * 8853 add key display * 8853 cleanup html * 8853 make token view accessible only once on POST * Clean up display of tokens in views * Honor ALLOW_TOKEN_RETRIEVAL in API serializer * Add docs & tweak default setting * Include token key when provisioning with user credentials Co-authored-by: jeremystretch --- docs/configuration/security.md | 8 ++++ docs/integrations/rest-api.md | 3 ++ docs/release-notes/version-3.4.md | 1 + netbox/netbox/configuration_example.py | 3 ++ netbox/netbox/settings.py | 1 + netbox/templates/users/api_token.html | 60 ++++++++++++++++++++++++++ netbox/users/api/serializers.py | 9 +++- netbox/users/api/views.py | 2 + netbox/users/forms.py | 8 ++++ netbox/users/models.py | 11 ++--- netbox/users/tables.py | 12 +++--- netbox/users/views.py | 10 ++++- 12 files changed, 116 insertions(+), 12 deletions(-) create mode 100644 netbox/templates/users/api_token.html diff --git a/docs/configuration/security.md b/docs/configuration/security.md index 6aa363b1a..b8c2b1e11 100644 --- a/docs/configuration/security.md +++ b/docs/configuration/security.md @@ -1,5 +1,13 @@ # Security & Authentication Parameters +## ALLOW_TOKEN_RETRIEVAL + +Default: True + +If disabled, the values of API tokens will not be displayed after each token's initial creation. A user **must** record the value of a token immediately upon its creation, or it will be lost. Note that this affects _all_ users, regardless of assigned permissions. + +--- + ## ALLOWED_URL_SCHEMES !!! tip "Dynamic Configuration Parameter" diff --git a/docs/integrations/rest-api.md b/docs/integrations/rest-api.md index 3a5aed055..6f54a8cb0 100644 --- a/docs/integrations/rest-api.md +++ b/docs/integrations/rest-api.md @@ -579,6 +579,9 @@ By default, a token can be used to perform all actions via the API that a user w Additionally, a token can be set to expire at a specific time. This can be useful if an external client needs to be granted temporary access to NetBox. +!!! warning "Restricting Token Retrieval" + The ability to retrieve the key value of a previously-created API token can be restricted by disabling the [`ALLOW_TOKEN_RETRIEVAL`](../configuration/security.md#allow_token_retrieval) configuration parameter. + #### Client IP Restriction !!! note diff --git a/docs/release-notes/version-3.4.md b/docs/release-notes/version-3.4.md index 3783cc967..b6e30f2a8 100644 --- a/docs/release-notes/version-3.4.md +++ b/docs/release-notes/version-3.4.md @@ -28,6 +28,7 @@ A new `PluginMenu` class has been introduced, which enables a plugin to inject a * [#8245](https://github.com/netbox-community/netbox/issues/8245) - Enable GraphQL filtering of related objects * [#8274](https://github.com/netbox-community/netbox/issues/8274) - Enable associating a custom link with multiple object types +* [#8853](https://github.com/netbox-community/netbox/issues/8853) - Introduce the `ALLOW_TOKEN_RETRIEVAL` config parameter to restrict the display of API tokens * [#9249](https://github.com/netbox-community/netbox/issues/9249) - Device and virtual machine names are no longer case-sensitive * [#9478](https://github.com/netbox-community/netbox/issues/9478) - Add `link_peers` field to GraphQL types for cabled objects * [#9654](https://github.com/netbox-community/netbox/issues/9654) - Add `weight` field to racks, device types, and module types diff --git a/netbox/netbox/configuration_example.py b/netbox/netbox/configuration_example.py index ad0dcc7c3..b3b6fbb6c 100644 --- a/netbox/netbox/configuration_example.py +++ b/netbox/netbox/configuration_example.py @@ -72,6 +72,9 @@ ADMINS = [ # ('John Doe', 'jdoe@example.com'), ] +# Permit the retrieval of API tokens after their creation. +ALLOW_TOKEN_RETRIEVAL = False + # Enable any desired validators for local account passwords below. For a list of included validators, please see the # Django documentation at https://docs.djangoproject.com/en/stable/topics/auth/passwords/#password-validation. AUTH_PASSWORD_VALIDATORS = [ diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 2898fbd75..4e93eb149 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -71,6 +71,7 @@ DEPLOYMENT_ID = hashlib.sha256(SECRET_KEY.encode('utf-8')).hexdigest()[:16] # Set static config parameters ADMINS = getattr(configuration, 'ADMINS', []) +ALLOW_TOKEN_RETRIEVAL = getattr(configuration, 'ALLOW_TOKEN_RETRIEVAL', True) AUTH_PASSWORD_VALIDATORS = getattr(configuration, 'AUTH_PASSWORD_VALIDATORS', []) BASE_PATH = getattr(configuration, 'BASE_PATH', '') if BASE_PATH: diff --git a/netbox/templates/users/api_token.html b/netbox/templates/users/api_token.html new file mode 100644 index 000000000..1a9296704 --- /dev/null +++ b/netbox/templates/users/api_token.html @@ -0,0 +1,60 @@ +{% extends 'generic/object.html' %} +{% load form_helpers %} +{% load helpers %} +{% load plugins %} + +{% block content %} +
+
+ {% if not settings.ALLOW_TOKEN_RETRIEVAL %} + + {% endif %} +
+
Token
+
+ + + + + + + + + + + + + + + + + + + + + +
Key +
+ + + +
+
{{ key }}
+
Description{{ object.description|placeholder }}
User{{ object.user }}
Created{{ object.created|annotated_date }}
Expires + {% if object.expires %} + {{ object.expires|annotated_date }} + {% else %} + Never + {% endif %} +
+
+
+ +
+
+{% endblock %} diff --git a/netbox/users/api/serializers.py b/netbox/users/api/serializers.py index 1ec3528f7..f1f1fc975 100644 --- a/netbox/users/api/serializers.py +++ b/netbox/users/api/serializers.py @@ -1,3 +1,4 @@ +from django.conf import settings from django.contrib.auth.models import Group, User from django.contrib.contenttypes.models import ContentType from rest_framework import serializers @@ -63,7 +64,13 @@ class GroupSerializer(ValidatedModelSerializer): class TokenSerializer(ValidatedModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='users-api:token-detail') - key = serializers.CharField(min_length=40, max_length=40, allow_blank=True, required=False) + key = serializers.CharField( + min_length=40, + max_length=40, + allow_blank=True, + required=False, + write_only=not settings.ALLOW_TOKEN_RETRIEVAL + ) user = NestedUserSerializer() allowed_ips = serializers.ListField( child=IPNetworkSerializer(), diff --git a/netbox/users/api/views.py b/netbox/users/api/views.py index 66ef92ab7..86a66a01f 100644 --- a/netbox/users/api/views.py +++ b/netbox/users/api/views.py @@ -88,6 +88,8 @@ class TokenProvisionView(APIView): token = Token(user=user) token.save() data = serializers.TokenSerializer(token, context={'request': request}).data + # Manually append the token key, which is normally write-only + data['key'] = token.key return Response(data, status=HTTP_201_CREATED) diff --git a/netbox/users/forms.py b/netbox/users/forms.py index b4e86461d..048005f13 100644 --- a/netbox/users/forms.py +++ b/netbox/users/forms.py @@ -1,4 +1,5 @@ from django import forms +from django.conf import settings from django.contrib.auth.forms import AuthenticationForm, PasswordChangeForm as DjangoPasswordChangeForm from django.contrib.postgres.forms import SimpleArrayField from django.utils.html import mark_safe @@ -117,3 +118,10 @@ class TokenForm(BootstrapMixin, forms.ModelForm): widgets = { 'expires': DateTimePicker(), } + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # Omit the key field if token retrieval is not permitted + if self.instance.pk and not settings.ALLOW_TOKEN_RETRIEVAL: + del self.fields['key'] diff --git a/netbox/users/models.py b/netbox/users/models.py index 4ee4dce6b..441ed2eee 100644 --- a/netbox/users/models.py +++ b/netbox/users/models.py @@ -1,6 +1,7 @@ import binascii import os +from django.conf import settings from django.contrib.auth.models import Group, User from django.contrib.contenttypes.models import ContentType from django.contrib.postgres.fields import ArrayField @@ -230,12 +231,12 @@ class Token(models.Model): 'Ex: "10.1.1.0/24, 192.168.10.16/32, 2001:DB8:1::/64"', ) - class Meta: - pass - def __str__(self): - # Only display the last 24 bits of the token to avoid accidental exposure. - return f"{self.key[-6:]} ({self.user})" + return self.key if settings.ALLOW_TOKEN_RETRIEVAL else self.partial + + @property + def partial(self): + return f'**********************************{self.key[-6:]}' if self.key else '' def save(self, *args, **kwargs): if not self.key: diff --git a/netbox/users/tables.py b/netbox/users/tables.py index 27547b955..8fbe9e8b3 100644 --- a/netbox/users/tables.py +++ b/netbox/users/tables.py @@ -6,14 +6,16 @@ __all__ = ( ) -TOKEN = """{{ value }}""" +TOKEN = """{{ record }}""" ALLOWED_IPS = """{{ value|join:", " }}""" COPY_BUTTON = """ - - - +{% if settings.ALLOW_TOKEN_RETRIEVAL %} + + + +{% endif %} """ @@ -38,5 +40,5 @@ class TokenTable(NetBoxTable): class Meta(NetBoxTable.Meta): model = Token fields = ( - 'pk', 'key', 'write_enabled', 'created', 'expires', 'last_used', 'allowed_ips', 'description', + 'pk', 'description', 'key', 'write_enabled', 'created', 'expires', 'last_used', 'allowed_ips', ) diff --git a/netbox/users/views.py b/netbox/users/views.py index 33ef3fadd..fe1181fc1 100644 --- a/netbox/users/views.py +++ b/netbox/users/views.py @@ -273,6 +273,7 @@ class TokenEditView(LoginRequiredMixin, View): form = TokenForm(request.POST) if form.is_valid(): + token = form.save(commit=False) token.user = request.user token.save() @@ -280,7 +281,13 @@ class TokenEditView(LoginRequiredMixin, View): msg = f"Modified token {token}" if pk else f"Created token {token}" messages.success(request, msg) - if '_addanother' in request.POST: + if not pk and not settings.ALLOW_TOKEN_RETRIEVAL: + return render(request, 'users/api_token.html', { + 'object': token, + 'key': token.key, + 'return_url': reverse('users:token_list'), + }) + elif '_addanother' in request.POST: return redirect(request.path) else: return redirect('users:token_list') @@ -289,6 +296,7 @@ class TokenEditView(LoginRequiredMixin, View): 'object': token, 'form': form, 'return_url': reverse('users:token_list'), + 'disable_addanother': not settings.ALLOW_TOKEN_RETRIEVAL }) From 81c0dce5a3abf47bb6e749be60f5ea5524cf45d6 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 2 Nov 2022 15:18:07 -0400 Subject: [PATCH 04/14] Closes #10697: Move application registry into core app --- docs/release-notes/version-3.4.md | 1 + netbox/extras/management/commands/reindex.py | 2 +- netbox/extras/plugins/__init__.py | 2 +- netbox/extras/templatetags/plugins.py | 2 +- netbox/extras/tests/test_plugins.py | 2 +- netbox/extras/utils.py | 2 +- netbox/extras/webhooks.py | 2 +- netbox/netbox/context_processors.py | 2 +- netbox/netbox/denormalized.py | 2 +- netbox/netbox/graphql/schema.py | 2 +- netbox/netbox/navigation/menu.py | 2 +- netbox/netbox/preferences.py | 2 +- netbox/{extras => netbox}/registry.py | 0 netbox/netbox/search/__init__.py | 2 +- netbox/netbox/search/backends.py | 2 +- netbox/{extras => netbox}/tests/test_registry.py | 2 +- netbox/utilities/templatetags/tabs.py | 2 +- netbox/utilities/urls.py | 2 +- netbox/utilities/views.py | 2 +- 19 files changed, 18 insertions(+), 17 deletions(-) rename netbox/{extras => netbox}/registry.py (100%) rename netbox/{extras => netbox}/tests/test_registry.py (94%) diff --git a/docs/release-notes/version-3.4.md b/docs/release-notes/version-3.4.md index b6e30f2a8..43e649f7b 100644 --- a/docs/release-notes/version-3.4.md +++ b/docs/release-notes/version-3.4.md @@ -56,6 +56,7 @@ A new `PluginMenu` class has been introduced, which enables a plugin to inject a * [#9045](https://github.com/netbox-community/netbox/issues/9045) - Remove legacy ASN field from provider model * [#9046](https://github.com/netbox-community/netbox/issues/9046) - Remove legacy contact fields from provider model * [#10358](https://github.com/netbox-community/netbox/issues/10358) - Raise minimum required PostgreSQL version from 10 to 11 +* [#10697](https://github.com/netbox-community/netbox/issues/10697) - Move application registry into core app * [#10699](https://github.com/netbox-community/netbox/issues/10699) - Remove custom `import_object()` function * [#10816](https://github.com/netbox-community/netbox/issues/10816) - Pass the current request when instantiating a FilterSet within UI views diff --git a/netbox/extras/management/commands/reindex.py b/netbox/extras/management/commands/reindex.py index 6dc9bbb2d..f519688f8 100644 --- a/netbox/extras/management/commands/reindex.py +++ b/netbox/extras/management/commands/reindex.py @@ -1,7 +1,7 @@ from django.contrib.contenttypes.models import ContentType from django.core.management.base import BaseCommand, CommandError -from extras.registry import registry +from netbox.registry import registry from netbox.search.backends import search_backend diff --git a/netbox/extras/plugins/__init__.py b/netbox/extras/plugins/__init__.py index f855027e2..681c5bc29 100644 --- a/netbox/extras/plugins/__init__.py +++ b/netbox/extras/plugins/__init__.py @@ -7,8 +7,8 @@ from django.core.exceptions import ImproperlyConfigured from django.template.loader import get_template from django.utils.module_loading import import_string -from extras.registry import registry from netbox.navigation import MenuGroup +from netbox.registry import registry from netbox.search import register_search from utilities.choices import ButtonColorChoices diff --git a/netbox/extras/templatetags/plugins.py b/netbox/extras/templatetags/plugins.py index df3024a16..b2f4ec0a7 100644 --- a/netbox/extras/templatetags/plugins.py +++ b/netbox/extras/templatetags/plugins.py @@ -3,7 +3,7 @@ from django.conf import settings from django.utils.safestring import mark_safe from extras.plugins import PluginTemplateExtension -from extras.registry import registry +from netbox.registry import registry register = template_.Library() diff --git a/netbox/extras/tests/test_plugins.py b/netbox/extras/tests/test_plugins.py index 2eca3a3f7..b65d32702 100644 --- a/netbox/extras/tests/test_plugins.py +++ b/netbox/extras/tests/test_plugins.py @@ -6,9 +6,9 @@ from django.test import Client, TestCase, override_settings from django.urls import reverse from extras.plugins import PluginMenu -from extras.registry import registry from extras.tests.dummy_plugin import config as dummy_config from netbox.graphql.schema import Query +from netbox.registry import registry @skipIf('extras.tests.dummy_plugin' not in settings.PLUGINS, "dummy_plugin not in settings.PLUGINS") diff --git a/netbox/extras/utils.py b/netbox/extras/utils.py index e16807821..268bf9e80 100644 --- a/netbox/extras/utils.py +++ b/netbox/extras/utils.py @@ -3,7 +3,7 @@ from django.utils.deconstruct import deconstructible from taggit.managers import _TaggableManager from extras.constants import EXTRAS_FEATURES -from extras.registry import registry +from netbox.registry import registry def is_taggable(obj): diff --git a/netbox/extras/webhooks.py b/netbox/extras/webhooks.py index bef90a245..a93be7934 100644 --- a/netbox/extras/webhooks.py +++ b/netbox/extras/webhooks.py @@ -5,11 +5,11 @@ from django.contrib.contenttypes.models import ContentType from django.utils import timezone from django_rq import get_queue +from netbox.registry import registry from utilities.api import get_serializer_for_model from utilities.utils import serialize_object from .choices import * from .models import Webhook -from .registry import registry def serialize_for_webhook(instance): diff --git a/netbox/netbox/context_processors.py b/netbox/netbox/context_processors.py index 74178ceb4..024ca85b5 100644 --- a/netbox/netbox/context_processors.py +++ b/netbox/netbox/context_processors.py @@ -1,7 +1,7 @@ from django.conf import settings as django_settings -from extras.registry import registry from netbox.config import get_config +from netbox.registry import registry def settings_and_registry(request): diff --git a/netbox/netbox/denormalized.py b/netbox/netbox/denormalized.py index cd4a869d2..a94f83e18 100644 --- a/netbox/netbox/denormalized.py +++ b/netbox/netbox/denormalized.py @@ -3,7 +3,7 @@ import logging from django.db.models.signals import post_save from django.dispatch import receiver -from extras.registry import registry +from netbox.registry import registry logger = logging.getLogger('netbox.denormalized') diff --git a/netbox/netbox/graphql/schema.py b/netbox/netbox/graphql/schema.py index 084ac3607..82abfb4d5 100644 --- a/netbox/netbox/graphql/schema.py +++ b/netbox/netbox/graphql/schema.py @@ -3,8 +3,8 @@ import graphene from circuits.graphql.schema import CircuitsQuery from dcim.graphql.schema import DCIMQuery from extras.graphql.schema import ExtrasQuery -from extras.registry import registry from ipam.graphql.schema import IPAMQuery +from netbox.registry import registry from tenancy.graphql.schema import TenancyQuery from users.graphql.schema import UsersQuery from virtualization.graphql.schema import VirtualizationQuery diff --git a/netbox/netbox/navigation/menu.py b/netbox/netbox/navigation/menu.py index 68551827c..60c0657ae 100644 --- a/netbox/netbox/navigation/menu.py +++ b/netbox/netbox/navigation/menu.py @@ -1,4 +1,4 @@ -from extras.registry import registry +from netbox.registry import registry from . import * diff --git a/netbox/netbox/preferences.py b/netbox/netbox/preferences.py index 6bf56b562..95fd101c3 100644 --- a/netbox/netbox/preferences.py +++ b/netbox/netbox/preferences.py @@ -1,4 +1,4 @@ -from extras.registry import registry +from netbox.registry import registry from users.preferences import UserPreference from utilities.paginator import EnhancedPaginator diff --git a/netbox/extras/registry.py b/netbox/netbox/registry.py similarity index 100% rename from netbox/extras/registry.py rename to netbox/netbox/registry.py diff --git a/netbox/netbox/search/__init__.py b/netbox/netbox/search/__init__.py index 568bf8652..c05a2492b 100644 --- a/netbox/netbox/search/__init__.py +++ b/netbox/netbox/search/__init__.py @@ -2,7 +2,7 @@ from collections import namedtuple from django.db import models -from extras.registry import registry +from netbox.registry import registry ObjectFieldValue = namedtuple('ObjectFieldValue', ('name', 'type', 'weight', 'value')) diff --git a/netbox/netbox/search/backends.py b/netbox/netbox/search/backends.py index f1e00b86b..3aa6c4f47 100644 --- a/netbox/netbox/search/backends.py +++ b/netbox/netbox/search/backends.py @@ -9,7 +9,7 @@ from django.db.models.signals import post_delete, post_save from django.utils.module_loading import import_string from extras.models import CachedValue, CustomField -from extras.registry import registry +from netbox.registry import registry from utilities.querysets import RestrictedPrefetch from utilities.templatetags.builtins.filters import bettertitle from . import FieldTypes, LookupTypes, get_indexer diff --git a/netbox/extras/tests/test_registry.py b/netbox/netbox/tests/test_registry.py similarity index 94% rename from netbox/extras/tests/test_registry.py rename to netbox/netbox/tests/test_registry.py index 38a6b9f83..25f9e43ec 100644 --- a/netbox/extras/tests/test_registry.py +++ b/netbox/netbox/tests/test_registry.py @@ -1,6 +1,6 @@ from django.test import TestCase -from extras.registry import Registry +from netbox.registry import Registry class RegistryTest(TestCase): diff --git a/netbox/utilities/templatetags/tabs.py b/netbox/utilities/templatetags/tabs.py index 6f245eff3..70f40d742 100644 --- a/netbox/utilities/templatetags/tabs.py +++ b/netbox/utilities/templatetags/tabs.py @@ -2,7 +2,7 @@ from django import template from django.urls import reverse from django.utils.module_loading import import_string -from extras.registry import registry +from netbox.registry import registry register = template.Library() diff --git a/netbox/utilities/urls.py b/netbox/utilities/urls.py index 16642f589..f344b9b61 100644 --- a/netbox/utilities/urls.py +++ b/netbox/utilities/urls.py @@ -2,7 +2,7 @@ from django.urls import path from django.utils.module_loading import import_string from django.views.generic import View -from extras.registry import registry +from netbox.registry import registry def get_model_urls(app_label, model_name): diff --git a/netbox/utilities/views.py b/netbox/utilities/views.py index edad7c1b2..400f127fc 100644 --- a/netbox/utilities/views.py +++ b/netbox/utilities/views.py @@ -3,7 +3,7 @@ from django.core.exceptions import ImproperlyConfigured from django.urls import reverse from django.urls.exceptions import NoReverseMatch -from extras.registry import registry +from netbox.registry import registry from .permissions import resolve_permission __all__ = ( From 3b0a84969bf5275a5de9486f31921ef8c1b9b67c Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 2 Nov 2022 15:38:17 -0400 Subject: [PATCH 05/14] Closes #10820: Switch timezone library from pytz to zoneinfo --- docs/models/dcim/site.md | 2 +- docs/release-notes/version-3.4.md | 1 + netbox/dcim/tests/test_views.py | 7 +++---- netbox/netbox/settings.py | 1 - 4 files changed, 5 insertions(+), 6 deletions(-) diff --git a/docs/models/dcim/site.md b/docs/models/dcim/site.md index c74c209e1..2e35ab11f 100644 --- a/docs/models/dcim/site.md +++ b/docs/models/dcim/site.md @@ -33,7 +33,7 @@ Each site can have multiple [AS numbers](../ipam/asn.md) assigned to it. ### Time Zone -The site's local time zone. (Time zones are provided by the [pytz](https://pypi.org/project/pytz/) package.) +The site's local time zone. (Time zones are provided by the [zoneinfo](https://docs.python.org/3/library/zoneinfo.html) library.) ### Physical Address diff --git a/docs/release-notes/version-3.4.md b/docs/release-notes/version-3.4.md index 43e649f7b..e337c8642 100644 --- a/docs/release-notes/version-3.4.md +++ b/docs/release-notes/version-3.4.md @@ -59,6 +59,7 @@ A new `PluginMenu` class has been introduced, which enables a plugin to inject a * [#10697](https://github.com/netbox-community/netbox/issues/10697) - Move application registry into core app * [#10699](https://github.com/netbox-community/netbox/issues/10699) - Remove custom `import_object()` function * [#10816](https://github.com/netbox-community/netbox/issues/10816) - Pass the current request when instantiating a FilterSet within UI views +* [#10820](https://github.com/netbox-community/netbox/issues/10820) - Switch timezone library from pytz to zoneinfo ### REST API Changes diff --git a/netbox/dcim/tests/test_views.py b/netbox/dcim/tests/test_views.py index 8bf1c1948..4c111be52 100644 --- a/netbox/dcim/tests/test_views.py +++ b/netbox/dcim/tests/test_views.py @@ -1,7 +1,7 @@ from decimal import Decimal -import pytz import yaml +from backports.zoneinfo import ZoneInfo from django.contrib.auth.models import User from django.contrib.contenttypes.models import ContentType from django.test import override_settings @@ -12,7 +12,6 @@ from dcim.choices import * from dcim.constants import * from dcim.models import * from ipam.models import ASN, RIR, VLAN, VRF -from netbox.api.serializers import GenericObjectSerializer from tenancy.models import Tenant from utilities.testing import ViewTestCases, create_tags, create_test_device, post_data from wireless.models import WirelessLAN @@ -153,7 +152,7 @@ class SiteTestCase(ViewTestCases.PrimaryObjectViewTestCase): 'tenant': None, 'facility': 'Facility X', 'asns': [asns[6].pk, asns[7].pk], - 'time_zone': pytz.UTC, + 'time_zone': ZoneInfo('UTC'), 'description': 'Site description', 'physical_address': '742 Evergreen Terrace, Springfield, USA', 'shipping_address': '742 Evergreen Terrace, Springfield, USA', @@ -182,7 +181,7 @@ class SiteTestCase(ViewTestCases.PrimaryObjectViewTestCase): 'region': regions[1].pk, 'group': groups[1].pk, 'tenant': None, - 'time_zone': pytz.timezone('US/Eastern'), + 'time_zone': ZoneInfo('US/Eastern'), 'description': 'New description', } diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 4e93eb149..2a9d43df0 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -389,7 +389,6 @@ LANGUAGE_CODE = 'en-us' USE_I18N = True USE_L10N = False USE_TZ = True -USE_DEPRECATED_PYTZ = True # WSGI WSGI_APPLICATION = 'netbox.wsgi.application' From 8fb91a1f8c53801ac9c3ba0b9eb00d594e04cf17 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 2 Nov 2022 15:55:39 -0400 Subject: [PATCH 06/14] Closes #10821: Enable data localization --- docs/release-notes/version-3.4.md | 1 + netbox/netbox/settings.py | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/release-notes/version-3.4.md b/docs/release-notes/version-3.4.md index e337c8642..a50686158 100644 --- a/docs/release-notes/version-3.4.md +++ b/docs/release-notes/version-3.4.md @@ -60,6 +60,7 @@ A new `PluginMenu` class has been introduced, which enables a plugin to inject a * [#10699](https://github.com/netbox-community/netbox/issues/10699) - Remove custom `import_object()` function * [#10816](https://github.com/netbox-community/netbox/issues/10816) - Pass the current request when instantiating a FilterSet within UI views * [#10820](https://github.com/netbox-community/netbox/issues/10820) - Switch timezone library from pytz to zoneinfo +* [#10821](https://github.com/netbox-community/netbox/issues/10821) - Enable data localization ### REST API Changes diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 2a9d43df0..1046812e8 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -386,8 +386,8 @@ AUTHENTICATION_BACKENDS = [ # Internationalization LANGUAGE_CODE = 'en-us' -USE_I18N = True -USE_L10N = False + +# Time zones USE_TZ = True # WSGI From 0ad7ae28377f44ff7d1ed119a6ff7a8f43bf8e91 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 2 Nov 2022 16:26:26 -0400 Subject: [PATCH 07/14] Closes #10698: Omit app label from content type in table columns --- docs/release-notes/version-3.4.md | 1 + netbox/netbox/search/backends.py | 5 ++--- netbox/netbox/tables/columns.py | 4 ++-- netbox/netbox/tables/tables.py | 5 ++--- netbox/utilities/templatetags/builtins/filters.py | 4 ++-- netbox/utilities/utils.py | 15 +++++++++++++-- 6 files changed, 22 insertions(+), 12 deletions(-) diff --git a/docs/release-notes/version-3.4.md b/docs/release-notes/version-3.4.md index a50686158..b15eb0262 100644 --- a/docs/release-notes/version-3.4.md +++ b/docs/release-notes/version-3.4.md @@ -38,6 +38,7 @@ A new `PluginMenu` class has been introduced, which enables a plugin to inject a * [#10348](https://github.com/netbox-community/netbox/issues/10348) - Add decimal custom field type * [#10556](https://github.com/netbox-community/netbox/issues/10556) - Include a `display` field in all GraphQL object types * [#10595](https://github.com/netbox-community/netbox/issues/10595) - Add GraphQL relationships for additional generic foreign key fields +* [#10698](https://github.com/netbox-community/netbox/issues/10698) - Omit app label from content type in table columns * [#10761](https://github.com/netbox-community/netbox/issues/10761) - Enable associating an export template with multiple object types * [#10781](https://github.com/netbox-community/netbox/issues/10781) - Add support for Python v3.11 diff --git a/netbox/netbox/search/backends.py b/netbox/netbox/search/backends.py index 3aa6c4f47..dfc251aa9 100644 --- a/netbox/netbox/search/backends.py +++ b/netbox/netbox/search/backends.py @@ -11,7 +11,7 @@ from django.utils.module_loading import import_string from extras.models import CachedValue, CustomField from netbox.registry import registry from utilities.querysets import RestrictedPrefetch -from utilities.templatetags.builtins.filters import bettertitle +from utilities.utils import title from . import FieldTypes, LookupTypes, get_indexer DEFAULT_LOOKUP_TYPE = LookupTypes.PARTIAL @@ -34,8 +34,7 @@ class SearchBackend: # Organize choices by category categories = defaultdict(dict) for label, idx in registry['search'].items(): - title = bettertitle(idx.model._meta.verbose_name) - categories[idx.get_category()][label] = title + categories[idx.get_category()][label] = title(idx.model._meta.verbose_name) # Compile a nested tuple of choices for form rendering results = ( diff --git a/netbox/netbox/tables/columns.py b/netbox/netbox/tables/columns.py index c7545192a..5e92196e5 100644 --- a/netbox/netbox/tables/columns.py +++ b/netbox/netbox/tables/columns.py @@ -300,7 +300,7 @@ class ContentTypeColumn(tables.Column): def render(self, value): if value is None: return None - return content_type_name(value) + return content_type_name(value, include_app=False) def value(self, value): if value is None: @@ -319,7 +319,7 @@ class ContentTypesColumn(tables.ManyToManyColumn): super().__init__(separator=separator, *args, **kwargs) def transform(self, obj): - return content_type_name(obj) + return content_type_name(obj, include_app=False) def value(self, value): return ','.join([ diff --git a/netbox/netbox/tables/tables.py b/netbox/netbox/tables/tables.py index 50c109be8..3a2e71084 100644 --- a/netbox/netbox/tables/tables.py +++ b/netbox/netbox/tables/tables.py @@ -12,8 +12,7 @@ from extras.models import CustomField, CustomLink from extras.choices import CustomFieldVisibilityChoices from netbox.tables import columns from utilities.paginator import EnhancedPaginator, get_paginate_count -from utilities.templatetags.builtins.filters import bettertitle -from utilities.utils import highlight_string +from utilities.utils import highlight_string, title __all__ = ( 'BaseTable', @@ -223,7 +222,7 @@ class SearchTable(tables.Table): def render_field(self, value, record): if hasattr(record.object, value): - return bettertitle(record.object._meta.get_field(value).verbose_name) + return title(record.object._meta.get_field(value).verbose_name) return value def render_value(self, value): diff --git a/netbox/utilities/templatetags/builtins/filters.py b/netbox/utilities/templatetags/builtins/filters.py index 6b548a89d..8c9315ffe 100644 --- a/netbox/utilities/templatetags/builtins/filters.py +++ b/netbox/utilities/templatetags/builtins/filters.py @@ -11,7 +11,7 @@ from markdown import markdown from netbox.config import get_config from utilities.markdown import StrikethroughExtension -from utilities.utils import clean_html, foreground_color +from utilities.utils import clean_html, foreground_color, title register = template.Library() @@ -46,7 +46,7 @@ def bettertitle(value): Alternative to the builtin title(). Ensures that the first letter of each word is uppercase but retains the original case of all others. """ - return ' '.join([w[0].upper() + w[1:] for w in value.split()]) + return title(value) @register.filter() diff --git a/netbox/utilities/utils.py b/netbox/utilities/utils.py index e1fbbfe84..a5bccfbea 100644 --- a/netbox/utilities/utils.py +++ b/netbox/utilities/utils.py @@ -21,6 +21,13 @@ from netbox.config import get_config from utilities.constants import HTTP_REQUEST_META_SAFE_COPY +def title(value): + """ + Improved implementation of str.title(); retains all existing uppercase letters. + """ + return ' '.join([w[0].upper() + w[1:] for w in str(value).split()]) + + def get_viewname(model, action=None, rest_api=False): """ Return the view name for the given model and action, if valid. @@ -393,13 +400,17 @@ def array_to_string(array): return ', '.join(ret) -def content_type_name(ct): +def content_type_name(ct, include_app=True): """ Return a human-friendly ContentType name (e.g. "DCIM > Site"). """ try: meta = ct.model_class()._meta - return f'{meta.app_config.verbose_name} > {meta.verbose_name}' + app_label = title(meta.app_config.verbose_name) + model_name = title(meta.verbose_name) + if include_app: + return f'{app_label} > {model_name}' + return model_name except AttributeError: # Model no longer exists return f'{ct.app_label} > {ct.model}' From 07730ccd33080d5160604d8867b0a7015f7de0dd Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 2 Nov 2022 16:29:42 -0400 Subject: [PATCH 08/14] #10820: Fix zoneinfo import for py3.9+ --- netbox/dcim/tests/test_views.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/netbox/dcim/tests/test_views.py b/netbox/dcim/tests/test_views.py index 4c111be52..d563dcfd6 100644 --- a/netbox/dcim/tests/test_views.py +++ b/netbox/dcim/tests/test_views.py @@ -1,7 +1,11 @@ from decimal import Decimal +try: + from zoneinfo import ZoneInfo +except ImportError: + # Python 3.8 + from backports.zoneinfo import ZoneInfo import yaml -from backports.zoneinfo import ZoneInfo from django.contrib.auth.models import User from django.contrib.contenttypes.models import ContentType from django.test import override_settings From e7f54c5867cf49126bbf95e28633e4283c2bbcb2 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 3 Nov 2022 12:59:01 -0400 Subject: [PATCH 09/14] Reorganize plugin resources --- netbox/extras/plugins/__init__.py | 195 +------------------------- netbox/extras/plugins/navigation.py | 66 +++++++++ netbox/extras/plugins/registration.py | 64 +++++++++ netbox/extras/plugins/templates.py | 65 +++++++++ 4 files changed, 199 insertions(+), 191 deletions(-) create mode 100644 netbox/extras/plugins/navigation.py create mode 100644 netbox/extras/plugins/registration.py create mode 100644 netbox/extras/plugins/templates.py diff --git a/netbox/extras/plugins/__init__.py b/netbox/extras/plugins/__init__.py index 681c5bc29..6fa22c4a3 100644 --- a/netbox/extras/plugins/__init__.py +++ b/netbox/extras/plugins/__init__.py @@ -1,17 +1,15 @@ import collections -import inspect -from packaging import version from django.apps import AppConfig from django.core.exceptions import ImproperlyConfigured -from django.template.loader import get_template from django.utils.module_loading import import_string +from packaging import version -from netbox.navigation import MenuGroup from netbox.registry import registry from netbox.search import register_search -from utilities.choices import ButtonColorChoices - +from .navigation import * +from .registration import * +from .templates import * # Initialize plugin registry registry['plugins'] = { @@ -142,188 +140,3 @@ class PluginConfig(AppConfig): for setting, value in cls.default_settings.items(): if setting not in user_config: user_config[setting] = value - - -# -# Template content injection -# - -class PluginTemplateExtension: - """ - This class is used to register plugin content to be injected into core NetBox templates. It contains methods - that are overridden by plugin authors to return template content. - - The `model` attribute on the class defines the which model detail page this class renders content for. It - should be set as a string in the form '.'. render() provides the following context data: - - * object - The object being viewed - * request - The current request - * settings - Global NetBox settings - * config - Plugin-specific configuration parameters - """ - model = None - - def __init__(self, context): - self.context = context - - def render(self, template_name, extra_context=None): - """ - Convenience method for rendering the specified Django template using the default context data. An additional - context dictionary may be passed as `extra_context`. - """ - if extra_context is None: - extra_context = {} - elif not isinstance(extra_context, dict): - raise TypeError("extra_context must be a dictionary") - - return get_template(template_name).render({**self.context, **extra_context}) - - def left_page(self): - """ - Content that will be rendered on the left of the detail page view. Content should be returned as an - HTML string. Note that content does not need to be marked as safe because this is automatically handled. - """ - raise NotImplementedError - - def right_page(self): - """ - Content that will be rendered on the right of the detail page view. Content should be returned as an - HTML string. Note that content does not need to be marked as safe because this is automatically handled. - """ - raise NotImplementedError - - def full_width_page(self): - """ - Content that will be rendered within the full width of the detail page view. Content should be returned as an - HTML string. Note that content does not need to be marked as safe because this is automatically handled. - """ - raise NotImplementedError - - def buttons(self): - """ - Buttons that will be rendered and added to the existing list of buttons on the detail page view. Content - should be returned as an HTML string. Note that content does not need to be marked as safe because this is - automatically handled. - """ - raise NotImplementedError - - -def register_template_extensions(class_list): - """ - Register a list of PluginTemplateExtension classes - """ - # Validation - for template_extension in class_list: - if not inspect.isclass(template_extension): - raise TypeError(f"PluginTemplateExtension class {template_extension} was passed as an instance!") - if not issubclass(template_extension, PluginTemplateExtension): - raise TypeError(f"{template_extension} is not a subclass of extras.plugins.PluginTemplateExtension!") - if template_extension.model is None: - raise TypeError(f"PluginTemplateExtension class {template_extension} does not define a valid model!") - - registry['plugins']['template_extensions'][template_extension.model].append(template_extension) - - -# -# Navigation menu links -# - -class PluginMenu: - icon_class = 'mdi mdi-puzzle' - - def __init__(self, label, groups, icon_class=None): - self.label = label - self.groups = [ - MenuGroup(label, items) for label, items in groups - ] - if icon_class is not None: - self.icon_class = icon_class - - -class PluginMenuItem: - """ - This class represents a navigation menu item. This constitutes primary link and its text, but also allows for - specifying additional link buttons that appear to the right of the item in the van menu. - - Links are specified as Django reverse URL strings. - Buttons are each specified as a list of PluginMenuButton instances. - """ - permissions = [] - buttons = [] - - def __init__(self, link, link_text, permissions=None, buttons=None): - self.link = link - self.link_text = link_text - if permissions is not None: - if type(permissions) not in (list, tuple): - raise TypeError("Permissions must be passed as a tuple or list.") - self.permissions = permissions - if buttons is not None: - if type(buttons) not in (list, tuple): - raise TypeError("Buttons must be passed as a tuple or list.") - self.buttons = buttons - - -class PluginMenuButton: - """ - This class represents a button within a PluginMenuItem. Note that button colors should come from - ButtonColorChoices. - """ - color = ButtonColorChoices.DEFAULT - permissions = [] - - def __init__(self, link, title, icon_class, color=None, permissions=None): - self.link = link - self.title = title - self.icon_class = icon_class - if permissions is not None: - if type(permissions) not in (list, tuple): - raise TypeError("Permissions must be passed as a tuple or list.") - self.permissions = permissions - if color is not None: - if color not in ButtonColorChoices.values(): - raise ValueError("Button color must be a choice within ButtonColorChoices.") - self.color = color - - -def register_menu(menu): - if not isinstance(menu, PluginMenu): - raise TypeError(f"{menu} must be an instance of extras.plugins.PluginMenu") - registry['plugins']['menus'].append(menu) - - -def register_menu_items(section_name, class_list): - """ - Register a list of PluginMenuItem instances for a given menu section (e.g. plugin name) - """ - # Validation - for menu_link in class_list: - if not isinstance(menu_link, PluginMenuItem): - raise TypeError(f"{menu_link} must be an instance of extras.plugins.PluginMenuItem") - for button in menu_link.buttons: - if not isinstance(button, PluginMenuButton): - raise TypeError(f"{button} must be an instance of extras.plugins.PluginMenuButton") - - registry['plugins']['menu_items'][section_name] = class_list - - -# -# GraphQL schemas -# - -def register_graphql_schema(graphql_schema): - """ - Register a GraphQL schema class for inclusion in NetBox's GraphQL API. - """ - registry['plugins']['graphql_schemas'].append(graphql_schema) - - -# -# User preferences -# - -def register_user_preferences(plugin_name, preferences): - """ - Register a list of user preferences defined by a plugin. - """ - registry['plugins']['preferences'][plugin_name] = preferences diff --git a/netbox/extras/plugins/navigation.py b/netbox/extras/plugins/navigation.py new file mode 100644 index 000000000..193be6cfb --- /dev/null +++ b/netbox/extras/plugins/navigation.py @@ -0,0 +1,66 @@ +from netbox.navigation import MenuGroup +from utilities.choices import ButtonColorChoices + +__all__ = ( + 'PluginMenu', + 'PluginMenuButton', + 'PluginMenuItem', +) + + +class PluginMenu: + icon_class = 'mdi mdi-puzzle' + + def __init__(self, label, groups, icon_class=None): + self.label = label + self.groups = [ + MenuGroup(label, items) for label, items in groups + ] + if icon_class is not None: + self.icon_class = icon_class + + +class PluginMenuItem: + """ + This class represents a navigation menu item. This constitutes primary link and its text, but also allows for + specifying additional link buttons that appear to the right of the item in the van menu. + + Links are specified as Django reverse URL strings. + Buttons are each specified as a list of PluginMenuButton instances. + """ + permissions = [] + buttons = [] + + def __init__(self, link, link_text, permissions=None, buttons=None): + self.link = link + self.link_text = link_text + if permissions is not None: + if type(permissions) not in (list, tuple): + raise TypeError("Permissions must be passed as a tuple or list.") + self.permissions = permissions + if buttons is not None: + if type(buttons) not in (list, tuple): + raise TypeError("Buttons must be passed as a tuple or list.") + self.buttons = buttons + + +class PluginMenuButton: + """ + This class represents a button within a PluginMenuItem. Note that button colors should come from + ButtonColorChoices. + """ + color = ButtonColorChoices.DEFAULT + permissions = [] + + def __init__(self, link, title, icon_class, color=None, permissions=None): + self.link = link + self.title = title + self.icon_class = icon_class + if permissions is not None: + if type(permissions) not in (list, tuple): + raise TypeError("Permissions must be passed as a tuple or list.") + self.permissions = permissions + if color is not None: + if color not in ButtonColorChoices.values(): + raise ValueError("Button color must be a choice within ButtonColorChoices.") + self.color = color diff --git a/netbox/extras/plugins/registration.py b/netbox/extras/plugins/registration.py new file mode 100644 index 000000000..5b7e58172 --- /dev/null +++ b/netbox/extras/plugins/registration.py @@ -0,0 +1,64 @@ +import inspect + +from netbox.registry import registry +from .navigation import PluginMenu, PluginMenuButton, PluginMenuItem +from .templates import PluginTemplateExtension + +__all__ = ( + 'register_graphql_schema', + 'register_menu', + 'register_menu_items', + 'register_template_extensions', + 'register_user_preferences', +) + + +def register_template_extensions(class_list): + """ + Register a list of PluginTemplateExtension classes + """ + # Validation + for template_extension in class_list: + if not inspect.isclass(template_extension): + raise TypeError(f"PluginTemplateExtension class {template_extension} was passed as an instance!") + if not issubclass(template_extension, PluginTemplateExtension): + raise TypeError(f"{template_extension} is not a subclass of extras.plugins.PluginTemplateExtension!") + if template_extension.model is None: + raise TypeError(f"PluginTemplateExtension class {template_extension} does not define a valid model!") + + registry['plugins']['template_extensions'][template_extension.model].append(template_extension) + + +def register_menu(menu): + if not isinstance(menu, PluginMenu): + raise TypeError(f"{menu} must be an instance of extras.plugins.PluginMenu") + registry['plugins']['menus'].append(menu) + + +def register_menu_items(section_name, class_list): + """ + Register a list of PluginMenuItem instances for a given menu section (e.g. plugin name) + """ + # Validation + for menu_link in class_list: + if not isinstance(menu_link, PluginMenuItem): + raise TypeError(f"{menu_link} must be an instance of extras.plugins.PluginMenuItem") + for button in menu_link.buttons: + if not isinstance(button, PluginMenuButton): + raise TypeError(f"{button} must be an instance of extras.plugins.PluginMenuButton") + + registry['plugins']['menu_items'][section_name] = class_list + + +def register_graphql_schema(graphql_schema): + """ + Register a GraphQL schema class for inclusion in NetBox's GraphQL API. + """ + registry['plugins']['graphql_schemas'].append(graphql_schema) + + +def register_user_preferences(plugin_name, preferences): + """ + Register a list of user preferences defined by a plugin. + """ + registry['plugins']['preferences'][plugin_name] = preferences diff --git a/netbox/extras/plugins/templates.py b/netbox/extras/plugins/templates.py new file mode 100644 index 000000000..5f3d038c6 --- /dev/null +++ b/netbox/extras/plugins/templates.py @@ -0,0 +1,65 @@ +from django.template.loader import get_template + +__all__ = ( + 'PluginTemplateExtension', +) + + +class PluginTemplateExtension: + """ + This class is used to register plugin content to be injected into core NetBox templates. It contains methods + that are overridden by plugin authors to return template content. + + The `model` attribute on the class defines the which model detail page this class renders content for. It + should be set as a string in the form '.'. render() provides the following context data: + + * object - The object being viewed + * request - The current request + * settings - Global NetBox settings + * config - Plugin-specific configuration parameters + """ + model = None + + def __init__(self, context): + self.context = context + + def render(self, template_name, extra_context=None): + """ + Convenience method for rendering the specified Django template using the default context data. An additional + context dictionary may be passed as `extra_context`. + """ + if extra_context is None: + extra_context = {} + elif not isinstance(extra_context, dict): + raise TypeError("extra_context must be a dictionary") + + return get_template(template_name).render({**self.context, **extra_context}) + + def left_page(self): + """ + Content that will be rendered on the left of the detail page view. Content should be returned as an + HTML string. Note that content does not need to be marked as safe because this is automatically handled. + """ + raise NotImplementedError + + def right_page(self): + """ + Content that will be rendered on the right of the detail page view. Content should be returned as an + HTML string. Note that content does not need to be marked as safe because this is automatically handled. + """ + raise NotImplementedError + + def full_width_page(self): + """ + Content that will be rendered within the full width of the detail page view. Content should be returned as an + HTML string. Note that content does not need to be marked as safe because this is automatically handled. + """ + raise NotImplementedError + + def buttons(self): + """ + Buttons that will be rendered and added to the existing list of buttons on the detail page view. Content + should be returned as an HTML string. Note that content does not need to be marked as safe because this is + automatically handled. + """ + raise NotImplementedError From 13afc526172af8bbaacbdc7346507d41cd2ce052 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 3 Nov 2022 13:18:58 -0400 Subject: [PATCH 10/14] Closes #10543: Introduce get_plugin_config() utility function --- docs/plugins/development/index.md | 6 +++--- netbox/extras/plugins/__init__.py | 21 +++++++++++++++++++++ netbox/extras/tests/test_plugins.py | 12 +++++++++++- 3 files changed, 35 insertions(+), 4 deletions(-) diff --git a/docs/plugins/development/index.md b/docs/plugins/development/index.md index cad77c7fe..dcbad9d8d 100644 --- a/docs/plugins/development/index.md +++ b/docs/plugins/development/index.md @@ -117,11 +117,11 @@ NetBox looks for the `config` variable within a plugin's `__init__.py` to load i All required settings must be configured by the user. If a configuration parameter is listed in both `required_settings` and `default_settings`, the default setting will be ignored. !!! tip "Accessing Config Parameters" - Plugin configuration parameters can be accessed in `settings.PLUGINS_CONFIG`, mapped by plugin name. For example: + Plugin configuration parameters can be accessed using the `get_plugin_config()` function. For example: ```python - from django.conf import settings - settings.PLUGINS_CONFIG['myplugin']['verbose_name'] + from extras.plugins import get_plugin_config + get_plugin_config('my_plugin', 'verbose_name') ``` #### Important Notes About `django_apps` diff --git a/netbox/extras/plugins/__init__.py b/netbox/extras/plugins/__init__.py index 6fa22c4a3..7694a1fbe 100644 --- a/netbox/extras/plugins/__init__.py +++ b/netbox/extras/plugins/__init__.py @@ -1,6 +1,7 @@ import collections from django.apps import AppConfig +from django.conf import settings from django.core.exceptions import ImproperlyConfigured from django.utils.module_loading import import_string from packaging import version @@ -140,3 +141,23 @@ class PluginConfig(AppConfig): for setting, value in cls.default_settings.items(): if setting not in user_config: user_config[setting] = value + + +# +# Utilities +# + +def get_plugin_config(plugin_name, parameter, default=None): + """ + Return the value of the specified plugin configuration parameter. + + Args: + plugin_name: The name of the plugin + parameter: The name of the configuration parameter + default: The value to return if the parameter is not defined (default: None) + """ + try: + plugin_config = settings.PLUGINS_CONFIG[plugin_name] + return plugin_config.get(parameter, default) + except KeyError: + raise ImproperlyConfigured(f"Plugin {plugin_name} is not registered.") diff --git a/netbox/extras/tests/test_plugins.py b/netbox/extras/tests/test_plugins.py index b65d32702..e20dccbd9 100644 --- a/netbox/extras/tests/test_plugins.py +++ b/netbox/extras/tests/test_plugins.py @@ -5,7 +5,7 @@ from django.core.exceptions import ImproperlyConfigured from django.test import Client, TestCase, override_settings from django.urls import reverse -from extras.plugins import PluginMenu +from extras.plugins import PluginMenu, get_plugin_config from extras.tests.dummy_plugin import config as dummy_config from netbox.graphql.schema import Query from netbox.registry import registry @@ -173,3 +173,13 @@ class PluginTest(TestCase): self.assertIn(DummyQuery, registry['plugins']['graphql_schemas']) self.assertTrue(issubclass(Query, DummyQuery)) + + @override_settings(PLUGINS_CONFIG={'extras.tests.dummy_plugin': {'foo': 123}}) + def test_get_plugin_config(self): + """ + Validate that get_plugin_config() returns config parameters correctly. + """ + plugin = 'extras.tests.dummy_plugin' + self.assertEqual(get_plugin_config(plugin, 'foo'), 123) + self.assertEqual(get_plugin_config(plugin, 'bar'), None) + self.assertEqual(get_plugin_config(plugin, 'bar', default=456), 456) From 6b2deaeced3d088a80ea910b2155ba048fa76414 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 3 Nov 2022 13:29:24 -0400 Subject: [PATCH 11/14] Closes #8485: Enable journaling for all organizational models --- docs/features/journaling.md | 2 +- docs/release-notes/version-3.4.md | 2 ++ netbox/netbox/models/__init__.py | 3 ++- 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/docs/features/journaling.md b/docs/features/journaling.md index ce126bf27..8aebdb446 100644 --- a/docs/features/journaling.md +++ b/docs/features/journaling.md @@ -1,5 +1,5 @@ # Journaling -All primary objects in NetBox support journaling. A journal is a collection of human-generated notes and comments about an object maintained for historical context. It supplements NetBox's change log to provide additional information about why changes have been made or to convey events which occur outside NetBox. Unlike the change log, in which records typically expire after a configurable period of time, journal entries persist for the life of their associated object. +All primary and organizational models in NetBox support journaling. A journal is a collection of human-generated notes and comments about an object maintained for historical context. It supplements NetBox's change log to provide additional information about why changes have been made or to convey events which occur outside NetBox. Unlike the change log, in which records typically expire after a configurable period of time, journal entries persist for the life of their associated object. Each journal entry has a selectable kind (info, success, warning, or danger) and a user-populated `comments` field. Each entry automatically records the date, time, and associated user upon being created. diff --git a/docs/release-notes/version-3.4.md b/docs/release-notes/version-3.4.md index b15eb0262..cc9fc90d2 100644 --- a/docs/release-notes/version-3.4.md +++ b/docs/release-notes/version-3.4.md @@ -28,6 +28,7 @@ A new `PluginMenu` class has been introduced, which enables a plugin to inject a * [#8245](https://github.com/netbox-community/netbox/issues/8245) - Enable GraphQL filtering of related objects * [#8274](https://github.com/netbox-community/netbox/issues/8274) - Enable associating a custom link with multiple object types +* [#8485](https://github.com/netbox-community/netbox/issues/8485) - Enable journaling for all organizational models * [#8853](https://github.com/netbox-community/netbox/issues/8853) - Introduce the `ALLOW_TOKEN_RETRIEVAL` config parameter to restrict the display of API tokens * [#9249](https://github.com/netbox-community/netbox/issues/9249) - Device and virtual machine names are no longer case-sensitive * [#9478](https://github.com/netbox-community/netbox/issues/9478) - Add `link_peers` field to GraphQL types for cabled objects @@ -50,6 +51,7 @@ A new `PluginMenu` class has been introduced, which enables a plugin to inject a * [#9880](https://github.com/netbox-community/netbox/issues/9880) - Introduce `django_apps` plugin configuration parameter * [#9887](https://github.com/netbox-community/netbox/issues/9887) - Inspect `docs_url` property to determine link to model documentation * [#10314](https://github.com/netbox-community/netbox/issues/10314) - Move `clone()` method from NetBoxModel to CloningMixin +* [#10543](https://github.com/netbox-community/netbox/issues/10543) - Introduce `get_plugin_config()` utility function * [#10739](https://github.com/netbox-community/netbox/issues/10739) - Introduce `get_queryset()` method on generic views ### Other Changes diff --git a/netbox/netbox/models/__init__.py b/netbox/netbox/models/__init__.py index 2f2dc1c9f..f4f28030d 100644 --- a/netbox/netbox/models/__init__.py +++ b/netbox/netbox/models/__init__.py @@ -21,6 +21,7 @@ class NetBoxFeatureSet( CustomLinksMixin, CustomValidationMixin, ExportTemplatesMixin, + JournalingMixin, TagsMixin, WebhooksMixin ): @@ -55,7 +56,7 @@ class ChangeLoggedModel(ChangeLoggingMixin, CustomValidationMixin, models.Model) abstract = True -class NetBoxModel(CloningMixin, JournalingMixin, NetBoxFeatureSet, models.Model): +class NetBoxModel(CloningMixin, NetBoxFeatureSet, models.Model): """ Primary models represent real objects within the infrastructure being modeled. """ From e2f5ee661a384c380bfd81e043b65b2eea9f4d12 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 3 Nov 2022 13:59:44 -0400 Subject: [PATCH 12/14] Clean up redundant NestedGroupModel, OrganizationalModel fields --- netbox/circuits/migrations/0001_squashed.py | 2 +- netbox/circuits/models/circuits.py | 19 ------- netbox/dcim/migrations/0001_squashed.py | 8 +-- .../dcim/migrations/0147_inventoryitemrole.py | 2 +- netbox/dcim/models/device_components.py | 18 ------ netbox/dcim/models/devices.py | 55 ------------------ netbox/dcim/models/racks.py | 18 ------ netbox/dcim/models/sites.py | 57 ------------------- netbox/ipam/migrations/0001_squashed.py | 4 +- netbox/ipam/models/ip.py | 31 +--------- netbox/ipam/models/vlans.py | 3 - netbox/netbox/models/__init__.py | 6 ++ netbox/tenancy/migrations/0003_contacts.py | 2 +- netbox/tenancy/models/contacts.py | 38 ------------- netbox/tenancy/models/tenants.py | 12 ---- .../migrations/0001_squashed_0022.py | 4 +- netbox/virtualization/models.py | 38 ------------- netbox/wireless/models.py | 15 ----- 18 files changed, 19 insertions(+), 313 deletions(-) diff --git a/netbox/circuits/migrations/0001_squashed.py b/netbox/circuits/migrations/0001_squashed.py index 971233162..656eb35a1 100644 --- a/netbox/circuits/migrations/0001_squashed.py +++ b/netbox/circuits/migrations/0001_squashed.py @@ -65,7 +65,7 @@ class Migration(migrations.Migration): ('description', models.CharField(blank=True, max_length=200)), ], options={ - 'ordering': ['name'], + 'ordering': ('name',), }, ), migrations.CreateModel( diff --git a/netbox/circuits/models/circuits.py b/netbox/circuits/models/circuits.py index ea74eeb40..7100c9796 100644 --- a/netbox/circuits/models/circuits.py +++ b/netbox/circuits/models/circuits.py @@ -23,25 +23,6 @@ class CircuitType(OrganizationalModel): Circuits can be organized by their functional role. For example, a user might wish to define CircuitTypes named "Long Haul," "Metro," or "Out-of-Band". """ - name = models.CharField( - max_length=100, - unique=True - ) - slug = models.SlugField( - max_length=100, - unique=True - ) - description = models.CharField( - max_length=200, - blank=True, - ) - - class Meta: - ordering = ['name'] - - def __str__(self): - return self.name - def get_absolute_url(self): return reverse('circuits:circuittype', args=[self.pk]) diff --git a/netbox/dcim/migrations/0001_squashed.py b/netbox/dcim/migrations/0001_squashed.py index fca7d8eb9..3d7156e17 100644 --- a/netbox/dcim/migrations/0001_squashed.py +++ b/netbox/dcim/migrations/0001_squashed.py @@ -195,7 +195,7 @@ class Migration(migrations.Migration): ('description', models.CharField(blank=True, max_length=200)), ], options={ - 'ordering': ['name'], + 'ordering': ('name',), }, ), migrations.CreateModel( @@ -352,7 +352,7 @@ class Migration(migrations.Migration): ('description', models.CharField(blank=True, max_length=200)), ], options={ - 'ordering': ['name'], + 'ordering': ('name',), }, ), migrations.CreateModel( @@ -369,7 +369,7 @@ class Migration(migrations.Migration): ('description', models.CharField(blank=True, max_length=200)), ], options={ - 'ordering': ['name'], + 'ordering': ('name',), }, ), migrations.CreateModel( @@ -538,7 +538,7 @@ class Migration(migrations.Migration): ('description', models.CharField(blank=True, max_length=200)), ], options={ - 'ordering': ['name'], + 'ordering': ('name',), }, ), migrations.CreateModel( diff --git a/netbox/dcim/migrations/0147_inventoryitemrole.py b/netbox/dcim/migrations/0147_inventoryitemrole.py index cbdd36c08..4b6c27450 100644 --- a/netbox/dcim/migrations/0147_inventoryitemrole.py +++ b/netbox/dcim/migrations/0147_inventoryitemrole.py @@ -27,7 +27,7 @@ class Migration(migrations.Migration): ('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')), ], options={ - 'ordering': ['name'], + 'ordering': ('name',), }, ), migrations.AddField( diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index 59d63ef7b..8855107b3 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -1025,27 +1025,9 @@ class InventoryItemRole(OrganizationalModel): """ Inventory items may optionally be assigned a functional role. """ - name = models.CharField( - max_length=100, - unique=True - ) - slug = models.SlugField( - max_length=100, - unique=True - ) color = ColorField( default=ColorChoices.COLOR_GREY ) - description = models.CharField( - max_length=200, - blank=True, - ) - - class Meta: - ordering = ['name'] - - def __str__(self): - return self.name def get_absolute_url(self): return reverse('dcim:inventoryitemrole', args=[self.pk]) diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py index d4646762f..3710bf7f4 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -45,30 +45,11 @@ class Manufacturer(OrganizationalModel): """ A Manufacturer represents a company which produces hardware devices; for example, Juniper or Dell. """ - name = models.CharField( - max_length=100, - unique=True - ) - slug = models.SlugField( - max_length=100, - unique=True - ) - description = models.CharField( - max_length=200, - blank=True - ) - # Generic relations contacts = GenericRelation( to='tenancy.ContactAssignment' ) - class Meta: - ordering = ['name'] - - def __str__(self): - return self.name - def get_absolute_url(self): return reverse('dcim:manufacturer', args=[self.pk]) @@ -418,14 +399,6 @@ class DeviceRole(OrganizationalModel): color to be used when displaying rack elevations. The vm_role field determines whether the role is applicable to virtual machines as well. """ - name = models.CharField( - max_length=100, - unique=True - ) - slug = models.SlugField( - max_length=100, - unique=True - ) color = ColorField( default=ColorChoices.COLOR_GREY ) @@ -434,16 +407,6 @@ class DeviceRole(OrganizationalModel): verbose_name='VM Role', help_text='Virtual machines may be assigned to this role' ) - description = models.CharField( - max_length=200, - blank=True, - ) - - class Meta: - ordering = ['name'] - - def __str__(self): - return self.name def get_absolute_url(self): return reverse('dcim:devicerole', args=[self.pk]) @@ -455,14 +418,6 @@ class Platform(OrganizationalModel): NetBox uses Platforms to determine how to interact with devices when pulling inventory data or other information by specifying a NAPALM driver. """ - name = models.CharField( - max_length=100, - unique=True - ) - slug = models.SlugField( - max_length=100, - unique=True - ) manufacturer = models.ForeignKey( to='dcim.Manufacturer', on_delete=models.PROTECT, @@ -483,16 +438,6 @@ class Platform(OrganizationalModel): verbose_name='NAPALM arguments', help_text='Additional arguments to pass when initiating the NAPALM driver (JSON format)' ) - description = models.CharField( - max_length=200, - blank=True - ) - - class Meta: - ordering = ['name'] - - def __str__(self): - return self.name def get_absolute_url(self): return reverse('dcim:platform', args=[self.pk]) diff --git a/netbox/dcim/models/racks.py b/netbox/dcim/models/racks.py index 6fcd65a19..e61765e69 100644 --- a/netbox/dcim/models/racks.py +++ b/netbox/dcim/models/racks.py @@ -38,27 +38,9 @@ class RackRole(OrganizationalModel): """ Racks can be organized by functional role, similar to Devices. """ - name = models.CharField( - max_length=100, - unique=True - ) - slug = models.SlugField( - max_length=100, - unique=True - ) color = ColorField( default=ColorChoices.COLOR_GREY ) - description = models.CharField( - max_length=200, - blank=True, - ) - - class Meta: - ordering = ['name'] - - def __str__(self): - return self.name def get_absolute_url(self): return reverse('dcim:rackrole', args=[self.pk]) diff --git a/netbox/dcim/models/sites.py b/netbox/dcim/models/sites.py index 9ddadace2..c352b69de 100644 --- a/netbox/dcim/models/sites.py +++ b/netbox/dcim/models/sites.py @@ -2,7 +2,6 @@ from django.contrib.contenttypes.fields import GenericRelation from django.core.exceptions import ValidationError from django.db import models from django.urls import reverse -from mptt.models import TreeForeignKey from timezone_field import TimeZoneField from dcim.choices import * @@ -28,25 +27,6 @@ class Region(NestedGroupModel): states, and/or cities. Regions are recursively nested into a hierarchy: all sites belonging to a child region are also considered to be members of its parent and ancestor region(s). """ - parent = TreeForeignKey( - to='self', - on_delete=models.CASCADE, - related_name='children', - blank=True, - null=True, - db_index=True - ) - name = models.CharField( - max_length=100 - ) - slug = models.SlugField( - max_length=100 - ) - description = models.CharField( - max_length=200, - blank=True - ) - # Generic relations vlan_groups = GenericRelation( to='ipam.VLANGroup', @@ -102,25 +82,6 @@ class SiteGroup(NestedGroupModel): within corporate sites you might distinguish between offices and data centers. Like regions, site groups can be nested recursively to form a hierarchy. """ - parent = TreeForeignKey( - to='self', - on_delete=models.CASCADE, - related_name='children', - blank=True, - null=True, - db_index=True - ) - name = models.CharField( - max_length=100 - ) - slug = models.SlugField( - max_length=100 - ) - description = models.CharField( - max_length=200, - blank=True - ) - # Generic relations vlan_groups = GenericRelation( to='ipam.VLANGroup', @@ -298,25 +259,11 @@ class Location(NestedGroupModel): A Location represents a subgroup of Racks and/or Devices within a Site. A Location may represent a building within a site, or a room within a building, for example. """ - name = models.CharField( - max_length=100 - ) - slug = models.SlugField( - max_length=100 - ) site = models.ForeignKey( to='dcim.Site', on_delete=models.CASCADE, related_name='locations' ) - parent = TreeForeignKey( - to='self', - on_delete=models.CASCADE, - related_name='children', - blank=True, - null=True, - db_index=True - ) status = models.CharField( max_length=50, choices=LocationStatusChoices, @@ -329,10 +276,6 @@ class Location(NestedGroupModel): blank=True, null=True ) - description = models.CharField( - max_length=200, - blank=True - ) # Generic relations vlan_groups = GenericRelation( diff --git a/netbox/ipam/migrations/0001_squashed.py b/netbox/ipam/migrations/0001_squashed.py index b5d68439a..bef36e698 100644 --- a/netbox/ipam/migrations/0001_squashed.py +++ b/netbox/ipam/migrations/0001_squashed.py @@ -91,7 +91,7 @@ class Migration(migrations.Migration): options={ 'verbose_name': 'RIR', 'verbose_name_plural': 'RIRs', - 'ordering': ['name'], + 'ordering': ('name',), }, ), migrations.CreateModel( @@ -107,7 +107,7 @@ class Migration(migrations.Migration): ('description', models.CharField(blank=True, max_length=200)), ], options={ - 'ordering': ['weight', 'name'], + 'ordering': ('weight', 'name'), }, ), migrations.CreateModel( diff --git a/netbox/ipam/models/ip.py b/netbox/ipam/models/ip.py index 456bab4f0..75f90ff54 100644 --- a/netbox/ipam/models/ip.py +++ b/netbox/ipam/models/ip.py @@ -61,32 +61,17 @@ class RIR(OrganizationalModel): A Regional Internet Registry (RIR) is responsible for the allocation of a large portion of the global IP address space. This can be an organization like ARIN or RIPE, or a governing standard such as RFC 1918. """ - name = models.CharField( - max_length=100, - unique=True - ) - slug = models.SlugField( - max_length=100, - unique=True - ) is_private = models.BooleanField( default=False, verbose_name='Private', help_text='IP space managed by this RIR is considered private' ) - description = models.CharField( - max_length=200, - blank=True - ) class Meta: - ordering = ['name'] + ordering = ('name',) verbose_name = 'RIR' verbose_name_plural = 'RIRs' - def __str__(self): - return self.name - def get_absolute_url(self): return reverse('ipam:rir', args=[self.pk]) @@ -265,24 +250,12 @@ class Role(OrganizationalModel): A Role represents the functional role of a Prefix or VLAN; for example, "Customer," "Infrastructure," or "Management." """ - name = models.CharField( - max_length=100, - unique=True - ) - slug = models.SlugField( - max_length=100, - unique=True - ) weight = models.PositiveSmallIntegerField( default=1000 ) - description = models.CharField( - max_length=200, - blank=True, - ) class Meta: - ordering = ['weight', 'name'] + ordering = ('weight', 'name') def __str__(self): return self.name diff --git a/netbox/ipam/models/vlans.py b/netbox/ipam/models/vlans.py index c8c401e1c..e3a4b973b 100644 --- a/netbox/ipam/models/vlans.py +++ b/netbox/ipam/models/vlans.py @@ -83,9 +83,6 @@ class VLANGroup(OrganizationalModel): verbose_name = 'VLAN group' verbose_name_plural = 'VLAN groups' - def __str__(self): - return self.name - def get_absolute_url(self): return reverse('ipam:vlangroup', args=[self.pk]) diff --git a/netbox/netbox/models/__init__.py b/netbox/netbox/models/__init__.py index f4f28030d..38a6fcc9f 100644 --- a/netbox/netbox/models/__init__.py +++ b/netbox/netbox/models/__init__.py @@ -82,6 +82,9 @@ class NestedGroupModel(NetBoxFeatureSet, MPTTModel): name = models.CharField( max_length=100 ) + slug = models.SlugField( + max_length=100 + ) description = models.CharField( max_length=200, blank=True @@ -135,3 +138,6 @@ class OrganizationalModel(NetBoxFeatureSet, models.Model): class Meta: abstract = True ordering = ('name',) + + def __str__(self): + return self.name diff --git a/netbox/tenancy/migrations/0003_contacts.py b/netbox/tenancy/migrations/0003_contacts.py index ba9bef50f..eb247ee29 100644 --- a/netbox/tenancy/migrations/0003_contacts.py +++ b/netbox/tenancy/migrations/0003_contacts.py @@ -26,7 +26,7 @@ class Migration(migrations.Migration): ('description', models.CharField(blank=True, max_length=200)), ], options={ - 'ordering': ['name'], + 'ordering': ('name',), }, ), migrations.CreateModel( diff --git a/netbox/tenancy/models/contacts.py b/netbox/tenancy/models/contacts.py index f2fd09de7..ba937c167 100644 --- a/netbox/tenancy/models/contacts.py +++ b/netbox/tenancy/models/contacts.py @@ -20,25 +20,6 @@ class ContactGroup(NestedGroupModel): """ An arbitrary collection of Contacts. """ - name = models.CharField( - max_length=100 - ) - slug = models.SlugField( - max_length=100 - ) - parent = TreeForeignKey( - to='self', - on_delete=models.CASCADE, - related_name='children', - blank=True, - null=True, - db_index=True - ) - description = models.CharField( - max_length=200, - blank=True - ) - class Meta: ordering = ['name'] constraints = ( @@ -56,25 +37,6 @@ class ContactRole(OrganizationalModel): """ Functional role for a Contact assigned to an object. """ - name = models.CharField( - max_length=100, - unique=True - ) - slug = models.SlugField( - max_length=100, - unique=True - ) - description = models.CharField( - max_length=200, - blank=True, - ) - - class Meta: - ordering = ['name'] - - def __str__(self): - return self.name - def get_absolute_url(self): return reverse('tenancy:contactrole', args=[self.pk]) diff --git a/netbox/tenancy/models/tenants.py b/netbox/tenancy/models/tenants.py index b0ccd1cb2..b76efcbf9 100644 --- a/netbox/tenancy/models/tenants.py +++ b/netbox/tenancy/models/tenants.py @@ -23,18 +23,6 @@ class TenantGroup(NestedGroupModel): max_length=100, unique=True ) - parent = TreeForeignKey( - to='self', - on_delete=models.CASCADE, - related_name='children', - blank=True, - null=True, - db_index=True - ) - description = models.CharField( - max_length=200, - blank=True - ) class Meta: ordering = ['name'] diff --git a/netbox/virtualization/migrations/0001_squashed_0022.py b/netbox/virtualization/migrations/0001_squashed_0022.py index 29eda8a50..2a7894737 100644 --- a/netbox/virtualization/migrations/0001_squashed_0022.py +++ b/netbox/virtualization/migrations/0001_squashed_0022.py @@ -72,7 +72,7 @@ class Migration(migrations.Migration): ('description', models.CharField(blank=True, max_length=200)), ], options={ - 'ordering': ['name'], + 'ordering': ('name',), }, ), migrations.CreateModel( @@ -87,7 +87,7 @@ class Migration(migrations.Migration): ('description', models.CharField(blank=True, max_length=200)), ], options={ - 'ordering': ['name'], + 'ordering': ('name',), }, ), migrations.CreateModel( diff --git a/netbox/virtualization/models.py b/netbox/virtualization/models.py index 4e8645707..b859d25fe 100644 --- a/netbox/virtualization/models.py +++ b/netbox/virtualization/models.py @@ -33,25 +33,6 @@ class ClusterType(OrganizationalModel): """ A type of Cluster. """ - name = models.CharField( - max_length=100, - unique=True - ) - slug = models.SlugField( - max_length=100, - unique=True - ) - description = models.CharField( - max_length=200, - blank=True - ) - - class Meta: - ordering = ['name'] - - def __str__(self): - return self.name - def get_absolute_url(self): return reverse('virtualization:clustertype', args=[self.pk]) @@ -64,19 +45,6 @@ class ClusterGroup(OrganizationalModel): """ An organizational group of Clusters. """ - name = models.CharField( - max_length=100, - unique=True - ) - slug = models.SlugField( - max_length=100, - unique=True - ) - description = models.CharField( - max_length=200, - blank=True - ) - # Generic relations vlan_groups = GenericRelation( to='ipam.VLANGroup', @@ -88,12 +56,6 @@ class ClusterGroup(OrganizationalModel): to='tenancy.ContactAssignment' ) - class Meta: - ordering = ['name'] - - def __str__(self): - return self.name - def get_absolute_url(self): return reverse('virtualization:clustergroup', args=[self.pk]) diff --git a/netbox/wireless/models.py b/netbox/wireless/models.py index 29fe33f4b..ee2744e40 100644 --- a/netbox/wireless/models.py +++ b/netbox/wireless/models.py @@ -54,18 +54,6 @@ class WirelessLANGroup(NestedGroupModel): max_length=100, unique=True ) - parent = TreeForeignKey( - to='self', - on_delete=models.CASCADE, - related_name='children', - blank=True, - null=True, - db_index=True - ) - description = models.CharField( - max_length=200, - blank=True - ) class Meta: ordering = ('name', 'pk') @@ -77,9 +65,6 @@ class WirelessLANGroup(NestedGroupModel): ) verbose_name = 'Wireless LAN Group' - def __str__(self): - return self.name - def get_absolute_url(self): return reverse('wireless:wirelesslangroup', args=[self.pk]) From bc6b5bc4be52e3310a1e1d7ad4bdd40db9ae290a Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 4 Nov 2022 08:28:09 -0400 Subject: [PATCH 13/14] Closes #10545: Standardize description & comment fields on primary models (#10834) * Standardize description & comments fields on primary models * Update REST API serializers * Update forms * Update tables * Update templates --- docs/release-notes/version-3.4.md | 51 +++++++ netbox/circuits/api/serializers.py | 4 +- netbox/circuits/forms/bulk_edit.py | 6 +- netbox/circuits/forms/bulk_import.py | 2 +- netbox/circuits/forms/model_forms.py | 9 +- .../0041_standardize_description_comments.py | 18 +++ netbox/circuits/models/circuits.py | 11 +- netbox/circuits/models/providers.py | 17 +-- netbox/circuits/tables/providers.py | 4 +- netbox/dcim/api/serializers.py | 41 +++--- netbox/dcim/forms/bulk_edit.py | 131 ++++++++++++++---- netbox/dcim/forms/bulk_import.py | 17 +-- netbox/dcim/forms/model_forms.py | 40 +++--- netbox/dcim/forms/object_import.py | 4 +- .../0165_standardize_description_comments.py | 78 +++++++++++ netbox/dcim/models/cables.py | 6 +- netbox/dcim/models/devices.py | 24 +--- netbox/dcim/models/power.py | 10 +- netbox/dcim/models/racks.py | 9 +- netbox/dcim/models/sites.py | 11 +- netbox/dcim/tables/cables.py | 3 +- netbox/dcim/tables/devices.py | 78 +++++------ netbox/dcim/tables/devicetypes.py | 44 ++---- netbox/dcim/tables/modules.py | 6 +- netbox/dcim/tables/power.py | 6 +- netbox/dcim/tables/racks.py | 9 +- netbox/ipam/api/serializers.py | 39 +++--- netbox/ipam/forms/bulk_edit.py | 90 +++++++++--- netbox/ipam/forms/bulk_import.py | 24 ++-- netbox/ipam/forms/model_forms.py | 50 ++++--- .../0063_standardize_description_comments.py | 73 ++++++++++ netbox/ipam/models/fhrp.py | 8 +- netbox/ipam/models/ip.py | 32 +---- netbox/ipam/models/l2vpn.py | 8 +- netbox/ipam/models/services.py | 10 +- netbox/ipam/models/vlans.py | 14 +- netbox/ipam/models/vrfs.py | 14 +- netbox/ipam/tables/fhrp.py | 4 +- netbox/ipam/tables/ip.py | 26 ++-- netbox/ipam/tables/l2vpn.py | 8 +- netbox/ipam/tables/services.py | 10 +- netbox/ipam/tables/vlans.py | 3 +- netbox/ipam/tables/vrfs.py | 10 +- netbox/netbox/models/__init__.py | 21 ++- netbox/templates/circuits/provider.html | 4 + netbox/templates/dcim/cable.html | 5 + netbox/templates/dcim/cable_edit.html | 23 +-- netbox/templates/dcim/device.html | 6 +- netbox/templates/dcim/device_edit.html | 1 + netbox/templates/dcim/devicetype.html | 4 + netbox/templates/dcim/module.html | 4 + netbox/templates/dcim/moduletype.html | 4 + netbox/templates/dcim/powerfeed.html | 4 + netbox/templates/dcim/powerpanel.html | 41 +++--- netbox/templates/dcim/rack.html | 4 + netbox/templates/dcim/rack_edit.html | 1 + netbox/templates/dcim/rackreservation.html | 1 + netbox/templates/dcim/virtualchassis.html | 7 +- .../templates/dcim/virtualchassis_edit.html | 8 +- netbox/templates/ipam/aggregate.html | 1 + netbox/templates/ipam/asn.html | 1 + netbox/templates/ipam/fhrpgroup.html | 1 + netbox/templates/ipam/fhrpgroup_edit.html | 11 +- netbox/templates/ipam/ipaddress.html | 1 + netbox/templates/ipam/ipaddress_edit.html | 7 + netbox/templates/ipam/iprange.html | 7 +- netbox/templates/ipam/l2vpn.html | 1 + netbox/templates/ipam/prefix.html | 1 + netbox/templates/ipam/routetarget.html | 1 + netbox/templates/ipam/service.html | 7 +- netbox/templates/ipam/service_create.html | 7 + netbox/templates/ipam/service_edit.html | 7 + netbox/templates/ipam/servicetemplate.html | 13 +- netbox/templates/ipam/vlan.html | 7 +- netbox/templates/ipam/vlan_edit.html | 7 + netbox/templates/ipam/vrf.html | 1 + netbox/templates/tenancy/contact.html | 4 + netbox/templates/virtualization/cluster.html | 4 + .../virtualization/virtualmachine.html | 4 + netbox/templates/wireless/wirelesslan.html | 1 + netbox/templates/wireless/wirelesslink.html | 1 + .../templates/wireless/wirelesslink_edit.html | 6 + netbox/tenancy/api/serializers.py | 4 +- netbox/tenancy/forms/bulk_edit.py | 14 +- netbox/tenancy/forms/bulk_import.py | 2 +- netbox/tenancy/forms/model_forms.py | 4 +- .../0009_standardize_description_comments.py | 18 +++ netbox/tenancy/models/contacts.py | 8 +- netbox/tenancy/models/tenants.py | 12 +- netbox/tenancy/tables/contacts.py | 4 +- netbox/virtualization/api/serializers.py | 8 +- netbox/virtualization/forms/bulk_edit.py | 18 ++- netbox/virtualization/forms/bulk_import.py | 4 +- netbox/virtualization/forms/model_forms.py | 10 +- .../0034_standardize_description_comments.py | 23 +++ netbox/virtualization/models.py | 12 +- netbox/virtualization/tables/clusters.py | 4 +- .../virtualization/tables/virtualmachines.py | 4 +- netbox/wireless/api/serializers.py | 4 +- netbox/wireless/forms/bulk_edit.py | 28 ++-- netbox/wireless/forms/bulk_import.py | 7 +- netbox/wireless/forms/model_forms.py | 11 +- .../0007_standardize_description_comments.py | 23 +++ netbox/wireless/models.py | 16 +-- netbox/wireless/tables/wirelesslan.py | 9 +- 105 files changed, 1014 insertions(+), 534 deletions(-) create mode 100644 netbox/circuits/migrations/0041_standardize_description_comments.py create mode 100644 netbox/dcim/migrations/0165_standardize_description_comments.py create mode 100644 netbox/ipam/migrations/0063_standardize_description_comments.py create mode 100644 netbox/tenancy/migrations/0009_standardize_description_comments.py create mode 100644 netbox/virtualization/migrations/0034_standardize_description_comments.py create mode 100644 netbox/wireless/migrations/0007_standardize_description_comments.py diff --git a/docs/release-notes/version-3.4.md b/docs/release-notes/version-3.4.md index cc9fc90d2..158e7a77f 100644 --- a/docs/release-notes/version-3.4.md +++ b/docs/release-notes/version-3.4.md @@ -69,18 +69,69 @@ A new `PluginMenu` class has been introduced, which enables a plugin to inject a * circuits.provider * Removed the `asn`, `noc_contact`, `admin_contact`, and `portal_url` fields + * Added a `description` field +* dcim.Cable + * Added `description` and `comments` fields +* dcim.Device + * Added a `description` field * dcim.DeviceType + * Added a `description` field * Added optional `weight` and `weight_unit` fields +* dcim.Module + * Added a `description` field * dcim.ModuleType + * Added a `description` field * Added optional `weight` and `weight_unit` fields +* dcim.PowerFeed + * Added a `description` field +* dcim.PowerPanel + * Added `description` and `comments` fields * dcim.Rack + * Added a `description` field * Added optional `weight` and `weight_unit` fields +* dcim.RackReservation + * Added a `comments` field +* dcim.VirtualChassis + * Added `description` and `comments` fields * extras.CustomLink * Renamed `content_type` field to `content_types` * extras.ExportTemplate * Renamed `content_type` field to `content_types` +* ipam.Aggregate + * Added a `comments` field +* ipam.ASN + * Added a `comments` field * ipam.FHRPGroup + * Added a `comments` field * Added optional `name` field +* ipam.IPAddress + * Added a `comments` field +* ipam.IPRange + * Added a `comments` field +* ipam.L2VPN + * Added a `comments` field +* ipam.Prefix + * Added a `comments` field +* ipam.RouteTarget + * Added a `comments` field +* ipam.Service + * Added a `comments` field +* ipam.ServiceTemplate + * Added a `comments` field +* ipam.VLAN + * Added a `comments` field +* ipam.VRF + * Added a `comments` field +* tenancy.Contact + * Added a `description` field +* virtualization.Cluster + * Added a `description` field +* virtualization.VirtualMachine + * Added a `description` field +* wireless.WirelessLAN + * Added a `comments` field +* wireless.WirelessLink + * Added a `comments` field ### GraphQL API Changes diff --git a/netbox/circuits/api/serializers.py b/netbox/circuits/api/serializers.py index 4a8e2bd28..2bcb0895a 100644 --- a/netbox/circuits/api/serializers.py +++ b/netbox/circuits/api/serializers.py @@ -31,8 +31,8 @@ class ProviderSerializer(NetBoxModelSerializer): class Meta: model = Provider fields = [ - 'id', 'url', 'display', 'name', 'slug', 'account', - 'comments', 'asns', 'tags', 'custom_fields', 'created', 'last_updated', 'circuit_count', + 'id', 'url', 'display', 'name', 'slug', 'account', 'description', 'comments', 'asns', 'tags', + 'custom_fields', 'created', 'last_updated', 'circuit_count', ] diff --git a/netbox/circuits/forms/bulk_edit.py b/netbox/circuits/forms/bulk_edit.py index 12975b5d6..6e9ae516c 100644 --- a/netbox/circuits/forms/bulk_edit.py +++ b/netbox/circuits/forms/bulk_edit.py @@ -30,6 +30,10 @@ class ProviderBulkEditForm(NetBoxModelBulkEditForm): required=False, label='Account number' ) + description = forms.CharField( + max_length=200, + required=False + ) comments = CommentField( widget=SmallTextarea, label='Comments' @@ -40,7 +44,7 @@ class ProviderBulkEditForm(NetBoxModelBulkEditForm): (None, ('asns', 'account', )), ) nullable_fields = ( - 'asns', 'account', 'comments', + 'asns', 'account', 'description', 'comments', ) diff --git a/netbox/circuits/forms/bulk_import.py b/netbox/circuits/forms/bulk_import.py index 77ebb3de9..d0bdb09a7 100644 --- a/netbox/circuits/forms/bulk_import.py +++ b/netbox/circuits/forms/bulk_import.py @@ -18,7 +18,7 @@ class ProviderCSVForm(NetBoxModelCSVForm): class Meta: model = Provider fields = ( - 'name', 'slug', 'account', 'comments', + 'name', 'slug', 'account', 'description', 'comments', ) diff --git a/netbox/circuits/forms/model_forms.py b/netbox/circuits/forms/model_forms.py index 03c473d62..ab1b6bca2 100644 --- a/netbox/circuits/forms/model_forms.py +++ b/netbox/circuits/forms/model_forms.py @@ -1,4 +1,3 @@ -from django import forms from django.utils.translation import gettext as _ from circuits.models import * @@ -7,8 +6,8 @@ from ipam.models import ASN from netbox.forms import NetBoxModelForm from tenancy.forms import TenancyForm from utilities.forms import ( - BootstrapMixin, CommentField, DatePicker, DynamicModelChoiceField, DynamicModelMultipleChoiceField, - SelectSpeedWidget, SmallTextarea, SlugField, StaticSelect, + CommentField, DatePicker, DynamicModelChoiceField, DynamicModelMultipleChoiceField, SelectSpeedWidget, SlugField, + StaticSelect, ) __all__ = ( @@ -30,14 +29,14 @@ class ProviderForm(NetBoxModelForm): comments = CommentField() fieldsets = ( - ('Provider', ('name', 'slug', 'asns', 'tags')), + ('Provider', ('name', 'slug', 'asns', 'description', 'tags')), ('Support Info', ('account',)), ) class Meta: model = Provider fields = [ - 'name', 'slug', 'account', 'asns', 'comments', 'tags', + 'name', 'slug', 'account', 'asns', 'description', 'comments', 'tags', ] help_texts = { 'name': "Full name of the provider", diff --git a/netbox/circuits/migrations/0041_standardize_description_comments.py b/netbox/circuits/migrations/0041_standardize_description_comments.py new file mode 100644 index 000000000..49cdefcba --- /dev/null +++ b/netbox/circuits/migrations/0041_standardize_description_comments.py @@ -0,0 +1,18 @@ +# Generated by Django 4.1.2 on 2022-11-03 18:24 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('circuits', '0040_provider_remove_deprecated_fields'), + ] + + operations = [ + migrations.AddField( + model_name='provider', + name='description', + field=models.CharField(blank=True, max_length=200), + ), + ] diff --git a/netbox/circuits/models/circuits.py b/netbox/circuits/models/circuits.py index 7100c9796..9d302bb8e 100644 --- a/netbox/circuits/models/circuits.py +++ b/netbox/circuits/models/circuits.py @@ -7,7 +7,7 @@ from django.urls import reverse from circuits.choices import * from dcim.models import CabledObjectModel from netbox.models import ( - ChangeLoggedModel, CustomFieldsMixin, CustomLinksMixin, OrganizationalModel, NetBoxModel, TagsMixin, + ChangeLoggedModel, CustomFieldsMixin, CustomLinksMixin, OrganizationalModel, PrimaryModel, TagsMixin, ) from netbox.models.features import WebhooksMixin @@ -27,7 +27,7 @@ class CircuitType(OrganizationalModel): return reverse('circuits:circuittype', args=[self.pk]) -class Circuit(NetBoxModel): +class Circuit(PrimaryModel): """ A communications circuit connects two points. Each Circuit belongs to a Provider; Providers may have multiple circuits. Each circuit is also assigned a CircuitType and a Site. Circuit port speed and commit rate are measured @@ -73,13 +73,6 @@ class Circuit(NetBoxModel): blank=True, null=True, verbose_name='Commit rate (Kbps)') - description = models.CharField( - max_length=200, - blank=True - ) - comments = models.TextField( - blank=True - ) # Generic relations contacts = GenericRelation( diff --git a/netbox/circuits/models/providers.py b/netbox/circuits/models/providers.py index bd63ff0c6..18a81dcef 100644 --- a/netbox/circuits/models/providers.py +++ b/netbox/circuits/models/providers.py @@ -2,8 +2,7 @@ from django.contrib.contenttypes.fields import GenericRelation from django.db import models from django.urls import reverse -from dcim.fields import ASNField -from netbox.models import NetBoxModel +from netbox.models import PrimaryModel __all__ = ( 'ProviderNetwork', @@ -11,7 +10,7 @@ __all__ = ( ) -class Provider(NetBoxModel): +class Provider(PrimaryModel): """ Each Circuit belongs to a Provider. This is usually a telecommunications company or similar organization. This model stores information pertinent to the user's relationship with the Provider. @@ -34,9 +33,6 @@ class Provider(NetBoxModel): blank=True, verbose_name='Account number' ) - comments = models.TextField( - blank=True - ) # Generic relations contacts = GenericRelation( @@ -57,7 +53,7 @@ class Provider(NetBoxModel): return reverse('circuits:provider', args=[self.pk]) -class ProviderNetwork(NetBoxModel): +class ProviderNetwork(PrimaryModel): """ This represents a provider network which exists outside of NetBox, the details of which are unknown or unimportant to the user. @@ -75,13 +71,6 @@ class ProviderNetwork(NetBoxModel): blank=True, verbose_name='Service ID' ) - description = models.CharField( - max_length=200, - blank=True - ) - comments = models.TextField( - blank=True - ) class Meta: ordering = ('provider', 'name') diff --git a/netbox/circuits/tables/providers.py b/netbox/circuits/tables/providers.py index a117274ff..9de8d25b2 100644 --- a/netbox/circuits/tables/providers.py +++ b/netbox/circuits/tables/providers.py @@ -39,8 +39,8 @@ class ProviderTable(ContactsColumnMixin, NetBoxTable): class Meta(NetBoxTable.Meta): model = Provider fields = ( - 'pk', 'id', 'name', 'asns', 'account', 'asn_count', - 'circuit_count', 'comments', 'contacts', 'tags', 'created', 'last_updated', + 'pk', 'id', 'name', 'asns', 'account', 'asn_count', 'circuit_count', 'description', 'comments', 'contacts', + 'tags', 'created', 'last_updated', ) default_columns = ('pk', 'name', 'account', 'circuit_count') diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index 19de84791..9317d7c51 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -210,8 +210,8 @@ class RackSerializer(NetBoxModelSerializer): fields = [ 'id', 'url', 'display', 'name', 'facility_id', 'site', 'location', 'tenant', 'status', 'role', 'serial', 'asset_tag', 'type', 'width', 'u_height', 'weight', 'weight_unit', 'desc_units', 'outer_width', - 'outer_depth', 'outer_unit', 'mounting_depth', 'comments', 'tags', 'custom_fields', 'created', - 'last_updated', 'device_count', 'powerfeed_count', + 'outer_depth', 'outer_unit', 'mounting_depth', 'description', 'comments', 'tags', 'custom_fields', + 'created', 'last_updated', 'device_count', 'powerfeed_count', ] @@ -243,8 +243,8 @@ class RackReservationSerializer(NetBoxModelSerializer): class Meta: model = RackReservation fields = [ - 'id', 'url', 'display', 'rack', 'units', 'created', 'last_updated', 'user', 'tenant', 'description', 'tags', - 'custom_fields', + 'id', 'url', 'display', 'rack', 'units', 'created', 'last_updated', 'user', 'tenant', 'description', + 'comments', 'tags', 'custom_fields', ] @@ -324,8 +324,8 @@ class DeviceTypeSerializer(NetBoxModelSerializer): model = DeviceType fields = [ 'id', 'url', 'display', 'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', - 'subdevice_role', 'airflow', 'weight', 'weight_unit', 'front_image', 'rear_image', 'comments', 'tags', - 'custom_fields', 'created', 'last_updated', 'device_count', + 'subdevice_role', 'airflow', 'weight', 'weight_unit', 'front_image', 'rear_image', 'description', + 'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'device_count', ] @@ -333,13 +333,12 @@ class ModuleTypeSerializer(NetBoxModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:moduletype-detail') manufacturer = NestedManufacturerSerializer() weight_unit = ChoiceField(choices=WeightUnitChoices, allow_blank=True, required=False) - # module_count = serializers.IntegerField(read_only=True) class Meta: model = ModuleType fields = [ - 'id', 'url', 'display', 'manufacturer', 'model', 'part_number', 'weight', 'weight_unit', 'comments', 'tags', - 'custom_fields', 'created', 'last_updated', + 'id', 'url', 'display', 'manufacturer', 'model', 'part_number', 'weight', 'weight_unit', 'description', + 'comments', 'tags', 'custom_fields', 'created', 'last_updated', ] @@ -656,8 +655,8 @@ class DeviceSerializer(NetBoxModelSerializer): 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', 'comments', - 'local_context_data', 'tags', 'custom_fields', 'created', 'last_updated', + 'primary_ip4', 'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'description', + 'comments', 'local_context_data', 'tags', 'custom_fields', 'created', 'last_updated', ] @swagger_serializer_method(serializer_or_field=NestedDeviceSerializer) @@ -681,8 +680,8 @@ class ModuleSerializer(NetBoxModelSerializer): class Meta: model = Module fields = [ - 'id', 'url', 'display', 'device', 'module_bay', 'module_type', 'serial', 'asset_tag', 'comments', 'tags', - 'custom_fields', 'created', 'last_updated', + 'id', 'url', 'display', 'device', 'module_bay', 'module_type', 'serial', 'asset_tag', 'description', + 'comments', 'tags', 'custom_fields', 'created', 'last_updated', ] @@ -1020,7 +1019,7 @@ class CableSerializer(NetBoxModelSerializer): model = Cable fields = [ 'id', 'url', 'display', 'type', 'a_terminations', 'b_terminations', 'status', 'tenant', 'label', 'color', - 'length', 'length_unit', 'tags', 'custom_fields', 'created', 'last_updated', + 'length', 'length_unit', 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', ] @@ -1086,8 +1085,8 @@ class VirtualChassisSerializer(NetBoxModelSerializer): class Meta: model = VirtualChassis fields = [ - 'id', 'url', 'display', 'name', 'domain', 'master', 'tags', 'custom_fields', 'member_count', - 'created', 'last_updated', + 'id', 'url', 'display', 'name', 'domain', 'master', 'description', 'comments', 'tags', 'custom_fields', + 'member_count', 'created', 'last_updated', ] @@ -1108,8 +1107,8 @@ class PowerPanelSerializer(NetBoxModelSerializer): class Meta: model = PowerPanel fields = [ - 'id', 'url', 'display', 'site', 'location', 'name', 'tags', 'custom_fields', 'powerfeed_count', - 'created', 'last_updated', + 'id', 'url', 'display', 'site', 'location', 'name', 'description', 'comments', 'tags', 'custom_fields', + 'powerfeed_count', 'created', 'last_updated', ] @@ -1142,7 +1141,7 @@ class PowerFeedSerializer(NetBoxModelSerializer, CabledObjectSerializer, Connect model = PowerFeed fields = [ 'id', 'url', 'display', 'power_panel', 'rack', 'name', 'status', 'type', 'supply', 'phase', 'voltage', - 'amperage', 'max_utilization', 'comments', 'mark_connected', 'cable', 'cable_end', 'link_peers', - 'link_peers_type', 'connected_endpoints', 'connected_endpoints_type', 'connected_endpoints_reachable', - 'tags', 'custom_fields', 'created', 'last_updated', '_occupied', + 'amperage', 'max_utilization', 'mark_connected', 'cable', 'cable_end', 'link_peers', 'link_peers_type', + 'connected_endpoints', 'connected_endpoints_type', 'connected_endpoints_reachable', 'description', + 'comments', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied', ] diff --git a/netbox/dcim/forms/bulk_edit.py b/netbox/dcim/forms/bulk_edit.py index e3b69dc81..1e58dd2f7 100644 --- a/netbox/dcim/forms/bulk_edit.py +++ b/netbox/dcim/forms/bulk_edit.py @@ -127,22 +127,26 @@ class SiteBulkEditForm(NetBoxModelBulkEditForm): required=False, label='Contact E-mail' ) - description = forms.CharField( - max_length=100, - required=False - ) time_zone = TimeZoneFormField( choices=add_blank_choice(TimeZoneFormField().choices), required=False, widget=StaticSelect() ) + description = forms.CharField( + max_length=200, + required=False + ) + comments = CommentField( + widget=SmallTextarea, + label='Comments' + ) model = Site fieldsets = ( (None, ('status', 'region', 'group', 'tenant', 'asns', 'time_zone', 'description')), ) nullable_fields = ( - 'region', 'group', 'tenant', 'asns', 'description', 'time_zone', + 'region', 'group', 'tenant', 'asns', 'time_zone', 'description', 'comments', ) @@ -285,10 +289,6 @@ class RackBulkEditForm(NetBoxModelBulkEditForm): required=False, min_value=1 ) - comments = CommentField( - widget=SmallTextarea, - label='Comments' - ) weight = forms.DecimalField( min_value=0, required=False @@ -299,10 +299,18 @@ class RackBulkEditForm(NetBoxModelBulkEditForm): initial='', widget=StaticSelect() ) + description = forms.CharField( + max_length=200, + required=False + ) + comments = CommentField( + widget=SmallTextarea, + label='Comments' + ) model = Rack fieldsets = ( - ('Rack', ('status', 'role', 'tenant', 'serial', 'asset_tag')), + ('Rack', ('status', 'role', 'tenant', 'serial', 'asset_tag', 'description')), ('Location', ('region', 'site_group', 'site', 'location')), ('Hardware', ( 'type', 'width', 'u_height', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit', 'mounting_depth', @@ -310,8 +318,8 @@ class RackBulkEditForm(NetBoxModelBulkEditForm): ('Weight', ('weight', 'weight_unit')), ) nullable_fields = ( - 'location', 'tenant', 'role', 'serial', 'asset_tag', 'outer_width', 'outer_depth', 'outer_unit', 'comments', - 'weight', 'weight_unit' + 'location', 'tenant', 'role', 'serial', 'asset_tag', 'outer_width', 'outer_depth', 'outer_unit', 'weight', + 'weight_unit', 'description', 'comments', ) @@ -328,14 +336,19 @@ class RackReservationBulkEditForm(NetBoxModelBulkEditForm): required=False ) description = forms.CharField( - max_length=100, + max_length=200, required=False ) + comments = CommentField( + widget=SmallTextarea, + label='Comments' + ) model = RackReservation fieldsets = ( (None, ('user', 'tenant', 'description')), ) + nullable_fields = ('comments',) class ManufacturerBulkEditForm(NetBoxModelBulkEditForm): @@ -383,13 +396,21 @@ class DeviceTypeBulkEditForm(NetBoxModelBulkEditForm): initial='', widget=StaticSelect() ) + description = forms.CharField( + max_length=200, + required=False + ) + comments = CommentField( + widget=SmallTextarea, + label='Comments' + ) model = DeviceType fieldsets = ( - ('Device Type', ('manufacturer', 'part_number', 'u_height', 'is_full_depth', 'airflow')), + ('Device Type', ('manufacturer', 'part_number', 'u_height', 'is_full_depth', 'airflow', 'description')), ('Weight', ('weight', 'weight_unit')), ) - nullable_fields = ('part_number', 'airflow', 'weight', 'weight_unit') + nullable_fields = ('part_number', 'airflow', 'weight', 'weight_unit', 'description', 'comments') class ModuleTypeBulkEditForm(NetBoxModelBulkEditForm): @@ -410,13 +431,21 @@ class ModuleTypeBulkEditForm(NetBoxModelBulkEditForm): initial='', widget=StaticSelect() ) + description = forms.CharField( + max_length=200, + required=False + ) + comments = CommentField( + widget=SmallTextarea, + label='Comments' + ) model = ModuleType fieldsets = ( - ('Module Type', ('manufacturer', 'part_number')), + ('Module Type', ('manufacturer', 'part_number', 'description')), ('Weight', ('weight', 'weight_unit')), ) - nullable_fields = ('part_number', 'weight', 'weight_unit') + nullable_fields = ('part_number', 'weight', 'weight_unit', 'description', 'comments') class DeviceRoleBulkEditForm(NetBoxModelBulkEditForm): @@ -512,15 +541,23 @@ class DeviceBulkEditForm(NetBoxModelBulkEditForm): required=False, label='Serial Number' ) + description = forms.CharField( + max_length=200, + required=False + ) + comments = CommentField( + widget=SmallTextarea, + label='Comments' + ) model = Device fieldsets = ( - ('Device', ('device_role', 'status', 'tenant', 'platform')), + ('Device', ('device_role', 'status', 'tenant', 'platform', 'description')), ('Location', ('site', 'location')), ('Hardware', ('manufacturer', 'device_type', 'airflow', 'serial')), ) nullable_fields = ( - 'location', 'tenant', 'platform', 'serial', 'airflow', + 'location', 'tenant', 'platform', 'serial', 'airflow', 'description', 'comments', ) @@ -541,12 +578,20 @@ class ModuleBulkEditForm(NetBoxModelBulkEditForm): required=False, label='Serial Number' ) + description = forms.CharField( + max_length=200, + required=False + ) + comments = CommentField( + widget=SmallTextarea, + label='Comments' + ) model = Module fieldsets = ( - (None, ('manufacturer', 'module_type', 'serial')), + (None, ('manufacturer', 'module_type', 'serial', 'description')), ) - nullable_fields = ('serial',) + nullable_fields = ('serial', 'description', 'comments') class CableBulkEditForm(NetBoxModelBulkEditForm): @@ -583,14 +628,22 @@ class CableBulkEditForm(NetBoxModelBulkEditForm): initial='', widget=StaticSelect() ) + description = forms.CharField( + max_length=200, + required=False + ) + comments = CommentField( + widget=SmallTextarea, + label='Comments' + ) model = Cable fieldsets = ( - (None, ('type', 'status', 'tenant', 'label')), + (None, ('type', 'status', 'tenant', 'label', 'description')), ('Attributes', ('color', 'length', 'length_unit')), ) nullable_fields = ( - 'type', 'status', 'tenant', 'label', 'color', 'length', + 'type', 'status', 'tenant', 'label', 'color', 'length', 'description', 'comments', ) @@ -599,12 +652,20 @@ class VirtualChassisBulkEditForm(NetBoxModelBulkEditForm): max_length=30, required=False ) + description = forms.CharField( + max_length=200, + required=False + ) + comments = CommentField( + widget=SmallTextarea, + label='Comments' + ) model = VirtualChassis fieldsets = ( - (None, ('domain',)), + (None, ('domain', 'description')), ) - nullable_fields = ('domain',) + nullable_fields = ('domain', 'description', 'comments') class PowerPanelBulkEditForm(NetBoxModelBulkEditForm): @@ -637,12 +698,20 @@ class PowerPanelBulkEditForm(NetBoxModelBulkEditForm): 'site_id': '$site' } ) + description = forms.CharField( + max_length=200, + required=False + ) + comments = CommentField( + widget=SmallTextarea, + label='Comments' + ) model = PowerPanel fieldsets = ( - (None, ('region', 'site_group', 'site', 'location')), + (None, ('region', 'site_group', 'site', 'location', 'description')), ) - nullable_fields = ('location',) + nullable_fields = ('location', 'description', 'comments') class PowerFeedBulkEditForm(NetBoxModelBulkEditForm): @@ -691,6 +760,10 @@ class PowerFeedBulkEditForm(NetBoxModelBulkEditForm): required=False, widget=BulkEditNullBooleanSelect ) + description = forms.CharField( + max_length=200, + required=False + ) comments = CommentField( widget=SmallTextarea, label='Comments' @@ -698,10 +771,10 @@ class PowerFeedBulkEditForm(NetBoxModelBulkEditForm): model = PowerFeed fieldsets = ( - (None, ('power_panel', 'rack', 'status', 'type', 'mark_connected')), + (None, ('power_panel', 'rack', 'status', 'type', 'mark_connected', 'description')), ('Power', ('supply', 'phase', 'voltage', 'amperage', 'max_utilization')) ) - nullable_fields = ('location', 'comments') + nullable_fields = ('location', 'description', 'comments') # diff --git a/netbox/dcim/forms/bulk_import.py b/netbox/dcim/forms/bulk_import.py index 13e788e75..4c90c9c02 100644 --- a/netbox/dcim/forms/bulk_import.py +++ b/netbox/dcim/forms/bulk_import.py @@ -196,7 +196,8 @@ class RackCSVForm(NetBoxModelCSVForm): model = Rack fields = ( 'site', 'location', 'name', 'facility_id', 'tenant', 'status', 'role', 'type', 'serial', 'asset_tag', - 'width', 'u_height', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit', 'mounting_depth', 'comments', + 'width', 'u_height', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit', 'mounting_depth', + 'description', 'comments', ) def __init__(self, data=None, *args, **kwargs): @@ -240,7 +241,7 @@ class RackReservationCSVForm(NetBoxModelCSVForm): class Meta: model = RackReservation - fields = ('site', 'location', 'rack', 'units', 'tenant', 'description') + fields = ('site', 'location', 'rack', 'units', 'tenant', 'description', 'comments') def __init__(self, data=None, *args, **kwargs): super().__init__(data, *args, **kwargs) @@ -387,7 +388,7 @@ class DeviceCSVForm(BaseDeviceCSVForm): fields = [ 'name', 'device_role', 'tenant', 'manufacturer', 'device_type', 'platform', 'serial', 'asset_tag', 'status', 'site', 'location', 'rack', 'position', 'face', 'airflow', 'virtual_chassis', 'vc_position', 'vc_priority', - 'cluster', 'comments', + 'cluster', 'description', 'comments', ] def __init__(self, data=None, *args, **kwargs): @@ -424,7 +425,7 @@ class ModuleCSVForm(NetBoxModelCSVForm): class Meta: model = Module fields = ( - 'device', 'module_bay', 'module_type', 'serial', 'asset_tag', 'comments', + 'device', 'module_bay', 'module_type', 'serial', 'asset_tag', 'description', 'comments', ) def __init__(self, data=None, *args, **kwargs): @@ -927,7 +928,7 @@ class CableCSVForm(NetBoxModelCSVForm): model = Cable fields = [ 'side_a_device', 'side_a_type', 'side_a_name', 'side_b_device', 'side_b_type', 'side_b_name', 'type', - 'status', 'tenant', 'label', 'color', 'length', 'length_unit', + 'status', 'tenant', 'label', 'color', 'length', 'length_unit', 'description', 'comments', ] help_texts = { 'color': mark_safe('RGB color in hexadecimal (e.g. 00ff00)'), @@ -984,7 +985,7 @@ class VirtualChassisCSVForm(NetBoxModelCSVForm): class Meta: model = VirtualChassis - fields = ('name', 'domain', 'master') + fields = ('name', 'domain', 'master', 'description') # @@ -1005,7 +1006,7 @@ class PowerPanelCSVForm(NetBoxModelCSVForm): class Meta: model = PowerPanel - fields = ('site', 'location', 'name') + fields = ('site', 'location', 'name', 'description', 'comments') def __init__(self, data=None, *args, **kwargs): super().__init__(data, *args, **kwargs) @@ -1061,7 +1062,7 @@ class PowerFeedCSVForm(NetBoxModelCSVForm): model = PowerFeed fields = ( 'site', 'power_panel', 'location', 'rack', 'name', 'status', 'type', 'mark_connected', 'supply', 'phase', - 'voltage', 'amperage', 'max_utilization', 'comments', + 'voltage', 'amperage', 'max_utilization', 'description', 'comments', ) def __init__(self, data=None, *args, **kwargs): diff --git a/netbox/dcim/forms/model_forms.py b/netbox/dcim/forms/model_forms.py index 0da2f3430..539c48709 100644 --- a/netbox/dcim/forms/model_forms.py +++ b/netbox/dcim/forms/model_forms.py @@ -278,7 +278,7 @@ class RackForm(TenancyForm, NetBoxModelForm): fields = [ 'region', 'site_group', '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', 'weight_unit', 'comments', 'tags', + 'outer_unit', 'mounting_depth', 'weight', 'weight_unit', 'description', 'comments', 'tags', ] help_texts = { 'site': "The site at which the rack exists", @@ -342,6 +342,7 @@ class RackReservationForm(TenancyForm, NetBoxModelForm): ), widget=StaticSelect() ) + comments = CommentField() fieldsets = ( ('Reservation', ('region', 'site_group', 'site', 'location', 'rack', 'units', 'user', 'description', 'tags')), @@ -352,7 +353,7 @@ class RackReservationForm(TenancyForm, NetBoxModelForm): model = RackReservation fields = [ 'region', 'site_group', 'site', 'location', 'rack', 'units', 'user', 'tenant_group', 'tenant', - 'description', 'tags', + 'description', 'comments', 'tags', ] @@ -383,10 +384,10 @@ class DeviceTypeForm(NetBoxModelForm): fieldsets = ( ('Device Type', ( - 'manufacturer', 'model', 'slug', 'part_number', 'tags', + 'manufacturer', 'model', 'slug', 'description', 'tags', )), ('Chassis', ( - 'u_height', 'is_full_depth', 'subdevice_role', 'airflow', + 'u_height', 'is_full_depth', 'part_number', 'subdevice_role', 'airflow', )), ('Attributes', ('weight', 'weight_unit')), ('Images', ('front_image', 'rear_image')), @@ -396,7 +397,7 @@ class DeviceTypeForm(NetBoxModelForm): model = DeviceType fields = [ 'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role', 'airflow', - 'weight', 'weight_unit', 'front_image', 'rear_image', 'comments', 'tags', + 'weight', 'weight_unit', 'front_image', 'rear_image', 'description', 'comments', 'tags', ] widgets = { 'airflow': StaticSelect(), @@ -418,15 +419,14 @@ class ModuleTypeForm(NetBoxModelForm): comments = CommentField() fieldsets = ( - ('Module Type', ( - 'manufacturer', 'model', 'part_number', 'tags', 'weight', 'weight_unit' - )), + ('Module Type', ('manufacturer', 'model', 'part_number', 'description', 'tags')), + ('Weight', ('weight', 'weight_unit')) ) class Meta: model = ModuleType fields = [ - 'manufacturer', 'model', 'part_number', 'weight', 'weight_unit', 'comments', 'tags', + 'manufacturer', 'model', 'part_number', 'weight', 'weight_unit', 'description', 'comments', 'tags', ] widgets = { @@ -591,7 +591,7 @@ class DeviceForm(TenancyForm, NetBoxModelForm): 'name', 'device_role', 'device_type', 'serial', 'asset_tag', 'region', 'site_group', 'site', 'rack', 'location', 'position', 'face', 'status', 'airflow', 'platform', 'primary_ip4', 'primary_ip6', 'cluster_group', 'cluster', 'tenant_group', 'tenant', 'virtual_chassis', 'vc_position', 'vc_priority', - 'comments', 'tags', 'local_context_data' + 'description', 'comments', 'tags', 'local_context_data' ] help_texts = { 'device_role': "The function this device serves", @@ -705,7 +705,7 @@ class ModuleForm(NetBoxModelForm): fieldsets = ( ('Module', ( - 'device', 'module_bay', 'manufacturer', 'module_type', 'tags', + 'device', 'module_bay', 'manufacturer', 'module_type', 'description', 'tags', )), ('Hardware', ( 'serial', 'asset_tag', 'replicate_components', 'adopt_components', @@ -716,7 +716,7 @@ class ModuleForm(NetBoxModelForm): model = Module fields = [ 'device', 'module_bay', 'manufacturer', 'module_type', 'serial', 'asset_tag', 'tags', - 'replicate_components', 'adopt_components', 'comments', + 'replicate_components', 'adopt_components', 'description', 'comments', ] def __init__(self, *args, **kwargs): @@ -793,11 +793,13 @@ class ModuleForm(NetBoxModelForm): class CableForm(TenancyForm, NetBoxModelForm): + comments = CommentField() class Meta: model = Cable fields = [ - 'type', 'status', 'tenant_group', 'tenant', 'label', 'color', 'length', 'length_unit', 'tags', + 'type', 'status', 'tenant_group', 'tenant', 'label', 'color', 'length', 'length_unit', 'description', + 'comments', 'tags', ] widgets = { 'status': StaticSelect, @@ -840,15 +842,16 @@ class PowerPanelForm(NetBoxModelForm): 'site_id': '$site' } ) + comments = CommentField() fieldsets = ( - ('Power Panel', ('region', 'site_group', 'site', 'location', 'name', 'tags')), + ('Power Panel', ('region', 'site_group', 'site', 'location', 'name', 'description', 'tags')), ) class Meta: model = PowerPanel fields = [ - 'region', 'site_group', 'site', 'location', 'name', 'tags', + 'region', 'site_group', 'site', 'location', 'name', 'description', 'comments', 'tags', ] @@ -894,7 +897,7 @@ class PowerFeedForm(NetBoxModelForm): comments = CommentField() fieldsets = ( - ('Power Panel', ('region', 'site', 'power_panel')), + ('Power Panel', ('region', 'site', 'power_panel', 'description')), ('Power Feed', ('rack', 'name', 'status', 'type', 'mark_connected', 'tags')), ('Characteristics', ('supply', 'voltage', 'amperage', 'phase', 'max_utilization')), ) @@ -903,7 +906,7 @@ class PowerFeedForm(NetBoxModelForm): model = PowerFeed fields = [ 'region', 'site_group', 'site', 'power_panel', 'rack', 'name', 'status', 'type', 'mark_connected', 'supply', - 'phase', 'voltage', 'amperage', 'max_utilization', 'comments', 'tags', + 'phase', 'voltage', 'amperage', 'max_utilization', 'description', 'comments', 'tags', ] widgets = { 'status': StaticSelect(), @@ -922,11 +925,12 @@ class VirtualChassisForm(NetBoxModelForm): queryset=Device.objects.all(), required=False, ) + comments = CommentField() class Meta: model = VirtualChassis fields = [ - 'name', 'domain', 'master', 'tags', + 'name', 'domain', 'master', 'description', 'comments', 'tags', ] widgets = { 'master': SelectWithPK(), diff --git a/netbox/dcim/forms/object_import.py b/netbox/dcim/forms/object_import.py index 023aba8f1..82ee093dd 100644 --- a/netbox/dcim/forms/object_import.py +++ b/netbox/dcim/forms/object_import.py @@ -30,7 +30,7 @@ class DeviceTypeImportForm(BootstrapMixin, forms.ModelForm): model = DeviceType fields = [ 'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role', 'airflow', - 'comments', + 'description', 'comments', ] @@ -42,7 +42,7 @@ class ModuleTypeImportForm(BootstrapMixin, forms.ModelForm): class Meta: model = ModuleType - fields = ['manufacturer', 'model', 'part_number', 'comments'] + fields = ['manufacturer', 'model', 'part_number', 'description', 'comments'] # diff --git a/netbox/dcim/migrations/0165_standardize_description_comments.py b/netbox/dcim/migrations/0165_standardize_description_comments.py new file mode 100644 index 000000000..f17f1d321 --- /dev/null +++ b/netbox/dcim/migrations/0165_standardize_description_comments.py @@ -0,0 +1,78 @@ +# Generated by Django 4.1.2 on 2022-11-03 18:24 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0164_rack_mounting_depth'), + ] + + operations = [ + migrations.AddField( + model_name='cable', + name='comments', + field=models.TextField(blank=True), + ), + migrations.AddField( + model_name='cable', + name='description', + field=models.CharField(blank=True, max_length=200), + ), + migrations.AddField( + model_name='device', + name='description', + field=models.CharField(blank=True, max_length=200), + ), + migrations.AddField( + model_name='devicetype', + name='description', + field=models.CharField(blank=True, max_length=200), + ), + migrations.AddField( + model_name='module', + name='description', + field=models.CharField(blank=True, max_length=200), + ), + migrations.AddField( + model_name='moduletype', + name='description', + field=models.CharField(blank=True, max_length=200), + ), + migrations.AddField( + model_name='powerfeed', + name='description', + field=models.CharField(blank=True, max_length=200), + ), + migrations.AddField( + model_name='powerpanel', + name='comments', + field=models.TextField(blank=True), + ), + migrations.AddField( + model_name='powerpanel', + name='description', + field=models.CharField(blank=True, max_length=200), + ), + migrations.AddField( + model_name='rack', + name='description', + field=models.CharField(blank=True, max_length=200), + ), + migrations.AddField( + model_name='rackreservation', + name='comments', + field=models.TextField(blank=True), + ), + migrations.AddField( + model_name='virtualchassis', + name='comments', + field=models.TextField(blank=True), + ), + migrations.AddField( + model_name='virtualchassis', + name='description', + field=models.CharField(blank=True, max_length=200), + ), + ] diff --git a/netbox/dcim/models/cables.py b/netbox/dcim/models/cables.py index fad3e8bd6..c51b59f94 100644 --- a/netbox/dcim/models/cables.py +++ b/netbox/dcim/models/cables.py @@ -12,8 +12,8 @@ from django.urls import reverse from dcim.choices import * from dcim.constants import * from dcim.fields import PathField -from dcim.utils import decompile_path_node, object_to_path_node, path_node_to_object -from netbox.models import NetBoxModel +from dcim.utils import decompile_path_node, object_to_path_node +from netbox.models import PrimaryModel from utilities.fields import ColorField from utilities.querysets import RestrictedQuerySet from utilities.utils import to_meters @@ -34,7 +34,7 @@ trace_paths = Signal() # Cables # -class Cable(NetBoxModel): +class Cable(PrimaryModel): """ A physical connection between two endpoints. """ diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py index 3710bf7f4..78282f893 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -18,7 +18,7 @@ from dcim.constants import * from extras.models import ConfigContextModel from extras.querysets import ConfigContextModelQuerySet from netbox.config import ConfigItem -from netbox.models import OrganizationalModel, NetBoxModel +from netbox.models import OrganizationalModel, PrimaryModel from utilities.choices import ColorChoices from utilities.fields import ColorField, NaturalOrderingField from .device_components import * @@ -54,7 +54,7 @@ class Manufacturer(OrganizationalModel): return reverse('dcim:manufacturer', args=[self.pk]) -class DeviceType(NetBoxModel, WeightMixin): +class DeviceType(PrimaryModel, WeightMixin): """ A DeviceType represents a particular make (Manufacturer) and model of device. It specifies rack height and depth, as well as high-level functional role(s). @@ -117,9 +117,6 @@ class DeviceType(NetBoxModel, WeightMixin): upload_to='devicetype-images', blank=True ) - comments = models.TextField( - blank=True - ) clone_fields = ( 'manufacturer', 'u_height', 'is_full_depth', 'subdevice_role', 'airflow', 'weight', 'weight_unit', @@ -298,7 +295,7 @@ class DeviceType(NetBoxModel, WeightMixin): return self.subdevice_role == SubdeviceRoleChoices.ROLE_CHILD -class ModuleType(NetBoxModel, WeightMixin): +class ModuleType(PrimaryModel, WeightMixin): """ A ModuleType represents a hardware element that can be installed within a device and which houses additional components; for example, a line card within a chassis-based switch such as the Cisco Catalyst 6500. Like a @@ -318,9 +315,6 @@ class ModuleType(NetBoxModel, WeightMixin): blank=True, help_text='Discrete part number (optional)' ) - comments = models.TextField( - blank=True - ) # Generic relations images = GenericRelation( @@ -443,7 +437,7 @@ class Platform(OrganizationalModel): return reverse('dcim:platform', args=[self.pk]) -class Device(NetBoxModel, ConfigContextModel): +class Device(PrimaryModel, ConfigContextModel): """ A Device represents a piece of physical hardware mounted within a Rack. Each Device is assigned a DeviceType, DeviceRole, and (optionally) a Platform. Device names are not required, however if one is set it must be unique. @@ -587,9 +581,6 @@ class Device(NetBoxModel, ConfigContextModel): null=True, validators=[MaxValueValidator(255)] ) - comments = models.TextField( - blank=True - ) # Generic relations contacts = GenericRelation( @@ -906,7 +897,7 @@ class Device(NetBoxModel, ConfigContextModel): return round(total_weight / 1000, 2) -class Module(NetBoxModel, ConfigContextModel): +class Module(PrimaryModel, ConfigContextModel): """ A Module represents a field-installable component within a Device which may itself hold multiple device components (for example, a line card within a chassis switch). Modules are instantiated from ModuleTypes. @@ -939,9 +930,6 @@ class Module(NetBoxModel, ConfigContextModel): verbose_name='Asset tag', help_text='A unique tag used to identify this device' ) - comments = models.TextField( - blank=True - ) clone_fields = ('device', 'module_type') @@ -1019,7 +1007,7 @@ class Module(NetBoxModel, ConfigContextModel): # Virtual chassis # -class VirtualChassis(NetBoxModel): +class VirtualChassis(PrimaryModel): """ A collection of Devices which operate with a shared control plane (e.g. a switch stack). """ diff --git a/netbox/dcim/models/power.py b/netbox/dcim/models/power.py index 39f0f37ef..e79cf4c44 100644 --- a/netbox/dcim/models/power.py +++ b/netbox/dcim/models/power.py @@ -6,9 +6,8 @@ from django.db import models from django.urls import reverse from dcim.choices import * -from dcim.constants import * from netbox.config import ConfigItem -from netbox.models import NetBoxModel +from netbox.models import PrimaryModel from utilities.validators import ExclusionValidator from .device_components import CabledObjectModel, PathEndpoint @@ -22,7 +21,7 @@ __all__ = ( # Power # -class PowerPanel(NetBoxModel): +class PowerPanel(PrimaryModel): """ A distribution point for electrical power; e.g. a data center RPP. """ @@ -77,7 +76,7 @@ class PowerPanel(NetBoxModel): ) -class PowerFeed(NetBoxModel, PathEndpoint, CabledObjectModel): +class PowerFeed(PrimaryModel, PathEndpoint, CabledObjectModel): """ An electrical circuit delivered from a PowerPanel. """ @@ -132,9 +131,6 @@ class PowerFeed(NetBoxModel, PathEndpoint, CabledObjectModel): default=0, editable=False ) - comments = models.TextField( - blank=True - ) clone_fields = ( 'power_panel', 'rack', 'status', 'type', 'mark_connected', 'supply', 'phase', 'voltage', 'amperage', diff --git a/netbox/dcim/models/racks.py b/netbox/dcim/models/racks.py index e61765e69..e37fc8dc3 100644 --- a/netbox/dcim/models/racks.py +++ b/netbox/dcim/models/racks.py @@ -14,7 +14,7 @@ from django.urls import reverse from dcim.choices import * from dcim.constants import * from dcim.svg import RackElevationSVG -from netbox.models import OrganizationalModel, NetBoxModel +from netbox.models import OrganizationalModel, PrimaryModel from utilities.choices import ColorChoices from utilities.fields import ColorField, NaturalOrderingField from utilities.utils import array_to_string, drange @@ -46,7 +46,7 @@ class RackRole(OrganizationalModel): return reverse('dcim:rackrole', args=[self.pk]) -class Rack(NetBoxModel, WeightMixin): +class Rack(PrimaryModel, WeightMixin): """ Devices are housed within Racks. Each rack has a defined height measured in rack units, and a front and rear face. Each Rack is assigned to a Site and (optionally) a Location. @@ -157,9 +157,6 @@ class Rack(NetBoxModel, WeightMixin): 'distance between the front and rear rails.' ) ) - comments = models.TextField( - blank=True - ) # Generic relations vlan_groups = GenericRelation( @@ -463,7 +460,7 @@ class Rack(NetBoxModel, WeightMixin): return round(total_weight / 1000, 2) -class RackReservation(NetBoxModel): +class RackReservation(PrimaryModel): """ One or more reserved units within a Rack. """ diff --git a/netbox/dcim/models/sites.py b/netbox/dcim/models/sites.py index c352b69de..c760119fb 100644 --- a/netbox/dcim/models/sites.py +++ b/netbox/dcim/models/sites.py @@ -6,7 +6,7 @@ from timezone_field import TimeZoneField from dcim.choices import * from dcim.constants import * -from netbox.models import NestedGroupModel, NetBoxModel +from netbox.models import NestedGroupModel, PrimaryModel from utilities.fields import NaturalOrderingField __all__ = ( @@ -131,7 +131,7 @@ class SiteGroup(NestedGroupModel): # Sites # -class Site(NetBoxModel): +class Site(PrimaryModel): """ A Site represents a geographic location within a network; typically a building or campus. The optional facility field can be used to include an external designation, such as a data center name (e.g. Equinix SV6). @@ -188,10 +188,6 @@ class Site(NetBoxModel): time_zone = TimeZoneField( blank=True ) - description = models.CharField( - max_length=200, - blank=True - ) physical_address = models.CharField( max_length=200, blank=True @@ -214,9 +210,6 @@ class Site(NetBoxModel): null=True, help_text='GPS coordinate (longitude)' ) - comments = models.TextField( - blank=True - ) # Generic relations vlan_groups = GenericRelation( diff --git a/netbox/dcim/tables/cables.py b/netbox/dcim/tables/cables.py index e5410e42a..6e9d49719 100644 --- a/netbox/dcim/tables/cables.py +++ b/netbox/dcim/tables/cables.py @@ -111,6 +111,7 @@ class CableTable(TenancyColumnsMixin, NetBoxTable): order_by=('_abs_length', 'length_unit') ) color = columns.ColorColumn() + comments = columns.MarkdownColumn() tags = columns.TagColumn( url_name='dcim:cable_list' ) @@ -120,7 +121,7 @@ class CableTable(TenancyColumnsMixin, NetBoxTable): fields = ( 'pk', 'id', 'label', 'a_terminations', 'b_terminations', 'device_a', 'device_b', 'rack_a', 'rack_b', 'location_a', 'location_b', 'site_a', 'site_b', 'status', 'type', 'tenant', 'tenant_group', 'color', - 'length', 'tags', 'created', 'last_updated', + 'length', 'description', 'comments', 'tags', 'created', 'last_updated', ) default_columns = ( 'pk', 'id', 'label', 'a_terminations', 'b_terminations', 'status', 'type', diff --git a/netbox/dcim/tables/devices.py b/netbox/dcim/tables/devices.py index 3b129c963..45a210080 100644 --- a/netbox/dcim/tables/devices.py +++ b/netbox/dcim/tables/devices.py @@ -1,21 +1,5 @@ import django_tables2 as tables -from dcim.models import ( - ConsolePort, - ConsoleServerPort, - Device, - DeviceBay, - DeviceRole, - FrontPort, - Interface, - InventoryItem, - InventoryItemRole, - ModuleBay, - Platform, - PowerOutlet, - PowerPort, - RearPort, - VirtualChassis, -) +from dcim import models from django_tables2.utils import Accessor from tenancy.tables import ContactsColumnMixin, TenancyColumnsMixin @@ -106,7 +90,7 @@ class DeviceRoleTable(NetBoxTable): ) class Meta(NetBoxTable.Meta): - model = DeviceRole + model = models.DeviceRole fields = ( 'pk', 'id', 'name', 'device_count', 'vm_count', 'color', 'vm_role', 'description', 'slug', 'tags', 'actions', 'created', 'last_updated', @@ -137,7 +121,7 @@ class PlatformTable(NetBoxTable): ) class Meta(NetBoxTable.Meta): - model = Platform + model = models.Platform fields = ( 'pk', 'id', 'name', 'manufacturer', 'device_count', 'vm_count', 'slug', 'napalm_driver', 'napalm_args', 'description', 'tags', 'actions', 'created', 'last_updated', @@ -220,12 +204,12 @@ class DeviceTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable): ) class Meta(NetBoxTable.Meta): - model = Device + model = models.Device fields = ( 'pk', 'id', 'name', 'status', 'tenant', 'tenant_group', 'device_role', 'manufacturer', 'device_type', 'platform', 'serial', 'asset_tag', 'region', 'site_group', 'site', 'location', 'rack', 'position', 'face', 'airflow', 'primary_ip', 'primary_ip4', 'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position', - 'vc_priority', 'comments', 'contacts', 'tags', 'created', 'last_updated', + 'vc_priority', 'description', 'comments', 'contacts', 'tags', 'created', 'last_updated', ) default_columns = ( 'pk', 'name', 'status', 'tenant', 'site', 'location', 'rack', 'device_role', 'manufacturer', 'device_type', @@ -252,7 +236,7 @@ class DeviceImportTable(TenancyColumnsMixin, NetBoxTable): ) class Meta(NetBoxTable.Meta): - model = Device + model = models.Device fields = ('id', 'name', 'status', 'tenant', 'tenant_group', 'site', 'rack', 'position', 'device_role', 'device_type') empty_text = False @@ -326,7 +310,7 @@ class ConsolePortTable(ModularDeviceComponentTable, PathEndpointTable): ) class Meta(DeviceComponentTable.Meta): - model = ConsolePort + model = models.ConsolePort fields = ( 'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'type', 'speed', 'description', 'mark_connected', 'cable', 'cable_color', 'link_peer', 'connection', 'tags', 'created', 'last_updated', @@ -345,7 +329,7 @@ class DeviceConsolePortTable(ConsolePortTable): ) class Meta(DeviceComponentTable.Meta): - model = ConsolePort + model = models.ConsolePort fields = ( 'pk', 'id', 'name', 'module_bay', 'module', 'label', 'type', 'speed', 'description', 'mark_connected', 'cable', 'cable_color', 'link_peer', 'connection', 'tags', 'actions' @@ -368,7 +352,7 @@ class ConsoleServerPortTable(ModularDeviceComponentTable, PathEndpointTable): ) class Meta(DeviceComponentTable.Meta): - model = ConsoleServerPort + model = models.ConsoleServerPort fields = ( 'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'type', 'speed', 'description', 'mark_connected', 'cable', 'cable_color', 'link_peer', 'connection', 'tags', 'created', 'last_updated', @@ -388,7 +372,7 @@ class DeviceConsoleServerPortTable(ConsoleServerPortTable): ) class Meta(DeviceComponentTable.Meta): - model = ConsoleServerPort + model = models.ConsoleServerPort fields = ( 'pk', 'id', 'name', 'module_bay', 'module', 'label', 'type', 'speed', 'description', 'mark_connected', 'cable', 'cable_color', 'link_peer', 'connection', 'tags', 'actions', @@ -411,7 +395,7 @@ class PowerPortTable(ModularDeviceComponentTable, PathEndpointTable): ) class Meta(DeviceComponentTable.Meta): - model = PowerPort + model = models.PowerPort fields = ( 'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'type', 'description', 'mark_connected', 'maximum_draw', 'allocated_draw', 'cable', 'cable_color', 'link_peer', 'connection', 'tags', 'created', @@ -432,7 +416,7 @@ class DevicePowerPortTable(PowerPortTable): ) class Meta(DeviceComponentTable.Meta): - model = PowerPort + model = models.PowerPort fields = ( 'pk', 'id', 'name', 'module_bay', 'module', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description', 'mark_connected', 'cable', 'cable_color', 'link_peer', 'connection', 'tags', 'actions', @@ -460,7 +444,7 @@ class PowerOutletTable(ModularDeviceComponentTable, PathEndpointTable): ) class Meta(DeviceComponentTable.Meta): - model = PowerOutlet + model = models.PowerOutlet fields = ( 'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'type', 'description', 'power_port', 'feed_leg', 'mark_connected', 'cable', 'cable_color', 'link_peer', 'connection', 'tags', 'created', @@ -480,7 +464,7 @@ class DevicePowerOutletTable(PowerOutletTable): ) class Meta(DeviceComponentTable.Meta): - model = PowerOutlet + model = models.PowerOutlet fields = ( 'pk', 'id', 'name', 'module_bay', 'module', 'label', 'type', 'power_port', 'feed_leg', 'description', 'mark_connected', 'cable', 'cable_color', 'link_peer', 'connection', 'tags', 'actions', @@ -544,7 +528,7 @@ class InterfaceTable(ModularDeviceComponentTable, BaseInterfaceTable, PathEndpoi ) class Meta(DeviceComponentTable.Meta): - model = Interface + model = models.Interface fields = ( 'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'enabled', 'type', 'mgmt_only', 'mtu', 'speed', 'duplex', 'mode', 'mac_address', 'wwn', 'poe_mode', 'poe_type', 'rf_role', 'rf_channel', @@ -578,7 +562,7 @@ class DeviceInterfaceTable(InterfaceTable): ) class Meta(DeviceComponentTable.Meta): - model = Interface + model = models.Interface fields = ( 'pk', 'id', 'name', 'module_bay', 'module', 'label', 'enabled', 'type', 'parent', 'bridge', 'lag', 'mgmt_only', 'mtu', 'mode', 'mac_address', 'wwn', 'rf_role', 'rf_channel', 'rf_channel_frequency', @@ -617,7 +601,7 @@ class FrontPortTable(ModularDeviceComponentTable, CableTerminationTable): ) class Meta(DeviceComponentTable.Meta): - model = FrontPort + model = models.FrontPort fields = ( 'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'type', 'color', 'rear_port', 'rear_port_position', 'description', 'mark_connected', 'cable', 'cable_color', 'link_peer', 'tags', @@ -640,7 +624,7 @@ class DeviceFrontPortTable(FrontPortTable): ) class Meta(DeviceComponentTable.Meta): - model = FrontPort + model = models.FrontPort fields = ( 'pk', 'id', 'name', 'module_bay', 'module', 'label', 'type', 'rear_port', 'rear_port_position', 'description', 'mark_connected', 'cable', 'cable_color', 'link_peer', 'tags', 'actions', @@ -666,7 +650,7 @@ class RearPortTable(ModularDeviceComponentTable, CableTerminationTable): ) class Meta(DeviceComponentTable.Meta): - model = RearPort + model = models.RearPort fields = ( 'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'type', 'color', 'positions', 'description', 'mark_connected', 'cable', 'cable_color', 'link_peer', 'tags', 'created', 'last_updated', @@ -686,7 +670,7 @@ class DeviceRearPortTable(RearPortTable): ) class Meta(DeviceComponentTable.Meta): - model = RearPort + model = models.RearPort fields = ( 'pk', 'id', 'name', 'module_bay', 'module', 'label', 'type', 'positions', 'description', 'mark_connected', 'cable', 'cable_color', 'link_peer', 'tags', 'actions', @@ -727,7 +711,7 @@ class DeviceBayTable(DeviceComponentTable): ) class Meta(DeviceComponentTable.Meta): - model = DeviceBay + model = models.DeviceBay fields = ( 'pk', 'id', 'name', 'device', 'label', 'status', 'device_role', 'device_type', 'installed_device', 'description', 'tags', 'created', 'last_updated', @@ -748,7 +732,7 @@ class DeviceDeviceBayTable(DeviceBayTable): ) class Meta(DeviceComponentTable.Meta): - model = DeviceBay + model = models.DeviceBay fields = ( 'pk', 'id', 'name', 'label', 'status', 'installed_device', 'description', 'tags', 'actions', ) @@ -777,7 +761,7 @@ class ModuleBayTable(DeviceComponentTable): ) class Meta(DeviceComponentTable.Meta): - model = ModuleBay + model = models.ModuleBay fields = ( 'pk', 'id', 'name', 'device', 'label', 'position', 'installed_module', 'module_serial', 'module_asset_tag', 'description', 'tags', @@ -791,7 +775,7 @@ class DeviceModuleBayTable(ModuleBayTable): ) class Meta(DeviceComponentTable.Meta): - model = ModuleBay + model = models.ModuleBay fields = ( 'pk', 'id', 'name', 'label', 'position', 'installed_module', 'module_serial', 'module_asset_tag', 'description', 'tags', 'actions', @@ -821,7 +805,7 @@ class InventoryItemTable(DeviceComponentTable): cable = None # Override DeviceComponentTable class Meta(NetBoxTable.Meta): - model = InventoryItem + model = models.InventoryItem fields = ( 'pk', 'id', 'name', 'device', 'component', 'label', 'role', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'description', 'discovered', 'tags', 'created', 'last_updated', @@ -840,7 +824,7 @@ class DeviceInventoryItemTable(InventoryItemTable): ) class Meta(NetBoxTable.Meta): - model = InventoryItem + model = models.InventoryItem fields = ( 'pk', 'id', 'name', 'label', 'role', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'component', 'description', 'discovered', 'tags', 'actions', @@ -865,7 +849,7 @@ class InventoryItemRoleTable(NetBoxTable): ) class Meta(NetBoxTable.Meta): - model = InventoryItemRole + model = models.InventoryItemRole fields = ( 'pk', 'id', 'name', 'inventoryitem_count', 'color', 'description', 'slug', 'tags', 'actions', ) @@ -888,11 +872,15 @@ class VirtualChassisTable(NetBoxTable): url_params={'virtual_chassis_id': 'pk'}, verbose_name='Members' ) + comments = columns.MarkdownColumn() tags = columns.TagColumn( url_name='dcim:virtualchassis_list' ) class Meta(NetBoxTable.Meta): - model = VirtualChassis - fields = ('pk', 'id', 'name', 'domain', 'master', 'member_count', 'tags', 'created', 'last_updated',) + model = models.VirtualChassis + fields = ( + 'pk', 'id', 'name', 'domain', 'master', 'member_count', 'description', 'comments', 'tags', 'created', + 'last_updated', + ) default_columns = ('pk', 'name', 'domain', 'master', 'member_count') diff --git a/netbox/dcim/tables/devicetypes.py b/netbox/dcim/tables/devicetypes.py index 19b04c70d..a52d41b70 100644 --- a/netbox/dcim/tables/devicetypes.py +++ b/netbox/dcim/tables/devicetypes.py @@ -1,19 +1,6 @@ import django_tables2 as tables -from dcim.models import ( - ConsolePortTemplate, - ConsoleServerPortTemplate, - DeviceBayTemplate, - DeviceType, - FrontPortTemplate, - InterfaceTemplate, - InventoryItemTemplate, - Manufacturer, - ModuleBayTemplate, - PowerOutletTemplate, - PowerPortTemplate, - RearPortTemplate, -) +from dcim import models from netbox.tables import NetBoxTable, columns from tenancy.tables import ContactsColumnMixin from .template_code import MODULAR_COMPONENT_TEMPLATE_BUTTONS, DEVICE_WEIGHT @@ -59,7 +46,7 @@ class ManufacturerTable(ContactsColumnMixin, NetBoxTable): ) class Meta(NetBoxTable.Meta): - model = Manufacturer + model = models.Manufacturer fields = ( 'pk', 'id', 'name', 'devicetype_count', 'inventoryitem_count', 'platform_count', 'description', 'slug', 'contacts', 'actions', 'created', 'last_updated', @@ -100,15 +87,12 @@ class DeviceTypeTable(NetBoxTable): template_code=DEVICE_WEIGHT, order_by=('_abs_weight', 'weight_unit') ) - u_height = columns.TemplateColumn( - template_code='{{ value|floatformat }}' - ) class Meta(NetBoxTable.Meta): - model = DeviceType + model = models.DeviceType fields = ( 'pk', 'id', 'model', 'manufacturer', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role', - 'airflow', 'weight', 'comments', 'instance_count', 'tags', 'created', 'last_updated', + 'airflow', 'weight', 'description', 'comments', 'instance_count', 'tags', 'created', 'last_updated', ) default_columns = ( 'pk', 'model', 'manufacturer', 'part_number', 'u_height', 'is_full_depth', 'instance_count', @@ -138,7 +122,7 @@ class ConsolePortTemplateTable(ComponentTemplateTable): ) class Meta(ComponentTemplateTable.Meta): - model = ConsolePortTemplate + model = models.ConsolePortTemplate fields = ('pk', 'name', 'label', 'type', 'description', 'actions') empty_text = "None" @@ -150,7 +134,7 @@ class ConsoleServerPortTemplateTable(ComponentTemplateTable): ) class Meta(ComponentTemplateTable.Meta): - model = ConsoleServerPortTemplate + model = models.ConsoleServerPortTemplate fields = ('pk', 'name', 'label', 'type', 'description', 'actions') empty_text = "None" @@ -162,7 +146,7 @@ class PowerPortTemplateTable(ComponentTemplateTable): ) class Meta(ComponentTemplateTable.Meta): - model = PowerPortTemplate + model = models.PowerPortTemplate fields = ('pk', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description', 'actions') empty_text = "None" @@ -174,7 +158,7 @@ class PowerOutletTemplateTable(ComponentTemplateTable): ) class Meta(ComponentTemplateTable.Meta): - model = PowerOutletTemplate + model = models.PowerOutletTemplate fields = ('pk', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description', 'actions') empty_text = "None" @@ -189,7 +173,7 @@ class InterfaceTemplateTable(ComponentTemplateTable): ) class Meta(ComponentTemplateTable.Meta): - model = InterfaceTemplate + model = models.InterfaceTemplate fields = ('pk', 'name', 'label', 'mgmt_only', 'type', 'description', 'poe_mode', 'poe_type', 'actions') empty_text = "None" @@ -205,7 +189,7 @@ class FrontPortTemplateTable(ComponentTemplateTable): ) class Meta(ComponentTemplateTable.Meta): - model = FrontPortTemplate + model = models.FrontPortTemplate fields = ('pk', 'name', 'label', 'type', 'color', 'rear_port', 'rear_port_position', 'description', 'actions') empty_text = "None" @@ -218,7 +202,7 @@ class RearPortTemplateTable(ComponentTemplateTable): ) class Meta(ComponentTemplateTable.Meta): - model = RearPortTemplate + model = models.RearPortTemplate fields = ('pk', 'name', 'label', 'type', 'color', 'positions', 'description', 'actions') empty_text = "None" @@ -229,7 +213,7 @@ class ModuleBayTemplateTable(ComponentTemplateTable): ) class Meta(ComponentTemplateTable.Meta): - model = ModuleBayTemplate + model = models.ModuleBayTemplate fields = ('pk', 'name', 'label', 'position', 'description', 'actions') empty_text = "None" @@ -240,7 +224,7 @@ class DeviceBayTemplateTable(ComponentTemplateTable): ) class Meta(ComponentTemplateTable.Meta): - model = DeviceBayTemplate + model = models.DeviceBayTemplate fields = ('pk', 'name', 'label', 'description', 'actions') empty_text = "None" @@ -260,7 +244,7 @@ class InventoryItemTemplateTable(ComponentTemplateTable): ) class Meta(ComponentTemplateTable.Meta): - model = InventoryItemTemplate + model = models.InventoryItemTemplate fields = ( 'pk', 'name', 'label', 'parent', 'role', 'manufacturer', 'part_id', 'component', 'description', 'actions', ) diff --git a/netbox/dcim/tables/modules.py b/netbox/dcim/tables/modules.py index b644e6ba6..9df26eb73 100644 --- a/netbox/dcim/tables/modules.py +++ b/netbox/dcim/tables/modules.py @@ -35,7 +35,7 @@ class ModuleTypeTable(NetBoxTable): class Meta(NetBoxTable.Meta): model = ModuleType fields = ( - 'pk', 'id', 'model', 'manufacturer', 'part_number', 'weight', 'comments', 'tags', + 'pk', 'id', 'model', 'manufacturer', 'part_number', 'weight', 'description', 'comments', 'tags', ) default_columns = ( 'pk', 'model', 'manufacturer', 'part_number', @@ -64,8 +64,8 @@ class ModuleTable(NetBoxTable): class Meta(NetBoxTable.Meta): model = Module fields = ( - 'pk', 'id', 'device', 'module_bay', 'manufacturer', 'module_type', 'serial', 'asset_tag', 'comments', - 'tags', + 'pk', 'id', 'device', 'module_bay', 'manufacturer', 'module_type', 'serial', 'asset_tag', 'description', + 'comments', 'tags', ) default_columns = ( 'pk', 'id', 'device', 'module_bay', 'manufacturer', 'module_type', 'serial', 'asset_tag', diff --git a/netbox/dcim/tables/power.py b/netbox/dcim/tables/power.py index 04012ea4a..feff29e12 100644 --- a/netbox/dcim/tables/power.py +++ b/netbox/dcim/tables/power.py @@ -31,6 +31,7 @@ class PowerPanelTable(ContactsColumnMixin, NetBoxTable): url_params={'power_panel_id': 'pk'}, verbose_name='Feeds' ) + comments = columns.MarkdownColumn() tags = columns.TagColumn( url_name='dcim:powerpanel_list' ) @@ -38,7 +39,8 @@ class PowerPanelTable(ContactsColumnMixin, NetBoxTable): class Meta(NetBoxTable.Meta): model = PowerPanel fields = ( - 'pk', 'id', 'name', 'site', 'location', 'powerfeed_count', 'contacts', 'tags', 'created', 'last_updated', + 'pk', 'id', 'name', 'site', 'location', 'powerfeed_count', 'contacts', 'description', 'comments', 'tags', + 'created', 'last_updated', ) default_columns = ('pk', 'name', 'site', 'location', 'powerfeed_count') @@ -77,7 +79,7 @@ class PowerFeedTable(CableTerminationTable): fields = ( 'pk', 'id', 'name', 'power_panel', 'rack', 'status', 'type', 'supply', 'voltage', 'amperage', 'phase', 'max_utilization', 'mark_connected', 'cable', 'cable_color', 'link_peer', 'connection', 'available_power', - 'comments', 'tags', 'created', 'last_updated', + 'description', 'comments', 'tags', 'created', 'last_updated', ) default_columns = ( 'pk', 'name', 'power_panel', 'rack', 'status', 'type', 'supply', 'voltage', 'amperage', 'phase', 'cable', diff --git a/netbox/dcim/tables/racks.py b/netbox/dcim/tables/racks.py index 1a355cc2a..b360002d2 100644 --- a/netbox/dcim/tables/racks.py +++ b/netbox/dcim/tables/racks.py @@ -90,8 +90,8 @@ class RackTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable): fields = ( 'pk', 'id', 'name', 'site', 'location', 'status', 'facility_id', 'tenant', 'tenant_group', 'role', 'serial', 'asset_tag', 'type', 'u_height', 'width', 'outer_width', 'outer_depth', 'mounting_depth', 'weight', - 'comments', 'device_count', 'get_utilization', 'get_power_utilization', 'contacts', 'tags', 'created', - 'last_updated', + 'comments', 'device_count', 'get_utilization', 'get_power_utilization', 'description', 'contacts', 'tags', + 'created', 'last_updated', ) default_columns = ( 'pk', 'name', 'site', 'location', 'status', 'facility_id', 'tenant', 'role', 'u_height', 'device_count', @@ -123,6 +123,7 @@ class RackReservationTable(TenancyColumnsMixin, NetBoxTable): orderable=False, verbose_name='Units' ) + comments = columns.MarkdownColumn() tags = columns.TagColumn( url_name='dcim:rackreservation_list' ) @@ -130,7 +131,7 @@ class RackReservationTable(TenancyColumnsMixin, NetBoxTable): class Meta(NetBoxTable.Meta): model = RackReservation fields = ( - 'pk', 'id', 'reservation', 'site', 'location', 'rack', 'unit_list', 'user', 'created', 'tenant', 'tenant_group', 'description', 'tags', - 'actions', 'created', 'last_updated', + 'pk', 'id', 'reservation', 'site', 'location', 'rack', 'unit_list', 'user', 'created', 'tenant', + 'tenant_group', 'description', 'comments', 'tags', 'actions', 'created', 'last_updated', ) default_columns = ('pk', 'reservation', 'site', 'rack', 'unit_list', 'user', 'description') diff --git a/netbox/ipam/api/serializers.py b/netbox/ipam/api/serializers.py index e04849c13..6ec062aee 100644 --- a/netbox/ipam/api/serializers.py +++ b/netbox/ipam/api/serializers.py @@ -31,8 +31,8 @@ class ASNSerializer(NetBoxModelSerializer): class Meta: model = ASN fields = [ - 'id', 'url', 'display', 'asn', 'rir', 'tenant', 'description', 'site_count', 'provider_count', 'tags', - 'custom_fields', 'created', 'last_updated', + 'id', 'url', 'display', 'asn', 'rir', 'tenant', 'description', 'comments', 'tags', 'custom_fields', + 'created', 'last_updated', 'site_count', 'provider_count', ] @@ -61,8 +61,9 @@ class VRFSerializer(NetBoxModelSerializer): class Meta: model = VRF fields = [ - 'id', 'url', 'display', 'name', 'rd', 'tenant', 'enforce_unique', 'description', 'import_targets', - 'export_targets', 'tags', 'custom_fields', 'created', 'last_updated', 'ipaddress_count', 'prefix_count', + 'id', 'url', 'display', 'name', 'rd', 'tenant', 'enforce_unique', 'description', 'comments', + 'import_targets', 'export_targets', 'tags', 'custom_fields', 'created', 'last_updated', 'ipaddress_count', + 'prefix_count', ] @@ -77,7 +78,8 @@ class RouteTargetSerializer(NetBoxModelSerializer): class Meta: model = RouteTarget fields = [ - 'id', 'url', 'display', 'name', 'tenant', 'description', 'tags', 'custom_fields', 'created', 'last_updated', + 'id', 'url', 'display', 'name', 'tenant', 'description', 'comments', 'tags', 'custom_fields', 'created', + 'last_updated', ] @@ -106,8 +108,8 @@ class AggregateSerializer(NetBoxModelSerializer): class Meta: model = Aggregate fields = [ - 'id', 'url', 'display', 'family', 'prefix', 'rir', 'tenant', 'date_added', 'description', 'tags', - 'custom_fields', 'created', 'last_updated', + 'id', 'url', 'display', 'family', 'prefix', 'rir', 'tenant', 'date_added', 'description', 'comments', + 'tags', 'custom_fields', 'created', 'last_updated', ] read_only_fields = ['family'] @@ -123,8 +125,8 @@ class FHRPGroupSerializer(NetBoxModelSerializer): class Meta: model = FHRPGroup fields = [ - 'id', 'name', 'url', 'display', 'protocol', 'group_id', 'auth_type', 'auth_key', 'description', 'ip_addresses', - 'tags', 'custom_fields', 'created', 'last_updated', + 'id', 'name', 'url', 'display', 'protocol', 'group_id', 'auth_type', 'auth_key', 'description', 'comments', + 'tags', 'custom_fields', 'created', 'last_updated', 'ip_addresses', ] @@ -215,7 +217,7 @@ class VLANSerializer(NetBoxModelSerializer): model = VLAN fields = [ 'id', 'url', 'display', 'site', 'group', 'vid', 'name', 'tenant', 'status', 'role', 'description', - 'l2vpn_termination', 'tags', 'custom_fields', 'created', 'last_updated', 'prefix_count', + 'comments', 'l2vpn_termination', 'tags', 'custom_fields', 'created', 'last_updated', 'prefix_count', ] @@ -273,7 +275,8 @@ class PrefixSerializer(NetBoxModelSerializer): model = Prefix fields = [ 'id', 'url', 'display', 'family', 'prefix', 'site', 'vrf', 'tenant', 'vlan', 'status', 'role', 'is_pool', - 'mark_utilized', 'description', 'tags', 'custom_fields', 'created', 'last_updated', 'children', '_depth', + 'mark_utilized', 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'children', + '_depth', ] read_only_fields = ['family'] @@ -342,7 +345,7 @@ class IPRangeSerializer(NetBoxModelSerializer): model = IPRange fields = [ 'id', 'url', 'display', 'family', 'start_address', 'end_address', 'size', 'vrf', 'tenant', 'status', 'role', - 'description', 'tags', 'custom_fields', 'created', 'last_updated', 'children', + 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'children', ] read_only_fields = ['family'] @@ -371,8 +374,8 @@ class IPAddressSerializer(NetBoxModelSerializer): model = IPAddress fields = [ 'id', 'url', 'display', 'family', 'address', 'vrf', 'tenant', 'status', 'role', 'assigned_object_type', - 'assigned_object_id', 'assigned_object', 'nat_inside', 'nat_outside', 'dns_name', 'description', 'tags', - 'custom_fields', 'created', 'last_updated', + 'assigned_object_id', 'assigned_object', 'nat_inside', 'nat_outside', 'dns_name', 'description', 'comments', + 'tags', 'custom_fields', 'created', 'last_updated', ] @swagger_serializer_method(serializer_or_field=serializers.JSONField) @@ -415,8 +418,8 @@ class ServiceTemplateSerializer(NetBoxModelSerializer): class Meta: model = ServiceTemplate fields = [ - 'id', 'url', 'display', 'name', 'ports', 'protocol', 'description', 'tags', 'custom_fields', 'created', - 'last_updated', + 'id', 'url', 'display', 'name', 'ports', 'protocol', 'description', 'comments', 'tags', 'custom_fields', + 'created', 'last_updated', ] @@ -436,7 +439,7 @@ class ServiceSerializer(NetBoxModelSerializer): model = Service fields = [ 'id', 'url', 'display', 'device', 'virtual_machine', 'name', 'ports', 'protocol', 'ipaddresses', - 'description', 'tags', 'custom_fields', 'created', 'last_updated', + 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', ] # @@ -465,7 +468,7 @@ class L2VPNSerializer(NetBoxModelSerializer): model = L2VPN fields = [ 'id', 'url', 'display', 'identifier', 'name', 'slug', 'type', 'import_targets', 'export_targets', - 'description', 'tenant', 'tags', 'custom_fields', 'created', 'last_updated' + 'description', 'comments', 'tenant', 'tags', 'custom_fields', 'created', 'last_updated' ] diff --git a/netbox/ipam/forms/bulk_edit.py b/netbox/ipam/forms/bulk_edit.py index 67bcf83fb..ed1d1d9e9 100644 --- a/netbox/ipam/forms/bulk_edit.py +++ b/netbox/ipam/forms/bulk_edit.py @@ -8,8 +8,8 @@ from ipam.models import ASN from netbox.forms import NetBoxModelBulkEditForm from tenancy.models import Tenant from utilities.forms import ( - add_blank_choice, BulkEditNullBooleanSelect, DynamicModelChoiceField, NumericArrayField, StaticSelect, - DynamicModelMultipleChoiceField, + add_blank_choice, BulkEditNullBooleanSelect, CommentField, DynamicModelChoiceField, NumericArrayField, + SmallTextarea, StaticSelect, DynamicModelMultipleChoiceField, ) __all__ = ( @@ -43,15 +43,19 @@ class VRFBulkEditForm(NetBoxModelBulkEditForm): label='Enforce unique space' ) description = forms.CharField( - max_length=100, + max_length=200, required=False ) + comments = CommentField( + widget=SmallTextarea, + label='Comments' + ) model = VRF fieldsets = ( (None, ('tenant', 'enforce_unique', 'description')), ) - nullable_fields = ('tenant', 'description') + nullable_fields = ('tenant', 'description', 'comments') class RouteTargetBulkEditForm(NetBoxModelBulkEditForm): @@ -63,12 +67,16 @@ class RouteTargetBulkEditForm(NetBoxModelBulkEditForm): max_length=200, required=False ) + comments = CommentField( + widget=SmallTextarea, + label='Comments' + ) model = RouteTarget fieldsets = ( (None, ('tenant', 'description')), ) - nullable_fields = ('tenant', 'description') + nullable_fields = ('tenant', 'description', 'comments') class RIRBulkEditForm(NetBoxModelBulkEditForm): @@ -103,15 +111,19 @@ class ASNBulkEditForm(NetBoxModelBulkEditForm): required=False ) description = forms.CharField( - max_length=100, + max_length=200, required=False ) + comments = CommentField( + widget=SmallTextarea, + label='Comments' + ) model = ASN fieldsets = ( (None, ('sites', 'rir', 'tenant', 'description')), ) - nullable_fields = ('date_added', 'description') + nullable_fields = ('date_added', 'description', 'comments') class AggregateBulkEditForm(NetBoxModelBulkEditForm): @@ -128,15 +140,19 @@ class AggregateBulkEditForm(NetBoxModelBulkEditForm): required=False ) description = forms.CharField( - max_length=100, + max_length=200, required=False ) + comments = CommentField( + widget=SmallTextarea, + label='Comments' + ) model = Aggregate fieldsets = ( (None, ('rir', 'tenant', 'date_added', 'description')), ) - nullable_fields = ('date_added', 'description') + nullable_fields = ('date_added', 'description', 'comments') class RoleBulkEditForm(NetBoxModelBulkEditForm): @@ -206,9 +222,13 @@ class PrefixBulkEditForm(NetBoxModelBulkEditForm): label='Treat as 100% utilized' ) description = forms.CharField( - max_length=100, + max_length=200, required=False ) + comments = CommentField( + widget=SmallTextarea, + label='Comments' + ) model = Prefix fieldsets = ( @@ -217,7 +237,7 @@ class PrefixBulkEditForm(NetBoxModelBulkEditForm): ('Addressing', ('vrf', 'prefix_length', 'is_pool', 'mark_utilized')), ) nullable_fields = ( - 'site', 'vrf', 'tenant', 'role', 'description', + 'site', 'vrf', 'tenant', 'role', 'description', 'comments', ) @@ -241,16 +261,20 @@ class IPRangeBulkEditForm(NetBoxModelBulkEditForm): required=False ) description = forms.CharField( - max_length=100, + max_length=200, required=False ) + comments = CommentField( + widget=SmallTextarea, + label='Comments' + ) model = IPRange fieldsets = ( (None, ('status', 'role', 'vrf', 'tenant', 'description')), ) nullable_fields = ( - 'vrf', 'tenant', 'role', 'description', + 'vrf', 'tenant', 'role', 'description', 'comments', ) @@ -285,9 +309,13 @@ class IPAddressBulkEditForm(NetBoxModelBulkEditForm): label='DNS name' ) description = forms.CharField( - max_length=100, + max_length=200, required=False ) + comments = CommentField( + widget=SmallTextarea, + label='Comments' + ) model = IPAddress fieldsets = ( @@ -295,7 +323,7 @@ class IPAddressBulkEditForm(NetBoxModelBulkEditForm): ('Addressing', ('vrf', 'mask_length', 'dns_name')), ) nullable_fields = ( - 'vrf', 'role', 'tenant', 'dns_name', 'description', + 'vrf', 'role', 'tenant', 'dns_name', 'description', 'comments', ) @@ -329,13 +357,17 @@ class FHRPGroupBulkEditForm(NetBoxModelBulkEditForm): max_length=200, required=False ) + comments = CommentField( + widget=SmallTextarea, + label='Comments' + ) model = FHRPGroup fieldsets = ( (None, ('protocol', 'group_id', 'name', 'description')), ('Authentication', ('auth_type', 'auth_key')), ) - nullable_fields = ('auth_type', 'auth_key', 'name', 'description') + nullable_fields = ('auth_type', 'auth_key', 'name', 'description', 'comments') class VLANGroupBulkEditForm(NetBoxModelBulkEditForm): @@ -405,9 +437,13 @@ class VLANBulkEditForm(NetBoxModelBulkEditForm): required=False ) description = forms.CharField( - max_length=100, + max_length=200, required=False ) + comments = CommentField( + widget=SmallTextarea, + label='Comments' + ) model = VLAN fieldsets = ( @@ -415,7 +451,7 @@ class VLANBulkEditForm(NetBoxModelBulkEditForm): ('Site & Group', ('region', 'site_group', 'site', 'group')), ) nullable_fields = ( - 'site', 'group', 'tenant', 'role', 'description', + 'site', 'group', 'tenant', 'role', 'description', 'comments', ) @@ -433,15 +469,19 @@ class ServiceTemplateBulkEditForm(NetBoxModelBulkEditForm): required=False ) description = forms.CharField( - max_length=100, + max_length=200, required=False ) + comments = CommentField( + widget=SmallTextarea, + label='Comments' + ) model = ServiceTemplate fieldsets = ( (None, ('protocol', 'ports', 'description')), ) - nullable_fields = ('description',) + nullable_fields = ('description', 'comments') class ServiceBulkEditForm(ServiceTemplateBulkEditForm): @@ -459,15 +499,19 @@ class L2VPNBulkEditForm(NetBoxModelBulkEditForm): required=False ) description = forms.CharField( - max_length=100, + max_length=200, required=False ) + comments = CommentField( + widget=SmallTextarea, + label='Comments' + ) model = L2VPN fieldsets = ( - (None, ('type', 'description', 'tenant')), + (None, ('type', 'tenant', 'description')), ) - nullable_fields = ('tenant', 'description',) + nullable_fields = ('tenant', 'description', 'comments') class L2VPNTerminationBulkEditForm(NetBoxModelBulkEditForm): diff --git a/netbox/ipam/forms/bulk_import.py b/netbox/ipam/forms/bulk_import.py index 3aead6151..3a31b6757 100644 --- a/netbox/ipam/forms/bulk_import.py +++ b/netbox/ipam/forms/bulk_import.py @@ -41,7 +41,7 @@ class VRFCSVForm(NetBoxModelCSVForm): class Meta: model = VRF - fields = ('name', 'rd', 'tenant', 'enforce_unique', 'description') + fields = ('name', 'rd', 'tenant', 'enforce_unique', 'description', 'comments') class RouteTargetCSVForm(NetBoxModelCSVForm): @@ -54,7 +54,7 @@ class RouteTargetCSVForm(NetBoxModelCSVForm): class Meta: model = RouteTarget - fields = ('name', 'description', 'tenant') + fields = ('name', 'tenant', 'description', 'comments') class RIRCSVForm(NetBoxModelCSVForm): @@ -83,7 +83,7 @@ class AggregateCSVForm(NetBoxModelCSVForm): class Meta: model = Aggregate - fields = ('prefix', 'rir', 'tenant', 'date_added', 'description') + fields = ('prefix', 'rir', 'tenant', 'date_added', 'description', 'comments') class ASNCSVForm(NetBoxModelCSVForm): @@ -101,7 +101,7 @@ class ASNCSVForm(NetBoxModelCSVForm): class Meta: model = ASN - fields = ('asn', 'rir', 'tenant', 'description') + fields = ('asn', 'rir', 'tenant', 'description', 'comments') help_texts = {} @@ -159,7 +159,7 @@ class PrefixCSVForm(NetBoxModelCSVForm): model = Prefix fields = ( 'prefix', 'vrf', 'tenant', 'site', 'vlan_group', 'vlan', 'status', 'role', 'is_pool', 'mark_utilized', - 'description', + 'description', 'comments', ) def __init__(self, data=None, *args, **kwargs): @@ -204,7 +204,7 @@ class IPRangeCSVForm(NetBoxModelCSVForm): class Meta: model = IPRange fields = ( - 'start_address', 'end_address', 'vrf', 'tenant', 'status', 'role', 'description', + 'start_address', 'end_address', 'vrf', 'tenant', 'status', 'role', 'description', 'comments', ) @@ -257,7 +257,7 @@ class IPAddressCSVForm(NetBoxModelCSVForm): model = IPAddress fields = [ 'address', 'vrf', 'tenant', 'status', 'role', 'device', 'virtual_machine', 'interface', 'is_primary', - 'dns_name', 'description', + 'dns_name', 'description', 'comments', ] def __init__(self, data=None, *args, **kwargs): @@ -326,7 +326,7 @@ class FHRPGroupCSVForm(NetBoxModelCSVForm): class Meta: model = FHRPGroup - fields = ('protocol', 'group_id', 'auth_type', 'auth_key', 'name', 'description') + fields = ('protocol', 'group_id', 'auth_type', 'auth_key', 'name', 'description', 'comments') class VLANGroupCSVForm(NetBoxModelCSVForm): @@ -389,7 +389,7 @@ class VLANCSVForm(NetBoxModelCSVForm): class Meta: model = VLAN - fields = ('site', 'group', 'vid', 'name', 'tenant', 'status', 'role', 'description') + fields = ('site', 'group', 'vid', 'name', 'tenant', 'status', 'role', 'description', 'comments') help_texts = { 'vid': 'Numeric VLAN ID (1-4094)', 'name': 'VLAN name', @@ -404,7 +404,7 @@ class ServiceTemplateCSVForm(NetBoxModelCSVForm): class Meta: model = ServiceTemplate - fields = ('name', 'protocol', 'ports', 'description') + fields = ('name', 'protocol', 'ports', 'description', 'comments') class ServiceCSVForm(NetBoxModelCSVForm): @@ -427,7 +427,7 @@ class ServiceCSVForm(NetBoxModelCSVForm): class Meta: model = Service - fields = ('device', 'virtual_machine', 'name', 'protocol', 'ports', 'description') + fields = ('device', 'virtual_machine', 'name', 'protocol', 'ports', 'description', 'comments') class L2VPNCSVForm(NetBoxModelCSVForm): @@ -443,7 +443,7 @@ class L2VPNCSVForm(NetBoxModelCSVForm): class Meta: model = L2VPN - fields = ('identifier', 'name', 'slug', 'type', 'description') + fields = ('identifier', 'name', 'slug', 'type', 'description', 'comments') class L2VPNTerminationCSVForm(NetBoxModelCSVForm): diff --git a/netbox/ipam/forms/model_forms.py b/netbox/ipam/forms/model_forms.py index 061462e71..9a5abc082 100644 --- a/netbox/ipam/forms/model_forms.py +++ b/netbox/ipam/forms/model_forms.py @@ -11,7 +11,7 @@ from netbox.forms import NetBoxModelForm from tenancy.forms import TenancyForm from utilities.exceptions import PermissionsViolation from utilities.forms import ( - add_blank_choice, BootstrapMixin, ContentTypeChoiceField, DatePicker, DynamicModelChoiceField, + add_blank_choice, BootstrapMixin, CommentField, ContentTypeChoiceField, DatePicker, DynamicModelChoiceField, DynamicModelMultipleChoiceField, NumericArrayField, SlugField, StaticSelect, StaticSelectMultiple, ) from virtualization.models import Cluster, ClusterGroup, VirtualMachine, VMInterface @@ -49,6 +49,7 @@ class VRFForm(TenancyForm, NetBoxModelForm): queryset=RouteTarget.objects.all(), required=False ) + comments = CommentField() fieldsets = ( ('VRF', ('name', 'rd', 'enforce_unique', 'description', 'tags')), @@ -59,8 +60,8 @@ class VRFForm(TenancyForm, NetBoxModelForm): class Meta: model = VRF fields = [ - 'name', 'rd', 'enforce_unique', 'description', 'import_targets', 'export_targets', 'tenant_group', 'tenant', - 'tags', + 'name', 'rd', 'enforce_unique', 'import_targets', 'export_targets', 'tenant_group', 'tenant', 'description', + 'comments', 'tags', ] labels = { 'rd': "RD", @@ -75,11 +76,12 @@ class RouteTargetForm(TenancyForm, NetBoxModelForm): ('Route Target', ('name', 'description', 'tags')), ('Tenancy', ('tenant_group', 'tenant')), ) + comments = CommentField() class Meta: model = RouteTarget fields = [ - 'name', 'description', 'tenant_group', 'tenant', 'tags', + 'name', 'tenant_group', 'tenant', 'description', 'comments', 'tags', ] @@ -104,6 +106,7 @@ class AggregateForm(TenancyForm, NetBoxModelForm): queryset=RIR.objects.all(), label='RIR' ) + comments = CommentField() fieldsets = ( ('Aggregate', ('prefix', 'rir', 'date_added', 'description', 'tags')), @@ -113,7 +116,7 @@ class AggregateForm(TenancyForm, NetBoxModelForm): class Meta: model = Aggregate fields = [ - 'prefix', 'rir', 'date_added', 'description', 'tenant_group', 'tenant', 'tags', + 'prefix', 'rir', 'date_added', 'tenant_group', 'tenant', 'description', 'comments', 'tags', ] help_texts = { 'prefix': "IPv4 or IPv6 network", @@ -134,6 +137,7 @@ class ASNForm(TenancyForm, NetBoxModelForm): label='Sites', required=False ) + comments = CommentField() fieldsets = ( ('ASN', ('asn', 'rir', 'sites', 'description', 'tags')), @@ -143,7 +147,7 @@ class ASNForm(TenancyForm, NetBoxModelForm): class Meta: model = ASN fields = [ - 'asn', 'rir', 'sites', 'tenant_group', 'tenant', 'description', 'tags' + 'asn', 'rir', 'sites', 'tenant_group', 'tenant', 'description', 'comments', 'tags' ] help_texts = { 'asn': "AS number", @@ -235,6 +239,7 @@ class PrefixForm(TenancyForm, NetBoxModelForm): queryset=Role.objects.all(), required=False ) + comments = CommentField() fieldsets = ( ('Prefix', ('prefix', 'status', 'vrf', 'role', 'is_pool', 'mark_utilized', 'description', 'tags')), @@ -245,8 +250,8 @@ class PrefixForm(TenancyForm, NetBoxModelForm): class Meta: model = Prefix fields = [ - 'prefix', 'vrf', 'site', 'vlan', 'status', 'role', 'is_pool', 'mark_utilized', 'description', - 'tenant_group', 'tenant', 'tags', + 'prefix', 'vrf', 'site', 'vlan', 'status', 'role', 'is_pool', 'mark_utilized', 'tenant_group', 'tenant', + 'description', 'comments', 'tags', ] widgets = { 'status': StaticSelect(), @@ -263,6 +268,7 @@ class IPRangeForm(TenancyForm, NetBoxModelForm): queryset=Role.objects.all(), required=False ) + comments = CommentField() fieldsets = ( ('IP Range', ('vrf', 'start_address', 'end_address', 'role', 'status', 'description', 'tags')), @@ -272,7 +278,8 @@ class IPRangeForm(TenancyForm, NetBoxModelForm): class Meta: model = IPRange fields = [ - 'vrf', 'start_address', 'end_address', 'status', 'role', 'description', 'tenant_group', 'tenant', 'tags', + 'vrf', 'start_address', 'end_address', 'status', 'role', 'tenant_group', 'tenant', 'description', + 'comments', 'tags', ] widgets = { 'status': StaticSelect(), @@ -394,13 +401,14 @@ class IPAddressForm(TenancyForm, NetBoxModelForm): required=False, label='Make this the primary IP for the device/VM' ) + comments = CommentField() class Meta: model = IPAddress fields = [ - 'address', 'vrf', 'status', 'role', 'dns_name', 'description', 'primary_for_parent', 'nat_site', 'nat_rack', - 'nat_device', 'nat_cluster', 'nat_virtual_machine', 'nat_vrf', 'nat_inside', 'tenant_group', 'tenant', - 'tags', + 'address', 'vrf', 'status', 'role', 'dns_name', 'primary_for_parent', 'nat_site', 'nat_rack', 'nat_device', + 'nat_cluster', 'nat_virtual_machine', 'nat_vrf', 'nat_inside', 'tenant_group', 'tenant', 'description', + 'comments', 'tags', ] widgets = { 'status': StaticSelect(), @@ -535,6 +543,7 @@ class FHRPGroupForm(NetBoxModelForm): required=False, label='Status' ) + comments = CommentField() fieldsets = ( ('FHRP Group', ('protocol', 'group_id', 'name', 'description', 'tags')), @@ -545,7 +554,8 @@ class FHRPGroupForm(NetBoxModelForm): class Meta: model = FHRPGroup fields = ( - 'protocol', 'group_id', 'auth_type', 'auth_key', 'name', 'description', 'ip_vrf', 'ip_address', 'ip_status', 'tags', + 'protocol', 'group_id', 'auth_type', 'auth_key', 'name', 'ip_vrf', 'ip_address', 'ip_status', 'description', + 'comments', 'tags', ) def save(self, *args, **kwargs): @@ -767,11 +777,13 @@ class VLANForm(TenancyForm, NetBoxModelForm): queryset=Role.objects.all(), required=False ) + comments = CommentField() class Meta: model = VLAN fields = [ - 'site', 'group', 'vid', 'name', 'status', 'role', 'description', 'tenant_group', 'tenant', 'tags', + 'site', 'group', 'vid', 'name', 'status', 'role', 'tenant_group', 'tenant', 'description', 'comments', + 'tags', ] help_texts = { 'site': "Leave blank if this VLAN spans multiple sites", @@ -794,6 +806,7 @@ class ServiceTemplateForm(NetBoxModelForm): ), help_text="Comma-separated list of one or more port numbers. A range may be specified using a hyphen." ) + comments = CommentField() fieldsets = ( ('Service Template', ( @@ -803,7 +816,7 @@ class ServiceTemplateForm(NetBoxModelForm): class Meta: model = ServiceTemplate - fields = ('name', 'protocol', 'ports', 'description', 'tags') + fields = ('name', 'protocol', 'ports', 'description', 'comments', 'tags') widgets = { 'protocol': StaticSelect(), } @@ -834,11 +847,12 @@ class ServiceForm(NetBoxModelForm): 'virtual_machine_id': '$virtual_machine', } ) + comments = CommentField() class Meta: model = Service fields = [ - 'device', 'virtual_machine', 'name', 'protocol', 'ports', 'ipaddresses', 'description', 'tags', + 'device', 'virtual_machine', 'name', 'protocol', 'ports', 'ipaddresses', 'description', 'comments', 'tags', ] help_texts = { 'ipaddresses': "IP address assignment is optional. If no IPs are selected, the service is assumed to be " @@ -899,6 +913,7 @@ class L2VPNForm(TenancyForm, NetBoxModelForm): queryset=RouteTarget.objects.all(), required=False ) + comments = CommentField() fieldsets = ( ('L2VPN', ('name', 'slug', 'type', 'identifier', 'description', 'tags')), @@ -909,7 +924,8 @@ class L2VPNForm(TenancyForm, NetBoxModelForm): class Meta: model = L2VPN fields = ( - 'name', 'slug', 'type', 'identifier', 'description', 'import_targets', 'export_targets', 'tenant', 'tags' + 'name', 'slug', 'type', 'identifier', 'import_targets', 'export_targets', 'tenant', 'description', + 'comments', 'tags' ) widgets = { 'type': StaticSelect(), diff --git a/netbox/ipam/migrations/0063_standardize_description_comments.py b/netbox/ipam/migrations/0063_standardize_description_comments.py new file mode 100644 index 000000000..3a4959d14 --- /dev/null +++ b/netbox/ipam/migrations/0063_standardize_description_comments.py @@ -0,0 +1,73 @@ +# Generated by Django 4.1.2 on 2022-11-03 18:24 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('ipam', '0062_unique_constraints'), + ] + + operations = [ + migrations.AddField( + model_name='aggregate', + name='comments', + field=models.TextField(blank=True), + ), + migrations.AddField( + model_name='asn', + name='comments', + field=models.TextField(blank=True), + ), + migrations.AddField( + model_name='fhrpgroup', + name='comments', + field=models.TextField(blank=True), + ), + migrations.AddField( + model_name='ipaddress', + name='comments', + field=models.TextField(blank=True), + ), + migrations.AddField( + model_name='iprange', + name='comments', + field=models.TextField(blank=True), + ), + migrations.AddField( + model_name='l2vpn', + name='comments', + field=models.TextField(blank=True), + ), + migrations.AddField( + model_name='prefix', + name='comments', + field=models.TextField(blank=True), + ), + migrations.AddField( + model_name='routetarget', + name='comments', + field=models.TextField(blank=True), + ), + migrations.AddField( + model_name='service', + name='comments', + field=models.TextField(blank=True), + ), + migrations.AddField( + model_name='servicetemplate', + name='comments', + field=models.TextField(blank=True), + ), + migrations.AddField( + model_name='vlan', + name='comments', + field=models.TextField(blank=True), + ), + migrations.AddField( + model_name='vrf', + name='comments', + field=models.TextField(blank=True), + ), + ] diff --git a/netbox/ipam/models/fhrp.py b/netbox/ipam/models/fhrp.py index 633affa41..759a6e1d3 100644 --- a/netbox/ipam/models/fhrp.py +++ b/netbox/ipam/models/fhrp.py @@ -4,7 +4,7 @@ from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models from django.urls import reverse -from netbox.models import ChangeLoggedModel, NetBoxModel +from netbox.models import ChangeLoggedModel, PrimaryModel from netbox.models.features import WebhooksMixin from ipam.choices import * from ipam.constants import * @@ -15,7 +15,7 @@ __all__ = ( ) -class FHRPGroup(NetBoxModel): +class FHRPGroup(PrimaryModel): """ A grouping of next hope resolution protocol (FHRP) peers. (For instance, VRRP or HSRP.) """ @@ -41,10 +41,6 @@ class FHRPGroup(NetBoxModel): blank=True, verbose_name='Authentication key' ) - description = models.CharField( - max_length=200, - blank=True - ) ip_addresses = GenericRelation( to='ipam.IPAddress', content_type_field='assigned_object_type', diff --git a/netbox/ipam/models/ip.py b/netbox/ipam/models/ip.py index 75f90ff54..bf9bd6d7f 100644 --- a/netbox/ipam/models/ip.py +++ b/netbox/ipam/models/ip.py @@ -9,7 +9,7 @@ from django.utils.functional import cached_property from dcim.fields import ASNField from dcim.models import Device -from netbox.models import OrganizationalModel, NetBoxModel +from netbox.models import OrganizationalModel, PrimaryModel from ipam.choices import * from ipam.constants import * from ipam.fields import IPNetworkField, IPAddressField @@ -76,7 +76,7 @@ class RIR(OrganizationalModel): return reverse('ipam:rir', args=[self.pk]) -class ASN(NetBoxModel): +class ASN(PrimaryModel): """ An autonomous system (AS) number is typically used to represent an independent routing domain. A site can have one or more ASNs assigned to it. @@ -86,10 +86,6 @@ class ASN(NetBoxModel): verbose_name='ASN', help_text='32-bit autonomous system number' ) - description = models.CharField( - max_length=200, - blank=True - ) rir = models.ForeignKey( to='ipam.RIR', on_delete=models.PROTECT, @@ -139,7 +135,7 @@ class ASN(NetBoxModel): return self.asn -class Aggregate(GetAvailablePrefixesMixin, NetBoxModel): +class Aggregate(GetAvailablePrefixesMixin, PrimaryModel): """ An aggregate exists at the root level of the IP address space hierarchy in NetBox. Aggregates are used to organize the hierarchy and track the overall utilization of available address space. Each Aggregate is assigned to a RIR. @@ -162,10 +158,6 @@ class Aggregate(GetAvailablePrefixesMixin, NetBoxModel): blank=True, null=True ) - description = models.CharField( - max_length=200, - blank=True - ) clone_fields = ( 'rir', 'tenant', 'date_added', 'description', @@ -264,7 +256,7 @@ class Role(OrganizationalModel): return reverse('ipam:role', args=[self.pk]) -class Prefix(GetAvailablePrefixesMixin, NetBoxModel): +class Prefix(GetAvailablePrefixesMixin, PrimaryModel): """ A Prefix represents an IPv4 or IPv6 network, including mask length. Prefixes can optionally be assigned to Sites and VRFs. A Prefix must be assigned a status and may optionally be assigned a used-define Role. A Prefix can also be @@ -327,10 +319,6 @@ class Prefix(GetAvailablePrefixesMixin, NetBoxModel): default=False, help_text="Treat as 100% utilized" ) - description = models.CharField( - max_length=200, - blank=True - ) # Cached depth & child counts _depth = models.PositiveSmallIntegerField( @@ -545,7 +533,7 @@ class Prefix(GetAvailablePrefixesMixin, NetBoxModel): return min(utilization, 100) -class IPRange(NetBoxModel): +class IPRange(PrimaryModel): """ A range of IP addresses, defined by start and end addresses. """ @@ -587,10 +575,6 @@ class IPRange(NetBoxModel): null=True, help_text='The primary function of this range' ) - description = models.CharField( - max_length=200, - blank=True - ) clone_fields = ( 'vrf', 'tenant', 'status', 'role', 'description', @@ -740,7 +724,7 @@ class IPRange(NetBoxModel): return int(float(child_count) / self.size * 100) -class IPAddress(NetBoxModel): +class IPAddress(PrimaryModel): """ An IPAddress represents an individual IPv4 or IPv6 address and its mask. The mask length should match what is configured in the real world. (Typically, only loopback interfaces are configured with /32 or /128 masks.) Like @@ -813,10 +797,6 @@ class IPAddress(NetBoxModel): verbose_name='DNS Name', help_text='Hostname or FQDN (not case-sensitive)' ) - description = models.CharField( - max_length=200, - blank=True - ) objects = IPAddressManager() diff --git a/netbox/ipam/models/l2vpn.py b/netbox/ipam/models/l2vpn.py index a457f334b..f3f7a1d55 100644 --- a/netbox/ipam/models/l2vpn.py +++ b/netbox/ipam/models/l2vpn.py @@ -8,7 +8,7 @@ from django.utils.functional import cached_property from ipam.choices import L2VPNTypeChoices from ipam.constants import L2VPN_ASSIGNMENT_MODELS -from netbox.models import NetBoxModel +from netbox.models import NetBoxModel, PrimaryModel __all__ = ( 'L2VPN', @@ -16,7 +16,7 @@ __all__ = ( ) -class L2VPN(NetBoxModel): +class L2VPN(PrimaryModel): name = models.CharField( max_length=100, unique=True @@ -43,10 +43,6 @@ class L2VPN(NetBoxModel): related_name='exporting_l2vpns', blank=True ) - description = models.CharField( - max_length=200, - blank=True - ) tenant = models.ForeignKey( to='tenancy.Tenant', on_delete=models.PROTECT, diff --git a/netbox/ipam/models/services.py b/netbox/ipam/models/services.py index b566db375..690abf045 100644 --- a/netbox/ipam/models/services.py +++ b/netbox/ipam/models/services.py @@ -6,7 +6,7 @@ from django.urls import reverse from ipam.choices import * from ipam.constants import * -from netbox.models import NetBoxModel +from netbox.models import PrimaryModel from utilities.utils import array_to_string @@ -30,10 +30,6 @@ class ServiceBase(models.Model): ), verbose_name='Port numbers' ) - description = models.CharField( - max_length=200, - blank=True - ) class Meta: abstract = True @@ -46,7 +42,7 @@ class ServiceBase(models.Model): return array_to_string(self.ports) -class ServiceTemplate(ServiceBase, NetBoxModel): +class ServiceTemplate(ServiceBase, PrimaryModel): """ A template for a Service to be applied to a device or virtual machine. """ @@ -62,7 +58,7 @@ class ServiceTemplate(ServiceBase, NetBoxModel): return reverse('ipam:servicetemplate', args=[self.pk]) -class Service(ServiceBase, NetBoxModel): +class Service(ServiceBase, PrimaryModel): """ A Service represents a layer-four service (e.g. HTTP or SSH) running on a Device or VirtualMachine. A Service may optionally be tied to one or more specific IPAddresses belonging to its parent. diff --git a/netbox/ipam/models/vlans.py b/netbox/ipam/models/vlans.py index e3a4b973b..4f5d513cf 100644 --- a/netbox/ipam/models/vlans.py +++ b/netbox/ipam/models/vlans.py @@ -8,12 +8,10 @@ from django.urls import reverse from dcim.models import Interface from ipam.choices import * from ipam.constants import * -from ipam.models import L2VPNTermination from ipam.querysets import VLANQuerySet -from netbox.models import OrganizationalModel, NetBoxModel +from netbox.models import OrganizationalModel, PrimaryModel from virtualization.models import VMInterface - __all__ = ( 'VLAN', 'VLANGroup', @@ -63,10 +61,6 @@ class VLANGroup(OrganizationalModel): ), help_text='Highest permissible ID of a child VLAN' ) - description = models.CharField( - max_length=200, - blank=True - ) class Meta: ordering = ('name', 'pk') # Name may be non-unique @@ -120,7 +114,7 @@ class VLANGroup(OrganizationalModel): return None -class VLAN(NetBoxModel): +class VLAN(PrimaryModel): """ A VLAN is a distinct layer two forwarding domain identified by a 12-bit integer (1-4094). Each VLAN must be assigned to a Site, however VLAN IDs need not be unique within a Site. A VLAN may optionally be assigned to a VLANGroup, @@ -172,10 +166,6 @@ class VLAN(NetBoxModel): blank=True, null=True ) - description = models.CharField( - max_length=200, - blank=True - ) l2vpn_terminations = GenericRelation( to='ipam.L2VPNTermination', diff --git a/netbox/ipam/models/vrfs.py b/netbox/ipam/models/vrfs.py index a926bec3e..0f3c9793c 100644 --- a/netbox/ipam/models/vrfs.py +++ b/netbox/ipam/models/vrfs.py @@ -2,7 +2,7 @@ from django.db import models from django.urls import reverse from ipam.constants import * -from netbox.models import NetBoxModel +from netbox.models import PrimaryModel __all__ = ( @@ -11,7 +11,7 @@ __all__ = ( ) -class VRF(NetBoxModel): +class VRF(PrimaryModel): """ A virtual routing and forwarding (VRF) table represents a discrete layer three forwarding domain (e.g. a routing table). Prefixes and IPAddresses can optionally be assigned to VRFs. (Prefixes and IPAddresses not assigned to a VRF @@ -40,10 +40,6 @@ class VRF(NetBoxModel): verbose_name='Enforce unique space', help_text='Prevent duplicate prefixes/IP addresses within this VRF' ) - description = models.CharField( - max_length=200, - blank=True - ) import_targets = models.ManyToManyField( to='ipam.RouteTarget', related_name='importing_vrfs', @@ -73,7 +69,7 @@ class VRF(NetBoxModel): return reverse('ipam:vrf', args=[self.pk]) -class RouteTarget(NetBoxModel): +class RouteTarget(PrimaryModel): """ A BGP extended community used to control the redistribution of routes among VRFs, as defined in RFC 4364. """ @@ -82,10 +78,6 @@ class RouteTarget(NetBoxModel): unique=True, help_text='Route target value (formatted in accordance with RFC 4360)' ) - description = models.CharField( - max_length=200, - blank=True - ) tenant = models.ForeignKey( to='tenancy.Tenant', on_delete=models.PROTECT, diff --git a/netbox/ipam/tables/fhrp.py b/netbox/ipam/tables/fhrp.py index beffdd232..89aa16e65 100644 --- a/netbox/ipam/tables/fhrp.py +++ b/netbox/ipam/tables/fhrp.py @@ -20,7 +20,6 @@ class FHRPGroupTable(NetBoxTable): group_id = tables.Column( linkify=True ) - comments = columns.MarkdownColumn() ip_addresses = tables.TemplateColumn( template_code=IPADDRESSES, orderable=False, @@ -29,6 +28,7 @@ class FHRPGroupTable(NetBoxTable): member_count = tables.Column( verbose_name='Members' ) + comments = columns.MarkdownColumn() tags = columns.TagColumn( url_name='ipam:fhrpgroup_list' ) @@ -36,7 +36,7 @@ class FHRPGroupTable(NetBoxTable): class Meta(NetBoxTable.Meta): model = FHRPGroup fields = ( - 'pk', 'group_id', 'protocol', 'name', 'auth_type', 'auth_key', 'description', 'ip_addresses', + 'pk', 'group_id', 'protocol', 'name', 'auth_type', 'auth_key', 'description', 'comments', 'ip_addresses', 'member_count', 'tags', 'created', 'last_updated', ) default_columns = ( diff --git a/netbox/ipam/tables/ip.py b/netbox/ipam/tables/ip.py index 44f40b8a1..f83831d2d 100644 --- a/netbox/ipam/tables/ip.py +++ b/netbox/ipam/tables/ip.py @@ -120,6 +120,7 @@ class ASNTable(TenancyColumnsMixin, NetBoxTable): linkify_item=True, verbose_name='Sites' ) + comments = columns.MarkdownColumn() tags = columns.TagColumn( url_name='ipam:asn_list' ) @@ -127,8 +128,8 @@ class ASNTable(TenancyColumnsMixin, NetBoxTable): class Meta(NetBoxTable.Meta): model = ASN fields = ( - 'pk', 'asn', 'asn_asdot', 'rir', 'site_count', 'provider_count', 'tenant', 'tenant_group', 'description', 'sites', 'tags', - 'created', 'last_updated', 'actions', + 'pk', 'asn', 'asn_asdot', 'rir', 'site_count', 'provider_count', 'tenant', 'tenant_group', 'description', + 'comments', 'sites', 'tags', 'created', 'last_updated', 'actions', ) default_columns = ('pk', 'asn', 'rir', 'site_count', 'provider_count', 'sites', 'description', 'tenant') @@ -153,6 +154,7 @@ class AggregateTable(TenancyColumnsMixin, NetBoxTable): accessor='get_utilization', orderable=False ) + comments = columns.MarkdownColumn() tags = columns.TagColumn( url_name='ipam:aggregate_list' ) @@ -160,8 +162,8 @@ class AggregateTable(TenancyColumnsMixin, NetBoxTable): class Meta(NetBoxTable.Meta): model = Aggregate fields = ( - 'pk', 'id', 'prefix', 'rir', 'tenant', 'tenant_group', 'child_count', 'utilization', 'date_added', 'description', 'tags', - 'created', 'last_updated', + 'pk', 'id', 'prefix', 'rir', 'tenant', 'tenant_group', 'child_count', 'utilization', 'date_added', + 'description', 'comments', 'tags', 'created', 'last_updated', ) default_columns = ('pk', 'prefix', 'rir', 'tenant', 'child_count', 'utilization', 'date_added', 'description') @@ -278,6 +280,7 @@ class PrefixTable(TenancyColumnsMixin, NetBoxTable): accessor='get_utilization', orderable=False ) + comments = columns.MarkdownColumn() tags = columns.TagColumn( url_name='ipam:prefix_list' ) @@ -285,8 +288,9 @@ class PrefixTable(TenancyColumnsMixin, NetBoxTable): class Meta(NetBoxTable.Meta): model = Prefix fields = ( - 'pk', 'id', 'prefix', 'prefix_flat', 'status', 'children', 'vrf', 'utilization', 'tenant', 'tenant_group', 'site', - 'vlan_group', 'vlan', 'role', 'is_pool', 'mark_utilized', 'description', 'tags', 'created', 'last_updated', + 'pk', 'id', 'prefix', 'prefix_flat', 'status', 'children', 'vrf', 'utilization', 'tenant', 'tenant_group', + 'site', 'vlan_group', 'vlan', 'role', 'is_pool', 'mark_utilized', 'description', 'comments', 'tags', + 'created', 'last_updated', ) default_columns = ( 'pk', 'prefix', 'status', 'children', 'vrf', 'utilization', 'tenant', 'site', 'vlan', 'role', 'description', @@ -317,6 +321,7 @@ class IPRangeTable(TenancyColumnsMixin, NetBoxTable): accessor='utilization', orderable=False ) + comments = columns.MarkdownColumn() tags = columns.TagColumn( url_name='ipam:iprange_list' ) @@ -324,8 +329,8 @@ class IPRangeTable(TenancyColumnsMixin, NetBoxTable): class Meta(NetBoxTable.Meta): model = IPRange fields = ( - 'pk', 'id', 'start_address', 'end_address', 'size', 'vrf', 'status', 'role', 'tenant', 'tenant_group', 'description', - 'utilization', 'tags', 'created', 'last_updated', + 'pk', 'id', 'start_address', 'end_address', 'size', 'vrf', 'status', 'role', 'tenant', 'tenant_group', + 'utilization', 'description', 'comments', 'tags', 'created', 'last_updated', ) default_columns = ( 'pk', 'start_address', 'end_address', 'size', 'vrf', 'status', 'role', 'tenant', 'description', @@ -378,6 +383,7 @@ class IPAddressTable(TenancyColumnsMixin, NetBoxTable): linkify=lambda record: record.assigned_object.get_absolute_url(), verbose_name='Assigned' ) + comments = columns.MarkdownColumn() tags = columns.TagColumn( url_name='ipam:ipaddress_list' ) @@ -385,8 +391,8 @@ class IPAddressTable(TenancyColumnsMixin, NetBoxTable): class Meta(NetBoxTable.Meta): model = IPAddress fields = ( - 'pk', 'id', 'address', 'vrf', 'status', 'role', 'tenant', 'tenant_group', 'nat_inside', 'nat_outside', 'assigned', 'dns_name', 'description', - 'tags', 'created', 'last_updated', + 'pk', 'id', 'address', 'vrf', 'status', 'role', 'tenant', 'tenant_group', 'nat_inside', 'nat_outside', + 'assigned', 'dns_name', 'description', 'comments', 'tags', 'created', 'last_updated', ) default_columns = ( 'pk', 'address', 'vrf', 'status', 'role', 'tenant', 'assigned', 'dns_name', 'description', diff --git a/netbox/ipam/tables/l2vpn.py b/netbox/ipam/tables/l2vpn.py index 4a6af7c9b..2ece2c434 100644 --- a/netbox/ipam/tables/l2vpn.py +++ b/netbox/ipam/tables/l2vpn.py @@ -29,12 +29,16 @@ class L2VPNTable(TenancyColumnsMixin, NetBoxTable): template_code=L2VPN_TARGETS, orderable=False ) + comments = columns.MarkdownColumn() + tags = columns.TagColumn( + url_name='ipam:prefix_list' + ) class Meta(NetBoxTable.Meta): model = L2VPN fields = ( - 'pk', 'name', 'slug', 'identifier', 'type', 'description', 'import_targets', 'export_targets', 'tenant', 'tenant_group', - 'actions', + 'pk', 'name', 'slug', 'identifier', 'type', 'import_targets', 'export_targets', 'tenant', 'tenant_group', + 'description', 'comments', 'tags', 'created', 'last_updated', 'actions', ) default_columns = ('pk', 'name', 'identifier', 'type', 'description', 'actions') diff --git a/netbox/ipam/tables/services.py b/netbox/ipam/tables/services.py index 58d0a9aff..826ac98d5 100644 --- a/netbox/ipam/tables/services.py +++ b/netbox/ipam/tables/services.py @@ -17,13 +17,16 @@ class ServiceTemplateTable(NetBoxTable): accessor=tables.A('port_list'), order_by=tables.A('ports'), ) + comments = columns.MarkdownColumn() tags = columns.TagColumn( url_name='ipam:servicetemplate_list' ) class Meta(NetBoxTable.Meta): model = ServiceTemplate - fields = ('pk', 'id', 'name', 'protocol', 'ports', 'description', 'tags') + fields = ( + 'pk', 'id', 'name', 'protocol', 'ports', 'description', 'comments', 'tags', 'created', 'last_updated', + ) default_columns = ('pk', 'name', 'protocol', 'ports', 'description') @@ -39,6 +42,7 @@ class ServiceTable(NetBoxTable): accessor=tables.A('port_list'), order_by=tables.A('ports'), ) + comments = columns.MarkdownColumn() tags = columns.TagColumn( url_name='ipam:service_list' ) @@ -46,7 +50,7 @@ class ServiceTable(NetBoxTable): class Meta(NetBoxTable.Meta): model = Service fields = ( - 'pk', 'id', 'name', 'parent', 'protocol', 'ports', 'ipaddresses', 'description', 'tags', 'created', - 'last_updated', + 'pk', 'id', 'name', 'parent', 'protocol', 'ports', 'ipaddresses', 'description', 'comments', 'tags', + 'created', 'last_updated', ) default_columns = ('pk', 'name', 'parent', 'protocol', 'ports', 'description') diff --git a/netbox/ipam/tables/vlans.py b/netbox/ipam/tables/vlans.py index f183f8a7b..6fa2cd2da 100644 --- a/netbox/ipam/tables/vlans.py +++ b/netbox/ipam/tables/vlans.py @@ -121,6 +121,7 @@ class VLANTable(TenancyColumnsMixin, NetBoxTable): orderable=False, verbose_name='Prefixes' ) + comments = columns.MarkdownColumn() tags = columns.TagColumn( url_name='ipam:vlan_list' ) @@ -129,7 +130,7 @@ class VLANTable(TenancyColumnsMixin, NetBoxTable): model = VLAN fields = ( 'pk', 'id', 'vid', 'name', 'site', 'group', 'prefixes', 'tenant', 'tenant_group', 'status', 'role', - 'description', 'tags', 'l2vpn', 'created', 'last_updated', + 'description', 'comments', 'tags', 'l2vpn', 'created', 'last_updated', ) default_columns = ('pk', 'vid', 'name', 'site', 'group', 'prefixes', 'tenant', 'status', 'role', 'description') row_attrs = { diff --git a/netbox/ipam/tables/vrfs.py b/netbox/ipam/tables/vrfs.py index 69807410b..635af48d0 100644 --- a/netbox/ipam/tables/vrfs.py +++ b/netbox/ipam/tables/vrfs.py @@ -38,6 +38,7 @@ class VRFTable(TenancyColumnsMixin, NetBoxTable): template_code=VRF_TARGETS, orderable=False ) + comments = columns.MarkdownColumn() tags = columns.TagColumn( url_name='ipam:vrf_list' ) @@ -45,8 +46,8 @@ class VRFTable(TenancyColumnsMixin, NetBoxTable): class Meta(NetBoxTable.Meta): model = VRF fields = ( - 'pk', 'id', 'name', 'rd', 'tenant', 'tenant_group', 'enforce_unique', 'description', 'import_targets', 'export_targets', - 'tags', 'created', 'last_updated', + 'pk', 'id', 'name', 'rd', 'tenant', 'tenant_group', 'enforce_unique', 'import_targets', 'export_targets', + 'description', 'comments', 'tags', 'created', 'last_updated', ) default_columns = ('pk', 'name', 'rd', 'tenant', 'description') @@ -59,11 +60,14 @@ class RouteTargetTable(TenancyColumnsMixin, NetBoxTable): name = tables.Column( linkify=True ) + comments = columns.MarkdownColumn() tags = columns.TagColumn( url_name='ipam:vrf_list' ) class Meta(NetBoxTable.Meta): model = RouteTarget - fields = ('pk', 'id', 'name', 'tenant', 'tenant_group', 'description', 'tags', 'created', 'last_updated',) + fields = ( + 'pk', 'id', 'name', 'tenant', 'tenant_group', 'description', 'comments', 'tags', 'created', 'last_updated', + ) default_columns = ('pk', 'name', 'tenant', 'description') diff --git a/netbox/netbox/models/__init__.py b/netbox/netbox/models/__init__.py index 38a6fcc9f..661470ee0 100644 --- a/netbox/netbox/models/__init__.py +++ b/netbox/netbox/models/__init__.py @@ -10,8 +10,9 @@ from netbox.models.features import * __all__ = ( 'ChangeLoggedModel', 'NestedGroupModel', - 'OrganizationalModel', 'NetBoxModel', + 'OrganizationalModel', + 'PrimaryModel', ) @@ -58,7 +59,7 @@ class ChangeLoggedModel(ChangeLoggingMixin, CustomValidationMixin, models.Model) class NetBoxModel(CloningMixin, NetBoxFeatureSet, models.Model): """ - Primary models represent real objects within the infrastructure being modeled. + Base model for most object types. Suitable for use by plugins. """ objects = RestrictedQuerySet.as_manager() @@ -66,6 +67,22 @@ class NetBoxModel(CloningMixin, NetBoxFeatureSet, models.Model): abstract = True +class PrimaryModel(NetBoxModel): + """ + Primary models represent real objects within the infrastructure being modeled. + """ + description = models.CharField( + max_length=200, + blank=True + ) + comments = models.TextField( + blank=True + ) + + class Meta: + abstract = True + + class NestedGroupModel(NetBoxFeatureSet, MPTTModel): """ Base model for objects which are used to form a hierarchy (regions, locations, etc.). These models nest diff --git a/netbox/templates/circuits/provider.html b/netbox/templates/circuits/provider.html index 0fc18a368..51f911350 100644 --- a/netbox/templates/circuits/provider.html +++ b/netbox/templates/circuits/provider.html @@ -33,6 +33,10 @@ Account {{ object.account|placeholder }} + + Description + {{ object.description|placeholder }} + Circuits diff --git a/netbox/templates/dcim/cable.html b/netbox/templates/dcim/cable.html index e032d7034..bd0f27106 100644 --- a/netbox/templates/dcim/cable.html +++ b/netbox/templates/dcim/cable.html @@ -32,6 +32,10 @@ Label {{ object.label|placeholder }} + + Description + {{ object.description|placeholder }} + Color @@ -57,6 +61,7 @@ {% include 'inc/panels/custom_fields.html' %} {% include 'inc/panels/tags.html' %} + {% include 'inc/panels/comments.html' %} {% plugin_left_page object %}
diff --git a/netbox/templates/dcim/cable_edit.html b/netbox/templates/dcim/cable_edit.html index 29bb60d70..1c747b44b 100644 --- a/netbox/templates/dcim/cable_edit.html +++ b/netbox/templates/dcim/cable_edit.html @@ -80,6 +80,7 @@ {% render_field form.tenant_group %} {% render_field form.tenant %} {% render_field form.label %} + {% render_field form.description %} {% render_field form.color %}
@@ -92,16 +93,22 @@
{% render_field form.tags %} - {% if form.custom_fields %} -
-
-
Custom Fields
-
- {% render_custom_fields form %} -
- {% endif %}
+
+
Comments
+
+ {% render_field form.comments %} +
+
+ {% if form.custom_fields %} +
+
Custom Fields
+
+ {% render_custom_fields form %} +
+
+ {% endif %}
diff --git a/netbox/templates/dcim/device.html b/netbox/templates/dcim/device.html index b0cd76de4..046600d08 100644 --- a/netbox/templates/dcim/device.html +++ b/netbox/templates/dcim/device.html @@ -94,7 +94,11 @@ - Airflow + Description + {{ object.description|placeholder }} + + + Airflow {{ object.get_airflow_display|placeholder }} diff --git a/netbox/templates/dcim/device_edit.html b/netbox/templates/dcim/device_edit.html index 38125e83c..b814e65ef 100644 --- a/netbox/templates/dcim/device_edit.html +++ b/netbox/templates/dcim/device_edit.html @@ -10,6 +10,7 @@
{% render_field form.name %} {% render_field form.device_role %} + {% render_field form.description %} {% render_field form.tags %} diff --git a/netbox/templates/dcim/devicetype.html b/netbox/templates/dcim/devicetype.html index 458c74ac1..930390a56 100644 --- a/netbox/templates/dcim/devicetype.html +++ b/netbox/templates/dcim/devicetype.html @@ -27,6 +27,10 @@ Part Number {{ object.part_number|placeholder }} + + Description + {{ object.description|placeholder }} + Height (U) {{ object.u_height|floatformat }} diff --git a/netbox/templates/dcim/module.html b/netbox/templates/dcim/module.html index f2dac38f2..139ac2eb8 100644 --- a/netbox/templates/dcim/module.html +++ b/netbox/templates/dcim/module.html @@ -62,6 +62,10 @@ Module Type {{ object.module_type|linkify }} + + Description + {{ object.description|placeholder }} + Serial Number {{ object.serial|placeholder }} diff --git a/netbox/templates/dcim/moduletype.html b/netbox/templates/dcim/moduletype.html index 8128e64be..fd0148c2f 100644 --- a/netbox/templates/dcim/moduletype.html +++ b/netbox/templates/dcim/moduletype.html @@ -22,6 +22,10 @@ Part Number {{ object.part_number|placeholder }} + + Description + {{ object.description|placeholder }} + Weight diff --git a/netbox/templates/dcim/powerfeed.html b/netbox/templates/dcim/powerfeed.html index 54ac96bab..6387c111d 100644 --- a/netbox/templates/dcim/powerfeed.html +++ b/netbox/templates/dcim/powerfeed.html @@ -38,6 +38,10 @@ Status {% badge object.get_status_display bg_color=object.get_status_color %} + + Description + {{ object.description|placeholder }} + Connected Device diff --git a/netbox/templates/dcim/powerpanel.html b/netbox/templates/dcim/powerpanel.html index b7fe8eb39..16bd82cc0 100644 --- a/netbox/templates/dcim/powerpanel.html +++ b/netbox/templates/dcim/powerpanel.html @@ -14,26 +14,29 @@ {% block content %}
-
-
- Power Panel -
-
- - - - - - - - - -
Site{{ object.site|linkify }}
Location{{ object.location|linkify|placeholder }}
-
-
- {% include 'inc/panels/tags.html' %} - {% plugin_left_page object %} +
+
Power Panel
+
+ + + + + + + + + + + + + +
Site{{ object.site|linkify }}
Location{{ object.location|linkify|placeholder }}
Description{{ object.description|placeholder }}
+
+ {% include 'inc/panels/tags.html' %} + {% include 'inc/panels/comments.html' %} + {% plugin_left_page object %} +
{% include 'inc/panels/custom_fields.html' %} {% include 'inc/panels/contacts.html' %} diff --git a/netbox/templates/dcim/rack.html b/netbox/templates/dcim/rack.html index 7118f09ef..185634e8a 100644 --- a/netbox/templates/dcim/rack.html +++ b/netbox/templates/dcim/rack.html @@ -78,6 +78,10 @@ Role {{ object.role|linkify|placeholder }} + + Description + {{ object.description|placeholder }} + Serial Number {{ object.serial|placeholder }} diff --git a/netbox/templates/dcim/rack_edit.html b/netbox/templates/dcim/rack_edit.html index a0af20c68..d214bbee8 100644 --- a/netbox/templates/dcim/rack_edit.html +++ b/netbox/templates/dcim/rack_edit.html @@ -13,6 +13,7 @@ {% render_field form.name %} {% render_field form.status %} {% render_field form.role %} + {% render_field form.description %} {% render_field form.tags %}
diff --git a/netbox/templates/dcim/rackreservation.html b/netbox/templates/dcim/rackreservation.html index ebdd1d845..52472e297 100644 --- a/netbox/templates/dcim/rackreservation.html +++ b/netbox/templates/dcim/rackreservation.html @@ -73,6 +73,7 @@
{% include 'inc/panels/custom_fields.html' %} {% include 'inc/panels/tags.html' %} + {% include 'inc/panels/comments.html' %} {% plugin_left_page object %}
diff --git a/netbox/templates/dcim/virtualchassis.html b/netbox/templates/dcim/virtualchassis.html index 1ff9f2e9a..d0fba3ca2 100644 --- a/netbox/templates/dcim/virtualchassis.html +++ b/netbox/templates/dcim/virtualchassis.html @@ -27,11 +27,15 @@ Master {{ object.master|linkify }} + + Description + {{ object.description|placeholder }} +
- {% include 'inc/panels/custom_fields.html' %} {% include 'inc/panels/tags.html' %} + {% include 'inc/panels/custom_fields.html' %} {% plugin_left_page object %}
@@ -73,6 +77,7 @@
{% endif %} + {% include 'inc/panels/comments.html' %} {% plugin_right_page object %} diff --git a/netbox/templates/dcim/virtualchassis_edit.html b/netbox/templates/dcim/virtualchassis_edit.html index 87917f2a2..f98a9fe64 100644 --- a/netbox/templates/dcim/virtualchassis_edit.html +++ b/netbox/templates/dcim/virtualchassis_edit.html @@ -17,12 +17,18 @@ {% render_field vc_form.name %} {% render_field vc_form.domain %} + {% render_field vc_form.description %} {% render_field vc_form.master %} {% render_field vc_form.tags %} +
+
Comments
+ {% render_field vc_form.comments %} +
+ {% if vc_form.custom_fields %} -
+
Custom Fields
diff --git a/netbox/templates/ipam/aggregate.html b/netbox/templates/ipam/aggregate.html index f3eff9df1..b95341b16 100644 --- a/netbox/templates/ipam/aggregate.html +++ b/netbox/templates/ipam/aggregate.html @@ -51,6 +51,7 @@
{% include 'inc/panels/custom_fields.html' %} {% include 'inc/panels/tags.html' %} + {% include 'inc/panels/comments.html' %} {% plugin_right_page object %}
diff --git a/netbox/templates/ipam/asn.html b/netbox/templates/ipam/asn.html index 7afe981e6..3af5177cc 100644 --- a/netbox/templates/ipam/asn.html +++ b/netbox/templates/ipam/asn.html @@ -67,6 +67,7 @@
{% include 'inc/panels/custom_fields.html' %} {% include 'inc/panels/tags.html' with tags=object.tags.all url='ipam:asn_list' %} + {% include 'inc/panels/comments.html' %} {% plugin_right_page object %}
diff --git a/netbox/templates/ipam/fhrpgroup.html b/netbox/templates/ipam/fhrpgroup.html index 89fc7083c..a74ddac70 100644 --- a/netbox/templates/ipam/fhrpgroup.html +++ b/netbox/templates/ipam/fhrpgroup.html @@ -42,6 +42,7 @@ {% include 'inc/panels/tags.html' %} + {% include 'inc/panels/comments.html' %} {% plugin_left_page object %}
diff --git a/netbox/templates/ipam/fhrpgroup_edit.html b/netbox/templates/ipam/fhrpgroup_edit.html index 02816b440..bf86e6c41 100644 --- a/netbox/templates/ipam/fhrpgroup_edit.html +++ b/netbox/templates/ipam/fhrpgroup_edit.html @@ -13,7 +13,7 @@ {% render_field form.tags %}
-
+
Authentication
@@ -22,7 +22,7 @@
{% if not form.instance.pk %} -
+
Virtual IP Address
@@ -32,6 +32,13 @@
{% endif %} +
+
+
Comments
+
+ {% render_field form.comments %} +
+ {% if form.custom_fields %}
Custom Fields
diff --git a/netbox/templates/ipam/ipaddress.html b/netbox/templates/ipam/ipaddress.html index 7f77e8137..4a110c2e6 100644 --- a/netbox/templates/ipam/ipaddress.html +++ b/netbox/templates/ipam/ipaddress.html @@ -108,6 +108,7 @@
{% include 'inc/panels/tags.html' %} {% include 'inc/panels/custom_fields.html' %} + {% include 'inc/panels/comments.html' %} {% plugin_left_page object %}
diff --git a/netbox/templates/ipam/ipaddress_edit.html b/netbox/templates/ipam/ipaddress_edit.html index f4b21397a..b9a988009 100644 --- a/netbox/templates/ipam/ipaddress_edit.html +++ b/netbox/templates/ipam/ipaddress_edit.html @@ -138,6 +138,13 @@
+
+
+
Comments
+
+ {% render_field form.comments %} +
+ {% if form.custom_fields %}
diff --git a/netbox/templates/ipam/iprange.html b/netbox/templates/ipam/iprange.html index c78b5a132..6ba9e4bea 100644 --- a/netbox/templates/ipam/iprange.html +++ b/netbox/templates/ipam/iprange.html @@ -70,9 +70,10 @@ {% plugin_left_page object %}
- {% include 'inc/panels/tags.html' %} - {% include 'inc/panels/custom_fields.html' %} - {% plugin_right_page object %} + {% include 'inc/panels/tags.html' %} + {% include 'inc/panels/custom_fields.html' %} + {% include 'inc/panels/comments.html' %} + {% plugin_right_page object %}
diff --git a/netbox/templates/ipam/l2vpn.html b/netbox/templates/ipam/l2vpn.html index c19363d33..4ffda2c98 100644 --- a/netbox/templates/ipam/l2vpn.html +++ b/netbox/templates/ipam/l2vpn.html @@ -39,6 +39,7 @@
{% include 'inc/panels/contacts.html' %} {% include 'inc/panels/custom_fields.html' %} + {% include 'inc/panels/comments.html' %} {% plugin_right_page object %}
diff --git a/netbox/templates/ipam/prefix.html b/netbox/templates/ipam/prefix.html index b15aa60bb..a0baf3325 100644 --- a/netbox/templates/ipam/prefix.html +++ b/netbox/templates/ipam/prefix.html @@ -155,6 +155,7 @@ {% include 'inc/panels/custom_fields.html' %} {% include 'inc/panels/tags.html' %} + {% include 'inc/panels/comments.html' %} {% plugin_right_page object %} diff --git a/netbox/templates/ipam/routetarget.html b/netbox/templates/ipam/routetarget.html index e093aee61..ea7a98c97 100644 --- a/netbox/templates/ipam/routetarget.html +++ b/netbox/templates/ipam/routetarget.html @@ -26,6 +26,7 @@ {% include 'inc/panels/tags.html' %} {% include 'inc/panels/custom_fields.html' %} + {% include 'inc/panels/comments.html' %} {% plugin_left_page object %}
diff --git a/netbox/templates/ipam/service.html b/netbox/templates/ipam/service.html index 47ae70dc9..fdc4be342 100644 --- a/netbox/templates/ipam/service.html +++ b/netbox/templates/ipam/service.html @@ -58,9 +58,10 @@ {% plugin_left_page object %}
- {% include 'inc/panels/custom_fields.html' %} - {% include 'inc/panels/tags.html' %} - {% plugin_right_page object %} + {% include 'inc/panels/custom_fields.html' %} + {% include 'inc/panels/tags.html' %} + {% include 'inc/panels/comments.html' %} + {% plugin_right_page object %}
diff --git a/netbox/templates/ipam/service_create.html b/netbox/templates/ipam/service_create.html index 022821bcf..5c47dd2f8 100644 --- a/netbox/templates/ipam/service_create.html +++ b/netbox/templates/ipam/service_create.html @@ -65,6 +65,13 @@ {% render_field form.tags %}
+
+
+
Comments
+
+ {% render_field form.comments %} +
+ {% if form.custom_fields %}
Custom Fields
diff --git a/netbox/templates/ipam/service_edit.html b/netbox/templates/ipam/service_edit.html index f3e34a7d1..709d816c1 100644 --- a/netbox/templates/ipam/service_edit.html +++ b/netbox/templates/ipam/service_edit.html @@ -52,6 +52,13 @@ {% render_field form.tags %}
+
+
+
Comments
+
+ {% render_field form.comments %} +
+ {% if form.custom_fields %}
Custom Fields
diff --git a/netbox/templates/ipam/servicetemplate.html b/netbox/templates/ipam/servicetemplate.html index 6e2aacb34..afb4163b9 100644 --- a/netbox/templates/ipam/servicetemplate.html +++ b/netbox/templates/ipam/servicetemplate.html @@ -31,12 +31,13 @@
{% plugin_left_page object %} - -
- {% include 'inc/panels/custom_fields.html' %} - {% include 'inc/panels/tags.html' %} - {% plugin_right_page object %} -
+ +
+ {% include 'inc/panels/custom_fields.html' %} + {% include 'inc/panels/tags.html' %} + {% include 'inc/panels/comments.html' %} + {% plugin_right_page object %} +
diff --git a/netbox/templates/ipam/vlan.html b/netbox/templates/ipam/vlan.html index 53bb75b8f..c0f68bae2 100644 --- a/netbox/templates/ipam/vlan.html +++ b/netbox/templates/ipam/vlan.html @@ -74,9 +74,10 @@ {% plugin_left_page object %}
- {% include 'inc/panels/custom_fields.html' %} - {% include 'inc/panels/tags.html' %} - {% plugin_right_page object %} + {% include 'inc/panels/custom_fields.html' %} + {% include 'inc/panels/tags.html' %} + {% include 'inc/panels/comments.html' %} + {% plugin_right_page object %}
diff --git a/netbox/templates/ipam/vlan_edit.html b/netbox/templates/ipam/vlan_edit.html index 5aa577942..f4432efe3 100644 --- a/netbox/templates/ipam/vlan_edit.html +++ b/netbox/templates/ipam/vlan_edit.html @@ -55,6 +55,13 @@ {% endwith %}
+
+
+
Comments
+
+ {% render_field form.comments %} +
+ {% if form.custom_fields %}
diff --git a/netbox/templates/ipam/vrf.html b/netbox/templates/ipam/vrf.html index 831338600..b53862f9e 100644 --- a/netbox/templates/ipam/vrf.html +++ b/netbox/templates/ipam/vrf.html @@ -55,6 +55,7 @@
{% include 'inc/panels/tags.html' %} {% include 'inc/panels/custom_fields.html' %} + {% include 'inc/panels/comments.html' %} {% plugin_right_page object %}
diff --git a/netbox/templates/tenancy/contact.html b/netbox/templates/tenancy/contact.html index 8e71628e9..d92226137 100644 --- a/netbox/templates/tenancy/contact.html +++ b/netbox/templates/tenancy/contact.html @@ -63,6 +63,10 @@ {% endif %} + + Description + {{ object.description|placeholder }} + Assignments {{ assignment_count }} diff --git a/netbox/templates/virtualization/cluster.html b/netbox/templates/virtualization/cluster.html index bc02424cc..510c5a48e 100644 --- a/netbox/templates/virtualization/cluster.html +++ b/netbox/templates/virtualization/cluster.html @@ -23,6 +23,10 @@ Status {% badge object.get_status_display bg_color=object.get_status_color %} + + Description + {{ object.description|placeholder }} + Group {{ object.group|linkify|placeholder }} diff --git a/netbox/templates/virtualization/virtualmachine.html b/netbox/templates/virtualization/virtualmachine.html index c0e2ebd07..9d95b02ea 100644 --- a/netbox/templates/virtualization/virtualmachine.html +++ b/netbox/templates/virtualization/virtualmachine.html @@ -29,6 +29,10 @@ Platform {{ object.platform|linkify|placeholder }} + + Description + {{ object.description|placeholder }} + Tenant diff --git a/netbox/templates/wireless/wirelesslan.html b/netbox/templates/wireless/wirelesslan.html index 9250ef7ef..19e8b930d 100644 --- a/netbox/templates/wireless/wirelesslan.html +++ b/netbox/templates/wireless/wirelesslan.html @@ -39,6 +39,7 @@
{% include 'inc/panels/tags.html' %} + {% include 'inc/panels/comments.html' %} {% plugin_left_page object %}
diff --git a/netbox/templates/wireless/wirelesslink.html b/netbox/templates/wireless/wirelesslink.html index d1a93e40d..be98979c1 100644 --- a/netbox/templates/wireless/wirelesslink.html +++ b/netbox/templates/wireless/wirelesslink.html @@ -40,6 +40,7 @@
{% include 'inc/panels/tags.html' %} + {% include 'inc/panels/comments.html' %} {% plugin_left_page object %}
diff --git a/netbox/templates/wireless/wirelesslink_edit.html b/netbox/templates/wireless/wirelesslink_edit.html index 034d147de..462ae5148 100644 --- a/netbox/templates/wireless/wirelesslink_edit.html +++ b/netbox/templates/wireless/wirelesslink_edit.html @@ -22,6 +22,12 @@
+
+
+
Comments
+
+ {% render_field form.comments %} +
{% if form.custom_fields %}
diff --git a/netbox/tenancy/api/serializers.py b/netbox/tenancy/api/serializers.py index d2c6801c6..c8ef77117 100644 --- a/netbox/tenancy/api/serializers.py +++ b/netbox/tenancy/api/serializers.py @@ -85,8 +85,8 @@ class ContactSerializer(NetBoxModelSerializer): class Meta: model = Contact fields = [ - 'id', 'url', 'display', 'group', 'name', 'title', 'phone', 'email', 'address', 'link', 'comments', 'tags', - 'custom_fields', 'created', 'last_updated', + 'id', 'url', 'display', 'group', 'name', 'title', 'phone', 'email', 'address', 'link', 'description', + 'comments', 'tags', 'custom_fields', 'created', 'last_updated', ] diff --git a/netbox/tenancy/forms/bulk_edit.py b/netbox/tenancy/forms/bulk_edit.py index 4c1f03757..183a8e851 100644 --- a/netbox/tenancy/forms/bulk_edit.py +++ b/netbox/tenancy/forms/bulk_edit.py @@ -2,7 +2,7 @@ from django import forms from netbox.forms import NetBoxModelBulkEditForm from tenancy.models import * -from utilities.forms import DynamicModelChoiceField +from utilities.forms import CommentField, DynamicModelChoiceField, SmallTextarea __all__ = ( 'ContactBulkEditForm', @@ -101,9 +101,17 @@ class ContactBulkEditForm(NetBoxModelBulkEditForm): link = forms.URLField( required=False ) + description = forms.CharField( + max_length=200, + required=False + ) + comments = CommentField( + widget=SmallTextarea, + label='Comments' + ) model = Contact fieldsets = ( - (None, ('group', 'title', 'phone', 'email', 'address', 'link')), + (None, ('group', 'title', 'phone', 'email', 'address', 'link', 'description')), ) - nullable_fields = ('group', 'title', 'phone', 'email', 'address', 'link', 'comments') + nullable_fields = ('group', 'title', 'phone', 'email', 'address', 'link', 'description', 'comments') diff --git a/netbox/tenancy/forms/bulk_import.py b/netbox/tenancy/forms/bulk_import.py index d617a27b5..a465230c5 100644 --- a/netbox/tenancy/forms/bulk_import.py +++ b/netbox/tenancy/forms/bulk_import.py @@ -79,4 +79,4 @@ class ContactCSVForm(NetBoxModelCSVForm): class Meta: model = Contact - fields = ('name', 'title', 'phone', 'email', 'address', 'link', 'group', 'comments') + fields = ('name', 'title', 'phone', 'email', 'address', 'link', 'group', 'description', 'comments') diff --git a/netbox/tenancy/forms/model_forms.py b/netbox/tenancy/forms/model_forms.py index 80af04928..b466c94b2 100644 --- a/netbox/tenancy/forms/model_forms.py +++ b/netbox/tenancy/forms/model_forms.py @@ -103,13 +103,13 @@ class ContactForm(NetBoxModelForm): comments = CommentField() fieldsets = ( - ('Contact', ('group', 'name', 'title', 'phone', 'email', 'address', 'link', 'tags')), + ('Contact', ('group', 'name', 'title', 'phone', 'email', 'address', 'link', 'description', 'tags')), ) class Meta: model = Contact fields = ( - 'group', 'name', 'title', 'phone', 'email', 'address', 'link', 'comments', 'tags', + 'group', 'name', 'title', 'phone', 'email', 'address', 'link', 'description', 'comments', 'tags', ) widgets = { 'address': SmallTextarea(attrs={'rows': 3}), diff --git a/netbox/tenancy/migrations/0009_standardize_description_comments.py b/netbox/tenancy/migrations/0009_standardize_description_comments.py new file mode 100644 index 000000000..af93b055c --- /dev/null +++ b/netbox/tenancy/migrations/0009_standardize_description_comments.py @@ -0,0 +1,18 @@ +# Generated by Django 4.1.2 on 2022-11-03 18:24 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('tenancy', '0008_unique_constraints'), + ] + + operations = [ + migrations.AddField( + model_name='contact', + name='description', + field=models.CharField(blank=True, max_length=200), + ), + ] diff --git a/netbox/tenancy/models/contacts.py b/netbox/tenancy/models/contacts.py index ba937c167..4fa8d87cb 100644 --- a/netbox/tenancy/models/contacts.py +++ b/netbox/tenancy/models/contacts.py @@ -2,9 +2,8 @@ from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.models import ContentType from django.db import models from django.urls import reverse -from mptt.models import TreeForeignKey -from netbox.models import ChangeLoggedModel, NestedGroupModel, OrganizationalModel, NetBoxModel +from netbox.models import ChangeLoggedModel, NestedGroupModel, OrganizationalModel, PrimaryModel from netbox.models.features import WebhooksMixin from tenancy.choices import * @@ -41,7 +40,7 @@ class ContactRole(OrganizationalModel): return reverse('tenancy:contactrole', args=[self.pk]) -class Contact(NetBoxModel): +class Contact(PrimaryModel): """ Contact information for a particular object(s) in NetBox. """ @@ -73,9 +72,6 @@ class Contact(NetBoxModel): link = models.URLField( blank=True ) - comments = models.TextField( - blank=True - ) clone_fields = ( 'group', 'name', 'title', 'phone', 'email', 'address', 'link', diff --git a/netbox/tenancy/models/tenants.py b/netbox/tenancy/models/tenants.py index b76efcbf9..4c0c11e2a 100644 --- a/netbox/tenancy/models/tenants.py +++ b/netbox/tenancy/models/tenants.py @@ -1,9 +1,8 @@ from django.contrib.contenttypes.fields import GenericRelation from django.db import models from django.urls import reverse -from mptt.models import TreeForeignKey -from netbox.models import NestedGroupModel, NetBoxModel +from netbox.models import NestedGroupModel, PrimaryModel __all__ = ( 'Tenant', @@ -31,7 +30,7 @@ class TenantGroup(NestedGroupModel): return reverse('tenancy:tenantgroup', args=[self.pk]) -class Tenant(NetBoxModel): +class Tenant(PrimaryModel): """ A Tenant represents an organization served by the NetBox owner. This is typically a customer or an internal department. @@ -51,13 +50,6 @@ class Tenant(NetBoxModel): blank=True, null=True ) - description = models.CharField( - max_length=200, - blank=True - ) - comments = models.TextField( - blank=True - ) # Generic relations contacts = GenericRelation( diff --git a/netbox/tenancy/tables/contacts.py b/netbox/tenancy/tables/contacts.py index 234dc2ad7..b66a1182f 100644 --- a/netbox/tenancy/tables/contacts.py +++ b/netbox/tenancy/tables/contacts.py @@ -65,8 +65,8 @@ class ContactTable(NetBoxTable): class Meta(NetBoxTable.Meta): model = Contact fields = ( - 'pk', 'name', 'group', 'title', 'phone', 'email', 'address', 'link', 'comments', 'assignment_count', 'tags', - 'created', 'last_updated', + 'pk', 'name', 'group', 'title', 'phone', 'email', 'address', 'link', 'description', 'comments', + 'assignment_count', 'tags', 'created', 'last_updated', ) default_columns = ('pk', 'name', 'group', 'assignment_count', 'title', 'phone', 'email') diff --git a/netbox/virtualization/api/serializers.py b/netbox/virtualization/api/serializers.py index b88bc7712..bb4418b43 100644 --- a/netbox/virtualization/api/serializers.py +++ b/netbox/virtualization/api/serializers.py @@ -58,8 +58,8 @@ class ClusterSerializer(NetBoxModelSerializer): class Meta: model = Cluster fields = [ - 'id', 'url', 'display', 'name', 'type', 'group', 'status', 'tenant', 'site', 'comments', 'tags', - 'custom_fields', 'created', 'last_updated', 'device_count', 'virtualmachine_count', + 'id', 'url', 'display', 'name', 'type', 'group', 'status', 'tenant', 'site', 'description', 'comments', + 'tags', 'custom_fields', 'created', 'last_updated', 'device_count', 'virtualmachine_count', ] @@ -84,8 +84,8 @@ class VirtualMachineSerializer(NetBoxModelSerializer): model = VirtualMachine fields = [ 'id', 'url', 'display', 'name', 'status', 'site', 'cluster', 'device', 'role', 'tenant', 'platform', - 'primary_ip', 'primary_ip4', 'primary_ip6', 'vcpus', 'memory', 'disk', 'comments', 'local_context_data', - 'tags', 'custom_fields', 'created', 'last_updated', + 'primary_ip', 'primary_ip4', 'primary_ip6', 'vcpus', 'memory', 'disk', 'description', 'comments', + 'local_context_data', 'tags', 'custom_fields', 'created', 'last_updated', ] validators = [] diff --git a/netbox/virtualization/forms/bulk_edit.py b/netbox/virtualization/forms/bulk_edit.py index b2429744b..a94b2da1c 100644 --- a/netbox/virtualization/forms/bulk_edit.py +++ b/netbox/virtualization/forms/bulk_edit.py @@ -84,6 +84,10 @@ class ClusterBulkEditForm(NetBoxModelBulkEditForm): 'group_id': '$site_group', } ) + description = forms.CharField( + max_length=200, + required=False + ) comments = CommentField( widget=SmallTextarea, label='Comments' @@ -91,11 +95,11 @@ class ClusterBulkEditForm(NetBoxModelBulkEditForm): model = Cluster fieldsets = ( - (None, ('type', 'group', 'status', 'tenant',)), - ('Site', ('region', 'site_group', 'site',)), + (None, ('type', 'group', 'status', 'tenant', 'description')), + ('Site', ('region', 'site_group', 'site')), ) nullable_fields = ( - 'group', 'site', 'comments', 'tenant', + 'group', 'site', 'tenant', 'description', 'comments', ) @@ -153,6 +157,10 @@ class VirtualMachineBulkEditForm(NetBoxModelBulkEditForm): required=False, label='Disk (GB)' ) + description = forms.CharField( + max_length=200, + required=False + ) comments = CommentField( widget=SmallTextarea, label='Comments' @@ -160,11 +168,11 @@ class VirtualMachineBulkEditForm(NetBoxModelBulkEditForm): model = VirtualMachine fieldsets = ( - (None, ('site', 'cluster', 'device', 'status', 'role', 'tenant', 'platform')), + (None, ('site', 'cluster', 'device', 'status', 'role', 'tenant', 'platform', 'description')), ('Resources', ('vcpus', 'memory', 'disk')) ) nullable_fields = ( - 'site', 'cluster', 'device', 'role', 'tenant', 'platform', 'vcpus', 'memory', 'disk', 'comments', + 'site', 'cluster', 'device', 'role', 'tenant', 'platform', 'vcpus', 'memory', 'disk', 'description', 'comments', ) diff --git a/netbox/virtualization/forms/bulk_import.py b/netbox/virtualization/forms/bulk_import.py index 2d7ee52e2..d140197dd 100644 --- a/netbox/virtualization/forms/bulk_import.py +++ b/netbox/virtualization/forms/bulk_import.py @@ -63,7 +63,7 @@ class ClusterCSVForm(NetBoxModelCSVForm): class Meta: model = Cluster - fields = ('name', 'type', 'group', 'status', 'site', 'comments') + fields = ('name', 'type', 'group', 'status', 'site', 'description', 'comments') class VirtualMachineCSVForm(NetBoxModelCSVForm): @@ -114,7 +114,7 @@ class VirtualMachineCSVForm(NetBoxModelCSVForm): model = VirtualMachine fields = ( 'name', 'status', 'role', 'site', 'cluster', 'device', 'tenant', 'platform', 'vcpus', 'memory', 'disk', - 'comments', + 'description', 'comments', ) diff --git a/netbox/virtualization/forms/model_forms.py b/netbox/virtualization/forms/model_forms.py index 5438002b4..3f598d061 100644 --- a/netbox/virtualization/forms/model_forms.py +++ b/netbox/virtualization/forms/model_forms.py @@ -90,7 +90,7 @@ class ClusterForm(TenancyForm, NetBoxModelForm): comments = CommentField() fieldsets = ( - ('Cluster', ('name', 'type', 'group', 'status', 'tags')), + ('Cluster', ('name', 'type', 'group', 'status', 'description', 'tags')), ('Site', ('region', 'site_group', 'site')), ('Tenancy', ('tenant_group', 'tenant')), ) @@ -98,7 +98,8 @@ class ClusterForm(TenancyForm, NetBoxModelForm): class Meta: model = Cluster fields = ( - 'name', 'type', 'group', 'status', 'tenant', 'region', 'site_group', 'site', 'comments', 'tags', + 'name', 'type', 'group', 'status', 'tenant', 'region', 'site_group', 'site', 'description', 'comments', + 'tags', ) widgets = { 'status': StaticSelect(), @@ -220,9 +221,10 @@ class VirtualMachineForm(TenancyForm, NetBoxModelForm): required=False, label='' ) + comments = CommentField() fieldsets = ( - ('Virtual Machine', ('name', 'role', 'status', 'tags')), + ('Virtual Machine', ('name', 'role', 'status', 'description', 'tags')), ('Site/Cluster', ('site', 'cluster_group', 'cluster', 'device')), ('Tenancy', ('tenant_group', 'tenant')), ('Management', ('platform', 'primary_ip4', 'primary_ip6')), @@ -234,7 +236,7 @@ class VirtualMachineForm(TenancyForm, NetBoxModelForm): model = VirtualMachine fields = [ 'name', 'status', 'site', 'cluster_group', 'cluster', 'device', 'role', 'tenant_group', 'tenant', - 'platform', 'primary_ip4', 'primary_ip6', 'vcpus', 'memory', 'disk', 'comments', 'tags', + 'platform', 'primary_ip4', 'primary_ip6', 'vcpus', 'memory', 'disk', 'description', 'comments', 'tags', 'local_context_data', ] help_texts = { diff --git a/netbox/virtualization/migrations/0034_standardize_description_comments.py b/netbox/virtualization/migrations/0034_standardize_description_comments.py new file mode 100644 index 000000000..8517adeca --- /dev/null +++ b/netbox/virtualization/migrations/0034_standardize_description_comments.py @@ -0,0 +1,23 @@ +# Generated by Django 4.1.2 on 2022-11-03 18:24 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('virtualization', '0033_unique_constraints'), + ] + + operations = [ + migrations.AddField( + model_name='cluster', + name='description', + field=models.CharField(blank=True, max_length=200), + ), + migrations.AddField( + model_name='virtualmachine', + name='description', + field=models.CharField(blank=True, max_length=200), + ), + ] diff --git a/netbox/virtualization/models.py b/netbox/virtualization/models.py index b859d25fe..b5129d581 100644 --- a/netbox/virtualization/models.py +++ b/netbox/virtualization/models.py @@ -10,7 +10,7 @@ from dcim.models import BaseInterface, Device from extras.models import ConfigContextModel from extras.querysets import ConfigContextModelQuerySet from netbox.config import get_config -from netbox.models import OrganizationalModel, NetBoxModel +from netbox.models import NetBoxModel, OrganizationalModel, PrimaryModel from utilities.fields import NaturalOrderingField from utilities.ordering import naturalize_interface from utilities.query_functions import CollateAsChar @@ -64,7 +64,7 @@ class ClusterGroup(OrganizationalModel): # Clusters # -class Cluster(NetBoxModel): +class Cluster(PrimaryModel): """ A cluster of VirtualMachines. Each Cluster may optionally be associated with one or more Devices. """ @@ -102,9 +102,6 @@ class Cluster(NetBoxModel): blank=True, null=True ) - comments = models.TextField( - blank=True - ) # Generic relations vlan_groups = GenericRelation( @@ -165,7 +162,7 @@ class Cluster(NetBoxModel): # Virtual machines # -class VirtualMachine(NetBoxModel, ConfigContextModel): +class VirtualMachine(PrimaryModel, ConfigContextModel): """ A virtual machine which runs inside a Cluster. """ @@ -262,9 +259,6 @@ class VirtualMachine(NetBoxModel, ConfigContextModel): null=True, verbose_name='Disk (GB)' ) - comments = models.TextField( - blank=True - ) # Generic relation contacts = GenericRelation( diff --git a/netbox/virtualization/tables/clusters.py b/netbox/virtualization/tables/clusters.py index ae4c610d7..a3e67373d 100644 --- a/netbox/virtualization/tables/clusters.py +++ b/netbox/virtualization/tables/clusters.py @@ -86,7 +86,7 @@ class ClusterTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable): class Meta(NetBoxTable.Meta): model = Cluster fields = ( - 'pk', 'id', 'name', 'type', 'group', 'status', 'tenant', 'tenant_group', 'site', 'comments', 'device_count', - 'vm_count', 'contacts', 'tags', 'created', 'last_updated', + 'pk', 'id', 'name', 'type', 'group', 'status', 'tenant', 'tenant_group', 'site', 'description', 'comments', + 'device_count', 'vm_count', 'contacts', 'tags', 'created', 'last_updated', ) default_columns = ('pk', 'name', 'type', 'group', 'status', 'tenant', 'site', 'device_count', 'vm_count') diff --git a/netbox/virtualization/tables/virtualmachines.py b/netbox/virtualization/tables/virtualmachines.py index 29baff4cb..b1d44ad02 100644 --- a/netbox/virtualization/tables/virtualmachines.py +++ b/netbox/virtualization/tables/virtualmachines.py @@ -75,8 +75,8 @@ class VirtualMachineTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable) model = VirtualMachine fields = ( 'pk', 'id', 'name', 'status', 'site', 'cluster', 'device', 'role', 'tenant', 'tenant_group', 'platform', - 'vcpus', 'memory', 'disk', 'primary_ip4', 'primary_ip6', 'primary_ip', 'comments', 'contacts', 'tags', - 'created', 'last_updated', + 'vcpus', 'memory', 'disk', 'primary_ip4', 'primary_ip6', 'primary_ip', 'description', 'comments', + 'contacts', 'tags', 'created', 'last_updated', ) default_columns = ( 'pk', 'name', 'status', 'site', 'cluster', 'role', 'tenant', 'vcpus', 'memory', 'disk', 'primary_ip', diff --git a/netbox/wireless/api/serializers.py b/netbox/wireless/api/serializers.py index d65511765..109c3a341 100644 --- a/netbox/wireless/api/serializers.py +++ b/netbox/wireless/api/serializers.py @@ -42,7 +42,7 @@ class WirelessLANSerializer(NetBoxModelSerializer): model = WirelessLAN fields = [ 'id', 'url', 'display', 'ssid', 'description', 'group', 'vlan', 'tenant', 'auth_type', 'auth_cipher', - 'auth_psk', 'description', 'tags', 'custom_fields', 'created', 'last_updated', + 'auth_psk', 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', ] @@ -59,5 +59,5 @@ class WirelessLinkSerializer(NetBoxModelSerializer): model = WirelessLink fields = [ 'id', 'url', 'display', 'interface_a', 'interface_b', 'ssid', 'status', 'tenant', 'auth_type', - 'auth_cipher', 'auth_psk', 'description', 'tags', 'custom_fields', 'created', 'last_updated', + 'auth_cipher', 'auth_psk', 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', ] diff --git a/netbox/wireless/forms/bulk_edit.py b/netbox/wireless/forms/bulk_edit.py index 639a1ed1b..543e7e0b3 100644 --- a/netbox/wireless/forms/bulk_edit.py +++ b/netbox/wireless/forms/bulk_edit.py @@ -4,7 +4,7 @@ from dcim.choices import LinkStatusChoices from ipam.models import VLAN from netbox.forms import NetBoxModelBulkEditForm from tenancy.models import Tenant -from utilities.forms import add_blank_choice, DynamicModelChoiceField +from utilities.forms import add_blank_choice, CommentField, DynamicModelChoiceField, SmallTextarea from wireless.choices import * from wireless.constants import SSID_MAX_LENGTH from wireless.models import * @@ -52,9 +52,6 @@ class WirelessLANBulkEditForm(NetBoxModelBulkEditForm): queryset=Tenant.objects.all(), required=False ) - description = forms.CharField( - required=False - ) auth_type = forms.ChoiceField( choices=add_blank_choice(WirelessAuthTypeChoices), required=False @@ -67,6 +64,14 @@ class WirelessLANBulkEditForm(NetBoxModelBulkEditForm): required=False, label='Pre-shared key' ) + description = forms.CharField( + max_length=200, + required=False + ) + comments = CommentField( + widget=SmallTextarea, + label='Comments' + ) model = WirelessLAN fieldsets = ( @@ -74,7 +79,7 @@ class WirelessLANBulkEditForm(NetBoxModelBulkEditForm): ('Authentication', ('auth_type', 'auth_cipher', 'auth_psk')), ) nullable_fields = ( - 'ssid', 'group', 'vlan', 'tenant', 'description', 'auth_type', 'auth_cipher', 'auth_psk', + 'ssid', 'group', 'vlan', 'tenant', 'description', 'auth_type', 'auth_cipher', 'auth_psk', 'comments', ) @@ -92,9 +97,6 @@ class WirelessLinkBulkEditForm(NetBoxModelBulkEditForm): queryset=Tenant.objects.all(), required=False ) - description = forms.CharField( - required=False - ) auth_type = forms.ChoiceField( choices=add_blank_choice(WirelessAuthTypeChoices), required=False @@ -107,6 +109,14 @@ class WirelessLinkBulkEditForm(NetBoxModelBulkEditForm): required=False, label='Pre-shared key' ) + description = forms.CharField( + max_length=200, + required=False + ) + comments = CommentField( + widget=SmallTextarea, + label='Comments' + ) model = WirelessLink fieldsets = ( @@ -114,5 +124,5 @@ class WirelessLinkBulkEditForm(NetBoxModelBulkEditForm): ('Authentication', ('auth_type', 'auth_cipher', 'auth_psk')) ) nullable_fields = ( - 'ssid', 'tenant', 'description', 'auth_type', 'auth_cipher', 'auth_psk', + 'ssid', 'tenant', 'description', 'auth_type', 'auth_cipher', 'auth_psk', 'comments', ) diff --git a/netbox/wireless/forms/bulk_import.py b/netbox/wireless/forms/bulk_import.py index 6a1ca4f36..03ac997a3 100644 --- a/netbox/wireless/forms/bulk_import.py +++ b/netbox/wireless/forms/bulk_import.py @@ -60,7 +60,9 @@ class WirelessLANCSVForm(NetBoxModelCSVForm): class Meta: model = WirelessLAN - fields = ('ssid', 'group', 'vlan', 'tenant', 'description', 'auth_type', 'auth_cipher', 'auth_psk') + fields = ( + 'ssid', 'group', 'vlan', 'tenant', 'auth_type', 'auth_cipher', 'auth_psk', 'description', 'comments', + ) class WirelessLinkCSVForm(NetBoxModelCSVForm): @@ -94,5 +96,6 @@ class WirelessLinkCSVForm(NetBoxModelCSVForm): class Meta: model = WirelessLink fields = ( - 'interface_a', 'interface_b', 'ssid', 'tenant', 'description', 'auth_type', 'auth_cipher', 'auth_psk', + 'interface_a', 'interface_b', 'ssid', 'tenant', 'auth_type', 'auth_cipher', 'auth_psk', 'description', + 'comments', ) diff --git a/netbox/wireless/forms/model_forms.py b/netbox/wireless/forms/model_forms.py index 386484193..d57c74575 100644 --- a/netbox/wireless/forms/model_forms.py +++ b/netbox/wireless/forms/model_forms.py @@ -2,7 +2,7 @@ from dcim.models import Device, Interface, Location, Region, Site, SiteGroup from ipam.models import VLAN, VLANGroup from netbox.forms import NetBoxModelForm from tenancy.forms import TenancyForm -from utilities.forms import DynamicModelChoiceField, SlugField, StaticSelect +from utilities.forms import CommentField, DynamicModelChoiceField, SlugField, StaticSelect from wireless.models import * __all__ = ( @@ -82,6 +82,7 @@ class WirelessLANForm(TenancyForm, NetBoxModelForm): 'group_id': '$vlan_group', } ) + comments = CommentField() fieldsets = ( ('Wireless LAN', ('ssid', 'group', 'description', 'tags')), @@ -93,8 +94,8 @@ class WirelessLANForm(TenancyForm, NetBoxModelForm): class Meta: model = WirelessLAN fields = [ - 'ssid', 'group', 'description', 'region', 'site_group', 'site', 'vlan_group', 'vlan', 'tenant_group', - 'tenant', 'auth_type', 'auth_cipher', 'auth_psk', 'tags', + 'ssid', 'group', 'region', 'site_group', 'site', 'vlan_group', 'vlan', 'tenant_group', 'tenant', + 'auth_type', 'auth_cipher', 'auth_psk', 'description', 'comments', 'tags', ] widgets = { 'auth_type': StaticSelect, @@ -183,6 +184,7 @@ class WirelessLinkForm(TenancyForm, NetBoxModelForm): disabled_indicator='_occupied', label='Interface' ) + comments = CommentField() fieldsets = ( ('Side A', ('site_a', 'location_a', 'device_a', 'interface_a')), @@ -196,7 +198,8 @@ class WirelessLinkForm(TenancyForm, NetBoxModelForm): model = WirelessLink fields = [ 'site_a', 'location_a', 'device_a', 'interface_a', 'site_b', 'location_b', 'device_b', 'interface_b', - 'status', 'ssid', 'tenant_group', 'tenant', 'description', 'auth_type', 'auth_cipher', 'auth_psk', 'tags', + 'status', 'ssid', 'tenant_group', 'tenant', 'auth_type', 'auth_cipher', 'auth_psk', 'description', + 'comments', 'tags', ] widgets = { 'status': StaticSelect, diff --git a/netbox/wireless/migrations/0007_standardize_description_comments.py b/netbox/wireless/migrations/0007_standardize_description_comments.py new file mode 100644 index 000000000..e6e1ce8dd --- /dev/null +++ b/netbox/wireless/migrations/0007_standardize_description_comments.py @@ -0,0 +1,23 @@ +# Generated by Django 4.1.2 on 2022-11-03 18:24 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('wireless', '0006_unique_constraints'), + ] + + operations = [ + migrations.AddField( + model_name='wirelesslan', + name='comments', + field=models.TextField(blank=True), + ), + migrations.AddField( + model_name='wirelesslink', + name='comments', + field=models.TextField(blank=True), + ), + ] diff --git a/netbox/wireless/models.py b/netbox/wireless/models.py index ee2744e40..96764b53c 100644 --- a/netbox/wireless/models.py +++ b/netbox/wireless/models.py @@ -2,11 +2,11 @@ from django.apps import apps from django.core.exceptions import ValidationError from django.db import models from django.urls import reverse -from mptt.models import MPTTModel, TreeForeignKey +from mptt.models import MPTTModel from dcim.choices import LinkStatusChoices from dcim.constants import WIRELESS_IFACE_TYPES -from netbox.models import NestedGroupModel, NetBoxModel +from netbox.models import NestedGroupModel, PrimaryModel from .choices import * from .constants import * @@ -69,7 +69,7 @@ class WirelessLANGroup(NestedGroupModel): return reverse('wireless:wirelesslangroup', args=[self.pk]) -class WirelessLAN(WirelessAuthenticationBase, NetBoxModel): +class WirelessLAN(WirelessAuthenticationBase, PrimaryModel): """ A wireless network formed among an arbitrary number of access point and clients. """ @@ -98,10 +98,6 @@ class WirelessLAN(WirelessAuthenticationBase, NetBoxModel): blank=True, null=True ) - description = models.CharField( - max_length=200, - blank=True - ) clone_fields = ('ssid', 'group', 'tenant', 'description') @@ -122,7 +118,7 @@ def get_wireless_interface_types(): return {'type__in': WIRELESS_IFACE_TYPES} -class WirelessLink(WirelessAuthenticationBase, NetBoxModel): +class WirelessLink(WirelessAuthenticationBase, PrimaryModel): """ A point-to-point connection between two wireless Interfaces. """ @@ -157,10 +153,6 @@ class WirelessLink(WirelessAuthenticationBase, NetBoxModel): blank=True, null=True ) - description = models.CharField( - max_length=200, - blank=True - ) # Cache the associated device for the A and B interfaces. This enables filtering of WirelessLinks by their # associated Devices. diff --git a/netbox/wireless/tables/wirelesslan.py b/netbox/wireless/tables/wirelesslan.py index af0cdae88..4aa5cc1fd 100644 --- a/netbox/wireless/tables/wirelesslan.py +++ b/netbox/wireless/tables/wirelesslan.py @@ -21,6 +21,7 @@ class WirelessLANGroupTable(NetBoxTable): url_params={'group_id': 'pk'}, verbose_name='Wireless LANs' ) + comments = columns.MarkdownColumn() tags = columns.TagColumn( url_name='wireless:wirelesslangroup_list' ) @@ -28,7 +29,8 @@ class WirelessLANGroupTable(NetBoxTable): class Meta(NetBoxTable.Meta): model = WirelessLANGroup fields = ( - 'pk', 'name', 'wirelesslan_count', 'description', 'slug', 'tags', 'created', 'last_updated', 'actions', + 'pk', 'name', 'wirelesslan_count', 'slug', 'description', 'comments', 'tags', 'created', 'last_updated', + 'actions', ) default_columns = ('pk', 'name', 'wirelesslan_count', 'description') @@ -43,6 +45,7 @@ class WirelessLANTable(TenancyColumnsMixin, NetBoxTable): interface_count = tables.Column( verbose_name='Interfaces' ) + comments = columns.MarkdownColumn() tags = columns.TagColumn( url_name='wireless:wirelesslan_list' ) @@ -50,8 +53,8 @@ class WirelessLANTable(TenancyColumnsMixin, NetBoxTable): class Meta(NetBoxTable.Meta): model = WirelessLAN fields = ( - 'pk', 'ssid', 'group', 'tenant', 'tenant_group', 'description', 'vlan', 'interface_count', 'auth_type', - 'auth_cipher', 'auth_psk', 'tags', 'created', 'last_updated', + 'pk', 'ssid', 'group', 'tenant', 'tenant_group', 'vlan', 'interface_count', 'auth_type', 'auth_cipher', + 'auth_psk', 'description', 'comments', 'tags', 'created', 'last_updated', ) default_columns = ('pk', 'ssid', 'group', 'description', 'vlan', 'auth_type', 'interface_count') From 341243c2ec3eb6785f3e1930161b1f40463432f3 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 4 Nov 2022 08:55:32 -0400 Subject: [PATCH 14/14] Accommodate recent changes in feature branch --- ...evicecontexts.py => 0166_virtualdevicecontexts.py} | 11 ++++++----- netbox/dcim/models/devices.py | 2 +- 2 files changed, 7 insertions(+), 6 deletions(-) rename netbox/dcim/migrations/{0165_virtualdevicecontexts.py => 0166_virtualdevicecontexts.py} (88%) diff --git a/netbox/dcim/migrations/0165_virtualdevicecontexts.py b/netbox/dcim/migrations/0166_virtualdevicecontexts.py similarity index 88% rename from netbox/dcim/migrations/0165_virtualdevicecontexts.py rename to netbox/dcim/migrations/0166_virtualdevicecontexts.py index f72061005..1db331bb3 100644 --- a/netbox/dcim/migrations/0165_virtualdevicecontexts.py +++ b/netbox/dcim/migrations/0166_virtualdevicecontexts.py @@ -1,4 +1,4 @@ -# Generated by Django 4.1.2 on 2022-11-02 13:24 +# Generated by Django 4.1.2 on 2022-11-04 12:46 from django.db import migrations, models import django.db.models.deletion @@ -9,10 +9,10 @@ import utilities.json class Migration(migrations.Migration): dependencies = [ - ('ipam', '0062_unique_constraints'), - ('extras', '0082_exporttemplate_content_types'), - ('tenancy', '0008_unique_constraints'), - ('dcim', '0164_rack_mounting_depth'), + ('extras', '0083_savedfilter'), + ('ipam', '0063_standardize_description_comments'), + ('tenancy', '0009_standardize_description_comments'), + ('dcim', '0165_standardize_description_comments'), ] operations = [ @@ -23,6 +23,7 @@ class Migration(migrations.Migration): ('created', models.DateTimeField(auto_now_add=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)), ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=utilities.json.CustomFieldJSONEncoder)), + ('description', models.CharField(blank=True, max_length=200)), ('name', models.CharField(max_length=64)), ('status', models.CharField(blank=True, max_length=50)), ('identifier', models.PositiveSmallIntegerField(blank=True, null=True)), diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py index 4cc8cff71..5e34cf25b 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -1065,7 +1065,7 @@ class VirtualChassis(PrimaryModel): return super().delete(*args, **kwargs) -class VirtualDeviceContext(NetBoxModel): +class VirtualDeviceContext(PrimaryModel): device = models.ForeignKey( to='Device', on_delete=models.PROTECT,