mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +00:00
Merge pull request #2603 from SchrodingersGat/bom-upload-improvements
Bom upload improvements
This commit is contained in:
commit
c94c0902b6
@ -2,6 +2,8 @@
|
|||||||
Custom field validators for InvenTree
|
Custom field validators for InvenTree
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from decimal import Decimal, InvalidOperation
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
@ -115,26 +117,28 @@ def validate_tree_name(value):
|
|||||||
|
|
||||||
|
|
||||||
def validate_overage(value):
|
def validate_overage(value):
|
||||||
""" Validate that a BOM overage string is properly formatted.
|
"""
|
||||||
|
Validate that a BOM overage string is properly formatted.
|
||||||
|
|
||||||
An overage string can look like:
|
An overage string can look like:
|
||||||
|
|
||||||
- An integer number ('1' / 3 / 4)
|
- An integer number ('1' / 3 / 4)
|
||||||
|
- A decimal number ('0.123')
|
||||||
- A percentage ('5%' / '10 %')
|
- A percentage ('5%' / '10 %')
|
||||||
"""
|
"""
|
||||||
|
|
||||||
value = str(value).lower().strip()
|
value = str(value).lower().strip()
|
||||||
|
|
||||||
# First look for a simple integer value
|
# First look for a simple numerical value
|
||||||
try:
|
try:
|
||||||
i = int(value)
|
i = Decimal(value)
|
||||||
|
|
||||||
if i < 0:
|
if i < 0:
|
||||||
raise ValidationError(_("Overage value must not be negative"))
|
raise ValidationError(_("Overage value must not be negative"))
|
||||||
|
|
||||||
# Looks like an integer!
|
# Looks like a number
|
||||||
return True
|
return True
|
||||||
except ValueError:
|
except (ValueError, InvalidOperation):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Now look for a percentage value
|
# Now look for a percentage value
|
||||||
@ -155,7 +159,7 @@ def validate_overage(value):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
raise ValidationError(
|
raise ValidationError(
|
||||||
_("Overage must be an integer value or a percentage")
|
_("Invalid value for overage")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@ -1533,6 +1533,40 @@ class BomList(generics.ListCreateAPIView):
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class BomExtract(generics.CreateAPIView):
|
||||||
|
"""
|
||||||
|
API endpoint for extracting BOM data from a BOM file.
|
||||||
|
"""
|
||||||
|
|
||||||
|
queryset = Part.objects.none()
|
||||||
|
serializer_class = part_serializers.BomExtractSerializer
|
||||||
|
|
||||||
|
def create(self, request, *args, **kwargs):
|
||||||
|
"""
|
||||||
|
Custom create function to return the extracted data
|
||||||
|
"""
|
||||||
|
|
||||||
|
serializer = self.get_serializer(data=request.data)
|
||||||
|
serializer.is_valid(raise_exception=True)
|
||||||
|
self.perform_create(serializer)
|
||||||
|
headers = self.get_success_headers(serializer.data)
|
||||||
|
|
||||||
|
data = serializer.extract_data()
|
||||||
|
|
||||||
|
return Response(data, status=status.HTTP_201_CREATED, headers=headers)
|
||||||
|
|
||||||
|
|
||||||
|
class BomUpload(generics.CreateAPIView):
|
||||||
|
"""
|
||||||
|
API endpoint for uploading a complete Bill of Materials.
|
||||||
|
|
||||||
|
It is assumed that the BOM has been extracted from a file using the BomExtract endpoint.
|
||||||
|
"""
|
||||||
|
|
||||||
|
queryset = Part.objects.all()
|
||||||
|
serializer_class = part_serializers.BomUploadSerializer
|
||||||
|
|
||||||
|
|
||||||
class BomDetail(generics.RetrieveUpdateDestroyAPIView):
|
class BomDetail(generics.RetrieveUpdateDestroyAPIView):
|
||||||
""" API endpoint for detail view of a single BomItem object """
|
""" API endpoint for detail view of a single BomItem object """
|
||||||
|
|
||||||
@ -1685,6 +1719,10 @@ bom_api_urls = [
|
|||||||
url(r'^.*$', BomDetail.as_view(), name='api-bom-item-detail'),
|
url(r'^.*$', BomDetail.as_view(), name='api-bom-item-detail'),
|
||||||
])),
|
])),
|
||||||
|
|
||||||
|
url(r'^extract/', BomExtract.as_view(), name='api-bom-extract'),
|
||||||
|
|
||||||
|
url(r'^upload/', BomUpload.as_view(), name='api-bom-upload'),
|
||||||
|
|
||||||
# Catch-all
|
# Catch-all
|
||||||
url(r'^.*$', BomList.as_view(), name='api-bom-list'),
|
url(r'^.*$', BomList.as_view(), name='api-bom-list'),
|
||||||
]
|
]
|
||||||
|
@ -4,9 +4,11 @@ JSON serializers for Part app
|
|||||||
|
|
||||||
import imghdr
|
import imghdr
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
import os
|
||||||
|
import tablib
|
||||||
|
|
||||||
from django.urls import reverse_lazy
|
from django.urls import reverse_lazy
|
||||||
from django.db import models
|
from django.db import models, transaction
|
||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
from django.db.models.functions import Coalesce
|
from django.db.models.functions import Coalesce
|
||||||
from django.utils.translation import ugettext_lazy as _
|
from django.utils.translation import ugettext_lazy as _
|
||||||
@ -462,7 +464,13 @@ class BomItemSerializer(InvenTreeModelSerializer):
|
|||||||
|
|
||||||
price_range = serializers.CharField(read_only=True)
|
price_range = serializers.CharField(read_only=True)
|
||||||
|
|
||||||
quantity = InvenTreeDecimalField()
|
quantity = InvenTreeDecimalField(required=True)
|
||||||
|
|
||||||
|
def validate_quantity(self, quantity):
|
||||||
|
if quantity <= 0:
|
||||||
|
raise serializers.ValidationError(_("Quantity must be greater than zero"))
|
||||||
|
|
||||||
|
return quantity
|
||||||
|
|
||||||
part = serializers.PrimaryKeyRelatedField(queryset=Part.objects.filter(assembly=True))
|
part = serializers.PrimaryKeyRelatedField(queryset=Part.objects.filter(assembly=True))
|
||||||
|
|
||||||
@ -699,3 +707,289 @@ class PartCopyBOMSerializer(serializers.Serializer):
|
|||||||
skip_invalid=data.get('skip_invalid', False),
|
skip_invalid=data.get('skip_invalid', False),
|
||||||
include_inherited=data.get('include_inherited', False),
|
include_inherited=data.get('include_inherited', False),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class BomExtractSerializer(serializers.Serializer):
|
||||||
|
"""
|
||||||
|
Serializer for uploading a file and extracting data from it.
|
||||||
|
|
||||||
|
Note: 2022-02-04 - This needs a *serious* refactor in future, probably
|
||||||
|
|
||||||
|
When parsing the file, the following things happen:
|
||||||
|
|
||||||
|
a) Check file format and validity
|
||||||
|
b) Look for "required" fields
|
||||||
|
c) Look for "part" fields - used to "infer" part
|
||||||
|
|
||||||
|
Once the file itself has been validated, we iterate through each data row:
|
||||||
|
|
||||||
|
- If the "level" column is provided, ignore anything below level 1
|
||||||
|
- Try to "guess" the part based on part_id / part_name / part_ipn
|
||||||
|
- Extract other fields as required
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
fields = [
|
||||||
|
'bom_file',
|
||||||
|
'part',
|
||||||
|
'clear_existing',
|
||||||
|
]
|
||||||
|
|
||||||
|
# These columns must be present
|
||||||
|
REQUIRED_COLUMNS = [
|
||||||
|
'quantity',
|
||||||
|
]
|
||||||
|
|
||||||
|
# We need at least one column to specify a "part"
|
||||||
|
PART_COLUMNS = [
|
||||||
|
'part',
|
||||||
|
'part_id',
|
||||||
|
'part_name',
|
||||||
|
'part_ipn',
|
||||||
|
]
|
||||||
|
|
||||||
|
# These columns are "optional"
|
||||||
|
OPTIONAL_COLUMNS = [
|
||||||
|
'allow_variants',
|
||||||
|
'inherited',
|
||||||
|
'optional',
|
||||||
|
'overage',
|
||||||
|
'note',
|
||||||
|
'reference',
|
||||||
|
]
|
||||||
|
|
||||||
|
def find_matching_column(self, col_name, columns):
|
||||||
|
|
||||||
|
# Direct match
|
||||||
|
if col_name in columns:
|
||||||
|
return col_name
|
||||||
|
|
||||||
|
col_name = col_name.lower().strip()
|
||||||
|
|
||||||
|
for col in columns:
|
||||||
|
if col.lower().strip() == col_name:
|
||||||
|
return col
|
||||||
|
|
||||||
|
# No match
|
||||||
|
return None
|
||||||
|
|
||||||
|
def find_matching_data(self, row, col_name, columns):
|
||||||
|
"""
|
||||||
|
Extract data from the row, based on the "expected" column name
|
||||||
|
"""
|
||||||
|
|
||||||
|
col_name = self.find_matching_column(col_name, columns)
|
||||||
|
|
||||||
|
return row.get(col_name, None)
|
||||||
|
|
||||||
|
bom_file = serializers.FileField(
|
||||||
|
label=_("BOM File"),
|
||||||
|
help_text=_("Select Bill of Materials file"),
|
||||||
|
required=True,
|
||||||
|
allow_empty_file=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
def validate_bom_file(self, bom_file):
|
||||||
|
"""
|
||||||
|
Perform validation checks on the uploaded BOM file
|
||||||
|
"""
|
||||||
|
|
||||||
|
self.filename = bom_file.name
|
||||||
|
|
||||||
|
name, ext = os.path.splitext(bom_file.name)
|
||||||
|
|
||||||
|
# Remove the leading . from the extension
|
||||||
|
ext = ext[1:]
|
||||||
|
|
||||||
|
accepted_file_types = [
|
||||||
|
'xls', 'xlsx',
|
||||||
|
'csv', 'tsv',
|
||||||
|
'xml',
|
||||||
|
]
|
||||||
|
|
||||||
|
if ext not in accepted_file_types:
|
||||||
|
raise serializers.ValidationError(_("Unsupported file type"))
|
||||||
|
|
||||||
|
# Impose a 50MB limit on uploaded BOM files
|
||||||
|
max_upload_file_size = 50 * 1024 * 1024
|
||||||
|
|
||||||
|
if bom_file.size > max_upload_file_size:
|
||||||
|
raise serializers.ValidationError(_("File is too large"))
|
||||||
|
|
||||||
|
# Read file data into memory (bytes object)
|
||||||
|
data = bom_file.read()
|
||||||
|
|
||||||
|
if ext in ['csv', 'tsv', 'xml']:
|
||||||
|
data = data.decode()
|
||||||
|
|
||||||
|
# Convert to a tablib dataset (we expect headers)
|
||||||
|
self.dataset = tablib.Dataset().load(data, ext, headers=True)
|
||||||
|
|
||||||
|
for header in self.REQUIRED_COLUMNS:
|
||||||
|
|
||||||
|
match = self.find_matching_column(header, self.dataset.headers)
|
||||||
|
|
||||||
|
if match is None:
|
||||||
|
raise serializers.ValidationError(_("Missing required column") + f": '{header}'")
|
||||||
|
|
||||||
|
part_column_matches = {}
|
||||||
|
|
||||||
|
part_match = False
|
||||||
|
|
||||||
|
for col in self.PART_COLUMNS:
|
||||||
|
col_match = self.find_matching_column(col, self.dataset.headers)
|
||||||
|
|
||||||
|
part_column_matches[col] = col_match
|
||||||
|
|
||||||
|
if col_match is not None:
|
||||||
|
part_match = True
|
||||||
|
|
||||||
|
if not part_match:
|
||||||
|
raise serializers.ValidationError(_("No part column found"))
|
||||||
|
|
||||||
|
return bom_file
|
||||||
|
|
||||||
|
def extract_data(self):
|
||||||
|
"""
|
||||||
|
Read individual rows out of the BOM file
|
||||||
|
"""
|
||||||
|
|
||||||
|
rows = []
|
||||||
|
|
||||||
|
headers = self.dataset.headers
|
||||||
|
|
||||||
|
level_column = self.find_matching_column('level', headers)
|
||||||
|
|
||||||
|
for row in self.dataset.dict:
|
||||||
|
|
||||||
|
"""
|
||||||
|
If the "level" column is specified, and this is not a top-level BOM item, ignore the row!
|
||||||
|
"""
|
||||||
|
if level_column is not None:
|
||||||
|
level = row.get('level', None)
|
||||||
|
|
||||||
|
if level is not None:
|
||||||
|
try:
|
||||||
|
level = int(level)
|
||||||
|
if level != 1:
|
||||||
|
continue
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
"""
|
||||||
|
Next, we try to "guess" the part, based on the provided data.
|
||||||
|
|
||||||
|
A) If the part_id is supplied, use that!
|
||||||
|
B) If the part name and/or part_ipn are supplied, maybe we can use those?
|
||||||
|
"""
|
||||||
|
part_id = self.find_matching_data(row, 'part_id', headers)
|
||||||
|
part_name = self.find_matching_data(row, 'part_name', headers)
|
||||||
|
part_ipn = self.find_matching_data(row, 'part_ipn', headers)
|
||||||
|
|
||||||
|
part = None
|
||||||
|
|
||||||
|
if part_id is not None:
|
||||||
|
try:
|
||||||
|
part = Part.objects.get(pk=part_id)
|
||||||
|
except (ValueError, Part.DoesNotExist):
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Optionally, specify using field "part"
|
||||||
|
if part is None:
|
||||||
|
pk = self.find_matching_data(row, 'part', headers)
|
||||||
|
|
||||||
|
if pk is not None:
|
||||||
|
try:
|
||||||
|
part = Part.objects.get(pk=pk)
|
||||||
|
except (ValueError, Part.DoesNotExist):
|
||||||
|
pass
|
||||||
|
|
||||||
|
if part is None:
|
||||||
|
|
||||||
|
if part_name is not None or part_ipn is not None:
|
||||||
|
queryset = Part.objects.all()
|
||||||
|
|
||||||
|
if part_name is not None:
|
||||||
|
queryset = queryset.filter(name=part_name)
|
||||||
|
|
||||||
|
if part_ipn is not None:
|
||||||
|
queryset = queryset.filter(IPN=part_ipn)
|
||||||
|
|
||||||
|
# Only if we have a single direct match
|
||||||
|
if queryset.exists() and queryset.count() == 1:
|
||||||
|
part = queryset.first()
|
||||||
|
|
||||||
|
row['part'] = part.pk if part is not None else None
|
||||||
|
|
||||||
|
rows.append(row)
|
||||||
|
|
||||||
|
return {
|
||||||
|
'rows': rows,
|
||||||
|
'headers': headers,
|
||||||
|
'filename': self.filename,
|
||||||
|
}
|
||||||
|
|
||||||
|
part = serializers.PrimaryKeyRelatedField(queryset=Part.objects.filter(assembly=True), required=True)
|
||||||
|
|
||||||
|
clear_existing = serializers.BooleanField(
|
||||||
|
label=_("Clear Existing BOM"),
|
||||||
|
help_text=_("Delete existing BOM data first"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def save(self):
|
||||||
|
|
||||||
|
data = self.validated_data
|
||||||
|
|
||||||
|
master_part = data['part']
|
||||||
|
clear_existing = data['clear_existing']
|
||||||
|
|
||||||
|
if clear_existing:
|
||||||
|
|
||||||
|
# Remove all existing BOM items
|
||||||
|
master_part.bom_items.all().delete()
|
||||||
|
|
||||||
|
|
||||||
|
class BomUploadSerializer(serializers.Serializer):
|
||||||
|
"""
|
||||||
|
Serializer for uploading a BOM against a specified part.
|
||||||
|
|
||||||
|
A "BOM" is a set of BomItem objects which are to be validated together as a set
|
||||||
|
"""
|
||||||
|
|
||||||
|
items = BomItemSerializer(many=True, required=True)
|
||||||
|
|
||||||
|
def validate(self, data):
|
||||||
|
|
||||||
|
items = data['items']
|
||||||
|
|
||||||
|
if len(items) == 0:
|
||||||
|
raise serializers.ValidationError(_("At least one BOM item is required"))
|
||||||
|
|
||||||
|
data = super().validate(data)
|
||||||
|
|
||||||
|
return data
|
||||||
|
|
||||||
|
def save(self):
|
||||||
|
|
||||||
|
data = self.validated_data
|
||||||
|
|
||||||
|
items = data['items']
|
||||||
|
|
||||||
|
try:
|
||||||
|
with transaction.atomic():
|
||||||
|
|
||||||
|
for item in items:
|
||||||
|
|
||||||
|
part = item['part']
|
||||||
|
sub_part = item['sub_part']
|
||||||
|
|
||||||
|
# Ignore duplicate BOM items
|
||||||
|
if BomItem.objects.filter(part=part, sub_part=sub_part).exists():
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Create a new BomItem object
|
||||||
|
BomItem.objects.create(**item)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
raise serializers.ValidationError(detail=serializers.as_serializer_error(e))
|
||||||
|
@ -1,99 +0,0 @@
|
|||||||
{% extends "part/bom_upload/upload_file.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' style='margin-top:12px;' 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-outline-secondary">{% trans "Previous Step" %}</button>
|
|
||||||
{% endif %}
|
|
||||||
<button type="submit" class="save btn btn-outline-secondary">{% 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-outline-secondary 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'>
|
|
||||||
<strong>{% trans "Duplicate selection" %}</strong>
|
|
||||||
</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-outline-secondary 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 %}
|
|
@ -1,127 +0,0 @@
|
|||||||
{% extends "part/bom_upload/upload_file.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-outline-secondary">{% trans "Previous Step" %}</button>
|
|
||||||
{% endif %}
|
|
||||||
<button type="submit" class="save btn btn-outline-secondary">{% trans "Submit Selections" %}</button>
|
|
||||||
{% endblock form_buttons_top %}
|
|
||||||
|
|
||||||
{% block form_content %}
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th></th>
|
|
||||||
<th>{% trans "Row" %}</th>
|
|
||||||
<th>{% trans "Select Part" %}</th>
|
|
||||||
<th>{% trans "Reference" %}</th>
|
|
||||||
<th>{% trans "Quantity" %}</th>
|
|
||||||
{% for col in columns %}
|
|
||||||
{% if col.guess != 'Quantity' %}
|
|
||||||
<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>
|
|
||||||
{% endif %}
|
|
||||||
{% 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-outline-secondary 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>
|
|
||||||
<td>
|
|
||||||
{% for field in form.visible_fields %}
|
|
||||||
{% if field.name == row.item_select %}
|
|
||||||
{{ field }}
|
|
||||||
{% endif %}
|
|
||||||
{% endfor %}
|
|
||||||
{% if row.errors.part %}
|
|
||||||
<p class='help-inline'>{{ row.errors.part }}</p>
|
|
||||||
{% endif %}
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
{% for field in form.visible_fields %}
|
|
||||||
{% if field.name == row.reference %}
|
|
||||||
{{ field|as_crispy_field }}
|
|
||||||
{% endif %}
|
|
||||||
{% endfor %}
|
|
||||||
{% if row.errors.reference %}
|
|
||||||
<p class='help-inline'>{{ row.errors.reference }}</p>
|
|
||||||
{% endif %}
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
{% for field in form.visible_fields %}
|
|
||||||
{% if field.name == row.quantity %}
|
|
||||||
{{ field|as_crispy_field }}
|
|
||||||
{% endif %}
|
|
||||||
{% endfor %}
|
|
||||||
{% if row.errors.quantity %}
|
|
||||||
<p class='help-inline'>{{ row.errors.quantity }}</p>
|
|
||||||
{% endif %}
|
|
||||||
</td>
|
|
||||||
{% for item in row.data %}
|
|
||||||
{% if item.column.guess != 'Quantity' %}
|
|
||||||
<td>
|
|
||||||
{% if item.column.guess == 'Overage' %}
|
|
||||||
{% for field in form.visible_fields %}
|
|
||||||
{% if field.name == row.overage %}
|
|
||||||
{{ field|as_crispy_field }}
|
|
||||||
{% endif %}
|
|
||||||
{% endfor %}
|
|
||||||
{% elif item.column.guess == 'Note' %}
|
|
||||||
{% for field in form.visible_fields %}
|
|
||||||
{% if field.name == row.note %}
|
|
||||||
{{ field|as_crispy_field }}
|
|
||||||
{% endif %}
|
|
||||||
{% endfor %}
|
|
||||||
{% else %}
|
|
||||||
{{ item.cell }}
|
|
||||||
{% endif %}
|
|
||||||
<input type='hidden' name='row_{{ row.index }}_col_{{ forloop.counter0 }}' value='{{ item.cell }}'/>
|
|
||||||
</td>
|
|
||||||
{% endif %}
|
|
||||||
{% 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,
|
|
||||||
});
|
|
||||||
|
|
||||||
{% endblock %}
|
|
@ -1,67 +0,0 @@
|
|||||||
{% extends "part/part_base.html" %}
|
|
||||||
{% load inventree_extras %}
|
|
||||||
{% load i18n %}
|
|
||||||
{% load static %}
|
|
||||||
|
|
||||||
{% block sidebar %}
|
|
||||||
{% url "part-detail" part.id as url %}
|
|
||||||
{% trans "Return to BOM" as text %}
|
|
||||||
{% include "sidebar_link.html" with url=url text=text icon="fa-undo" %}
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block heading %}
|
|
||||||
{% trans "Upload Bill of Materials" %}
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block actions %}
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block page_info %}
|
|
||||||
<div class='panel-content'>
|
|
||||||
<p>{% blocktrans with step=wizard.steps.step1 count=wizard.steps.count %}Step {{step}} of {{count}}{% endblocktrans %}
|
|
||||||
{% if description %}- {{ description }}{% endif %}</p>
|
|
||||||
|
|
||||||
<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 %}
|
|
||||||
|
|
||||||
{% block form_alert %}
|
|
||||||
<div class='alert alert-info alert-block'>
|
|
||||||
<strong>{% trans "Requirements for BOM upload" %}:</strong>
|
|
||||||
<ul>
|
|
||||||
<li>{% trans "The BOM file must contain the required named columns as provided in the " %} <strong><a href='#' id='bom-template-download'>{% trans "BOM Upload Template" %}</a></strong></li>
|
|
||||||
<li>{% trans "Each part must already exist in the database" %}</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
<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-outline-secondary">{% trans "Previous Step" %}</button>
|
|
||||||
{% endif %}
|
|
||||||
<button type="submit" class="save btn btn-outline-secondary">{% trans "Upload File" %}</button>
|
|
||||||
</form>
|
|
||||||
{% endblock form_buttons_bottom %}
|
|
||||||
</div>
|
|
||||||
{% endblock page_info %}
|
|
||||||
|
|
||||||
{% block js_ready %}
|
|
||||||
{{ block.super }}
|
|
||||||
|
|
||||||
enableSidebar('bom-upload');
|
|
||||||
|
|
||||||
$('#bom-template-download').click(function() {
|
|
||||||
downloadBomTemplate();
|
|
||||||
});
|
|
||||||
|
|
||||||
{% endblock js_ready %}
|
|
105
InvenTree/part/templates/part/upload_bom.html
Normal file
105
InvenTree/part/templates/part/upload_bom.html
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
{% extends "part/part_base.html" %}
|
||||||
|
{% load inventree_extras %}
|
||||||
|
{% load i18n %}
|
||||||
|
{% load static %}
|
||||||
|
|
||||||
|
{% block sidebar %}
|
||||||
|
{% url "part-detail" part.id as url %}
|
||||||
|
{% trans "Return to BOM" as text %}
|
||||||
|
{% include "sidebar_link.html" with url=url text=text icon="fa-undo" %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block heading %}
|
||||||
|
{% trans "Upload Bill of Materials" %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block actions %}
|
||||||
|
<!--
|
||||||
|
<button type='button' class='btn btn-outline-secondary' id='bom-info'>
|
||||||
|
<span class='fas fa-info-circle' title='{% trans "BOM upload requirements" %}'></span>
|
||||||
|
</button>
|
||||||
|
-->
|
||||||
|
<button type='button' class='btn btn-primary' id='bom-upload'>
|
||||||
|
<span class='fas fa-file-upload'></span> {% trans "Upload BOM File" %}
|
||||||
|
</button>
|
||||||
|
<button type='button' class='btn btn-success' id='bom-submit' style='display: none;'>
|
||||||
|
<span class='fas fa-sign-in-alt'></span> {% trans "Submit BOM Data" %}
|
||||||
|
</button>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block page_info %}
|
||||||
|
<div class='panel-content'>
|
||||||
|
|
||||||
|
<div class='alert alert-info alert-block'>
|
||||||
|
<strong>{% trans "Requirements for BOM upload" %}:</strong>
|
||||||
|
<ul>
|
||||||
|
<li>{% trans "The BOM file must contain the required named columns as provided in the " %} <strong><a href='#' id='bom-template-download'>{% trans "BOM Upload Template" %}</a></strong></li>
|
||||||
|
<li>{% trans "Each part must already exist in the database" %}</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id='non-field-errors'>
|
||||||
|
<!-- Upload error messages go here -->
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- This table is filled out after BOM file is uploaded and processed -->
|
||||||
|
<table class='table table-striped table-condensed' id='bom-import-table'>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style='max-width: 500px;'>{% trans "Part" %}</th>
|
||||||
|
<th>{% trans "Quantity" %}</th>
|
||||||
|
<th>{% trans "Reference" %}</th>
|
||||||
|
<th>{% trans "Overage" %}</th>
|
||||||
|
<th>{% trans "Allow Variants" %}</th>
|
||||||
|
<th>{% trans "Inherited" %}</th>
|
||||||
|
<th>{% trans "Optional" %}</th>
|
||||||
|
<th>{% trans "Note" %}</th>
|
||||||
|
<th><!-- Buttons Column --></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody></tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
{% endblock page_info %}
|
||||||
|
|
||||||
|
{% block js_ready %}
|
||||||
|
{{ block.super }}
|
||||||
|
|
||||||
|
enableSidebar('bom-upload');
|
||||||
|
|
||||||
|
$('#bom-template-download').click(function() {
|
||||||
|
downloadBomTemplate();
|
||||||
|
});
|
||||||
|
|
||||||
|
$('#bom-upload').click(function() {
|
||||||
|
|
||||||
|
constructForm('{% url "api-bom-extract" %}', {
|
||||||
|
method: 'POST',
|
||||||
|
fields: {
|
||||||
|
bom_file: {},
|
||||||
|
part: {
|
||||||
|
value: {{ part.pk }},
|
||||||
|
hidden: true,
|
||||||
|
},
|
||||||
|
clear_existing: {},
|
||||||
|
},
|
||||||
|
title: '{% trans "Upload BOM File" %}',
|
||||||
|
onSuccess: function(response) {
|
||||||
|
$('#bom-upload').hide();
|
||||||
|
|
||||||
|
$('#bom-submit').show();
|
||||||
|
|
||||||
|
constructBomUploadTable(response);
|
||||||
|
|
||||||
|
$('#bom-submit').click(function() {
|
||||||
|
submitBomTable({{ part.pk }}, {
|
||||||
|
bom_data: response,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
{% endblock js_ready %}
|
@ -107,7 +107,7 @@ class BomExportTest(TestCase):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
params = {
|
params = {
|
||||||
'file_format': 'csv',
|
'format': 'csv',
|
||||||
'cascade': True,
|
'cascade': True,
|
||||||
'parameter_data': True,
|
'parameter_data': True,
|
||||||
'stock_data': True,
|
'stock_data': True,
|
||||||
@ -171,7 +171,7 @@ class BomExportTest(TestCase):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
params = {
|
params = {
|
||||||
'file_format': 'xls',
|
'format': 'xls',
|
||||||
'cascade': True,
|
'cascade': True,
|
||||||
'parameter_data': True,
|
'parameter_data': True,
|
||||||
'stock_data': True,
|
'stock_data': True,
|
||||||
@ -192,7 +192,7 @@ class BomExportTest(TestCase):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
params = {
|
params = {
|
||||||
'file_format': 'xlsx',
|
'format': 'xlsx',
|
||||||
'cascade': True,
|
'cascade': True,
|
||||||
'parameter_data': True,
|
'parameter_data': True,
|
||||||
'stock_data': True,
|
'stock_data': True,
|
||||||
@ -210,7 +210,7 @@ class BomExportTest(TestCase):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
params = {
|
params = {
|
||||||
'file_format': 'json',
|
'format': 'json',
|
||||||
'cascade': True,
|
'cascade': True,
|
||||||
'parameter_data': True,
|
'parameter_data': True,
|
||||||
'stock_data': True,
|
'stock_data': True,
|
||||||
|
@ -33,7 +33,6 @@ part_parameter_urls = [
|
|||||||
|
|
||||||
part_detail_urls = [
|
part_detail_urls = [
|
||||||
url(r'^delete/?', views.PartDelete.as_view(), name='part-delete'),
|
url(r'^delete/?', views.PartDelete.as_view(), name='part-delete'),
|
||||||
url(r'^bom-export/?', views.BomExport.as_view(), name='bom-export'),
|
|
||||||
url(r'^bom-download/?', views.BomDownload.as_view(), name='bom-download'),
|
url(r'^bom-download/?', views.BomDownload.as_view(), name='bom-download'),
|
||||||
|
|
||||||
url(r'^pricing/', views.PartPricing.as_view(), name='part-pricing'),
|
url(r'^pricing/', views.PartPricing.as_view(), name='part-pricing'),
|
||||||
|
@ -28,20 +28,17 @@ import requests
|
|||||||
import os
|
import os
|
||||||
import io
|
import io
|
||||||
|
|
||||||
from rapidfuzz import fuzz
|
from decimal import Decimal
|
||||||
from decimal import Decimal, InvalidOperation
|
|
||||||
|
|
||||||
from .models import PartCategory, Part
|
from .models import PartCategory, Part
|
||||||
from .models import PartParameterTemplate
|
from .models import PartParameterTemplate
|
||||||
from .models import PartCategoryParameterTemplate
|
from .models import PartCategoryParameterTemplate
|
||||||
from .models import BomItem
|
|
||||||
from .models import PartSellPriceBreak, PartInternalPriceBreak
|
from .models import PartSellPriceBreak, PartInternalPriceBreak
|
||||||
|
|
||||||
from common.models import InvenTreeSetting
|
from common.models import InvenTreeSetting
|
||||||
from company.models import SupplierPart
|
from company.models import SupplierPart
|
||||||
from common.files import FileManager
|
from common.files import FileManager
|
||||||
from common.views import FileManagementFormView, FileManagementAjaxView
|
from common.views import FileManagementFormView, FileManagementAjaxView
|
||||||
from common.forms import UploadFileForm, MatchFieldForm
|
|
||||||
|
|
||||||
from stock.models import StockItem, StockLocation
|
from stock.models import StockItem, StockLocation
|
||||||
|
|
||||||
@ -704,270 +701,12 @@ class PartImageSelect(AjaxUpdateView):
|
|||||||
return self.renderJsonResponse(request, form, data)
|
return self.renderJsonResponse(request, form, data)
|
||||||
|
|
||||||
|
|
||||||
class BomUpload(InvenTreeRoleMixin, FileManagementFormView):
|
class BomUpload(InvenTreeRoleMixin, DetailView):
|
||||||
""" View for uploading a BOM file, and handling BOM data importing.
|
""" View for uploading a BOM file, and handling BOM data importing. """
|
||||||
|
|
||||||
The BOM upload process is as follows:
|
context_object_name = 'part'
|
||||||
|
queryset = Part.objects.all()
|
||||||
1. (Client) Select and upload BOM file
|
template_name = 'part/upload_bom.html'
|
||||||
2. (Server) Verify that supplied file is a file compatible with tablib library
|
|
||||||
3. (Server) Introspect data file, try to find sensible columns / values / etc
|
|
||||||
4. (Server) Send suggestions back to the client
|
|
||||||
5. (Client) Makes choices based on suggestions:
|
|
||||||
- Accept automatic matching to parts found in database
|
|
||||||
- Accept suggestions for 'partial' or 'fuzzy' matches
|
|
||||||
- Create new parts in case of parts not being available
|
|
||||||
6. (Client) Sends updated dataset back to server
|
|
||||||
7. (Server) Check POST data for validity, sanity checking, etc.
|
|
||||||
8. (Server) Respond to POST request
|
|
||||||
- If data are valid, proceed to 9.
|
|
||||||
- If data not valid, return to 4.
|
|
||||||
9. (Server) Send confirmation form to user
|
|
||||||
- Display the actions which will occur
|
|
||||||
- Provide final "CONFIRM" button
|
|
||||||
10. (Client) Confirm final changes
|
|
||||||
11. (Server) Apply changes to database, update BOM items.
|
|
||||||
|
|
||||||
During these steps, data are passed between the server/client as JSON objects.
|
|
||||||
"""
|
|
||||||
|
|
||||||
role_required = ('part.change', 'part.add')
|
|
||||||
|
|
||||||
class BomFileManager(FileManager):
|
|
||||||
# Fields which are absolutely necessary for valid upload
|
|
||||||
REQUIRED_HEADERS = [
|
|
||||||
'Quantity'
|
|
||||||
]
|
|
||||||
|
|
||||||
# Fields which are used for part matching (only one of them is needed)
|
|
||||||
ITEM_MATCH_HEADERS = [
|
|
||||||
'Part_Name',
|
|
||||||
'Part_IPN',
|
|
||||||
'Part_ID',
|
|
||||||
]
|
|
||||||
|
|
||||||
# Fields which would be helpful but are not required
|
|
||||||
OPTIONAL_HEADERS = [
|
|
||||||
'Reference',
|
|
||||||
'Note',
|
|
||||||
'Overage',
|
|
||||||
]
|
|
||||||
|
|
||||||
EDITABLE_HEADERS = [
|
|
||||||
'Reference',
|
|
||||||
'Note',
|
|
||||||
'Overage'
|
|
||||||
]
|
|
||||||
|
|
||||||
name = 'order'
|
|
||||||
form_list = [
|
|
||||||
('upload', UploadFileForm),
|
|
||||||
('fields', MatchFieldForm),
|
|
||||||
('items', part_forms.BomMatchItemForm),
|
|
||||||
]
|
|
||||||
form_steps_template = [
|
|
||||||
'part/bom_upload/upload_file.html',
|
|
||||||
'part/bom_upload/match_fields.html',
|
|
||||||
'part/bom_upload/match_parts.html',
|
|
||||||
]
|
|
||||||
form_steps_description = [
|
|
||||||
_("Upload File"),
|
|
||||||
_("Match Fields"),
|
|
||||||
_("Match Parts"),
|
|
||||||
]
|
|
||||||
form_field_map = {
|
|
||||||
'item_select': 'part',
|
|
||||||
'quantity': 'quantity',
|
|
||||||
'overage': 'overage',
|
|
||||||
'reference': 'reference',
|
|
||||||
'note': 'note',
|
|
||||||
}
|
|
||||||
file_manager_class = BomFileManager
|
|
||||||
|
|
||||||
def get_part(self):
|
|
||||||
""" Get part or return 404 """
|
|
||||||
|
|
||||||
return get_object_or_404(Part, pk=self.kwargs['pk'])
|
|
||||||
|
|
||||||
def get_context_data(self, form, **kwargs):
|
|
||||||
""" Handle context data for order """
|
|
||||||
|
|
||||||
context = super().get_context_data(form=form, **kwargs)
|
|
||||||
|
|
||||||
part = self.get_part()
|
|
||||||
|
|
||||||
context.update({'part': part})
|
|
||||||
|
|
||||||
return context
|
|
||||||
|
|
||||||
def get_allowed_parts(self):
|
|
||||||
""" Return a queryset of parts which are allowed to be added to this BOM.
|
|
||||||
"""
|
|
||||||
|
|
||||||
return self.get_part().get_allowed_bom_items()
|
|
||||||
|
|
||||||
def get_field_selection(self):
|
|
||||||
""" Once data columns have been selected, attempt to pre-select the proper data from the database.
|
|
||||||
This function is called once the field selection has been validated.
|
|
||||||
The pre-fill data are then passed through to the part selection form.
|
|
||||||
"""
|
|
||||||
|
|
||||||
self.allowed_items = self.get_allowed_parts()
|
|
||||||
|
|
||||||
# Fields prefixed with "Part_" can be used to do "smart matching" against Part objects in the database
|
|
||||||
k_idx = self.get_column_index('Part_ID')
|
|
||||||
p_idx = self.get_column_index('Part_Name')
|
|
||||||
i_idx = self.get_column_index('Part_IPN')
|
|
||||||
|
|
||||||
q_idx = self.get_column_index('Quantity')
|
|
||||||
r_idx = self.get_column_index('Reference')
|
|
||||||
o_idx = self.get_column_index('Overage')
|
|
||||||
n_idx = self.get_column_index('Note')
|
|
||||||
|
|
||||||
for row in self.rows:
|
|
||||||
"""
|
|
||||||
Iterate through each row in the uploaded data,
|
|
||||||
and see if we can match the row to a "Part" object in the database.
|
|
||||||
There are three potential ways to match, based on the uploaded data:
|
|
||||||
a) Use the PK (primary key) field for the part, uploaded in the "Part_ID" field
|
|
||||||
b) Use the IPN (internal part number) field for the part, uploaded in the "Part_IPN" field
|
|
||||||
c) Use the name of the part, uploaded in the "Part_Name" field
|
|
||||||
Notes:
|
|
||||||
- If using the Part_ID field, we can do an exact match against the PK field
|
|
||||||
- If using the Part_IPN field, we can do an exact match against the IPN field
|
|
||||||
- If using the Part_Name field, we can use fuzzy string matching to match "close" values
|
|
||||||
We also extract other information from the row, for the other non-matched fields:
|
|
||||||
- Quantity
|
|
||||||
- Reference
|
|
||||||
- Overage
|
|
||||||
- Note
|
|
||||||
"""
|
|
||||||
|
|
||||||
# Initially use a quantity of zero
|
|
||||||
quantity = Decimal(0)
|
|
||||||
|
|
||||||
# Initially we do not have a part to reference
|
|
||||||
exact_match_part = None
|
|
||||||
|
|
||||||
# A list of potential Part matches
|
|
||||||
part_options = self.allowed_items
|
|
||||||
|
|
||||||
# Check if there is a column corresponding to "quantity"
|
|
||||||
if q_idx >= 0:
|
|
||||||
q_val = row['data'][q_idx]['cell']
|
|
||||||
|
|
||||||
if q_val:
|
|
||||||
# Delete commas
|
|
||||||
q_val = q_val.replace(',', '')
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Attempt to extract a valid quantity from the field
|
|
||||||
quantity = Decimal(q_val)
|
|
||||||
# Store the 'quantity' value
|
|
||||||
row['quantity'] = quantity
|
|
||||||
except (ValueError, InvalidOperation):
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Check if there is a column corresponding to "PK"
|
|
||||||
if k_idx >= 0:
|
|
||||||
pk = row['data'][k_idx]['cell']
|
|
||||||
|
|
||||||
if pk:
|
|
||||||
try:
|
|
||||||
# Attempt Part lookup based on PK value
|
|
||||||
exact_match_part = self.allowed_items.get(pk=pk)
|
|
||||||
except (ValueError, Part.DoesNotExist):
|
|
||||||
exact_match_part = None
|
|
||||||
|
|
||||||
# Check if there is a column corresponding to "Part IPN" and no exact match found yet
|
|
||||||
if i_idx >= 0 and not exact_match_part:
|
|
||||||
part_ipn = row['data'][i_idx]['cell']
|
|
||||||
|
|
||||||
if part_ipn:
|
|
||||||
part_matches = [part for part in self.allowed_items if part.IPN and part_ipn.lower() == str(part.IPN.lower())]
|
|
||||||
|
|
||||||
# Check for single match
|
|
||||||
if len(part_matches) == 1:
|
|
||||||
exact_match_part = part_matches[0]
|
|
||||||
|
|
||||||
# Check if there is a column corresponding to "Part Name" and no exact match found yet
|
|
||||||
if p_idx >= 0 and not exact_match_part:
|
|
||||||
part_name = row['data'][p_idx]['cell']
|
|
||||||
|
|
||||||
row['part_name'] = part_name
|
|
||||||
|
|
||||||
matches = []
|
|
||||||
|
|
||||||
for part in self.allowed_items:
|
|
||||||
ratio = fuzz.partial_ratio(part.name + part.description, part_name)
|
|
||||||
matches.append({'part': part, 'match': ratio})
|
|
||||||
|
|
||||||
# Sort matches by the 'strength' of the match ratio
|
|
||||||
if len(matches) > 0:
|
|
||||||
matches = sorted(matches, key=lambda item: item['match'], reverse=True)
|
|
||||||
|
|
||||||
part_options = [m['part'] for m in matches]
|
|
||||||
|
|
||||||
# Supply list of part options for each row, sorted by how closely they match the part name
|
|
||||||
row['item_options'] = part_options
|
|
||||||
|
|
||||||
# Unless found, the 'item_match' is blank
|
|
||||||
row['item_match'] = None
|
|
||||||
|
|
||||||
if exact_match_part:
|
|
||||||
# If there is an exact match based on PK or IPN, use that
|
|
||||||
row['item_match'] = exact_match_part
|
|
||||||
|
|
||||||
# Check if there is a column corresponding to "Overage" field
|
|
||||||
if o_idx >= 0:
|
|
||||||
row['overage'] = row['data'][o_idx]['cell']
|
|
||||||
|
|
||||||
# Check if there is a column corresponding to "Reference" field
|
|
||||||
if r_idx >= 0:
|
|
||||||
row['reference'] = row['data'][r_idx]['cell']
|
|
||||||
|
|
||||||
# Check if there is a column corresponding to "Note" field
|
|
||||||
if n_idx >= 0:
|
|
||||||
row['note'] = row['data'][n_idx]['cell']
|
|
||||||
|
|
||||||
def done(self, form_list, **kwargs):
|
|
||||||
""" Once all the data is in, process it to add BomItem instances to the part """
|
|
||||||
|
|
||||||
self.part = self.get_part()
|
|
||||||
items = self.get_clean_items()
|
|
||||||
|
|
||||||
# Clear BOM
|
|
||||||
self.part.clear_bom()
|
|
||||||
|
|
||||||
# Generate new BOM items
|
|
||||||
for bom_item in items.values():
|
|
||||||
try:
|
|
||||||
part = Part.objects.get(pk=int(bom_item.get('part')))
|
|
||||||
except (ValueError, Part.DoesNotExist):
|
|
||||||
continue
|
|
||||||
|
|
||||||
quantity = bom_item.get('quantity')
|
|
||||||
overage = bom_item.get('overage', '')
|
|
||||||
reference = bom_item.get('reference', '')
|
|
||||||
note = bom_item.get('note', '')
|
|
||||||
|
|
||||||
# Create a new BOM item
|
|
||||||
item = BomItem(
|
|
||||||
part=self.part,
|
|
||||||
sub_part=part,
|
|
||||||
quantity=quantity,
|
|
||||||
overage=overage,
|
|
||||||
reference=reference,
|
|
||||||
note=note,
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
item.save()
|
|
||||||
except IntegrityError:
|
|
||||||
# BomItem already exists
|
|
||||||
pass
|
|
||||||
|
|
||||||
return HttpResponseRedirect(reverse('part-detail', kwargs={'pk': self.kwargs['pk']}))
|
|
||||||
|
|
||||||
|
|
||||||
class PartExport(AjaxView):
|
class PartExport(AjaxView):
|
||||||
@ -1060,7 +799,7 @@ class BomDownload(AjaxView):
|
|||||||
|
|
||||||
part = get_object_or_404(Part, pk=self.kwargs['pk'])
|
part = get_object_or_404(Part, pk=self.kwargs['pk'])
|
||||||
|
|
||||||
export_format = request.GET.get('file_format', 'csv')
|
export_format = request.GET.get('format', 'csv')
|
||||||
|
|
||||||
cascade = str2bool(request.GET.get('cascade', False))
|
cascade = str2bool(request.GET.get('cascade', False))
|
||||||
|
|
||||||
@ -1103,55 +842,6 @@ class BomDownload(AjaxView):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class BomExport(AjaxView):
|
|
||||||
""" Provide a simple form to allow the user to select BOM download options.
|
|
||||||
"""
|
|
||||||
|
|
||||||
model = Part
|
|
||||||
ajax_form_title = _("Export Bill of Materials")
|
|
||||||
|
|
||||||
role_required = 'part.view'
|
|
||||||
|
|
||||||
def post(self, request, *args, **kwargs):
|
|
||||||
|
|
||||||
# Extract POSTed form data
|
|
||||||
fmt = request.POST.get('file_format', 'csv').lower()
|
|
||||||
cascade = str2bool(request.POST.get('cascading', False))
|
|
||||||
levels = request.POST.get('levels', None)
|
|
||||||
parameter_data = str2bool(request.POST.get('parameter_data', False))
|
|
||||||
stock_data = str2bool(request.POST.get('stock_data', False))
|
|
||||||
supplier_data = str2bool(request.POST.get('supplier_data', False))
|
|
||||||
manufacturer_data = str2bool(request.POST.get('manufacturer_data', False))
|
|
||||||
|
|
||||||
try:
|
|
||||||
part = Part.objects.get(pk=self.kwargs['pk'])
|
|
||||||
except:
|
|
||||||
part = None
|
|
||||||
|
|
||||||
# Format a URL to redirect to
|
|
||||||
if part:
|
|
||||||
url = reverse('bom-download', kwargs={'pk': part.pk})
|
|
||||||
else:
|
|
||||||
url = ''
|
|
||||||
|
|
||||||
url += '?file_format=' + fmt
|
|
||||||
url += '&cascade=' + str(cascade)
|
|
||||||
url += '¶meter_data=' + str(parameter_data)
|
|
||||||
url += '&stock_data=' + str(stock_data)
|
|
||||||
url += '&supplier_data=' + str(supplier_data)
|
|
||||||
url += '&manufacturer_data=' + str(manufacturer_data)
|
|
||||||
|
|
||||||
if levels:
|
|
||||||
url += '&levels=' + str(levels)
|
|
||||||
|
|
||||||
data = {
|
|
||||||
'form_valid': part is not None,
|
|
||||||
'url': url,
|
|
||||||
}
|
|
||||||
|
|
||||||
return self.renderJsonResponse(request, self.form_class(), data=data)
|
|
||||||
|
|
||||||
|
|
||||||
class PartDelete(AjaxDeleteView):
|
class PartDelete(AjaxDeleteView):
|
||||||
""" View to delete a Part object """
|
""" View to delete a Part object """
|
||||||
|
|
||||||
|
@ -15,6 +15,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
/* exported
|
/* exported
|
||||||
|
constructBomUploadTable,
|
||||||
downloadBomTemplate,
|
downloadBomTemplate,
|
||||||
exportBom,
|
exportBom,
|
||||||
newPartFromBomWizard,
|
newPartFromBomWizard,
|
||||||
@ -22,8 +23,175 @@
|
|||||||
loadUsedInTable,
|
loadUsedInTable,
|
||||||
removeRowFromBomWizard,
|
removeRowFromBomWizard,
|
||||||
removeColFromBomWizard,
|
removeColFromBomWizard,
|
||||||
|
submitBomTable
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
|
||||||
|
/* Construct a table of data extracted from a BOM file.
|
||||||
|
* This data is used to import a BOM interactively.
|
||||||
|
*/
|
||||||
|
function constructBomUploadTable(data, options={}) {
|
||||||
|
|
||||||
|
if (!data.rows) {
|
||||||
|
// TODO: Error message!
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
function constructRow(row, idx, fields) {
|
||||||
|
// Construct an individual row from the provided data
|
||||||
|
|
||||||
|
var field_options = {
|
||||||
|
hideLabels: true,
|
||||||
|
hideClearButton: true,
|
||||||
|
form_classes: 'bom-form-group',
|
||||||
|
};
|
||||||
|
|
||||||
|
function constructRowField(field_name) {
|
||||||
|
|
||||||
|
var field = fields[field_name] || null;
|
||||||
|
|
||||||
|
if (!field) {
|
||||||
|
return `Cannot render field '${field_name}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
field.value = row[field_name];
|
||||||
|
|
||||||
|
return constructField(`items_${field_name}_${idx}`, field, field_options);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// Construct form inputs
|
||||||
|
var sub_part = constructRowField('sub_part');
|
||||||
|
var quantity = constructRowField('quantity');
|
||||||
|
var reference = constructRowField('reference');
|
||||||
|
var overage = constructRowField('overage');
|
||||||
|
var variants = constructRowField('allow_variants');
|
||||||
|
var inherited = constructRowField('inherited');
|
||||||
|
var optional = constructRowField('optional');
|
||||||
|
var note = constructRowField('note');
|
||||||
|
|
||||||
|
var buttons = `<div class='btn-group float-right' role='group'>`;
|
||||||
|
|
||||||
|
// buttons += makeIconButton('fa-file-alt', 'button-row-data', idx, '{% trans "Display row data" %}');
|
||||||
|
buttons += makeIconButton('fa-times icon-red', 'button-row-remove', idx, '{% trans "Remove row" %}');
|
||||||
|
|
||||||
|
buttons += `</div>`;
|
||||||
|
|
||||||
|
var html = `
|
||||||
|
<tr id='items_${idx}' class='bom-import-row' idx='${idx}'>
|
||||||
|
<td id='col_sub_part_${idx}'>${sub_part}</td>
|
||||||
|
<td id='col_quantity_${idx}'>${quantity}</td>
|
||||||
|
<td id='col_reference_${idx}'>${reference}</td>
|
||||||
|
<td id='col_overage_${idx}'>${overage}</td>
|
||||||
|
<td id='col_variants_${idx}'>${variants}</td>
|
||||||
|
<td id='col_inherited_${idx}'>${inherited}</td>
|
||||||
|
<td id='col_optional_${idx}'>${optional}</td>
|
||||||
|
<td id='col_note_${idx}'>${note}</td>
|
||||||
|
<td id='col_buttons_${idx}'>${buttons}</td>
|
||||||
|
</tr>`;
|
||||||
|
|
||||||
|
$('#bom-import-table tbody').append(html);
|
||||||
|
|
||||||
|
// Initialize the "part" selector for this row
|
||||||
|
initializeRelatedField(
|
||||||
|
{
|
||||||
|
name: `items_sub_part_${idx}`,
|
||||||
|
value: row.part,
|
||||||
|
api_url: '{% url "api-part-list" %}',
|
||||||
|
filters: {
|
||||||
|
component: true,
|
||||||
|
},
|
||||||
|
model: 'part',
|
||||||
|
required: true,
|
||||||
|
auto_fill: false,
|
||||||
|
onSelect: function(data, field, opts) {
|
||||||
|
// TODO?
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Add callback for "remove row" button
|
||||||
|
$(`#button-row-remove-${idx}`).click(function() {
|
||||||
|
$(`#items_${idx}`).remove();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Request API endpoint options
|
||||||
|
getApiEndpointOptions('{% url "api-bom-list" %}', function(response) {
|
||||||
|
|
||||||
|
var fields = response.actions.POST;
|
||||||
|
|
||||||
|
data.rows.forEach(function(row, idx) {
|
||||||
|
constructRow(row, idx, fields);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* Extract rows from the BOM upload table,
|
||||||
|
* and submit data to the server
|
||||||
|
*/
|
||||||
|
function submitBomTable(part_id, options={}) {
|
||||||
|
|
||||||
|
// Extract rows from the form
|
||||||
|
var rows = [];
|
||||||
|
|
||||||
|
var idx_values = [];
|
||||||
|
|
||||||
|
var url = '{% url "api-bom-upload" %}';
|
||||||
|
|
||||||
|
$('.bom-import-row').each(function() {
|
||||||
|
var idx = $(this).attr('idx');
|
||||||
|
|
||||||
|
idx_values.push(idx);
|
||||||
|
|
||||||
|
// Extract each field from the row
|
||||||
|
rows.push({
|
||||||
|
part: part_id,
|
||||||
|
sub_part: getFormFieldValue(`items_sub_part_${idx}`, {}),
|
||||||
|
quantity: getFormFieldValue(`items_quantity_${idx}`, {}),
|
||||||
|
reference: getFormFieldValue(`items_reference_${idx}`, {}),
|
||||||
|
overage: getFormFieldValue(`items_overage_${idx}`, {}),
|
||||||
|
allow_variants: getFormFieldValue(`items_allow_variants_${idx}`, {type: 'boolean'}),
|
||||||
|
inherited: getFormFieldValue(`items_inherited_${idx}`, {type: 'boolean'}),
|
||||||
|
optional: getFormFieldValue(`items_optional_${idx}`, {type: 'boolean'}),
|
||||||
|
note: getFormFieldValue(`items_note_${idx}`, {}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
var data = {
|
||||||
|
items: rows,
|
||||||
|
};
|
||||||
|
|
||||||
|
var options = {
|
||||||
|
nested: {
|
||||||
|
items: idx_values,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
getApiEndpointOptions(url, function(response) {
|
||||||
|
var fields = response.actions.POST;
|
||||||
|
|
||||||
|
inventreePut(url, data, {
|
||||||
|
method: 'POST',
|
||||||
|
success: function(response) {
|
||||||
|
window.location.href = `/part/${part_id}/?display=bom`;
|
||||||
|
},
|
||||||
|
error: function(xhr) {
|
||||||
|
switch (xhr.status) {
|
||||||
|
case 400:
|
||||||
|
handleFormErrors(xhr.responseJSON, fields, options);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
showApiError(xhr, url);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
function downloadBomTemplate(options={}) {
|
function downloadBomTemplate(options={}) {
|
||||||
|
|
||||||
var format = options.format;
|
var format = options.format;
|
||||||
|
@ -837,7 +837,15 @@ function getFormFieldElement(name, options) {
|
|||||||
|
|
||||||
var field_name = getFieldName(name, options);
|
var field_name = getFieldName(name, options);
|
||||||
|
|
||||||
var el = $(options.modal).find(`#id_${field_name}`);
|
var el = null;
|
||||||
|
|
||||||
|
if (options && options.modal) {
|
||||||
|
// Field element is associated with a model?
|
||||||
|
el = $(options.modal).find(`#id_${field_name}`);
|
||||||
|
} else {
|
||||||
|
// Field element is top-level
|
||||||
|
el = $(`#id_${field_name}`);
|
||||||
|
}
|
||||||
|
|
||||||
if (!el.exists) {
|
if (!el.exists) {
|
||||||
console.log(`ERROR: Could not find form element for field '${name}'`);
|
console.log(`ERROR: Could not find form element for field '${name}'`);
|
||||||
@ -882,12 +890,13 @@ function validateFormField(name, options) {
|
|||||||
* - field: The field specification provided from the OPTIONS request
|
* - field: The field specification provided from the OPTIONS request
|
||||||
* - options: The original options object provided by the client
|
* - options: The original options object provided by the client
|
||||||
*/
|
*/
|
||||||
function getFormFieldValue(name, field, options) {
|
function getFormFieldValue(name, field={}, options={}) {
|
||||||
|
|
||||||
// Find the HTML element
|
// Find the HTML element
|
||||||
var el = getFormFieldElement(name, options);
|
var el = getFormFieldElement(name, options);
|
||||||
|
|
||||||
if (!el) {
|
if (!el) {
|
||||||
|
console.log(`ERROR: getFormFieldValue could not locate field '{name}'`);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -973,16 +982,22 @@ function handleFormSuccess(response, options) {
|
|||||||
/*
|
/*
|
||||||
* Remove all error text items from the form
|
* Remove all error text items from the form
|
||||||
*/
|
*/
|
||||||
function clearFormErrors(options) {
|
function clearFormErrors(options={}) {
|
||||||
|
|
||||||
// Remove the individual error messages
|
if (options && options.modal) {
|
||||||
$(options.modal).find('.form-error-message').remove();
|
// Remove the individual error messages
|
||||||
|
$(options.modal).find('.form-error-message').remove();
|
||||||
|
|
||||||
// Remove the "has error" class
|
// Remove the "has error" class
|
||||||
$(options.modal).find('.form-field-error').removeClass('form-field-error');
|
$(options.modal).find('.form-field-error').removeClass('form-field-error');
|
||||||
|
|
||||||
// Hide the 'non field errors'
|
// Hide the 'non field errors'
|
||||||
$(options.modal).find('#non-field-errors').html('');
|
$(options.modal).find('#non-field-errors').html('');
|
||||||
|
} else {
|
||||||
|
$('.form-error-message').remove();
|
||||||
|
$('.form-field-errors').removeClass('form-field-error');
|
||||||
|
$('#non-field-errors').html('');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@ -1010,7 +1025,7 @@ function clearFormErrors(options) {
|
|||||||
*
|
*
|
||||||
*/
|
*/
|
||||||
|
|
||||||
function handleNestedErrors(errors, field_name, options) {
|
function handleNestedErrors(errors, field_name, options={}) {
|
||||||
|
|
||||||
var error_list = errors[field_name];
|
var error_list = errors[field_name];
|
||||||
|
|
||||||
@ -1041,8 +1056,31 @@ function handleNestedErrors(errors, field_name, options) {
|
|||||||
|
|
||||||
// Here, error_item is a map of field names to error messages
|
// Here, error_item is a map of field names to error messages
|
||||||
for (sub_field_name in error_item) {
|
for (sub_field_name in error_item) {
|
||||||
|
|
||||||
var errors = error_item[sub_field_name];
|
var errors = error_item[sub_field_name];
|
||||||
|
|
||||||
|
if (sub_field_name == 'non_field_errors') {
|
||||||
|
|
||||||
|
var row = null;
|
||||||
|
|
||||||
|
if (options.modal) {
|
||||||
|
row = $(options.modal).find(`#items_${nest_id}`);
|
||||||
|
} else {
|
||||||
|
row = $(`#items_${nest_id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (var ii = errors.length - 1; ii >= 0; ii--) {
|
||||||
|
|
||||||
|
var html = `
|
||||||
|
<div id='error_${ii}_non_field_error' class='help-block form-field-error form-error-message'>
|
||||||
|
<strong>${errors[ii]}</strong>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
row.after(html);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
// Find the target (nested) field
|
// Find the target (nested) field
|
||||||
var target = `${field_name}_${sub_field_name}_${nest_id}`;
|
var target = `${field_name}_${sub_field_name}_${nest_id}`;
|
||||||
|
|
||||||
@ -1066,15 +1104,23 @@ function handleNestedErrors(errors, field_name, options) {
|
|||||||
* - fields: The form data object
|
* - fields: The form data object
|
||||||
* - options: Form options provided by the client
|
* - options: Form options provided by the client
|
||||||
*/
|
*/
|
||||||
function handleFormErrors(errors, fields, options) {
|
function handleFormErrors(errors, fields={}, options={}) {
|
||||||
|
|
||||||
// Reset the status of the "submit" button
|
// Reset the status of the "submit" button
|
||||||
$(options.modal).find('#modal-form-submit').prop('disabled', false);
|
if (options.modal) {
|
||||||
|
$(options.modal).find('#modal-form-submit').prop('disabled', false);
|
||||||
|
}
|
||||||
|
|
||||||
// Remove any existing error messages from the form
|
// Remove any existing error messages from the form
|
||||||
clearFormErrors(options);
|
clearFormErrors(options);
|
||||||
|
|
||||||
var non_field_errors = $(options.modal).find('#non-field-errors');
|
var non_field_errors = null;
|
||||||
|
|
||||||
|
if (options.modal) {
|
||||||
|
non_field_errors = $(options.modal).find('#non-field-errors');
|
||||||
|
} else {
|
||||||
|
non_field_errors = $('#non-field-errors');
|
||||||
|
}
|
||||||
|
|
||||||
// TODO: Display the JSON error text when hovering over the "info" icon
|
// TODO: Display the JSON error text when hovering over the "info" icon
|
||||||
non_field_errors.append(
|
non_field_errors.append(
|
||||||
@ -1150,14 +1196,19 @@ function handleFormErrors(errors, fields, options) {
|
|||||||
/*
|
/*
|
||||||
* Add a rendered error message to the provided field
|
* Add a rendered error message to the provided field
|
||||||
*/
|
*/
|
||||||
function addFieldErrorMessage(name, error_text, error_idx, options) {
|
function addFieldErrorMessage(name, error_text, error_idx, options={}) {
|
||||||
|
|
||||||
field_name = getFieldName(name, options);
|
field_name = getFieldName(name, options);
|
||||||
|
|
||||||
// Add the 'form-field-error' class
|
var field_dom = null;
|
||||||
$(options.modal).find(`#div_id_${field_name}`).addClass('form-field-error');
|
|
||||||
|
|
||||||
var field_dom = $(options.modal).find(`#errors-${field_name}`);
|
if (options.modal) {
|
||||||
|
$(options.modal).find(`#div_id_${field_name}`).addClass('form-field-error');
|
||||||
|
field_dom = $(options.modal).find(`#errors-${field_name}`);
|
||||||
|
} else {
|
||||||
|
$(`#div_id_${field_name}`).addClass('form-field-error');
|
||||||
|
field_dom = $(`#errors-${field_name}`);
|
||||||
|
}
|
||||||
|
|
||||||
if (field_dom) {
|
if (field_dom) {
|
||||||
|
|
||||||
@ -1228,12 +1279,18 @@ function addClearCallbacks(fields, options) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
function addClearCallback(name, field, options) {
|
function addClearCallback(name, field, options={}) {
|
||||||
|
|
||||||
var field_name = getFieldName(name, options);
|
var field_name = getFieldName(name, options);
|
||||||
|
|
||||||
var el = $(options.modal).find(`#clear_${field_name}`);
|
var el = null;
|
||||||
|
|
||||||
|
if (options && options.modal) {
|
||||||
|
el = $(options.modal).find(`#clear_${field_name}`);
|
||||||
|
} else {
|
||||||
|
el = $(`#clear_${field_name}`);
|
||||||
|
}
|
||||||
|
|
||||||
if (!el) {
|
if (!el) {
|
||||||
console.log(`WARNING: addClearCallback could not find field '${name}'`);
|
console.log(`WARNING: addClearCallback could not find field '${name}'`);
|
||||||
return;
|
return;
|
||||||
@ -1330,11 +1387,13 @@ function hideFormGroup(group, options) {
|
|||||||
$(options.modal).find(`#form-panel-${group}`).hide();
|
$(options.modal).find(`#form-panel-${group}`).hide();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// Show a form group
|
// Show a form group
|
||||||
function showFormGroup(group, options) {
|
function showFormGroup(group, options) {
|
||||||
$(options.modal).find(`#form-panel-${group}`).show();
|
$(options.modal).find(`#form-panel-${group}`).show();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
function setFormGroupVisibility(group, vis, options) {
|
function setFormGroupVisibility(group, vis, options) {
|
||||||
if (vis) {
|
if (vis) {
|
||||||
showFormGroup(group, options);
|
showFormGroup(group, options);
|
||||||
@ -1344,7 +1403,7 @@ function setFormGroupVisibility(group, vis, options) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
function initializeRelatedFields(fields, options) {
|
function initializeRelatedFields(fields, options={}) {
|
||||||
|
|
||||||
var field_names = options.field_names;
|
var field_names = options.field_names;
|
||||||
|
|
||||||
@ -1452,12 +1511,11 @@ function addSecondaryModal(field, fields, options) {
|
|||||||
* - field: Field definition from the OPTIONS request
|
* - field: Field definition from the OPTIONS request
|
||||||
* - options: Original options object provided by the client
|
* - options: Original options object provided by the client
|
||||||
*/
|
*/
|
||||||
function initializeRelatedField(field, fields, options) {
|
function initializeRelatedField(field, fields, options={}) {
|
||||||
|
|
||||||
var name = field.name;
|
var name = field.name;
|
||||||
|
|
||||||
if (!field.api_url) {
|
if (!field.api_url) {
|
||||||
// TODO: Provide manual api_url option?
|
|
||||||
console.log(`WARNING: Related field '${name}' missing 'api_url' parameter.`);
|
console.log(`WARNING: Related field '${name}' missing 'api_url' parameter.`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -1475,10 +1533,22 @@ function initializeRelatedField(field, fields, options) {
|
|||||||
// limit size for AJAX requests
|
// limit size for AJAX requests
|
||||||
var pageSize = options.pageSize || 25;
|
var pageSize = options.pageSize || 25;
|
||||||
|
|
||||||
|
var parent = null;
|
||||||
|
var auto_width = false;
|
||||||
|
var width = '100%';
|
||||||
|
|
||||||
|
// Special considerations if the select2 input is a child of a modal
|
||||||
|
if (options && options.modal) {
|
||||||
|
parent = $(options.modal);
|
||||||
|
auto_width = true;
|
||||||
|
width = null;
|
||||||
|
}
|
||||||
|
|
||||||
select.select2({
|
select.select2({
|
||||||
placeholder: '',
|
placeholder: '',
|
||||||
dropdownParent: $(options.modal),
|
dropdownParent: parent,
|
||||||
dropdownAutoWidth: false,
|
dropdownAutoWidth: auto_width,
|
||||||
|
width: width,
|
||||||
language: {
|
language: {
|
||||||
noResults: function(query) {
|
noResults: function(query) {
|
||||||
if (field.noResults) {
|
if (field.noResults) {
|
||||||
@ -1654,7 +1724,7 @@ function initializeRelatedField(field, fields, options) {
|
|||||||
* - data: JSON data representing the model instance
|
* - data: JSON data representing the model instance
|
||||||
* - options: The modal form specifications
|
* - options: The modal form specifications
|
||||||
*/
|
*/
|
||||||
function setRelatedFieldData(name, data, options) {
|
function setRelatedFieldData(name, data, options={}) {
|
||||||
|
|
||||||
var select = getFormFieldElement(name, options);
|
var select = getFormFieldElement(name, options);
|
||||||
|
|
||||||
@ -1779,10 +1849,10 @@ function renderModelData(name, model, data, parameters, options) {
|
|||||||
/*
|
/*
|
||||||
* Construct a field name for the given field
|
* Construct a field name for the given field
|
||||||
*/
|
*/
|
||||||
function getFieldName(name, options) {
|
function getFieldName(name, options={}) {
|
||||||
var field_name = name;
|
var field_name = name;
|
||||||
|
|
||||||
if (options.depth) {
|
if (options && options.depth) {
|
||||||
field_name += `_${options.depth}`;
|
field_name += `_${options.depth}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1872,12 +1942,12 @@ function constructField(name, parameters, options) {
|
|||||||
options.current_group = group;
|
options.current_group = group;
|
||||||
}
|
}
|
||||||
|
|
||||||
var form_classes = 'form-group';
|
var form_classes = options.form_classes || 'form-group';
|
||||||
|
|
||||||
if (parameters.errors) {
|
if (parameters.errors) {
|
||||||
form_classes += ' form-field-error';
|
form_classes += ' form-field-error';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Optional content to render before the field
|
// Optional content to render before the field
|
||||||
if (parameters.before) {
|
if (parameters.before) {
|
||||||
html += parameters.before;
|
html += parameters.before;
|
||||||
@ -1925,7 +1995,7 @@ function constructField(name, parameters, options) {
|
|||||||
|
|
||||||
if (extra) {
|
if (extra) {
|
||||||
|
|
||||||
if (!parameters.required) {
|
if (!parameters.required && !options.hideClearButton) {
|
||||||
html += `
|
html += `
|
||||||
<span class='input-group-text form-clear' id='clear_${field_name}' title='{% trans "Clear input" %}'>
|
<span class='input-group-text form-clear' id='clear_${field_name}' title='{% trans "Clear input" %}'>
|
||||||
<span class='icon-red fas fa-backspace'></span>
|
<span class='icon-red fas fa-backspace'></span>
|
||||||
@ -2053,7 +2123,7 @@ function constructInput(name, parameters, options) {
|
|||||||
|
|
||||||
|
|
||||||
// Construct a set of default input options which apply to all input types
|
// Construct a set of default input options which apply to all input types
|
||||||
function constructInputOptions(name, classes, type, parameters) {
|
function constructInputOptions(name, classes, type, parameters, options={}) {
|
||||||
|
|
||||||
var opts = [];
|
var opts = [];
|
||||||
|
|
||||||
@ -2135,11 +2205,18 @@ function constructInputOptions(name, classes, type, parameters) {
|
|||||||
if (parameters.multiline) {
|
if (parameters.multiline) {
|
||||||
return `<textarea ${opts.join(' ')}></textarea>`;
|
return `<textarea ${opts.join(' ')}></textarea>`;
|
||||||
} else if (parameters.type == 'boolean') {
|
} else if (parameters.type == 'boolean') {
|
||||||
|
|
||||||
|
var help_text = '';
|
||||||
|
|
||||||
|
if (!options.hideLabels && parameters.help_text) {
|
||||||
|
help_text = `<em><small>${parameters.help_text}</small></em>`;
|
||||||
|
}
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div class='form-check form-switch'>
|
<div class='form-check form-switch'>
|
||||||
<input ${opts.join(' ')}>
|
<input ${opts.join(' ')}>
|
||||||
<label class='form-check-label' for=''>
|
<label class='form-check-label' for=''>
|
||||||
<em><small>${parameters.help_text}</small></em>
|
${help_text}
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
@ -2162,13 +2239,14 @@ function constructHiddenInput(name, parameters) {
|
|||||||
|
|
||||||
|
|
||||||
// Construct a "checkbox" input
|
// Construct a "checkbox" input
|
||||||
function constructCheckboxInput(name, parameters) {
|
function constructCheckboxInput(name, parameters, options={}) {
|
||||||
|
|
||||||
return constructInputOptions(
|
return constructInputOptions(
|
||||||
name,
|
name,
|
||||||
'form-check-input',
|
'form-check-input',
|
||||||
'checkbox',
|
'checkbox',
|
||||||
parameters
|
parameters,
|
||||||
|
options
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -161,7 +161,7 @@ function renderPart(name, data, parameters, options) {
|
|||||||
html += ` <span>${data.full_name || data.name}</span>`;
|
html += ` <span>${data.full_name || data.name}</span>`;
|
||||||
|
|
||||||
if (data.description) {
|
if (data.description) {
|
||||||
html += ` - <i>${data.description}</i>`;
|
html += ` - <i><small>${data.description}</small></i>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
var extra = '';
|
var extra = '';
|
||||||
|
Loading…
Reference in New Issue
Block a user