From a504f5f309dfae02ce567c233fb3daf28b457b4d Mon Sep 17 00:00:00 2001 From: John Anderson Date: Tue, 10 Mar 2020 19:15:24 -0400 Subject: [PATCH 01/19] closes #4340 - Enforce unique constraints for device and virtual machine names in the API --- docs/release-notes/version-2.7.md | 6 ++++++ netbox/dcim/tests/test_api.py | 14 ++++++++++++++ netbox/utilities/api.py | 1 + netbox/virtualization/tests/test_api.py | 12 ++++++++++++ 4 files changed, 33 insertions(+) diff --git a/docs/release-notes/version-2.7.md b/docs/release-notes/version-2.7.md index 69be137d7..4ebae5f76 100644 --- a/docs/release-notes/version-2.7.md +++ b/docs/release-notes/version-2.7.md @@ -1,5 +1,11 @@ # NetBox v2.7 Release Notes +## v2.7.11 (FUTURE) + +### Bug Fixes + +* [#4340](https://github.com/netbox-community/netbox/issues/4340) - Enforce unique constraints for device and virtual machine names in the API + ## v2.7.10 (2020-03-10) **Note:** If your deployment requires any non-core Python packages (such as `napalm`, `django-storages`, or `django-auth-ldap`), list them in a file named `local_requirements.txt` in the NetBox root directory (alongside `requirements.txt`). This will ensure they are detected and re-installed by the upgrade script when the Python virtual environment is rebuilt. diff --git a/netbox/dcim/tests/test_api.py b/netbox/dcim/tests/test_api.py index ad893bec6..09de27d92 100644 --- a/netbox/dcim/tests/test_api.py +++ b/netbox/dcim/tests/test_api.py @@ -2089,6 +2089,20 @@ class DeviceTest(APITestCase): self.assertFalse('config_context' in response.data['results'][0]) + def test_unique_name_per_site_constraint(self): + + data = { + 'device_type': self.devicetype1.pk, + 'device_role': self.devicerole1.pk, + 'name': 'Test Device 1', + 'site': self.site1.pk, + } + + url = reverse('dcim-api:device-list') + response = self.client.post(url, data, format='json', **self.header) + + self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST) + class ConsolePortTest(APITestCase): diff --git a/netbox/utilities/api.py b/netbox/utilities/api.py index 72a5735de..a34d7983a 100644 --- a/netbox/utilities/api.py +++ b/netbox/utilities/api.py @@ -234,6 +234,7 @@ class ValidatedModelSerializer(ModelSerializer): for k, v in attrs.items(): setattr(instance, k, v) instance.clean() + instance.validate_unique() return data diff --git a/netbox/virtualization/tests/test_api.py b/netbox/virtualization/tests/test_api.py index 719954c10..fa425c460 100644 --- a/netbox/virtualization/tests/test_api.py +++ b/netbox/virtualization/tests/test_api.py @@ -501,6 +501,18 @@ class VirtualMachineTest(APITestCase): self.assertFalse('config_context' in response.data['results'][0]) + def test_unique_name_per_cluster_constraint(self): + + data = { + 'name': 'Test Virtual Machine 1', + 'cluster': self.cluster1.pk, + } + + url = reverse('virtualization-api:virtualmachine-list') + response = self.client.post(url, data, format='json', **self.header) + + self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST) + class InterfaceTest(APITestCase): From 79aba5edf2e5a1b7fbe3de0f50e82db72a3150bc Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 11 Mar 2020 09:52:02 -0400 Subject: [PATCH 02/19] Fixes #4343: Fix Markdown support for tables --- docs/release-notes/version-2.7.md | 1 + netbox/utilities/templatetags/helpers.py | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/release-notes/version-2.7.md b/docs/release-notes/version-2.7.md index 4ebae5f76..32fd17f64 100644 --- a/docs/release-notes/version-2.7.md +++ b/docs/release-notes/version-2.7.md @@ -5,6 +5,7 @@ ### Bug Fixes * [#4340](https://github.com/netbox-community/netbox/issues/4340) - Enforce unique constraints for device and virtual machine names in the API +* [#4343](https://github.com/netbox-community/netbox/issues/4343) - Fix Markdown support for tables ## v2.7.10 (2020-03-10) diff --git a/netbox/utilities/templatetags/helpers.py b/netbox/utilities/templatetags/helpers.py index 618641a07..7d05ce749 100644 --- a/netbox/utilities/templatetags/helpers.py +++ b/netbox/utilities/templatetags/helpers.py @@ -40,7 +40,7 @@ def render_markdown(value): value = strip_tags(value) # Render Markdown - html = markdown(value, extensions=['fenced_code']) + html = markdown(value, extensions=['fenced_code', 'tables']) return mark_safe(html) @@ -196,7 +196,7 @@ def get_docs(model): return "Unable to load documentation, error reading file: {}".format(path) # Render Markdown with the admonition extension - content = markdown(content, extensions=['admonition', 'fenced_code']) + content = markdown(content, extensions=['admonition', 'fenced_code', 'tables']) return mark_safe(content) From ea9de37dd1abfef2342ba2764b8e2fe0508136dc Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 12 Mar 2020 10:48:17 -0400 Subject: [PATCH 03/19] Remove FieldChoicesViewSet --- netbox/circuits/api/urls.py | 3 -- netbox/circuits/api/views.py | 13 +-------- netbox/dcim/api/urls.py | 3 -- netbox/dcim/api/views.py | 31 +------------------- netbox/extras/api/urls.py | 3 -- netbox/extras/api/views.py | 14 +-------- netbox/ipam/api/urls.py | 3 -- netbox/ipam/api/views.py | 16 +---------- netbox/secrets/api/urls.py | 3 -- netbox/secrets/api/views.py | 10 +------ netbox/tenancy/api/urls.py | 3 -- netbox/tenancy/api/views.py | 10 +------ netbox/utilities/api.py | 46 ------------------------------ netbox/virtualization/api/urls.py | 3 -- netbox/virtualization/api/views.py | 13 +-------- 15 files changed, 7 insertions(+), 167 deletions(-) diff --git a/netbox/circuits/api/urls.py b/netbox/circuits/api/urls.py index cd3015d0a..01fbfb62c 100644 --- a/netbox/circuits/api/urls.py +++ b/netbox/circuits/api/urls.py @@ -14,9 +14,6 @@ class CircuitsRootView(routers.APIRootView): router = routers.DefaultRouter() router.APIRootView = CircuitsRootView -# Field choices -router.register('_choices', views.CircuitsFieldChoicesViewSet, basename='field-choice') - # Providers router.register('providers', views.ProviderViewSet) diff --git a/netbox/circuits/api/views.py b/netbox/circuits/api/views.py index 75f7e0e3e..363392a4d 100644 --- a/netbox/circuits/api/views.py +++ b/netbox/circuits/api/views.py @@ -8,21 +8,10 @@ from circuits.models import Provider, CircuitTermination, CircuitType, Circuit from extras.api.serializers import RenderedGraphSerializer from extras.api.views import CustomFieldModelViewSet from extras.models import Graph -from utilities.api import FieldChoicesViewSet, ModelViewSet +from utilities.api import ModelViewSet from . import serializers -# -# Field choices -# - -class CircuitsFieldChoicesViewSet(FieldChoicesViewSet): - fields = ( - (serializers.CircuitSerializer, ['status']), - (serializers.CircuitTerminationSerializer, ['term_side']), - ) - - # # Providers # diff --git a/netbox/dcim/api/urls.py b/netbox/dcim/api/urls.py index 5a915becc..f989d817c 100644 --- a/netbox/dcim/api/urls.py +++ b/netbox/dcim/api/urls.py @@ -14,9 +14,6 @@ class DCIMRootView(routers.APIRootView): router = routers.DefaultRouter() router.APIRootView = DCIMRootView -# Field choices -router.register('_choices', views.DCIMFieldChoicesViewSet, basename='field-choice') - # Sites router.register('regions', views.RegionViewSet) router.register('sites', views.SiteViewSet) diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index d044d6198..f61041b58 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -26,7 +26,7 @@ from extras.api.views import CustomFieldModelViewSet from extras.models import Graph from ipam.models import Prefix, VLAN from utilities.api import ( - get_serializer_for_model, IsAuthenticatedOrLoginNotRequired, FieldChoicesViewSet, ModelViewSet, ServiceUnavailable, + get_serializer_for_model, IsAuthenticatedOrLoginNotRequired, ModelViewSet, ServiceUnavailable, ) from utilities.utils import get_subquery from virtualization.models import VirtualMachine @@ -34,35 +34,6 @@ from . import serializers from .exceptions import MissingFilterException -# -# Field choices -# - -class DCIMFieldChoicesViewSet(FieldChoicesViewSet): - fields = ( - (serializers.CableSerializer, ['length_unit', 'status', 'termination_a_type', 'termination_b_type', 'type']), - (serializers.ConsolePortSerializer, ['type', 'connection_status']), - (serializers.ConsolePortTemplateSerializer, ['type']), - (serializers.ConsoleServerPortSerializer, ['type']), - (serializers.ConsoleServerPortTemplateSerializer, ['type']), - (serializers.DeviceSerializer, ['face', 'status']), - (serializers.DeviceTypeSerializer, ['subdevice_role']), - (serializers.FrontPortSerializer, ['type']), - (serializers.FrontPortTemplateSerializer, ['type']), - (serializers.InterfaceSerializer, ['type', 'mode']), - (serializers.InterfaceTemplateSerializer, ['type']), - (serializers.PowerFeedSerializer, ['phase', 'status', 'supply', 'type']), - (serializers.PowerOutletSerializer, ['type', 'feed_leg']), - (serializers.PowerOutletTemplateSerializer, ['type', 'feed_leg']), - (serializers.PowerPortSerializer, ['type', 'connection_status']), - (serializers.PowerPortTemplateSerializer, ['type']), - (serializers.RackSerializer, ['outer_unit', 'status', 'type', 'width']), - (serializers.RearPortSerializer, ['type']), - (serializers.RearPortTemplateSerializer, ['type']), - (serializers.SiteSerializer, ['status']), - ) - - # Mixins class CableTraceMixin(object): diff --git a/netbox/extras/api/urls.py b/netbox/extras/api/urls.py index d699cd22e..8d8463bad 100644 --- a/netbox/extras/api/urls.py +++ b/netbox/extras/api/urls.py @@ -14,9 +14,6 @@ class ExtrasRootView(routers.APIRootView): router = routers.DefaultRouter() router.APIRootView = ExtrasRootView -# Field choices -router.register('_choices', views.ExtrasFieldChoicesViewSet, basename='field-choice') - # Custom field choices router.register('_custom_field_choices', views.CustomFieldChoicesViewSet, basename='custom-field-choice') diff --git a/netbox/extras/api/views.py b/netbox/extras/api/views.py index aa9e380ba..7e547dafd 100644 --- a/netbox/extras/api/views.py +++ b/netbox/extras/api/views.py @@ -15,22 +15,10 @@ from extras.models import ( ) from extras.reports import get_report, get_reports from extras.scripts import get_script, get_scripts, run_script -from utilities.api import FieldChoicesViewSet, IsAuthenticatedOrLoginNotRequired, ModelViewSet +from utilities.api import IsAuthenticatedOrLoginNotRequired, ModelViewSet from . import serializers -# -# Field choices -# - -class ExtrasFieldChoicesViewSet(FieldChoicesViewSet): - fields = ( - (serializers.ExportTemplateSerializer, ['template_language']), - (serializers.GraphSerializer, ['type', 'template_language']), - (serializers.ObjectChangeSerializer, ['action']), - ) - - # # Custom field choices # diff --git a/netbox/ipam/api/urls.py b/netbox/ipam/api/urls.py index c4d68f9c0..ff0ea32a8 100644 --- a/netbox/ipam/api/urls.py +++ b/netbox/ipam/api/urls.py @@ -14,9 +14,6 @@ class IPAMRootView(routers.APIRootView): router = routers.DefaultRouter() router.APIRootView = IPAMRootView -# Field choices -router.register('_choices', views.IPAMFieldChoicesViewSet, basename='field-choice') - # VRFs router.register('vrfs', views.VRFViewSet) diff --git a/netbox/ipam/api/views.py b/netbox/ipam/api/views.py index 262ca7908..4b50ac7de 100644 --- a/netbox/ipam/api/views.py +++ b/netbox/ipam/api/views.py @@ -10,26 +10,12 @@ from rest_framework.response import Response from extras.api.views import CustomFieldModelViewSet from ipam import filters from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF -from utilities.api import FieldChoicesViewSet, ModelViewSet +from utilities.api import ModelViewSet from utilities.constants import ADVISORY_LOCK_KEYS from utilities.utils import get_subquery from . import serializers -# -# Field choices -# - -class IPAMFieldChoicesViewSet(FieldChoicesViewSet): - fields = ( - (serializers.AggregateSerializer, ['family']), - (serializers.PrefixSerializer, ['family', 'status']), - (serializers.IPAddressSerializer, ['family', 'status', 'role']), - (serializers.VLANSerializer, ['status']), - (serializers.ServiceSerializer, ['protocol']), - ) - - # # VRFs # diff --git a/netbox/secrets/api/urls.py b/netbox/secrets/api/urls.py index 70abcfe29..7ae2ae9ac 100644 --- a/netbox/secrets/api/urls.py +++ b/netbox/secrets/api/urls.py @@ -14,9 +14,6 @@ class SecretsRootView(routers.APIRootView): router = routers.DefaultRouter() router.APIRootView = SecretsRootView -# Field choices -router.register('_choices', views.SecretsFieldChoicesViewSet, basename='field-choice') - # Secrets router.register('secret-roles', views.SecretRoleViewSet) router.register('secrets', views.SecretViewSet) diff --git a/netbox/secrets/api/views.py b/netbox/secrets/api/views.py index 367dc9bd0..1795e6c0a 100644 --- a/netbox/secrets/api/views.py +++ b/netbox/secrets/api/views.py @@ -11,7 +11,7 @@ from rest_framework.viewsets import ViewSet from secrets import filters from secrets.exceptions import InvalidKey from secrets.models import Secret, SecretRole, SessionKey, UserKey -from utilities.api import FieldChoicesViewSet, ModelViewSet +from utilities.api import ModelViewSet from . import serializers ERR_USERKEY_MISSING = "No UserKey found for the current user." @@ -20,14 +20,6 @@ ERR_PRIVKEY_MISSING = "Private key was not provided." ERR_PRIVKEY_INVALID = "Invalid private key." -# -# Field choices -# - -class SecretsFieldChoicesViewSet(FieldChoicesViewSet): - fields = () - - # # Secret Roles # diff --git a/netbox/tenancy/api/urls.py b/netbox/tenancy/api/urls.py index 5762f9a0d..645cc2edc 100644 --- a/netbox/tenancy/api/urls.py +++ b/netbox/tenancy/api/urls.py @@ -14,9 +14,6 @@ class TenancyRootView(routers.APIRootView): router = routers.DefaultRouter() router.APIRootView = TenancyRootView -# Field choices -router.register('_choices', views.TenancyFieldChoicesViewSet, basename='field-choice') - # Tenants router.register('tenant-groups', views.TenantGroupViewSet) router.register('tenants', views.TenantViewSet) diff --git a/netbox/tenancy/api/views.py b/netbox/tenancy/api/views.py index ab82c3cf5..148058a33 100644 --- a/netbox/tenancy/api/views.py +++ b/netbox/tenancy/api/views.py @@ -4,20 +4,12 @@ from extras.api.views import CustomFieldModelViewSet from ipam.models import IPAddress, Prefix, VLAN, VRF from tenancy import filters from tenancy.models import Tenant, TenantGroup -from utilities.api import FieldChoicesViewSet, ModelViewSet +from utilities.api import ModelViewSet from utilities.utils import get_subquery from virtualization.models import VirtualMachine from . import serializers -# -# Field choices -# - -class TenancyFieldChoicesViewSet(FieldChoicesViewSet): - fields = () - - # # Tenant Groups # diff --git a/netbox/utilities/api.py b/netbox/utilities/api.py index 43062af69..25501a182 100644 --- a/netbox/utilities/api.py +++ b/netbox/utilities/api.py @@ -371,49 +371,3 @@ class ModelViewSet(_ModelViewSet): logger = logging.getLogger('netbox.api.views.ModelViewSet') logger.info(f"Deleting {instance} (PK: {instance.pk})") return super().perform_destroy(instance) - - -class FieldChoicesViewSet(ViewSet): - """ - Expose the built-in numeric values which represent static choices for a model's field. - """ - permission_classes = [IsAuthenticatedOrLoginNotRequired] - fields = [] - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - # Compile a dict of all fields in this view - self._fields = OrderedDict() - for serializer_class, field_list in self.fields: - for field_name in field_list: - - model_name = serializer_class.Meta.model._meta.verbose_name - key = ':'.join([model_name.lower().replace(' ', '-'), field_name]) - serializer = serializer_class() - choices = [] - - for k, v in serializer.get_fields()[field_name].choices.items(): - if type(v) in [list, tuple]: - for k2, v2 in v: - choices.append({ - 'value': k2, - 'label': v2, - }) - else: - choices.append({ - 'value': k, - 'label': v, - }) - self._fields[key] = choices - - def list(self, request): - return Response(self._fields) - - def retrieve(self, request, pk): - if pk not in self._fields: - raise Http404 - return Response(self._fields[pk]) - - def get_view_name(self): - return "Field Choices" diff --git a/netbox/virtualization/api/urls.py b/netbox/virtualization/api/urls.py index a94e043b2..c237f1e68 100644 --- a/netbox/virtualization/api/urls.py +++ b/netbox/virtualization/api/urls.py @@ -14,9 +14,6 @@ class VirtualizationRootView(routers.APIRootView): router = routers.DefaultRouter() router.APIRootView = VirtualizationRootView -# Field choices -router.register('_choices', views.VirtualizationFieldChoicesViewSet, basename='field-choice') - # Clusters router.register('cluster-types', views.ClusterTypeViewSet) router.register('cluster-groups', views.ClusterGroupViewSet) diff --git a/netbox/virtualization/api/views.py b/netbox/virtualization/api/views.py index 415fc6289..2a1d7c3a9 100644 --- a/netbox/virtualization/api/views.py +++ b/netbox/virtualization/api/views.py @@ -2,24 +2,13 @@ from django.db.models import Count from dcim.models import Device, Interface from extras.api.views import CustomFieldModelViewSet -from utilities.api import FieldChoicesViewSet, ModelViewSet +from utilities.api import ModelViewSet from utilities.utils import get_subquery from virtualization import filters from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine from . import serializers -# -# Field choices -# - -class VirtualizationFieldChoicesViewSet(FieldChoicesViewSet): - fields = ( - (serializers.VirtualMachineSerializer, ['status']), - (serializers.InterfaceSerializer, ['type']), - ) - - # # Clusters # From a53f85418774b53088c72186562ca475cc07db03 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 12 Mar 2020 10:48:53 -0400 Subject: [PATCH 04/19] Remove tests for API _choices endpoints --- netbox/circuits/tests/test_api.py | 15 +---- netbox/dcim/tests/test_api.py | 75 +------------------------ netbox/extras/tests/test_api.py | 25 +-------- netbox/ipam/tests/test_api.py | 27 +-------- netbox/secrets/tests/test_api.py | 7 --- netbox/tenancy/tests/test_api.py | 7 --- netbox/utilities/testing/utils.py | 27 --------- netbox/virtualization/tests/test_api.py | 15 +---- 8 files changed, 5 insertions(+), 193 deletions(-) diff --git a/netbox/circuits/tests/test_api.py b/netbox/circuits/tests/test_api.py index b1b6d9e14..b5f8758e7 100644 --- a/netbox/circuits/tests/test_api.py +++ b/netbox/circuits/tests/test_api.py @@ -6,7 +6,7 @@ from circuits.choices import * from circuits.models import Circuit, CircuitTermination, CircuitType, Provider from dcim.models import Site from extras.models import Graph -from utilities.testing import APITestCase, choices_to_dict +from utilities.testing import APITestCase class AppTest(APITestCase): @@ -18,19 +18,6 @@ class AppTest(APITestCase): self.assertEqual(response.status_code, 200) - def test_choices(self): - - url = reverse('circuits-api:field-choice-list') - response = self.client.get(url, **self.header) - - self.assertEqual(response.status_code, 200) - - # Circuit - self.assertEqual(choices_to_dict(response.data.get('circuit:status')), CircuitStatusChoices.as_dict()) - - # CircuitTermination - self.assertEqual(choices_to_dict(response.data.get('circuit-termination:term_side')), CircuitTerminationSideChoices.as_dict()) - class ProviderTest(APITestCase): diff --git a/netbox/dcim/tests/test_api.py b/netbox/dcim/tests/test_api.py index ddb9c0b52..d57aaa7d5 100644 --- a/netbox/dcim/tests/test_api.py +++ b/netbox/dcim/tests/test_api.py @@ -14,7 +14,7 @@ from dcim.models import ( ) from ipam.models import IPAddress, VLAN from extras.models import Graph -from utilities.testing import APITestCase, choices_to_dict +from utilities.testing import APITestCase from virtualization.models import Cluster, ClusterType @@ -27,79 +27,6 @@ class AppTest(APITestCase): self.assertEqual(response.status_code, 200) - def test_choices(self): - - url = reverse('dcim-api:field-choice-list') - response = self.client.get(url, **self.header) - - self.assertEqual(response.status_code, 200) - - # Cable - self.assertEqual(choices_to_dict(response.data.get('cable:length_unit')), CableLengthUnitChoices.as_dict()) - self.assertEqual(choices_to_dict(response.data.get('cable:status')), CableStatusChoices.as_dict()) - content_types = ContentType.objects.filter(CABLE_TERMINATION_MODELS) - cable_termination_choices = { - "{}.{}".format(ct.app_label, ct.model): str(ct) for ct in content_types - } - self.assertEqual(choices_to_dict(response.data.get('cable:termination_a_type')), cable_termination_choices) - self.assertEqual(choices_to_dict(response.data.get('cable:termination_b_type')), cable_termination_choices) - self.assertEqual(choices_to_dict(response.data.get('cable:type')), CableTypeChoices.as_dict()) - - # Console ports - self.assertEqual(choices_to_dict(response.data.get('console-port:type')), ConsolePortTypeChoices.as_dict()) - self.assertEqual(choices_to_dict(response.data.get('console-port:connection_status')), dict(CONNECTION_STATUS_CHOICES)) - self.assertEqual(choices_to_dict(response.data.get('console-port-template:type')), ConsolePortTypeChoices.as_dict()) - - # Console server ports - self.assertEqual(choices_to_dict(response.data.get('console-server-port:type')), ConsolePortTypeChoices.as_dict()) - self.assertEqual(choices_to_dict(response.data.get('console-server-port-template:type')), ConsolePortTypeChoices.as_dict()) - - # Device - self.assertEqual(choices_to_dict(response.data.get('device:face')), DeviceFaceChoices.as_dict()) - self.assertEqual(choices_to_dict(response.data.get('device:status')), DeviceStatusChoices.as_dict()) - - # Device type - self.assertEqual(choices_to_dict(response.data.get('device-type:subdevice_role')), SubdeviceRoleChoices.as_dict()) - - # Front ports - self.assertEqual(choices_to_dict(response.data.get('front-port:type')), PortTypeChoices.as_dict()) - self.assertEqual(choices_to_dict(response.data.get('front-port-template:type')), PortTypeChoices.as_dict()) - - # Interfaces - self.assertEqual(choices_to_dict(response.data.get('interface:type')), InterfaceTypeChoices.as_dict()) - self.assertEqual(choices_to_dict(response.data.get('interface:mode')), InterfaceModeChoices.as_dict()) - self.assertEqual(choices_to_dict(response.data.get('interface-template:type')), InterfaceTypeChoices.as_dict()) - - # Power feed - self.assertEqual(choices_to_dict(response.data.get('power-feed:phase')), PowerFeedPhaseChoices.as_dict()) - self.assertEqual(choices_to_dict(response.data.get('power-feed:status')), PowerFeedStatusChoices.as_dict()) - self.assertEqual(choices_to_dict(response.data.get('power-feed:supply')), PowerFeedSupplyChoices.as_dict()) - self.assertEqual(choices_to_dict(response.data.get('power-feed:type')), PowerFeedTypeChoices.as_dict()) - - # Power outlets - self.assertEqual(choices_to_dict(response.data.get('power-outlet:type')), PowerOutletTypeChoices.as_dict()) - self.assertEqual(choices_to_dict(response.data.get('power-outlet:feed_leg')), PowerOutletFeedLegChoices.as_dict()) - self.assertEqual(choices_to_dict(response.data.get('power-outlet-template:type')), PowerOutletTypeChoices.as_dict()) - self.assertEqual(choices_to_dict(response.data.get('power-outlet-template:feed_leg')), PowerOutletFeedLegChoices.as_dict()) - - # Power ports - self.assertEqual(choices_to_dict(response.data.get('power-port:type')), PowerPortTypeChoices.as_dict()) - self.assertEqual(choices_to_dict(response.data.get('power-port:connection_status')), dict(CONNECTION_STATUS_CHOICES)) - self.assertEqual(choices_to_dict(response.data.get('power-port-template:type')), PowerPortTypeChoices.as_dict()) - - # Rack - self.assertEqual(choices_to_dict(response.data.get('rack:type')), RackTypeChoices.as_dict()) - self.assertEqual(choices_to_dict(response.data.get('rack:width')), RackWidthChoices.as_dict()) - self.assertEqual(choices_to_dict(response.data.get('rack:status')), RackStatusChoices.as_dict()) - self.assertEqual(choices_to_dict(response.data.get('rack:outer_unit')), RackDimensionUnitChoices.as_dict()) - - # Rear ports - self.assertEqual(choices_to_dict(response.data.get('rear-port:type')), PortTypeChoices.as_dict()) - self.assertEqual(choices_to_dict(response.data.get('rear-port-template:type')), PortTypeChoices.as_dict()) - - # Site - self.assertEqual(choices_to_dict(response.data.get('site:status')), SiteStatusChoices.as_dict()) - class RegionTest(APITestCase): diff --git a/netbox/extras/tests/test_api.py b/netbox/extras/tests/test_api.py index 6871b2654..b04b216ba 100644 --- a/netbox/extras/tests/test_api.py +++ b/netbox/extras/tests/test_api.py @@ -7,12 +7,10 @@ from rest_framework import status from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Platform, Rack, RackGroup, RackRole, Region, Site from extras.api.views import ScriptViewSet -from extras.choices import * -from extras.constants import GRAPH_MODELS from extras.models import ConfigContext, Graph, ExportTemplate, Tag from extras.scripts import BooleanVar, IntegerVar, Script, StringVar from tenancy.models import Tenant, TenantGroup -from utilities.testing import APITestCase, choices_to_dict +from utilities.testing import APITestCase class AppTest(APITestCase): @@ -24,27 +22,6 @@ class AppTest(APITestCase): self.assertEqual(response.status_code, 200) - def test_choices(self): - - url = reverse('extras-api:field-choice-list') - response = self.client.get(url, **self.header) - - self.assertEqual(response.status_code, 200) - - # ExportTemplate - self.assertEqual(choices_to_dict(response.data.get('export-template:template_language')), TemplateLanguageChoices.as_dict()) - - # Graph - content_types = ContentType.objects.filter(GRAPH_MODELS) - graph_type_choices = { - "{}.{}".format(ct.app_label, ct.model): str(ct) for ct in content_types - } - self.assertEqual(choices_to_dict(response.data.get('graph:type')), graph_type_choices) - self.assertEqual(choices_to_dict(response.data.get('graph:template_language')), TemplateLanguageChoices.as_dict()) - - # ObjectChange - self.assertEqual(choices_to_dict(response.data.get('object-change:action')), ObjectChangeActionChoices.as_dict()) - class GraphTest(APITestCase): diff --git a/netbox/ipam/tests/test_api.py b/netbox/ipam/tests/test_api.py index 99a7eaca4..8bdf7fd06 100644 --- a/netbox/ipam/tests/test_api.py +++ b/netbox/ipam/tests/test_api.py @@ -7,7 +7,7 @@ from rest_framework import status from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site from ipam.choices import * from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF -from utilities.testing import APITestCase, choices_to_dict, disable_warnings +from utilities.testing import APITestCase, disable_warnings class AppTest(APITestCase): @@ -19,31 +19,6 @@ class AppTest(APITestCase): self.assertEqual(response.status_code, 200) - def test_choices(self): - - url = reverse('ipam-api:field-choice-list') - response = self.client.get(url, **self.header) - - self.assertEqual(response.status_code, 200) - - # Aggregate - # self.assertEqual(choices_to_dict(response.data.get('aggregate:family')), ) - - # Prefix - # self.assertEqual(choices_to_dict(response.data.get('prefix:family')), ) - self.assertEqual(choices_to_dict(response.data.get('prefix:status')), PrefixStatusChoices.as_dict()) - - # IPAddress - # self.assertEqual(choices_to_dict(response.data.get('ip-address:family')), ) - self.assertEqual(choices_to_dict(response.data.get('ip-address:role')), IPAddressRoleChoices.as_dict()) - self.assertEqual(choices_to_dict(response.data.get('ip-address:status')), IPAddressStatusChoices.as_dict()) - - # VLAN - self.assertEqual(choices_to_dict(response.data.get('vlan:status')), VLANStatusChoices.as_dict()) - - # Service - self.assertEqual(choices_to_dict(response.data.get('service:protocol')), ServiceProtocolChoices.as_dict()) - class VRFTest(APITestCase): diff --git a/netbox/secrets/tests/test_api.py b/netbox/secrets/tests/test_api.py index df32ad7f2..339c370d8 100644 --- a/netbox/secrets/tests/test_api.py +++ b/netbox/secrets/tests/test_api.py @@ -19,13 +19,6 @@ class AppTest(APITestCase): self.assertEqual(response.status_code, 200) - def test_choices(self): - - url = reverse('secrets-api:field-choice-list') - response = self.client.get(url, **self.header) - - self.assertEqual(response.status_code, 200) - class SecretRoleTest(APITestCase): diff --git a/netbox/tenancy/tests/test_api.py b/netbox/tenancy/tests/test_api.py index 1767c8f28..8da3d7594 100644 --- a/netbox/tenancy/tests/test_api.py +++ b/netbox/tenancy/tests/test_api.py @@ -14,13 +14,6 @@ class AppTest(APITestCase): self.assertEqual(response.status_code, 200) - def test_choices(self): - - url = reverse('tenancy-api:field-choice-list') - response = self.client.get(url, **self.header) - - self.assertEqual(response.status_code, 200) - class TenantGroupTest(APITestCase): diff --git a/netbox/utilities/testing/utils.py b/netbox/utilities/testing/utils.py index 38ec6e196..fd8c70f05 100644 --- a/netbox/utilities/testing/utils.py +++ b/netbox/utilities/testing/utils.py @@ -36,33 +36,6 @@ def create_test_user(username='testuser', permissions=None): return user -def choices_to_dict(choices_list): - """ - Convert a list of field choices to a dictionary suitable for direct comparison with a ChoiceSet. For example: - - [ - { - "value": "choice-1", - "label": "First Choice" - }, - { - "value": "choice-2", - "label": "Second Choice" - } - ] - - Becomes: - - { - "choice-1": "First Choice", - "choice-2": "Second Choice - } - """ - return { - choice['value']: choice['label'] for choice in choices_list - } - - @contextmanager def disable_warnings(logger_name): """ diff --git a/netbox/virtualization/tests/test_api.py b/netbox/virtualization/tests/test_api.py index 719954c10..7aa4e929f 100644 --- a/netbox/virtualization/tests/test_api.py +++ b/netbox/virtualization/tests/test_api.py @@ -5,7 +5,7 @@ from rest_framework import status from dcim.choices import InterfaceModeChoices from dcim.models import Interface from ipam.models import IPAddress, VLAN -from utilities.testing import APITestCase, choices_to_dict, disable_warnings +from utilities.testing import APITestCase, disable_warnings from virtualization.choices import * from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine @@ -19,19 +19,6 @@ class AppTest(APITestCase): self.assertEqual(response.status_code, 200) - def test_choices(self): - - url = reverse('virtualization-api:field-choice-list') - response = self.client.get(url, **self.header) - - self.assertEqual(response.status_code, 200) - - # VirtualMachine - self.assertEqual(choices_to_dict(response.data.get('virtual-machine:status')), VirtualMachineStatusChoices.as_dict()) - - # Interface - self.assertEqual(choices_to_dict(response.data.get('interface:type')), VMInterfaceTypeChoices.as_dict()) - class ClusterTypeTest(APITestCase): From ef5c20dc6fd3bf6366332ef498c80cea0970cdea Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 12 Mar 2020 11:14:27 -0400 Subject: [PATCH 05/19] Update documentation --- docs/api/overview.md | 76 +++++++++++++++----------------------------- 1 file changed, 25 insertions(+), 51 deletions(-) diff --git a/docs/api/overview.md b/docs/api/overview.md index 1d8a91084..81e4caa25 100644 --- a/docs/api/overview.md +++ b/docs/api/overview.md @@ -187,37 +187,6 @@ GET /api/ipam/prefixes/13980/?brief=1 The brief format is supported for both lists and individual objects. -### Static Choice Fields - -Some model fields, such as the `status` field in the above example, utilize static integers corresponding to static choices. The available choices can be retrieved from the read-only `_choices` endpoint within each app. A specific `model:field` tuple may optionally be specified in the URL. - -Each choice includes a human-friendly label and its corresponding numeric value. For example, `GET /api/ipam/_choices/prefix:status/` will return: - -``` -[ - { - "value": 0, - "label": "Container" - }, - { - "value": 1, - "label": "Active" - }, - { - "value": 2, - "label": "Reserved" - }, - { - "value": 3, - "label": "Deprecated" - } -] -``` - -Thus, to set a prefix's status to "Reserved," it would be assigned the integer `2`. - -A request for `GET /api/ipam/_choices/` will return choices for _all_ fields belonging to models within the IPAM app. - ## Pagination API responses which contain a list of objects (for example, a request to `/api/dcim/devices/`) will be paginated to avoid unnecessary overhead. The root JSON object will contain the following attributes: @@ -280,27 +249,32 @@ A list of objects retrieved via the API can be filtered by passing one or more q GET /api/ipam/prefixes/?status=1 ``` -The choices available for fixed choice fields such as `status` are exposed in the API under a special `_choices` endpoint for each NetBox app. For example, the available choices for `Prefix.status` are listed at `/api/ipam/_choices/` under the key `prefix:status`: +The choices available for fixed choice fields such as `status` can be retrieved by sending an `OPTIONS` API request for the desired endpoint: + +```no-highlight +$ curl -s -X OPTIONS \ +-H "Content-Type: application/json" \ +-H "Accept: application/json; indent=4" \ +http://localhost:8000/api/ipam/prefixes/ | jq ".actions.POST.status.choices" +[ + { + "value": "container", + "display_name": "Container" + }, + { + "value": "active", + "display_name": "Active" + }, + { + "value": "reserved", + "display_name": "Reserved" + }, + { + "value": "deprecated", + "display_name": "Deprecated" + } +] -``` -"prefix:status": [ - { - "label": "Container", - "value": 0 - }, - { - "label": "Active", - "value": 1 - }, - { - "label": "Reserved", - "value": 2 - }, - { - "label": "Deprecated", - "value": 3 - } -], ``` For most fields, when a filter is passed multiple times, objects matching _any_ of the provided values will be returned. For example, `GET /api/dcim/sites/?name=Foo&name=Bar` will return all sites named "Foo" _or_ "Bar". The exception to this rule is ManyToManyFields which may have multiple values assigned. Tags are the most common example of a ManyToManyField. For example, `GET /api/dcim/sites/?tag=foo&tag=bar` will return only sites tagged with both "foo" _and_ "bar". From 2cd44d02342ff8129b69cf1f92692619d9e0a846 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 12 Mar 2020 11:38:39 -0400 Subject: [PATCH 06/19] Changelog for #3416 --- docs/release-notes/version-2.8.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/release-notes/version-2.8.md b/docs/release-notes/version-2.8.md index e3ed0291d..60c111187 100644 --- a/docs/release-notes/version-2.8.md +++ b/docs/release-notes/version-2.8.md @@ -24,8 +24,9 @@ If further customization of remote authentication is desired (for instance, if y ### API Changes -* dcim.Rack: The `/api/dcim/racks//units/` endpoint has been replaced with `/api/dcim/racks//elevation/`. +* The `_choices` API endpoints have been removed. Instead, use an `OPTIONS` request to a model's endpoint to view the available values for all fields. ([#3416](https://github.com/netbox-community/netbox/issues/3416)) * The `id__in` filter has been removed. Use the format `?id=1&id=2` instead. ([#4313](https://github.com/netbox-community/netbox/issues/4313)) +* dcim.Rack: The `/api/dcim/racks//units/` endpoint has been replaced with `/api/dcim/racks//elevation/`. ### Other Changes From 9fc1e88d9f5d3a93cb0288a6aee85c6a2333152b Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 12 Mar 2020 11:46:11 -0400 Subject: [PATCH 07/19] Update minimum Python version to 3.6 --- docs/additional-features/custom-scripts.md | 2 +- docs/administration/netbox-shell.md | 4 ++-- docs/index.md | 2 +- netbox/netbox/settings.py | 10 ++-------- 4 files changed, 6 insertions(+), 12 deletions(-) diff --git a/docs/additional-features/custom-scripts.md b/docs/additional-features/custom-scripts.md index 0904f8c82..1d84fea24 100644 --- a/docs/additional-features/custom-scripts.md +++ b/docs/additional-features/custom-scripts.md @@ -63,7 +63,7 @@ A human-friendly description of what your script does. ### `field_order` -A list of field names indicating the order in which the form fields should appear. This is optional, however on Python 3.5 and earlier the fields will appear in random order. (Declarative ordering is preserved on Python 3.6 and above.) For example: +A list of field names indicating the order in which the form fields should appear. This is optional, and should not be required on Python 3.6 and above. For example: ``` field_order = ['var1', 'var2', 'var3'] diff --git a/docs/administration/netbox-shell.md b/docs/administration/netbox-shell.md index bae4471b8..34cd5a30f 100644 --- a/docs/administration/netbox-shell.md +++ b/docs/administration/netbox-shell.md @@ -10,8 +10,8 @@ This will launch a customized version of [the built-in Django shell](https://doc ``` $ ./manage.py nbshell -### NetBox interactive shell (jstretch-laptop) -### Python 3.5.2 | Django 2.0.8 | NetBox 2.4.3 +### NetBox interactive shell (localhost) +### Python 3.6.9 | Django 2.2.11 | NetBox 2.7.10 ### lsmodels() will show available models. Use help() for more info. ``` diff --git a/docs/index.md b/docs/index.md index 4db2c55f5..3880c9d07 100644 --- a/docs/index.md +++ b/docs/index.md @@ -55,7 +55,7 @@ NetBox is built on the [Django](https://djangoproject.com/) Python framework and ## Supported Python Versions -NetBox supports Python 3.5, 3.6, and 3.7 environments currently. Python 3.5 is scheduled to be unsupported in NetBox v2.8. +NetBox supports Python 3.6 and 3.7 environments currently. (Support for Python 3.5 was removed in NetBox v2.8.) ## Getting Started diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 3c24b061a..9cc5cd58e 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -21,15 +21,9 @@ HOSTNAME = platform.node() BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) # Validate Python version -if platform.python_version_tuple() < ('3', '5'): +if platform.python_version_tuple() < ('3', '6'): raise RuntimeError( - "NetBox requires Python 3.5 or higher (current: Python {})".format(platform.python_version()) - ) -elif platform.python_version_tuple() < ('3', '6'): - warnings.warn( - "Python 3.6 or higher will be required starting with NetBox v2.8 (current: Python {})".format( - platform.python_version() - ) + "NetBox requires Python 3.6 or higher (current: Python {})".format(platform.python_version()) ) From f1080491426eb790a54f347e2c020fa924aa1e73 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 12 Mar 2020 11:57:26 -0400 Subject: [PATCH 08/19] Remove outdated TODOs --- netbox/extras/models.py | 2 -- netbox/extras/tests/test_filters.py | 1 - 2 files changed, 3 deletions(-) diff --git a/netbox/extras/models.py b/netbox/extras/models.py index d81fbeab9..68625ae2d 100644 --- a/netbox/extras/models.py +++ b/netbox/extras/models.py @@ -550,7 +550,6 @@ class Graph(models.Model): def embed_url(self, obj): context = {'obj': obj} - # TODO: Remove in v2.8 if self.template_language == TemplateLanguageChoices.LANGUAGE_DJANGO: template = Template(self.source) return template.render(Context(context)) @@ -564,7 +563,6 @@ class Graph(models.Model): context = {'obj': obj} - # TODO: Remove in v2.8 if self.template_language == TemplateLanguageChoices.LANGUAGE_DJANGO: template = Template(self.link) return template.render(Context(context)) diff --git a/netbox/extras/tests/test_filters.py b/netbox/extras/tests/test_filters.py index 126414cfd..ca60b8524 100644 --- a/netbox/extras/tests/test_filters.py +++ b/netbox/extras/tests/test_filters.py @@ -36,7 +36,6 @@ class GraphTestCase(TestCase): params = {'type': content_type.pk} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) - # TODO: Remove in v2.8 def test_template_language(self): params = {'template_language': TemplateLanguageChoices.LANGUAGE_JINJA2} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) From 5e971994ff7385d6707c006ef290d8517e144fed Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 12 Mar 2020 17:43:11 -0400 Subject: [PATCH 09/19] Closes #4362: Standardize URL for creation of RackReservations --- netbox/dcim/forms.py | 12 +++++++++-- netbox/dcim/tests/test_views.py | 1 - netbox/dcim/urls.py | 2 +- netbox/dcim/views.py | 12 +++++------ netbox/templates/dcim/rack.html | 6 +++--- .../templates/dcim/rackreservation_edit.html | 21 +++++++++++++++++++ 6 files changed, 40 insertions(+), 14 deletions(-) create mode 100644 netbox/templates/dcim/rackreservation_edit.html diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index ac8fc40d5..f2719eca6 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -791,6 +791,13 @@ class RackElevationFilterForm(RackFilterForm): # class RackReservationForm(BootstrapMixin, TenancyForm, forms.ModelForm): + rack = forms.ModelChoiceField( + queryset=Rack.objects.all(), + required=False, + widget=forms.HiddenInput() + ) + # TODO: Change this to an API-backed form field. We can't do this currently because we want to retain + # the multi-line + + + + + + + +
+ {% if perms.dcim.change_rackreservation %} + {% edit_button rackreservation %} + {% endif %} + {% if perms.dcim.delete_rackreservation %} + {% delete_button rackreservation %} + {% endif %} +
+

