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

Merge branch 'develop' into develop-2.7

This commit is contained in:
Jeremy Stretch
2020-01-02 17:21:15 -05:00
42 changed files with 2303 additions and 182 deletions

View File

@@ -3,7 +3,8 @@ from django.contrib import admin
from netbox.admin import admin_site
from utilities.forms import LaxURLField
from .models import CustomField, CustomFieldChoice, CustomLink, Graph, ExportTemplate, Webhook
from .models import CustomField, CustomFieldChoice, CustomLink, Graph, ExportTemplate, ReportResult, Webhook
from .reports import get_report
def order_content_types(field):
@@ -164,3 +165,33 @@ class ExportTemplateAdmin(admin.ModelAdmin):
'content_type',
]
form = ExportTemplateForm
#
# Reports
#
@admin.register(ReportResult, site=admin_site)
class ReportResultAdmin(admin.ModelAdmin):
list_display = [
'report', 'active', 'created', 'user', 'passing',
]
fields = [
'report', 'user', 'passing', 'data',
]
list_filter = [
'failed',
]
readonly_fields = fields
def has_add_permission(self, request):
return False
def active(self, obj):
module, report_name = obj.report.split('.')
return True if get_report(module, report_name) else False
active.boolean = True
def passing(self, obj):
return not obj.failed
passing.boolean = True

View File

@@ -22,7 +22,9 @@ class CustomFieldsSerializer(serializers.BaseSerializer):
def to_internal_value(self, data):
content_type = ContentType.objects.get_for_model(self.parent.Meta.model)
custom_fields = {field.name: field for field in CustomField.objects.filter(obj_type=content_type)}
custom_fields = {
field.name: field for field in CustomField.objects.filter(obj_type=content_type)
}
for field_name, value in data.items():
@@ -107,11 +109,11 @@ class CustomFieldModelSerializer(ValidatedModelSerializer):
super().__init__(*args, **kwargs)
if self.instance is not None:
# Retrieve the set of CustomFields which apply to this type of object
content_type = ContentType.objects.get_for_model(self.Meta.model)
fields = CustomField.objects.filter(obj_type=content_type)
# Retrieve the set of CustomFields which apply to this type of object
content_type = ContentType.objects.get_for_model(self.Meta.model)
fields = CustomField.objects.filter(obj_type=content_type)
if self.instance is not None:
# Populate CustomFieldValues for each instance from database
try:
@@ -120,6 +122,26 @@ class CustomFieldModelSerializer(ValidatedModelSerializer):
except TypeError:
_populate_custom_fields(self.instance, fields)
else:
if not hasattr(self, 'initial_data'):
self.initial_data = {}
# Populate default values
if fields and 'custom_fields' not in self.initial_data:
self.initial_data['custom_fields'] = {}
# Populate initial data using custom field default values
for field in fields:
if field.name not in self.initial_data['custom_fields'] and field.default:
if field.type == CustomFieldTypeChoices.TYPE_SELECT:
field_value = field.choices.get(value=field.default).pk
elif field.type == CustomFieldTypeChoices.TYPE_BOOLEAN:
field_value = bool(field.default)
else:
field_value = field.default
self.initial_data['custom_fields'][field.name] = field_value
def _save_custom_fields(self, instance, custom_fields):
content_type = ContentType.objects.get_for_model(self.Meta.model)
for field_name, value in custom_fields.items():

View File

