mirror of
https://github.com/inventree/InvenTree.git
synced 2025-06-19 21:45:39 +00:00
Merge branch 'master' into partial-shipment
# Conflicts: # InvenTree/build/serializers.py # InvenTree/order/templates/order/so_sidebar.html
This commit is contained in:
@ -205,7 +205,7 @@ class BomItemResource(ModelResource):
|
||||
|
||||
# If we are not generating an "import" template,
|
||||
# just return the complete list of fields
|
||||
if not self.is_importing:
|
||||
if not getattr(self, 'is_importing', False):
|
||||
return fields
|
||||
|
||||
# Otherwise, remove some fields we are not interested in
|
||||
|
@ -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
|
||||
@ -169,7 +169,7 @@ class CategoryDetail(generics.RetrieveUpdateDestroyAPIView):
|
||||
"""
|
||||
API endpoint for detail view of a single PartCategory object
|
||||
"""
|
||||
|
||||
|
||||
serializer_class = part_serializers.CategorySerializer
|
||||
queryset = PartCategory.objects.all()
|
||||
|
||||
@ -222,7 +222,7 @@ class CategoryParameterList(generics.ListAPIView):
|
||||
|
||||
if category is not None:
|
||||
try:
|
||||
|
||||
|
||||
category = PartCategory.objects.get(pk=category)
|
||||
|
||||
fetch_parent = str2bool(params.get('fetch_parent', True))
|
||||
@ -734,7 +734,7 @@ class PartList(generics.ListCreateAPIView):
|
||||
raise ValidationError({
|
||||
'initial_stock_quantity': [_('Must be a valid quantity')],
|
||||
})
|
||||
|
||||
|
||||
initial_stock_location = request.data.get('initial_stock_location', None)
|
||||
|
||||
try:
|
||||
@ -850,7 +850,7 @@ class PartList(generics.ListCreateAPIView):
|
||||
id_values.append(val)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
|
||||
queryset = queryset.exclude(pk__in=id_values)
|
||||
|
||||
# Exclude part variant tree?
|
||||
@ -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.
|
||||
|
||||
@ -1081,24 +1153,6 @@ class BomFilter(rest_filters.FilterSet):
|
||||
inherited = rest_filters.BooleanFilter(label='BOM line is inherited')
|
||||
allow_variants = rest_filters.BooleanFilter(label='Variants are allowed')
|
||||
|
||||
validated = rest_filters.BooleanFilter(label='BOM line has been validated', method='filter_validated')
|
||||
|
||||
def filter_validated(self, queryset, name, value):
|
||||
|
||||
# Work out which lines have actually been validated
|
||||
pks = []
|
||||
|
||||
for bom_item in queryset.all():
|
||||
if bom_item.is_line_valid():
|
||||
pks.append(bom_item.pk)
|
||||
|
||||
if str2bool(value):
|
||||
queryset = queryset.filter(pk__in=pks)
|
||||
else:
|
||||
queryset = queryset.exclude(pk__in=pks)
|
||||
|
||||
return queryset
|
||||
|
||||
# Filters for linked 'part'
|
||||
part_active = rest_filters.BooleanFilter(label='Master part is active', field_name='part__active')
|
||||
part_trackable = rest_filters.BooleanFilter(label='Master part is trackable', field_name='part__trackable')
|
||||
@ -1107,6 +1161,30 @@ class BomFilter(rest_filters.FilterSet):
|
||||
sub_part_trackable = rest_filters.BooleanFilter(label='Sub part is trackable', field_name='sub_part__trackable')
|
||||
sub_part_assembly = rest_filters.BooleanFilter(label='Sub part is an assembly', field_name='sub_part__assembly')
|
||||
|
||||
validated = rest_filters.BooleanFilter(label='BOM line has been validated', method='filter_validated')
|
||||
|
||||
def filter_validated(self, queryset, name, value):
|
||||
|
||||
# Work out which lines have actually been validated
|
||||
pks = []
|
||||
|
||||
value = str2bool(value)
|
||||
|
||||
# Shortcut for quicker filtering - BomItem with empty 'checksum' values are not validated
|
||||
if value:
|
||||
queryset = queryset.exclude(checksum=None).exclude(checksum='')
|
||||
|
||||
for bom_item in queryset.all():
|
||||
if bom_item.is_line_valid:
|
||||
pks.append(bom_item.pk)
|
||||
|
||||
if value:
|
||||
queryset = queryset.filter(pk__in=pks)
|
||||
else:
|
||||
queryset = queryset.exclude(pk__in=pks)
|
||||
|
||||
return queryset
|
||||
|
||||
|
||||
class BomList(generics.ListCreateAPIView):
|
||||
"""
|
||||
@ -1257,7 +1335,7 @@ class BomList(generics.ListCreateAPIView):
|
||||
queryset = self.annotate_pricing(queryset)
|
||||
|
||||
return queryset
|
||||
|
||||
|
||||
def include_pricing(self):
|
||||
"""
|
||||
Determine if pricing information should be included in the response
|
||||
@ -1291,7 +1369,7 @@ class BomList(generics.ListCreateAPIView):
|
||||
|
||||
# Get default currency from settings
|
||||
default_currency = InvenTreeSetting.get_setting('INVENTREE_DEFAULT_CURRENCY')
|
||||
|
||||
|
||||
if price:
|
||||
if currency and default_currency:
|
||||
try:
|
||||
@ -1381,7 +1459,7 @@ class BomItemSubstituteList(generics.ListCreateAPIView):
|
||||
|
||||
serializer_class = part_serializers.BomItemSubstituteSerializer
|
||||
queryset = BomItemSubstitute.objects.all()
|
||||
|
||||
|
||||
filter_backends = [
|
||||
DjangoFilterBackend,
|
||||
filters.SearchFilter,
|
||||
@ -1435,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'),
|
||||
|
@ -59,7 +59,7 @@ def ExportBom(part, fmt='csv', cascade=False, max_levels=None, parameter_data=Fa
|
||||
|
||||
uids = []
|
||||
|
||||
def add_items(items, level, cascade):
|
||||
def add_items(items, level, cascade=True):
|
||||
# Add items at a given layer
|
||||
for item in items:
|
||||
|
||||
@ -172,7 +172,7 @@ def ExportBom(part, fmt='csv', cascade=False, max_levels=None, parameter_data=Fa
|
||||
|
||||
# Filter manufacturer parts
|
||||
manufacturer_parts = ManufacturerPart.objects.filter(part__pk=b_part.pk).prefetch_related('supplier_parts')
|
||||
|
||||
|
||||
for mp_idx, mp_part in enumerate(manufacturer_parts):
|
||||
|
||||
# Extract the "name" field of the Manufacturer (Company)
|
||||
@ -190,7 +190,7 @@ def ExportBom(part, fmt='csv', cascade=False, max_levels=None, parameter_data=Fa
|
||||
# Generate a column name for this manufacturer
|
||||
k_man = f'{_("Manufacturer")}_{mp_idx}'
|
||||
k_mpn = f'{_("MPN")}_{mp_idx}'
|
||||
|
||||
|
||||
try:
|
||||
manufacturer_cols[k_man].update({bom_idx: manufacturer_name})
|
||||
manufacturer_cols[k_mpn].update({bom_idx: manufacturer_mpn})
|
||||
@ -200,7 +200,7 @@ def ExportBom(part, fmt='csv', cascade=False, max_levels=None, parameter_data=Fa
|
||||
|
||||
# We wish to include supplier data for this manufacturer part
|
||||
if supplier_data:
|
||||
|
||||
|
||||
for sp_idx, sp_part in enumerate(mp_part.supplier_parts.all()):
|
||||
|
||||
supplier_parts_used.add(sp_part)
|
||||
|
@ -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 """
|
||||
|
||||
|
@ -1587,7 +1587,7 @@ class Part(MPTTModel):
|
||||
# Exclude any parts that this part is used *in* (to prevent recursive BOMs)
|
||||
used_in = self.get_used_in().all()
|
||||
|
||||
parts = parts.exclude(id__in=[item.part.id for item in used_in])
|
||||
parts = parts.exclude(id__in=[part.id for part in used_in])
|
||||
|
||||
return parts
|
||||
|
||||
@ -2118,7 +2118,7 @@ class Part(MPTTModel):
|
||||
"""
|
||||
Returns True if the total stock for this part is less than the minimum stock level
|
||||
"""
|
||||
|
||||
|
||||
return self.get_stock_count() < self.minimum_stock
|
||||
|
||||
|
||||
@ -2155,7 +2155,7 @@ class PartSellPriceBreak(common.models.PriceBreak):
|
||||
"""
|
||||
Represents a price break for selling this part
|
||||
"""
|
||||
|
||||
|
||||
@staticmethod
|
||||
def get_api_url():
|
||||
return reverse('api-part-sale-price-list')
|
||||
|
@ -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 """
|
||||
|
||||
@ -446,9 +465,9 @@ class BomItemSerializer(InvenTreeModelSerializer):
|
||||
purchase_price_min = MoneyField(max_digits=10, decimal_places=6, read_only=True)
|
||||
|
||||
purchase_price_max = MoneyField(max_digits=10, decimal_places=6, read_only=True)
|
||||
|
||||
|
||||
purchase_price_avg = serializers.SerializerMethodField()
|
||||
|
||||
|
||||
purchase_price_range = serializers.SerializerMethodField()
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
@ -520,7 +539,7 @@ class BomItemSerializer(InvenTreeModelSerializer):
|
||||
|
||||
def get_purchase_price_avg(self, obj):
|
||||
""" Return purchase price average """
|
||||
|
||||
|
||||
try:
|
||||
purchase_price_avg = obj.purchase_price_avg
|
||||
except AttributeError:
|
||||
|
@ -62,7 +62,7 @@ def notify_low_stock(part: part.models.Part):
|
||||
def notify_low_stock_if_required(part: part.models.Part):
|
||||
"""
|
||||
Check if the stock quantity has fallen below the minimum threshold of part.
|
||||
|
||||
|
||||
If true, notify the users who have subscribed to the part
|
||||
"""
|
||||
|
||||
|
@ -5,7 +5,8 @@
|
||||
|
||||
{% block sidebar %}
|
||||
{% url "part-detail" part.id as url %}
|
||||
{% include "sidebar_link.html" with url=url text="Return to BOM" icon="fa-undo" %}
|
||||
{% trans "Return to BOM" as text %}
|
||||
{% include "sidebar_link.html" with url=url text=text icon="fa-undo" %}
|
||||
{% endblock %}
|
||||
|
||||
{% block heading %}
|
||||
|
@ -4,12 +4,16 @@
|
||||
|
||||
{% settings_value 'PART_SHOW_IMPORT' as show_import %}
|
||||
|
||||
{% include "sidebar_item.html" with label="subcategories" text="Subcategories" icon="fa-sitemap" %}
|
||||
{% include "sidebar_item.html" with label="parts" text="Parts" icon="fa-shapes" %}
|
||||
{% trans "Subcategories" as text %}
|
||||
{% include "sidebar_item.html" with label="subcategories" text=text icon="fa-sitemap" %}
|
||||
{% trans "Parts" as text %}
|
||||
{% include "sidebar_item.html" with label="parts" text=text icon="fa-shapes" %}
|
||||
{% if show_import and user.is_staff and roles.part.add %}
|
||||
{% url "part-import" as url %}
|
||||
{% include "sidebar_link.html" with url=url text="Import Parts" icon="fa-file-upload" %}
|
||||
{% trans "Import Parts" as text %}
|
||||
{% include "sidebar_link.html" with url=url text=text icon="fa-file-upload" %}
|
||||
{% endif %}
|
||||
{% if category %}
|
||||
{% include "sidebar_item.html" with label="parameters" text="Parameters" icon="fa-tasks" %}
|
||||
{% trans "Parameters" as text %}
|
||||
{% include "sidebar_item.html" with label="parameters" text=text icon="fa-tasks" %}
|
||||
{% endif %}
|
@ -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,8 @@
|
||||
|
||||
{% block sidebar %}
|
||||
{% url 'part-index' as url %}
|
||||
{% include "sidebar_link.html" with url=url text="Return to Parts" icon="fa-undo" %}
|
||||
{% trans "Return to Parts" as text %}
|
||||
{% include "sidebar_link.html" with url=url text=text icon="fa-undo" %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
@ -5,34 +5,49 @@
|
||||
{% settings_value "PART_INTERNAL_PRICE" as show_internal_price %}
|
||||
{% settings_value 'PART_SHOW_RELATED' as show_related %}
|
||||
|
||||
{% include "sidebar_item.html" with label="part-details" text="Details" icon="fa-shapes" %}
|
||||
{% include "sidebar_item.html" with label="part-parameters" text="Parameters" icon="fa-th-list" %}
|
||||
{% trans "Details" as text %}
|
||||
{% include "sidebar_item.html" with label="part-details" text=text icon="fa-shapes" %}
|
||||
{% trans "Parameters" as text %}
|
||||
{% include "sidebar_item.html" with label="part-parameters" text=text icon="fa-th-list" %}
|
||||
{% if part.is_template %}
|
||||
{% include "sidebar_item.html" with label="variants" text="Variants" icon="fa-shapes" %}
|
||||
{% trans "Variants" as text %}
|
||||
{% include "sidebar_item.html" with label="variants" text=text icon="fa-shapes" %}
|
||||
{% endif %}
|
||||
{% include "sidebar_item.html" with label="part-stock" text="Stock" icon="fa-boxes" %}
|
||||
{% trans "Stock" as text %}
|
||||
{% include "sidebar_item.html" with label="part-stock" text=text icon="fa-boxes" %}
|
||||
{% if part.assembly %}
|
||||
{% include "sidebar_item.html" with label="bom" text="Bill of Materials" icon="fa-list" %}
|
||||
{% trans "Bill of Materials" as text %}
|
||||
{% include "sidebar_item.html" with label="bom" text=text icon="fa-list" %}
|
||||
{% if roles.build.view %}
|
||||
{% include "sidebar_item.html" with label="build-orders" text="Build Orders" icon="fa-tools" %}
|
||||
{% trans "Build Orders" as text %}
|
||||
{% include "sidebar_item.html" with label="build-orders" text=text icon="fa-tools" %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% if part.component %}
|
||||
{% include "sidebar_item.html" with label="used-in" text="Used In" icon="fa-layer-group" %}
|
||||
{% trans "Used In" as text %}
|
||||
{% include "sidebar_item.html" with label="used-in" text=text icon="fa-layer-group" %}
|
||||
{% endif %}
|
||||
{% include "sidebar_item.html" with label="pricing" text="Pricing" icon="fa-dollar-sign" %}
|
||||
{% trans "Pricing" as text %}
|
||||
{% include "sidebar_item.html" with label="pricing" text=text icon="fa-dollar-sign" %}
|
||||
{% if part.purchaseable and roles.purchase_order.view %}
|
||||
{% include "sidebar_item.html" with label="suppliers" text="Suppliers" icon="fa-building" %}
|
||||
{% include "sidebar_item.html" with label="purchase-orders" text="Purchase Orders" icon="fa-shopping-cart" %}
|
||||
{% trans "Suppliers" as text %}
|
||||
{% include "sidebar_item.html" with label="suppliers" text=text icon="fa-building" %}
|
||||
{% trans "Purchase Orders" as text %}
|
||||
{% include "sidebar_item.html" with label="purchase-orders" text=text icon="fa-shopping-cart" %}
|
||||
{% endif %}
|
||||
{% if part.salable and roles.sales_order.view %}
|
||||
{% include "sidebar_item.html" with label="sales-orders" text="Sales Orders" icon="fa-truck" %}
|
||||
{% trans "Sales Orders" as text %}
|
||||
{% include "sidebar_item.html" with label="sales-orders" text=text icon="fa-truck" %}
|
||||
{% endif %}
|
||||
{% if part.trackable %}
|
||||
{% include "sidebar_item.html" with label="test-templates" text="Test Templates" icon="fa-vial" %}
|
||||
{% trans "Test Templates" as text %}
|
||||
{% include "sidebar_item.html" with label="test-templates" text=text icon="fa-vial" %}
|
||||
{% endif %}
|
||||
{% if show_related %}
|
||||
{% include "sidebar_item.html" with label="related-parts" text="Related Parts" icon="fa-random" %}
|
||||
{% trans "Related Parts" as text %}
|
||||
{% include "sidebar_item.html" with label="related-parts" text=text icon="fa-random" %}
|
||||
{% endif %}
|
||||
{% include "sidebar_item.html" with label="part-attachments" text="Attachments" icon="fa-paperclip" %}
|
||||
{% include "sidebar_item.html" with label="part-notes" text="Notes" icon="fa-clipboard" %}
|
||||
{% trans "Attachments" as text %}
|
||||
{% include "sidebar_item.html" with label="part-attachments" text=text icon="fa-paperclip" %}
|
||||
{% trans "Notes" as text %}
|
||||
{% include "sidebar_item.html" with label="part-notes" text=text icon="fa-clipboard" %}
|
||||
|
@ -236,7 +236,7 @@ def settings_value(key, *args, **kwargs):
|
||||
|
||||
if 'user' in kwargs:
|
||||
return InvenTreeUserSetting.get_setting(key, user=kwargs['user'])
|
||||
|
||||
|
||||
return InvenTreeSetting.get_setting(key)
|
||||
|
||||
|
||||
@ -384,7 +384,7 @@ def keyvalue(dict, key):
|
||||
def call_method(obj, method_name, *args):
|
||||
"""
|
||||
enables calling model methods / functions from templates with arguments
|
||||
|
||||
|
||||
usage:
|
||||
{% call_method model_object 'fnc_name' argument1 %}
|
||||
"""
|
||||
|
@ -542,7 +542,7 @@ class PartAPITest(InvenTreeAPITestCase):
|
||||
# Check that there is a new manufacturer part *and* a new supplier part
|
||||
self.assertEqual(new_part.supplier_parts.count(), 1)
|
||||
self.assertEqual(new_part.manufacturer_parts.count(), 1)
|
||||
|
||||
|
||||
def test_strange_chars(self):
|
||||
"""
|
||||
Test that non-standard ASCII chars are accepted
|
||||
@ -911,7 +911,7 @@ class BomItemTest(InvenTreeAPITestCase):
|
||||
|
||||
# How many BOM items currently exist in the database?
|
||||
n = BomItem.objects.count()
|
||||
|
||||
|
||||
url = reverse('api-bom-list')
|
||||
response = self.get(url, expected_code=200)
|
||||
self.assertEqual(len(response.data), n)
|
||||
@ -925,7 +925,46 @@ class BomItemTest(InvenTreeAPITestCase):
|
||||
expected_code=200
|
||||
)
|
||||
|
||||
print("results:", len(response.data))
|
||||
# Filter by "validated"
|
||||
response = self.get(
|
||||
url,
|
||||
data={
|
||||
'validated': True,
|
||||
},
|
||||
expected_code=200,
|
||||
)
|
||||
|
||||
# Should be zero validated results
|
||||
self.assertEqual(len(response.data), 0)
|
||||
|
||||
# Now filter by "not validated"
|
||||
response = self.get(
|
||||
url,
|
||||
data={
|
||||
'validated': False,
|
||||
},
|
||||
expected_code=200
|
||||
)
|
||||
|
||||
# There should be at least one non-validated item
|
||||
self.assertTrue(len(response.data) > 0)
|
||||
|
||||
# Now, let's validate an item
|
||||
bom_item = BomItem.objects.first()
|
||||
|
||||
bom_item.validate_hash()
|
||||
|
||||
response = self.get(
|
||||
url,
|
||||
data={
|
||||
'validated': True,
|
||||
},
|
||||
expected_code=200
|
||||
)
|
||||
|
||||
# Check that the expected response is returned
|
||||
self.assertEqual(len(response.data), 1)
|
||||
self.assertEqual(response.data[0]['pk'], bom_item.pk)
|
||||
|
||||
def test_get_bom_detail(self):
|
||||
"""
|
||||
@ -962,7 +1001,7 @@ class BomItemTest(InvenTreeAPITestCase):
|
||||
}
|
||||
|
||||
self.post(url, data, expected_code=201)
|
||||
|
||||
|
||||
# Now try to create a BomItem which references itself
|
||||
data['part'] = 100
|
||||
data['sub_part'] = 100
|
||||
@ -1003,7 +1042,7 @@ class BomItemTest(InvenTreeAPITestCase):
|
||||
|
||||
# Now we will create some variant parts and stock
|
||||
for ii in range(5):
|
||||
|
||||
|
||||
# Create a variant part!
|
||||
variant = Part.objects.create(
|
||||
name=f"Variant_{ii}",
|
||||
|
@ -153,7 +153,7 @@ class BomItemTest(TestCase):
|
||||
subs = []
|
||||
|
||||
for ii in range(5):
|
||||
|
||||
|
||||
# Create a new part
|
||||
sub_part = Part.objects.create(
|
||||
name=f"Orphan {ii}",
|
||||
@ -181,7 +181,7 @@ class BomItemTest(TestCase):
|
||||
|
||||
# There should be now 5 substitute parts available
|
||||
self.assertEqual(bom_item.substitutes.count(), 5)
|
||||
|
||||
|
||||
# Try to create a substitute which points to the same sub-part (should fail)
|
||||
with self.assertRaises(django_exceptions.ValidationError):
|
||||
BomItemSubstitute.objects.create(
|
||||
|
@ -370,7 +370,7 @@ class PartSubscriptionTests(TestCase):
|
||||
|
||||
# electronics / IC / MCU
|
||||
self.category = PartCategory.objects.get(pk=4)
|
||||
|
||||
|
||||
self.part = Part.objects.create(
|
||||
category=self.category,
|
||||
name='STM32F103',
|
||||
@ -382,7 +382,7 @@ class PartSubscriptionTests(TestCase):
|
||||
"""
|
||||
Test basic subscription against a part
|
||||
"""
|
||||
|
||||
|
||||
# First check that the user is *not* subscribed to the part
|
||||
self.assertFalse(self.part.is_starred_by(self.user))
|
||||
|
||||
@ -450,7 +450,7 @@ class PartSubscriptionTests(TestCase):
|
||||
"""
|
||||
Check that a parent category can be subscribed to
|
||||
"""
|
||||
|
||||
|
||||
# Top-level "electronics" category
|
||||
cat = PartCategory.objects.get(pk=1)
|
||||
|
||||
|
@ -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'),
|
||||
@ -40,7 +36,7 @@ part_detail_urls = [
|
||||
url(r'^bom-export/?', views.BomExport.as_view(), name='bom-export'),
|
||||
url(r'^bom-download/?', views.BomDownload.as_view(), name='bom-download'),
|
||||
url(r'^validate-bom/', views.BomValidate.as_view(), name='bom-validate'),
|
||||
|
||||
|
||||
url(r'^pricing/', views.PartPricing.as_view(), name='part-pricing'),
|
||||
|
||||
url(r'^bom-upload/?', views.BomUpload.as_view(), name='upload-bom'),
|
||||
@ -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 """
|
||||
|
||||
@ -459,7 +390,7 @@ class PartDetail(InvenTreeRoleMixin, DetailView):
|
||||
part = self.get_object()
|
||||
|
||||
ctx = part.get_context_data(self.request)
|
||||
|
||||
|
||||
context.update(**ctx)
|
||||
|
||||
# Pricing information
|
||||
@ -1056,7 +987,7 @@ class BomUpload(InvenTreeRoleMixin, FileManagementFormView):
|
||||
matches = sorted(matches, key=lambda item: item['match'], reverse=True)
|
||||
|
||||
part_options = [m['part'] for m in matches]
|
||||
|
||||
|
||||
# Supply list of part options for each row, sorted by how closely they match the part name
|
||||
row['item_options'] = part_options
|
||||
|
||||
@ -1520,11 +1451,11 @@ class CategoryDetail(InvenTreeRoleMixin, DetailView):
|
||||
|
||||
# Prefetch parts parameters
|
||||
parts_parameters = category.prefetch_parts_parameters(cascade=cascade)
|
||||
|
||||
|
||||
# Get table headers (unique parameters names)
|
||||
context['headers'] = category.get_unique_parameters(cascade=cascade,
|
||||
prefetch=parts_parameters)
|
||||
|
||||
|
||||
# Insert part information
|
||||
context['headers'].insert(0, 'description')
|
||||
context['headers'].insert(0, 'part')
|
||||
|
Reference in New Issue
Block a user