From a504f5f309dfae02ce567c233fb3daf28b457b4d Mon Sep 17 00:00:00 2001 From: John Anderson Date: Tue, 10 Mar 2020 19:15:24 -0400 Subject: [PATCH 1/8] closes #4340 - Enforce unique constraints for device and virtual machine names in the API --- docs/release-notes/version-2.7.md | 6 ++++++ netbox/dcim/tests/test_api.py | 14 ++++++++++++++ netbox/utilities/api.py | 1 + netbox/virtualization/tests/test_api.py | 12 ++++++++++++ 4 files changed, 33 insertions(+) diff --git a/docs/release-notes/version-2.7.md b/docs/release-notes/version-2.7.md index 69be137d7..4ebae5f76 100644 --- a/docs/release-notes/version-2.7.md +++ b/docs/release-notes/version-2.7.md @@ -1,5 +1,11 @@ # NetBox v2.7 Release Notes +## v2.7.11 (FUTURE) + +### Bug Fixes + +* [#4340](https://github.com/netbox-community/netbox/issues/4340) - Enforce unique constraints for device and virtual machine names in the API + ## v2.7.10 (2020-03-10) **Note:** If your deployment requires any non-core Python packages (such as `napalm`, `django-storages`, or `django-auth-ldap`), list them in a file named `local_requirements.txt` in the NetBox root directory (alongside `requirements.txt`). This will ensure they are detected and re-installed by the upgrade script when the Python virtual environment is rebuilt. diff --git a/netbox/dcim/tests/test_api.py b/netbox/dcim/tests/test_api.py index ad893bec6..09de27d92 100644 --- a/netbox/dcim/tests/test_api.py +++ b/netbox/dcim/tests/test_api.py @@ -2089,6 +2089,20 @@ class DeviceTest(APITestCase): self.assertFalse('config_context' in response.data['results'][0]) + def test_unique_name_per_site_constraint(self): + + data = { + 'device_type': self.devicetype1.pk, + 'device_role': self.devicerole1.pk, + 'name': 'Test Device 1', + 'site': self.site1.pk, + } + + url = reverse('dcim-api:device-list') + response = self.client.post(url, data, format='json', **self.header) + + self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST) + class ConsolePortTest(APITestCase): diff --git a/netbox/utilities/api.py b/netbox/utilities/api.py index 72a5735de..a34d7983a 100644 --- a/netbox/utilities/api.py +++ b/netbox/utilities/api.py @@ -234,6 +234,7 @@ class ValidatedModelSerializer(ModelSerializer): for k, v in attrs.items(): setattr(instance, k, v) instance.clean() + instance.validate_unique() return data diff --git a/netbox/virtualization/tests/test_api.py b/netbox/virtualization/tests/test_api.py index 719954c10..fa425c460 100644 --- a/netbox/virtualization/tests/test_api.py +++ b/netbox/virtualization/tests/test_api.py @@ -501,6 +501,18 @@ class VirtualMachineTest(APITestCase): self.assertFalse('config_context' in response.data['results'][0]) + def test_unique_name_per_cluster_constraint(self): + + data = { + 'name': 'Test Virtual Machine 1', + 'cluster': self.cluster1.pk, + } + + url = reverse('virtualization-api:virtualmachine-list') + response = self.client.post(url, data, format='json', **self.header) + + self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST) + class InterfaceTest(APITestCase): From 79aba5edf2e5a1b7fbe3de0f50e82db72a3150bc Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 11 Mar 2020 09:52:02 -0400 Subject: [PATCH 2/8] Fixes #4343: Fix Markdown support for tables --- docs/release-notes/version-2.7.md | 1 + netbox/utilities/templatetags/helpers.py | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/release-notes/version-2.7.md b/docs/release-notes/version-2.7.md index 4ebae5f76..32fd17f64 100644 --- a/docs/release-notes/version-2.7.md +++ b/docs/release-notes/version-2.7.md @@ -5,6 +5,7 @@ ### Bug Fixes * [#4340](https://github.com/netbox-community/netbox/issues/4340) - Enforce unique constraints for device and virtual machine names in the API +* [#4343](https://github.com/netbox-community/netbox/issues/4343) - Fix Markdown support for tables ## v2.7.10 (2020-03-10) diff --git a/netbox/utilities/templatetags/helpers.py b/netbox/utilities/templatetags/helpers.py index 618641a07..7d05ce749 100644 --- a/netbox/utilities/templatetags/helpers.py +++ b/netbox/utilities/templatetags/helpers.py @@ -40,7 +40,7 @@ def render_markdown(value): value = strip_tags(value) # Render Markdown - html = markdown(value, extensions=['fenced_code']) + html = markdown(value, extensions=['fenced_code', 'tables']) return mark_safe(html) @@ -196,7 +196,7 @@ def get_docs(model): return "Unable to load documentation, error reading file: {}".format(path) # Render Markdown with the admonition extension - content = markdown(content, extensions=['admonition', 'fenced_code']) + content = markdown(content, extensions=['admonition', 'fenced_code', 'tables']) return mark_safe(content) From 5e971994ff7385d6707c006ef290d8517e144fed Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 12 Mar 2020 17:43:11 -0400 Subject: [PATCH 3/8] Closes #4362: Standardize URL for creation of RackReservations --- netbox/dcim/forms.py | 12 +++++++++-- netbox/dcim/tests/test_views.py | 1 - netbox/dcim/urls.py | 2 +- netbox/dcim/views.py | 12 +++++------ netbox/templates/dcim/rack.html | 6 +++--- .../templates/dcim/rackreservation_edit.html | 21 +++++++++++++++++++ 6 files changed, 40 insertions(+), 14 deletions(-) create mode 100644 netbox/templates/dcim/rackreservation_edit.html diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index ac8fc40d5..f2719eca6 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -791,6 +791,13 @@ class RackElevationFilterForm(RackFilterForm): # class RackReservationForm(BootstrapMixin, TenancyForm, forms.ModelForm): + rack = forms.ModelChoiceField( + queryset=Rack.objects.all(), + required=False, + widget=forms.HiddenInput() + ) + # TODO: Change this to an API-backed form field. We can't do this currently because we want to retain + # the multi-line + + + + + + + +
+ {% if perms.dcim.change_rackreservation %} + {% edit_button rackreservation %} + {% endif %} + {% if perms.dcim.delete_rackreservation %} + {% delete_button rackreservation %} + {% endif %} +
+

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

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

