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 69be137d72f..4ebae5f76c7 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 ad893bec699..09de27d924d 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 72a5735de77..a34d7983a3a 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 719954c10c0..fa425c460b0 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 4ebae5f76c7..32fd17f64b4 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 618641a074f..7d05ce7490d 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 cd3015d0a54..01fbfb62c31 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 75f7e0e3ed2..363392a4d39 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 5a915beccd4..f989d817cfe 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 d044d61982d..f61041b58b2 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 d699cd22e63..8d8463bad7b 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 aa9e380baf7..7e547dafde5 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 c4d68f9c0c4..ff0ea32a80a 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 262ca79080b..4b50ac7de7b 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 70abcfe2909..7ae2ae9ac74 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 367dc9bd0ed..1795e6c0af4 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 5762f9a0d52..645cc2edc8b 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 ab82c3cf519..148058a3345 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 43062af69d0..25501a1824c 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 a94e043b2ad..c237f1e6882 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 415fc628933..2a1d7c3a94d 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 b1b6d9e1496..b5f8758e701 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 ddb9c0b5282..d57aaa7d549 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 6871b26549f..b04b216ba02 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 99a7eaca4a9..8bdf7fd068a 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 df32ad7f27d..339c370d81c 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 1767c8f28b9..8da3d7594e8 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 38ec6e196bf..fd8c70f055b 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 719954c10c0..7aa4e929fae 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 1d8a9108476..81e4caa254d 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 e3ed0291dbb..60c1111875e 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 0904f8c824a..1d84fea242c 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 bae4471b8c5..34cd5a30f13 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 4db2c55f538..3880c9d0742 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 3c24b061a64..9cc5cd58e9d 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 d81fbeab95e..68625ae2d51 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 126414cfd56..ca60b852460 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 ac8fc40d5ba..f2719eca686 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 812eaa79e85..c650e27b710 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 1dbfdb76b66..e0d0cd0cd9c 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 9a3d608d728..d6582baac7f 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 68625ae2d51..0de5ec8e53f 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 098dac2f26d..4de3cdfddd4 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 7cebb744c6a..5038823aea6 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 1a02184cdc2..649905c172e 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 cf0d826b583..5be784777ff 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 85ff1895c90..efc186f9773 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 e4ddf792be4..1fa7e7210ec 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 1a6d60b8633..acecc3598e3 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 e0d0cd0cd9c..6fc549dacc9 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 ebda79dc0f4..283145b1752 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 3986d0892c0..3158596fc69 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 d7434c808bb..b0eeca20a14 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 e6d9adecdcd..d654ecf7021 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 1cfc9038de5..1390da945b2 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 f9c6fe515b5..3c555a8fcb9 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 4de3cdfddd4..71a8b2caf8b 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 4dcb0a6c30e..19735b81c06 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 5bfbb30d911..b7089f5f8c4 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 aedc44bac51..de0dfb0d628 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 ec5e60a34c0..9c7a099e4fb 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 12e852879e5..40e35270e2e 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 9b8fc59dad3..3af848f3dce 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 649905c172e..5eeb687d76f 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 adf73dc415a..0eca7de71ef 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 bb1ac889ca4..51deedde8bb 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 5b47f8080c7..ca2c2633fc2 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 a294cdb6faa..3cca95b22bf 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 2c450e5a22f..cf71b05e60a 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 d110545c7be..9d595b35bd7 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 13b181137e9..8eb94eea393 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 fdb997dab54..09c22ab8afa 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 db5935be908..e69e358d438 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 63990897720..e7bb19285ff 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 00000000000..fecdee3ca31 --- /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 00000000000..eb4a2d76044 --- /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 00000000000..e56f3e1eb96 --- /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 00000000000..b0da0635aa3 --- /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 00000000000..f64c0ba553a --- /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 00000000000..0f65ced048d --- /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 00000000000..8916edcbd51 --- /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 00000000000..e02655bb744 --- /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 60c1111875e..2473de067e1 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 2473de067e1..f42625ef2f3 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 40606ed8e7a..f3c05d37ea5 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 b792ec48433..85d19756a61 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 00000000000..9d17b205ff6 --- /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 0de5ec8e53f..33b25685c34 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 08c5ed471bf..b145824c6b7 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 64e5bbebd4e..d87aec3f76f 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 800db1d2608..87b9a2e53a2 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 812eaa79e85..919fc45a596 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 94e8a239147..63c3044c1a5 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 9a3d608d728..806d652b704 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 40606ed8e7a..567beedb0aa 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 7bb026d349f..3b6c044dc64 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 00000000000..c347b119849 --- /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 d81fbeab95e..21809c35bd7 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 3e6e4378957..7733149420f 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 ab559cf73c9..e507e403412 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 ca3a725260c..5edf3f562b0 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 8b20641d7e3..f1a3391a061 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 4cbcb4bf0f8..0ffce07cfdf 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 7cebb744c6a..123135eec3b 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 9fa7f23ea0d..757728fbba0 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 13b181137e9..2bd39186372 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.