@@ -10,8 +10,8 @@ from dcim.models import DeviceRole, Platform, Region, Site
from tenancy.models import Tenant, TenantGroup
from utilities.forms import (
add_blank_choice, APISelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, ColorSelect,
CommentField, ContentTypeSelect, FilterChoiceField, LaxURLField, JSONField, SlugField, StaticSelect2,
BOOLEAN_WITH_BLANK_CHOICES,
CommentField, ContentTypeSelect, DatePicker, DateTimePicker, FilterChoiceField, LaxURLField, JSONField,
SlugField, StaticSelect2, BOOLEAN_WITH_BLANK_CHOICES,
)
from .choices import *
from .models import ConfigContext, CustomField, CustomFieldValue, ImageAttachment, ObjectChange, Tag
@@ -52,12 +52,12 @@ def get_custom_fields_for_model(content_type, filterable_only=False, bulk_edit=F
else:
initial = None
field = forms.NullBooleanField(
required=cf.required, initial=initial, widget=forms.Select(choices=choices)
required=cf.required, initial=initial, widget=StaticSelect2(choices=choices)
)
# Date
elif cf.type == CustomFieldTypeChoices.TYPE_DATE:
field = forms.DateField(required=cf.required, initial=initial, help_text="Date format: YYYY-MM-DD")
field = forms.DateField(required=cf.required, initial=initial, widget=DatePicker())
# Select
elif cf.type == CustomFieldTypeChoices.TYPE_SELECT:
@@ -71,7 +71,9 @@ def get_custom_fields_for_model(content_type, filterable_only=False, bulk_edit=F
default_choice = cf.choices.get(value=initial).pk
except ObjectDoesNotExist:
pass
field = forms.TypedChoiceField(choices=choices, coerce=int, required=cf.required, initial=default_choice)
field = forms.TypedChoiceField(
choices=choices, coerce=int, required=cf.required, initial=default_choice, widget=StaticSelect2()
)
# URL
elif cf.type == CustomFieldTypeChoices.TYPE_URL:
@@ -404,16 +406,12 @@ class ObjectChangeFilterForm(BootstrapMixin, forms.Form):
time_after = forms.DateTimeField(
label='After',
required=False,
widget=forms.TextInput(
attrs={'placeholder': 'YYYY-MM-DD hh:mm:ss'}
)
widget=DateTimePicker()
)
time_before = forms.DateTimeField(
label='Before',
required=False,
widget=forms.TextInput(
attrs={'placeholder': 'YYYY-MM-DD hh:mm:ss'}
)
widget=DateTimePicker()
)
action = forms.ChoiceField(
choices=add_blank_choice(ObjectChangeActionChoices),

View File

@@ -10,11 +10,10 @@ from django.db import models
from django.http import HttpResponse
from django.template import Template, Context
from django.urls import reverse
from jinja2 import Environment
from taggit.models import TagBase, GenericTaggedItemBase
from utilities.fields import ColorField
from utilities.utils import deepmerge, model_names_to_filter_dict
from utilities.utils import deepmerge, model_names_to_filter_dict, render_jinja2
from .choices import *
from .constants import *
from .querysets import ConfigContextQuerySet
@@ -511,8 +510,7 @@ class ExportTemplate(models.Model):
output = template.render(Context(context))
elif self.template_language == ExportTemplateLanguageChoices.LANGUAGE_JINJA2:
template = Environment().from_string(source=self.template_code)
output = template.render(**context)
output = render_jinja2(self.template_code, context)
else:
return None
@@ -792,6 +790,13 @@ class ReportResult(models.Model):
class Meta:
ordering = ['report']
def __str__(self):
return "{} {} at {}".format(
self.report,
"passed" if not self.failed else "failed",
self.created
)
#
# Change logging

View File

@@ -235,6 +235,9 @@ class BaseScript:
# Initiate the log
self.log = []
# Declare the placeholder for the current request
self.request = None
# Grab some info about the script
self.filename = inspect.getfile(self.__class__)
self.source = inspect.getsource(self.__class__)
@@ -342,7 +345,7 @@ def is_variable(obj):
return isinstance(obj, ScriptVariable)
def run_script(script, data, files, commit=True):
def run_script(script, data, request, commit=True):
"""
A wrapper for calling Script.run(). This performs error handling and provides a hook for committing changes. It
exists outside of the Script class to ensure it cannot be overridden by a script author.
@@ -352,9 +355,13 @@ def run_script(script, data, files, commit=True):
end_time = None
# Add files to form data
files = request.FILES
for field_name, fileobj in files.items():
data[field_name] = fileobj
# Add the current request as a property of the script
script.request = request
try:
with transaction.atomic():
start_time = time.time()

View File

@@ -3,9 +3,9 @@ from collections import OrderedDict
from django import template
from django.contrib.contenttypes.models import ContentType
from django.utils.safestring import mark_safe
from jinja2 import Environment
from extras.models import CustomLink
from utilities.utils import render_jinja2
register = template.Library()
@@ -46,12 +46,17 @@ def custom_links(obj):
# Add non-grouped links
else:
text_rendered = Environment().from_string(source=cl.text).render(**context)
if text_rendered:
link_target = ' target="_blank"' if cl.new_window else ''
template_code += LINK_BUTTON.format(
cl.url, link_target, cl.button_class, text_rendered
)
try:
text_rendered = render_jinja2(cl.text, context)
if text_rendered:
link_rendered = render_jinja2(cl.url, context)
link_target = ' target="_blank"' if cl.new_window else ''
template_code += LINK_BUTTON.format(
link_rendered, link_target, cl.button_class, text_rendered
)
except Exception as e:
template_code += '<a class="btn btn-sm btn-default" disabled="disabled" title="{}">' \
'<i class="fa fa-warning"></i> {}</a>\n'.format(e, cl.name)
# Add grouped links to template
for group, links in group_names.items():
@@ -59,11 +64,17 @@ def custom_links(obj):
links_rendered = []
for cl in links:
text_rendered = Environment().from_string(source=cl.text).render(**context)
if text_rendered:
link_target = ' target="_blank"' if cl.new_window else ''
try:
text_rendered = render_jinja2(cl.text, context)
if text_rendered:
link_target = ' target="_blank"' if cl.new_window else ''
links_rendered.append(
GROUP_LINK.format(cl.url, link_target, cl.text)
)
except Exception as e:
links_rendered.append(
GROUP_LINK.format(cl.url, link_target, cl.text)
'<li><a disabled="disabled" title="{}"><span class="text-muted">'
'<i class="fa fa-warning"></i> {}</span></a></li>'.format(e, cl.name)
)
if links_rendered:
@@ -71,7 +82,4 @@ def custom_links(obj):
links[0].button_class, group, ''.join(links_rendered)
)
# Render template
rendered = Environment().from_string(source=template_code).render(**context)
return mark_safe(rendered)
return mark_safe(template_code)

View File

@@ -1,8 +1,11 @@
import datetime
from django.contrib.contenttypes.models import ContentType
from django.urls import reverse
from django.utils import timezone
from rest_framework import status
from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Platform, Region, Site
from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Platform, Rack, RackGroup, RackRole, Region, Site
from extras.api.views import ScriptViewSet
from extras.models import ConfigContext, Graph, ExportTemplate, Tag
from extras.scripts import BooleanVar, IntegerVar, Script, StringVar
@@ -598,3 +601,68 @@ class ScriptTest(APITestCase):
self.assertEqual(response.data['log'][2]['status'], 'failure')
self.assertEqual(response.data['log'][2]['message'], script_data['var3'])
self.assertEqual(response.data['output'], 'Script complete')
class CreatedUpdatedFilterTest(APITestCase):
def setUp(self):
super().setUp()
self.site1 = Site.objects.create(name='Test Site 1', slug='test-site-1')
self.rackgroup1 = RackGroup.objects.create(site=self.site1, name='Test Rack Group 1', slug='test-rack-group-1')
self.rackrole1 = RackRole.objects.create(name='Test Rack Role 1', slug='test-rack-role-1', color='ff0000')
self.rack1 = Rack.objects.create(
site=self.site1, group=self.rackgroup1, role=self.rackrole1, name='Test Rack 1', u_height=42,
)
self.rack2 = Rack.objects.create(
site=self.site1, group=self.rackgroup1, role=self.rackrole1, name='Test Rack 2', u_height=42,
)
# change the created and last_updated of one
Rack.objects.filter(pk=self.rack2.pk).update(
last_updated=datetime.datetime(2001, 2, 3, 1, 2, 3, 4, tzinfo=timezone.utc),
created=datetime.datetime(2001, 2, 3)
)
def test_get_rack_created(self):
url = reverse('dcim-api:rack-list')
response = self.client.get('{}?created=2001-02-03'.format(url), **self.header)
self.assertEqual(response.data['count'], 1)
self.assertEqual(response.data['results'][0]['id'], self.rack2.pk)
def test_get_rack_created_gte(self):
url = reverse('dcim-api:rack-list')
response = self.client.get('{}?created__gte=2001-02-04'.format(url), **self.header)
self.assertEqual(response.data['count'], 1)
self.assertEqual(response.data['results'][0]['id'], self.rack1.pk)
def test_get_rack_created_lte(self):
url = reverse('dcim-api:rack-list')
response = self.client.get('{}?created__lte=2001-02-04'.format(url), **self.header)
self.assertEqual(response.data['count'], 1)
self.assertEqual(response.data['results'][0]['id'], self.rack2.pk)
def test_get_rack_last_updated(self):
url = reverse('dcim-api:rack-list')
response = self.client.get('{}?last_updated=2001-02-03%2001:02:03.000004'.format(url), **self.header)
self.assertEqual(response.data['count'], 1)
self.assertEqual(response.data['results'][0]['id'], self.rack2.pk)
def test_get_rack_last_updated_gte(self):
url = reverse('dcim-api:rack-list')
response = self.client.get('{}?last_updated__gte=2001-02-04%2001:02:03.000004'.format(url), **self.header)
self.assertEqual(response.data['count'], 1)
self.assertEqual(response.data['results'][0]['id'], self.rack1.pk)
def test_get_rack_last_updated_lte(self):
url = reverse('dcim-api:rack-list')
response = self.client.get('{}?last_updated__lte=2001-02-04%2001:02:03.000004'.format(url), **self.header)
self.assertEqual(response.data['count'], 1)
self.assertEqual(response.data['results'][0]['id'], self.rack2.pk)

View File

@@ -301,6 +301,40 @@ class CustomFieldAPITest(APITestCase):
cfv = self.site.custom_field_values.get(field=self.cf_select)
self.assertEqual(cfv.value.pk, data['custom_fields']['magic_choice'])
def test_set_custom_field_defaults(self):
"""
Create a new object with no custom field data. Custom field values should be created using the custom fields'
default values.
"""
CUSTOM_FIELD_DEFAULTS = {
'magic_word': 'foobar',
'magic_number': '123',
'is_magic': 'true',
'magic_date': '2019-12-13',
'magic_url': 'http://example.com/',
'magic_choice': self.cf_select_choice1.value,
}
# Update CustomFields to set default values
for field_name, default_value in CUSTOM_FIELD_DEFAULTS.items():
CustomField.objects.filter(name=field_name).update(default=default_value)
data = {
'name': 'Test Site X',
'slug': 'test-site-x',
}
url = reverse('dcim-api:site-list')
response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
self.assertEqual(response.data['custom_fields']['magic_word'], CUSTOM_FIELD_DEFAULTS['magic_word'])
self.assertEqual(response.data['custom_fields']['magic_number'], str(CUSTOM_FIELD_DEFAULTS['magic_number']))
self.assertEqual(response.data['custom_fields']['is_magic'], bool(CUSTOM_FIELD_DEFAULTS['is_magic']))
self.assertEqual(response.data['custom_fields']['magic_date'], CUSTOM_FIELD_DEFAULTS['magic_date'])
self.assertEqual(response.data['custom_fields']['magic_url'], CUSTOM_FIELD_DEFAULTS['magic_url'])
self.assertEqual(response.data['custom_fields']['magic_choice'], self.cf_select_choice1.pk)
class CustomFieldChoiceAPITest(APITestCase):
def setUp(self):

View File

@@ -413,7 +413,7 @@ class ScriptView(PermissionRequiredMixin, View):
if form.is_valid():
commit = form.cleaned_data.pop('_commit')
output, execution_time = run_script(script, form.cleaned_data, request.FILES, commit)
output, execution_time = run_script(script, form.cleaned_data, request, commit)
return render(request, 'extras/script.html', {
'module': module,