mirror of
https://github.com/netbox-community/netbox.git
synced 2024-05-10 07:54:54 +00:00
Merge branch 'develop' into 3840-limit-vlan-choices
This commit is contained in:
@ -13,6 +13,7 @@ from rest_framework.response import Response
|
||||
from rest_framework.serializers import Field, ModelSerializer, ValidationError
|
||||
from rest_framework.viewsets import ModelViewSet as _ModelViewSet, ViewSet
|
||||
|
||||
from utilities.choices import ChoiceSet
|
||||
from .utils import dict_to_filter_params, dynamic_import
|
||||
|
||||
|
||||
@ -64,14 +65,17 @@ class ChoiceField(Field):
|
||||
Represent a ChoiceField as {'value': <DB value>, 'label': <string>}.
|
||||
"""
|
||||
def __init__(self, choices, **kwargs):
|
||||
self.choiceset = choices
|
||||
self._choices = dict()
|
||||
|
||||
# Unpack grouped choices
|
||||
for k, v in choices:
|
||||
# Unpack grouped choices
|
||||
if type(v) in [list, tuple]:
|
||||
for k2, v2 in v:
|
||||
self._choices[k2] = v2
|
||||
else:
|
||||
self._choices[k] = v
|
||||
|
||||
super().__init__(**kwargs)
|
||||
|
||||
def to_representation(self, obj):
|
||||
@ -81,6 +85,12 @@ class ChoiceField(Field):
|
||||
('value', obj),
|
||||
('label', self._choices[obj])
|
||||
])
|
||||
|
||||
# TODO: Remove in v2.8
|
||||
# Include legacy numeric ID (where applicable)
|
||||
if hasattr(self.choiceset, 'LEGACY_MAP') and obj in self.choiceset.LEGACY_MAP:
|
||||
data['id'] = self.choiceset.LEGACY_MAP.get(obj)
|
||||
|
||||
return data
|
||||
|
||||
def to_internal_value(self, data):
|
||||
@ -104,6 +114,10 @@ class ChoiceField(Field):
|
||||
try:
|
||||
if data in self._choices:
|
||||
return data
|
||||
# Check if data is a legacy numeric ID
|
||||
slug = self.choiceset.id_to_slug(data)
|
||||
if slug is not None:
|
||||
return slug
|
||||
except TypeError: # Input is an unhashable type
|
||||
pass
|
||||
|
||||
@ -315,13 +329,12 @@ class FieldChoicesViewSet(ViewSet):
|
||||
|
||||
# Compile a dict of all fields in this view
|
||||
self._fields = OrderedDict()
|
||||
for cls, field_list in self.fields:
|
||||
for serializer_class, field_list in self.fields:
|
||||
for field_name in field_list:
|
||||
|
||||
model_name = cls._meta.verbose_name.lower().replace(' ', '-')
|
||||
key = ':'.join([model_name, field_name])
|
||||
|
||||
serializer = get_serializer_for_model(cls)()
|
||||
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():
|
||||
|
80
netbox/utilities/choices.py
Normal file
80
netbox/utilities/choices.py
Normal file
@ -0,0 +1,80 @@
|
||||
class ChoiceSetMeta(type):
|
||||
"""
|
||||
Metaclass for ChoiceSet
|
||||
"""
|
||||
def __call__(cls, *args, **kwargs):
|
||||
# Django will check if a 'choices' value is callable, and if so assume that it returns an iterable
|
||||
return getattr(cls, 'CHOICES', ())
|
||||
|
||||
def __iter__(cls):
|
||||
choices = getattr(cls, 'CHOICES', ())
|
||||
return iter(choices)
|
||||
|
||||
|
||||
class ChoiceSet(metaclass=ChoiceSetMeta):
|
||||
|
||||
CHOICES = list()
|
||||
LEGACY_MAP = dict()
|
||||
|
||||
@classmethod
|
||||
def values(cls):
|
||||
return [c[0] for c in cls.CHOICES]
|
||||
|
||||
@classmethod
|
||||
def as_dict(cls):
|
||||
# Unpack grouped choices before casting as a dict
|
||||
return dict(unpack_grouped_choices(cls.CHOICES))
|
||||
|
||||
@classmethod
|
||||
def slug_to_id(cls, slug):
|
||||
"""
|
||||
Return the legacy integer value corresponding to a slug.
|
||||
"""
|
||||
return cls.LEGACY_MAP.get(slug)
|
||||
|
||||
@classmethod
|
||||
def id_to_slug(cls, legacy_id):
|
||||
"""
|
||||
Return the slug value corresponding to a legacy integer value.
|
||||
"""
|
||||
if legacy_id in cls.LEGACY_MAP.values():
|
||||
# Invert the legacy map to allow lookup by integer
|
||||
legacy_map = dict([
|
||||
(id, slug) for slug, id in cls.LEGACY_MAP.items()
|
||||
])
|
||||
return legacy_map.get(legacy_id)
|
||||
|
||||
|
||||
def unpack_grouped_choices(choices):
|
||||
"""
|
||||
Unpack a grouped choices hierarchy into a flat list of two-tuples. For example:
|
||||
|
||||
choices = (
|
||||
('Foo', (
|
||||
(1, 'A'),
|
||||
(2, 'B')
|
||||
)),
|
||||
('Bar', (
|
||||
(3, 'C'),
|
||||
(4, 'D')
|
||||
))
|
||||
)
|
||||
|
||||
becomes:
|
||||
|
||||
choices = (
|
||||
(1, 'A'),
|
||||
(2, 'B'),
|
||||
(3, 'C'),
|
||||
(4, 'D')
|
||||
)
|
||||
"""
|
||||
unpacked_choices = []
|
||||
for key, value in choices:
|
||||
if isinstance(value, (list, tuple)):
|
||||
# Entered an optgroup
|
||||
for optgroup_key, optgroup_value in value:
|
||||
unpacked_choices.append((optgroup_key, optgroup_value))
|
||||
else:
|
||||
unpacked_choices.append((key, value))
|
||||
return unpacked_choices
|
@ -75,7 +75,7 @@ class CustomChoiceFieldInspector(FieldInspector):
|
||||
SwaggerType, _ = self._get_partial_types(field, swagger_object_type, use_references, **kwargs)
|
||||
|
||||
if isinstance(field, ChoiceField):
|
||||
value_schema = openapi.Schema(type=openapi.TYPE_INTEGER)
|
||||
value_schema = openapi.Schema(type=openapi.TYPE_STRING)
|
||||
|
||||
choices = list(field._choices.keys())
|
||||
if set([None] + choices) == {None, True, False}:
|
||||
@ -83,7 +83,7 @@ class CustomChoiceFieldInspector(FieldInspector):
|
||||
# differentiated since they each have subtly different values in their choice keys.
|
||||
# - subdevice_role and connection_status are booleans, although subdevice_role includes None
|
||||
# - face is an integer set {0, 1} which is easily confused with {False, True}
|
||||
schema_type = openapi.TYPE_INTEGER
|
||||
schema_type = openapi.TYPE_STRING
|
||||
if all(type(x) == bool for x in [c for c in choices if c is not None]):
|
||||
schema_type = openapi.TYPE_BOOLEAN
|
||||
value_schema = openapi.Schema(type=schema_type)
|
||||
|
@ -2,12 +2,14 @@ import csv
|
||||
import json
|
||||
import re
|
||||
from io import StringIO
|
||||
import yaml
|
||||
|
||||
from django import forms
|
||||
from django.conf import settings
|
||||
from django.contrib.postgres.forms.jsonb import JSONField as _JSONField, InvalidJSONInput
|
||||
from mptt.forms import TreeNodeMultipleChoiceField
|
||||
|
||||
from .choices import unpack_grouped_choices
|
||||
from .constants import *
|
||||
from .validators import EnhancedURLValidator
|
||||
|
||||
@ -118,43 +120,6 @@ def add_blank_choice(choices):
|
||||
return ((None, '---------'),) + tuple(choices)
|
||||
|
||||
|
||||
def unpack_grouped_choices(choices):
|
||||
"""
|
||||
Unpack a grouped choices hierarchy into a flat list of two-tuples. For example:
|
||||
|
||||
choices = (
|
||||
('Foo', (
|
||||
(1, 'A'),
|
||||
(2, 'B')
|
||||
)),
|
||||
('Bar', (
|
||||
(3, 'C'),
|
||||
(4, 'D')
|
||||
))
|
||||
)
|
||||
|
||||
becomes:
|
||||
|
||||
choices = (
|
||||
(1, 'A'),
|
||||
(2, 'B'),
|
||||
(3, 'C'),
|
||||
(4, 'D')
|
||||
)
|
||||
"""
|
||||
unpacked_choices = []
|
||||
for key, value in choices:
|
||||
if key == 1300:
|
||||
breakme = True
|
||||
if isinstance(value, (list, tuple)):
|
||||
# Entered an optgroup
|
||||
for optgroup_key, optgroup_value in value:
|
||||
unpacked_choices.append((optgroup_key, optgroup_value))
|
||||
else:
|
||||
unpacked_choices.append((key, value))
|
||||
return unpacked_choices
|
||||
|
||||
|
||||
#
|
||||
# Widgets
|
||||
#
|
||||
@ -476,7 +441,7 @@ class CSVChoiceField(forms.ChoiceField):
|
||||
def clean(self, value):
|
||||
value = super().clean(value)
|
||||
if not value:
|
||||
return None
|
||||
return ''
|
||||
if value not in self.choice_values:
|
||||
raise forms.ValidationError("Invalid choice: {}".format(value))
|
||||
return self.choice_values[value]
|
||||
@ -774,3 +739,41 @@ class BulkEditForm(forms.Form):
|
||||
# Copy any nullable fields defined in Meta
|
||||
if hasattr(self.Meta, 'nullable_fields'):
|
||||
self.nullable_fields = self.Meta.nullable_fields
|
||||
|
||||
|
||||
class ImportForm(BootstrapMixin, forms.Form):
|
||||
"""
|
||||
Generic form for creating an object from JSON/YAML data
|
||||
"""
|
||||
data = forms.CharField(
|
||||
widget=forms.Textarea,
|
||||
help_text="Enter object data in JSON or YAML format."
|
||||
)
|
||||
format = forms.ChoiceField(
|
||||
choices=(
|
||||
('json', 'JSON'),
|
||||
('yaml', 'YAML')
|
||||
),
|
||||
initial='yaml'
|
||||
)
|
||||
|
||||
def clean(self):
|
||||
|
||||
data = self.cleaned_data['data']
|
||||
format = self.cleaned_data['format']
|
||||
|
||||
# Process JSON/YAML data
|
||||
if format == 'json':
|
||||
try:
|
||||
self.cleaned_data['data'] = json.loads(data)
|
||||
except json.decoder.JSONDecodeError as err:
|
||||
raise forms.ValidationError({
|
||||
'data': "Invalid JSON data: {}".format(err)
|
||||
})
|
||||
else:
|
||||
try:
|
||||
self.cleaned_data['data'] = yaml.load(data, Loader=yaml.SafeLoader)
|
||||
except yaml.scanner.ScannerError as err:
|
||||
raise forms.ValidationError({
|
||||
'data': "Invalid YAML data: {}".format(err)
|
||||
})
|
||||
|
@ -4,6 +4,11 @@ from extras.models import ObjectChange
|
||||
from utilities.utils import serialize_object
|
||||
|
||||
|
||||
__all__ = (
|
||||
'ChangeLoggedModel',
|
||||
)
|
||||
|
||||
|
||||
class ChangeLoggedModel(models.Model):
|
||||
"""
|
||||
An abstract model which adds fields to store the creation and last-updated times for an object. Both fields can be
|
||||
|
@ -1,3 +1,3 @@
|
||||
<a href="{% url add_url %}" class="btn btn-primary">
|
||||
<a href="{{ add_url }}" class="btn btn-primary">
|
||||
<span class="fa fa-plus" aria-hidden="true"></span> Add
|
||||
</a>
|
||||
|
3
netbox/utilities/templates/buttons/clone.html
Normal file
3
netbox/utilities/templates/buttons/clone.html
Normal file
@ -0,0 +1,3 @@
|
||||
<a href="{{ url }}" class="btn btn-success">
|
||||
<span class="fa fa-plus" aria-hidden="true"></span> Clone
|
||||
</a>
|
3
netbox/utilities/templates/buttons/delete.html
Normal file
3
netbox/utilities/templates/buttons/delete.html
Normal file
@ -0,0 +1,3 @@
|
||||
<a href="{{ url }}" class="btn btn-danger">
|
||||
<span class="fa fa-trash" aria-hidden="true"></span> Delete
|
||||
</a>
|
3
netbox/utilities/templates/buttons/edit.html
Normal file
3
netbox/utilities/templates/buttons/edit.html
Normal file
@ -0,0 +1,3 @@
|
||||
<a href="{{ url }}" class="btn btn-warning">
|
||||
<span class="fa fa-pencil" aria-hidden="true"></span> Edit
|
||||
</a>
|
@ -1,23 +1,106 @@
|
||||
from django import template
|
||||
from django.urls import reverse
|
||||
|
||||
from extras.models import ExportTemplate
|
||||
from utilities.utils import prepare_cloned_fields
|
||||
|
||||
register = template.Library()
|
||||
|
||||
|
||||
def _get_viewname(instance, action):
|
||||
"""
|
||||
Return the appropriate viewname for adding, editing, or deleting an instance.
|
||||
"""
|
||||
|
||||
# Validate action
|
||||
assert action in ('add', 'edit', 'delete')
|
||||
viewname = "{}:{}_{}".format(
|
||||
instance._meta.app_label, instance._meta.model_name, action
|
||||
)
|
||||
|
||||
return viewname
|
||||
|
||||
|
||||
#
|
||||
# Instance buttons
|
||||
#
|
||||
|
||||
@register.inclusion_tag('buttons/clone.html')
|
||||
def clone_button(instance):
|
||||
viewname = _get_viewname(instance, 'add')
|
||||
|
||||
# Populate cloned field values
|
||||
param_string = prepare_cloned_fields(instance)
|
||||
if param_string:
|
||||
url = '{}?{}'.format(reverse(viewname), param_string)
|
||||
|
||||
return {
|
||||
'url': url,
|
||||
}
|
||||
|
||||
|
||||
@register.inclusion_tag('buttons/edit.html')
|
||||
def edit_button(instance, use_pk=False):
|
||||
viewname = _get_viewname(instance, 'edit')
|
||||
|
||||
# Assign kwargs
|
||||
if hasattr(instance, 'slug') and not use_pk:
|
||||
kwargs = {'slug': instance.slug}
|
||||
else:
|
||||
kwargs = {'pk': instance.pk}
|
||||
|
||||
url = reverse(viewname, kwargs=kwargs)
|
||||
|
||||
return {
|
||||
'url': url,
|
||||
}
|
||||
|
||||
|
||||
@register.inclusion_tag('buttons/delete.html')
|
||||
def delete_button(instance, use_pk=False):
|
||||
viewname = _get_viewname(instance, 'delete')
|
||||
|
||||
# Assign kwargs
|
||||
if hasattr(instance, 'slug') and not use_pk:
|
||||
kwargs = {'slug': instance.slug}
|
||||
else:
|
||||
kwargs = {'pk': instance.pk}
|
||||
|
||||
url = reverse(viewname, kwargs=kwargs)
|
||||
|
||||
return {
|
||||
'url': url,
|
||||
}
|
||||
|
||||
|
||||
#
|
||||
# List buttons
|
||||
#
|
||||
|
||||
@register.inclusion_tag('buttons/add.html')
|
||||
def add_button(url):
|
||||
return {'add_url': url}
|
||||
url = reverse(url)
|
||||
|
||||
return {
|
||||
'add_url': url,
|
||||
}
|
||||
|
||||
|
||||
@register.inclusion_tag('buttons/import.html')
|
||||
def import_button(url):
|
||||
return {'import_url': url}
|
||||
|
||||
return {
|
||||
'import_url': url,
|
||||
}
|
||||
|
||||
|
||||
@register.inclusion_tag('buttons/export.html', takes_context=True)
|
||||
def export_button(context, content_type=None):
|
||||
export_templates = ExportTemplate.objects.filter(content_type=content_type)
|
||||
if content_type is not None:
|
||||
export_templates = ExportTemplate.objects.filter(content_type=content_type)
|
||||
else:
|
||||
export_templates = []
|
||||
|
||||
return {
|
||||
'url_params': context['request'].GET,
|
||||
'export_templates': export_templates,
|
||||
|
@ -7,7 +7,7 @@ from django.utils.html import strip_tags
|
||||
from django.utils.safestring import mark_safe
|
||||
from markdown import markdown
|
||||
|
||||
from utilities.forms import unpack_grouped_choices
|
||||
from utilities.choices import unpack_grouped_choices
|
||||
from utilities.utils import foreground_color
|
||||
|
||||
|
||||
|
@ -35,3 +35,30 @@ def create_test_user(username='testuser', permissions=list()):
|
||||
user.user_permissions.add(perm)
|
||||
|
||||
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
|
||||
}
|
||||
|
@ -6,7 +6,7 @@ from django.urls import reverse
|
||||
from rest_framework import status
|
||||
|
||||
from dcim.models import Region, Site
|
||||
from extras.constants import CF_TYPE_TEXT
|
||||
from extras.choices import CustomFieldTypeChoices
|
||||
from extras.models import CustomField
|
||||
from ipam.models import VLAN
|
||||
from utilities.testing import APITestCase
|
||||
@ -132,7 +132,7 @@ class APIDocsTestCase(TestCase):
|
||||
|
||||
# Populate a CustomField to activate CustomFieldSerializer
|
||||
content_type = ContentType.objects.get_for_model(Site)
|
||||
self.cf_text = CustomField(type=CF_TYPE_TEXT, name='test')
|
||||
self.cf_text = CustomField(type=CustomFieldTypeChoices.TYPE_TEXT, name='test')
|
||||
self.cf_text.save()
|
||||
self.cf_text.obj_type.set([content_type])
|
||||
self.cf_text.save()
|
||||
|
@ -6,7 +6,8 @@ from django.core.serializers import serialize
|
||||
from django.db.models import Count, OuterRef, Subquery
|
||||
from jinja2 import Environment
|
||||
|
||||
from dcim.constants import LENGTH_UNIT_CENTIMETER, LENGTH_UNIT_FOOT, LENGTH_UNIT_INCH, LENGTH_UNIT_METER
|
||||
from dcim.choices import CableLengthUnitChoices
|
||||
from extras.utils import is_taggable
|
||||
|
||||
|
||||
def csv_format(data):
|
||||
@ -61,17 +62,6 @@ def dynamic_import(name):
|
||||
return mod
|
||||
|
||||
|
||||
def model_names_to_filter_dict(names):
|
||||
"""
|
||||
Accept a list of content types in the format ['<app>.<model>', '<app>.<model>', ...] and return a dictionary
|
||||
suitable for QuerySet filtering.
|
||||
"""
|
||||
# TODO: This should match on the app_label as well as the model name to avoid potential duplicate names
|
||||
return {
|
||||
'model__in': [model.split('.')[1] for model in names],
|
||||
}
|
||||
|
||||
|
||||
def get_subquery(model, field):
|
||||
"""
|
||||
Return a Subquery suitable for annotating a child object count.
|
||||
@ -104,7 +94,7 @@ def serialize_object(obj, extra=None):
|
||||
}
|
||||
|
||||
# Include any tags
|
||||
if hasattr(obj, 'tags'):
|
||||
if is_taggable(obj):
|
||||
data['tags'] = [tag.name for tag in obj.tags.all()]
|
||||
|
||||
# Append any extra data
|
||||
@ -166,13 +156,20 @@ def to_meters(length, unit):
|
||||
length = int(length)
|
||||
if length < 0:
|
||||
raise ValueError("Length must be a positive integer")
|
||||
if unit == LENGTH_UNIT_METER:
|
||||
|
||||
valid_units = CableLengthUnitChoices.values()
|
||||
if unit not in valid_units:
|
||||
raise ValueError(
|
||||
"Unknown unit {}. Must be one of the following: {}".format(unit, ', '.join(valid_units))
|
||||
)
|
||||
|
||||
if unit == CableLengthUnitChoices.UNIT_METER:
|
||||
return length
|
||||
if unit == LENGTH_UNIT_CENTIMETER:
|
||||
if unit == CableLengthUnitChoices.UNIT_CENTIMETER:
|
||||
return length / 100
|
||||
if unit == LENGTH_UNIT_FOOT:
|
||||
if unit == CableLengthUnitChoices.UNIT_FOOT:
|
||||
return length * 0.3048
|
||||
if unit == LENGTH_UNIT_INCH:
|
||||
if unit == CableLengthUnitChoices.UNIT_INCH:
|
||||
return length * 0.3048 * 12
|
||||
raise ValueError("Unknown unit {}. Must be 'm', 'cm', 'ft', or 'in'.".format(unit))
|
||||
|
||||
@ -182,3 +179,33 @@ def render_jinja2(template_code, context):
|
||||
Render a Jinja2 template with the provided context. Return the rendered content.
|
||||
"""
|
||||
return Environment().from_string(source=template_code).render(**context)
|
||||
|
||||
|
||||
def prepare_cloned_fields(instance):
|
||||
"""
|
||||
Compile an object's `clone_fields` list into a string of URL query parameters. Tags are automatically cloned where
|
||||
applicable.
|
||||
"""
|
||||
params = {}
|
||||
for field_name in getattr(instance, 'clone_fields', []):
|
||||
field = instance._meta.get_field(field_name)
|
||||
field_value = field.value_from_object(instance)
|
||||
|
||||
# Swap out False with URL-friendly value
|
||||
if field_value is False:
|
||||
field_value = ''
|
||||
|
||||
# Omit empty values
|
||||
if field_value not in (None, ''):
|
||||
params[field_name] = field_value
|
||||
|
||||
# Copy tags
|
||||
if is_taggable(instance):
|
||||
params['tags'] = ','.join([t.name for t in instance.tags.all()])
|
||||
|
||||
# Concatenate parameters into a URL query string
|
||||
param_string = '&'.join(
|
||||
['{}={}'.format(k, v) for k, v in params.items()]
|
||||
)
|
||||
|
||||
return param_string
|
||||
|
@ -24,10 +24,12 @@ from django_tables2 import RequestConfig
|
||||
|
||||
from extras.models import CustomField, CustomFieldValue, ExportTemplate
|
||||
from extras.querysets import CustomFieldQueryset
|
||||
from extras.utils import is_taggable
|
||||
from utilities.exceptions import AbortTransaction
|
||||
from utilities.forms import BootstrapMixin, CSVDataField
|
||||
from utilities.utils import csv_format
|
||||
from utilities.utils import csv_format, prepare_cloned_fields
|
||||
from .error_handlers import handle_protectederror
|
||||
from .forms import ConfirmationForm
|
||||
from .forms import ConfirmationForm, ImportForm
|
||||
from .paginator import EnhancedPaginator
|
||||
|
||||
|
||||
@ -68,11 +70,19 @@ class ObjectListView(View):
|
||||
template_name: The name of the template
|
||||
"""
|
||||
queryset = None
|
||||
filter = None
|
||||
filter_form = None
|
||||
filterset = None
|
||||
filterset_form = None
|
||||
table = None
|
||||
template_name = None
|
||||
|
||||
def queryset_to_yaml(self):
|
||||
"""
|
||||
Export the queryset of objects as concatenated YAML documents.
|
||||
"""
|
||||
yaml_data = [obj.to_yaml() for obj in self.queryset]
|
||||
|
||||
return '---\n'.join(yaml_data)
|
||||
|
||||
def queryset_to_csv(self):
|
||||
"""
|
||||
Export the queryset of objects as comma-separated value (CSV), using the model's to_csv() method.
|
||||
@ -88,15 +98,15 @@ class ObjectListView(View):
|
||||
data = csv_format(obj.to_csv())
|
||||
csv_data.append(data)
|
||||
|
||||
return csv_data
|
||||
return '\n'.join(csv_data)
|
||||
|
||||
def get(self, request):
|
||||
|
||||
model = self.queryset.model
|
||||
content_type = ContentType.objects.get_for_model(model)
|
||||
|
||||
if self.filter:
|
||||
self.queryset = self.filter(request.GET, self.queryset).qs
|
||||
if self.filterset:
|
||||
self.queryset = self.filterset(request.GET, self.queryset).qs
|
||||
|
||||
# If this type of object has one or more custom fields, prefetch any relevant custom field values
|
||||
custom_fields = CustomField.objects.filter(
|
||||
@ -119,13 +129,16 @@ class ObjectListView(View):
|
||||
)
|
||||
)
|
||||
|
||||
# Check for YAML export support
|
||||
elif 'export' in request.GET and hasattr(model, 'to_yaml'):
|
||||
response = HttpResponse(self.queryset_to_yaml(), content_type='text/yaml')
|
||||
filename = 'netbox_{}.yaml'.format(self.queryset.model._meta.verbose_name_plural)
|
||||
response['Content-Disposition'] = 'attachment; filename="{}"'.format(filename)
|
||||
return response
|
||||
|
||||
# Fall back to built-in CSV formatting if export requested but no template specified
|
||||
elif 'export' in request.GET and hasattr(model, 'to_csv'):
|
||||
data = self.queryset_to_csv()
|
||||
response = HttpResponse(
|
||||
'\n'.join(data),
|
||||
content_type='text/csv'
|
||||
)
|
||||
response = HttpResponse(self.queryset_to_csv(), content_type='text/csv')
|
||||
filename = 'netbox_{}.csv'.format(self.queryset.model._meta.verbose_name_plural)
|
||||
response['Content-Disposition'] = 'attachment; filename="{}"'.format(filename)
|
||||
return response
|
||||
@ -143,7 +156,7 @@ class ObjectListView(View):
|
||||
table.columns.show('pk')
|
||||
|
||||
# Construct queryset for tags list
|
||||
if hasattr(model, 'tags'):
|
||||
if is_taggable(model):
|
||||
tags = model.tags.annotate(count=Count('extras_taggeditem_items')).order_by('name')
|
||||
else:
|
||||
tags = None
|
||||
@ -159,7 +172,7 @@ class ObjectListView(View):
|
||||
'content_type': content_type,
|
||||
'table': table,
|
||||
'permissions': permissions,
|
||||
'filter_form': self.filter_form(request.GET, label_suffix='') if self.filter_form else None,
|
||||
'filter_form': self.filterset_form(request.GET, label_suffix='') if self.filterset_form else None,
|
||||
'tags': tags,
|
||||
}
|
||||
context.update(self.extra_context())
|
||||
@ -235,6 +248,12 @@ class ObjectEditView(GetReturnURLMixin, View):
|
||||
messages.success(request, mark_safe(msg))
|
||||
|
||||
if '_addanother' in request.POST:
|
||||
|
||||
# If the object has clone_fields, pre-populate a new instance of the form
|
||||
if hasattr(obj, 'clone_fields'):
|
||||
url = '{}?{}'.format(request.path, prepare_cloned_fields(obj))
|
||||
return redirect(url)
|
||||
|
||||
return redirect(request.get_full_path())
|
||||
|
||||
return_url = form.cleaned_data.get('return_url')
|
||||
@ -394,6 +413,106 @@ class BulkCreateView(GetReturnURLMixin, View):
|
||||
})
|
||||
|
||||
|
||||
class ObjectImportView(GetReturnURLMixin, View):
|
||||
"""
|
||||
Import a single object (YAML or JSON format).
|
||||
"""
|
||||
model = None
|
||||
model_form = None
|
||||
related_object_forms = dict()
|
||||
template_name = 'utilities/obj_import.html'
|
||||
|
||||
def get(self, request):
|
||||
|
||||
form = ImportForm()
|
||||
|
||||
return render(request, self.template_name, {
|
||||
'form': form,
|
||||
'obj_type': self.model._meta.verbose_name,
|
||||
'return_url': self.get_return_url(request),
|
||||
})
|
||||
|
||||
def post(self, request):
|
||||
|
||||
form = ImportForm(request.POST)
|
||||
if form.is_valid():
|
||||
|
||||
# Initialize model form
|
||||
data = form.cleaned_data['data']
|
||||
model_form = self.model_form(data)
|
||||
|
||||
# Assign default values for any fields which were not specified. We have to do this manually because passing
|
||||
# 'initial=' to the form on initialization merely sets default values for the widgets. Since widgets are not
|
||||
# used for YAML/JSON import, we first bind the imported data normally, then update the form's data with the
|
||||
# applicable field defaults as needed prior to form validation.
|
||||
for field_name, field in model_form.fields.items():
|
||||
if field_name not in data and hasattr(field, 'initial'):
|
||||
model_form.data[field_name] = field.initial
|
||||
|
||||
if model_form.is_valid():
|
||||
|
||||
try:
|
||||
with transaction.atomic():
|
||||
|
||||
# Save the primary object
|
||||
obj = model_form.save()
|
||||
|
||||
# Iterate through the related object forms (if any), validating and saving each instance.
|
||||
for field_name, related_object_form in self.related_object_forms.items():
|
||||
|
||||
for i, rel_obj_data in enumerate(data.get(field_name, list())):
|
||||
|
||||
f = related_object_form(obj, rel_obj_data)
|
||||
|
||||
for subfield_name, field in f.fields.items():
|
||||
if subfield_name not in rel_obj_data and hasattr(field, 'initial'):
|
||||
f.data[subfield_name] = field.initial
|
||||
|
||||
if f.is_valid():
|
||||
f.save()
|
||||
else:
|
||||
# Replicate errors on the related object form to the primary form for display
|
||||
for subfield_name, errors in f.errors.items():
|
||||
for err in errors:
|
||||
err_msg = "{}[{}] {}: {}".format(field_name, i, subfield_name, err)
|
||||
model_form.add_error(None, err_msg)
|
||||
raise AbortTransaction()
|
||||
|
||||
except AbortTransaction:
|
||||
pass
|
||||
|
||||
if not model_form.errors:
|
||||
|
||||
messages.success(request, mark_safe('Imported object: <a href="{}">{}</a>'.format(
|
||||
obj.get_absolute_url(), obj
|
||||
)))
|
||||
|
||||
if '_addanother' in request.POST:
|
||||
return redirect(request.get_full_path())
|
||||
|
||||
return_url = form.cleaned_data.get('return_url')
|
||||
if return_url is not None and is_safe_url(url=return_url, allowed_hosts=request.get_host()):
|
||||
return redirect(return_url)
|
||||
else:
|
||||
return redirect(self.get_return_url(request, obj))
|
||||
|
||||
else:
|
||||
|
||||
# Replicate model form errors for display
|
||||
for field, errors in model_form.errors.items():
|
||||
for err in errors:
|
||||
if field == '__all__':
|
||||
form.add_error(None, err)
|
||||
else:
|
||||
form.add_error(None, "{}: {}".format(field, err))
|
||||
|
||||
return render(request, self.template_name, {
|
||||
'form': form,
|
||||
'obj_type': self.model._meta.verbose_name,
|
||||
'return_url': self.get_return_url(request),
|
||||
})
|
||||
|
||||
|
||||
class BulkImportView(GetReturnURLMixin, View):
|
||||
"""
|
||||
Import objects in bulk (CSV format).
|
||||
@ -405,7 +524,7 @@ class BulkImportView(GetReturnURLMixin, View):
|
||||
"""
|
||||
model_form = None
|
||||
table = None
|
||||
template_name = 'utilities/obj_import.html'
|
||||
template_name = 'utilities/obj_bulk_import.html'
|
||||
widget_attrs = {}
|
||||
|
||||
def _import_form(self, *args, **kwargs):
|
||||
@ -490,7 +609,7 @@ class BulkEditView(GetReturnURLMixin, View):
|
||||
"""
|
||||
queryset = None
|
||||
parent_model = None
|
||||
filter = None
|
||||
filterset = None
|
||||
table = None
|
||||
form = None
|
||||
template_name = 'utilities/obj_bulk_edit.html'
|
||||
@ -509,8 +628,8 @@ class BulkEditView(GetReturnURLMixin, View):
|
||||
parent_obj = None
|
||||
|
||||
# Are we editing *all* objects in the queryset or just a selected subset?
|
||||
if request.POST.get('_all') and self.filter is not None:
|
||||
pk_list = [obj.pk for obj in self.filter(request.GET, model.objects.only('pk')).qs]
|
||||
if request.POST.get('_all') and self.filterset is not None:
|
||||
pk_list = [obj.pk for obj in self.filterset(request.GET, model.objects.only('pk')).qs]
|
||||
else:
|
||||
pk_list = [int(pk) for pk in request.POST.getlist('pk')]
|
||||
|
||||
@ -611,7 +730,7 @@ class BulkDeleteView(GetReturnURLMixin, View):
|
||||
"""
|
||||
queryset = None
|
||||
parent_model = None
|
||||
filter = None
|
||||
filterset = None
|
||||
table = None
|
||||
form = None
|
||||
template_name = 'utilities/obj_bulk_delete.html'
|
||||
@ -631,8 +750,8 @@ class BulkDeleteView(GetReturnURLMixin, View):
|
||||
|
||||
# Are we deleting *all* objects in the queryset or just a selected subset?
|
||||
if request.POST.get('_all'):
|
||||
if self.filter is not None:
|
||||
pk_list = [obj.pk for obj in self.filter(request.GET, model.objects.only('pk')).qs]
|
||||
if self.filterset is not None:
|
||||
pk_list = [obj.pk for obj in self.filterset(request.GET, model.objects.only('pk')).qs]
|
||||
else:
|
||||
pk_list = model.objects.values_list('pk', flat=True)
|
||||
else:
|
||||
@ -775,7 +894,7 @@ class BulkComponentCreateView(GetReturnURLMixin, View):
|
||||
form = None
|
||||
model = None
|
||||
model_form = None
|
||||
filter = None
|
||||
filterset = None
|
||||
table = None
|
||||
template_name = 'utilities/obj_bulk_add_component.html'
|
||||
|
||||
@ -785,8 +904,8 @@ class BulkComponentCreateView(GetReturnURLMixin, View):
|
||||
model_name = self.model._meta.verbose_name_plural
|
||||
|
||||
# Are we editing *all* objects in the queryset or just a selected subset?
|
||||
if request.POST.get('_all') and self.filter is not None:
|
||||
pk_list = [obj.pk for obj in self.filter(request.GET, self.parent_model.objects.only('pk')).qs]
|
||||
if request.POST.get('_all') and self.filterset is not None:
|
||||
pk_list = [obj.pk for obj in self.filterset(request.GET, self.parent_model.objects.only('pk')).qs]
|
||||
else:
|
||||
pk_list = [int(pk) for pk in request.POST.getlist('pk')]
|
||||
|
||||
|
Reference in New Issue
Block a user