mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +00:00
Merge pull request #2361 from SchrodingersGat/related-part-api
Related part api
This commit is contained in:
commit
a6327f95a5
@ -26,7 +26,7 @@ from djmoney.contrib.exchange.exceptions import MissingRate
|
||||
|
||||
from decimal import Decimal, InvalidOperation
|
||||
|
||||
from .models import Part, PartCategory
|
||||
from .models import Part, PartCategory, PartRelated
|
||||
from .models import BomItem, BomItemSubstitute
|
||||
from .models import PartParameter, PartParameterTemplate
|
||||
from .models import PartAttachment, PartTestTemplate
|
||||
@ -901,6 +901,40 @@ class PartList(generics.ListCreateAPIView):
|
||||
|
||||
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?
|
||||
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):
|
||||
""" 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'),
|
||||
])),
|
||||
|
||||
# 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
|
||||
url(r'^parameter/', include([
|
||||
url(r'^template/$', PartParameterTemplateList.as_view(), name='api-part-parameter-template-list'),
|
||||
|
@ -17,7 +17,7 @@ from InvenTree.fields import RoundingDecimalFormField
|
||||
import common.models
|
||||
from common.forms import MatchItemForm
|
||||
|
||||
from .models import Part, PartCategory, PartRelated
|
||||
from .models import Part, PartCategory
|
||||
from .models import PartParameterTemplate
|
||||
from .models import PartCategoryParameterTemplate
|
||||
from .models import PartSellPriceBreak, PartInternalPriceBreak
|
||||
@ -157,20 +157,6 @@ class BomMatchItemForm(MatchItemForm):
|
||||
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):
|
||||
""" Form for setting the category of multiple Part objects """
|
||||
|
||||
|
@ -25,7 +25,7 @@ from InvenTree.status_codes import BuildStatus, PurchaseOrderStatus
|
||||
from stock.models import StockItem
|
||||
|
||||
from .models import (BomItem, BomItemSubstitute,
|
||||
Part, PartAttachment, PartCategory,
|
||||
Part, PartAttachment, PartCategory, PartRelated,
|
||||
PartParameter, PartParameterTemplate, PartSellPriceBreak,
|
||||
PartStar, PartTestTemplate, PartCategoryParameterTemplate,
|
||||
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):
|
||||
""" Serializer for a PartStar object """
|
||||
|
||||
|
@ -329,34 +329,8 @@
|
||||
{% include "filter_list.html" with id="related" %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<table id='table-related-part' class='table table-condensed table-striped' data-toolbar='#related-button-toolbar'>
|
||||
<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>
|
||||
|
||||
<table id='related-parts-table' class='table table-striped table-condensed' data-toolbar='#related-button-toolbar'></table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -771,15 +745,32 @@
|
||||
|
||||
// Load the "related parts" tab
|
||||
onPanelLoad("related-parts", function() {
|
||||
$('#table-related-part').inventreeTable({
|
||||
});
|
||||
|
||||
loadRelatedPartsTable(
|
||||
"#related-parts-table",
|
||||
{{ part.pk }}
|
||||
);
|
||||
|
||||
$("#add-related-part").click(function() {
|
||||
launchModalForm("{% url 'part-related-create' %}", {
|
||||
data: {
|
||||
part: {{ part.id }},
|
||||
|
||||
constructForm('{% url "api-part-related-list" %}', {
|
||||
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');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -5,7 +5,7 @@ from django.urls import reverse
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.auth.models import Group
|
||||
|
||||
from .models import Part, PartRelated
|
||||
from .models import Part
|
||||
|
||||
|
||||
class PartViewTestCase(TestCase):
|
||||
@ -145,36 +145,6 @@ class PartDetailTest(PartViewTestCase):
|
||||
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):
|
||||
""" Tests for the Part QR Code AJAX view """
|
||||
|
||||
|
@ -12,10 +12,6 @@ from django.conf.urls import url, include
|
||||
|
||||
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 = [
|
||||
url(r'^new/', views.PartSalePriceBreakCreate.as_view(), name='sale-price-break-create'),
|
||||
@ -96,9 +92,6 @@ part_urls = [
|
||||
# Part category
|
||||
url(r'^category/', include(category_urls)),
|
||||
|
||||
# Part related
|
||||
url(r'^related-parts/', include(part_related_urls)),
|
||||
|
||||
# Part price breaks
|
||||
url(r'^sale-price/', include(sale_price_break_urls)),
|
||||
|
||||
|
@ -30,7 +30,7 @@ import io
|
||||
from rapidfuzz import fuzz
|
||||
from decimal import Decimal, InvalidOperation
|
||||
|
||||
from .models import PartCategory, Part, PartRelated
|
||||
from .models import PartCategory, Part
|
||||
from .models import PartParameterTemplate
|
||||
from .models import PartCategoryParameterTemplate
|
||||
from .models import BomItem
|
||||
@ -85,75 +85,6 @@ class PartIndex(InvenTreeRoleMixin, ListView):
|
||||
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):
|
||||
""" View for settings the part category for multiple parts at once """
|
||||
|
||||
|
@ -273,7 +273,7 @@ function setupFilterList(tableKey, table, target) {
|
||||
|
||||
var element = $(target);
|
||||
|
||||
if (!element) {
|
||||
if (!element || !element.exists()) {
|
||||
console.log(`WARNING: setupFilterList could not find target '${target}'`);
|
||||
return;
|
||||
}
|
||||
|
@ -32,6 +32,7 @@
|
||||
loadPartTable,
|
||||
loadPartTestTemplateTable,
|
||||
loadPartVariantTable,
|
||||
loadRelatedPartsTable,
|
||||
loadSellPricingChart,
|
||||
loadSimplePartTable,
|
||||
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={}) {
|
||||
/* Load parametric table for part parameters
|
||||
*
|
||||
@ -836,6 +928,7 @@ function loadPartTable(table, url, options={}) {
|
||||
* query: extra query params for API request
|
||||
* buttons: If provided, link buttons to selection status of this table
|
||||
* disableFilters: If true, disable custom filters
|
||||
* actions: Provide a callback function to construct an "actions" column
|
||||
*/
|
||||
|
||||
// Ensure category detail is included
|
||||
@ -895,7 +988,7 @@ function loadPartTable(table, url, options={}) {
|
||||
|
||||
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);
|
||||
|
||||
@ -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;
|
||||
|
||||
$(table).inventreeTable({
|
||||
@ -1020,6 +1128,10 @@ function loadPartTable(table, url, options={}) {
|
||||
$('#view-part-grid').removeClass('btn-secondary').addClass('btn-outline-secondary');
|
||||
$('#view-part-list').removeClass('btn-outline-secondary').addClass('btn-secondary');
|
||||
}
|
||||
|
||||
if (options.onPostBody) {
|
||||
options.onPostBody();
|
||||
}
|
||||
},
|
||||
buttons: options.gridView ? [
|
||||
{
|
||||
|
@ -74,6 +74,12 @@ function getAvailableTableFilters(tableKey) {
|
||||
};
|
||||
}
|
||||
|
||||
// Filters for the "related parts" table
|
||||
if (tableKey == 'related') {
|
||||
return {
|
||||
};
|
||||
}
|
||||
|
||||
// Filters for the "used in" table
|
||||
if (tableKey == 'usedin') {
|
||||
return {
|
||||
|
Loading…
Reference in New Issue
Block a user