Convert category parameter forms to use the API (#3130)

* Moving PartCategoryParameterTemplate model to the API

- Add detail API endpoint
- Add 'create' action to LIST endpoint

* Update settings page to use the new API forms

* Remove old views / forms

* Update API version

* Fix table buttons

* Add title to deletion form

* Add unit tests for new API views
This commit is contained in:
Oliver 2022-06-06 00:25:08 +10:00 committed by GitHub
parent 80b10c62f3
commit bbbfd003e0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 124 additions and 250 deletions

View File

@ -2,11 +2,14 @@
# InvenTree API version
INVENTREE_API_VERSION = 56
INVENTREE_API_VERSION = 57
"""
Increment this API version number whenever there is a significant change to the API that any clients need to know about
v57 -> 2022-06-05 : https://github.com/inventree/InvenTree/pull/3130
- Transfer PartCategoryTemplateParameter actions to the API
v56 -> 2022-06-02 : https://github.com/inventree/InvenTree/pull/3123
- Expose the PartParameterTemplate model to use the API

View File

@ -194,7 +194,7 @@ class CategoryMetadata(generics.RetrieveUpdateAPIView):
queryset = PartCategory.objects.all()
class CategoryParameterList(generics.ListAPIView):
class CategoryParameterList(generics.ListCreateAPIView):
"""API endpoint for accessing a list of PartCategoryParameterTemplate objects.
- GET: Return a list of PartCategoryParameterTemplate objects
@ -235,6 +235,13 @@ class CategoryParameterList(generics.ListAPIView):
return queryset
class CategoryParameterDetail(generics.RetrieveUpdateDestroyAPIView):
"""Detail endpoint fro the PartCategoryParameterTemplate model"""
queryset = PartCategoryParameterTemplate.objects.all()
serializer_class = part_serializers.CategoryParameterTemplateSerializer
class CategoryTree(generics.ListAPIView):
"""API endpoint for accessing a list of PartCategory objects ready for rendering a tree."""
@ -1855,7 +1862,11 @@ part_api_urls = [
# Base URL for PartCategory API endpoints
re_path(r'^category/', include([
re_path(r'^tree/', CategoryTree.as_view(), name='api-part-category-tree'),
re_path(r'^parameters/', CategoryParameterList.as_view(), name='api-part-category-parameter-list'),
re_path(r'^parameters/', include([
re_path('^(?P<pk>\d+)/', CategoryParameterDetail.as_view(), name='api-part-category-parameter-detail'),
re_path('^.*$', CategoryParameterList.as_view(), name='api-part-category-parameter-list'),
])),
# Category detail endpoints
re_path(r'^(?P<pk>\d+)/', include([

View File

@ -10,8 +10,8 @@ from InvenTree.fields import RoundingDecimalFormField
from InvenTree.forms import HelperForm
from InvenTree.helpers import clean_decimal
from .models import (Part, PartCategory, PartCategoryParameterTemplate,
PartInternalPriceBreak, PartSellPriceBreak)
from .models import (Part, PartCategory, PartInternalPriceBreak,
PartSellPriceBreak)
class PartImageDownloadForm(HelperForm):
@ -59,29 +59,6 @@ class SetPartCategoryForm(forms.Form):
part_category = TreeNodeChoiceField(queryset=PartCategory.objects.all(), required=True, help_text=_('Select part category'))
class EditCategoryParameterTemplateForm(HelperForm):
"""Form for editing a PartCategoryParameterTemplate object."""
add_to_same_level_categories = forms.BooleanField(required=False,
initial=False,
help_text=_('Add parameter template to same level categories'))
add_to_all_categories = forms.BooleanField(required=False,
initial=False,
help_text=_('Add parameter template to all categories'))
class Meta:
"""Metaclass defines fields for this form"""
model = PartCategoryParameterTemplate
fields = [
'category',
'parameter_template',
'default_value',
'add_to_same_level_categories',
'add_to_all_categories',
]
class PartPriceForm(forms.Form):
"""Simple form for viewing part pricing information."""

View File

@ -2383,7 +2383,9 @@ class PartParameter(models.Model):
class PartCategoryParameterTemplate(models.Model):
"""A PartCategoryParameterTemplate creates a unique relationship between a PartCategory and a PartParameterTemplate. Multiple PartParameterTemplate instances can be associated to a PartCategory to drive a default list of parameter templates attached to a Part instance upon creation.
"""A PartCategoryParameterTemplate creates a unique relationship between a PartCategory and a PartParameterTemplate.
Multiple PartParameterTemplate instances can be associated to a PartCategory to drive a default list of parameter templates attached to a Part instance upon creation.
Attributes:
category: Reference to a single PartCategory object

View File

@ -753,10 +753,9 @@ class BomItemSerializer(InvenTreeModelSerializer):
class CategoryParameterTemplateSerializer(InvenTreeModelSerializer):
"""Serializer for PartCategoryParameterTemplate."""
"""Serializer for the PartCategoryParameterTemplate model."""
parameter_template = PartParameterTemplateSerializer(many=False,
read_only=True)
parameter_template_detail = PartParameterTemplateSerializer(source='parameter_template', many=False, read_only=True)
category_detail = CategorySerializer(source='category', many=False, read_only=True)
@ -768,6 +767,7 @@ class CategoryParameterTemplateSerializer(InvenTreeModelSerializer):
'category',
'category_detail',
'parameter_template',
'parameter_template_detail',
'default_value',
]

View File

@ -14,6 +14,7 @@ from InvenTree.api_tester import InvenTreeAPITestCase
from InvenTree.status_codes import (BuildStatus, PurchaseOrderStatus,
StockStatus)
from part.models import (BomItem, BomItemSubstitute, Part, PartCategory,
PartCategoryParameterTemplate, PartParameterTemplate,
PartRelated)
from stock.models import StockItem, StockLocation
@ -24,6 +25,7 @@ class PartCategoryAPITest(InvenTreeAPITestCase):
fixtures = [
'category',
'part',
'params',
'location',
'bom',
'company',
@ -40,6 +42,7 @@ class PartCategoryAPITest(InvenTreeAPITestCase):
'part.delete',
'part_category.change',
'part_category.add',
'part_category.delete',
]
def test_category_list(self):
@ -94,6 +97,57 @@ class PartCategoryAPITest(InvenTreeAPITestCase):
self.assertEqual(metadata['water'], 'melon')
self.assertEqual(metadata['abc'], 'ABC')
def test_category_parameters(self):
"""Test that the PartCategoryParameterTemplate API function work"""
url = reverse('api-part-category-parameter-list')
response = self.get(url, {}, expected_code=200)
self.assertEqual(len(response.data), 2)
# Add some more category templates via the API
n = PartParameterTemplate.objects.count()
for template in PartParameterTemplate.objects.all():
response = self.post(
url,
{
'category': 2,
'parameter_template': template.pk,
'default_value': 'xyz',
}
)
# Total number of category templates should have increased
response = self.get(url, {}, expected_code=200)
self.assertEqual(len(response.data), 2 + n)
# Filter by category
response = self.get(
url,
{
'category': 2,
}
)
self.assertEqual(len(response.data), n)
# Test that we can retrieve individual templates via the API
for template in PartCategoryParameterTemplate.objects.all():
url = reverse('api-part-category-parameter-detail', kwargs={'pk': template.pk})
data = self.get(url, {}, expected_code=200).data
for key in ['pk', 'category', 'category_detail', 'parameter_template', 'parameter_template_detail', 'default_value']:
self.assertIn(key, data.keys())
# Test that we can delete via the API also
response = self.delete(url, expected_code=204)
# There should not be any templates left at this point
self.assertEqual(PartCategoryParameterTemplate.objects.count(), 0)
class PartOptionsAPITest(InvenTreeAPITestCase):
"""Tests for the various OPTIONS endpoints in the /part/ API.

View File

@ -28,12 +28,6 @@ part_detail_urls = [
re_path(r'^.*$', views.PartDetail.as_view(), name='part-detail'),
]
category_parameter_urls = [
re_path(r'^new/', views.CategoryParameterTemplateCreate.as_view(), name='category-param-template-create'),
re_path(r'^(?P<pid>\d+)/edit/', views.CategoryParameterTemplateEdit.as_view(), name='category-param-template-edit'),
re_path(r'^(?P<pid>\d+)/delete/', views.CategoryParameterTemplateDelete.as_view(), name='category-param-template-delete'),
]
category_urls = [
# Top level subcategory display
@ -42,8 +36,6 @@ category_urls = [
# Category detail views
re_path(r'(?P<pk>\d+)/', include([
re_path(r'^delete/', views.CategoryDelete.as_view(), name='category-delete'),
re_path(r'^parameters/', include(category_parameter_urls)),
# Anything else
re_path(r'^.*$', views.CategoryDetail.as_view(), name='category-detail'),
]))

View File

@ -9,8 +9,6 @@ from django.contrib import messages
from django.core.exceptions import ValidationError
from django.core.files.base import ContentFile
from django.db import transaction
from django.db.utils import IntegrityError
from django.forms import HiddenInput
from django.shortcuts import HttpResponseRedirect, get_object_or_404
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
@ -27,8 +25,8 @@ from common.models import InvenTreeSetting
from common.views import FileManagementAjaxView, FileManagementFormView
from company.models import SupplierPart
from InvenTree.helpers import str2bool
from InvenTree.views import (AjaxCreateView, AjaxDeleteView, AjaxUpdateView,
AjaxView, InvenTreeRoleMixin, QRCodeView)
from InvenTree.views import (AjaxDeleteView, AjaxUpdateView, AjaxView,
InvenTreeRoleMixin, QRCodeView)
from order.models import PurchaseOrderLineItem
from plugin.views import InvenTreePluginViewMixin
from stock.models import StockItem, StockLocation
@ -36,7 +34,7 @@ from stock.models import StockItem, StockLocation
from . import forms as part_forms
from . import settings as part_settings
from .bom import ExportBom, IsValidBOMFormat, MakeBomTemplate
from .models import Part, PartCategory, PartCategoryParameterTemplate
from .models import Part, PartCategory
class PartIndex(InvenTreeRoleMixin, ListView):
@ -984,185 +982,3 @@ class CategoryDelete(AjaxDeleteView):
return {
'danger': _('Part category was deleted'),
}
class CategoryParameterTemplateCreate(AjaxCreateView):
"""View for creating a new PartCategoryParameterTemplate."""
model = PartCategoryParameterTemplate
form_class = part_forms.EditCategoryParameterTemplateForm
ajax_form_title = _('Create Category Parameter Template')
def get_initial(self):
"""Get initial data for Category."""
initials = super().get_initial()
category_id = self.kwargs.get('pk', None)
if category_id:
try:
initials['category'] = PartCategory.objects.get(pk=category_id)
except (PartCategory.DoesNotExist, ValueError):
pass
return initials
def get_form(self):
"""Create a form to upload a new CategoryParameterTemplate.
- Hide the 'category' field (parent part)
- Display parameter templates which are not yet related
"""
form = super().get_form()
form.fields['category'].widget = HiddenInput()
if form.is_valid():
form.cleaned_data['category'] = self.kwargs.get('pk', None)
try:
# Get selected category
category = self.get_initial()['category']
# Get existing parameter templates
parameters = [template.parameter_template.pk
for template in category.get_parameter_templates()]
# Exclude templates already linked to category
updated_choices = []
for choice in form.fields["parameter_template"].choices:
if (choice[0] not in parameters):
updated_choices.append(choice)
# Update choices for parameter templates
form.fields['parameter_template'].choices = updated_choices
except KeyError:
pass
return form
def post(self, request, *args, **kwargs):
"""Capture the POST request.
- If the add_to_all_categories object is set, link parameter template to
all categories
- If the add_to_same_level_categories object is set, link parameter template to
same level categories
"""
form = self.get_form()
valid = form.is_valid()
if valid:
add_to_same_level_categories = form.cleaned_data['add_to_same_level_categories']
add_to_all_categories = form.cleaned_data['add_to_all_categories']
selected_category = PartCategory.objects.get(pk=int(self.kwargs['pk']))
parameter_template = form.cleaned_data['parameter_template']
default_value = form.cleaned_data['default_value']
categories = PartCategory.objects.all()
if add_to_same_level_categories and not add_to_all_categories:
# Get level
level = selected_category.level
# Filter same level categories
categories = categories.filter(level=level)
if add_to_same_level_categories or add_to_all_categories:
# Add parameter template and default value to categories
for category in categories:
# Skip selected category (will be processed in the post call)
if category.pk != selected_category.pk:
try:
cat_template = PartCategoryParameterTemplate.objects.create(category=category,
parameter_template=parameter_template,
default_value=default_value)
cat_template.save()
except IntegrityError:
# Parameter template is already linked to category
pass
return super().post(request, *args, **kwargs)
class CategoryParameterTemplateEdit(AjaxUpdateView):
"""View for editing a PartCategoryParameterTemplate."""
model = PartCategoryParameterTemplate
form_class = part_forms.EditCategoryParameterTemplateForm
ajax_form_title = _('Edit Category Parameter Template')
def get_object(self):
"""Returns the PartCategoryParameterTemplate associated with this view
- First, attempt lookup based on supplied 'pid' kwarg
- Else, attempt lookup based on supplied 'pk' kwarg
"""
try:
self.object = self.model.objects.get(pk=self.kwargs['pid'])
except:
return None
return self.object
def get_form(self):
"""Create a form to upload a new CategoryParameterTemplate.
- Hide the 'category' field (parent part)
- Display parameter templates which are not yet related
"""
form = super().get_form()
form.fields['category'].widget = HiddenInput()
form.fields['add_to_all_categories'].widget = HiddenInput()
form.fields['add_to_same_level_categories'].widget = HiddenInput()
if form.is_valid():
form.cleaned_data['category'] = self.kwargs.get('pk', None)
try:
# Get selected category
category = PartCategory.objects.get(pk=self.kwargs.get('pk', None))
# Get selected template
selected_template = self.get_object().parameter_template
# Get existing parameter templates
parameters = [template.parameter_template.pk
for template in category.get_parameter_templates()
if template.parameter_template.pk != selected_template.pk]
# Exclude templates already linked to category
updated_choices = []
for choice in form.fields["parameter_template"].choices:
if (choice[0] not in parameters):
updated_choices.append(choice)
# Update choices for parameter templates
form.fields['parameter_template'].choices = updated_choices
# Set initial choice to current template
form.fields['parameter_template'].initial = selected_template
except KeyError:
pass
return form
class CategoryParameterTemplateDelete(AjaxDeleteView):
"""View for deleting an existing PartCategoryParameterTemplate."""
model = PartCategoryParameterTemplate
ajax_form_title = _("Delete Category Parameter Template")
def get_object(self):
"""Returns the PartCategoryParameterTemplate associated with this view
- First, attempt lookup based on supplied 'pid' kwarg
- Else, attempt lookup based on supplied 'pk' kwarg
"""
try:
self.object = self.model.objects.get(pk=self.kwargs['pid'])
except:
return None
return self.object

View File

@ -8,14 +8,14 @@
{% endblock %}
{% block actions %}
<button class='btn btn-success' id='new-cat-param' disabled=''>
<button class='btn btn-success' id='new-cat-param'>
<div class='fas fa-plus-circle'></div> {% trans "New Parameter" %}
</button>
{% endblock %}
{% block content %}
<div class='row'>
<div class='row' id='cat-param-buttons'>
<form action=''>
<div class='col-sm-6' style='width: 250px'>
<div class='form-group'><div class='controls'>

View File

@ -222,7 +222,7 @@ $('#cat-param-table').inventreeTable({
switchable: false,
},
{
field: 'parameter_template.name',
field: 'parameter_template_detail.name',
title: '{% trans "Parameter Template" %}',
sortable: 'true',
},
@ -249,18 +249,23 @@ $('#cat-param-table').inventreeTable({
function loadTemplateTable(pk) {
// Enable the buttons
$('#new-cat-param').removeAttr('disabled');
var query = {};
if (pk) {
query['category'] = pk;
}
// Load the parameter table
$("#cat-param-table").bootstrapTable('refresh', {
query: {
category: pk,
},
query: query,
url: '{% url "api-part-category-parameter-list" %}',
});
}
// Initially load table with *all* categories
loadTemplateTable();
$('body').on('change', '#category-select', function() {
var pk = $(this).val();
loadTemplateTable(pk);
@ -270,14 +275,20 @@ $("#new-cat-param").click(function() {
var pk = $('#category-select').val();
launchModalForm(`/part/category/${pk}/parameters/new/`, {
success: function() {
$("#cat-param-table").bootstrapTable('refresh', {
query: {
category: pk,
}
});
constructForm('{% url "api-part-category-parameter-list" %}', {
title: '{% trans "Create Category Parameter Template" %}',
method: 'POST',
fields: {
parameter_template: {},
category: {
icon: 'fa-sitemap',
value: pk,
},
default_value: {},
},
onSuccess: function() {
loadTemplateTable(pk);
}
});
});
@ -286,15 +297,21 @@ $("#cat-param-table").on('click', '.template-edit', function() {
var category = $('#category-select').val();
var pk = $(this).attr('pk');
var url = `/part/category/${category}/parameters/${pk}/edit/`;
launchModalForm(url, {
success: function() {
$("#cat-param-table").bootstrapTable('refresh');
constructForm(`/api/part/category/parameters/${pk}/`, {
fields: {
parameter_template: {},
category: {
icon: 'fa-sitemap',
},
default_value: {},
},
onSuccess: function() {
loadTemplateTable(pk);
}
});
});
$("#cat-param-table").on('click', '.template-delete', function() {
var category = $('#category-select').val();
@ -302,9 +319,11 @@ $("#cat-param-table").on('click', '.template-delete', function() {
var url = `/part/category/${category}/parameters/${pk}/delete/`;
launchModalForm(url, {
success: function() {
$("#cat-param-table").bootstrapTable('refresh');
constructForm(`/api/part/category/parameters/${pk}/`, {
method: 'DELETE',
title: '{% trans "Delete Category Parameter Template" %}',
onSuccess: function() {
loadTemplateTable(pk);
}
});
});