mirror of
https://github.com/inventree/InvenTree.git
synced 2025-06-20 13:56:30 +00:00
Merge branch 'master' of https://github.com/inventree/InvenTree into one-pricing-view
This commit is contained in:
@ -39,7 +39,8 @@ class PartConfig(AppConfig):
|
||||
logger.debug("InvenTree: Checking Part image thumbnails")
|
||||
|
||||
try:
|
||||
for part in Part.objects.all():
|
||||
# Only check parts which have images
|
||||
for part in Part.objects.exclude(image=None):
|
||||
if part.image:
|
||||
url = part.image.thumbnail.name
|
||||
loc = os.path.join(settings.MEDIA_ROOT, url)
|
||||
@ -50,8 +51,7 @@ class PartConfig(AppConfig):
|
||||
part.image.render_variations(replace=False)
|
||||
except FileNotFoundError:
|
||||
logger.warning(f"Image file '{part.image}' missing")
|
||||
part.image = None
|
||||
part.save()
|
||||
pass
|
||||
except UnidentifiedImageError:
|
||||
logger.warning(f"Image file '{part.image}' is invalid")
|
||||
except (OperationalError, ProgrammingError):
|
||||
|
@ -1479,16 +1479,17 @@ class Part(MPTTModel):
|
||||
|
||||
return True
|
||||
|
||||
def get_price_info(self, quantity=1, buy=True, bom=True):
|
||||
def get_price_info(self, quantity=1, buy=True, bom=True, internal=False):
|
||||
""" Return a simplified pricing string for this part
|
||||
|
||||
Args:
|
||||
quantity: Number of units to calculate price for
|
||||
buy: Include supplier pricing (default = True)
|
||||
bom: Include BOM pricing (default = True)
|
||||
internal: Include internal pricing (default = False)
|
||||
"""
|
||||
|
||||
price_range = self.get_price_range(quantity, buy, bom)
|
||||
price_range = self.get_price_range(quantity, buy, bom, internal)
|
||||
|
||||
if price_range is None:
|
||||
return None
|
||||
@ -1576,9 +1577,10 @@ class Part(MPTTModel):
|
||||
|
||||
- Supplier price (if purchased from suppliers)
|
||||
- BOM price (if built from other parts)
|
||||
- Internal price (if set for the part)
|
||||
|
||||
Returns:
|
||||
Minimum of the supplier price or BOM price. If no pricing available, returns None
|
||||
Minimum of the supplier, BOM or internal price. If no pricing available, returns None
|
||||
"""
|
||||
|
||||
# only get internal price if set and should be used
|
||||
@ -2499,7 +2501,9 @@ class BomItem(models.Model):
|
||||
def price_range(self):
|
||||
""" Return the price-range for this BOM item. """
|
||||
|
||||
prange = self.sub_part.get_price_range(self.quantity)
|
||||
# get internal price setting
|
||||
use_internal = common.models.InvenTreeSetting.get_setting('PART_BOM_USE_INTERNAL_PRICE', False)
|
||||
prange = self.sub_part.get_price_range(self.quantity, intenal=use_internal)
|
||||
|
||||
if prange is None:
|
||||
return prange
|
||||
|
@ -7,12 +7,15 @@ from decimal import Decimal
|
||||
from django.db import models
|
||||
from django.db.models import Q
|
||||
from django.db.models.functions import Coalesce
|
||||
from InvenTree.serializers import (InvenTreeAttachmentSerializerField,
|
||||
InvenTreeModelSerializer)
|
||||
from InvenTree.status_codes import BuildStatus, PurchaseOrderStatus
|
||||
|
||||
from rest_framework import serializers
|
||||
from sql_util.utils import SubqueryCount, SubquerySum
|
||||
from djmoney.contrib.django_rest_framework import MoneyField
|
||||
|
||||
from InvenTree.serializers import (InvenTreeAttachmentSerializerField,
|
||||
InvenTreeImageSerializerField,
|
||||
InvenTreeModelSerializer)
|
||||
from InvenTree.status_codes import BuildStatus, PurchaseOrderStatus
|
||||
from stock.models import StockItem
|
||||
|
||||
from .models import (BomItem, Part, PartAttachment, PartCategory,
|
||||
@ -300,7 +303,7 @@ class PartSerializer(InvenTreeModelSerializer):
|
||||
stock_item_count = serializers.IntegerField(read_only=True)
|
||||
suppliers = serializers.IntegerField(read_only=True)
|
||||
|
||||
image = serializers.CharField(source='get_image_url', read_only=True)
|
||||
image = InvenTreeImageSerializerField(required=False, allow_null=True)
|
||||
thumbnail = serializers.CharField(source='get_thumbnail_url', read_only=True)
|
||||
starred = serializers.SerializerMethodField()
|
||||
|
||||
|
@ -8,6 +8,15 @@
|
||||
|
||||
{% block content %}
|
||||
|
||||
{% if messages %}
|
||||
{% for message in messages %}
|
||||
<div class='{{ message.tags }}'>
|
||||
{{ message|safe }}
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
|
||||
<div class='panel panel-default panel-inventree'>
|
||||
<div class='row'>
|
||||
<div class='col-sm-6'>
|
||||
|
@ -1,4 +1,7 @@
|
||||
{% load i18n %}
|
||||
{% load inventree_extras %}
|
||||
|
||||
{% settings_value 'PART_SHOW_IMPORT' as show_import %}
|
||||
|
||||
<ul class='list-group'>
|
||||
|
||||
@ -30,6 +33,15 @@
|
||||
</a>
|
||||
</li>
|
||||
|
||||
{% if show_import and user.is_staff and roles.part.add %}
|
||||
<li class='list-group-item {% if tab == "import" %}active{% endif %}' title='{% trans "Import Parts" %}'>
|
||||
<a href='{% url "part-import" %}'>
|
||||
<span class='fas fa-file-upload sidebar-icon'></span>
|
||||
{% trans "Import Parts" %}
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
|
||||
{% if category %}
|
||||
<li class='list-group-item {% if tab == "parameters" %}active{% endif %}' title='{% trans "Parameters" %}'>
|
||||
<a href='{% url "category-parametric" category.id %}'>
|
||||
|
@ -0,0 +1,89 @@
|
||||
{% extends "part/import_wizard/ajax_part_upload.html" %}
|
||||
{% load inventree_extras %}
|
||||
{% load i18n %}
|
||||
{% load static %}
|
||||
|
||||
{% block form_alert %}
|
||||
{% if missing_columns and missing_columns|length > 0 %}
|
||||
<div class='alert alert-danger alert-block' role='alert'>
|
||||
{% trans "Missing selections for the following required columns" %}:
|
||||
<br>
|
||||
<ul>
|
||||
{% for col in missing_columns %}
|
||||
<li>{{ col }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if duplicates and duplicates|length > 0 %}
|
||||
<div class='alert alert-danger alert-block' role='alert'>
|
||||
{% trans "Duplicate selections found, see below. Fix them then retry submitting." %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock form_alert %}
|
||||
|
||||
{% block form_content %}
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{% trans "File Fields" %}</th>
|
||||
<th></th>
|
||||
{% for col in form %}
|
||||
<th>
|
||||
<div>
|
||||
<input type='hidden' name='col_name_{{ forloop.counter0 }}' value='{{ col.name }}'/>
|
||||
{{ col.name }}
|
||||
<button class='btn btn-default btn-remove' onClick='removeColFromBomWizard()' id='del_col_{{ forloop.counter0 }}' style='display: inline; float: right;' title='{% trans "Remove column" %}'>
|
||||
<span col_id='{{ forloop.counter0 }}' class='fas fa-trash-alt icon-red'></span>
|
||||
</button>
|
||||
</div>
|
||||
</th>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>{% trans "Match Fields" %}</td>
|
||||
<td></td>
|
||||
{% for col in form %}
|
||||
<td>
|
||||
{{ col }}
|
||||
{% for duplicate in duplicates %}
|
||||
{% if duplicate == col.value %}
|
||||
<div class='alert alert-danger alert-block text-center' role='alert' style='padding:2px; margin-top:6px; margin-bottom:2px'>
|
||||
<b>{% trans "Duplicate selection" %}</b>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</td>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
{% for row in rows %}
|
||||
{% with forloop.counter as row_index %}
|
||||
<tr>
|
||||
<td style='width: 32px;'>
|
||||
<button class='btn btn-default btn-remove' onClick='removeRowFromBomWizard()' id='del_row_{{ row_index }}' style='display: inline; float: left;' title='{% trans "Remove row" %}'>
|
||||
<span row_id='{{ row_index }}' class='fas fa-trash-alt icon-red'></span>
|
||||
</button>
|
||||
</td>
|
||||
<td style='text-align: left;'>{{ row_index }}</td>
|
||||
{% for item in row.data %}
|
||||
<td>
|
||||
<input type='hidden' name='row_{{ row_index }}_col_{{ forloop.counter0 }}' value='{{ item }}'/>
|
||||
{{ item }}
|
||||
</td>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
{% endwith %}
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
{% endblock form_content %}
|
||||
|
||||
{% block js_ready %}
|
||||
{{ block.super }}
|
||||
|
||||
$('.fieldselect').select2({
|
||||
width: '100%',
|
||||
matcher: partialMatcher,
|
||||
});
|
||||
|
||||
{% endblock %}
|
@ -0,0 +1,84 @@
|
||||
{% extends "part/import_wizard/ajax_part_upload.html" %}
|
||||
{% load inventree_extras %}
|
||||
{% load i18n %}
|
||||
{% load static %}
|
||||
{% load crispy_forms_tags %}
|
||||
|
||||
{% block form_alert %}
|
||||
{% if form.errors %}
|
||||
{% endif %}
|
||||
{% if form_errors %}
|
||||
<div class='alert alert-danger alert-block' role='alert'>
|
||||
{% trans "Errors exist in the submitted data" %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock form_alert %}
|
||||
|
||||
{% block form_content %}
|
||||
<thead>
|
||||
<tr>
|
||||
<th></th>
|
||||
<th>{% trans "Row" %}</th>
|
||||
{% for col in columns %}
|
||||
|
||||
<th>
|
||||
<input type='hidden' name='col_name_{{ forloop.counter0 }}' value='{{ col.name }}'/>
|
||||
<input type='hidden' name='col_guess_{{ forloop.counter0 }}' value='{{ col.guess }}'/>
|
||||
{% if col.guess %}
|
||||
{{ col.guess }}
|
||||
{% else %}
|
||||
{{ col.name }}
|
||||
{% endif %}
|
||||
</th>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr></tr> {% comment %} Dummy row for javascript del_row method {% endcomment %}
|
||||
{% for row in rows %}
|
||||
<tr {% if row.errors %} style='background: #ffeaea;'{% endif %} part-select='#select_part_{{ row.index }}'>
|
||||
<td>
|
||||
<button class='btn btn-default btn-remove' onClick='removeRowFromBomWizard()' id='del_row_{{ row.index }}' style='display: inline; float: right;' title='{% trans "Remove row" %}'>
|
||||
<span row_id='{{ row.index }}' class='fas fa-trash-alt icon-red'></span>
|
||||
</button>
|
||||
</td>
|
||||
<td>
|
||||
{% add row.index 1 %}
|
||||
</td>
|
||||
{% for item in row.data %}
|
||||
<td>
|
||||
{% if item.column.guess %}
|
||||
{% with row_name=item.column.guess|lower %}
|
||||
{% for field in form.visible_fields %}
|
||||
{% if field.name == row|keyvalue:row_name %}
|
||||
{{ field|as_crispy_field }}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% endwith %}
|
||||
{% else %}
|
||||
{{ item.cell }}
|
||||
{% endif %}
|
||||
<input type='hidden' name='row_{{ row.index }}_col_{{ forloop.counter0 }}' value='{{ item.cell }}'/>
|
||||
</td>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
{% endblock form_content %}
|
||||
|
||||
{% block form_buttons_bottom %}
|
||||
{% endblock form_buttons_bottom %}
|
||||
|
||||
{% block js_ready %}
|
||||
{{ block.super }}
|
||||
|
||||
$('.bomselect').select2({
|
||||
dropdownAutoWidth: true,
|
||||
matcher: partialMatcher,
|
||||
});
|
||||
|
||||
$('.currencyselect').select2({
|
||||
dropdownAutoWidth: true,
|
||||
});
|
||||
|
||||
{% endblock %}
|
@ -0,0 +1,33 @@
|
||||
{% extends "modal_form.html" %}
|
||||
|
||||
{% load inventree_extras %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block form %}
|
||||
|
||||
{% if roles.part.change %}
|
||||
|
||||
<p>{% blocktrans with step=wizard.steps.step1 count=wizard.steps.count %}Step {{step}} of {{count}}{% endblocktrans %}
|
||||
{% if description %}- {{ description }}{% endif %}</p>
|
||||
|
||||
{% block form_alert %}
|
||||
{% endblock form_alert %}
|
||||
|
||||
<form action="" method="post" class='js-modal-form' enctype="multipart/form-data">
|
||||
{% csrf_token %}
|
||||
{% load crispy_forms_tags %}
|
||||
|
||||
<table class='table table-striped' style='margin-top: 12px; margin-bottom: 0px'>
|
||||
{{ wizard.management_form }}
|
||||
{% block form_content %}
|
||||
{% crispy wizard.form %}
|
||||
{% endblock form_content %}
|
||||
</table>
|
||||
|
||||
{% else %}
|
||||
<div class='alert alert-danger alert-block' role='alert'>
|
||||
{% trans "Unsuffitient privileges." %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
@ -0,0 +1,99 @@
|
||||
{% extends "part/import_wizard/part_upload.html" %}
|
||||
{% load inventree_extras %}
|
||||
{% load i18n %}
|
||||
{% load static %}
|
||||
|
||||
{% block form_alert %}
|
||||
{% if missing_columns and missing_columns|length > 0 %}
|
||||
<div class='alert alert-danger alert-block' role='alert'>
|
||||
{% trans "Missing selections for the following required columns" %}:
|
||||
<br>
|
||||
<ul>
|
||||
{% for col in missing_columns %}
|
||||
<li>{{ col }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if duplicates and duplicates|length > 0 %}
|
||||
<div class='alert alert-danger alert-block' role='alert'>
|
||||
{% trans "Duplicate selections found, see below. Fix them then retry submitting." %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock form_alert %}
|
||||
|
||||
{% block form_buttons_top %}
|
||||
{% if wizard.steps.prev %}
|
||||
<button name="wizard_goto_step" type="submit" value="{{ wizard.steps.prev }}" class="save btn btn-default">{% trans "Previous Step" %}</button>
|
||||
{% endif %}
|
||||
<button type="submit" class="save btn btn-default">{% trans "Submit Selections" %}</button>
|
||||
{% endblock form_buttons_top %}
|
||||
|
||||
{% block form_content %}
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{% trans "File Fields" %}</th>
|
||||
<th></th>
|
||||
{% for col in form %}
|
||||
<th>
|
||||
<div>
|
||||
<input type='hidden' name='col_name_{{ forloop.counter0 }}' value='{{ col.name }}'/>
|
||||
{{ col.name }}
|
||||
<button class='btn btn-default btn-remove' onClick='removeColFromBomWizard()' id='del_col_{{ forloop.counter0 }}' style='display: inline; float: right;' title='{% trans "Remove column" %}'>
|
||||
<span col_id='{{ forloop.counter0 }}' class='fas fa-trash-alt icon-red'></span>
|
||||
</button>
|
||||
</div>
|
||||
</th>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>{% trans "Match Fields" %}</td>
|
||||
<td></td>
|
||||
{% for col in form %}
|
||||
<td>
|
||||
{{ col }}
|
||||
{% for duplicate in duplicates %}
|
||||
{% if duplicate == col.value %}
|
||||
<div class='alert alert-danger alert-block text-center' role='alert' style='padding:2px; margin-top:6px; margin-bottom:2px'>
|
||||
<b>{% trans "Duplicate selection" %}</b>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</td>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
{% for row in rows %}
|
||||
{% with forloop.counter as row_index %}
|
||||
<tr>
|
||||
<td style='width: 32px;'>
|
||||
<button class='btn btn-default btn-remove' onClick='removeRowFromBomWizard()' id='del_row_{{ row_index }}' style='display: inline; float: left;' title='{% trans "Remove row" %}'>
|
||||
<span row_id='{{ row_index }}' class='fas fa-trash-alt icon-red'></span>
|
||||
</button>
|
||||
</td>
|
||||
<td style='text-align: left;'>{{ row_index }}</td>
|
||||
{% for item in row.data %}
|
||||
<td>
|
||||
<input type='hidden' name='row_{{ row_index }}_col_{{ forloop.counter0 }}' value='{{ item }}'/>
|
||||
{{ item }}
|
||||
</td>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
{% endwith %}
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
{% endblock form_content %}
|
||||
|
||||
{% block form_buttons_bottom %}
|
||||
{% endblock form_buttons_bottom %}
|
||||
|
||||
{% block js_ready %}
|
||||
{{ block.super }}
|
||||
|
||||
$('.fieldselect').select2({
|
||||
width: '100%',
|
||||
matcher: partialMatcher,
|
||||
});
|
||||
|
||||
{% endblock %}
|
@ -0,0 +1,91 @@
|
||||
{% extends "part/import_wizard/part_upload.html" %}
|
||||
{% load inventree_extras %}
|
||||
{% load i18n %}
|
||||
{% load static %}
|
||||
{% load crispy_forms_tags %}
|
||||
|
||||
{% block form_alert %}
|
||||
{% if form.errors %}
|
||||
{% endif %}
|
||||
{% if form_errors %}
|
||||
<div class='alert alert-danger alert-block' role='alert'>
|
||||
{% trans "Errors exist in the submitted data" %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock form_alert %}
|
||||
|
||||
{% block form_buttons_top %}
|
||||
{% if wizard.steps.prev %}
|
||||
<button name="wizard_goto_step" type="submit" value="{{ wizard.steps.prev }}" class="save btn btn-default">{% trans "Previous Step" %}</button>
|
||||
{% endif %}
|
||||
<button type="submit" class="save btn btn-default">{% trans "Submit Selections" %}</button>
|
||||
{% endblock form_buttons_top %}
|
||||
|
||||
{% block form_content %}
|
||||
<thead>
|
||||
<tr>
|
||||
<th></th>
|
||||
<th>{% trans "Row" %}</th>
|
||||
{% for col in columns %}
|
||||
|
||||
<th>
|
||||
<input type='hidden' name='col_name_{{ forloop.counter0 }}' value='{{ col.name }}'/>
|
||||
<input type='hidden' name='col_guess_{{ forloop.counter0 }}' value='{{ col.guess }}'/>
|
||||
{% if col.guess %}
|
||||
{{ col.guess }}
|
||||
{% else %}
|
||||
{{ col.name }}
|
||||
{% endif %}
|
||||
</th>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr></tr> {% comment %} Dummy row for javascript del_row method {% endcomment %}
|
||||
{% for row in rows %}
|
||||
<tr {% if row.errors %} style='background: #ffeaea;'{% endif %} part-select='#select_part_{{ row.index }}'>
|
||||
<td>
|
||||
<button class='btn btn-default btn-remove' onClick='removeRowFromBomWizard()' id='del_row_{{ row.index }}' style='display: inline; float: right;' title='{% trans "Remove row" %}'>
|
||||
<span row_id='{{ row.index }}' class='fas fa-trash-alt icon-red'></span>
|
||||
</button>
|
||||
</td>
|
||||
<td>
|
||||
{% add row.index 1 %}
|
||||
</td>
|
||||
{% for item in row.data %}
|
||||
<td>
|
||||
{% if item.column.guess %}
|
||||
{% with row_name=item.column.guess|lower %}
|
||||
{% for field in form.visible_fields %}
|
||||
{% if field.name == row|keyvalue:row_name %}
|
||||
{{ field|as_crispy_field }}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% endwith %}
|
||||
{% else %}
|
||||
{{ item.cell }}
|
||||
{% endif %}
|
||||
<input type='hidden' name='row_{{ row.index }}_col_{{ forloop.counter0 }}' value='{{ item.cell }}'/>
|
||||
</td>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
{% endblock form_content %}
|
||||
|
||||
{% block form_buttons_bottom %}
|
||||
{% endblock form_buttons_bottom %}
|
||||
|
||||
{% block js_ready %}
|
||||
{{ block.super }}
|
||||
|
||||
$('.bomselect').select2({
|
||||
dropdownAutoWidth: true,
|
||||
matcher: partialMatcher,
|
||||
});
|
||||
|
||||
$('.currencyselect').select2({
|
||||
dropdownAutoWidth: true,
|
||||
});
|
||||
|
||||
{% endblock %}
|
61
InvenTree/part/templates/part/import_wizard/part_upload.html
Normal file
61
InvenTree/part/templates/part/import_wizard/part_upload.html
Normal file
@ -0,0 +1,61 @@
|
||||
{% extends "part/category.html" %}
|
||||
{% load inventree_extras %}
|
||||
{% load i18n %}
|
||||
{% load static %}
|
||||
|
||||
{% block menubar %}
|
||||
{% include 'part/category_navbar.html' with tab='import' %}
|
||||
{% endblock %}
|
||||
|
||||
{% block category_content %}
|
||||
<div class='panel panel-default panel-inventree'>
|
||||
<div class='panel-heading'>
|
||||
<h4>
|
||||
{% trans "Import Parts from File" %}
|
||||
{{ wizard.form.media }}
|
||||
</h4>
|
||||
</div>
|
||||
<div class='panel-content'>
|
||||
{% if roles.part.change %}
|
||||
|
||||
<p>{% blocktrans with step=wizard.steps.step1 count=wizard.steps.count %}Step {{step}} of {{count}}{% endblocktrans %}
|
||||
{% if description %}- {{ description }}{% endif %}</p>
|
||||
|
||||
{% block form_alert %}
|
||||
{% endblock form_alert %}
|
||||
|
||||
<form action="" method="post" class='js-modal-form' enctype="multipart/form-data">
|
||||
{% csrf_token %}
|
||||
{% load crispy_forms_tags %}
|
||||
|
||||
{% block form_buttons_top %}
|
||||
{% endblock form_buttons_top %}
|
||||
|
||||
<table class='table table-striped' style='margin-top: 12px; margin-bottom: 0px'>
|
||||
{{ wizard.management_form }}
|
||||
{% block form_content %}
|
||||
{% crispy wizard.form %}
|
||||
{% endblock form_content %}
|
||||
</table>
|
||||
|
||||
{% block form_buttons_bottom %}
|
||||
{% if wizard.steps.prev %}
|
||||
<button name="wizard_goto_step" type="submit" value="{{ wizard.steps.prev }}" class="save btn btn-default">{% trans "Previous Step" %}</button>
|
||||
{% endif %}
|
||||
<button type="submit" class="save btn btn-default">{% trans "Upload File" %}</button>
|
||||
</form>
|
||||
{% endblock form_buttons_bottom %}
|
||||
|
||||
{% else %}
|
||||
<div class='alert alert-danger alert-block' role='alert'>
|
||||
{% trans "Unsuffitient privileges." %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block js_ready %}
|
||||
{{ block.super }}
|
||||
|
||||
{% endblock %}
|
@ -239,11 +239,19 @@
|
||||
|
||||
enableDragAndDrop(
|
||||
'#part-thumb',
|
||||
"{% url 'part-image-upload' part.id %}",
|
||||
"{% url 'api-part-detail' part.id %}",
|
||||
{
|
||||
label: 'image',
|
||||
method: 'PATCH',
|
||||
success: function(data, status, xhr) {
|
||||
location.reload();
|
||||
|
||||
// If image / thumbnail data present, live update
|
||||
if (data.image) {
|
||||
$('#part-image').attr('src', data.image);
|
||||
} else {
|
||||
// Otherwise, reload the page
|
||||
location.reload();
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
@ -1,15 +1,19 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from rest_framework import status
|
||||
import PIL
|
||||
|
||||
from django.urls import reverse
|
||||
|
||||
from rest_framework import status
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from InvenTree.api_tester import InvenTreeAPITestCase
|
||||
from InvenTree.status_codes import StockStatus
|
||||
|
||||
from part.models import Part, PartCategory
|
||||
from stock.models import StockItem
|
||||
from company.models import Company
|
||||
|
||||
from InvenTree.api_tester import InvenTreeAPITestCase
|
||||
from InvenTree.status_codes import StockStatus
|
||||
from common.models import InvenTreeSetting
|
||||
|
||||
|
||||
class PartAPITest(InvenTreeAPITestCase):
|
||||
@ -308,6 +312,59 @@ class PartAPITest(InvenTreeAPITestCase):
|
||||
|
||||
self.assertEqual(len(data['results']), n)
|
||||
|
||||
def test_default_values(self):
|
||||
"""
|
||||
Tests for 'default' values:
|
||||
|
||||
Ensure that unspecified fields revert to "default" values
|
||||
(as specified in the model field definition)
|
||||
"""
|
||||
|
||||
url = reverse('api-part-list')
|
||||
|
||||
response = self.client.post(url, {
|
||||
'name': 'all defaults',
|
||||
'description': 'my test part',
|
||||
'category': 1,
|
||||
})
|
||||
|
||||
data = response.data
|
||||
|
||||
# Check that the un-specified fields have used correct default values
|
||||
self.assertTrue(data['active'])
|
||||
self.assertFalse(data['virtual'])
|
||||
|
||||
# By default, parts are not purchaseable
|
||||
self.assertFalse(data['purchaseable'])
|
||||
|
||||
# Set the default 'purchaseable' status to True
|
||||
InvenTreeSetting.set_setting(
|
||||
'PART_PURCHASEABLE',
|
||||
True,
|
||||
self.user
|
||||
)
|
||||
|
||||
response = self.client.post(url, {
|
||||
'name': 'all defaults',
|
||||
'description': 'my test part 2',
|
||||
'category': 1,
|
||||
})
|
||||
|
||||
# Part should now be purchaseable by default
|
||||
self.assertTrue(response.data['purchaseable'])
|
||||
|
||||
# "default" values should not be used if the value is specified
|
||||
response = self.client.post(url, {
|
||||
'name': 'all defaults',
|
||||
'description': 'my test part 2',
|
||||
'category': 1,
|
||||
'active': False,
|
||||
'purchaseable': False,
|
||||
})
|
||||
|
||||
self.assertFalse(response.data['active'])
|
||||
self.assertFalse(response.data['purchaseable'])
|
||||
|
||||
|
||||
class PartDetailTests(InvenTreeAPITestCase):
|
||||
"""
|
||||
@ -388,7 +445,14 @@ class PartDetailTests(InvenTreeAPITestCase):
|
||||
|
||||
# Try to remove the part
|
||||
response = self.client.delete(url)
|
||||
|
||||
|
||||
# As the part is 'active' we cannot delete it
|
||||
self.assertEqual(response.status_code, 405)
|
||||
|
||||
# So, let's make it not active
|
||||
response = self.patch(url, {'active': False}, expected_code=200)
|
||||
|
||||
response = self.client.delete(url)
|
||||
self.assertEqual(response.status_code, 204)
|
||||
|
||||
# Part count should have reduced
|
||||
@ -473,6 +537,72 @@ class PartDetailTests(InvenTreeAPITestCase):
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_image_upload(self):
|
||||
"""
|
||||
Test that we can upload an image to the part API
|
||||
"""
|
||||
|
||||
self.assignRole('part.add')
|
||||
|
||||
# Create a new part
|
||||
response = self.client.post(
|
||||
reverse('api-part-list'),
|
||||
{
|
||||
'name': 'imagine',
|
||||
'description': 'All the people',
|
||||
'category': 1,
|
||||
},
|
||||
expected_code=201
|
||||
)
|
||||
|
||||
pk = response.data['pk']
|
||||
|
||||
url = reverse('api-part-detail', kwargs={'pk': pk})
|
||||
|
||||
p = Part.objects.get(pk=pk)
|
||||
|
||||
# Part should not have an image!
|
||||
with self.assertRaises(ValueError):
|
||||
print(p.image.file)
|
||||
|
||||
# Create a custom APIClient for file uploads
|
||||
# Ref: https://stackoverflow.com/questions/40453947/how-to-generate-a-file-upload-test-request-with-django-rest-frameworks-apireq
|
||||
upload_client = APIClient()
|
||||
upload_client.force_authenticate(user=self.user)
|
||||
|
||||
# Try to upload a non-image file
|
||||
with open('dummy_image.txt', 'w') as dummy_image:
|
||||
dummy_image.write('hello world')
|
||||
|
||||
with open('dummy_image.txt', 'rb') as dummy_image:
|
||||
response = upload_client.patch(
|
||||
url,
|
||||
{
|
||||
'image': dummy_image,
|
||||
},
|
||||
format='multipart',
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 400)
|
||||
|
||||
# Now try to upload a valid image file
|
||||
img = PIL.Image.new('RGB', (128, 128), color='red')
|
||||
img.save('dummy_image.jpg')
|
||||
|
||||
with open('dummy_image.jpg', 'rb') as dummy_image:
|
||||
response = upload_client.patch(
|
||||
url,
|
||||
{
|
||||
'image': dummy_image,
|
||||
},
|
||||
format='multipart',
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
# And now check that the image has been set
|
||||
p = Part.objects.get(pk=pk)
|
||||
|
||||
|
||||
class PartAPIAggregationTest(InvenTreeAPITestCase):
|
||||
"""
|
||||
|
@ -23,7 +23,7 @@ class TestParams(TestCase):
|
||||
def test_str(self):
|
||||
|
||||
t1 = PartParameterTemplate.objects.get(pk=1)
|
||||
self.assertEquals(str(t1), 'Length (mm)')
|
||||
self.assertEqual(str(t1), 'Length (mm)')
|
||||
|
||||
p1 = PartParameter.objects.get(pk=1)
|
||||
self.assertEqual(str(p1), 'M2x4 LPHS : Length = 4mm')
|
||||
|
@ -126,6 +126,10 @@ 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'),
|
||||
|
||||
|
@ -17,6 +17,7 @@ from django.views.generic import DetailView, ListView, FormView, UpdateView
|
||||
from django.forms.models import model_to_dict
|
||||
from django.forms import HiddenInput, CheckboxInput
|
||||
from django.conf import settings
|
||||
from django.contrib import messages
|
||||
|
||||
from moneyed import CURRENCIES
|
||||
from djmoney.contrib.exchange.models import convert_money
|
||||
@ -40,6 +41,10 @@ from .models import PartSellPriceBreak, PartInternalPriceBreak
|
||||
|
||||
from common.models import InvenTreeSetting
|
||||
from company.models import SupplierPart
|
||||
from common.files import FileManager
|
||||
from common.views import FileManagementFormView, FileManagementAjaxView
|
||||
|
||||
from stock.models import StockLocation
|
||||
|
||||
import common.settings as inventree_settings
|
||||
|
||||
@ -719,6 +724,168 @@ class PartCreate(AjaxCreateView):
|
||||
return initials
|
||||
|
||||
|
||||
class PartImport(FileManagementFormView):
|
||||
''' Part: Upload file, match to fields and import parts(using multi-Step form) '''
|
||||
permission_required = 'part.add'
|
||||
|
||||
class PartFileManager(FileManager):
|
||||
REQUIRED_HEADERS = [
|
||||
'Name',
|
||||
'Description',
|
||||
]
|
||||
|
||||
OPTIONAL_MATCH_HEADERS = [
|
||||
'Category',
|
||||
'default_location',
|
||||
'default_supplier',
|
||||
]
|
||||
|
||||
OPTIONAL_HEADERS = [
|
||||
'Keywords',
|
||||
'IPN',
|
||||
'Revision',
|
||||
'Link',
|
||||
'default_expiry',
|
||||
'minimum_stock',
|
||||
'Units',
|
||||
'Notes',
|
||||
]
|
||||
|
||||
name = 'part'
|
||||
form_steps_template = [
|
||||
'part/import_wizard/part_upload.html',
|
||||
'part/import_wizard/match_fields.html',
|
||||
'part/import_wizard/match_references.html',
|
||||
]
|
||||
form_steps_description = [
|
||||
_("Upload File"),
|
||||
_("Match Fields"),
|
||||
_("Match References"),
|
||||
]
|
||||
|
||||
form_field_map = {
|
||||
'name': 'name',
|
||||
'description': 'description',
|
||||
'keywords': 'keywords',
|
||||
'ipn': 'ipn',
|
||||
'revision': 'revision',
|
||||
'link': 'link',
|
||||
'default_expiry': 'default_expiry',
|
||||
'minimum_stock': 'minimum_stock',
|
||||
'units': 'units',
|
||||
'notes': 'notes',
|
||||
'category': 'category',
|
||||
'default_location': 'default_location',
|
||||
'default_supplier': 'default_supplier',
|
||||
}
|
||||
file_manager_class = PartFileManager
|
||||
|
||||
def get_field_selection(self):
|
||||
""" Fill the form fields for step 3 """
|
||||
# fetch available elements
|
||||
self.allowed_items = {}
|
||||
self.matches = {}
|
||||
|
||||
self.allowed_items['Category'] = PartCategory.objects.all()
|
||||
self.matches['Category'] = ['name__contains']
|
||||
self.allowed_items['default_location'] = StockLocation.objects.all()
|
||||
self.matches['default_location'] = ['name__contains']
|
||||
self.allowed_items['default_supplier'] = SupplierPart.objects.all()
|
||||
self.matches['default_supplier'] = ['SKU__contains']
|
||||
|
||||
# setup
|
||||
self.file_manager.setup()
|
||||
# collect submitted column indexes
|
||||
col_ids = {}
|
||||
for col in self.file_manager.HEADERS:
|
||||
index = self.get_column_index(col)
|
||||
if index >= 0:
|
||||
col_ids[col] = index
|
||||
|
||||
# parse all rows
|
||||
for row in self.rows:
|
||||
# check each submitted column
|
||||
for idx in col_ids:
|
||||
data = row['data'][col_ids[idx]]['cell']
|
||||
|
||||
if idx in self.file_manager.OPTIONAL_MATCH_HEADERS:
|
||||
try:
|
||||
exact_match = self.allowed_items[idx].get(**{a: data for a in self.matches[idx]})
|
||||
except (ValueError, self.allowed_items[idx].model.DoesNotExist, self.allowed_items[idx].model.MultipleObjectsReturned):
|
||||
exact_match = None
|
||||
|
||||
row['match_options_' + idx] = self.allowed_items[idx]
|
||||
row['match_' + idx] = exact_match
|
||||
continue
|
||||
|
||||
# general fields
|
||||
row[idx.lower()] = data
|
||||
|
||||
def done(self, form_list, **kwargs):
|
||||
""" Create items """
|
||||
items = self.get_clean_items()
|
||||
|
||||
import_done = 0
|
||||
import_error = []
|
||||
|
||||
# Create Part instances
|
||||
for part_data in items.values():
|
||||
|
||||
# set related parts
|
||||
optional_matches = {}
|
||||
for idx in self.file_manager.OPTIONAL_MATCH_HEADERS:
|
||||
if idx.lower() in part_data:
|
||||
try:
|
||||
optional_matches[idx] = self.allowed_items[idx].get(pk=int(part_data[idx.lower()]))
|
||||
except (ValueError, self.allowed_items[idx].model.DoesNotExist, self.allowed_items[idx].model.MultipleObjectsReturned):
|
||||
optional_matches[idx] = None
|
||||
else:
|
||||
optional_matches[idx] = None
|
||||
|
||||
# add part
|
||||
new_part = Part(
|
||||
name=part_data.get('name', ''),
|
||||
description=part_data.get('description', ''),
|
||||
keywords=part_data.get('keywords', None),
|
||||
IPN=part_data.get('ipn', None),
|
||||
revision=part_data.get('revision', None),
|
||||
link=part_data.get('link', None),
|
||||
default_expiry=part_data.get('default_expiry', 0),
|
||||
minimum_stock=part_data.get('minimum_stock', 0),
|
||||
units=part_data.get('units', None),
|
||||
notes=part_data.get('notes', None),
|
||||
category=optional_matches['Category'],
|
||||
default_location=optional_matches['default_location'],
|
||||
default_supplier=optional_matches['default_supplier'],
|
||||
)
|
||||
try:
|
||||
new_part.save()
|
||||
import_done += 1
|
||||
except ValidationError as _e:
|
||||
import_error.append(', '.join(set(_e.messages)))
|
||||
|
||||
# Set alerts
|
||||
if import_done:
|
||||
alert = f"<strong>{_('Part-Import')}</strong><br>{_('Imported {n} parts').format(n=import_done)}"
|
||||
messages.success(self.request, alert)
|
||||
if import_error:
|
||||
error_text = '\n'.join([f'<li><strong>x{import_error.count(a)}</strong>: {a}</li>' for a in set(import_error)])
|
||||
messages.error(self.request, f"<strong>{_('Some errors occured:')}</strong><br><ul>{error_text}</ul>")
|
||||
|
||||
return HttpResponseRedirect(reverse('part-index'))
|
||||
|
||||
|
||||
class PartImportAjax(FileManagementAjaxView, PartImport):
|
||||
ajax_form_steps_template = [
|
||||
'part/import_wizard/ajax_part_upload.html',
|
||||
'part/import_wizard/ajax_match_fields.html',
|
||||
'part/import_wizard/ajax_match_references.html',
|
||||
]
|
||||
|
||||
def validate(self, obj, form, **kwargs):
|
||||
return PartImport.validate(self, self.steps.current, form, **kwargs)
|
||||
|
||||
|
||||
class PartNotes(UpdateView):
|
||||
""" View for editing the 'notes' field of a Part object.
|
||||
Presents a live markdown editor.
|
||||
@ -847,11 +1014,13 @@ class PartPricingView(PartDetail):
|
||||
|
||||
# BOM Information for Pie-Chart
|
||||
if part.has_bom:
|
||||
# get internal price setting
|
||||
use_internal = InvenTreeSetting.get_setting('PART_BOM_USE_INTERNAL_PRICE', False)
|
||||
ctx_bom_parts = []
|
||||
# iterate over all bom-items
|
||||
for item in part.bom_items.all():
|
||||
ctx_item = {'name': str(item.sub_part)}
|
||||
price, qty = item.sub_part.get_price_range(quantity), item.quantity
|
||||
price, qty = item.sub_part.get_price_range(quantity, internal=use_internal), item.quantity
|
||||
|
||||
price_min, price_max = 0, 0
|
||||
if price: # check if price available
|
||||
|
Reference in New Issue
Block a user