1
0
mirror of https://github.com/netbox-community/netbox.git synced 2024-05-10 07:54:54 +00:00

Merge branch 'develop-2.8' into 3351-plugins

This commit is contained in:
John Anderson
2020-03-15 00:26:33 -04:00
96 changed files with 1082 additions and 820 deletions

View File

@@ -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,
@@ -91,7 +92,7 @@ class TagSerializer(ValidatedModelSerializer):
class Meta:
model = Tag
fields = ['id', 'name', 'slug', 'color', 'comments', 'tagged_items']
fields = ['id', 'name', 'slug', 'color', 'description', 'tagged_items']
#

View File

@@ -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')

View File

@@ -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
#

View File

@@ -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'
]

View File

@@ -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']
#

View File

@@ -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),
),
]

View File

@@ -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'),
),
]

View File

@@ -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',
),
]

View File

@@ -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(
@@ -242,7 +243,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(
@@ -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
@@ -550,7 +551,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 +564,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))
@@ -581,7 +580,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
@@ -766,7 +765,7 @@ class ConfigContext(models.Model):
default=1000
)
description = models.CharField(
max_length=100,
max_length=200,
blank=True
)
is_active = models.BooleanField(
@@ -1053,9 +1052,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):

View File

@@ -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):

View File

@@ -7,12 +7,11 @@ 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
from utilities.testing import APITestCase
class AppTest(APITestCase):
@@ -24,27 +23,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):

View File

@@ -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,11 +32,10 @@ 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)
# 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)

View File

@@ -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

View File

@@ -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