{% block title %}{{ rackreservation }}{% endblock %}

+ {% include 'inc/created_updated.html' with obj=rackreservation %} + +{% endblock %} + +{% block content %} +
+
+
+
+ Rack +
+ + {% with rack=rackreservation.rack %} + + + + + + + + + + + + + {% endwith %} +
Site + {% if rack.site.region %} + {{ rack.site.region }} + + {% endif %} + {{ rack.site }} +
Group + {% if rack.group %} + {{ rack.group }} + {% else %} + None + {% endif %} +
Rack + {{ rack }} +
+
+
+
+ Reservation Details +
+ + + + + + + + + + + + + + + + + +
Units{{ rackreservation.unit_list }}
Tenant + {% if rackreservation.tenant %} + {% if rackreservation.tenant.group %} + {{ rackreservation.tenant.group }} + + {% endif %} + {{ rackreservation.tenant }} + {% else %} + None + {% endif %} +
User{{ rackreservation.user }}
Description{{ rackreservation.description }}
+
+
+
+ {% with rack=rackreservation.rack %} +
+
+
+

Front

+
+ {% include 'dcim/inc/rack_elevation.html' with face='front' %} +
+
+
+

Rear

+
+ {% include 'dcim/inc/rack_elevation.html' with face='rear' %} +
+
+ {% endwith %} +
+
+{% endblock %} + +{% block javascript %} + +{% endblock %} From 3b4ec5926deb7d9fbbc0ac8de60354533d98d4c1 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 13 Mar 2020 15:49:58 -0400 Subject: [PATCH 14/19] Standardize existing description fields to a length of 200 chars --- netbox/circuits/models.py | 6 +++--- netbox/dcim/models/__init__.py | 8 ++++---- netbox/dcim/models/device_components.py | 2 +- netbox/extras/models.py | 4 ++-- netbox/ipam/models.py | 14 +++++++------- netbox/secrets/models.py | 2 +- netbox/tenancy/models.py | 5 ++--- netbox/users/models.py | 2 +- 8 files changed, 21 insertions(+), 22 deletions(-) diff --git a/netbox/circuits/models.py b/netbox/circuits/models.py index 812eaa79e..c650e27b7 100644 --- a/netbox/circuits/models.py +++ b/netbox/circuits/models.py @@ -108,7 +108,7 @@ class CircuitType(ChangeLoggedModel): unique=True ) description = models.CharField( - max_length=100, + max_length=200, blank=True, ) @@ -173,7 +173,7 @@ class Circuit(ChangeLoggedModel, CustomFieldModel): null=True, verbose_name='Commit rate (Kbps)') description = models.CharField( - max_length=100, + max_length=200, blank=True ) comments = models.TextField( @@ -292,7 +292,7 @@ class CircuitTermination(CableTermination): verbose_name='Patch panel/port(s)' ) description = models.CharField( - max_length=100, + max_length=200, blank=True ) diff --git a/netbox/dcim/models/__init__.py b/netbox/dcim/models/__init__.py index 1dbfdb76b..e0d0cd0cd 100644 --- a/netbox/dcim/models/__init__.py +++ b/netbox/dcim/models/__init__.py @@ -182,7 +182,7 @@ class Site(ChangeLoggedModel, CustomFieldModel): blank=True ) description = models.CharField( - max_length=100, + max_length=200, blank=True ) physical_address = models.CharField( @@ -362,7 +362,7 @@ class RackRole(ChangeLoggedModel): ) color = ColorField() description = models.CharField( - max_length=100, + max_length=200, blank=True, ) @@ -785,7 +785,7 @@ class RackReservation(ChangeLoggedModel): on_delete=models.PROTECT ) description = models.CharField( - max_length=100 + max_length=200 ) csv_headers = ['site', 'rack_group', 'rack', 'units', 'tenant', 'user', 'description'] @@ -1142,7 +1142,7 @@ class DeviceRole(ChangeLoggedModel): help_text='Virtual machines may be assigned to this role' ) description = models.CharField( - max_length=100, + max_length=200, blank=True, ) diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index 9a3d608d7..d6582baac 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -33,7 +33,7 @@ __all__ = ( class ComponentModel(models.Model): description = models.CharField( - max_length=100, + max_length=200, blank=True ) diff --git a/netbox/extras/models.py b/netbox/extras/models.py index 68625ae2d..0de5ec8e5 100644 --- a/netbox/extras/models.py +++ b/netbox/extras/models.py @@ -242,7 +242,7 @@ class CustomField(models.Model): 'the field\'s name will be used)' ) description = models.CharField( - max_length=100, + max_length=200, blank=True ) required = models.BooleanField( @@ -764,7 +764,7 @@ class ConfigContext(models.Model): default=1000 ) description = models.CharField( - max_length=100, + max_length=200, blank=True ) is_active = models.BooleanField( diff --git a/netbox/ipam/models.py b/netbox/ipam/models.py index 098dac2f2..4de3cdfdd 100644 --- a/netbox/ipam/models.py +++ b/netbox/ipam/models.py @@ -63,7 +63,7 @@ class VRF(ChangeLoggedModel, CustomFieldModel): help_text='Prevent duplicate prefixes/IP addresses within this VRF' ) description = models.CharField( - max_length=100, + max_length=200, blank=True ) custom_field_values = GenericRelation( @@ -162,7 +162,7 @@ class Aggregate(ChangeLoggedModel, CustomFieldModel): null=True ) description = models.CharField( - max_length=100, + max_length=200, blank=True ) custom_field_values = GenericRelation( @@ -261,7 +261,7 @@ class Role(ChangeLoggedModel): default=1000 ) description = models.CharField( - max_length=100, + max_length=200, blank=True, ) @@ -342,7 +342,7 @@ class Prefix(ChangeLoggedModel, CustomFieldModel): help_text='All IP addresses within this prefix are considered usable' ) description = models.CharField( - max_length=100, + max_length=200, blank=True ) custom_field_values = GenericRelation( @@ -612,7 +612,7 @@ class IPAddress(ChangeLoggedModel, CustomFieldModel): help_text='Hostname or FQDN (not case-sensitive)' ) description = models.CharField( - max_length=100, + max_length=200, blank=True ) custom_field_values = GenericRelation( @@ -898,7 +898,7 @@ class VLAN(ChangeLoggedModel, CustomFieldModel): null=True ) description = models.CharField( - max_length=100, + max_length=200, blank=True ) custom_field_values = GenericRelation( @@ -1010,7 +1010,7 @@ class Service(ChangeLoggedModel, CustomFieldModel): verbose_name='IP addresses' ) description = models.CharField( - max_length=100, + max_length=200, blank=True ) custom_field_values = GenericRelation( diff --git a/netbox/secrets/models.py b/netbox/secrets/models.py index 7cebb744c..5038823ae 100644 --- a/netbox/secrets/models.py +++ b/netbox/secrets/models.py @@ -254,7 +254,7 @@ class SecretRole(ChangeLoggedModel): unique=True ) description = models.CharField( - max_length=100, + max_length=200, blank=True, ) users = models.ManyToManyField( diff --git a/netbox/tenancy/models.py b/netbox/tenancy/models.py index 1a02184cd..649905c17 100644 --- a/netbox/tenancy/models.py +++ b/netbox/tenancy/models.py @@ -86,9 +86,8 @@ class Tenant(ChangeLoggedModel, CustomFieldModel): null=True ) description = models.CharField( - max_length=100, - blank=True, - help_text='Long-form name (optional)' + max_length=200, + blank=True ) comments = models.TextField( blank=True diff --git a/netbox/users/models.py b/netbox/users/models.py index cf0d826b5..5be784777 100644 --- a/netbox/users/models.py +++ b/netbox/users/models.py @@ -39,7 +39,7 @@ class Token(models.Model): help_text='Permit create/update/delete operations using this key' ) description = models.CharField( - max_length=100, + max_length=200, blank=True ) From cebe580484c8ea7177b5164679ce08e8e8557c2c Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 13 Mar 2020 16:24:37 -0400 Subject: [PATCH 15/19] Add a description field to all organizational models --- netbox/dcim/api/serializers.py | 10 ++-- netbox/dcim/filters.py | 8 ++-- netbox/dcim/forms.py | 8 ++-- netbox/dcim/models/__init__.py | 28 +++++++++-- netbox/dcim/tables.py | 12 +++-- netbox/dcim/tests/test_filters.py | 51 ++++++++++++++------- netbox/dcim/tests/test_views.py | 36 ++++++++------- netbox/ipam/api/serializers.py | 4 +- netbox/ipam/filters.py | 4 +- netbox/ipam/forms.py | 4 +- netbox/ipam/models.py | 14 +++++- netbox/ipam/tables.py | 4 +- netbox/ipam/tests/test_filters.py | 26 +++++++---- netbox/ipam/tests/test_views.py | 18 ++++---- netbox/tenancy/api/serializers.py | 2 +- netbox/tenancy/filters.py | 2 +- netbox/tenancy/forms.py | 2 +- netbox/tenancy/models.py | 7 ++- netbox/tenancy/tables.py | 2 +- netbox/tenancy/tests/test_filters.py | 10 ++-- netbox/tenancy/tests/test_views.py | 9 ++-- netbox/virtualization/api/serializers.py | 4 +- netbox/virtualization/filters.py | 4 +- netbox/virtualization/forms.py | 4 +- netbox/virtualization/models.py | 14 +++++- netbox/virtualization/tables.py | 4 +- netbox/virtualization/tests/test_filters.py | 20 +++++--- netbox/virtualization/tests/test_views.py | 18 ++++---- 28 files changed, 213 insertions(+), 116 deletions(-) diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index 85ff1895c..efc186f97 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -64,7 +64,7 @@ class RegionSerializer(serializers.ModelSerializer): class Meta: model = Region - fields = ['id', 'name', 'slug', 'parent', 'site_count'] + fields = ['id', 'name', 'slug', 'parent', 'description', 'site_count'] class SiteSerializer(TaggitSerializer, CustomFieldModelSerializer): @@ -101,7 +101,7 @@ class RackGroupSerializer(ValidatedModelSerializer): class Meta: model = RackGroup - fields = ['id', 'name', 'slug', 'site', 'parent', 'rack_count'] + fields = ['id', 'name', 'slug', 'site', 'parent', 'description', 'rack_count'] class RackRoleSerializer(ValidatedModelSerializer): @@ -219,7 +219,9 @@ class ManufacturerSerializer(ValidatedModelSerializer): class Meta: model = Manufacturer - fields = ['id', 'name', 'slug', 'devicetype_count', 'inventoryitem_count', 'platform_count'] + fields = [ + 'id', 'name', 'slug', 'description', 'devicetype_count', 'inventoryitem_count', 'platform_count', + ] class DeviceTypeSerializer(TaggitSerializer, CustomFieldModelSerializer): @@ -356,7 +358,7 @@ class PlatformSerializer(ValidatedModelSerializer): class Meta: model = Platform fields = [ - 'id', 'name', 'slug', 'manufacturer', 'napalm_driver', 'napalm_args', 'device_count', + 'id', 'name', 'slug', 'manufacturer', 'napalm_driver', 'napalm_args', 'description', 'device_count', 'virtualmachine_count', ] diff --git a/netbox/dcim/filters.py b/netbox/dcim/filters.py index e4ddf792b..1fa7e7210 100644 --- a/netbox/dcim/filters.py +++ b/netbox/dcim/filters.py @@ -74,7 +74,7 @@ class RegionFilterSet(BaseFilterSet, NameSlugSearchFilterSet): class Meta: model = Region - fields = ['id', 'name', 'slug'] + fields = ['id', 'name', 'slug', 'description'] class SiteFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet): @@ -166,7 +166,7 @@ class RackGroupFilterSet(BaseFilterSet, NameSlugSearchFilterSet): class Meta: model = RackGroup - fields = ['id', 'name', 'slug'] + fields = ['id', 'name', 'slug', 'description'] class RackRoleFilterSet(BaseFilterSet, NameSlugSearchFilterSet): @@ -318,7 +318,7 @@ class ManufacturerFilterSet(BaseFilterSet, NameSlugSearchFilterSet): class Meta: model = Manufacturer - fields = ['id', 'name', 'slug'] + fields = ['id', 'name', 'slug', 'description'] class DeviceTypeFilterSet(BaseFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet): @@ -493,7 +493,7 @@ class PlatformFilterSet(BaseFilterSet, NameSlugSearchFilterSet): class Meta: model = Platform - fields = ['id', 'name', 'slug', 'napalm_driver'] + fields = ['id', 'name', 'slug', 'napalm_driver', 'description'] class DeviceFilterSet( diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index 1a6d60b86..acecc3598 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -192,7 +192,7 @@ class RegionForm(BootstrapMixin, forms.ModelForm): class Meta: model = Region fields = ( - 'parent', 'name', 'slug', + 'parent', 'name', 'slug', 'description', ) @@ -404,7 +404,7 @@ class RackGroupForm(BootstrapMixin, forms.ModelForm): class Meta: model = RackGroup fields = ( - 'site', 'parent', 'name', 'slug', + 'site', 'parent', 'name', 'slug', 'description', ) @@ -983,7 +983,7 @@ class ManufacturerForm(BootstrapMixin, forms.ModelForm): class Meta: model = Manufacturer fields = [ - 'name', 'slug', + 'name', 'slug', 'description', ] @@ -1768,7 +1768,7 @@ class PlatformForm(BootstrapMixin, forms.ModelForm): class Meta: model = Platform fields = [ - 'name', 'slug', 'manufacturer', 'napalm_driver', 'napalm_args', + 'name', 'slug', 'manufacturer', 'napalm_driver', 'napalm_args', 'description', ] widgets = { 'napalm_args': SmallTextarea(), diff --git a/netbox/dcim/models/__init__.py b/netbox/dcim/models/__init__.py index e0d0cd0cd..6fc549dac 100644 --- a/netbox/dcim/models/__init__.py +++ b/netbox/dcim/models/__init__.py @@ -94,8 +94,12 @@ class Region(MPTTModel, ChangeLoggedModel): slug = models.SlugField( unique=True ) + description = models.CharField( + max_length=200, + blank=True + ) - csv_headers = ['name', 'slug', 'parent'] + csv_headers = ['name', 'slug', 'parent', 'description'] class MPTTMeta: order_insertion_by = ['name'] @@ -111,6 +115,7 @@ class Region(MPTTModel, ChangeLoggedModel): self.name, self.slug, self.parent.name if self.parent else None, + self.description, ) def get_site_count(self): @@ -306,8 +311,12 @@ class RackGroup(MPTTModel, ChangeLoggedModel): null=True, db_index=True ) + description = models.CharField( + max_length=200, + blank=True + ) - csv_headers = ['site', 'parent', 'name', 'slug'] + csv_headers = ['site', 'parent', 'name', 'slug', 'description'] class Meta: ordering = ['site', 'name'] @@ -331,6 +340,7 @@ class RackGroup(MPTTModel, ChangeLoggedModel): self.parent.name if self.parent else '', self.name, self.slug, + self.description, ) def to_objectchange(self, action): @@ -858,8 +868,12 @@ class Manufacturer(ChangeLoggedModel): slug = models.SlugField( unique=True ) + description = models.CharField( + max_length=200, + blank=True + ) - csv_headers = ['name', 'slug'] + csv_headers = ['name', 'slug', 'description'] class Meta: ordering = ['name'] @@ -874,6 +888,7 @@ class Manufacturer(ChangeLoggedModel): return ( self.name, self.slug, + self.description ) @@ -1198,8 +1213,12 @@ class Platform(ChangeLoggedModel): 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 + ) - csv_headers = ['name', 'slug', 'manufacturer', 'napalm_driver', 'napalm_args'] + csv_headers = ['name', 'slug', 'manufacturer', 'napalm_driver', 'napalm_args', 'description'] class Meta: ordering = ['name'] @@ -1217,6 +1236,7 @@ class Platform(ChangeLoggedModel): self.manufacturer.name if self.manufacturer else None, self.napalm_driver, self.napalm_args, + self.description, ) diff --git a/netbox/dcim/tables.py b/netbox/dcim/tables.py index ebda79dc0..283145b17 100644 --- a/netbox/dcim/tables.py +++ b/netbox/dcim/tables.py @@ -225,7 +225,7 @@ class RegionTable(BaseTable): class Meta(BaseTable.Meta): model = Region - fields = ('pk', 'name', 'site_count', 'slug', 'actions') + fields = ('pk', 'name', 'site_count', 'description', 'slug', 'actions') # @@ -271,7 +271,7 @@ class RackGroupTable(BaseTable): class Meta(BaseTable.Meta): model = RackGroup - fields = ('pk', 'name', 'site', 'rack_count', 'slug', 'actions') + fields = ('pk', 'name', 'site', 'rack_count', 'description', 'slug', 'actions') # @@ -383,7 +383,9 @@ class ManufacturerTable(BaseTable): class Meta(BaseTable.Meta): model = Manufacturer - fields = ('pk', 'name', 'devicetype_count', 'inventoryitem_count', 'platform_count', 'slug', 'actions') + fields = ( + 'pk', 'name', 'devicetype_count', 'inventoryitem_count', 'platform_count', 'description', 'slug', 'actions', + ) # @@ -659,7 +661,9 @@ class PlatformTable(BaseTable): class Meta(BaseTable.Meta): model = Platform - fields = ('pk', 'name', 'manufacturer', 'device_count', 'vm_count', 'slug', 'napalm_driver', 'actions') + fields = ( + 'pk', 'name', 'manufacturer', 'device_count', 'vm_count', 'slug', 'napalm_driver', 'description', 'actions', + ) # diff --git a/netbox/dcim/tests/test_filters.py b/netbox/dcim/tests/test_filters.py index 3986d0892..3158596fc 100644 --- a/netbox/dcim/tests/test_filters.py +++ b/netbox/dcim/tests/test_filters.py @@ -17,14 +17,15 @@ from virtualization.models import Cluster, ClusterType class RegionTestCase(TestCase): queryset = Region.objects.all() + filterset = RegionFilterSet @classmethod def setUpTestData(cls): regions = ( - Region(name='Region 1', slug='region-1'), - Region(name='Region 2', slug='region-2'), - Region(name='Region 3', slug='region-3'), + Region(name='Region 1', slug='region-1', description='A'), + Region(name='Region 2', slug='region-2', description='B'), + Region(name='Region 3', slug='region-3', description='C'), ) for region in regions: region.save() @@ -43,22 +44,26 @@ class RegionTestCase(TestCase): def test_id(self): id_list = self.queryset.values_list('id', flat=True)[:2] params = {'id': [str(id) for id in id_list]} - self.assertEqual(RegionFilterSet(params, self.queryset).qs.count(), 2) + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) def test_name(self): params = {'name': ['Region 1', 'Region 2']} - self.assertEqual(RegionFilterSet(params, self.queryset).qs.count(), 2) + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) def test_slug(self): params = {'slug': ['region-1', 'region-2']} - self.assertEqual(RegionFilterSet(params, self.queryset).qs.count(), 2) + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_description(self): + params = {'description': ['A', 'B']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) def test_parent(self): parent_regions = Region.objects.filter(parent__isnull=True)[:2] params = {'parent_id': [parent_regions[0].pk, parent_regions[1].pk]} - self.assertEqual(RegionFilterSet(params, self.queryset).qs.count(), 4) + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) params = {'parent': [parent_regions[0].slug, parent_regions[1].slug]} - self.assertEqual(RegionFilterSet(params, self.queryset).qs.count(), 4) + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) class SiteTestCase(TestCase): @@ -196,9 +201,9 @@ class RackGroupTestCase(TestCase): rackgroup.save() rack_groups = ( - RackGroup(name='Rack Group 1', slug='rack-group-1', site=sites[0], parent=parent_rack_groups[0]), - RackGroup(name='Rack Group 2', slug='rack-group-2', site=sites[1], parent=parent_rack_groups[1]), - RackGroup(name='Rack Group 3', slug='rack-group-3', site=sites[2], parent=parent_rack_groups[2]), + RackGroup(name='Rack Group 1', slug='rack-group-1', site=sites[0], parent=parent_rack_groups[0], description='A'), + RackGroup(name='Rack Group 2', slug='rack-group-2', site=sites[1], parent=parent_rack_groups[1], description='B'), + RackGroup(name='Rack Group 3', slug='rack-group-3', site=sites[2], parent=parent_rack_groups[2], description='C'), ) for rackgroup in rack_groups: rackgroup.save() @@ -216,6 +221,10 @@ class RackGroupTestCase(TestCase): params = {'slug': ['rack-group-1', 'rack-group-2']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_description(self): + params = {'description': ['A', 'B']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_region(self): regions = Region.objects.all()[:2] params = {'region_id': [regions[0].pk, regions[1].pk]} @@ -535,9 +544,9 @@ class ManufacturerTestCase(TestCase): def setUpTestData(cls): manufacturers = ( - Manufacturer(name='Manufacturer 1', slug='manufacturer-1'), - Manufacturer(name='Manufacturer 2', slug='manufacturer-2'), - Manufacturer(name='Manufacturer 3', slug='manufacturer-3'), + Manufacturer(name='Manufacturer 1', slug='manufacturer-1', description='A'), + Manufacturer(name='Manufacturer 2', slug='manufacturer-2', description='B'), + Manufacturer(name='Manufacturer 3', slug='manufacturer-3', description='C'), ) Manufacturer.objects.bulk_create(manufacturers) @@ -554,6 +563,10 @@ class ManufacturerTestCase(TestCase): params = {'slug': ['manufacturer-1', 'manufacturer-2']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_description(self): + params = {'description': ['A', 'B']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + class DeviceTypeTestCase(TestCase): queryset = DeviceType.objects.all() @@ -1081,9 +1094,9 @@ class PlatformTestCase(TestCase): Manufacturer.objects.bulk_create(manufacturers) platforms = ( - Platform(name='Platform 1', slug='platform-1', manufacturer=manufacturers[0], napalm_driver='driver-1'), - Platform(name='Platform 2', slug='platform-2', manufacturer=manufacturers[1], napalm_driver='driver-2'), - Platform(name='Platform 3', slug='platform-3', manufacturer=manufacturers[2], napalm_driver='driver-3'), + Platform(name='Platform 1', slug='platform-1', manufacturer=manufacturers[0], napalm_driver='driver-1', description='A'), + Platform(name='Platform 2', slug='platform-2', manufacturer=manufacturers[1], napalm_driver='driver-2', description='B'), + Platform(name='Platform 3', slug='platform-3', manufacturer=manufacturers[2], napalm_driver='driver-3', description='C'), ) Platform.objects.bulk_create(platforms) @@ -1100,6 +1113,10 @@ class PlatformTestCase(TestCase): params = {'slug': ['platform-1', 'platform-2']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_description(self): + params = {'description': ['A', 'B']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_napalm_driver(self): params = {'napalm_driver': ['driver-1', 'driver-2']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) diff --git a/netbox/dcim/tests/test_views.py b/netbox/dcim/tests/test_views.py index d7434c808..b0eeca20a 100644 --- a/netbox/dcim/tests/test_views.py +++ b/netbox/dcim/tests/test_views.py @@ -46,13 +46,14 @@ class RegionTestCase(ViewTestCases.OrganizationalObjectViewTestCase): 'name': 'Region X', 'slug': 'region-x', 'parent': regions[2].pk, + 'description': 'A new region', } cls.csv_data = ( - "name,slug", - "Region 4,region-4", - "Region 5,region-5", - "Region 6,region-6", + "name,slug,description", + "Region 4,region-4,Fourth region", + "Region 5,region-5,Fifth region", + "Region 6,region-6,Sixth region", ) @@ -134,13 +135,14 @@ class RackGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase): 'name': 'Rack Group X', 'slug': 'rack-group-x', 'site': site.pk, + 'description': 'A new rack group', } cls.csv_data = ( - "site,name,slug", - "Site 1,Rack Group 4,rack-group-4", - "Site 1,Rack Group 5,rack-group-5", - "Site 1,Rack Group 6,rack-group-6", + "site,name,slug,description", + "Site 1,Rack Group 4,rack-group-4,Fourth rack group", + "Site 1,Rack Group 5,rack-group-5,Fifth rack group", + "Site 1,Rack Group 6,rack-group-6,Sixth rack group", ) @@ -309,13 +311,14 @@ class ManufacturerTestCase(ViewTestCases.OrganizationalObjectViewTestCase): cls.form_data = { 'name': 'Manufacturer X', 'slug': 'manufacturer-x', + 'description': 'A new manufacturer', } cls.csv_data = ( - "name,slug", - "Manufacturer 4,manufacturer-4", - "Manufacturer 5,manufacturer-5", - "Manufacturer 6,manufacturer-6", + "name,slug,description", + "Manufacturer 4,manufacturer-4,Fourth manufacturer", + "Manufacturer 5,manufacturer-5,Fifth manufacturer", + "Manufacturer 6,manufacturer-6,Sixth manufacturer", ) @@ -868,13 +871,14 @@ class PlatformTestCase(ViewTestCases.OrganizationalObjectViewTestCase): 'manufacturer': manufacturer.pk, 'napalm_driver': 'junos', 'napalm_args': None, + 'description': 'A new platform', } cls.csv_data = ( - "name,slug", - "Platform 4,platform-4", - "Platform 5,platform-5", - "Platform 6,platform-6", + "name,slug,description", + "Platform 4,platform-4,Fourth platform", + "Platform 5,platform-5,Fifth platform", + "Platform 6,platform-6,Sixth platform", ) diff --git a/netbox/ipam/api/serializers.py b/netbox/ipam/api/serializers.py index e6d9adecd..d654ecf70 100644 --- a/netbox/ipam/api/serializers.py +++ b/netbox/ipam/api/serializers.py @@ -45,7 +45,7 @@ class RIRSerializer(ValidatedModelSerializer): class Meta: model = RIR - fields = ['id', 'name', 'slug', 'is_private', 'aggregate_count'] + fields = ['id', 'name', 'slug', 'is_private', 'description', 'aggregate_count'] class AggregateSerializer(TaggitSerializer, CustomFieldModelSerializer): @@ -81,7 +81,7 @@ class VLANGroupSerializer(ValidatedModelSerializer): class Meta: model = VLANGroup - fields = ['id', 'name', 'slug', 'site', 'vlan_count'] + fields = ['id', 'name', 'slug', 'site', 'description', 'vlan_count'] validators = [] def validate(self, data): diff --git a/netbox/ipam/filters.py b/netbox/ipam/filters.py index 1cfc9038d..1390da945 100644 --- a/netbox/ipam/filters.py +++ b/netbox/ipam/filters.py @@ -54,7 +54,7 @@ class RIRFilterSet(BaseFilterSet, NameSlugSearchFilterSet): class Meta: model = RIR - fields = ['name', 'slug', 'is_private'] + fields = ['name', 'slug', 'is_private', 'description'] class AggregateFilterSet(BaseFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet): @@ -419,7 +419,7 @@ class VLANGroupFilterSet(BaseFilterSet, NameSlugSearchFilterSet): class Meta: model = VLANGroup - fields = ['id', 'name', 'slug'] + fields = ['id', 'name', 'slug', 'description'] class VLANFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet): diff --git a/netbox/ipam/forms.py b/netbox/ipam/forms.py index f9c6fe515..3c555a8fc 100644 --- a/netbox/ipam/forms.py +++ b/netbox/ipam/forms.py @@ -119,7 +119,7 @@ class RIRForm(BootstrapMixin, forms.ModelForm): class Meta: model = RIR fields = [ - 'name', 'slug', 'is_private', + 'name', 'slug', 'is_private', 'description', ] @@ -1048,7 +1048,7 @@ class VLANGroupForm(BootstrapMixin, forms.ModelForm): class Meta: model = VLANGroup fields = [ - 'site', 'name', 'slug', + 'site', 'name', 'slug', 'description', ] diff --git a/netbox/ipam/models.py b/netbox/ipam/models.py index 4de3cdfdd..71a8b2caf 100644 --- a/netbox/ipam/models.py +++ b/netbox/ipam/models.py @@ -123,8 +123,12 @@ class RIR(ChangeLoggedModel): verbose_name='Private', help_text='IP space managed by this RIR is considered private' ) + description = models.CharField( + max_length=200, + blank=True + ) - csv_headers = ['name', 'slug', 'is_private'] + csv_headers = ['name', 'slug', 'is_private', 'description'] class Meta: ordering = ['name'] @@ -142,6 +146,7 @@ class RIR(ChangeLoggedModel): self.name, self.slug, self.is_private, + self.description, ) @@ -812,8 +817,12 @@ class VLANGroup(ChangeLoggedModel): blank=True, null=True ) + description = models.CharField( + max_length=200, + blank=True + ) - csv_headers = ['name', 'slug', 'site'] + csv_headers = ['name', 'slug', 'site', 'description'] class Meta: ordering = ('site', 'name', 'pk') # (site, name) may be non-unique @@ -835,6 +844,7 @@ class VLANGroup(ChangeLoggedModel): self.name, self.slug, self.site.name if self.site else None, + self.description, ) def get_next_available_vid(self): diff --git a/netbox/ipam/tables.py b/netbox/ipam/tables.py index 4dcb0a6c3..19735b81c 100644 --- a/netbox/ipam/tables.py +++ b/netbox/ipam/tables.py @@ -211,7 +211,7 @@ class RIRTable(BaseTable): class Meta(BaseTable.Meta): model = RIR - fields = ('pk', 'name', 'is_private', 'aggregate_count', 'actions') + fields = ('pk', 'name', 'is_private', 'aggregate_count', 'description', 'actions') class RIRDetailTable(RIRTable): @@ -410,7 +410,7 @@ class VLANGroupTable(BaseTable): class Meta(BaseTable.Meta): model = VLANGroup - fields = ('pk', 'name', 'site', 'vlan_count', 'slug', 'actions') + fields = ('pk', 'name', 'site', 'vlan_count', 'slug', 'description', 'actions') # diff --git a/netbox/ipam/tests/test_filters.py b/netbox/ipam/tests/test_filters.py index 5bfbb30d9..b7089f5f8 100644 --- a/netbox/ipam/tests/test_filters.py +++ b/netbox/ipam/tests/test_filters.py @@ -77,12 +77,12 @@ class RIRTestCase(TestCase): def setUpTestData(cls): rirs = ( - RIR(name='RIR 1', slug='rir-1', is_private=False), - RIR(name='RIR 2', slug='rir-2', is_private=False), - RIR(name='RIR 3', slug='rir-3', is_private=False), - RIR(name='RIR 4', slug='rir-4', is_private=True), - RIR(name='RIR 5', slug='rir-5', is_private=True), - RIR(name='RIR 6', slug='rir-6', is_private=True), + RIR(name='RIR 1', slug='rir-1', is_private=False, description='A'), + RIR(name='RIR 2', slug='rir-2', is_private=False, description='B'), + RIR(name='RIR 3', slug='rir-3', is_private=False, description='C'), + RIR(name='RIR 4', slug='rir-4', is_private=True, description='D'), + RIR(name='RIR 5', slug='rir-5', is_private=True, description='E'), + RIR(name='RIR 6', slug='rir-6', is_private=True, description='F'), ) RIR.objects.bulk_create(rirs) @@ -94,6 +94,10 @@ class RIRTestCase(TestCase): params = {'slug': ['rir-1', 'rir-2']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_description(self): + params = {'description': ['A', 'B']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_is_private(self): params = {'is_private': 'true'} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3) @@ -519,9 +523,9 @@ class VLANGroupTestCase(TestCase): Site.objects.bulk_create(sites) vlan_groups = ( - VLANGroup(name='VLAN Group 1', slug='vlan-group-1', site=sites[0]), - VLANGroup(name='VLAN Group 2', slug='vlan-group-2', site=sites[1]), - VLANGroup(name='VLAN Group 3', slug='vlan-group-3', site=sites[2]), + VLANGroup(name='VLAN Group 1', slug='vlan-group-1', site=sites[0], description='A'), + VLANGroup(name='VLAN Group 2', slug='vlan-group-2', site=sites[1], description='B'), + VLANGroup(name='VLAN Group 3', slug='vlan-group-3', site=sites[2], description='C'), VLANGroup(name='VLAN Group 4', slug='vlan-group-4', site=None), ) VLANGroup.objects.bulk_create(vlan_groups) @@ -539,6 +543,10 @@ class VLANGroupTestCase(TestCase): params = {'slug': ['vlan-group-1', 'vlan-group-2']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_description(self): + params = {'description': ['A', 'B']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_region(self): regions = Region.objects.all()[:2] params = {'region_id': [regions[0].pk, regions[1].pk]} diff --git a/netbox/ipam/tests/test_views.py b/netbox/ipam/tests/test_views.py index aedc44bac..de0dfb0d6 100644 --- a/netbox/ipam/tests/test_views.py +++ b/netbox/ipam/tests/test_views.py @@ -59,13 +59,14 @@ class RIRTestCase(ViewTestCases.OrganizationalObjectViewTestCase): 'name': 'RIR X', 'slug': 'rir-x', 'is_private': True, + 'description': 'A new RIR', } cls.csv_data = ( - "name,slug", - "RIR 4,rir-4", - "RIR 5,rir-5", - "RIR 6,rir-6", + "name,slug,description", + "RIR 4,rir-4,Fourth RIR", + "RIR 5,rir-5,Fifth RIR", + "RIR 6,rir-6,Sixth RIR", ) @@ -261,13 +262,14 @@ class VLANGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase): 'name': 'VLAN Group X', 'slug': 'vlan-group-x', 'site': site.pk, + 'description': 'A new VLAN group', } cls.csv_data = ( - "name,slug", - "VLAN Group 4,vlan-group-4", - "VLAN Group 5,vlan-group-5", - "VLAN Group 6,vlan-group-6", + "name,slug,description", + "VLAN Group 4,vlan-group-4,Fourth VLAN group", + "VLAN Group 5,vlan-group-5,Fifth VLAN group", + "VLAN Group 6,vlan-group-6,Sixth VLAN group", ) diff --git a/netbox/tenancy/api/serializers.py b/netbox/tenancy/api/serializers.py index ec5e60a34..9c7a099e4 100644 --- a/netbox/tenancy/api/serializers.py +++ b/netbox/tenancy/api/serializers.py @@ -17,7 +17,7 @@ class TenantGroupSerializer(ValidatedModelSerializer): class Meta: model = TenantGroup - fields = ['id', 'name', 'slug', 'parent', 'tenant_count'] + fields = ['id', 'name', 'slug', 'parent', 'description', 'tenant_count'] class TenantSerializer(TaggitSerializer, CustomFieldModelSerializer): diff --git a/netbox/tenancy/filters.py b/netbox/tenancy/filters.py index 12e852879..40e35270e 100644 --- a/netbox/tenancy/filters.py +++ b/netbox/tenancy/filters.py @@ -27,7 +27,7 @@ class TenantGroupFilterSet(BaseFilterSet, NameSlugSearchFilterSet): class Meta: model = TenantGroup - fields = ['id', 'name', 'slug'] + fields = ['id', 'name', 'slug', 'description'] class TenantFilterSet(BaseFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet): diff --git a/netbox/tenancy/forms.py b/netbox/tenancy/forms.py index 9b8fc59da..3af848f3d 100644 --- a/netbox/tenancy/forms.py +++ b/netbox/tenancy/forms.py @@ -28,7 +28,7 @@ class TenantGroupForm(BootstrapMixin, forms.ModelForm): class Meta: model = TenantGroup fields = [ - 'parent', 'name', 'slug', + 'parent', 'name', 'slug', 'description', ] diff --git a/netbox/tenancy/models.py b/netbox/tenancy/models.py index 649905c17..5eeb687d7 100644 --- a/netbox/tenancy/models.py +++ b/netbox/tenancy/models.py @@ -34,8 +34,12 @@ class TenantGroup(MPTTModel, ChangeLoggedModel): null=True, db_index=True ) + description = models.CharField( + max_length=200, + blank=True + ) - csv_headers = ['name', 'slug', 'parent'] + csv_headers = ['name', 'slug', 'parent', 'description'] class Meta: ordering = ['name'] @@ -54,6 +58,7 @@ class TenantGroup(MPTTModel, ChangeLoggedModel): self.name, self.slug, self.parent.name if self.parent else '', + self.description, ) def to_objectchange(self, action): diff --git a/netbox/tenancy/tables.py b/netbox/tenancy/tables.py index adf73dc41..0eca7de71 100644 --- a/netbox/tenancy/tables.py +++ b/netbox/tenancy/tables.py @@ -53,7 +53,7 @@ class TenantGroupTable(BaseTable): class Meta(BaseTable.Meta): model = TenantGroup - fields = ('pk', 'name', 'tenant_count', 'slug', 'actions') + fields = ('pk', 'name', 'tenant_count', 'description', 'slug', 'actions') # diff --git a/netbox/tenancy/tests/test_filters.py b/netbox/tenancy/tests/test_filters.py index bb1ac889c..51deedde8 100644 --- a/netbox/tenancy/tests/test_filters.py +++ b/netbox/tenancy/tests/test_filters.py @@ -20,9 +20,9 @@ class TenantGroupTestCase(TestCase): tenantgroup.save() tenant_groups = ( - TenantGroup(name='Tenant Group 1', slug='tenant-group-1', parent=parent_tenant_groups[0]), - TenantGroup(name='Tenant Group 2', slug='tenant-group-2', parent=parent_tenant_groups[1]), - TenantGroup(name='Tenant Group 3', slug='tenant-group-3', parent=parent_tenant_groups[2]), + TenantGroup(name='Tenant Group 1', slug='tenant-group-1', parent=parent_tenant_groups[0], description='A'), + TenantGroup(name='Tenant Group 2', slug='tenant-group-2', parent=parent_tenant_groups[1], description='B'), + TenantGroup(name='Tenant Group 3', slug='tenant-group-3', parent=parent_tenant_groups[2], description='C'), ) for tenantgroup in tenant_groups: tenantgroup.save() @@ -40,6 +40,10 @@ class TenantGroupTestCase(TestCase): params = {'slug': ['tenant-group-1', 'tenant-group-2']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_description(self): + params = {'description': ['A', 'B']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_parent(self): parent_groups = TenantGroup.objects.filter(name__startswith='Parent')[:2] params = {'parent_id': [parent_groups[0].pk, parent_groups[1].pk]} diff --git a/netbox/tenancy/tests/test_views.py b/netbox/tenancy/tests/test_views.py index 5b47f8080..ca2c2633f 100644 --- a/netbox/tenancy/tests/test_views.py +++ b/netbox/tenancy/tests/test_views.py @@ -19,13 +19,14 @@ class TenantGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase): cls.form_data = { 'name': 'Tenant Group X', 'slug': 'tenant-group-x', + 'description': 'A new tenant group', } cls.csv_data = ( - "name,slug", - "Tenant Group 4,tenant-group-4", - "Tenant Group 5,tenant-group-5", - "Tenant Group 6,tenant-group-6", + "name,slug,description", + "Tenant Group 4,tenant-group-4,Fourth tenant group", + "Tenant Group 5,tenant-group-5,Fifth tenant group", + "Tenant Group 6,tenant-group-6,Sixth tenant group", ) diff --git a/netbox/virtualization/api/serializers.py b/netbox/virtualization/api/serializers.py index a294cdb6f..3cca95b22 100644 --- a/netbox/virtualization/api/serializers.py +++ b/netbox/virtualization/api/serializers.py @@ -24,7 +24,7 @@ class ClusterTypeSerializer(ValidatedModelSerializer): class Meta: model = ClusterType - fields = ['id', 'name', 'slug', 'cluster_count'] + fields = ['id', 'name', 'slug', 'description', 'cluster_count'] class ClusterGroupSerializer(ValidatedModelSerializer): @@ -32,7 +32,7 @@ class ClusterGroupSerializer(ValidatedModelSerializer): class Meta: model = ClusterGroup - fields = ['id', 'name', 'slug', 'cluster_count'] + fields = ['id', 'name', 'slug', 'description', 'cluster_count'] class ClusterSerializer(TaggitSerializer, CustomFieldModelSerializer): diff --git a/netbox/virtualization/filters.py b/netbox/virtualization/filters.py index 2c450e5a2..cf71b05e6 100644 --- a/netbox/virtualization/filters.py +++ b/netbox/virtualization/filters.py @@ -24,14 +24,14 @@ class ClusterTypeFilterSet(BaseFilterSet, NameSlugSearchFilterSet): class Meta: model = ClusterType - fields = ['id', 'name', 'slug'] + fields = ['id', 'name', 'slug', 'description'] class ClusterGroupFilterSet(BaseFilterSet, NameSlugSearchFilterSet): class Meta: model = ClusterGroup - fields = ['id', 'name', 'slug'] + fields = ['id', 'name', 'slug', 'description'] class ClusterFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet): diff --git a/netbox/virtualization/forms.py b/netbox/virtualization/forms.py index d110545c7..9d595b35b 100644 --- a/netbox/virtualization/forms.py +++ b/netbox/virtualization/forms.py @@ -31,7 +31,7 @@ class ClusterTypeForm(BootstrapMixin, forms.ModelForm): class Meta: model = ClusterType fields = [ - 'name', 'slug', + 'name', 'slug', 'description', ] @@ -56,7 +56,7 @@ class ClusterGroupForm(BootstrapMixin, forms.ModelForm): class Meta: model = ClusterGroup fields = [ - 'name', 'slug', + 'name', 'slug', 'description', ] diff --git a/netbox/virtualization/models.py b/netbox/virtualization/models.py index 13b181137..8eb94eea3 100644 --- a/netbox/virtualization/models.py +++ b/netbox/virtualization/models.py @@ -34,8 +34,12 @@ class ClusterType(ChangeLoggedModel): slug = models.SlugField( unique=True ) + description = models.CharField( + max_length=200, + blank=True + ) - csv_headers = ['name', 'slug'] + csv_headers = ['name', 'slug', 'description'] class Meta: ordering = ['name'] @@ -50,6 +54,7 @@ class ClusterType(ChangeLoggedModel): return ( self.name, self.slug, + self.description, ) @@ -68,8 +73,12 @@ class ClusterGroup(ChangeLoggedModel): slug = models.SlugField( unique=True ) + description = models.CharField( + max_length=200, + blank=True + ) - csv_headers = ['name', 'slug'] + csv_headers = ['name', 'slug', 'description'] class Meta: ordering = ['name'] @@ -84,6 +93,7 @@ class ClusterGroup(ChangeLoggedModel): return ( self.name, self.slug, + self.description, ) diff --git a/netbox/virtualization/tables.py b/netbox/virtualization/tables.py index fdb997dab..09c22ab8a 100644 --- a/netbox/virtualization/tables.py +++ b/netbox/virtualization/tables.py @@ -55,7 +55,7 @@ class ClusterTypeTable(BaseTable): class Meta(BaseTable.Meta): model = ClusterType - fields = ('pk', 'name', 'cluster_count', 'actions') + fields = ('pk', 'name', 'cluster_count', 'description', 'actions') # @@ -74,7 +74,7 @@ class ClusterGroupTable(BaseTable): class Meta(BaseTable.Meta): model = ClusterGroup - fields = ('pk', 'name', 'cluster_count', 'actions') + fields = ('pk', 'name', 'cluster_count', 'description', 'actions') # diff --git a/netbox/virtualization/tests/test_filters.py b/netbox/virtualization/tests/test_filters.py index db5935be9..e69e358d4 100644 --- a/netbox/virtualization/tests/test_filters.py +++ b/netbox/virtualization/tests/test_filters.py @@ -15,9 +15,9 @@ class ClusterTypeTestCase(TestCase): def setUpTestData(cls): cluster_types = ( - ClusterType(name='Cluster Type 1', slug='cluster-type-1'), - ClusterType(name='Cluster Type 2', slug='cluster-type-2'), - ClusterType(name='Cluster Type 3', slug='cluster-type-3'), + ClusterType(name='Cluster Type 1', slug='cluster-type-1', description='A'), + ClusterType(name='Cluster Type 2', slug='cluster-type-2', description='B'), + ClusterType(name='Cluster Type 3', slug='cluster-type-3', description='C'), ) ClusterType.objects.bulk_create(cluster_types) @@ -34,6 +34,10 @@ class ClusterTypeTestCase(TestCase): params = {'slug': ['cluster-type-1', 'cluster-type-2']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_description(self): + params = {'description': ['A', 'B']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + class ClusterGroupTestCase(TestCase): queryset = ClusterGroup.objects.all() @@ -43,9 +47,9 @@ class ClusterGroupTestCase(TestCase): def setUpTestData(cls): cluster_groups = ( - ClusterGroup(name='Cluster Group 1', slug='cluster-group-1'), - ClusterGroup(name='Cluster Group 2', slug='cluster-group-2'), - ClusterGroup(name='Cluster Group 3', slug='cluster-group-3'), + ClusterGroup(name='Cluster Group 1', slug='cluster-group-1', description='A'), + ClusterGroup(name='Cluster Group 2', slug='cluster-group-2', description='B'), + ClusterGroup(name='Cluster Group 3', slug='cluster-group-3', description='C'), ) ClusterGroup.objects.bulk_create(cluster_groups) @@ -62,6 +66,10 @@ class ClusterGroupTestCase(TestCase): params = {'slug': ['cluster-group-1', 'cluster-group-2']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_description(self): + params = {'description': ['A', 'B']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + class ClusterTestCase(TestCase): queryset = Cluster.objects.all() diff --git a/netbox/virtualization/tests/test_views.py b/netbox/virtualization/tests/test_views.py index 639908977..e7bb19285 100644 --- a/netbox/virtualization/tests/test_views.py +++ b/netbox/virtualization/tests/test_views.py @@ -23,13 +23,14 @@ class ClusterGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase): cls.form_data = { 'name': 'Cluster Group X', 'slug': 'cluster-group-x', + 'description': 'A new cluster group', } cls.csv_data = ( - "name,slug", - "Cluster Group 4,cluster-group-4", - "Cluster Group 5,cluster-group-5", - "Cluster Group 6,cluster-group-6", + "name,slug,description", + "Cluster Group 4,cluster-group-4,Fourth cluster group", + "Cluster Group 5,cluster-group-5,Fifth cluster group", + "Cluster Group 6,cluster-group-6,Sixth cluster group", ) @@ -48,13 +49,14 @@ class ClusterTypeTestCase(ViewTestCases.OrganizationalObjectViewTestCase): cls.form_data = { 'name': 'Cluster Type X', 'slug': 'cluster-type-x', + 'description': 'A new cluster type', } cls.csv_data = ( - "name,slug", - "Cluster Type 4,cluster-type-4", - "Cluster Type 5,cluster-type-5", - "Cluster Type 6,cluster-type-6", + "name,slug,description", + "Cluster Type 4,cluster-type-4,Fourth cluster type", + "Cluster Type 5,cluster-type-5,Fifth cluster type", + "Cluster Type 6,cluster-type-6,Sixth cluster type", ) From 9f5b138b0fd96de7d77424ab93a06d517c11b0ca Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 13 Mar 2020 16:35:36 -0400 Subject: [PATCH 16/19] Add migrations for description fields --- .../0008_standardize_description.py | 28 ++++++ .../0103_standardize_description.py | 98 +++++++++++++++++++ .../0039_standardize_description.py | 23 +++++ .../0036_standardize_description.py | 58 +++++++++++ .../0008_standardize_description.py | 18 ++++ .../0009_standardize_description.py | 23 +++++ .../0002_standardize_description.py | 18 ++++ .../0014_standardize_description.py | 23 +++++ 8 files changed, 289 insertions(+) create mode 100644 netbox/circuits/migrations/0008_standardize_description.py create mode 100644 netbox/dcim/migrations/0103_standardize_description.py create mode 100644 netbox/extras/migrations/0039_standardize_description.py create mode 100644 netbox/ipam/migrations/0036_standardize_description.py create mode 100644 netbox/secrets/migrations/0008_standardize_description.py create mode 100644 netbox/tenancy/migrations/0009_standardize_description.py create mode 100644 netbox/users/migrations/0002_standardize_description.py create mode 100644 netbox/virtualization/migrations/0014_standardize_description.py diff --git a/netbox/circuits/migrations/0008_standardize_description.py b/netbox/circuits/migrations/0008_standardize_description.py new file mode 100644 index 000000000..fecdee3ca --- /dev/null +++ b/netbox/circuits/migrations/0008_standardize_description.py @@ -0,0 +1,28 @@ +# Generated by Django 3.0.3 on 2020-03-13 20:27 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('circuits', '0007_circuit_add_description_squashed_0017_circuittype_description'), + ] + + operations = [ + migrations.AlterField( + model_name='circuit', + name='description', + field=models.CharField(blank=True, max_length=200), + ), + migrations.AlterField( + model_name='circuittermination', + name='description', + field=models.CharField(blank=True, max_length=200), + ), + migrations.AlterField( + model_name='circuittype', + name='description', + field=models.CharField(blank=True, max_length=200), + ), + ] diff --git a/netbox/dcim/migrations/0103_standardize_description.py b/netbox/dcim/migrations/0103_standardize_description.py new file mode 100644 index 000000000..eb4a2d760 --- /dev/null +++ b/netbox/dcim/migrations/0103_standardize_description.py @@ -0,0 +1,98 @@ +# Generated by Django 3.0.3 on 2020-03-13 20:27 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0102_nested_rackgroups_rebuild'), + ] + + operations = [ + migrations.AddField( + model_name='manufacturer', + name='description', + field=models.CharField(blank=True, max_length=200), + ), + migrations.AddField( + model_name='platform', + name='description', + field=models.CharField(blank=True, max_length=200), + ), + migrations.AddField( + model_name='rackgroup', + name='description', + field=models.CharField(blank=True, max_length=200), + ), + migrations.AddField( + model_name='region', + name='description', + field=models.CharField(blank=True, max_length=200), + ), + migrations.AlterField( + model_name='consoleport', + name='description', + field=models.CharField(blank=True, max_length=200), + ), + migrations.AlterField( + model_name='consoleserverport', + name='description', + field=models.CharField(blank=True, max_length=200), + ), + migrations.AlterField( + model_name='devicebay', + name='description', + field=models.CharField(blank=True, max_length=200), + ), + migrations.AlterField( + model_name='devicerole', + name='description', + field=models.CharField(blank=True, max_length=200), + ), + migrations.AlterField( + model_name='frontport', + name='description', + field=models.CharField(blank=True, max_length=200), + ), + migrations.AlterField( + model_name='interface', + name='description', + field=models.CharField(blank=True, max_length=200), + ), + migrations.AlterField( + model_name='inventoryitem', + name='description', + field=models.CharField(blank=True, max_length=200), + ), + migrations.AlterField( + model_name='poweroutlet', + name='description', + field=models.CharField(blank=True, max_length=200), + ), + migrations.AlterField( + model_name='powerport', + name='description', + field=models.CharField(blank=True, max_length=200), + ), + migrations.AlterField( + model_name='rackreservation', + name='description', + field=models.CharField(max_length=200), + ), + migrations.AlterField( + model_name='rackrole', + name='description', + field=models.CharField(blank=True, max_length=200), + ), + migrations.AlterField( + model_name='rearport', + name='description', + field=models.CharField(blank=True, max_length=200), + ), + migrations.AlterField( + model_name='site', + name='description', + field=models.CharField(blank=True, max_length=200), + ), + ] diff --git a/netbox/extras/migrations/0039_standardize_description.py b/netbox/extras/migrations/0039_standardize_description.py new file mode 100644 index 000000000..e56f3e1eb --- /dev/null +++ b/netbox/extras/migrations/0039_standardize_description.py @@ -0,0 +1,23 @@ +# Generated by Django 3.0.3 on 2020-03-13 20:27 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('extras', '0038_webhook_template_support'), + ] + + operations = [ + migrations.AlterField( + model_name='configcontext', + name='description', + field=models.CharField(blank=True, max_length=200), + ), + migrations.AlterField( + model_name='customfield', + name='description', + field=models.CharField(blank=True, max_length=200), + ), + ] diff --git a/netbox/ipam/migrations/0036_standardize_description.py b/netbox/ipam/migrations/0036_standardize_description.py new file mode 100644 index 000000000..b0da0635a --- /dev/null +++ b/netbox/ipam/migrations/0036_standardize_description.py @@ -0,0 +1,58 @@ +# Generated by Django 3.0.3 on 2020-03-13 20:27 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('ipam', '0035_drop_ip_family'), + ] + + operations = [ + migrations.AddField( + model_name='rir', + name='description', + field=models.CharField(blank=True, max_length=200), + ), + migrations.AddField( + model_name='vlangroup', + name='description', + field=models.CharField(blank=True, max_length=200), + ), + migrations.AlterField( + model_name='aggregate', + name='description', + field=models.CharField(blank=True, max_length=200), + ), + migrations.AlterField( + model_name='ipaddress', + name='description', + field=models.CharField(blank=True, max_length=200), + ), + migrations.AlterField( + model_name='prefix', + name='description', + field=models.CharField(blank=True, max_length=200), + ), + migrations.AlterField( + model_name='role', + name='description', + field=models.CharField(blank=True, max_length=200), + ), + migrations.AlterField( + model_name='service', + name='description', + field=models.CharField(blank=True, max_length=200), + ), + migrations.AlterField( + model_name='vlan', + name='description', + field=models.CharField(blank=True, max_length=200), + ), + migrations.AlterField( + model_name='vrf', + name='description', + field=models.CharField(blank=True, max_length=200), + ), + ] diff --git a/netbox/secrets/migrations/0008_standardize_description.py b/netbox/secrets/migrations/0008_standardize_description.py new file mode 100644 index 000000000..f64c0ba55 --- /dev/null +++ b/netbox/secrets/migrations/0008_standardize_description.py @@ -0,0 +1,18 @@ +# Generated by Django 3.0.3 on 2020-03-13 20:27 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('secrets', '0007_secretrole_description'), + ] + + operations = [ + migrations.AlterField( + model_name='secretrole', + name='description', + field=models.CharField(blank=True, max_length=200), + ), + ] diff --git a/netbox/tenancy/migrations/0009_standardize_description.py b/netbox/tenancy/migrations/0009_standardize_description.py new file mode 100644 index 000000000..0f65ced04 --- /dev/null +++ b/netbox/tenancy/migrations/0009_standardize_description.py @@ -0,0 +1,23 @@ +# Generated by Django 3.0.3 on 2020-03-13 20:27 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('tenancy', '0008_nested_tenantgroups_rebuild'), + ] + + operations = [ + migrations.AddField( + model_name='tenantgroup', + name='description', + field=models.CharField(blank=True, max_length=200), + ), + migrations.AlterField( + model_name='tenant', + name='description', + field=models.CharField(blank=True, max_length=200), + ), + ] diff --git a/netbox/users/migrations/0002_standardize_description.py b/netbox/users/migrations/0002_standardize_description.py new file mode 100644 index 000000000..8916edcbd --- /dev/null +++ b/netbox/users/migrations/0002_standardize_description.py @@ -0,0 +1,18 @@ +# Generated by Django 3.0.3 on 2020-03-13 20:27 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0001_api_tokens_squashed_0003_token_permissions'), + ] + + operations = [ + migrations.AlterField( + model_name='token', + name='description', + field=models.CharField(blank=True, max_length=200), + ), + ] diff --git a/netbox/virtualization/migrations/0014_standardize_description.py b/netbox/virtualization/migrations/0014_standardize_description.py new file mode 100644 index 000000000..e02655bb7 --- /dev/null +++ b/netbox/virtualization/migrations/0014_standardize_description.py @@ -0,0 +1,23 @@ +# Generated by Django 3.0.3 on 2020-03-13 20:27 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('virtualization', '0013_deterministic_ordering'), + ] + + operations = [ + migrations.AddField( + model_name='clustergroup', + name='description', + field=models.CharField(blank=True, max_length=200), + ), + migrations.AddField( + model_name='clustertype', + name='description', + field=models.CharField(blank=True, max_length=200), + ), + ] From 1a8554fd32179c9eb96487dc267b92f5c1dac0d7 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 13 Mar 2020 16:42:47 -0400 Subject: [PATCH 17/19] Changelog for #4078 --- docs/release-notes/version-2.8.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/docs/release-notes/version-2.8.md b/docs/release-notes/version-2.8.md index 60c111187..2473de067 100644 --- a/docs/release-notes/version-2.8.md +++ b/docs/release-notes/version-2.8.md @@ -20,13 +20,23 @@ If further customization of remote authentication is desired (for instance, if y * [#1754](https://github.com/netbox-community/netbox/issues/1754) - Added support for nested rack groups * [#3939](https://github.com/netbox-community/netbox/issues/3939) - Added support for nested tenant groups +* [#4078](https://github.com/netbox-community/netbox/issues/4078) - Standardized description fields across all models * [#4195](https://github.com/netbox-community/netbox/issues/4195) - Enabled application logging (see [logging configuration](../configuration/optional-settings.md#logging)) ### API Changes * The `_choices` API endpoints have been removed. Instead, use an `OPTIONS` request to a model's endpoint to view the available values for all fields. ([#3416](https://github.com/netbox-community/netbox/issues/3416)) * The `id__in` filter has been removed. Use the format `?id=1&id=2` instead. ([#4313](https://github.com/netbox-community/netbox/issues/4313)) +* dcim.Manufacturer: Added a `description` field +* dcim.Platform: Added a `description` field * dcim.Rack: The `/api/dcim/racks//units/` endpoint has been replaced with `/api/dcim/racks//elevation/`. +* dcim.RackGroup: Added a `description` field +* dcim.Region: Added a `description` field +* ipam.RIR: Added a `description` field +* ipam.VLANGroup: Added a `description` field +* tenancy.TenantGroup: Added a `description` field +* virtualization.ClusterGroup: Added a `description` field +* virtualization.ClusterType: Added a `description` field ### Other Changes From d4f6909859a5f32e49329c484ea730fb455714d1 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 13 Mar 2020 17:00:00 -0400 Subject: [PATCH 18/19] Rename Tag.comments to description --- docs/release-notes/version-2.8.md | 1 + netbox/extras/api/serializers.py | 2 +- netbox/extras/forms.py | 9 +++++--- .../extras/migrations/0040_tag_description.py | 23 +++++++++++++++++++ netbox/extras/models.py | 4 ++-- netbox/extras/tables.py | 2 +- netbox/templates/extras/tag.html | 17 ++++---------- netbox/templates/extras/tag_edit.html | 7 +----- 8 files changed, 40 insertions(+), 25 deletions(-) create mode 100644 netbox/extras/migrations/0040_tag_description.py diff --git a/docs/release-notes/version-2.8.md b/docs/release-notes/version-2.8.md index 2473de067..f42625ef2 100644 --- a/docs/release-notes/version-2.8.md +++ b/docs/release-notes/version-2.8.md @@ -32,6 +32,7 @@ If further customization of remote authentication is desired (for instance, if y * dcim.Rack: The `/api/dcim/racks//units/` endpoint has been replaced with `/api/dcim/racks//elevation/`. * dcim.RackGroup: Added a `description` field * dcim.Region: Added a `description` field +* extras.Tag: Renamed `comments` to `description`; truncated length to 200 characters; removed Markdown rendering * ipam.RIR: Added a `description` field * ipam.VLANGroup: Added a `description` field * tenancy.TenantGroup: Added a `description` field diff --git a/netbox/extras/api/serializers.py b/netbox/extras/api/serializers.py index 40606ed8e..f3c05d37e 100644 --- a/netbox/extras/api/serializers.py +++ b/netbox/extras/api/serializers.py @@ -91,7 +91,7 @@ class TagSerializer(ValidatedModelSerializer): class Meta: model = Tag - fields = ['id', 'name', 'slug', 'color', 'comments', 'tagged_items'] + fields = ['id', 'name', 'slug', 'color', 'description', 'tagged_items'] # diff --git a/netbox/extras/forms.py b/netbox/extras/forms.py index b792ec484..85d19756a 100644 --- a/netbox/extras/forms.py +++ b/netbox/extras/forms.py @@ -144,12 +144,11 @@ class CustomFieldFilterForm(forms.Form): class TagForm(BootstrapMixin, forms.ModelForm): slug = SlugField() - comments = CommentField() class Meta: model = Tag fields = [ - 'name', 'slug', 'color', 'comments' + 'name', 'slug', 'color', 'description' ] @@ -181,9 +180,13 @@ class TagBulkEditForm(BootstrapMixin, BulkEditForm): required=False, widget=ColorSelect() ) + description = forms.CharField( + max_length=200, + required=False + ) class Meta: - nullable_fields = [] + nullable_fields = ['description'] # diff --git a/netbox/extras/migrations/0040_tag_description.py b/netbox/extras/migrations/0040_tag_description.py new file mode 100644 index 000000000..9d17b205f --- /dev/null +++ b/netbox/extras/migrations/0040_tag_description.py @@ -0,0 +1,23 @@ +# Generated by Django 3.0.3 on 2020-03-13 20:46 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('extras', '0039_standardize_description'), + ] + + operations = [ + migrations.AlterField( + model_name='tag', + name='comments', + field=models.CharField(blank=True, max_length=200), + ), + migrations.RenameField( + model_name='tag', + old_name='comments', + new_name='description', + ), + ] diff --git a/netbox/extras/models.py b/netbox/extras/models.py index 0de5ec8e5..33b25685c 100644 --- a/netbox/extras/models.py +++ b/netbox/extras/models.py @@ -1051,9 +1051,9 @@ class Tag(TagBase, ChangeLoggedModel): color = ColorField( default='9e9e9e' ) - comments = models.TextField( + description = models.CharField( + max_length=200, blank=True, - default='' ) def get_absolute_url(self): diff --git a/netbox/extras/tables.py b/netbox/extras/tables.py index 08c5ed471..b145824c6 100644 --- a/netbox/extras/tables.py +++ b/netbox/extras/tables.py @@ -77,7 +77,7 @@ class TagTable(BaseTable): class Meta(BaseTable.Meta): model = Tag - fields = ('pk', 'name', 'items', 'slug', 'color', 'actions') + fields = ('pk', 'name', 'items', 'slug', 'color', 'description', 'actions') class TaggedItemTable(BaseTable): diff --git a/netbox/templates/extras/tag.html b/netbox/templates/extras/tag.html index 64e5bbebd..d87aec3f7 100644 --- a/netbox/templates/extras/tag.html +++ b/netbox/templates/extras/tag.html @@ -82,20 +82,13 @@   + + Description + + {{ tag.description }} + -
-
- Comments -
-
- {% if tag.comments %} - {{ tag.comments|render_markdown }} - {% else %} - None - {% endif %} -
-
{% include 'panel_table.html' with table=items_table heading='Tagged Objects' %} diff --git a/netbox/templates/extras/tag_edit.html b/netbox/templates/extras/tag_edit.html index 800db1d26..87b9a2e53 100644 --- a/netbox/templates/extras/tag_edit.html +++ b/netbox/templates/extras/tag_edit.html @@ -8,12 +8,7 @@ {% render_field form.name %} {% render_field form.slug %} {% render_field form.color %} -
- -
-
Comments
-
- {% render_field form.comments %} + {% render_field form.description %}
{% endblock %} From 9466802a95cbaa773d97627b0a3e4af4601dff5b Mon Sep 17 00:00:00 2001 From: John Anderson Date: Sat, 14 Mar 2020 03:03:22 -0400 Subject: [PATCH 19/19] closes #4368 - extras features model registration --- netbox/circuits/models.py | 3 + netbox/dcim/models/__init__.py | 12 ++ netbox/dcim/models/device_components.py | 10 + netbox/extras/api/serializers.py | 5 +- netbox/extras/constants.py | 181 +----------------- .../0039_update_features_content_types.py | 40 ++++ netbox/extras/models.py | 11 +- netbox/extras/tests/test_api.py | 4 +- netbox/extras/tests/test_filters.py | 6 +- netbox/extras/utils.py | 68 +++++++ netbox/extras/webhooks.py | 3 +- netbox/ipam/models.py | 7 + netbox/secrets/models.py | 2 + netbox/tenancy/models.py | 2 + netbox/virtualization/models.py | 3 + 15 files changed, 172 insertions(+), 185 deletions(-) create mode 100644 netbox/extras/migrations/0039_update_features_content_types.py diff --git a/netbox/circuits/models.py b/netbox/circuits/models.py index 812eaa79e..919fc45a5 100644 --- a/netbox/circuits/models.py +++ b/netbox/circuits/models.py @@ -7,6 +7,7 @@ from dcim.constants import CONNECTION_STATUS_CHOICES from dcim.fields import ASNField from dcim.models import CableTermination from extras.models import CustomFieldModel, ObjectChange, TaggedItem +from extras.utils import extras_features from utilities.models import ChangeLoggedModel from utilities.utils import serialize_object from .choices import * @@ -21,6 +22,7 @@ __all__ = ( ) +@extras_features('custom_fields', 'custom_links', 'graphs', 'export_templates', 'webhooks') class Provider(ChangeLoggedModel, CustomFieldModel): """ Each Circuit belongs to a Provider. This is usually a telecommunications company or similar organization. This model @@ -131,6 +133,7 @@ class CircuitType(ChangeLoggedModel): ) +@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') class Circuit(ChangeLoggedModel, CustomFieldModel): """ A communications circuit connects two points. Each Circuit belongs to a Provider; Providers may have multiple diff --git a/netbox/dcim/models/__init__.py b/netbox/dcim/models/__init__.py index 94e8a2391..63c3044c1 100644 --- a/netbox/dcim/models/__init__.py +++ b/netbox/dcim/models/__init__.py @@ -21,6 +21,7 @@ from dcim.constants import * from dcim.fields import ASNField from dcim.elevations import RackElevationSVG from extras.models import ConfigContextModel, CustomFieldModel, ObjectChange, TaggedItem +from extras.utils import extras_features from utilities.fields import ColorField, NaturalOrderingField from utilities.models import ChangeLoggedModel from utilities.utils import serialize_object, to_meters @@ -75,6 +76,7 @@ __all__ = ( # Regions # +@extras_features('export_templates', 'webhooks') class Region(MPTTModel, ChangeLoggedModel): """ Sites can be grouped within geographic Regions. @@ -133,6 +135,7 @@ class Region(MPTTModel, ChangeLoggedModel): # Sites # +@extras_features('custom_fields', 'custom_links', 'graphs', 'export_templates', 'webhooks') class Site(ChangeLoggedModel, CustomFieldModel): """ A Site represents a geographic location within a network; typically a building or campus. The optional facility @@ -283,6 +286,7 @@ class Site(ChangeLoggedModel, CustomFieldModel): # Racks # +@extras_features('export_templates') class RackGroup(ChangeLoggedModel): """ Racks can be grouped as subsets within a Site. The scope of a group will depend on how Sites are defined. For @@ -359,6 +363,7 @@ class RackRole(ChangeLoggedModel): ) +@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') class Rack(ChangeLoggedModel, CustomFieldModel): """ Devices are housed within Racks. Each rack has a defined height measured in rack units, and a front and rear face. @@ -823,6 +828,7 @@ class RackReservation(ChangeLoggedModel): # Device Types # +@extras_features('export_templates', 'webhooks') class Manufacturer(ChangeLoggedModel): """ A Manufacturer represents a company which produces hardware devices; for example, Juniper or Dell. @@ -853,6 +859,7 @@ class Manufacturer(ChangeLoggedModel): ) +@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') class DeviceType(ChangeLoggedModel, CustomFieldModel): """ A DeviceType represents a particular make (Manufacturer) and model of device. It specifies rack height and depth, as @@ -1196,6 +1203,7 @@ class Platform(ChangeLoggedModel): ) +@extras_features('custom_fields', 'custom_links', 'graphs', 'export_templates', 'webhooks') class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel): """ A Device represents a piece of physical hardware mounted within a Rack. Each Device is assigned a DeviceType, @@ -1631,6 +1639,7 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel): # Virtual chassis # +@extras_features('export_templates', 'webhooks') class VirtualChassis(ChangeLoggedModel): """ A collection of Devices which operate with a shared control plane (e.g. a switch stack). @@ -1697,6 +1706,7 @@ class VirtualChassis(ChangeLoggedModel): # Power # +@extras_features('custom_links', 'export_templates', 'webhooks') class PowerPanel(ChangeLoggedModel): """ A distribution point for electrical power; e.g. a data center RPP. @@ -1743,6 +1753,7 @@ class PowerPanel(ChangeLoggedModel): )) +@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') class PowerFeed(ChangeLoggedModel, CableTermination, CustomFieldModel): """ An electrical circuit delivered from a PowerPanel. @@ -1904,6 +1915,7 @@ class PowerFeed(ChangeLoggedModel, CableTermination, CustomFieldModel): # Cables # +@extras_features('custom_links', 'export_templates', 'webhooks') class Cable(ChangeLoggedModel): """ A physical connection between two endpoints. diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index 9a3d608d7..806d652b7 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -11,6 +11,7 @@ from dcim.constants import * from dcim.exceptions import LoopDetected from dcim.fields import MACAddressField from extras.models import ObjectChange, TaggedItem +from extras.utils import extras_features from utilities.fields import NaturalOrderingField from utilities.ordering import naturalize_interface from utilities.utils import serialize_object @@ -169,6 +170,7 @@ class CableTermination(models.Model): # Console ports # +@extras_features('export_templates', 'webhooks') class ConsolePort(CableTermination, ComponentModel): """ A physical console port within a Device. ConsolePorts connect to ConsoleServerPorts. @@ -229,6 +231,7 @@ class ConsolePort(CableTermination, ComponentModel): # Console server ports # +@extras_features('webhooks') class ConsoleServerPort(CableTermination, ComponentModel): """ A physical port within a Device (typically a designated console server) which provides access to ConsolePorts. @@ -282,6 +285,7 @@ class ConsoleServerPort(CableTermination, ComponentModel): # Power ports # +@extras_features('export_templates', 'webhooks') class PowerPort(CableTermination, ComponentModel): """ A physical power supply (intake) port within a Device. PowerPorts connect to PowerOutlets. @@ -443,6 +447,7 @@ class PowerPort(CableTermination, ComponentModel): # Power outlets # +@extras_features('webhooks') class PowerOutlet(CableTermination, ComponentModel): """ A physical power outlet (output) within a Device which provides power to a PowerPort. @@ -519,6 +524,7 @@ class PowerOutlet(CableTermination, ComponentModel): # Interfaces # +@extras_features('graphs', 'export_templates', 'webhooks') class Interface(CableTermination, ComponentModel): """ A network interface within a Device or VirtualMachine. A physical Interface can connect to exactly one other @@ -792,6 +798,7 @@ class Interface(CableTermination, ComponentModel): # Pass-through ports # +@extras_features('webhooks') class FrontPort(CableTermination, ComponentModel): """ A pass-through port on the front of a Device. @@ -864,6 +871,7 @@ class FrontPort(CableTermination, ComponentModel): ) +@extras_features('webhooks') class RearPort(CableTermination, ComponentModel): """ A pass-through port on the rear of a Device. @@ -915,6 +923,7 @@ class RearPort(CableTermination, ComponentModel): # Device bays # +@extras_features('webhooks') class DeviceBay(ComponentModel): """ An empty space within a Device which can house a child device @@ -989,6 +998,7 @@ class DeviceBay(ComponentModel): # Inventory items # +@extras_features('export_templates', 'webhooks') class InventoryItem(ComponentModel): """ An InventoryItem represents a serialized piece of hardware within a Device, such as a line card or power supply. diff --git a/netbox/extras/api/serializers.py b/netbox/extras/api/serializers.py index 40606ed8e..567beedb0 100644 --- a/netbox/extras/api/serializers.py +++ b/netbox/extras/api/serializers.py @@ -13,6 +13,7 @@ from extras.constants import * from extras.models import ( ConfigContext, ExportTemplate, Graph, ImageAttachment, ObjectChange, ReportResult, Tag, ) +from extras.utils import FeatureQuerySet from tenancy.api.nested_serializers import NestedTenantSerializer, NestedTenantGroupSerializer from tenancy.models import Tenant, TenantGroup from users.api.nested_serializers import NestedUserSerializer @@ -31,7 +32,7 @@ from .nested_serializers import * class GraphSerializer(ValidatedModelSerializer): type = ContentTypeField( - queryset=ContentType.objects.filter(GRAPH_MODELS), + queryset=ContentType.objects.filter(FeatureQuerySet('graphs').get_queryset()), ) class Meta: @@ -67,7 +68,7 @@ class RenderedGraphSerializer(serializers.ModelSerializer): class ExportTemplateSerializer(ValidatedModelSerializer): content_type = ContentTypeField( - queryset=ContentType.objects.filter(EXPORTTEMPLATE_MODELS), + queryset=ContentType.objects.filter(FeatureQuerySet('export_templates').get_queryset()), ) template_language = ChoiceField( choices=TemplateLanguageChoices, diff --git a/netbox/extras/constants.py b/netbox/extras/constants.py index 7bb026d34..3b6c044dc 100644 --- a/netbox/extras/constants.py +++ b/netbox/extras/constants.py @@ -1,129 +1,3 @@ -from django.db.models import Q - - -# Models which support custom fields -CUSTOMFIELD_MODELS = Q( - Q(app_label='circuits', model__in=[ - 'circuit', - 'provider', - ]) | - Q(app_label='dcim', model__in=[ - 'device', - 'devicetype', - 'powerfeed', - 'rack', - 'site', - ]) | - Q(app_label='ipam', model__in=[ - 'aggregate', - 'ipaddress', - 'prefix', - 'service', - 'vlan', - 'vrf', - ]) | - Q(app_label='secrets', model__in=[ - 'secret', - ]) | - Q(app_label='tenancy', model__in=[ - 'tenant', - ]) | - Q(app_label='virtualization', model__in=[ - 'cluster', - 'virtualmachine', - ]) -) - -# Custom links -CUSTOMLINK_MODELS = Q( - Q(app_label='circuits', model__in=[ - 'circuit', - 'provider', - ]) | - Q(app_label='dcim', model__in=[ - 'cable', - 'device', - 'devicetype', - 'powerpanel', - 'powerfeed', - 'rack', - 'site', - ]) | - Q(app_label='ipam', model__in=[ - 'aggregate', - 'ipaddress', - 'prefix', - 'service', - 'vlan', - 'vrf', - ]) | - Q(app_label='secrets', model__in=[ - 'secret', - ]) | - Q(app_label='tenancy', model__in=[ - 'tenant', - ]) | - Q(app_label='virtualization', model__in=[ - 'cluster', - 'virtualmachine', - ]) -) - -# Models which can have Graphs associated with them -GRAPH_MODELS = Q( - Q(app_label='circuits', model__in=[ - 'provider', - ]) | - Q(app_label='dcim', model__in=[ - 'device', - 'interface', - 'site', - ]) -) - -# Models which support export templates -EXPORTTEMPLATE_MODELS = Q( - Q(app_label='circuits', model__in=[ - 'circuit', - 'provider', - ]) | - Q(app_label='dcim', model__in=[ - 'cable', - 'consoleport', - 'device', - 'devicetype', - 'interface', - 'inventoryitem', - 'manufacturer', - 'powerpanel', - 'powerport', - 'powerfeed', - 'rack', - 'rackgroup', - 'region', - 'site', - 'virtualchassis', - ]) | - Q(app_label='ipam', model__in=[ - 'aggregate', - 'ipaddress', - 'prefix', - 'service', - 'vlan', - 'vrf', - ]) | - Q(app_label='secrets', model__in=[ - 'secret', - ]) | - Q(app_label='tenancy', model__in=[ - 'tenant', - ]) | - Q(app_label='virtualization', model__in=[ - 'cluster', - 'virtualmachine', - ]) -) - # Report logging levels LOG_DEFAULT = 0 LOG_SUCCESS = 10 @@ -138,51 +12,14 @@ LOG_LEVEL_CODES = { LOG_FAILURE: 'failure', } +# Webhook content types HTTP_CONTENT_TYPE_JSON = 'application/json' -# Models which support registered webhooks -WEBHOOK_MODELS = Q( - Q(app_label='circuits', model__in=[ - 'circuit', - 'provider', - ]) | - Q(app_label='dcim', model__in=[ - 'cable', - 'consoleport', - 'consoleserverport', - 'device', - 'devicebay', - 'devicetype', - 'frontport', - 'interface', - 'inventoryitem', - 'manufacturer', - 'poweroutlet', - 'powerpanel', - 'powerport', - 'powerfeed', - 'rack', - 'rearport', - 'region', - 'site', - 'virtualchassis', - ]) | - Q(app_label='ipam', model__in=[ - 'aggregate', - 'ipaddress', - 'prefix', - 'service', - 'vlan', - 'vrf', - ]) | - Q(app_label='secrets', model__in=[ - 'secret', - ]) | - Q(app_label='tenancy', model__in=[ - 'tenant', - ]) | - Q(app_label='virtualization', model__in=[ - 'cluster', - 'virtualmachine', - ]) -) +# Registerable extras features +EXTRAS_FEATURES = [ + 'custom_fields', + 'custom_links', + 'graphs', + 'export_templates', + 'webhooks' +] diff --git a/netbox/extras/migrations/0039_update_features_content_types.py b/netbox/extras/migrations/0039_update_features_content_types.py new file mode 100644 index 000000000..c347b1198 --- /dev/null +++ b/netbox/extras/migrations/0039_update_features_content_types.py @@ -0,0 +1,40 @@ +# Generated by Django 2.2.11 on 2020-03-14 06:50 + +from django.db import migrations, models +import django.db.models.deletion +import extras.utils + + +class Migration(migrations.Migration): + + dependencies = [ + ('extras', '0038_webhook_template_support'), + ] + + operations = [ + migrations.AlterField( + model_name='customfield', + name='obj_type', + field=models.ManyToManyField(limit_choices_to=extras.utils.FeatureQuerySet('custom_fields'), related_name='custom_fields', to='contenttypes.ContentType'), + ), + migrations.AlterField( + model_name='customlink', + name='content_type', + field=models.ForeignKey(limit_choices_to=extras.utils.FeatureQuerySet('custom_links'), on_delete=django.db.models.deletion.CASCADE, to='contenttypes.ContentType'), + ), + migrations.AlterField( + model_name='exporttemplate', + name='content_type', + field=models.ForeignKey(limit_choices_to=extras.utils.FeatureQuerySet('export_templates'), on_delete=django.db.models.deletion.CASCADE, to='contenttypes.ContentType'), + ), + migrations.AlterField( + model_name='graph', + name='type', + field=models.ForeignKey(limit_choices_to=extras.utils.FeatureQuerySet('graphs'), on_delete=django.db.models.deletion.CASCADE, to='contenttypes.ContentType'), + ), + migrations.AlterField( + model_name='webhook', + name='obj_type', + field=models.ManyToManyField(limit_choices_to=extras.utils.FeatureQuerySet('webhooks'), related_name='webhooks', to='contenttypes.ContentType'), + ), + ] diff --git a/netbox/extras/models.py b/netbox/extras/models.py index d81fbeab9..21809c35b 100644 --- a/netbox/extras/models.py +++ b/netbox/extras/models.py @@ -22,6 +22,7 @@ from utilities.utils import deepmerge, render_jinja2 from .choices import * from .constants import * from .querysets import ConfigContextQuerySet +from .utils import FeatureQuerySet __all__ = ( @@ -58,7 +59,7 @@ class Webhook(models.Model): to=ContentType, related_name='webhooks', verbose_name='Object types', - limit_choices_to=WEBHOOK_MODELS, + limit_choices_to=FeatureQuerySet('webhooks'), help_text="The object(s) to which this Webhook applies." ) name = models.CharField( @@ -223,7 +224,7 @@ class CustomField(models.Model): to=ContentType, related_name='custom_fields', verbose_name='Object(s)', - limit_choices_to=CUSTOMFIELD_MODELS, + limit_choices_to=FeatureQuerySet('custom_fields'), help_text='The object(s) to which this field applies.' ) type = models.CharField( @@ -470,7 +471,7 @@ class CustomLink(models.Model): content_type = models.ForeignKey( to=ContentType, on_delete=models.CASCADE, - limit_choices_to=CUSTOMLINK_MODELS + limit_choices_to=FeatureQuerySet('custom_links') ) name = models.CharField( max_length=100, @@ -518,7 +519,7 @@ class Graph(models.Model): type = models.ForeignKey( to=ContentType, on_delete=models.CASCADE, - limit_choices_to=GRAPH_MODELS + limit_choices_to=FeatureQuerySet('graphs') ) weight = models.PositiveSmallIntegerField( default=1000 @@ -581,7 +582,7 @@ class ExportTemplate(models.Model): content_type = models.ForeignKey( to=ContentType, on_delete=models.CASCADE, - limit_choices_to=EXPORTTEMPLATE_MODELS + limit_choices_to=FeatureQuerySet('export_templates') ) name = models.CharField( max_length=100 diff --git a/netbox/extras/tests/test_api.py b/netbox/extras/tests/test_api.py index 3e6e43789..773314942 100644 --- a/netbox/extras/tests/test_api.py +++ b/netbox/extras/tests/test_api.py @@ -8,9 +8,9 @@ from rest_framework import status from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Platform, Rack, RackGroup, RackRole, Region, Site from extras.api.views import ScriptViewSet from extras.choices import * -from extras.constants import GRAPH_MODELS from extras.models import ConfigContext, Graph, ExportTemplate, Tag from extras.scripts import BooleanVar, IntegerVar, Script, StringVar +from extras.utils import FeatureQuerySet from tenancy.models import Tenant, TenantGroup from utilities.testing import APITestCase, choices_to_dict @@ -35,7 +35,7 @@ class AppTest(APITestCase): self.assertEqual(choices_to_dict(response.data.get('export-template:template_language')), TemplateLanguageChoices.as_dict()) # Graph - content_types = ContentType.objects.filter(GRAPH_MODELS) + content_types = ContentType.objects.filter(FeatureQuerySet('graphs').get_queryset()) graph_type_choices = { "{}.{}".format(ct.app_label, ct.model): ct.name for ct in content_types } diff --git a/netbox/extras/tests/test_filters.py b/netbox/extras/tests/test_filters.py index ab559cf73..e507e4034 100644 --- a/netbox/extras/tests/test_filters.py +++ b/netbox/extras/tests/test_filters.py @@ -3,8 +3,8 @@ from django.test import TestCase from dcim.models import DeviceRole, Platform, Region, Site from extras.choices import * -from extras.constants import GRAPH_MODELS from extras.filters import * +from extras.utils import FeatureQuerySet from extras.models import ConfigContext, ExportTemplate, Graph from tenancy.models import Tenant, TenantGroup from virtualization.models import Cluster, ClusterGroup, ClusterType @@ -18,7 +18,7 @@ class GraphTestCase(TestCase): def setUpTestData(cls): # Get the first three available types - content_types = ContentType.objects.filter(GRAPH_MODELS)[:3] + content_types = ContentType.objects.filter(FeatureQuerySet('graphs').get_queryset())[:3] graphs = ( Graph(name='Graph 1', type=content_types[0], template_language=TemplateLanguageChoices.LANGUAGE_DJANGO, source='http://example.com/1'), @@ -32,7 +32,7 @@ class GraphTestCase(TestCase): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) def test_type(self): - content_type = ContentType.objects.filter(GRAPH_MODELS).first() + content_type = ContentType.objects.filter(FeatureQuerySet('graphs').get_queryset()).first() params = {'type': content_type.pk} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) diff --git a/netbox/extras/utils.py b/netbox/extras/utils.py index ca3a72526..5edf3f562 100644 --- a/netbox/extras/utils.py +++ b/netbox/extras/utils.py @@ -1,6 +1,12 @@ +import collections + +from django.db.models import Q +from django.utils.deconstruct import deconstructible from taggit.managers import _TaggableManager from utilities.querysets import DummyQuerySet +from extras.constants import EXTRAS_FEATURES + def is_taggable(obj): """ @@ -13,3 +19,65 @@ def is_taggable(obj): if isinstance(obj.tags, DummyQuerySet): return True return False + + +# +# Dynamic feature registration +# + +class Registry: + """ + The registry is a place to hook into for data storage across components + """ + + def add_store(self, store_name, initial_value=None): + """ + Given the name of some new data parameter and an optional initial value, setup the registry store + """ + if not hasattr(Registry, store_name): + setattr(Registry, store_name, initial_value) + + +registry = Registry() + + +@deconstructible +class FeatureQuerySet: + """ + Helper class that delays evaluation of the registry contents for the functionaility store + until it has been populated. + """ + + def __init__(self, feature): + self.feature = feature + + def __call__(self): + return self.get_queryset() + + def get_queryset(self): + """ + Given an extras feature, return a Q object for content type lookup + """ + query = Q() + for app_label, models in registry.model_feature_store[self.feature].items(): + query |= Q(app_label=app_label, model__in=models) + + return query + + +registry.add_store('model_feature_store', {f: collections.defaultdict(list) for f in EXTRAS_FEATURES}) + + +def extras_features(*features): + """ + Decorator used to register extras provided features to a model + """ + def wrapper(model_class): + for feature in features: + if feature in EXTRAS_FEATURES: + app_label, model_name = model_class._meta.label_lower.split('.') + registry.model_feature_store[feature][app_label].append(model_name) + else: + raise ValueError('{} is not a valid extras feature!'.format(feature)) + return model_class + return wrapper diff --git a/netbox/extras/webhooks.py b/netbox/extras/webhooks.py index 8b20641d7..f1a3391a0 100644 --- a/netbox/extras/webhooks.py +++ b/netbox/extras/webhooks.py @@ -8,6 +8,7 @@ from extras.models import Webhook from utilities.api import get_serializer_for_model from .choices import * from .constants import * +from .utils import FeatureQuerySet def generate_signature(request_body, secret): @@ -29,7 +30,7 @@ def enqueue_webhooks(instance, user, request_id, action): """ obj_type = ContentType.objects.get_for_model(instance.__class__) - webhook_models = ContentType.objects.filter(WEBHOOK_MODELS) + webhook_models = ContentType.objects.filter(FeatureQuerySet('webhooks').get_queryset()) if obj_type not in webhook_models: return diff --git a/netbox/ipam/models.py b/netbox/ipam/models.py index 4cbcb4bf0..0ffce07cf 100644 --- a/netbox/ipam/models.py +++ b/netbox/ipam/models.py @@ -10,6 +10,7 @@ from taggit.managers import TaggableManager from dcim.models import Device, Interface from extras.models import CustomFieldModel, ObjectChange, TaggedItem +from extras.utils import extras_features from utilities.models import ChangeLoggedModel from utilities.utils import serialize_object from virtualization.models import VirtualMachine @@ -34,6 +35,7 @@ __all__ = ( ) +@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') class VRF(ChangeLoggedModel, CustomFieldModel): """ A virtual routing and forwarding (VRF) table represents a discrete layer three forwarding domain (e.g. a routing @@ -145,6 +147,7 @@ class RIR(ChangeLoggedModel): ) +@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') class Aggregate(ChangeLoggedModel, CustomFieldModel): """ An aggregate exists at the root level of the IP address space hierarchy in NetBox. Aggregates are used to organize @@ -285,6 +288,7 @@ class Role(ChangeLoggedModel): ) +@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') class Prefix(ChangeLoggedModel, CustomFieldModel): """ A Prefix represents an IPv4 or IPv6 network, including mask length. Prefixes can optionally be assigned to Sites and @@ -551,6 +555,7 @@ class Prefix(ChangeLoggedModel, CustomFieldModel): return int(float(child_count) / prefix_size * 100) +@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') class IPAddress(ChangeLoggedModel, CustomFieldModel): """ An IPAddress represents an individual IPv4 or IPv6 address and its mask. The mask length should match what is @@ -854,6 +859,7 @@ class VLANGroup(ChangeLoggedModel): return None +@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') class VLAN(ChangeLoggedModel, CustomFieldModel): """ A VLAN is a distinct layer two forwarding domain identified by a 12-bit integer (1-4094). Each VLAN must be assigned @@ -978,6 +984,7 @@ class VLAN(ChangeLoggedModel, CustomFieldModel): ).distinct() +@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') class Service(ChangeLoggedModel, CustomFieldModel): """ A Service represents a layer-four service (e.g. HTTP or SSH) running on a Device or VirtualMachine. A Service may diff --git a/netbox/secrets/models.py b/netbox/secrets/models.py index 7cebb744c..123135eec 100644 --- a/netbox/secrets/models.py +++ b/netbox/secrets/models.py @@ -16,6 +16,7 @@ from taggit.managers import TaggableManager from dcim.models import Device from extras.models import CustomFieldModel, TaggedItem +from extras.utils import extras_features from utilities.models import ChangeLoggedModel from .exceptions import InvalidKey from .hashers import SecretValidationHasher @@ -295,6 +296,7 @@ class SecretRole(ChangeLoggedModel): return user in self.users.all() or user.groups.filter(pk__in=self.groups.all()).exists() +@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') class Secret(ChangeLoggedModel, CustomFieldModel): """ A Secret stores an AES256-encrypted copy of sensitive data, such as passwords or secret keys. An irreversible diff --git a/netbox/tenancy/models.py b/netbox/tenancy/models.py index 9fa7f23ea..757728fbb 100644 --- a/netbox/tenancy/models.py +++ b/netbox/tenancy/models.py @@ -4,6 +4,7 @@ from django.urls import reverse from taggit.managers import TaggableManager from extras.models import CustomFieldModel, TaggedItem +from extras.utils import extras_features from utilities.models import ChangeLoggedModel @@ -43,6 +44,7 @@ class TenantGroup(ChangeLoggedModel): ) +@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') class Tenant(ChangeLoggedModel, CustomFieldModel): """ A Tenant represents an organization served by the NetBox owner. This is typically a customer or an internal diff --git a/netbox/virtualization/models.py b/netbox/virtualization/models.py index 13b181137..2bd391863 100644 --- a/netbox/virtualization/models.py +++ b/netbox/virtualization/models.py @@ -7,6 +7,7 @@ from taggit.managers import TaggableManager from dcim.models import Device from extras.models import ConfigContextModel, CustomFieldModel, TaggedItem +from extras.utils import extras_features from utilities.models import ChangeLoggedModel from .choices import * @@ -91,6 +92,7 @@ class ClusterGroup(ChangeLoggedModel): # Clusters # +@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') class Cluster(ChangeLoggedModel, CustomFieldModel): """ A cluster of VirtualMachines. Each Cluster may optionally be associated with one or more Devices. @@ -177,6 +179,7 @@ class Cluster(ChangeLoggedModel, CustomFieldModel): # Virtual machines # +@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') class VirtualMachine(ChangeLoggedModel, ConfigContextModel, CustomFieldModel): """ A virtual machine which runs inside a Cluster.