1
0
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:
Saria Hajjar
2020-01-16 15:09:39 +00:00
1024 changed files with 94096 additions and 6649 deletions

View File

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

View 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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,3 @@
<a href="{{ url }}" class="btn btn-success">
<span class="fa fa-plus" aria-hidden="true"></span> Clone
</a>

View File

@ -0,0 +1,3 @@
<a href="{{ url }}" class="btn btn-danger">
<span class="fa fa-trash" aria-hidden="true"></span> Delete
</a>

View File

@ -0,0 +1,3 @@
<a href="{{ url }}" class="btn btn-warning">
<span class="fa fa-pencil" aria-hidden="true"></span> Edit
</a>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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