Front

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

Rear

+
+ {% include 'dcim/inc/rack_elevation.html' with face='rear' %} +
+
+ {% endwith %} +
+
+{% endblock %} + +{% block javascript %} + +{% endblock %} From 9466802a95cbaa773d97627b0a3e4af4601dff5b Mon Sep 17 00:00:00 2001 From: John Anderson Date: Sat, 14 Mar 2020 03:03:22 -0400 Subject: [PATCH 8/8] closes #4368 - extras features model registration --- netbox/circuits/models.py | 3 + netbox/dcim/models/__init__.py | 12 ++ netbox/dcim/models/device_components.py | 10 + netbox/extras/api/serializers.py | 5 +- netbox/extras/constants.py | 181 +----------------- .../0039_update_features_content_types.py | 40 ++++ netbox/extras/models.py | 11 +- netbox/extras/tests/test_api.py | 4 +- netbox/extras/tests/test_filters.py | 6 +- netbox/extras/utils.py | 68 +++++++ netbox/extras/webhooks.py | 3 +- netbox/ipam/models.py | 7 + netbox/secrets/models.py | 2 + netbox/tenancy/models.py | 2 + netbox/virtualization/models.py | 3 + 15 files changed, 172 insertions(+), 185 deletions(-) create mode 100644 netbox/extras/migrations/0039_update_features_content_types.py diff --git a/netbox/circuits/models.py b/netbox/circuits/models.py index 812eaa79e..919fc45a5 100644 --- a/netbox/circuits/models.py +++ b/netbox/circuits/models.py @@ -7,6 +7,7 @@ from dcim.constants import CONNECTION_STATUS_CHOICES from dcim.fields import ASNField from dcim.models import CableTermination from extras.models import CustomFieldModel, ObjectChange, TaggedItem +from extras.utils import extras_features from utilities.models import ChangeLoggedModel from utilities.utils import serialize_object from .choices import * @@ -21,6 +22,7 @@ __all__ = ( ) +@extras_features('custom_fields', 'custom_links', 'graphs', 'export_templates', 'webhooks') class Provider(ChangeLoggedModel, CustomFieldModel): """ Each Circuit belongs to a Provider. This is usually a telecommunications company or similar organization. This model @@ -131,6 +133,7 @@ class CircuitType(ChangeLoggedModel): ) +@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') class Circuit(ChangeLoggedModel, CustomFieldModel): """ A communications circuit connects two points. Each Circuit belongs to a Provider; Providers may have multiple diff --git a/netbox/dcim/models/__init__.py b/netbox/dcim/models/__init__.py index 94e8a2391..63c3044c1 100644 --- a/netbox/dcim/models/__init__.py +++ b/netbox/dcim/models/__init__.py @@ -21,6 +21,7 @@ from dcim.constants import * from dcim.fields import ASNField from dcim.elevations import RackElevationSVG from extras.models import ConfigContextModel, CustomFieldModel, ObjectChange, TaggedItem +from extras.utils import extras_features from utilities.fields import ColorField, NaturalOrderingField from utilities.models import ChangeLoggedModel from utilities.utils import serialize_object, to_meters @@ -75,6 +76,7 @@ __all__ = ( # Regions # +@extras_features('export_templates', 'webhooks') class Region(MPTTModel, ChangeLoggedModel): """ Sites can be grouped within geographic Regions. @@ -133,6 +135,7 @@ class Region(MPTTModel, ChangeLoggedModel): # Sites # +@extras_features('custom_fields', 'custom_links', 'graphs', 'export_templates', 'webhooks') class Site(ChangeLoggedModel, CustomFieldModel): """ A Site represents a geographic location within a network; typically a building or campus. The optional facility @@ -283,6 +286,7 @@ class Site(ChangeLoggedModel, CustomFieldModel): # Racks # +@extras_features('export_templates') class RackGroup(ChangeLoggedModel): """ Racks can be grouped as subsets within a Site. The scope of a group will depend on how Sites are defined. For @@ -359,6 +363,7 @@ class RackRole(ChangeLoggedModel): ) +@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') class Rack(ChangeLoggedModel, CustomFieldModel): """ Devices are housed within Racks. Each rack has a defined height measured in rack units, and a front and rear face. @@ -823,6 +828,7 @@ class RackReservation(ChangeLoggedModel): # Device Types # +@extras_features('export_templates', 'webhooks') class Manufacturer(ChangeLoggedModel): """ A Manufacturer represents a company which produces hardware devices; for example, Juniper or Dell. @@ -853,6 +859,7 @@ class Manufacturer(ChangeLoggedModel): ) +@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') class DeviceType(ChangeLoggedModel, CustomFieldModel): """ A DeviceType represents a particular make (Manufacturer) and model of device. It specifies rack height and depth, as @@ -1196,6 +1203,7 @@ class Platform(ChangeLoggedModel): ) +@extras_features('custom_fields', 'custom_links', 'graphs', 'export_templates', 'webhooks') class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel): """ A Device represents a piece of physical hardware mounted within a Rack. Each Device is assigned a DeviceType, @@ -1631,6 +1639,7 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel): # Virtual chassis # +@extras_features('export_templates', 'webhooks') class VirtualChassis(ChangeLoggedModel): """ A collection of Devices which operate with a shared control plane (e.g. a switch stack). @@ -1697,6 +1706,7 @@ class VirtualChassis(ChangeLoggedModel): # Power # +@extras_features('custom_links', 'export_templates', 'webhooks') class PowerPanel(ChangeLoggedModel): """ A distribution point for electrical power; e.g. a data center RPP. @@ -1743,6 +1753,7 @@ class PowerPanel(ChangeLoggedModel): )) +@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') class PowerFeed(ChangeLoggedModel, CableTermination, CustomFieldModel): """ An electrical circuit delivered from a PowerPanel. @@ -1904,6 +1915,7 @@ class PowerFeed(ChangeLoggedModel, CableTermination, CustomFieldModel): # Cables # +@extras_features('custom_links', 'export_templates', 'webhooks') class Cable(ChangeLoggedModel): """ A physical connection between two endpoints. diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index 9a3d608d7..806d652b7 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -11,6 +11,7 @@ from dcim.constants import * from dcim.exceptions import LoopDetected from dcim.fields import MACAddressField from extras.models import ObjectChange, TaggedItem +from extras.utils import extras_features from utilities.fields import NaturalOrderingField from utilities.ordering import naturalize_interface from utilities.utils import serialize_object @@ -169,6 +170,7 @@ class CableTermination(models.Model): # Console ports # +@extras_features('export_templates', 'webhooks') class ConsolePort(CableTermination, ComponentModel): """ A physical console port within a Device. ConsolePorts connect to ConsoleServerPorts. @@ -229,6 +231,7 @@ class ConsolePort(CableTermination, ComponentModel): # Console server ports # +@extras_features('webhooks') class ConsoleServerPort(CableTermination, ComponentModel): """ A physical port within a Device (typically a designated console server) which provides access to ConsolePorts. @@ -282,6 +285,7 @@ class ConsoleServerPort(CableTermination, ComponentModel): # Power ports # +@extras_features('export_templates', 'webhooks') class PowerPort(CableTermination, ComponentModel): """ A physical power supply (intake) port within a Device. PowerPorts connect to PowerOutlets. @@ -443,6 +447,7 @@ class PowerPort(CableTermination, ComponentModel): # Power outlets # +@extras_features('webhooks') class PowerOutlet(CableTermination, ComponentModel): """ A physical power outlet (output) within a Device which provides power to a PowerPort. @@ -519,6 +524,7 @@ class PowerOutlet(CableTermination, ComponentModel): # Interfaces # +@extras_features('graphs', 'export_templates', 'webhooks') class Interface(CableTermination, ComponentModel): """ A network interface within a Device or VirtualMachine. A physical Interface can connect to exactly one other @@ -792,6 +798,7 @@ class Interface(CableTermination, ComponentModel): # Pass-through ports # +@extras_features('webhooks') class FrontPort(CableTermination, ComponentModel): """ A pass-through port on the front of a Device. @@ -864,6 +871,7 @@ class FrontPort(CableTermination, ComponentModel): ) +@extras_features('webhooks') class RearPort(CableTermination, ComponentModel): """ A pass-through port on the rear of a Device. @@ -915,6 +923,7 @@ class RearPort(CableTermination, ComponentModel): # Device bays # +@extras_features('webhooks') class DeviceBay(ComponentModel): """ An empty space within a Device which can house a child device @@ -989,6 +998,7 @@ class DeviceBay(ComponentModel): # Inventory items # +@extras_features('export_templates', 'webhooks') class InventoryItem(ComponentModel): """ An InventoryItem represents a serialized piece of hardware within a Device, such as a line card or power supply. diff --git a/netbox/extras/api/serializers.py b/netbox/extras/api/serializers.py index 40606ed8e..567beedb0 100644 --- a/netbox/extras/api/serializers.py +++ b/netbox/extras/api/serializers.py @@ -13,6 +13,7 @@ from extras.constants import * from extras.models import ( ConfigContext, ExportTemplate, Graph, ImageAttachment, ObjectChange, ReportResult, Tag, ) +from extras.utils import FeatureQuerySet from tenancy.api.nested_serializers import NestedTenantSerializer, NestedTenantGroupSerializer from tenancy.models import Tenant, TenantGroup from users.api.nested_serializers import NestedUserSerializer @@ -31,7 +32,7 @@ from .nested_serializers import * class GraphSerializer(ValidatedModelSerializer): type = ContentTypeField( - queryset=ContentType.objects.filter(GRAPH_MODELS), + queryset=ContentType.objects.filter(FeatureQuerySet('graphs').get_queryset()), ) class Meta: @@ -67,7 +68,7 @@ class RenderedGraphSerializer(serializers.ModelSerializer): class ExportTemplateSerializer(ValidatedModelSerializer): content_type = ContentTypeField( - queryset=ContentType.objects.filter(EXPORTTEMPLATE_MODELS), + queryset=ContentType.objects.filter(FeatureQuerySet('export_templates').get_queryset()), ) template_language = ChoiceField( choices=TemplateLanguageChoices, diff --git a/netbox/extras/constants.py b/netbox/extras/constants.py index 7bb026d34..3b6c044dc 100644 --- a/netbox/extras/constants.py +++ b/netbox/extras/constants.py @@ -1,129 +1,3 @@ -from django.db.models import Q - - -# Models which support custom fields -CUSTOMFIELD_MODELS = Q( - Q(app_label='circuits', model__in=[ - 'circuit', - 'provider', - ]) | - Q(app_label='dcim', model__in=[ - 'device', - 'devicetype', - 'powerfeed', - 'rack', - 'site', - ]) | - Q(app_label='ipam', model__in=[ - 'aggregate', - 'ipaddress', - 'prefix', - 'service', - 'vlan', - 'vrf', - ]) | - Q(app_label='secrets', model__in=[ - 'secret', - ]) | - Q(app_label='tenancy', model__in=[ - 'tenant', - ]) | - Q(app_label='virtualization', model__in=[ - 'cluster', - 'virtualmachine', - ]) -) - -# Custom links -CUSTOMLINK_MODELS = Q( - Q(app_label='circuits', model__in=[ - 'circuit', - 'provider', - ]) | - Q(app_label='dcim', model__in=[ - 'cable', - 'device', - 'devicetype', - 'powerpanel', - 'powerfeed', - 'rack', - 'site', - ]) | - Q(app_label='ipam', model__in=[ - 'aggregate', - 'ipaddress', - 'prefix', - 'service', - 'vlan', - 'vrf', - ]) | - Q(app_label='secrets', model__in=[ - 'secret', - ]) | - Q(app_label='tenancy', model__in=[ - 'tenant', - ]) | - Q(app_label='virtualization', model__in=[ - 'cluster', - 'virtualmachine', - ]) -) - -# Models which can have Graphs associated with them -GRAPH_MODELS = Q( - Q(app_label='circuits', model__in=[ - 'provider', - ]) | - Q(app_label='dcim', model__in=[ - 'device', - 'interface', - 'site', - ]) -) - -# Models which support export templates -EXPORTTEMPLATE_MODELS = Q( - Q(app_label='circuits', model__in=[ - 'circuit', - 'provider', - ]) | - Q(app_label='dcim', model__in=[ - 'cable', - 'consoleport', - 'device', - 'devicetype', - 'interface', - 'inventoryitem', - 'manufacturer', - 'powerpanel', - 'powerport', - 'powerfeed', - 'rack', - 'rackgroup', - 'region', - 'site', - 'virtualchassis', - ]) | - Q(app_label='ipam', model__in=[ - 'aggregate', - 'ipaddress', - 'prefix', - 'service', - 'vlan', - 'vrf', - ]) | - Q(app_label='secrets', model__in=[ - 'secret', - ]) | - Q(app_label='tenancy', model__in=[ - 'tenant', - ]) | - Q(app_label='virtualization', model__in=[ - 'cluster', - 'virtualmachine', - ]) -) - # Report logging levels LOG_DEFAULT = 0 LOG_SUCCESS = 10 @@ -138,51 +12,14 @@ LOG_LEVEL_CODES = { LOG_FAILURE: 'failure', } +# Webhook content types HTTP_CONTENT_TYPE_JSON = 'application/json' -# Models which support registered webhooks -WEBHOOK_MODELS = Q( - Q(app_label='circuits', model__in=[ - 'circuit', - 'provider', - ]) | - Q(app_label='dcim', model__in=[ - 'cable', - 'consoleport', - 'consoleserverport', - 'device', - 'devicebay', - 'devicetype', - 'frontport', - 'interface', - 'inventoryitem', - 'manufacturer', - 'poweroutlet', - 'powerpanel', - 'powerport', - 'powerfeed', - 'rack', - 'rearport', - 'region', - 'site', - 'virtualchassis', - ]) | - Q(app_label='ipam', model__in=[ - 'aggregate', - 'ipaddress', - 'prefix', - 'service', - 'vlan', - 'vrf', - ]) | - Q(app_label='secrets', model__in=[ - 'secret', - ]) | - Q(app_label='tenancy', model__in=[ - 'tenant', - ]) | - Q(app_label='virtualization', model__in=[ - 'cluster', - 'virtualmachine', - ]) -) +# Registerable extras features +EXTRAS_FEATURES = [ + 'custom_fields', + 'custom_links', + 'graphs', + 'export_templates', + 'webhooks' +] diff --git a/netbox/extras/migrations/0039_update_features_content_types.py b/netbox/extras/migrations/0039_update_features_content_types.py new file mode 100644 index 000000000..c347b1198 --- /dev/null +++ b/netbox/extras/migrations/0039_update_features_content_types.py @@ -0,0 +1,40 @@ +# Generated by Django 2.2.11 on 2020-03-14 06:50 + +from django.db import migrations, models +import django.db.models.deletion +import extras.utils + + +class Migration(migrations.Migration): + + dependencies = [ + ('extras', '0038_webhook_template_support'), + ] + + operations = [ + migrations.AlterField( + model_name='customfield', + name='obj_type', + field=models.ManyToManyField(limit_choices_to=extras.utils.FeatureQuerySet('custom_fields'), related_name='custom_fields', to='contenttypes.ContentType'), + ), + migrations.AlterField( + model_name='customlink', + name='content_type', + field=models.ForeignKey(limit_choices_to=extras.utils.FeatureQuerySet('custom_links'), on_delete=django.db.models.deletion.CASCADE, to='contenttypes.ContentType'), + ), + migrations.AlterField( + model_name='exporttemplate', + name='content_type', + field=models.ForeignKey(limit_choices_to=extras.utils.FeatureQuerySet('export_templates'), on_delete=django.db.models.deletion.CASCADE, to='contenttypes.ContentType'), + ), + migrations.AlterField( + model_name='graph', + name='type', + field=models.ForeignKey(limit_choices_to=extras.utils.FeatureQuerySet('graphs'), on_delete=django.db.models.deletion.CASCADE, to='contenttypes.ContentType'), + ), + migrations.AlterField( + model_name='webhook', + name='obj_type', + field=models.ManyToManyField(limit_choices_to=extras.utils.FeatureQuerySet('webhooks'), related_name='webhooks', to='contenttypes.ContentType'), + ), + ] diff --git a/netbox/extras/models.py b/netbox/extras/models.py index d81fbeab9..21809c35b 100644 --- a/netbox/extras/models.py +++ b/netbox/extras/models.py @@ -22,6 +22,7 @@ from utilities.utils import deepmerge, render_jinja2 from .choices import * from .constants import * from .querysets import ConfigContextQuerySet +from .utils import FeatureQuerySet __all__ = ( @@ -58,7 +59,7 @@ class Webhook(models.Model): to=ContentType, related_name='webhooks', verbose_name='Object types', - limit_choices_to=WEBHOOK_MODELS, + limit_choices_to=FeatureQuerySet('webhooks'), help_text="The object(s) to which this Webhook applies." ) name = models.CharField( @@ -223,7 +224,7 @@ class CustomField(models.Model): to=ContentType, related_name='custom_fields', verbose_name='Object(s)', - limit_choices_to=CUSTOMFIELD_MODELS, + limit_choices_to=FeatureQuerySet('custom_fields'), help_text='The object(s) to which this field applies.' ) type = models.CharField( @@ -470,7 +471,7 @@ class CustomLink(models.Model): content_type = models.ForeignKey( to=ContentType, on_delete=models.CASCADE, - limit_choices_to=CUSTOMLINK_MODELS + limit_choices_to=FeatureQuerySet('custom_links') ) name = models.CharField( max_length=100, @@ -518,7 +519,7 @@ class Graph(models.Model): type = models.ForeignKey( to=ContentType, on_delete=models.CASCADE, - limit_choices_to=GRAPH_MODELS + limit_choices_to=FeatureQuerySet('graphs') ) weight = models.PositiveSmallIntegerField( default=1000 @@ -581,7 +582,7 @@ class ExportTemplate(models.Model): content_type = models.ForeignKey( to=ContentType, on_delete=models.CASCADE, - limit_choices_to=EXPORTTEMPLATE_MODELS + limit_choices_to=FeatureQuerySet('export_templates') ) name = models.CharField( max_length=100 diff --git a/netbox/extras/tests/test_api.py b/netbox/extras/tests/test_api.py index 3e6e43789..773314942 100644 --- a/netbox/extras/tests/test_api.py +++ b/netbox/extras/tests/test_api.py @@ -8,9 +8,9 @@ from rest_framework import status from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Platform, Rack, RackGroup, RackRole, Region, Site from extras.api.views import ScriptViewSet from extras.choices import * -from extras.constants import GRAPH_MODELS from extras.models import ConfigContext, Graph, ExportTemplate, Tag from extras.scripts import BooleanVar, IntegerVar, Script, StringVar +from extras.utils import FeatureQuerySet from tenancy.models import Tenant, TenantGroup from utilities.testing import APITestCase, choices_to_dict @@ -35,7 +35,7 @@ class AppTest(APITestCase): self.assertEqual(choices_to_dict(response.data.get('export-template:template_language')), TemplateLanguageChoices.as_dict()) # Graph - content_types = ContentType.objects.filter(GRAPH_MODELS) + content_types = ContentType.objects.filter(FeatureQuerySet('graphs').get_queryset()) graph_type_choices = { "{}.{}".format(ct.app_label, ct.model): ct.name for ct in content_types } diff --git a/netbox/extras/tests/test_filters.py b/netbox/extras/tests/test_filters.py index ab559cf73..e507e4034 100644 --- a/netbox/extras/tests/test_filters.py +++ b/netbox/extras/tests/test_filters.py @@ -3,8 +3,8 @@ from django.test import TestCase from dcim.models import DeviceRole, Platform, Region, Site from extras.choices import * -from extras.constants import GRAPH_MODELS from extras.filters import * +from extras.utils import FeatureQuerySet from extras.models import ConfigContext, ExportTemplate, Graph from tenancy.models import Tenant, TenantGroup from virtualization.models import Cluster, ClusterGroup, ClusterType @@ -18,7 +18,7 @@ class GraphTestCase(TestCase): def setUpTestData(cls): # Get the first three available types - content_types = ContentType.objects.filter(GRAPH_MODELS)[:3] + content_types = ContentType.objects.filter(FeatureQuerySet('graphs').get_queryset())[:3] graphs = ( Graph(name='Graph 1', type=content_types[0], template_language=TemplateLanguageChoices.LANGUAGE_DJANGO, source='http://example.com/1'), @@ -32,7 +32,7 @@ class GraphTestCase(TestCase): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) def test_type(self): - content_type = ContentType.objects.filter(GRAPH_MODELS).first() + content_type = ContentType.objects.filter(FeatureQuerySet('graphs').get_queryset()).first() params = {'type': content_type.pk} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) diff --git a/netbox/extras/utils.py b/netbox/extras/utils.py index ca3a72526..5edf3f562 100644 --- a/netbox/extras/utils.py +++ b/netbox/extras/utils.py @@ -1,6 +1,12 @@ +import collections + +from django.db.models import Q +from django.utils.deconstruct import deconstructible from taggit.managers import _TaggableManager from utilities.querysets import DummyQuerySet +from extras.constants import EXTRAS_FEATURES + def is_taggable(obj): """ @@ -13,3 +19,65 @@ def is_taggable(obj): if isinstance(obj.tags, DummyQuerySet): return True return False + + +# +# Dynamic feature registration +# + +class Registry: + """ + The registry is a place to hook into for data storage across components + """ + + def add_store(self, store_name, initial_value=None): + """ + Given the name of some new data parameter and an optional initial value, setup the registry store + """ + if not hasattr(Registry, store_name): + setattr(Registry, store_name, initial_value) + + +registry = Registry() + + +@deconstructible +class FeatureQuerySet: + """ + Helper class that delays evaluation of the registry contents for the functionaility store + until it has been populated. + """ + + def __init__(self, feature): + self.feature = feature + + def __call__(self): + return self.get_queryset() + + def get_queryset(self): + """ + Given an extras feature, return a Q object for content type lookup + """ + query = Q() + for app_label, models in registry.model_feature_store[self.feature].items(): + query |= Q(app_label=app_label, model__in=models) + + return query + + +registry.add_store('model_feature_store', {f: collections.defaultdict(list) for f in EXTRAS_FEATURES}) + + +def extras_features(*features): + """ + Decorator used to register extras provided features to a model + """ + def wrapper(model_class): + for feature in features: + if feature in EXTRAS_FEATURES: + app_label, model_name = model_class._meta.label_lower.split('.') + registry.model_feature_store[feature][app_label].append(model_name) + else: + raise ValueError('{} is not a valid extras feature!'.format(feature)) + return model_class + return wrapper diff --git a/netbox/extras/webhooks.py b/netbox/extras/webhooks.py index 8b20641d7..f1a3391a0 100644 --- a/netbox/extras/webhooks.py +++ b/netbox/extras/webhooks.py @@ -8,6 +8,7 @@ from extras.models import Webhook from utilities.api import get_serializer_for_model from .choices import * from .constants import * +from .utils import FeatureQuerySet def generate_signature(request_body, secret): @@ -29,7 +30,7 @@ def enqueue_webhooks(instance, user, request_id, action): """ obj_type = ContentType.objects.get_for_model(instance.__class__) - webhook_models = ContentType.objects.filter(WEBHOOK_MODELS) + webhook_models = ContentType.objects.filter(FeatureQuerySet('webhooks').get_queryset()) if obj_type not in webhook_models: return diff --git a/netbox/ipam/models.py b/netbox/ipam/models.py index 4cbcb4bf0..0ffce07cf 100644 --- a/netbox/ipam/models.py +++ b/netbox/ipam/models.py @@ -10,6 +10,7 @@ from taggit.managers import TaggableManager from dcim.models import Device, Interface from extras.models import CustomFieldModel, ObjectChange, TaggedItem +from extras.utils import extras_features from utilities.models import ChangeLoggedModel from utilities.utils import serialize_object from virtualization.models import VirtualMachine @@ -34,6 +35,7 @@ __all__ = ( ) +@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') class VRF(ChangeLoggedModel, CustomFieldModel): """ A virtual routing and forwarding (VRF) table represents a discrete layer three forwarding domain (e.g. a routing @@ -145,6 +147,7 @@ class RIR(ChangeLoggedModel): ) +@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') class Aggregate(ChangeLoggedModel, CustomFieldModel): """ An aggregate exists at the root level of the IP address space hierarchy in NetBox. Aggregates are used to organize @@ -285,6 +288,7 @@ class Role(ChangeLoggedModel): ) +@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') class Prefix(ChangeLoggedModel, CustomFieldModel): """ A Prefix represents an IPv4 or IPv6 network, including mask length. Prefixes can optionally be assigned to Sites and @@ -551,6 +555,7 @@ class Prefix(ChangeLoggedModel, CustomFieldModel): return int(float(child_count) / prefix_size * 100) +@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') class IPAddress(ChangeLoggedModel, CustomFieldModel): """ An IPAddress represents an individual IPv4 or IPv6 address and its mask. The mask length should match what is @@ -854,6 +859,7 @@ class VLANGroup(ChangeLoggedModel): return None +@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') class VLAN(ChangeLoggedModel, CustomFieldModel): """ A VLAN is a distinct layer two forwarding domain identified by a 12-bit integer (1-4094). Each VLAN must be assigned @@ -978,6 +984,7 @@ class VLAN(ChangeLoggedModel, CustomFieldModel): ).distinct() +@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') class Service(ChangeLoggedModel, CustomFieldModel): """ A Service represents a layer-four service (e.g. HTTP or SSH) running on a Device or VirtualMachine. A Service may diff --git a/netbox/secrets/models.py b/netbox/secrets/models.py index 7cebb744c..123135eec 100644 --- a/netbox/secrets/models.py +++ b/netbox/secrets/models.py @@ -16,6 +16,7 @@ from taggit.managers import TaggableManager from dcim.models import Device from extras.models import CustomFieldModel, TaggedItem +from extras.utils import extras_features from utilities.models import ChangeLoggedModel from .exceptions import InvalidKey from .hashers import SecretValidationHasher @@ -295,6 +296,7 @@ class SecretRole(ChangeLoggedModel): return user in self.users.all() or user.groups.filter(pk__in=self.groups.all()).exists() +@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') class Secret(ChangeLoggedModel, CustomFieldModel): """ A Secret stores an AES256-encrypted copy of sensitive data, such as passwords or secret keys. An irreversible diff --git a/netbox/tenancy/models.py b/netbox/tenancy/models.py index 9fa7f23ea..757728fbb 100644 --- a/netbox/tenancy/models.py +++ b/netbox/tenancy/models.py @@ -4,6 +4,7 @@ from django.urls import reverse from taggit.managers import TaggableManager from extras.models import CustomFieldModel, TaggedItem +from extras.utils import extras_features from utilities.models import ChangeLoggedModel @@ -43,6 +44,7 @@ class TenantGroup(ChangeLoggedModel): ) +@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') class Tenant(ChangeLoggedModel, CustomFieldModel): """ A Tenant represents an organization served by the NetBox owner. This is typically a customer or an internal diff --git a/netbox/virtualization/models.py b/netbox/virtualization/models.py index 13b181137..2bd391863 100644 --- a/netbox/virtualization/models.py +++ b/netbox/virtualization/models.py @@ -7,6 +7,7 @@ from taggit.managers import TaggableManager from dcim.models import Device from extras.models import ConfigContextModel, CustomFieldModel, TaggedItem +from extras.utils import extras_features from utilities.models import ChangeLoggedModel from .choices import * @@ -91,6 +92,7 @@ class ClusterGroup(ChangeLoggedModel): # Clusters # +@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') class Cluster(ChangeLoggedModel, CustomFieldModel): """ A cluster of VirtualMachines. Each Cluster may optionally be associated with one or more Devices. @@ -177,6 +179,7 @@ class Cluster(ChangeLoggedModel, CustomFieldModel): # Virtual machines # +@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') class VirtualMachine(ChangeLoggedModel, ConfigContextModel, CustomFieldModel): """ A virtual machine which runs inside a Cluster.