mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +00:00
Merge branch 'inventree:master' into trans-improv
This commit is contained in:
commit
96378cb556
@ -85,8 +85,10 @@ class InvenTreeModelSerializer(serializers.ModelSerializer):
|
||||
"""
|
||||
|
||||
def __init__(self, instance=None, data=empty, **kwargs):
|
||||
|
||||
# self.instance = instance
|
||||
"""
|
||||
Custom __init__ routine to ensure that *default* values (as specified in the ORM)
|
||||
are used by the DRF serializers, *if* the values are not provided by the user.
|
||||
"""
|
||||
|
||||
# If instance is None, we are creating a new instance
|
||||
if instance is None and data is not empty:
|
||||
@ -193,7 +195,15 @@ class InvenTreeModelSerializer(serializers.ModelSerializer):
|
||||
try:
|
||||
instance.full_clean()
|
||||
except (ValidationError, DjangoValidationError) as exc:
|
||||
raise ValidationError(detail=serializers.as_serializer_error(exc))
|
||||
|
||||
data = exc.message_dict
|
||||
|
||||
# Change '__all__' key (django style) to 'non_field_errors' (DRF style)
|
||||
if '__all__' in data:
|
||||
data['non_field_errors'] = data['__all__']
|
||||
del data['__all__']
|
||||
|
||||
raise ValidationError(data)
|
||||
|
||||
return data
|
||||
|
||||
|
@ -111,7 +111,7 @@ src="{% static 'img/blank_image.png' %}"
|
||||
<li><a href='#' id='build-cancel'><span class='fas fa-times-circle icon-red'></span> {% trans "Cancel Build" %}</a></li>
|
||||
{% endif %}
|
||||
{% if build.status == BuildStatus.CANCELLED and roles.build.delete %}
|
||||
<li><a href='#' id='build-delete'><span class='fas fa-trash-alt'></span> {% trans "Delete Build"% }</a>
|
||||
<li><a href='#' id='build-delete'><span class='fas fa-trash-alt'></span> {% trans "Delete Build" %}</a>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</div>
|
||||
|
@ -637,7 +637,7 @@ class InvenTreeSetting(BaseInvenTreeSetting):
|
||||
'PART_PURCHASEABLE': {
|
||||
'name': _('Purchaseable'),
|
||||
'description': _('Parts are purchaseable by default'),
|
||||
'default': False,
|
||||
'default': True,
|
||||
'validator': bool,
|
||||
},
|
||||
|
||||
@ -662,6 +662,7 @@ class InvenTreeSetting(BaseInvenTreeSetting):
|
||||
'validator': bool,
|
||||
},
|
||||
|
||||
# TODO: Remove this setting in future, new API forms make this not useful
|
||||
'PART_SHOW_QUANTITY_IN_FORMS': {
|
||||
'name': _('Show Quantity in Forms'),
|
||||
'description': _('Display available part quantity in some forms'),
|
||||
|
@ -23,6 +23,7 @@ from djmoney.money import Money
|
||||
from djmoney.contrib.exchange.models import convert_money
|
||||
from djmoney.contrib.exchange.exceptions import MissingRate
|
||||
|
||||
from decimal import Decimal
|
||||
|
||||
from .models import Part, PartCategory, BomItem
|
||||
from .models import PartParameter, PartParameterTemplate
|
||||
@ -30,6 +31,7 @@ from .models import PartAttachment, PartTestTemplate
|
||||
from .models import PartSellPriceBreak, PartInternalPriceBreak
|
||||
from .models import PartCategoryParameterTemplate
|
||||
|
||||
from stock.models import StockItem
|
||||
from common.models import InvenTreeSetting
|
||||
from build.models import Build
|
||||
|
||||
@ -338,9 +340,7 @@ class PartThumbs(generics.ListAPIView):
|
||||
- Images may be used for multiple parts!
|
||||
"""
|
||||
|
||||
queryset = self.get_queryset()
|
||||
|
||||
# TODO - We should return the thumbnails here, not the full image!
|
||||
queryset = self.filter_queryset(self.get_queryset())
|
||||
|
||||
# Return the most popular parts first
|
||||
data = queryset.values(
|
||||
@ -349,6 +349,19 @@ class PartThumbs(generics.ListAPIView):
|
||||
|
||||
return Response(data)
|
||||
|
||||
filter_backends = [
|
||||
filters.SearchFilter,
|
||||
]
|
||||
|
||||
search_fields = [
|
||||
'name',
|
||||
'description',
|
||||
'IPN',
|
||||
'revision',
|
||||
'keywords',
|
||||
'category__name',
|
||||
]
|
||||
|
||||
|
||||
class PartThumbsUpdate(generics.RetrieveUpdateAPIView):
|
||||
""" API endpoint for updating Part thumbnails"""
|
||||
@ -443,6 +456,8 @@ class PartFilter(rest_filters.FilterSet):
|
||||
else:
|
||||
queryset = queryset.filter(IPN='')
|
||||
|
||||
return queryset
|
||||
|
||||
# Regex filter for name
|
||||
name_regex = rest_filters.CharFilter(label='Filter by name (regex)', field_name='name', lookup_expr='iregex')
|
||||
|
||||
@ -615,17 +630,76 @@ class PartList(generics.ListCreateAPIView):
|
||||
else:
|
||||
return Response(data)
|
||||
|
||||
def perform_create(self, serializer):
|
||||
def create(self, request, *args, **kwargs):
|
||||
"""
|
||||
We wish to save the user who created this part!
|
||||
|
||||
Note: Implementation copied from DRF class CreateModelMixin
|
||||
"""
|
||||
|
||||
serializer = self.get_serializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
part = serializer.save()
|
||||
part.creation_user = self.request.user
|
||||
|
||||
# Optionally copy templates from category or parent category
|
||||
copy_templates = {
|
||||
'main': str2bool(request.data.get('copy_category_templates', False)),
|
||||
'parent': str2bool(request.data.get('copy_parent_templates', False))
|
||||
}
|
||||
|
||||
part.save(**{'add_category_templates': copy_templates})
|
||||
|
||||
# Optionally copy data from another part (e.g. when duplicating)
|
||||
copy_from = request.data.get('copy_from', None)
|
||||
|
||||
if copy_from is not None:
|
||||
|
||||
try:
|
||||
original = Part.objects.get(pk=copy_from)
|
||||
|
||||
copy_bom = str2bool(request.data.get('copy_bom', False))
|
||||
copy_parameters = str2bool(request.data.get('copy_parameters', False))
|
||||
copy_image = str2bool(request.data.get('copy_image', True))
|
||||
|
||||
# Copy image?
|
||||
if copy_image:
|
||||
part.image = original.image
|
||||
part.save()
|
||||
|
||||
# Copy BOM?
|
||||
if copy_bom:
|
||||
part.copy_bom_from(original)
|
||||
|
||||
# Copy parameter data?
|
||||
if copy_parameters:
|
||||
part.copy_parameters_from(original)
|
||||
|
||||
except (ValueError, Part.DoesNotExist):
|
||||
pass
|
||||
|
||||
# Optionally create initial stock item
|
||||
try:
|
||||
initial_stock = Decimal(request.data.get('initial_stock', 0))
|
||||
|
||||
if initial_stock > 0 and part.default_location is not None:
|
||||
|
||||
stock_item = StockItem(
|
||||
part=part,
|
||||
quantity=initial_stock,
|
||||
location=part.default_location,
|
||||
)
|
||||
|
||||
stock_item.save(user=request.user)
|
||||
|
||||
except:
|
||||
pass
|
||||
|
||||
headers = self.get_success_headers(serializer.data)
|
||||
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
|
||||
|
||||
def get_queryset(self, *args, **kwargs):
|
||||
|
||||
queryset = super().get_queryset(*args, **kwargs)
|
||||
|
@ -18,7 +18,6 @@ import common.models
|
||||
from common.forms import MatchItemForm
|
||||
|
||||
from .models import Part, PartCategory, PartRelated
|
||||
from .models import BomItem
|
||||
from .models import PartParameterTemplate, PartParameter
|
||||
from .models import PartCategoryParameterTemplate
|
||||
from .models import PartSellPriceBreak, PartInternalPriceBreak
|
||||
@ -178,82 +177,6 @@ class SetPartCategoryForm(forms.Form):
|
||||
part_category = TreeNodeChoiceField(queryset=PartCategory.objects.all(), required=True, help_text=_('Select part category'))
|
||||
|
||||
|
||||
class EditPartForm(HelperForm):
|
||||
"""
|
||||
Form for editing a Part object.
|
||||
"""
|
||||
|
||||
field_prefix = {
|
||||
'keywords': 'fa-key',
|
||||
'link': 'fa-link',
|
||||
'IPN': 'fa-hashtag',
|
||||
'default_expiry': 'fa-stopwatch',
|
||||
}
|
||||
|
||||
bom_copy = forms.BooleanField(required=False,
|
||||
initial=True,
|
||||
help_text=_("Duplicate all BOM data for this part"),
|
||||
label=_('Copy BOM'),
|
||||
widget=forms.HiddenInput())
|
||||
|
||||
parameters_copy = forms.BooleanField(required=False,
|
||||
initial=True,
|
||||
help_text=_("Duplicate all parameter data for this part"),
|
||||
label=_('Copy Parameters'),
|
||||
widget=forms.HiddenInput())
|
||||
|
||||
confirm_creation = forms.BooleanField(required=False,
|
||||
initial=False,
|
||||
help_text=_('Confirm part creation'),
|
||||
widget=forms.HiddenInput())
|
||||
|
||||
selected_category_templates = forms.BooleanField(required=False,
|
||||
initial=False,
|
||||
label=_('Include category parameter templates'),
|
||||
widget=forms.HiddenInput())
|
||||
|
||||
parent_category_templates = forms.BooleanField(required=False,
|
||||
initial=False,
|
||||
label=_('Include parent categories parameter templates'),
|
||||
widget=forms.HiddenInput())
|
||||
|
||||
initial_stock = forms.IntegerField(required=False,
|
||||
initial=0,
|
||||
label=_('Initial stock amount'),
|
||||
help_text=_('Create stock for this part'))
|
||||
|
||||
class Meta:
|
||||
model = Part
|
||||
fields = [
|
||||
'confirm_creation',
|
||||
'category',
|
||||
'selected_category_templates',
|
||||
'parent_category_templates',
|
||||
'name',
|
||||
'IPN',
|
||||
'description',
|
||||
'revision',
|
||||
'bom_copy',
|
||||
'parameters_copy',
|
||||
'keywords',
|
||||
'variant_of',
|
||||
'link',
|
||||
'default_location',
|
||||
'default_supplier',
|
||||
'default_expiry',
|
||||
'units',
|
||||
'minimum_stock',
|
||||
'initial_stock',
|
||||
'component',
|
||||
'assembly',
|
||||
'is_template',
|
||||
'trackable',
|
||||
'purchaseable',
|
||||
'salable',
|
||||
'virtual',
|
||||
]
|
||||
|
||||
|
||||
class EditPartParameterTemplateForm(HelperForm):
|
||||
""" Form for editing a PartParameterTemplate object """
|
||||
|
||||
@ -317,33 +240,6 @@ class EditCategoryParameterTemplateForm(HelperForm):
|
||||
]
|
||||
|
||||
|
||||
class EditBomItemForm(HelperForm):
|
||||
""" Form for editing a BomItem object """
|
||||
|
||||
quantity = RoundingDecimalFormField(max_digits=10, decimal_places=5, label=_('Quantity'))
|
||||
|
||||
sub_part = PartModelChoiceField(queryset=Part.objects.all(), label=_('Sub part'))
|
||||
|
||||
class Meta:
|
||||
model = BomItem
|
||||
fields = [
|
||||
'part',
|
||||
'sub_part',
|
||||
'quantity',
|
||||
'reference',
|
||||
'overage',
|
||||
'note',
|
||||
'allow_variants',
|
||||
'inherited',
|
||||
'optional',
|
||||
]
|
||||
|
||||
# Prevent editing of the part associated with this BomItem
|
||||
widgets = {
|
||||
'part': forms.HiddenInput()
|
||||
}
|
||||
|
||||
|
||||
class PartPriceForm(forms.Form):
|
||||
""" Simple form for viewing part pricing information """
|
||||
|
||||
|
@ -34,7 +34,6 @@ from stdimage.models import StdImageField
|
||||
|
||||
from decimal import Decimal, InvalidOperation
|
||||
from datetime import datetime
|
||||
from rapidfuzz import fuzz
|
||||
import hashlib
|
||||
|
||||
from InvenTree import helpers
|
||||
@ -235,57 +234,6 @@ def rename_part_image(instance, filename):
|
||||
return os.path.join(base, fname)
|
||||
|
||||
|
||||
def match_part_names(match, threshold=80, reverse=True, compare_length=False):
|
||||
""" Return a list of parts whose name matches the search term using fuzzy search.
|
||||
|
||||
Args:
|
||||
match: Term to match against
|
||||
threshold: Match percentage that must be exceeded (default = 65)
|
||||
reverse: Ordering for search results (default = True - highest match is first)
|
||||
compare_length: Include string length checks
|
||||
|
||||
Returns:
|
||||
A sorted dict where each element contains the following key:value pairs:
|
||||
- 'part' : The matched part
|
||||
- 'ratio' : The matched ratio
|
||||
"""
|
||||
|
||||
match = str(match).strip().lower()
|
||||
|
||||
if len(match) == 0:
|
||||
return []
|
||||
|
||||
parts = Part.objects.all()
|
||||
|
||||
matches = []
|
||||
|
||||
for part in parts:
|
||||
compare = str(part.name).strip().lower()
|
||||
|
||||
if len(compare) == 0:
|
||||
continue
|
||||
|
||||
ratio = fuzz.partial_token_sort_ratio(compare, match)
|
||||
|
||||
if compare_length:
|
||||
# Also employ primitive length comparison
|
||||
# TODO - Improve this somewhat...
|
||||
l_min = min(len(match), len(compare))
|
||||
l_max = max(len(match), len(compare))
|
||||
|
||||
ratio *= (l_min / l_max)
|
||||
|
||||
if ratio >= threshold:
|
||||
matches.append({
|
||||
'part': part,
|
||||
'ratio': round(ratio, 1)
|
||||
})
|
||||
|
||||
matches = sorted(matches, key=lambda item: item['ratio'], reverse=reverse)
|
||||
|
||||
return matches
|
||||
|
||||
|
||||
class PartManager(TreeManager):
|
||||
"""
|
||||
Defines a custom object manager for the Part model.
|
||||
@ -409,7 +357,7 @@ class Part(MPTTModel):
|
||||
"""
|
||||
|
||||
# Get category templates settings
|
||||
add_category_templates = kwargs.pop('add_category_templates', None)
|
||||
add_category_templates = kwargs.pop('add_category_templates', False)
|
||||
|
||||
if self.pk:
|
||||
previous = Part.objects.get(pk=self.pk)
|
||||
@ -437,36 +385,26 @@ class Part(MPTTModel):
|
||||
# Get part category
|
||||
category = self.category
|
||||
|
||||
if category and add_category_templates:
|
||||
# Store templates added to part
|
||||
if category is not None:
|
||||
|
||||
template_list = []
|
||||
|
||||
# Create part parameters for selected category
|
||||
category_templates = add_category_templates['main']
|
||||
if category_templates:
|
||||
for template in category.get_parameter_templates():
|
||||
parameter = PartParameter.create(part=self,
|
||||
template=template.parameter_template,
|
||||
data=template.default_value,
|
||||
save=True)
|
||||
if parameter:
|
||||
template_list.append(template.parameter_template)
|
||||
|
||||
# Create part parameters for parent category
|
||||
category_templates = add_category_templates['parent']
|
||||
if category_templates:
|
||||
# Get parent categories
|
||||
parent_categories = category.get_ancestors()
|
||||
parent_categories = category.get_ancestors(include_self=True)
|
||||
|
||||
for category in parent_categories:
|
||||
for template in category.get_parameter_templates():
|
||||
# Check that template wasn't already added
|
||||
if template.parameter_template not in template_list:
|
||||
|
||||
template_list.append(template.parameter_template)
|
||||
|
||||
try:
|
||||
PartParameter.create(part=self,
|
||||
PartParameter.create(
|
||||
part=self,
|
||||
template=template.parameter_template,
|
||||
data=template.default_value,
|
||||
save=True)
|
||||
save=True
|
||||
)
|
||||
except IntegrityError:
|
||||
# PartParameter already exists
|
||||
pass
|
||||
|
@ -240,32 +240,20 @@
|
||||
});
|
||||
|
||||
$("#cat-create").click(function() {
|
||||
launchModalForm(
|
||||
"{% url 'category-create' %}",
|
||||
{
|
||||
follow: true,
|
||||
data: {
|
||||
|
||||
var fields = categoryFields();
|
||||
|
||||
{% if category %}
|
||||
category: {{ category.id }}
|
||||
fields.parent.value = {{ category.pk }};
|
||||
{% endif %}
|
||||
},
|
||||
secondary: [
|
||||
{
|
||||
field: 'default_location',
|
||||
label: '{% trans "New Location" %}',
|
||||
title: '{% trans "Create new location" %}',
|
||||
url: "{% url 'stock-location-create' %}",
|
||||
},
|
||||
{
|
||||
field: 'parent',
|
||||
label: '{% trans "New Category" %}',
|
||||
title: '{% trans "Create new category" %}',
|
||||
url: "{% url 'category-create' %}",
|
||||
},
|
||||
]
|
||||
}
|
||||
);
|
||||
})
|
||||
|
||||
constructForm('{% url "api-part-category-list" %}', {
|
||||
fields: fields,
|
||||
method: 'POST',
|
||||
title: '{% trans "Create Part Category" %}',
|
||||
follow: true,
|
||||
});
|
||||
});
|
||||
|
||||
$("#part-export").click(function() {
|
||||
|
||||
@ -276,55 +264,32 @@
|
||||
|
||||
{% if roles.part.add %}
|
||||
$("#part-create").click(function() {
|
||||
launchModalForm(
|
||||
"{% url 'part-create' %}",
|
||||
{
|
||||
follow: true,
|
||||
data: {
|
||||
|
||||
var fields = partFields({
|
||||
create: true,
|
||||
});
|
||||
|
||||
{% if category %}
|
||||
category: {{ category.id }}
|
||||
fields.category.value = {{ category.pk }};
|
||||
{% endif %}
|
||||
|
||||
constructForm('{% url "api-part-list" %}', {
|
||||
method: 'POST',
|
||||
fields: fields,
|
||||
title: '{% trans "Create Part" %}',
|
||||
onSuccess: function(data) {
|
||||
// Follow the new part
|
||||
location.href = `/part/${data.pk}/`;
|
||||
},
|
||||
secondary: [
|
||||
{
|
||||
field: 'category',
|
||||
label: '{% trans "New Category" %}',
|
||||
title: '{% trans "Create new Part Category" %}',
|
||||
url: "{% url 'category-create' %}",
|
||||
},
|
||||
{
|
||||
field: 'default_location',
|
||||
label: '{% trans "New Location" %}',
|
||||
title: '{% trans "Create new Stock Location" %}',
|
||||
url: "{% url 'stock-location-create' %}",
|
||||
}
|
||||
]
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
});
|
||||
{% endif %}
|
||||
|
||||
{% if category %}
|
||||
$("#cat-edit").click(function () {
|
||||
|
||||
constructForm(
|
||||
'{% url "api-part-category-detail" category.pk %}',
|
||||
{
|
||||
fields: {
|
||||
name: {},
|
||||
description: {},
|
||||
parent: {
|
||||
help_text: '{% trans "Select parent category" %}',
|
||||
},
|
||||
default_location: {},
|
||||
default_keywords: {
|
||||
icon: 'fa-key',
|
||||
}
|
||||
},
|
||||
title: '{% trans "Edit Part Category" %}',
|
||||
reload: true
|
||||
}
|
||||
);
|
||||
editCategory({{ category.pk }});
|
||||
});
|
||||
|
||||
{% if category.parent %}
|
||||
|
@ -440,22 +440,22 @@
|
||||
});
|
||||
|
||||
$("#bom-item-new").click(function () {
|
||||
launchModalForm(
|
||||
"{% url 'bom-item-create' %}?parent={{ part.id }}",
|
||||
{
|
||||
success: function() {
|
||||
$("#bom-table").bootstrapTable('refresh');
|
||||
},
|
||||
secondary: [
|
||||
{
|
||||
field: 'sub_part',
|
||||
label: '{% trans "New Part" %}',
|
||||
title: '{% trans "Create New Part" %}',
|
||||
url: "{% url 'part-create' %}",
|
||||
},
|
||||
]
|
||||
|
||||
var fields = bomItemFields();
|
||||
|
||||
fields.part.value = {{ part.pk }};
|
||||
fields.sub_part.filters = {
|
||||
active: true,
|
||||
};
|
||||
|
||||
constructForm('{% url "api-bom-list" %}', {
|
||||
fields: fields,
|
||||
method: 'POST',
|
||||
title: '{% trans "Create BOM Item" %}',
|
||||
onSuccess: function() {
|
||||
$('#bom-table').bootstrapTable('refresh');
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
{% else %}
|
||||
@ -525,10 +525,11 @@
|
||||
loadPartVariantTable($('#variants-table'), {{ part.pk }});
|
||||
|
||||
$('#new-variant').click(function() {
|
||||
launchModalForm(
|
||||
"{% url 'make-part-variant' part.id %}",
|
||||
|
||||
duplicatePart(
|
||||
{{ part.pk}},
|
||||
{
|
||||
follow: true,
|
||||
variant: true,
|
||||
}
|
||||
);
|
||||
});
|
||||
@ -899,7 +900,7 @@
|
||||
{% for line in price_history %}'{{ line.date }}',{% endfor %}
|
||||
],
|
||||
datasets: [{
|
||||
label: '{% blocktrans %}Single Price - {{currency}}{% endblocktrans %}',
|
||||
label: '{% blocktrans %}Purchase Unit Price - {{currency}}{% endblocktrans %}',
|
||||
backgroundColor: 'rgba(255, 99, 132, 0.2)',
|
||||
borderColor: 'rgb(255, 99, 132)',
|
||||
yAxisID: 'y',
|
||||
@ -911,7 +912,7 @@
|
||||
},
|
||||
{% if 'price_diff' in price_history.0 %}
|
||||
{
|
||||
label: '{% blocktrans %}Single Price Difference - {{currency}}{% endblocktrans %}',
|
||||
label: '{% blocktrans %}Unit Price-Cost Difference - {{currency}}{% endblocktrans %}',
|
||||
backgroundColor: 'rgba(68, 157, 68, 0.2)',
|
||||
borderColor: 'rgb(68, 157, 68)',
|
||||
yAxisID: 'y2',
|
||||
@ -923,7 +924,7 @@
|
||||
hidden: true,
|
||||
},
|
||||
{
|
||||
label: '{% blocktrans %}Part Single Price - {{currency}}{% endblocktrans %}',
|
||||
label: '{% blocktrans %}Supplier Unit Cost - {{currency}}{% endblocktrans %}',
|
||||
backgroundColor: 'rgba(70, 127, 155, 0.2)',
|
||||
borderColor: 'rgb(70, 127, 155)',
|
||||
yAxisID: 'y',
|
||||
|
@ -415,13 +415,16 @@
|
||||
// Callback when the image-selection modal form is displayed
|
||||
// Populate the form with image data (requested via AJAX)
|
||||
|
||||
$("#modal-form").find("#image-select-table").bootstrapTable({
|
||||
pagination: true,
|
||||
pageSize: 25,
|
||||
$("#modal-form").find("#image-select-table").inventreeTable({
|
||||
url: "{% url 'api-part-thumbs' %}",
|
||||
showHeader: false,
|
||||
showColumns: false,
|
||||
clickToSelect: true,
|
||||
sidePagination: 'server',
|
||||
singleSelect: true,
|
||||
formatNoMatches: function() {
|
||||
return '{% trans "No matching images found" %}';
|
||||
},
|
||||
columns: [
|
||||
{
|
||||
checkbox: true,
|
||||
@ -429,6 +432,7 @@
|
||||
{
|
||||
field: 'image',
|
||||
title: 'Image',
|
||||
searchable: true,
|
||||
formatter: function(value, row, index, field) {
|
||||
return "<img src='/media/" + value + "' class='grid-image'/>"
|
||||
}
|
||||
@ -482,12 +486,7 @@
|
||||
|
||||
{% if roles.part.add %}
|
||||
$("#part-duplicate").click(function() {
|
||||
launchModalForm(
|
||||
"{% url 'part-duplicate' part.id %}",
|
||||
{
|
||||
follow: true,
|
||||
}
|
||||
);
|
||||
duplicatePart({{ part.pk }});
|
||||
});
|
||||
{% endif %}
|
||||
|
||||
|
@ -161,7 +161,7 @@
|
||||
<div class='panel-content'>
|
||||
<h4>{% trans 'Stock Pricing' %}
|
||||
<i class="fas fa-info-circle" title="Shows the purchase prices of stock for this part.
|
||||
The part single price is the current purchase price for that supplier part."></i>
|
||||
The Supplier Unit Cost is the current purchase price for that supplier part."></i>
|
||||
</h4>
|
||||
{% if price_history|length > 0 %}
|
||||
<div style="max-width: 99%; min-height: 300px">
|
||||
|
@ -434,8 +434,8 @@ class PartAPITest(InvenTreeAPITestCase):
|
||||
self.assertTrue(data['active'])
|
||||
self.assertFalse(data['virtual'])
|
||||
|
||||
# By default, parts are not purchaseable
|
||||
self.assertFalse(data['purchaseable'])
|
||||
# By default, parts are purchaseable
|
||||
self.assertTrue(data['purchaseable'])
|
||||
|
||||
# Set the default 'purchaseable' status to True
|
||||
InvenTreeSetting.set_setting(
|
||||
|
@ -12,7 +12,7 @@ from django.core.exceptions import ValidationError
|
||||
import os
|
||||
|
||||
from .models import Part, PartCategory, PartTestTemplate
|
||||
from .models import rename_part_image, match_part_names
|
||||
from .models import rename_part_image
|
||||
from .templatetags import inventree_extras
|
||||
|
||||
import part.settings
|
||||
@ -163,12 +163,6 @@ class PartTest(TestCase):
|
||||
def test_copy(self):
|
||||
self.r2.deep_copy(self.r1, image=True, bom=True)
|
||||
|
||||
def test_match_names(self):
|
||||
|
||||
matches = match_part_names('M2x5 LPHS')
|
||||
|
||||
self.assertTrue(len(matches) > 0)
|
||||
|
||||
def test_sell_pricing(self):
|
||||
# check that the sell pricebreaks were loaded
|
||||
self.assertTrue(self.r1.has_price_breaks)
|
||||
@ -281,7 +275,7 @@ class PartSettingsTest(TestCase):
|
||||
"""
|
||||
|
||||
self.assertTrue(part.settings.part_component_default())
|
||||
self.assertFalse(part.settings.part_purchaseable_default())
|
||||
self.assertTrue(part.settings.part_purchaseable_default())
|
||||
self.assertFalse(part.settings.part_salable_default())
|
||||
self.assertFalse(part.settings.part_trackable_default())
|
||||
|
||||
@ -293,7 +287,7 @@ class PartSettingsTest(TestCase):
|
||||
part = self.make_part()
|
||||
|
||||
self.assertTrue(part.component)
|
||||
self.assertFalse(part.purchaseable)
|
||||
self.assertTrue(part.purchaseable)
|
||||
self.assertFalse(part.salable)
|
||||
self.assertFalse(part.trackable)
|
||||
|
||||
|
@ -155,38 +155,6 @@ class PartDetailTest(PartViewTestCase):
|
||||
self.assertIn('streaming_content', dir(response))
|
||||
|
||||
|
||||
class PartTests(PartViewTestCase):
|
||||
""" Tests for Part forms """
|
||||
|
||||
def test_part_create(self):
|
||||
""" Launch form to create a new part """
|
||||
response = self.client.get(reverse('part-create'), {'category': 1}, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
# And again, with an invalid category
|
||||
response = self.client.get(reverse('part-create'), {'category': 9999}, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
# And again, with no category
|
||||
response = self.client.get(reverse('part-create'), {'name': 'Test part'}, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_part_duplicate(self):
|
||||
""" Launch form to duplicate part """
|
||||
|
||||
# First try with an invalid part
|
||||
response = self.client.get(reverse('part-duplicate', args=(9999,)), HTTP_X_REQUESTED_WITH='XMLHttpRequest')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
response = self.client.get(reverse('part-duplicate', args=(1,)), HTTP_X_REQUESTED_WITH='XMLHttpRequest')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_make_variant(self):
|
||||
|
||||
response = self.client.get(reverse('make-part-variant', args=(1,)), HTTP_X_REQUESTED_WITH='XMLHttpRequest')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
|
||||
class PartRelatedTests(PartViewTestCase):
|
||||
|
||||
def test_valid_create(self):
|
||||
@ -243,19 +211,6 @@ class PartQRTest(PartViewTestCase):
|
||||
class CategoryTest(PartViewTestCase):
|
||||
""" Tests for PartCategory related views """
|
||||
|
||||
def test_create(self):
|
||||
""" Test view for creating a new category """
|
||||
response = self.client.get(reverse('category-create'), {'category': 1}, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_create_invalid_parent(self):
|
||||
""" test creation of a new category with an invalid parent """
|
||||
response = self.client.get(reverse('category-create'), {'category': 9999}, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
|
||||
|
||||
# Form should still return OK
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_set_category(self):
|
||||
""" Test that the "SetCategory" view works """
|
||||
|
||||
@ -272,22 +227,3 @@ class CategoryTest(PartViewTestCase):
|
||||
|
||||
response = self.client.post(url, data, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
|
||||
class BomItemTests(PartViewTestCase):
|
||||
""" Tests for BomItem related views """
|
||||
|
||||
def test_create_valid_parent(self):
|
||||
""" Create a BomItem for a valid part """
|
||||
response = self.client.get(reverse('bom-item-create'), {'parent': 1}, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_create_no_parent(self):
|
||||
""" Create a BomItem without a parent """
|
||||
response = self.client.get(reverse('bom-item-create'), HTTP_X_REQUESTED_WITH='XMLHttpRequest')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_create_invalid_parent(self):
|
||||
""" Create a BomItem with an invalid parent """
|
||||
response = self.client.get(reverse('bom-item-create'), {'parent': 99999}, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
@ -40,8 +40,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'^duplicate/', views.PartDuplicate.as_view(), name='part-duplicate'),
|
||||
url(r'^make-variant/', views.MakePartVariant.as_view(), name='make-part-variant'),
|
||||
|
||||
url(r'^pricing/', views.PartPricing.as_view(), name='part-pricing'),
|
||||
|
||||
url(r'^bom-upload/?', views.BomUpload.as_view(), name='upload-bom'),
|
||||
@ -65,9 +64,6 @@ category_parameter_urls = [
|
||||
|
||||
category_urls = [
|
||||
|
||||
# Create a new category
|
||||
url(r'^new/', views.CategoryCreate.as_view(), name='category-create'),
|
||||
|
||||
# Top level subcategory display
|
||||
url(r'^subcategory/', views.PartIndex.as_view(template_name='part/subcategory.html'), name='category-index-subcategory'),
|
||||
|
||||
@ -81,23 +77,13 @@ category_urls = [
|
||||
]))
|
||||
]
|
||||
|
||||
part_bom_urls = [
|
||||
url(r'^edit/?', views.BomItemEdit.as_view(), name='bom-item-edit'),
|
||||
]
|
||||
|
||||
# URL list for part web interface
|
||||
part_urls = [
|
||||
|
||||
# Create a new part
|
||||
url(r'^new/?', views.PartCreate.as_view(), name='part-create'),
|
||||
|
||||
# Upload a part
|
||||
url(r'^import/', views.PartImport.as_view(), name='part-import'),
|
||||
url(r'^import-api/', views.PartImportAjax.as_view(), name='api-part-import'),
|
||||
|
||||
# Create a new BOM item
|
||||
url(r'^bom/new/?', views.BomItemCreate.as_view(), name='bom-item-create'),
|
||||
|
||||
# Download a BOM upload template
|
||||
url(r'^bom_template/?', views.BomUploadTemplate.as_view(), name='bom-upload-template'),
|
||||
|
||||
@ -125,9 +111,6 @@ part_urls = [
|
||||
# Change category for multiple parts
|
||||
url(r'^set-category/?', views.PartSetCategory.as_view(), name='part-set-category'),
|
||||
|
||||
# Bom Items
|
||||
url(r'^bom/(?P<pk>\d+)/', include(part_bom_urls)),
|
||||
|
||||
# Individual part using IPN as slug
|
||||
url(r'^(?P<slug>[-\w]+)/', views.PartDetailFromIPN.as_view(), name='part-detail-from-ipn'),
|
||||
|
||||
|
@ -12,10 +12,9 @@ from django.db.utils import IntegrityError
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.shortcuts import HttpResponseRedirect
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.urls import reverse, reverse_lazy
|
||||
from django.urls import reverse
|
||||
from django.views.generic import DetailView, ListView
|
||||
from django.forms.models import model_to_dict
|
||||
from django.forms import HiddenInput, CheckboxInput
|
||||
from django.forms import HiddenInput
|
||||
from django.conf import settings
|
||||
from django.contrib import messages
|
||||
|
||||
@ -35,7 +34,6 @@ from .models import PartCategory, Part, PartRelated
|
||||
from .models import PartParameterTemplate
|
||||
from .models import PartCategoryParameterTemplate
|
||||
from .models import BomItem
|
||||
from .models import match_part_names
|
||||
from .models import PartSellPriceBreak, PartInternalPriceBreak
|
||||
|
||||
from common.models import InvenTreeSetting
|
||||
@ -44,7 +42,7 @@ from common.files import FileManager
|
||||
from common.views import FileManagementFormView, FileManagementAjaxView
|
||||
from common.forms import UploadFileForm, MatchFieldForm
|
||||
|
||||
from stock.models import StockItem, StockLocation
|
||||
from stock.models import StockLocation
|
||||
|
||||
import common.settings as inventree_settings
|
||||
|
||||
@ -233,370 +231,6 @@ class PartSetCategory(AjaxUpdateView):
|
||||
return ctx
|
||||
|
||||
|
||||
class MakePartVariant(AjaxCreateView):
|
||||
""" View for creating a new variant based on an existing template Part
|
||||
|
||||
- Part <pk> is provided in the URL '/part/<pk>/make_variant/'
|
||||
- Automatically copy relevent data (BOM, etc, etc)
|
||||
|
||||
"""
|
||||
|
||||
model = Part
|
||||
form_class = part_forms.EditPartForm
|
||||
|
||||
ajax_form_title = _('Create Variant')
|
||||
ajax_template_name = 'part/variant_part.html'
|
||||
|
||||
def get_part_template(self):
|
||||
return get_object_or_404(Part, id=self.kwargs['pk'])
|
||||
|
||||
def get_context_data(self):
|
||||
return {
|
||||
'part': self.get_part_template(),
|
||||
}
|
||||
|
||||
def get_form(self):
|
||||
form = super(AjaxCreateView, self).get_form()
|
||||
|
||||
# Hide some variant-related fields
|
||||
# form.fields['variant_of'].widget = HiddenInput()
|
||||
|
||||
# Force display of the 'bom_copy' widget
|
||||
form.fields['bom_copy'].widget = CheckboxInput()
|
||||
|
||||
# Force display of the 'parameters_copy' widget
|
||||
form.fields['parameters_copy'].widget = CheckboxInput()
|
||||
|
||||
return form
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
|
||||
form = self.get_form()
|
||||
context = self.get_context_data()
|
||||
part_template = self.get_part_template()
|
||||
|
||||
valid = form.is_valid()
|
||||
|
||||
data = {
|
||||
'form_valid': valid,
|
||||
}
|
||||
|
||||
if valid:
|
||||
# Create the new part variant
|
||||
part = form.save(commit=False)
|
||||
part.variant_of = part_template
|
||||
part.is_template = False
|
||||
|
||||
part.save()
|
||||
|
||||
data['pk'] = part.pk
|
||||
data['text'] = str(part)
|
||||
data['url'] = part.get_absolute_url()
|
||||
|
||||
bom_copy = str2bool(request.POST.get('bom_copy', False))
|
||||
parameters_copy = str2bool(request.POST.get('parameters_copy', False))
|
||||
|
||||
# Copy relevent information from the template part
|
||||
part.deep_copy(part_template, bom=bom_copy, parameters=parameters_copy)
|
||||
|
||||
return self.renderJsonResponse(request, form, data, context=context)
|
||||
|
||||
def get_initial(self):
|
||||
|
||||
part_template = self.get_part_template()
|
||||
|
||||
initials = model_to_dict(part_template)
|
||||
initials['is_template'] = False
|
||||
initials['variant_of'] = part_template
|
||||
initials['bom_copy'] = InvenTreeSetting.get_setting('PART_COPY_BOM')
|
||||
initials['parameters_copy'] = InvenTreeSetting.get_setting('PART_COPY_PARAMETERS')
|
||||
|
||||
return initials
|
||||
|
||||
|
||||
class PartDuplicate(AjaxCreateView):
|
||||
""" View for duplicating an existing Part object.
|
||||
|
||||
- Part <pk> is provided in the URL '/part/<pk>/copy/'
|
||||
- Option for 'deep-copy' which will duplicate all BOM items (default = True)
|
||||
"""
|
||||
|
||||
model = Part
|
||||
form_class = part_forms.EditPartForm
|
||||
|
||||
ajax_form_title = _("Duplicate Part")
|
||||
ajax_template_name = "part/copy_part.html"
|
||||
|
||||
def get_data(self):
|
||||
return {
|
||||
'success': _('Copied part')
|
||||
}
|
||||
|
||||
def get_part_to_copy(self):
|
||||
try:
|
||||
return Part.objects.get(id=self.kwargs['pk'])
|
||||
except (Part.DoesNotExist, ValueError):
|
||||
return None
|
||||
|
||||
def get_context_data(self):
|
||||
return {
|
||||
'part': self.get_part_to_copy()
|
||||
}
|
||||
|
||||
def get_form(self):
|
||||
form = super(AjaxCreateView, self).get_form()
|
||||
|
||||
# Force display of the 'bom_copy' widget
|
||||
form.fields['bom_copy'].widget = CheckboxInput()
|
||||
|
||||
# Force display of the 'parameters_copy' widget
|
||||
form.fields['parameters_copy'].widget = CheckboxInput()
|
||||
|
||||
return form
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
""" Capture the POST request for part duplication
|
||||
|
||||
- If the bom_copy object is set, copy all the BOM items too!
|
||||
- If the parameters_copy object is set, copy all the parameters too!
|
||||
"""
|
||||
|
||||
form = self.get_form()
|
||||
|
||||
context = self.get_context_data()
|
||||
|
||||
valid = form.is_valid()
|
||||
|
||||
name = request.POST.get('name', None)
|
||||
|
||||
if name:
|
||||
matches = match_part_names(name)
|
||||
|
||||
if len(matches) > 0:
|
||||
# Display the first five closest matches
|
||||
context['matches'] = matches[:5]
|
||||
|
||||
# Enforce display of the checkbox
|
||||
form.fields['confirm_creation'].widget = CheckboxInput()
|
||||
|
||||
# Check if the user has checked the 'confirm_creation' input
|
||||
confirmed = str2bool(request.POST.get('confirm_creation', False))
|
||||
|
||||
if not confirmed:
|
||||
msg = _('Possible matches exist - confirm creation of new part')
|
||||
form.add_error('confirm_creation', msg)
|
||||
form.pre_form_warning = msg
|
||||
valid = False
|
||||
|
||||
data = {
|
||||
'form_valid': valid
|
||||
}
|
||||
|
||||
if valid:
|
||||
# Create the new Part
|
||||
part = form.save(commit=False)
|
||||
|
||||
part.creation_user = request.user
|
||||
part.save()
|
||||
|
||||
data['pk'] = part.pk
|
||||
data['text'] = str(part)
|
||||
|
||||
bom_copy = str2bool(request.POST.get('bom_copy', False))
|
||||
parameters_copy = str2bool(request.POST.get('parameters_copy', False))
|
||||
|
||||
original = self.get_part_to_copy()
|
||||
|
||||
if original:
|
||||
part.deep_copy(original, bom=bom_copy, parameters=parameters_copy)
|
||||
|
||||
try:
|
||||
data['url'] = part.get_absolute_url()
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
if valid:
|
||||
pass
|
||||
|
||||
return self.renderJsonResponse(request, form, data, context=context)
|
||||
|
||||
def get_initial(self):
|
||||
""" Get initial data based on the Part to be copied from.
|
||||
"""
|
||||
|
||||
part = self.get_part_to_copy()
|
||||
|
||||
if part:
|
||||
initials = model_to_dict(part)
|
||||
else:
|
||||
initials = super(AjaxCreateView, self).get_initial()
|
||||
|
||||
initials['bom_copy'] = str2bool(InvenTreeSetting.get_setting('PART_COPY_BOM', True))
|
||||
|
||||
initials['parameters_copy'] = str2bool(InvenTreeSetting.get_setting('PART_COPY_PARAMETERS', True))
|
||||
|
||||
return initials
|
||||
|
||||
|
||||
class PartCreate(AjaxCreateView):
|
||||
""" View for creating a new Part object.
|
||||
|
||||
Options for providing initial conditions:
|
||||
|
||||
- Provide a category object as initial data
|
||||
"""
|
||||
model = Part
|
||||
form_class = part_forms.EditPartForm
|
||||
|
||||
ajax_form_title = _('Create New Part')
|
||||
ajax_template_name = 'part/create_part.html'
|
||||
|
||||
def get_data(self):
|
||||
return {
|
||||
'success': _("Created new part"),
|
||||
}
|
||||
|
||||
def get_category_id(self):
|
||||
return self.request.GET.get('category', None)
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
""" Provide extra context information for the form to display:
|
||||
|
||||
- Add category information (if provided)
|
||||
"""
|
||||
context = super(PartCreate, self).get_context_data(**kwargs)
|
||||
|
||||
# Add category information to the page
|
||||
cat_id = self.get_category_id()
|
||||
|
||||
if cat_id:
|
||||
try:
|
||||
context['category'] = PartCategory.objects.get(pk=cat_id)
|
||||
except (PartCategory.DoesNotExist, ValueError):
|
||||
pass
|
||||
|
||||
return context
|
||||
|
||||
def get_form(self):
|
||||
""" Create Form for making new Part object.
|
||||
Remove the 'default_supplier' field as there are not yet any matching SupplierPart objects
|
||||
"""
|
||||
form = super(AjaxCreateView, self).get_form()
|
||||
|
||||
# Hide the "default expiry" field if the feature is not enabled
|
||||
if not inventree_settings.stock_expiry_enabled():
|
||||
form.fields['default_expiry'].widget = HiddenInput()
|
||||
|
||||
# Hide the "initial stock amount" field if the feature is not enabled
|
||||
if not InvenTreeSetting.get_setting('PART_CREATE_INITIAL'):
|
||||
form.fields['initial_stock'].widget = HiddenInput()
|
||||
|
||||
# Hide the default_supplier field (there are no matching supplier parts yet!)
|
||||
form.fields['default_supplier'].widget = HiddenInput()
|
||||
|
||||
# Display category templates widgets
|
||||
form.fields['selected_category_templates'].widget = CheckboxInput()
|
||||
form.fields['parent_category_templates'].widget = CheckboxInput()
|
||||
|
||||
return form
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
|
||||
form = self.get_form()
|
||||
|
||||
context = {}
|
||||
|
||||
valid = form.is_valid()
|
||||
|
||||
name = request.POST.get('name', None)
|
||||
|
||||
if name:
|
||||
matches = match_part_names(name)
|
||||
|
||||
if len(matches) > 0:
|
||||
|
||||
# Limit to the top 5 matches (to prevent clutter)
|
||||
context['matches'] = matches[:5]
|
||||
|
||||
# Enforce display of the checkbox
|
||||
form.fields['confirm_creation'].widget = CheckboxInput()
|
||||
|
||||
# Check if the user has checked the 'confirm_creation' input
|
||||
confirmed = str2bool(request.POST.get('confirm_creation', False))
|
||||
|
||||
if not confirmed:
|
||||
msg = _('Possible matches exist - confirm creation of new part')
|
||||
form.add_error('confirm_creation', msg)
|
||||
|
||||
form.pre_form_warning = msg
|
||||
valid = False
|
||||
|
||||
data = {
|
||||
'form_valid': valid
|
||||
}
|
||||
|
||||
if valid:
|
||||
# Create the new Part
|
||||
part = form.save(commit=False)
|
||||
|
||||
# Record the user who created this part
|
||||
part.creation_user = request.user
|
||||
|
||||
# Store category templates settings
|
||||
add_category_templates = {
|
||||
'main': form.cleaned_data['selected_category_templates'],
|
||||
'parent': form.cleaned_data['parent_category_templates'],
|
||||
}
|
||||
|
||||
# Save part and pass category template settings
|
||||
part.save(**{'add_category_templates': add_category_templates})
|
||||
|
||||
# Add stock if set
|
||||
init_stock = int(request.POST.get('initial_stock', 0))
|
||||
if init_stock:
|
||||
stock = StockItem(part=part,
|
||||
quantity=init_stock,
|
||||
location=part.default_location)
|
||||
stock.save()
|
||||
|
||||
data['pk'] = part.pk
|
||||
data['text'] = str(part)
|
||||
|
||||
try:
|
||||
data['url'] = part.get_absolute_url()
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
return self.renderJsonResponse(request, form, data, context=context)
|
||||
|
||||
def get_initial(self):
|
||||
""" Get initial data for the new Part object:
|
||||
|
||||
- If a category is provided, pre-fill the Category field
|
||||
"""
|
||||
|
||||
initials = super(PartCreate, self).get_initial()
|
||||
|
||||
if self.get_category_id():
|
||||
try:
|
||||
category = PartCategory.objects.get(pk=self.get_category_id())
|
||||
initials['category'] = category
|
||||
initials['keywords'] = category.default_keywords
|
||||
except (PartCategory.DoesNotExist, ValueError):
|
||||
pass
|
||||
|
||||
# Allow initial data to be passed through as arguments
|
||||
for label in ['name', 'IPN', 'description', 'revision', 'keywords']:
|
||||
if label in self.request.GET:
|
||||
initials[label] = self.request.GET.get(label)
|
||||
|
||||
# Automatically create part parameters from category templates
|
||||
initials['selected_category_templates'] = str2bool(InvenTreeSetting.get_setting('PART_CATEGORY_PARAMETERS', False))
|
||||
initials['parent_category_templates'] = initials['selected_category_templates']
|
||||
|
||||
return initials
|
||||
|
||||
|
||||
class PartImport(FileManagementFormView):
|
||||
''' Part: Upload file, match to fields and import parts(using multi-Step form) '''
|
||||
permission_required = 'part.add'
|
||||
@ -1905,49 +1539,6 @@ class CategoryDelete(AjaxDeleteView):
|
||||
}
|
||||
|
||||
|
||||
class CategoryCreate(AjaxCreateView):
|
||||
""" Create view to make a new PartCategory """
|
||||
model = PartCategory
|
||||
ajax_form_action = reverse_lazy('category-create')
|
||||
ajax_form_title = _('Create new part category')
|
||||
ajax_template_name = 'modal_form.html'
|
||||
form_class = part_forms.EditCategoryForm
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
""" Add extra context data to template.
|
||||
|
||||
- If parent category provided, pass the category details to the template
|
||||
"""
|
||||
context = super(CategoryCreate, self).get_context_data(**kwargs).copy()
|
||||
|
||||
parent_id = self.request.GET.get('category', None)
|
||||
|
||||
if parent_id:
|
||||
try:
|
||||
context['category'] = PartCategory.objects.get(pk=parent_id)
|
||||
except PartCategory.DoesNotExist:
|
||||
pass
|
||||
|
||||
return context
|
||||
|
||||
def get_initial(self):
|
||||
""" Get initial data for new PartCategory
|
||||
|
||||
- If parent provided, pre-fill the parent category
|
||||
"""
|
||||
initials = super(CategoryCreate, self).get_initial().copy()
|
||||
|
||||
parent_id = self.request.GET.get('category', None)
|
||||
|
||||
if parent_id:
|
||||
try:
|
||||
initials['parent'] = PartCategory.objects.get(pk=parent_id)
|
||||
except PartCategory.DoesNotExist:
|
||||
pass
|
||||
|
||||
return initials
|
||||
|
||||
|
||||
class CategoryParameterTemplateCreate(AjaxCreateView):
|
||||
""" View for creating a new PartCategoryParameterTemplate """
|
||||
|
||||
@ -2121,134 +1712,6 @@ class CategoryParameterTemplateDelete(AjaxDeleteView):
|
||||
return self.object
|
||||
|
||||
|
||||
class BomItemCreate(AjaxCreateView):
|
||||
"""
|
||||
Create view for making a new BomItem object
|
||||
"""
|
||||
|
||||
model = BomItem
|
||||
form_class = part_forms.EditBomItemForm
|
||||
ajax_template_name = 'modal_form.html'
|
||||
ajax_form_title = _('Create BOM Item')
|
||||
|
||||
def get_form(self):
|
||||
""" Override get_form() method to reduce Part selection options.
|
||||
|
||||
- Do not allow part to be added to its own BOM
|
||||
- Remove any Part items that are already in the BOM
|
||||
"""
|
||||
|
||||
form = super(AjaxCreateView, self).get_form()
|
||||
|
||||
part_id = form['part'].value()
|
||||
|
||||
# Construct a queryset for the part field
|
||||
part_query = Part.objects.filter(active=True)
|
||||
|
||||
# Construct a queryset for the sub_part field
|
||||
sub_part_query = Part.objects.filter(
|
||||
component=True,
|
||||
active=True
|
||||
)
|
||||
|
||||
try:
|
||||
part = Part.objects.get(id=part_id)
|
||||
|
||||
# Hide the 'part' field
|
||||
form.fields['part'].widget = HiddenInput()
|
||||
|
||||
# Exclude the part from its own BOM
|
||||
sub_part_query = sub_part_query.exclude(id=part.id)
|
||||
|
||||
# Eliminate any options that are already in the BOM!
|
||||
sub_part_query = sub_part_query.exclude(id__in=[item.id for item in part.getRequiredParts()])
|
||||
|
||||
except (ValueError, Part.DoesNotExist):
|
||||
pass
|
||||
|
||||
# Set the querysets for the fields
|
||||
form.fields['part'].queryset = part_query
|
||||
form.fields['sub_part'].queryset = sub_part_query
|
||||
|
||||
return form
|
||||
|
||||
def get_initial(self):
|
||||
""" Provide initial data for the BomItem:
|
||||
|
||||
- If 'parent' provided, set the parent part field
|
||||
"""
|
||||
|
||||
# Look for initial values
|
||||
initials = super(BomItemCreate, self).get_initial().copy()
|
||||
|
||||
# Parent part for this item?
|
||||
parent_id = self.request.GET.get('parent', None)
|
||||
|
||||
if parent_id:
|
||||
try:
|
||||
initials['part'] = Part.objects.get(pk=parent_id)
|
||||
except Part.DoesNotExist:
|
||||
pass
|
||||
|
||||
return initials
|
||||
|
||||
|
||||
class BomItemEdit(AjaxUpdateView):
|
||||
""" Update view for editing BomItem """
|
||||
|
||||
model = BomItem
|
||||
form_class = part_forms.EditBomItemForm
|
||||
ajax_template_name = 'modal_form.html'
|
||||
ajax_form_title = _('Edit BOM item')
|
||||
|
||||
def get_form(self):
|
||||
""" Override get_form() method to filter part selection options
|
||||
|
||||
- Do not allow part to be added to its own BOM
|
||||
- Remove any part items that are already in the BOM
|
||||
"""
|
||||
|
||||
item = self.get_object()
|
||||
|
||||
form = super().get_form()
|
||||
|
||||
part_id = form['part'].value()
|
||||
|
||||
try:
|
||||
part = Part.objects.get(pk=part_id)
|
||||
|
||||
# Construct a queryset
|
||||
query = Part.objects.filter(component=True)
|
||||
|
||||
# Limit to "active" items, *unless* the currently selected item is not active
|
||||
if item.sub_part.active:
|
||||
query = query.filter(active=True)
|
||||
|
||||
# Prevent the parent part from being selected
|
||||
query = query.exclude(pk=part_id)
|
||||
|
||||
# Eliminate any options that are already in the BOM,
|
||||
# *except* for the item which is already selected
|
||||
try:
|
||||
sub_part_id = int(form['sub_part'].value())
|
||||
except ValueError:
|
||||
sub_part_id = -1
|
||||
|
||||
existing = [item.pk for item in part.getRequiredParts()]
|
||||
|
||||
if sub_part_id in existing:
|
||||
existing.remove(sub_part_id)
|
||||
|
||||
query = query.exclude(id__in=existing)
|
||||
|
||||
form.fields['sub_part'].queryset = query
|
||||
|
||||
except (ValueError, Part.DoesNotExist):
|
||||
pass
|
||||
|
||||
return form
|
||||
|
||||
|
||||
class PartSalePriceBreakCreate(AjaxCreateView):
|
||||
"""
|
||||
View for creating a sale price break for a part
|
||||
|
@ -79,7 +79,7 @@ content: "v{{report_revision}} - {{ date.isoformat }}";
|
||||
|
||||
{% block header_content %}
|
||||
<!-- TODO - Make the company logo asset generic -->
|
||||
<img class='logo' src="{% asset 'company_logo.png' %}" alt="hello" width="150">
|
||||
<img class='logo' src="{% asset 'company_logo.png' %}" alt="logo" width="150">
|
||||
|
||||
<div class='header-right'>
|
||||
<h3>
|
||||
|
@ -8,7 +8,6 @@ from __future__ import unicode_literals
|
||||
from django import forms
|
||||
from django.forms.utils import ErrorDict
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from django.core.validators import MinValueValidator
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
from mptt.fields import TreeNodeChoiceField
|
||||
@ -241,14 +240,9 @@ class InstallStockForm(HelperForm):
|
||||
help_text=_('Stock item to install')
|
||||
)
|
||||
|
||||
quantity_to_install = RoundingDecimalFormField(
|
||||
max_digits=10, decimal_places=5,
|
||||
initial=1,
|
||||
label=_('Quantity'),
|
||||
help_text=_('Stock quantity to assign'),
|
||||
validators=[
|
||||
MinValueValidator(0.001)
|
||||
]
|
||||
to_install = forms.BooleanField(
|
||||
widget=forms.HiddenInput(),
|
||||
required=False,
|
||||
)
|
||||
|
||||
notes = forms.CharField(
|
||||
@ -261,7 +255,7 @@ class InstallStockForm(HelperForm):
|
||||
fields = [
|
||||
'part',
|
||||
'stock_item',
|
||||
'quantity_to_install',
|
||||
# 'quantity_to_install',
|
||||
'notes',
|
||||
]
|
||||
|
||||
|
@ -119,6 +119,11 @@
|
||||
<h4>{% trans "Installed Stock Items" %}</h4>
|
||||
</div>
|
||||
<div class='panel-content'>
|
||||
<div class='btn-group'>
|
||||
<button type='button' class='btn btn-success' id='stock-item-install'>
|
||||
<span class='fas fa-plus-circle'></span> {% trans "Install Stock Item" %}
|
||||
</button>
|
||||
</div>
|
||||
<table class='table table-striped table-condensed' id='installed-table'></table>
|
||||
</div>
|
||||
</div>
|
||||
@ -128,6 +133,20 @@
|
||||
{% block js_ready %}
|
||||
{{ block.super }}
|
||||
|
||||
$('#stock-item-install').click(function() {
|
||||
|
||||
launchModalForm(
|
||||
"{% url 'stock-item-install' item.pk %}",
|
||||
{
|
||||
data: {
|
||||
'part': {{ item.part.pk }},
|
||||
'install_item': true,
|
||||
},
|
||||
reload: true,
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
loadInstalledInTable(
|
||||
$('#installed-table'),
|
||||
{
|
||||
|
@ -127,9 +127,11 @@
|
||||
<li><a href='#' id='stock-return-from-customer' title='{% trans "Return to stock" %}'><span class='fas fa-undo'></span> {% trans "Return to stock" %}</a></li>
|
||||
{% endif %}
|
||||
{% if item.belongs_to %}
|
||||
<li>
|
||||
<a href='#' id='stock-uninstall' title='{% trans "Uninstall stock item" %}'><span class='fas fa-unlink'></span> {% trans "Uninstall" %}</a>
|
||||
</li>
|
||||
<li><a href='#' id='stock-uninstall' title='{% trans "Uninstall stock item" %}'><span class='fas fa-unlink'></span> {% trans "Uninstall" %}</a></li>
|
||||
{% else %}
|
||||
{% if item.part.get_used_in %}
|
||||
<li><a href='#' id='stock-install-in' title='{% trans "Install stock item" %}'><span class='fas fa-link'></span> {% trans "Install" %}</a></li>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</ul>
|
||||
</div>
|
||||
@ -461,13 +463,27 @@ $("#stock-serialize").click(function() {
|
||||
);
|
||||
});
|
||||
|
||||
$('#stock-install-in').click(function() {
|
||||
|
||||
launchModalForm(
|
||||
"{% url 'stock-item-install' item.pk %}",
|
||||
{
|
||||
data: {
|
||||
'part': {{ item.part.pk }},
|
||||
'install_in': true,
|
||||
},
|
||||
reload: true,
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
$('#stock-uninstall').click(function() {
|
||||
|
||||
launchModalForm(
|
||||
"{% url 'stock-item-uninstall' %}",
|
||||
{
|
||||
data: {
|
||||
'items[]': [{{ item.pk}}],
|
||||
'items[]': [{{ item.pk }}],
|
||||
},
|
||||
reload: true,
|
||||
}
|
||||
|
@ -3,15 +3,31 @@
|
||||
|
||||
{% block pre_form_content %}
|
||||
|
||||
{% if install_item %}
|
||||
<p>
|
||||
{% trans "Install another StockItem into this item." %}
|
||||
{% trans "Install another Stock Item into this item." %}
|
||||
</p>
|
||||
<p>
|
||||
{% trans "Stock items can only be installed if they meet the following criteria" %}:
|
||||
|
||||
<ul>
|
||||
<li>{% trans "The StockItem links to a Part which is in the BOM for this StockItem" %}</li>
|
||||
<li>{% trans "The StockItem is currently in stock" %}</li>
|
||||
<li>{% trans "The Stock Item links to a Part which is in the BOM for this Stock Item" %}</li>
|
||||
<li>{% trans "The Stock Item is currently in stock" %}</li>
|
||||
<li>{% trans "The Stock Item is serialized and does not belong to another item" %}</li>
|
||||
</ul>
|
||||
</p>
|
||||
{% elif install_in %}
|
||||
<p>
|
||||
{% trans "Install this Stock Item in another stock item." %}
|
||||
</p>
|
||||
<p>
|
||||
{% trans "Stock items can only be installed if they meet the following criteria" %}:
|
||||
|
||||
<ul>
|
||||
<li>{% trans "The part associated to this Stock Item belongs to another part's BOM" %}</li>
|
||||
<li>{% trans "This Stock Item is serialized and does not belong to another item" %}</li>
|
||||
</ul>
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
{% endblock %}
|
@ -536,36 +536,73 @@ class StockItemInstall(AjaxUpdateView):
|
||||
|
||||
part = None
|
||||
|
||||
def get_params(self):
|
||||
""" Retrieve GET parameters """
|
||||
|
||||
# Look at GET params
|
||||
self.part_id = self.request.GET.get('part', None)
|
||||
self.install_in = self.request.GET.get('install_in', False)
|
||||
self.install_item = self.request.GET.get('install_item', False)
|
||||
|
||||
if self.part_id is None:
|
||||
# Look at POST params
|
||||
self.part_id = self.request.POST.get('part', None)
|
||||
|
||||
try:
|
||||
self.part = Part.objects.get(pk=self.part_id)
|
||||
except (ValueError, Part.DoesNotExist):
|
||||
self.part = None
|
||||
|
||||
def get_stock_items(self):
|
||||
"""
|
||||
Return a list of stock items suitable for displaying to the user.
|
||||
|
||||
Requirements:
|
||||
- Items must be in stock
|
||||
|
||||
Filters:
|
||||
- Items can be filtered by Part reference
|
||||
- Items must be in BOM of stock item
|
||||
- Items must be serialized
|
||||
"""
|
||||
|
||||
# Filter items in stock
|
||||
items = StockItem.objects.filter(StockItem.IN_STOCK_FILTER)
|
||||
|
||||
# Filter by Part association
|
||||
# Filter serialized stock items
|
||||
items = items.exclude(serial__isnull=True).exclude(serial__exact='')
|
||||
|
||||
# Look at GET params
|
||||
part_id = self.request.GET.get('part', None)
|
||||
if self.part:
|
||||
# Filter for parts to install this item in
|
||||
if self.install_in:
|
||||
# Get parts using this part
|
||||
allowed_parts = self.part.get_used_in()
|
||||
# Filter
|
||||
items = items.filter(part__in=allowed_parts)
|
||||
|
||||
if part_id is None:
|
||||
# Look at POST params
|
||||
part_id = self.request.POST.get('part', None)
|
||||
|
||||
try:
|
||||
self.part = Part.objects.get(pk=part_id)
|
||||
items = items.filter(part=self.part)
|
||||
except (ValueError, Part.DoesNotExist):
|
||||
self.part = None
|
||||
# Filter for parts to install in this item
|
||||
if self.install_item:
|
||||
# Get parts used in this part's BOM
|
||||
bom_items = self.part.get_bom_items()
|
||||
allowed_parts = [item.sub_part for item in bom_items]
|
||||
# Filter
|
||||
items = items.filter(part__in=allowed_parts)
|
||||
|
||||
return items
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
""" Retrieve parameters and update context """
|
||||
|
||||
ctx = super().get_context_data(**kwargs)
|
||||
|
||||
# Get request parameters
|
||||
self.get_params()
|
||||
|
||||
ctx.update({
|
||||
'part': self.part,
|
||||
'install_in': self.install_in,
|
||||
'install_item': self.install_item,
|
||||
})
|
||||
|
||||
return ctx
|
||||
|
||||
def get_initial(self):
|
||||
|
||||
initials = super().get_initial()
|
||||
@ -576,11 +613,16 @@ class StockItemInstall(AjaxUpdateView):
|
||||
if items.count() == 1:
|
||||
item = items.first()
|
||||
initials['stock_item'] = item.pk
|
||||
initials['quantity_to_install'] = item.quantity
|
||||
|
||||
if self.part:
|
||||
initials['part'] = self.part
|
||||
|
||||
try:
|
||||
# Is this stock item being installed in the other stock item?
|
||||
initials['to_install'] = self.install_in or not self.install_item
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
return initials
|
||||
|
||||
def get_form(self):
|
||||
@ -593,6 +635,8 @@ class StockItemInstall(AjaxUpdateView):
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
|
||||
self.get_params()
|
||||
|
||||
form = self.get_form()
|
||||
|
||||
valid = form.is_valid()
|
||||
@ -602,12 +646,18 @@ class StockItemInstall(AjaxUpdateView):
|
||||
data = form.cleaned_data
|
||||
|
||||
other_stock_item = data['stock_item']
|
||||
quantity = data['quantity_to_install']
|
||||
# Quantity will always be 1 for serialized item
|
||||
quantity = 1
|
||||
notes = data['notes']
|
||||
|
||||
# Install the other stock item into this one
|
||||
# Get stock item
|
||||
this_stock_item = self.get_object()
|
||||
|
||||
if data['to_install']:
|
||||
# Install this stock item into the other stock item
|
||||
other_stock_item.installStockItem(this_stock_item, quantity, request.user, notes)
|
||||
else:
|
||||
# Install the other stock item into this one
|
||||
this_stock_item.installStockItem(other_stock_item, quantity, request.user, notes)
|
||||
|
||||
data = {
|
||||
|
@ -8,6 +8,26 @@
|
||||
*/
|
||||
|
||||
|
||||
function bomItemFields() {
|
||||
|
||||
return {
|
||||
part: {
|
||||
hidden: true,
|
||||
},
|
||||
sub_part: {
|
||||
},
|
||||
quantity: {},
|
||||
reference: {},
|
||||
overage: {},
|
||||
note: {},
|
||||
allow_variants: {},
|
||||
inherited: {},
|
||||
optional: {},
|
||||
};
|
||||
|
||||
}
|
||||
|
||||
|
||||
function reloadBomTable(table, options) {
|
||||
|
||||
table.bootstrapTable('refresh');
|
||||
@ -262,13 +282,13 @@ function loadBomTable(table, options) {
|
||||
cols.push(
|
||||
{
|
||||
field: 'price_range',
|
||||
title: '{% trans "Buy Price" %}',
|
||||
title: '{% trans "Supplier Cost" %}',
|
||||
sortable: true,
|
||||
formatter: function(value, row, index, field) {
|
||||
if (value) {
|
||||
return value;
|
||||
} else {
|
||||
return "<span class='warning-msg'>{% trans 'No pricing available' %}</span>";
|
||||
return "<span class='warning-msg'>{% trans 'No supplier pricing available' %}</span>";
|
||||
}
|
||||
}
|
||||
});
|
||||
@ -528,14 +548,15 @@ function loadBomTable(table, options) {
|
||||
var pk = $(this).attr('pk');
|
||||
var url = `/part/bom/${pk}/edit/`;
|
||||
|
||||
launchModalForm(
|
||||
url,
|
||||
{
|
||||
success: function() {
|
||||
var fields = bomItemFields();
|
||||
|
||||
constructForm(`/api/bom/${pk}/`, {
|
||||
fields: fields,
|
||||
title: '{% trans "Edit BOM Item" %}',
|
||||
onSuccess: function() {
|
||||
reloadBomTable(table);
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
table.on('click', '.bom-validate-button', function() {
|
||||
|
@ -927,7 +927,7 @@ function loadBuildTable(table, options) {
|
||||
},
|
||||
{
|
||||
field: 'responsible',
|
||||
title: '{% trans "Resposible" %}',
|
||||
title: '{% trans "Responsible" %}',
|
||||
sortable: true,
|
||||
formatter: function(value, row, index, field) {
|
||||
if (value)
|
||||
|
@ -265,6 +265,8 @@ function setupFilterList(tableKey, table, target) {
|
||||
// One blank slate, please
|
||||
element.empty();
|
||||
|
||||
element.append(`<button id='reload-${tableKey}' title='{% trans "Reload data" %}' class='btn btn-default filter-tag'><span class='fas fa-redo-alt'></span></button>`);
|
||||
|
||||
element.append(`<button id='${add}' title='{% trans "Add new filter" %}' class='btn btn-default filter-tag'><span class='fas fa-filter'></span></button>`);
|
||||
|
||||
if (Object.keys(filters).length > 0) {
|
||||
@ -279,6 +281,11 @@ function setupFilterList(tableKey, table, target) {
|
||||
element.append(`<div title='${description}' class='filter-tag'>${title} = ${value}<span ${tag}='${key}' class='close'>x</span></div>`);
|
||||
}
|
||||
|
||||
// Callback for reloading the table
|
||||
element.find(`#reload-${tableKey}`).click(function() {
|
||||
$(table).bootstrapTable('refresh');
|
||||
});
|
||||
|
||||
// Add a callback for adding a new filter
|
||||
element.find(`#${add}`).click(function clicked() {
|
||||
|
||||
|
@ -240,6 +240,7 @@ function constructDeleteForm(fields, options) {
|
||||
* - hidden: Set to true to hide the field
|
||||
* - icon: font-awesome icon to display before the field
|
||||
* - prefix: Custom HTML prefix to display before the field
|
||||
* - data: map of data to fill out field values with
|
||||
* - focus: Name of field to focus on when modal is displayed
|
||||
* - preventClose: Set to true to prevent form from closing on success
|
||||
* - onSuccess: callback function when form action is successful
|
||||
@ -263,6 +264,11 @@ function constructForm(url, options) {
|
||||
// Default HTTP method
|
||||
options.method = options.method || 'PATCH';
|
||||
|
||||
// Construct an "empty" data object if not provided
|
||||
if (!options.data) {
|
||||
options.data = {};
|
||||
}
|
||||
|
||||
// Request OPTIONS endpoint from the API
|
||||
getApiEndpointOptions(url, function(OPTIONS) {
|
||||
|
||||
@ -346,11 +352,20 @@ function constructFormBody(fields, options) {
|
||||
// otherwise *all* fields will be displayed
|
||||
var displayed_fields = options.fields || fields;
|
||||
|
||||
// Handle initial data overrides
|
||||
if (options.data) {
|
||||
for (const field in options.data) {
|
||||
|
||||
if (field in fields) {
|
||||
fields[field].value = options.data[field];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Provide each field object with its own name
|
||||
for(field in fields) {
|
||||
fields[field].name = field;
|
||||
|
||||
|
||||
// If any "instance_filters" are defined for the endpoint, copy them across (overwrite)
|
||||
if (fields[field].instance_filters) {
|
||||
fields[field].filters = Object.assign(fields[field].filters || {}, fields[field].instance_filters);
|
||||
@ -366,6 +381,10 @@ function constructFormBody(fields, options) {
|
||||
|
||||
// TODO: Refactor the following code with Object.assign (see above)
|
||||
|
||||
// "before" and "after" renders
|
||||
fields[field].before = field_options.before;
|
||||
fields[field].after = field_options.after;
|
||||
|
||||
// Secondary modal options
|
||||
fields[field].secondary = field_options.secondary;
|
||||
|
||||
@ -560,10 +579,15 @@ function submitFormData(fields, options) {
|
||||
var has_files = false;
|
||||
|
||||
// Extract values for each field
|
||||
options.field_names.forEach(function(name) {
|
||||
for (var idx = 0; idx < options.field_names.length; idx++) {
|
||||
|
||||
var name = options.field_names[idx];
|
||||
|
||||
var field = fields[name] || null;
|
||||
|
||||
// Ignore visual fields
|
||||
if (field && field.type == 'candy') continue;
|
||||
|
||||
if (field) {
|
||||
|
||||
var value = getFormFieldValue(name, field, options);
|
||||
@ -593,7 +617,7 @@ function submitFormData(fields, options) {
|
||||
} else {
|
||||
console.log(`WARNING: Could not find field matching '${name}'`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
var upload_func = inventreePut;
|
||||
|
||||
@ -1279,6 +1303,11 @@ function renderModelData(name, model, data, parameters, options) {
|
||||
*/
|
||||
function constructField(name, parameters, options) {
|
||||
|
||||
// Shortcut for simple visual fields
|
||||
if (parameters.type == 'candy') {
|
||||
return constructCandyInput(name, parameters, options);
|
||||
}
|
||||
|
||||
var field_name = `id_${name}`;
|
||||
|
||||
// Hidden inputs are rendered without label / help text / etc
|
||||
@ -1292,7 +1321,14 @@ function constructField(name, parameters, options) {
|
||||
form_classes += ' has-error';
|
||||
}
|
||||
|
||||
var html = `<div id='div_${field_name}' class='${form_classes}'>`;
|
||||
var html = '';
|
||||
|
||||
// Optional content to render before the field
|
||||
if (parameters.before) {
|
||||
html += parameters.before;
|
||||
}
|
||||
|
||||
html += `<div id='div_${field_name}' class='${form_classes}'>`;
|
||||
|
||||
// Add a label
|
||||
html += constructLabel(name, parameters);
|
||||
@ -1352,6 +1388,10 @@ function constructField(name, parameters, options) {
|
||||
html += `</div>`; // controls
|
||||
html += `</div>`; // form-group
|
||||
|
||||
if (parameters.after) {
|
||||
html += parameters.after;
|
||||
}
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
@ -1430,6 +1470,9 @@ function constructInput(name, parameters, options) {
|
||||
case 'date':
|
||||
func = constructDateInput;
|
||||
break;
|
||||
case 'candy':
|
||||
func = constructCandyInput;
|
||||
break;
|
||||
default:
|
||||
// Unsupported field type!
|
||||
break;
|
||||
@ -1658,6 +1701,17 @@ function constructDateInput(name, parameters, options) {
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
* Construct a "candy" field input
|
||||
* No actual field data!
|
||||
*/
|
||||
function constructCandyInput(name, parameters, options) {
|
||||
|
||||
return parameters.html;
|
||||
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
* Construct a 'help text' div based on the field parameters
|
||||
*
|
||||
|
@ -13,91 +13,213 @@ function yesNoLabel(value) {
|
||||
}
|
||||
}
|
||||
|
||||
// Construct fieldset for part forms
|
||||
function partFields(options={}) {
|
||||
|
||||
var fields = {
|
||||
category: {},
|
||||
name: {},
|
||||
IPN: {},
|
||||
revision: {},
|
||||
description: {},
|
||||
variant_of: {},
|
||||
keywords: {
|
||||
icon: 'fa-key',
|
||||
},
|
||||
units: {},
|
||||
link: {
|
||||
icon: 'fa-link',
|
||||
},
|
||||
default_location: {},
|
||||
default_supplier: {},
|
||||
default_expiry: {
|
||||
icon: 'fa-calendar-alt',
|
||||
},
|
||||
minimum_stock: {
|
||||
icon: 'fa-boxes',
|
||||
},
|
||||
attributes: {
|
||||
type: 'candy',
|
||||
html: `<hr><h4><i>{% trans "Part Attributes" %}</i></h4><hr>`
|
||||
},
|
||||
component: {
|
||||
value: global_settings.PART_COMPONENT,
|
||||
},
|
||||
assembly: {
|
||||
value: global_settings.PART_ASSEMBLY,
|
||||
},
|
||||
is_template: {
|
||||
value: global_settings.PART_TEMPLATE,
|
||||
},
|
||||
trackable: {
|
||||
value: global_settings.PART_TRACKABLE,
|
||||
},
|
||||
purchaseable: {
|
||||
value: global_settings.PART_PURCHASEABLE,
|
||||
},
|
||||
salable: {
|
||||
value: global_settings.PART_SALABLE,
|
||||
},
|
||||
virtual: {
|
||||
value: global_settings.PART_VIRTUAL,
|
||||
},
|
||||
};
|
||||
|
||||
// If editing a part, we can set the "active" status
|
||||
if (options.edit) {
|
||||
fields.active = {};
|
||||
}
|
||||
|
||||
// Pop expiry field
|
||||
if (!global_settings.STOCK_ENABLE_EXPIRY) {
|
||||
delete fields["default_expiry"];
|
||||
}
|
||||
|
||||
// Additional fields when "creating" a new part
|
||||
if (options.create) {
|
||||
|
||||
// No supplier parts available yet
|
||||
delete fields["default_supplier"];
|
||||
|
||||
fields.create = {
|
||||
type: 'candy',
|
||||
html: `<hr><h4><i>{% trans "Part Creation Options" %}</i></h4><hr>`,
|
||||
};
|
||||
|
||||
if (global_settings.PART_CREATE_INITIAL) {
|
||||
fields.initial_stock = {
|
||||
type: 'decimal',
|
||||
label: '{% trans "Initial Stock Quantity" %}',
|
||||
help_text: '{% trans "Initialize part stock with specified quantity" %}',
|
||||
};
|
||||
}
|
||||
|
||||
fields.copy_category_parameters = {
|
||||
type: 'boolean',
|
||||
label: '{% trans "Copy Category Parameters" %}',
|
||||
help_text: '{% trans "Copy parameter templates from selected part category" %}',
|
||||
value: global_settings.PART_CATEGORY_PARAMETERS,
|
||||
};
|
||||
}
|
||||
|
||||
// Additional fields when "duplicating" a part
|
||||
if (options.duplicate) {
|
||||
|
||||
fields.duplicate = {
|
||||
type: 'candy',
|
||||
html: `<hr><h4><i>{% trans "Part Duplication Options" %}</i></h4><hr>`,
|
||||
};
|
||||
|
||||
fields.copy_from = {
|
||||
type: 'integer',
|
||||
hidden: true,
|
||||
value: options.duplicate,
|
||||
},
|
||||
|
||||
fields.copy_image = {
|
||||
type: 'boolean',
|
||||
label: '{% trans "Copy Image" %}',
|
||||
help_text: '{% trans "Copy image from original part" %}',
|
||||
value: true,
|
||||
},
|
||||
|
||||
fields.copy_bom = {
|
||||
type: 'boolean',
|
||||
label: '{% trans "Copy BOM" %}',
|
||||
help_text: '{% trans "Copy bill of materials from original part" %}',
|
||||
value: global_settings.PART_COPY_BOM,
|
||||
};
|
||||
|
||||
fields.copy_parameters = {
|
||||
type: 'boolean',
|
||||
label: '{% trans "Copy Parameters" %}',
|
||||
help_text: '{% trans "Copy parameter data from original part" %}',
|
||||
value: global_settings.PART_COPY_PARAMETERS,
|
||||
};
|
||||
}
|
||||
|
||||
return fields;
|
||||
}
|
||||
|
||||
|
||||
function categoryFields() {
|
||||
return {
|
||||
parent: {
|
||||
help_text: '{% trans "Parent part category" %}',
|
||||
},
|
||||
name: {},
|
||||
description: {},
|
||||
default_location: {},
|
||||
default_keywords: {
|
||||
icon: 'fa-key',
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
// Edit a PartCategory via the API
|
||||
function editCategory(pk, options={}) {
|
||||
|
||||
var url = `/api/part/category/${pk}/`;
|
||||
|
||||
var fields = categoryFields();
|
||||
|
||||
constructForm(url, {
|
||||
fields: fields,
|
||||
title: '{% trans "Edit Part Category" %}',
|
||||
reload: true,
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
|
||||
function editPart(pk, options={}) {
|
||||
|
||||
var url = `/api/part/${pk}/`;
|
||||
|
||||
var fields = {
|
||||
category: {
|
||||
/*
|
||||
secondary: {
|
||||
label: '{% trans "New Category" %}',
|
||||
title: '{% trans "Create New Part Category" %}',
|
||||
api_url: '{% url "api-part-category-list" %}',
|
||||
method: 'POST',
|
||||
fields: {
|
||||
name: {},
|
||||
description: {},
|
||||
parent: {
|
||||
secondary: {
|
||||
title: '{% trans "New Parent" %}',
|
||||
api_url: '{% url "api-part-category-list" %}',
|
||||
method: 'POST',
|
||||
fields: {
|
||||
name: {},
|
||||
description: {},
|
||||
parent: {},
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
*/
|
||||
},
|
||||
name: {
|
||||
placeholder: 'part name',
|
||||
},
|
||||
IPN: {},
|
||||
description: {},
|
||||
revision: {},
|
||||
keywords: {
|
||||
icon: 'fa-key',
|
||||
},
|
||||
variant_of: {},
|
||||
link: {
|
||||
icon: 'fa-link',
|
||||
},
|
||||
default_location: {
|
||||
/*
|
||||
secondary: {
|
||||
label: '{% trans "New Location" %}',
|
||||
title: '{% trans "Create new stock location" %}',
|
||||
},
|
||||
*/
|
||||
},
|
||||
default_supplier: {
|
||||
filters: {
|
||||
part: pk,
|
||||
part_detail: true,
|
||||
manufacturer_detail: true,
|
||||
supplier_detail: true,
|
||||
},
|
||||
/*
|
||||
secondary: {
|
||||
label: '{% trans "New Supplier Part" %}',
|
||||
title: '{% trans "Create new supplier part" %}',
|
||||
}
|
||||
*/
|
||||
},
|
||||
units: {},
|
||||
minimum_stock: {},
|
||||
virtual: {},
|
||||
is_template: {},
|
||||
assembly: {},
|
||||
component: {},
|
||||
trackable: {},
|
||||
purchaseable: {},
|
||||
salable: {},
|
||||
active: {},
|
||||
};
|
||||
var fields = partFields({
|
||||
edit: true
|
||||
});
|
||||
|
||||
constructForm(url, {
|
||||
fields: fields,
|
||||
title: '{% trans "Edit Part" %}',
|
||||
reload: true,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// Launch form to duplicate a part
|
||||
function duplicatePart(pk, options={}) {
|
||||
|
||||
// First we need all the part information
|
||||
inventreeGet(`/api/part/${pk}/`, {}, {
|
||||
|
||||
success: function(data) {
|
||||
|
||||
var fields = partFields({
|
||||
duplicate: pk,
|
||||
});
|
||||
|
||||
// If we are making a "variant" part
|
||||
if (options.variant) {
|
||||
|
||||
// Override the "variant_of" field
|
||||
data.variant_of = pk;
|
||||
}
|
||||
|
||||
constructForm('{% url "api-part-list" %}', {
|
||||
method: 'POST',
|
||||
fields: fields,
|
||||
title: '{% trans "Duplicate Part" %}',
|
||||
data: data,
|
||||
onSuccess: function(data) {
|
||||
// Follow the new part
|
||||
location.href = `/part/${data.pk}/`;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
@ -187,7 +187,7 @@ $.fn.inventreeTable = function(options) {
|
||||
if (!options.disablePagination) {
|
||||
options.pagination = true;
|
||||
options.paginationVAlign = options.paginationVAlign || 'both';
|
||||
options.pageSize = inventreeLoad(varName, 25);
|
||||
options.pageSize = options.pageSize || inventreeLoad(varName, 25);
|
||||
options.pageList = [25, 50, 100, 250, 'all'];
|
||||
options.totalField = 'count';
|
||||
options.dataField = 'results';
|
||||
|
@ -21,7 +21,8 @@ coverage==5.3 # Unit test coverage
|
||||
coveralls==2.1.2 # Coveralls linking (for Travis)
|
||||
rapidfuzz==0.7.6 # Fuzzy string matching
|
||||
django-stdimage==5.1.1 # Advanced ImageField management
|
||||
django-weasyprint==1.0.1 # HTML PDF export
|
||||
weasyprint==52.5 # PDF generation library (Note: in the future need to update to 53)
|
||||
django-weasyprint==1.0.1 # django weasyprint integration
|
||||
django-debug-toolbar==2.2 # Debug / profiling toolbar
|
||||
django-admin-shell==0.1.2 # Python shell for the admin interface
|
||||
py-moneyed==0.8.0 # Specific version requirement for py-moneyed
|
||||
|
Loading…
Reference in New Issue
Block a user