2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-04-29 12:06:44 +00:00

Merge pull request #2361 from SchrodingersGat/related-part-api

Related part api
This commit is contained in:
Oliver 2021-11-25 15:45:11 +11:00 committed by GitHub
commit a6327f95a5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 247 additions and 161 deletions

View File

@ -26,7 +26,7 @@ from djmoney.contrib.exchange.exceptions import MissingRate
from decimal import Decimal, InvalidOperation from decimal import Decimal, InvalidOperation
from .models import Part, PartCategory from .models import Part, PartCategory, PartRelated
from .models import BomItem, BomItemSubstitute from .models import BomItem, BomItemSubstitute
from .models import PartParameter, PartParameterTemplate from .models import PartParameter, PartParameterTemplate
from .models import PartAttachment, PartTestTemplate from .models import PartAttachment, PartTestTemplate
@ -901,6 +901,40 @@ class PartList(generics.ListCreateAPIView):
queryset = queryset.filter(pk__in=pks) queryset = queryset.filter(pk__in=pks)
# Filter by 'related' parts?
related = params.get('related', None)
exclude_related = params.get('exclude_related', None)
if related is not None or exclude_related is not None:
try:
pk = related if related is not None else exclude_related
pk = int(pk)
related_part = Part.objects.get(pk=pk)
part_ids = set()
# Return any relationship which points to the part in question
relation_filter = Q(part_1=related_part) | Q(part_2=related_part)
for relation in PartRelated.objects.filter(relation_filter):
if relation.part_1.pk != pk:
part_ids.add(relation.part_1.pk)
if relation.part_2.pk != pk:
part_ids.add(relation.part_2.pk)
if related is not None:
# Only return related results
queryset = queryset.filter(pk__in=[pk for pk in part_ids])
elif exclude_related is not None:
# Exclude related results
queryset = queryset.exclude(pk__in=[pk for pk in part_ids])
except (ValueError, Part.DoesNotExist):
pass
# Filter by 'starred' parts? # Filter by 'starred' parts?
starred = params.get('starred', None) starred = params.get('starred', None)
@ -1017,6 +1051,44 @@ class PartList(generics.ListCreateAPIView):
] ]
class PartRelatedList(generics.ListCreateAPIView):
"""
API endpoint for accessing a list of PartRelated objects
"""
queryset = PartRelated.objects.all()
serializer_class = part_serializers.PartRelationSerializer
def filter_queryset(self, queryset):
queryset = super().filter_queryset(queryset)
params = self.request.query_params
# Add a filter for "part" - we can filter either part_1 or part_2
part = params.get('part', None)
if part is not None:
try:
part = Part.objects.get(pk=part)
queryset = queryset.filter(Q(part_1=part) | Q(part_2=part))
except (ValueError, Part.DoesNotExist):
pass
return queryset
class PartRelatedDetail(generics.RetrieveUpdateDestroyAPIView):
"""
API endpoint for accessing detail view of a PartRelated object
"""
queryset = PartRelated.objects.all()
serializer_class = part_serializers.PartRelationSerializer
class PartParameterTemplateList(generics.ListCreateAPIView): class PartParameterTemplateList(generics.ListCreateAPIView):
""" API endpoint for accessing a list of PartParameterTemplate objects. """ API endpoint for accessing a list of PartParameterTemplate objects.
@ -1441,6 +1513,12 @@ part_api_urls = [
url(r'^.*$', PartInternalPriceList.as_view(), name='api-part-internal-price-list'), url(r'^.*$', PartInternalPriceList.as_view(), name='api-part-internal-price-list'),
])), ])),
# Base URL for PartRelated API endpoints
url(r'^related/', include([
url(r'^(?P<pk>\d+)/', PartRelatedDetail.as_view(), name='api-part-related-detail'),
url(r'^.*$', PartRelatedList.as_view(), name='api-part-related-list'),
])),
# Base URL for PartParameter API endpoints # Base URL for PartParameter API endpoints
url(r'^parameter/', include([ url(r'^parameter/', include([
url(r'^template/$', PartParameterTemplateList.as_view(), name='api-part-parameter-template-list'), url(r'^template/$', PartParameterTemplateList.as_view(), name='api-part-parameter-template-list'),

View File

@ -17,7 +17,7 @@ from InvenTree.fields import RoundingDecimalFormField
import common.models import common.models
from common.forms import MatchItemForm from common.forms import MatchItemForm
from .models import Part, PartCategory, PartRelated from .models import Part, PartCategory
from .models import PartParameterTemplate from .models import PartParameterTemplate
from .models import PartCategoryParameterTemplate from .models import PartCategoryParameterTemplate
from .models import PartSellPriceBreak, PartInternalPriceBreak from .models import PartSellPriceBreak, PartInternalPriceBreak
@ -157,20 +157,6 @@ class BomMatchItemForm(MatchItemForm):
return super().get_special_field(col_guess, row, file_manager) return super().get_special_field(col_guess, row, file_manager)
class CreatePartRelatedForm(HelperForm):
""" Form for creating a PartRelated object """
class Meta:
model = PartRelated
fields = [
'part_1',
'part_2',
]
labels = {
'part_2': _('Related Part'),
}
class SetPartCategoryForm(forms.Form): class SetPartCategoryForm(forms.Form):
""" Form for setting the category of multiple Part objects """ """ Form for setting the category of multiple Part objects """

View File

@ -25,7 +25,7 @@ from InvenTree.status_codes import BuildStatus, PurchaseOrderStatus
from stock.models import StockItem from stock.models import StockItem
from .models import (BomItem, BomItemSubstitute, from .models import (BomItem, BomItemSubstitute,
Part, PartAttachment, PartCategory, Part, PartAttachment, PartCategory, PartRelated,
PartParameter, PartParameterTemplate, PartSellPriceBreak, PartParameter, PartParameterTemplate, PartSellPriceBreak,
PartStar, PartTestTemplate, PartCategoryParameterTemplate, PartStar, PartTestTemplate, PartCategoryParameterTemplate,
PartInternalPriceBreak) PartInternalPriceBreak)
@ -388,6 +388,25 @@ class PartSerializer(InvenTreeModelSerializer):
] ]
class PartRelationSerializer(InvenTreeModelSerializer):
"""
Serializer for a PartRelated model
"""
part_1_detail = PartSerializer(source='part_1', read_only=True, many=False)
part_2_detail = PartSerializer(source='part_2', read_only=True, many=False)
class Meta:
model = PartRelated
fields = [
'pk',
'part_1',
'part_1_detail',
'part_2',
'part_2_detail',
]
class PartStarSerializer(InvenTreeModelSerializer): class PartStarSerializer(InvenTreeModelSerializer):
""" Serializer for a PartStar object """ """ Serializer for a PartStar object """

View File

@ -329,34 +329,8 @@
{% include "filter_list.html" with id="related" %} {% include "filter_list.html" with id="related" %}
</div> </div>
</div> </div>
<table id='table-related-part' class='table table-condensed table-striped' data-toolbar='#related-button-toolbar'> <table id='related-parts-table' class='table table-striped table-condensed' data-toolbar='#related-button-toolbar'></table>
<thead>
<tr>
<th data-field='part' data-serachable='true'>{% trans "Part" %}</th>
</tr>
</thead>
<tbody>
{% for item in part.get_related_parts %}
{% with part_related=item.0 part=item.1 %}
<tr>
<td>
<a class='hover-icon'>
<img class='hover-img-thumb' src='{{ part.get_thumbnail_url }}'>
<img class='hover-img-large' src='{{ part.get_thumbnail_url }}'>
</a>
<a href='/part/{{ part.id }}/'>{{ part }}</a>
<div class='btn-group' style='float: right;'>
{% if roles.part.change %}
<button title='{% trans "Delete" %}' class='btn btn-outline-secondary delete-related-part' url="{% url 'part-related-delete' part_related.id %}" type='button'><span class='fas fa-trash-alt icon-red'/></button>
{% endif %}
</div>
</td>
</tr>
{% endwith %}
{% endfor %}
</tbody>
</table>
</div> </div>
</div> </div>
@ -771,15 +745,32 @@
// Load the "related parts" tab // Load the "related parts" tab
onPanelLoad("related-parts", function() { onPanelLoad("related-parts", function() {
$('#table-related-part').inventreeTable({
}); loadRelatedPartsTable(
"#related-parts-table",
{{ part.pk }}
);
$("#add-related-part").click(function() { $("#add-related-part").click(function() {
launchModalForm("{% url 'part-related-create' %}", {
data: { constructForm('{% url "api-part-related-list" %}', {
part: {{ part.id }}, method: 'POST',
fields: {
part_1: {
hidden: true,
value: {{ part.pk }},
},
part_2: {
label: '{% trans "Related Part" %}',
filters: {
exclude_related: {{ part.pk }},
}
}
}, },
reload: true, title: '{% trans "Add Related Part" %}',
onSuccess: function() {
$('#related-parts-table').bootstrapTable('refresh');
}
}); });
}); });

View File

@ -5,7 +5,7 @@ from django.urls import reverse
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.contrib.auth.models import Group from django.contrib.auth.models import Group
from .models import Part, PartRelated from .models import Part
class PartViewTestCase(TestCase): class PartViewTestCase(TestCase):
@ -145,36 +145,6 @@ class PartDetailTest(PartViewTestCase):
self.assertIn('streaming_content', dir(response)) self.assertIn('streaming_content', dir(response))
class PartRelatedTests(PartViewTestCase):
def test_valid_create(self):
""" test creation of a related part """
# Test GET view
response = self.client.get(reverse('part-related-create'), {'part': 1},
HTTP_X_REQUESTED_WITH='XMLHttpRequest')
self.assertEqual(response.status_code, 200)
# Test POST view with valid form data
response = self.client.post(reverse('part-related-create'), {'part_1': 1, 'part_2': 2},
HTTP_X_REQUESTED_WITH='XMLHttpRequest')
self.assertContains(response, '"form_valid": true', status_code=200)
# Try to create the same relationship with part_1 and part_2 pks reversed
response = self.client.post(reverse('part-related-create'), {'part_1': 2, 'part_2': 1},
HTTP_X_REQUESTED_WITH='XMLHttpRequest')
self.assertContains(response, '"form_valid": false', status_code=200)
# Try to create part related to itself
response = self.client.post(reverse('part-related-create'), {'part_1': 1, 'part_2': 1},
HTTP_X_REQUESTED_WITH='XMLHttpRequest')
self.assertContains(response, '"form_valid": false', status_code=200)
# Check final count
n = PartRelated.objects.all().count()
self.assertEqual(n, 1)
class PartQRTest(PartViewTestCase): class PartQRTest(PartViewTestCase):
""" Tests for the Part QR Code AJAX view """ """ Tests for the Part QR Code AJAX view """

View File

@ -12,10 +12,6 @@ from django.conf.urls import url, include
from . import views from . import views
part_related_urls = [
url(r'^new/?', views.PartRelatedCreate.as_view(), name='part-related-create'),
url(r'^(?P<pk>\d+)/delete/?', views.PartRelatedDelete.as_view(), name='part-related-delete'),
]
sale_price_break_urls = [ sale_price_break_urls = [
url(r'^new/', views.PartSalePriceBreakCreate.as_view(), name='sale-price-break-create'), url(r'^new/', views.PartSalePriceBreakCreate.as_view(), name='sale-price-break-create'),
@ -96,9 +92,6 @@ part_urls = [
# Part category # Part category
url(r'^category/', include(category_urls)), url(r'^category/', include(category_urls)),
# Part related
url(r'^related-parts/', include(part_related_urls)),
# Part price breaks # Part price breaks
url(r'^sale-price/', include(sale_price_break_urls)), url(r'^sale-price/', include(sale_price_break_urls)),

View File

@ -30,7 +30,7 @@ import io
from rapidfuzz import fuzz from rapidfuzz import fuzz
from decimal import Decimal, InvalidOperation from decimal import Decimal, InvalidOperation
from .models import PartCategory, Part, PartRelated from .models import PartCategory, Part
from .models import PartParameterTemplate from .models import PartParameterTemplate
from .models import PartCategoryParameterTemplate from .models import PartCategoryParameterTemplate
from .models import BomItem from .models import BomItem
@ -85,75 +85,6 @@ class PartIndex(InvenTreeRoleMixin, ListView):
return context return context
class PartRelatedCreate(AjaxCreateView):
""" View for creating a new PartRelated object
- The view only makes sense if a Part object is passed to it
"""
model = PartRelated
form_class = part_forms.CreatePartRelatedForm
ajax_form_title = _("Add Related Part")
ajax_template_name = "modal_form.html"
def get_initial(self):
""" Set parent part as part_1 field """
initials = {}
part_id = self.request.GET.get('part', None)
if part_id:
try:
initials['part_1'] = Part.objects.get(pk=part_id)
except (Part.DoesNotExist, ValueError):
pass
return initials
def get_form(self):
""" Create a form to upload a new PartRelated
- Hide the 'part_1' field (parent part)
- Display parts which are not yet related
"""
form = super(AjaxCreateView, self).get_form()
form.fields['part_1'].widget = HiddenInput()
try:
# Get parent part
parent_part = self.get_initial()['part_1']
# Get existing related parts
related_parts = [related_part[1].pk for related_part in parent_part.get_related_parts()]
# Build updated choice list excluding
# - parts already related to parent part
# - the parent part itself
updated_choices = []
for choice in form.fields["part_2"].choices:
if (choice[0] not in related_parts) and (choice[0] != parent_part.pk):
updated_choices.append(choice)
# Update choices for related part
form.fields['part_2'].choices = updated_choices
except KeyError:
pass
return form
class PartRelatedDelete(AjaxDeleteView):
""" View for deleting a PartRelated object """
model = PartRelated
ajax_form_title = _("Delete Related Part")
context_object_name = "related"
# Explicit role requirement
role_required = 'part.change'
class PartSetCategory(AjaxUpdateView): class PartSetCategory(AjaxUpdateView):
""" View for settings the part category for multiple parts at once """ """ View for settings the part category for multiple parts at once """

View File

@ -273,7 +273,7 @@ function setupFilterList(tableKey, table, target) {
var element = $(target); var element = $(target);
if (!element) { if (!element || !element.exists()) {
console.log(`WARNING: setupFilterList could not find target '${target}'`); console.log(`WARNING: setupFilterList could not find target '${target}'`);
return; return;
} }

View File

@ -32,6 +32,7 @@
loadPartTable, loadPartTable,
loadPartTestTemplateTable, loadPartTestTemplateTable,
loadPartVariantTable, loadPartVariantTable,
loadRelatedPartsTable,
loadSellPricingChart, loadSellPricingChart,
loadSimplePartTable, loadSimplePartTable,
loadStockPricingChart, loadStockPricingChart,
@ -705,6 +706,97 @@ function loadPartParameterTable(table, url, options) {
} }
function loadRelatedPartsTable(table, part_id, options={}) {
/*
* Load table of "related" parts
*/
options.params = options.params || {};
options.params.part = part_id;
var filters = {};
for (var key in options.params) {
filters[key] = options.params[key];
}
setupFilterList('related', $(table), options.filterTarget);
function getPart(row) {
if (row.part_1 == part_id) {
return row.part_2_detail;
} else {
return row.part_1_detail;
}
}
var columns = [
{
field: 'name',
title: '{% trans "Part" %}',
switchable: false,
formatter: function(value, row) {
var part = getPart(row);
var html = imageHoverIcon(part.thumbnail) + renderLink(part.full_name, `/part/${part.pk}/`);
html += makePartIcons(part);
return html;
}
},
{
field: 'description',
title: '{% trans "Description" %}',
formatter: function(value, row) {
return getPart(row).description;
}
},
{
field: 'actions',
title: '',
switchable: false,
formatter: function(value, row) {
var html = `<div class='btn-group float-right' role='group'>`;
html += makeIconButton('fa-trash-alt icon-red', 'button-related-delete', row.pk, '{% trans "Delete part relationship" %}');
html += '</div>';
return html;
}
}
];
$(table).inventreeTable({
url: '{% url "api-part-related-list" %}',
groupBy: false,
name: 'related',
original: options.params,
queryParams: filters,
columns: columns,
showColumns: false,
search: true,
onPostBody: function() {
$(table).find('.button-related-delete').click(function() {
var pk = $(this).attr('pk');
constructForm(`/api/part/related/${pk}/`, {
method: 'DELETE',
title: '{% trans "Delete Part Relationship" %}',
onSuccess: function() {
$(table).bootstrapTable('refresh');
}
});
});
},
});
}
function loadParametricPartTable(table, options={}) { function loadParametricPartTable(table, options={}) {
/* Load parametric table for part parameters /* Load parametric table for part parameters
* *
@ -836,6 +928,7 @@ function loadPartTable(table, url, options={}) {
* query: extra query params for API request * query: extra query params for API request
* buttons: If provided, link buttons to selection status of this table * buttons: If provided, link buttons to selection status of this table
* disableFilters: If true, disable custom filters * disableFilters: If true, disable custom filters
* actions: Provide a callback function to construct an "actions" column
*/ */
// Ensure category detail is included // Ensure category detail is included
@ -895,7 +988,7 @@ function loadPartTable(table, url, options={}) {
var name = row.full_name; var name = row.full_name;
var display = imageHoverIcon(row.thumbnail) + renderLink(name, '/part/' + row.pk + '/'); var display = imageHoverIcon(row.thumbnail) + renderLink(name, `/part/${row.pk}/`);
display += makePartIcons(row); display += makePartIcons(row);
@ -993,6 +1086,21 @@ function loadPartTable(table, url, options={}) {
} }
}); });
// Push an "actions" column
if (options.actions) {
columns.push({
field: 'actions',
title: '',
switchable: false,
visible: true,
searchable: false,
sortable: false,
formatter: function(value, row) {
return options.actions(value, row);
}
});
}
var grid_view = options.gridView && inventreeLoad('part-grid-view') == 1; var grid_view = options.gridView && inventreeLoad('part-grid-view') == 1;
$(table).inventreeTable({ $(table).inventreeTable({
@ -1020,6 +1128,10 @@ function loadPartTable(table, url, options={}) {
$('#view-part-grid').removeClass('btn-secondary').addClass('btn-outline-secondary'); $('#view-part-grid').removeClass('btn-secondary').addClass('btn-outline-secondary');
$('#view-part-list').removeClass('btn-outline-secondary').addClass('btn-secondary'); $('#view-part-list').removeClass('btn-outline-secondary').addClass('btn-secondary');
} }
if (options.onPostBody) {
options.onPostBody();
}
}, },
buttons: options.gridView ? [ buttons: options.gridView ? [
{ {

View File

@ -74,6 +74,12 @@ function getAvailableTableFilters(tableKey) {
}; };
} }
// Filters for the "related parts" table
if (tableKey == 'related') {
return {
};
}
// Filters for the "used in" table // Filters for the "used in" table
if (tableKey == 'usedin') { if (tableKey == 'usedin') {
return { return {