mirror of
https://github.com/inventree/InvenTree.git
synced 2026-05-30 13:18:44 +00:00
Merge remote-tracking branch 'inventree/master'
This commit is contained in:
@@ -0,0 +1,2 @@
|
|||||||
|
patreon: inventree
|
||||||
|
ko_fi: inventree
|
||||||
@@ -36,7 +36,7 @@ jobs:
|
|||||||
push: true
|
push: true
|
||||||
target: production
|
target: production
|
||||||
build-args:
|
build-args:
|
||||||
branch: stable
|
branch=stable
|
||||||
tags: inventree/inventree:stable
|
tags: inventree/inventree:stable
|
||||||
- name: Image Digest
|
- name: Image Digest
|
||||||
run: echo ${{ steps.docker_build.outputs.digest }}
|
run: echo ${{ steps.docker_build.outputs.digest }}
|
||||||
|
|||||||
@@ -34,5 +34,5 @@ jobs:
|
|||||||
push: true
|
push: true
|
||||||
target: production
|
target: production
|
||||||
build-args:
|
build-args:
|
||||||
tag: ${{ github.event.release.tag_name }}
|
tag=${{ github.event.release.tag_name }}
|
||||||
tags: inventree/inventree:${{ github.event.release.tag_name }}
|
tags: inventree/inventree:${{ github.event.release.tag_name }}
|
||||||
|
|||||||
@@ -106,12 +106,12 @@ class InvenTreeAPITestCase(APITestCase):
|
|||||||
|
|
||||||
return response
|
return response
|
||||||
|
|
||||||
def post(self, url, data, expected_code=None):
|
def post(self, url, data, expected_code=None, format='json'):
|
||||||
"""
|
"""
|
||||||
Issue a POST request
|
Issue a POST request
|
||||||
"""
|
"""
|
||||||
|
|
||||||
response = self.client.post(url, data=data, format='json')
|
response = self.client.post(url, data=data, format=format)
|
||||||
|
|
||||||
if expected_code is not None:
|
if expected_code is not None:
|
||||||
self.assertEqual(response.status_code, expected_code)
|
self.assertEqual(response.status_code, expected_code)
|
||||||
@@ -130,12 +130,12 @@ class InvenTreeAPITestCase(APITestCase):
|
|||||||
|
|
||||||
return response
|
return response
|
||||||
|
|
||||||
def patch(self, url, data, files=None, expected_code=None):
|
def patch(self, url, data, expected_code=None, format='json'):
|
||||||
"""
|
"""
|
||||||
Issue a PATCH request
|
Issue a PATCH request
|
||||||
"""
|
"""
|
||||||
|
|
||||||
response = self.client.patch(url, data=data, files=files, format='json')
|
response = self.client.patch(url, data=data, format=format)
|
||||||
|
|
||||||
if expected_code is not None:
|
if expected_code is not None:
|
||||||
self.assertEqual(response.status_code, expected_code)
|
self.assertEqual(response.status_code, expected_code)
|
||||||
|
|||||||
+11
-13
@@ -38,7 +38,7 @@ class InvenTreeConfig(AppConfig):
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
from django_q.models import Schedule
|
from django_q.models import Schedule
|
||||||
except (AppRegistryNotReady):
|
except AppRegistryNotReady: # pragma: no cover
|
||||||
return
|
return
|
||||||
|
|
||||||
# Remove any existing obsolete tasks
|
# Remove any existing obsolete tasks
|
||||||
@@ -48,7 +48,7 @@ class InvenTreeConfig(AppConfig):
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
from django_q.models import Schedule
|
from django_q.models import Schedule
|
||||||
except (AppRegistryNotReady):
|
except AppRegistryNotReady: # pragma: no cover
|
||||||
return
|
return
|
||||||
|
|
||||||
logger.info("Starting background tasks...")
|
logger.info("Starting background tasks...")
|
||||||
@@ -100,10 +100,10 @@ class InvenTreeConfig(AppConfig):
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
from djmoney.contrib.exchange.models import ExchangeBackend
|
from djmoney.contrib.exchange.models import ExchangeBackend
|
||||||
from datetime import datetime, timedelta
|
|
||||||
from InvenTree.tasks import update_exchange_rates
|
from InvenTree.tasks import update_exchange_rates
|
||||||
from common.settings import currency_code_default
|
from common.settings import currency_code_default
|
||||||
except AppRegistryNotReady:
|
except AppRegistryNotReady: # pragma: no cover
|
||||||
pass
|
pass
|
||||||
|
|
||||||
base_currency = currency_code_default()
|
base_currency = currency_code_default()
|
||||||
@@ -115,23 +115,18 @@ class InvenTreeConfig(AppConfig):
|
|||||||
|
|
||||||
last_update = backend.last_update
|
last_update = backend.last_update
|
||||||
|
|
||||||
if last_update is not None:
|
if last_update is None:
|
||||||
delta = datetime.now().date() - last_update.date()
|
|
||||||
if delta > timedelta(days=1):
|
|
||||||
print(f"Last update was {last_update}")
|
|
||||||
update = True
|
|
||||||
else:
|
|
||||||
# Never been updated
|
# Never been updated
|
||||||
print("Exchange backend has never been updated")
|
logger.info("Exchange backend has never been updated")
|
||||||
update = True
|
update = True
|
||||||
|
|
||||||
# Backend currency has changed?
|
# Backend currency has changed?
|
||||||
if not base_currency == backend.base_currency:
|
if not base_currency == backend.base_currency:
|
||||||
print(f"Base currency changed from {backend.base_currency} to {base_currency}")
|
logger.info(f"Base currency changed from {backend.base_currency} to {base_currency}")
|
||||||
update = True
|
update = True
|
||||||
|
|
||||||
except (ExchangeBackend.DoesNotExist):
|
except (ExchangeBackend.DoesNotExist):
|
||||||
print("Exchange backend not found - updating")
|
logger.info("Exchange backend not found - updating")
|
||||||
update = True
|
update = True
|
||||||
|
|
||||||
except:
|
except:
|
||||||
@@ -139,4 +134,7 @@ class InvenTreeConfig(AppConfig):
|
|||||||
return
|
return
|
||||||
|
|
||||||
if update:
|
if update:
|
||||||
|
try:
|
||||||
update_exchange_rates()
|
update_exchange_rates()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error updating exchange rates: {e}")
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"""
|
"""
|
||||||
Pull rendered copies of the templated
|
Pull rendered copies of the templated
|
||||||
|
only used for testing the js files! - This file is omited from coverage
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ def health_status(request):
|
|||||||
|
|
||||||
if request.path.endswith('.js'):
|
if request.path.endswith('.js'):
|
||||||
# Do not provide to script requests
|
# Do not provide to script requests
|
||||||
return {}
|
return {} # pragma: no cover
|
||||||
|
|
||||||
if hasattr(request, '_inventree_health_status'):
|
if hasattr(request, '_inventree_health_status'):
|
||||||
# Do not duplicate efforts
|
# Do not duplicate efforts
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
import certifi
|
||||||
|
import ssl
|
||||||
|
from urllib.request import urlopen
|
||||||
|
|
||||||
from common.settings import currency_code_default, currency_codes
|
from common.settings import currency_code_default, currency_codes
|
||||||
from urllib.error import URLError
|
from urllib.error import URLError
|
||||||
|
|
||||||
@@ -24,6 +28,22 @@ class InvenTreeExchange(SimpleExchangeBackend):
|
|||||||
return {
|
return {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def get_response(self, **kwargs):
|
||||||
|
"""
|
||||||
|
Custom code to get response from server.
|
||||||
|
Note: Adds a 5-second timeout
|
||||||
|
"""
|
||||||
|
|
||||||
|
url = self.get_url(**kwargs)
|
||||||
|
|
||||||
|
try:
|
||||||
|
context = ssl.create_default_context(cafile=certifi.where())
|
||||||
|
response = urlopen(url, timeout=5, context=context)
|
||||||
|
return response.read()
|
||||||
|
except:
|
||||||
|
# Returning None here will raise an error upstream
|
||||||
|
return None
|
||||||
|
|
||||||
def update_rates(self, base_currency=currency_code_default()):
|
def update_rates(self, base_currency=currency_code_default()):
|
||||||
|
|
||||||
symbols = ','.join(currency_codes())
|
symbols = ','.join(currency_codes())
|
||||||
|
|||||||
@@ -475,7 +475,6 @@ def extract_serial_numbers(serials, expected_quantity, next_number: int):
|
|||||||
continue
|
continue
|
||||||
else:
|
else:
|
||||||
errors.append(_("Invalid group: {g}").format(g=group))
|
errors.append(_("Invalid group: {g}").format(g=group))
|
||||||
continue
|
|
||||||
|
|
||||||
# plus signals either
|
# plus signals either
|
||||||
# 1: 'start+': expected number of serials, starting at start
|
# 1: 'start+': expected number of serials, starting at start
|
||||||
@@ -500,7 +499,6 @@ def extract_serial_numbers(serials, expected_quantity, next_number: int):
|
|||||||
# no case
|
# no case
|
||||||
else:
|
else:
|
||||||
errors.append(_("Invalid group: {g}").format(g=group))
|
errors.append(_("Invalid group: {g}").format(g=group))
|
||||||
continue
|
|
||||||
|
|
||||||
# Group should be a number
|
# Group should be a number
|
||||||
elif group:
|
elif group:
|
||||||
|
|||||||
@@ -45,6 +45,62 @@ def rename_attachment(instance, filename):
|
|||||||
return os.path.join(instance.getSubdir(), filename)
|
return os.path.join(instance.getSubdir(), filename)
|
||||||
|
|
||||||
|
|
||||||
|
class DataImportMixin(object):
|
||||||
|
"""
|
||||||
|
Model mixin class which provides support for 'data import' functionality.
|
||||||
|
|
||||||
|
Models which implement this mixin should provide information on the fields available for import
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Define a map of fields avaialble for import
|
||||||
|
IMPORT_FIELDS = {}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_import_fields(cls):
|
||||||
|
"""
|
||||||
|
Return all available import fields
|
||||||
|
|
||||||
|
Where information on a particular field is not explicitly provided,
|
||||||
|
introspect the base model to (attempt to) find that information.
|
||||||
|
|
||||||
|
"""
|
||||||
|
fields = cls.IMPORT_FIELDS
|
||||||
|
|
||||||
|
for name, field in fields.items():
|
||||||
|
|
||||||
|
# Attempt to extract base field information from the model
|
||||||
|
base_field = None
|
||||||
|
|
||||||
|
for f in cls._meta.fields:
|
||||||
|
if f.name == name:
|
||||||
|
base_field = f
|
||||||
|
break
|
||||||
|
|
||||||
|
if base_field:
|
||||||
|
if 'label' not in field:
|
||||||
|
field['label'] = base_field.verbose_name
|
||||||
|
|
||||||
|
if 'help_text' not in field:
|
||||||
|
field['help_text'] = base_field.help_text
|
||||||
|
|
||||||
|
fields[name] = field
|
||||||
|
|
||||||
|
return fields
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_required_import_fields(cls):
|
||||||
|
""" Return all *required* import fields """
|
||||||
|
fields = {}
|
||||||
|
|
||||||
|
for name, field in cls.get_import_fields().items():
|
||||||
|
required = field.get('required', False)
|
||||||
|
|
||||||
|
if required:
|
||||||
|
fields[name] = field
|
||||||
|
|
||||||
|
return fields
|
||||||
|
|
||||||
|
|
||||||
class ReferenceIndexingMixin(models.Model):
|
class ReferenceIndexingMixin(models.Model):
|
||||||
"""
|
"""
|
||||||
A mixin for keeping track of numerical copies of the "reference" field.
|
A mixin for keeping track of numerical copies of the "reference" field.
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ Serializers used in various InvenTree apps
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
from __future__ import unicode_literals
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
import tablib
|
||||||
|
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
|
||||||
@@ -328,4 +328,313 @@ class InvenTreeDecimalField(serializers.FloatField):
|
|||||||
def to_internal_value(self, data):
|
def to_internal_value(self, data):
|
||||||
|
|
||||||
# Convert the value to a string, and then a decimal
|
# Convert the value to a string, and then a decimal
|
||||||
|
try:
|
||||||
return Decimal(str(data))
|
return Decimal(str(data))
|
||||||
|
except:
|
||||||
|
raise serializers.ValidationError(_("Invalid value"))
|
||||||
|
|
||||||
|
|
||||||
|
class DataFileUploadSerializer(serializers.Serializer):
|
||||||
|
"""
|
||||||
|
Generic serializer for uploading a data file, and extracting a dataset.
|
||||||
|
|
||||||
|
- Validates uploaded file
|
||||||
|
- Extracts column names
|
||||||
|
- Extracts data rows
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Implementing class should register a target model (database model) to be used for import
|
||||||
|
TARGET_MODEL = None
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
fields = [
|
||||||
|
'data_file',
|
||||||
|
]
|
||||||
|
|
||||||
|
data_file = serializers.FileField(
|
||||||
|
label=_("Data File"),
|
||||||
|
help_text=_("Select data file for upload"),
|
||||||
|
required=True,
|
||||||
|
allow_empty_file=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
def validate_data_file(self, data_file):
|
||||||
|
"""
|
||||||
|
Perform validation checks on the uploaded data file.
|
||||||
|
"""
|
||||||
|
|
||||||
|
self.filename = data_file.name
|
||||||
|
|
||||||
|
name, ext = os.path.splitext(data_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 data_file.size > max_upload_file_size:
|
||||||
|
raise serializers.ValidationError(_("File is too large"))
|
||||||
|
|
||||||
|
# Read file data into memory (bytes object)
|
||||||
|
try:
|
||||||
|
data = data_file.read()
|
||||||
|
except Exception as e:
|
||||||
|
raise serializers.ValidationError(str(e))
|
||||||
|
|
||||||
|
if ext in ['csv', 'tsv', 'xml']:
|
||||||
|
try:
|
||||||
|
data = data.decode()
|
||||||
|
except Exception as e:
|
||||||
|
raise serializers.ValidationError(str(e))
|
||||||
|
|
||||||
|
# Convert to a tablib dataset (we expect headers)
|
||||||
|
try:
|
||||||
|
self.dataset = tablib.Dataset().load(data, ext, headers=True)
|
||||||
|
except Exception as e:
|
||||||
|
raise serializers.ValidationError(str(e))
|
||||||
|
|
||||||
|
if len(self.dataset.headers) == 0:
|
||||||
|
raise serializers.ValidationError(_("No columns found in file"))
|
||||||
|
|
||||||
|
if len(self.dataset) == 0:
|
||||||
|
raise serializers.ValidationError(_("No data rows found in file"))
|
||||||
|
|
||||||
|
return data_file
|
||||||
|
|
||||||
|
def match_column(self, column_name, field_names, exact=False):
|
||||||
|
"""
|
||||||
|
Attempt to match a column name (from the file) to a field (defined in the model)
|
||||||
|
|
||||||
|
Order of matching is:
|
||||||
|
- Direct match
|
||||||
|
- Case insensitive match
|
||||||
|
- Fuzzy match
|
||||||
|
"""
|
||||||
|
|
||||||
|
column_name = column_name.strip()
|
||||||
|
|
||||||
|
column_name_lower = column_name.lower()
|
||||||
|
|
||||||
|
if column_name in field_names:
|
||||||
|
return column_name
|
||||||
|
|
||||||
|
for field_name in field_names:
|
||||||
|
if field_name.lower() == column_name_lower:
|
||||||
|
return field_name
|
||||||
|
|
||||||
|
if exact:
|
||||||
|
# Finished available 'exact' matches
|
||||||
|
return None
|
||||||
|
|
||||||
|
# TODO: Fuzzy pattern matching for column names
|
||||||
|
|
||||||
|
# No matches found
|
||||||
|
return None
|
||||||
|
|
||||||
|
def extract_data(self):
|
||||||
|
"""
|
||||||
|
Returns dataset extracted from the file
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Provide a dict of available import fields for the model
|
||||||
|
model_fields = {}
|
||||||
|
|
||||||
|
# Keep track of columns we have already extracted
|
||||||
|
matched_columns = set()
|
||||||
|
|
||||||
|
if self.TARGET_MODEL:
|
||||||
|
try:
|
||||||
|
model_fields = self.TARGET_MODEL.get_import_fields()
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Extract a list of valid model field names
|
||||||
|
model_field_names = [key for key in model_fields.keys()]
|
||||||
|
|
||||||
|
# Provide a dict of available columns from the dataset
|
||||||
|
file_columns = {}
|
||||||
|
|
||||||
|
for header in self.dataset.headers:
|
||||||
|
column = {}
|
||||||
|
|
||||||
|
# Attempt to "match" file columns to model fields
|
||||||
|
match = self.match_column(header, model_field_names, exact=True)
|
||||||
|
|
||||||
|
if match is not None and match not in matched_columns:
|
||||||
|
matched_columns.add(match)
|
||||||
|
column['value'] = match
|
||||||
|
else:
|
||||||
|
column['value'] = None
|
||||||
|
|
||||||
|
file_columns[header] = column
|
||||||
|
|
||||||
|
return {
|
||||||
|
'file_fields': file_columns,
|
||||||
|
'model_fields': model_fields,
|
||||||
|
'rows': [row.values() for row in self.dataset.dict],
|
||||||
|
'filename': self.filename,
|
||||||
|
}
|
||||||
|
|
||||||
|
def save(self):
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
class DataFileExtractSerializer(serializers.Serializer):
|
||||||
|
"""
|
||||||
|
Generic serializer for extracting data from an imported dataset.
|
||||||
|
|
||||||
|
- User provides an array of matched headers
|
||||||
|
- User provides an array of raw data rows
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Implementing class should register a target model (database model) to be used for import
|
||||||
|
TARGET_MODEL = None
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
fields = [
|
||||||
|
'columns',
|
||||||
|
'rows',
|
||||||
|
]
|
||||||
|
|
||||||
|
# Mapping of columns
|
||||||
|
columns = serializers.ListField(
|
||||||
|
child=serializers.CharField(
|
||||||
|
allow_blank=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
rows = serializers.ListField(
|
||||||
|
child=serializers.ListField(
|
||||||
|
child=serializers.CharField(
|
||||||
|
allow_blank=True,
|
||||||
|
allow_null=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def validate(self, data):
|
||||||
|
|
||||||
|
data = super().validate(data)
|
||||||
|
|
||||||
|
self.columns = data.get('columns', [])
|
||||||
|
self.rows = data.get('rows', [])
|
||||||
|
|
||||||
|
if len(self.rows) == 0:
|
||||||
|
raise serializers.ValidationError(_("No data rows provided"))
|
||||||
|
|
||||||
|
if len(self.columns) == 0:
|
||||||
|
raise serializers.ValidationError(_("No data columns supplied"))
|
||||||
|
|
||||||
|
self.validate_extracted_columns()
|
||||||
|
|
||||||
|
return data
|
||||||
|
|
||||||
|
@property
|
||||||
|
def data(self):
|
||||||
|
|
||||||
|
if self.TARGET_MODEL:
|
||||||
|
try:
|
||||||
|
model_fields = self.TARGET_MODEL.get_import_fields()
|
||||||
|
except:
|
||||||
|
model_fields = {}
|
||||||
|
|
||||||
|
rows = []
|
||||||
|
|
||||||
|
for row in self.rows:
|
||||||
|
"""
|
||||||
|
Optionally pre-process each row, before sending back to the client
|
||||||
|
"""
|
||||||
|
|
||||||
|
processed_row = self.process_row(self.row_to_dict(row))
|
||||||
|
|
||||||
|
if processed_row:
|
||||||
|
rows.append({
|
||||||
|
"original": row,
|
||||||
|
"data": processed_row,
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
'fields': model_fields,
|
||||||
|
'columns': self.columns,
|
||||||
|
'rows': rows,
|
||||||
|
}
|
||||||
|
|
||||||
|
def process_row(self, row):
|
||||||
|
"""
|
||||||
|
Process a 'row' of data, which is a mapped column:value dict
|
||||||
|
|
||||||
|
Returns either a mapped column:value dict, or None.
|
||||||
|
|
||||||
|
If the function returns None, the column is ignored!
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Default implementation simply returns the original row data
|
||||||
|
return row
|
||||||
|
|
||||||
|
def row_to_dict(self, row):
|
||||||
|
"""
|
||||||
|
Convert a "row" to a named data dict
|
||||||
|
"""
|
||||||
|
|
||||||
|
row_dict = {
|
||||||
|
'errors': {},
|
||||||
|
}
|
||||||
|
|
||||||
|
for idx, value in enumerate(row):
|
||||||
|
|
||||||
|
if idx < len(self.columns):
|
||||||
|
col = self.columns[idx]
|
||||||
|
|
||||||
|
if col:
|
||||||
|
row_dict[col] = value
|
||||||
|
|
||||||
|
return row_dict
|
||||||
|
|
||||||
|
def validate_extracted_columns(self):
|
||||||
|
"""
|
||||||
|
Perform custom validation of header mapping.
|
||||||
|
"""
|
||||||
|
|
||||||
|
if self.TARGET_MODEL:
|
||||||
|
try:
|
||||||
|
model_fields = self.TARGET_MODEL.get_import_fields()
|
||||||
|
except:
|
||||||
|
model_fields = {}
|
||||||
|
|
||||||
|
cols_seen = set()
|
||||||
|
|
||||||
|
for name, field in model_fields.items():
|
||||||
|
|
||||||
|
required = field.get('required', False)
|
||||||
|
|
||||||
|
# Check for missing required columns
|
||||||
|
if required:
|
||||||
|
if name not in self.columns:
|
||||||
|
raise serializers.ValidationError(_(f"Missing required column: '{name}'"))
|
||||||
|
|
||||||
|
for col in self.columns:
|
||||||
|
|
||||||
|
if not col:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Check for duplicated columns
|
||||||
|
if col in cols_seen:
|
||||||
|
raise serializers.ValidationError(_(f"Duplicate column: '{col}'"))
|
||||||
|
|
||||||
|
cols_seen.add(col)
|
||||||
|
|
||||||
|
def save(self):
|
||||||
|
"""
|
||||||
|
No "save" action for this serializer
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ logging.basicConfig(
|
|||||||
)
|
)
|
||||||
|
|
||||||
if log_level not in ['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL']:
|
if log_level not in ['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL']:
|
||||||
log_level = 'WARNING'
|
log_level = 'WARNING' # pragma: no cover
|
||||||
|
|
||||||
LOGGING = {
|
LOGGING = {
|
||||||
'version': 1,
|
'version': 1,
|
||||||
@@ -119,20 +119,20 @@ d) Create "secret_key.txt" if it does not exist
|
|||||||
|
|
||||||
if os.getenv("INVENTREE_SECRET_KEY"):
|
if os.getenv("INVENTREE_SECRET_KEY"):
|
||||||
# Secret key passed in directly
|
# Secret key passed in directly
|
||||||
SECRET_KEY = os.getenv("INVENTREE_SECRET_KEY").strip()
|
SECRET_KEY = os.getenv("INVENTREE_SECRET_KEY").strip() # pragma: no cover
|
||||||
logger.info("SECRET_KEY loaded by INVENTREE_SECRET_KEY")
|
logger.info("SECRET_KEY loaded by INVENTREE_SECRET_KEY") # pragma: no cover
|
||||||
else:
|
else:
|
||||||
# Secret key passed in by file location
|
# Secret key passed in by file location
|
||||||
key_file = os.getenv("INVENTREE_SECRET_KEY_FILE")
|
key_file = os.getenv("INVENTREE_SECRET_KEY_FILE")
|
||||||
|
|
||||||
if key_file:
|
if key_file:
|
||||||
key_file = os.path.abspath(key_file)
|
key_file = os.path.abspath(key_file) # pragma: no cover
|
||||||
else:
|
else:
|
||||||
# default secret key location
|
# default secret key location
|
||||||
key_file = os.path.join(BASE_DIR, "secret_key.txt")
|
key_file = os.path.join(BASE_DIR, "secret_key.txt")
|
||||||
key_file = os.path.abspath(key_file)
|
key_file = os.path.abspath(key_file)
|
||||||
|
|
||||||
if not os.path.exists(key_file):
|
if not os.path.exists(key_file): # pragma: no cover
|
||||||
logger.info(f"Generating random key file at '{key_file}'")
|
logger.info(f"Generating random key file at '{key_file}'")
|
||||||
# Create a random key file
|
# Create a random key file
|
||||||
with open(key_file, 'w') as f:
|
with open(key_file, 'w') as f:
|
||||||
@@ -144,7 +144,7 @@ else:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
SECRET_KEY = open(key_file, "r").read().strip()
|
SECRET_KEY = open(key_file, "r").read().strip()
|
||||||
except Exception:
|
except Exception: # pragma: no cover
|
||||||
logger.exception(f"Couldn't load keyfile {key_file}")
|
logger.exception(f"Couldn't load keyfile {key_file}")
|
||||||
sys.exit(-1)
|
sys.exit(-1)
|
||||||
|
|
||||||
@@ -156,7 +156,7 @@ STATIC_ROOT = os.path.abspath(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
if STATIC_ROOT is None:
|
if STATIC_ROOT is None: # pragma: no cover
|
||||||
print("ERROR: INVENTREE_STATIC_ROOT directory not defined")
|
print("ERROR: INVENTREE_STATIC_ROOT directory not defined")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
@@ -168,7 +168,7 @@ MEDIA_ROOT = os.path.abspath(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
if MEDIA_ROOT is None:
|
if MEDIA_ROOT is None: # pragma: no cover
|
||||||
print("ERROR: INVENTREE_MEDIA_ROOT directory is not defined")
|
print("ERROR: INVENTREE_MEDIA_ROOT directory is not defined")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
@@ -187,7 +187,7 @@ if cors_opt:
|
|||||||
CORS_ORIGIN_ALLOW_ALL = cors_opt.get('allow_all', False)
|
CORS_ORIGIN_ALLOW_ALL = cors_opt.get('allow_all', False)
|
||||||
|
|
||||||
if not CORS_ORIGIN_ALLOW_ALL:
|
if not CORS_ORIGIN_ALLOW_ALL:
|
||||||
CORS_ORIGIN_WHITELIST = cors_opt.get('whitelist', [])
|
CORS_ORIGIN_WHITELIST = cors_opt.get('whitelist', []) # pragma: no cover
|
||||||
|
|
||||||
# Web URL endpoint for served static files
|
# Web URL endpoint for served static files
|
||||||
STATIC_URL = '/static/'
|
STATIC_URL = '/static/'
|
||||||
@@ -215,7 +215,7 @@ if DEBUG:
|
|||||||
logger.info("InvenTree running with DEBUG enabled")
|
logger.info("InvenTree running with DEBUG enabled")
|
||||||
|
|
||||||
if DEMO_MODE:
|
if DEMO_MODE:
|
||||||
logger.warning("InvenTree running in DEMO mode")
|
logger.warning("InvenTree running in DEMO mode") # pragma: no cover
|
||||||
|
|
||||||
logger.debug(f"MEDIA_ROOT: '{MEDIA_ROOT}'")
|
logger.debug(f"MEDIA_ROOT: '{MEDIA_ROOT}'")
|
||||||
logger.debug(f"STATIC_ROOT: '{STATIC_ROOT}'")
|
logger.debug(f"STATIC_ROOT: '{STATIC_ROOT}'")
|
||||||
@@ -304,7 +304,7 @@ AUTHENTICATION_BACKENDS = CONFIG.get('authentication_backends', [
|
|||||||
])
|
])
|
||||||
|
|
||||||
# If the debug toolbar is enabled, add the modules
|
# If the debug toolbar is enabled, add the modules
|
||||||
if DEBUG and CONFIG.get('debug_toolbar', False):
|
if DEBUG and CONFIG.get('debug_toolbar', False): # pragma: no cover
|
||||||
logger.info("Running with DEBUG_TOOLBAR enabled")
|
logger.info("Running with DEBUG_TOOLBAR enabled")
|
||||||
INSTALLED_APPS.append('debug_toolbar')
|
INSTALLED_APPS.append('debug_toolbar')
|
||||||
MIDDLEWARE.append('debug_toolbar.middleware.DebugToolbarMiddleware')
|
MIDDLEWARE.append('debug_toolbar.middleware.DebugToolbarMiddleware')
|
||||||
@@ -396,7 +396,7 @@ for key in db_keys:
|
|||||||
reqiured_keys = ['ENGINE', 'NAME']
|
reqiured_keys = ['ENGINE', 'NAME']
|
||||||
|
|
||||||
for key in reqiured_keys:
|
for key in reqiured_keys:
|
||||||
if key not in db_config:
|
if key not in db_config: # pragma: no cover
|
||||||
error_msg = f'Missing required database configuration value {key}'
|
error_msg = f'Missing required database configuration value {key}'
|
||||||
logger.error(error_msg)
|
logger.error(error_msg)
|
||||||
|
|
||||||
@@ -415,7 +415,7 @@ db_engine = db_config['ENGINE'].lower()
|
|||||||
|
|
||||||
# Correct common misspelling
|
# Correct common misspelling
|
||||||
if db_engine == 'sqlite':
|
if db_engine == 'sqlite':
|
||||||
db_engine = 'sqlite3'
|
db_engine = 'sqlite3' # pragma: no cover
|
||||||
|
|
||||||
if db_engine in ['sqlite3', 'postgresql', 'mysql']:
|
if db_engine in ['sqlite3', 'postgresql', 'mysql']:
|
||||||
# Prepend the required python module string
|
# Prepend the required python module string
|
||||||
@@ -443,7 +443,7 @@ Ref: https://docs.djangoproject.com/en/3.2/ref/settings/#std:setting-OPTIONS
|
|||||||
db_options = db_config.get("OPTIONS", db_config.get("options", {}))
|
db_options = db_config.get("OPTIONS", db_config.get("options", {}))
|
||||||
|
|
||||||
# Specific options for postgres backend
|
# Specific options for postgres backend
|
||||||
if "postgres" in db_engine:
|
if "postgres" in db_engine: # pragma: no cover
|
||||||
from psycopg2.extensions import (
|
from psycopg2.extensions import (
|
||||||
ISOLATION_LEVEL_READ_COMMITTED,
|
ISOLATION_LEVEL_READ_COMMITTED,
|
||||||
ISOLATION_LEVEL_SERIALIZABLE,
|
ISOLATION_LEVEL_SERIALIZABLE,
|
||||||
@@ -505,7 +505,7 @@ if "postgres" in db_engine:
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Specific options for MySql / MariaDB backend
|
# Specific options for MySql / MariaDB backend
|
||||||
if "mysql" in db_engine:
|
if "mysql" in db_engine: # pragma: no cover
|
||||||
# TODO TCP time outs and keepalives
|
# TODO TCP time outs and keepalives
|
||||||
|
|
||||||
# MariaDB's default isolation level is Repeatable Read which is
|
# MariaDB's default isolation level is Repeatable Read which is
|
||||||
@@ -546,7 +546,7 @@ _cache_port = _cache_config.get(
|
|||||||
"port", os.getenv("INVENTREE_CACHE_PORT", "6379")
|
"port", os.getenv("INVENTREE_CACHE_PORT", "6379")
|
||||||
)
|
)
|
||||||
|
|
||||||
if _cache_host:
|
if _cache_host: # pragma: no cover
|
||||||
# We are going to rely upon a possibly non-localhost for our cache,
|
# We are going to rely upon a possibly non-localhost for our cache,
|
||||||
# so don't wait too long for the cache as nothing in the cache should be
|
# so don't wait too long for the cache as nothing in the cache should be
|
||||||
# irreplacable.
|
# irreplacable.
|
||||||
@@ -591,7 +591,7 @@ else:
|
|||||||
try:
|
try:
|
||||||
# 4 background workers seems like a sensible default
|
# 4 background workers seems like a sensible default
|
||||||
background_workers = int(os.environ.get('INVENTREE_BACKGROUND_WORKERS', 4))
|
background_workers = int(os.environ.get('INVENTREE_BACKGROUND_WORKERS', 4))
|
||||||
except ValueError:
|
except ValueError: # pragma: no cover
|
||||||
background_workers = 4
|
background_workers = 4
|
||||||
|
|
||||||
# django-q configuration
|
# django-q configuration
|
||||||
@@ -606,7 +606,7 @@ Q_CLUSTER = {
|
|||||||
'sync': False,
|
'sync': False,
|
||||||
}
|
}
|
||||||
|
|
||||||
if _cache_host:
|
if _cache_host: # pragma: no cover
|
||||||
# If using external redis cache, make the cache the broker for Django Q
|
# If using external redis cache, make the cache the broker for Django Q
|
||||||
# as well
|
# as well
|
||||||
Q_CLUSTER["django_redis"] = "worker"
|
Q_CLUSTER["django_redis"] = "worker"
|
||||||
@@ -641,7 +641,7 @@ AUTH_PASSWORD_VALIDATORS = [
|
|||||||
|
|
||||||
EXTRA_URL_SCHEMES = CONFIG.get('extra_url_schemes', [])
|
EXTRA_URL_SCHEMES = CONFIG.get('extra_url_schemes', [])
|
||||||
|
|
||||||
if not type(EXTRA_URL_SCHEMES) in [list]:
|
if not type(EXTRA_URL_SCHEMES) in [list]: # pragma: no cover
|
||||||
logger.warning("extra_url_schemes not correctly formatted")
|
logger.warning("extra_url_schemes not correctly formatted")
|
||||||
EXTRA_URL_SCHEMES = []
|
EXTRA_URL_SCHEMES = []
|
||||||
|
|
||||||
@@ -659,6 +659,7 @@ LANGUAGES = [
|
|||||||
('es-mx', _('Spanish (Mexican)')),
|
('es-mx', _('Spanish (Mexican)')),
|
||||||
('fr', _('French')),
|
('fr', _('French')),
|
||||||
('he', _('Hebrew')),
|
('he', _('Hebrew')),
|
||||||
|
('hu', _('Hungarian')),
|
||||||
('it', _('Italian')),
|
('it', _('Italian')),
|
||||||
('ja', _('Japanese')),
|
('ja', _('Japanese')),
|
||||||
('ko', _('Korean')),
|
('ko', _('Korean')),
|
||||||
@@ -675,7 +676,7 @@ LANGUAGES = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
# Testing interface translations
|
# Testing interface translations
|
||||||
if get_setting('TEST_TRANSLATIONS', False):
|
if get_setting('TEST_TRANSLATIONS', False): # pragma: no cover
|
||||||
# Set default language
|
# Set default language
|
||||||
LANGUAGE_CODE = 'xx'
|
LANGUAGE_CODE = 'xx'
|
||||||
|
|
||||||
@@ -703,7 +704,7 @@ CURRENCIES = CONFIG.get(
|
|||||||
|
|
||||||
# Check that each provided currency is supported
|
# Check that each provided currency is supported
|
||||||
for currency in CURRENCIES:
|
for currency in CURRENCIES:
|
||||||
if currency not in moneyed.CURRENCIES:
|
if currency not in moneyed.CURRENCIES: # pragma: no cover
|
||||||
print(f"Currency code '{currency}' is not supported")
|
print(f"Currency code '{currency}' is not supported")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
@@ -777,7 +778,7 @@ USE_L10N = True
|
|||||||
# Do not use native timezone support in "test" mode
|
# Do not use native timezone support in "test" mode
|
||||||
# It generates a *lot* of cruft in the logs
|
# It generates a *lot* of cruft in the logs
|
||||||
if not TESTING:
|
if not TESTING:
|
||||||
USE_TZ = True
|
USE_TZ = True # pragma: no cover
|
||||||
|
|
||||||
DATE_INPUT_FORMATS = [
|
DATE_INPUT_FORMATS = [
|
||||||
"%Y-%m-%d",
|
"%Y-%m-%d",
|
||||||
@@ -805,7 +806,7 @@ SITE_ID = 1
|
|||||||
# Load the allauth social backends
|
# Load the allauth social backends
|
||||||
SOCIAL_BACKENDS = CONFIG.get('social_backends', [])
|
SOCIAL_BACKENDS = CONFIG.get('social_backends', [])
|
||||||
for app in SOCIAL_BACKENDS:
|
for app in SOCIAL_BACKENDS:
|
||||||
INSTALLED_APPS.append(app)
|
INSTALLED_APPS.append(app) # pragma: no cover
|
||||||
|
|
||||||
SOCIALACCOUNT_PROVIDERS = CONFIG.get('social_providers', [])
|
SOCIALACCOUNT_PROVIDERS = CONFIG.get('social_providers', [])
|
||||||
|
|
||||||
@@ -879,7 +880,7 @@ PLUGIN_DIRS = ['plugin.builtin', 'barcodes.plugins', ]
|
|||||||
|
|
||||||
if not TESTING:
|
if not TESTING:
|
||||||
# load local deploy directory in prod
|
# load local deploy directory in prod
|
||||||
PLUGIN_DIRS.append('plugins')
|
PLUGIN_DIRS.append('plugins') # pragma: no cover
|
||||||
|
|
||||||
if DEBUG or TESTING:
|
if DEBUG or TESTING:
|
||||||
# load samples in debug mode
|
# load samples in debug mode
|
||||||
|
|||||||
@@ -60,21 +60,21 @@ def is_email_configured():
|
|||||||
configured = False
|
configured = False
|
||||||
|
|
||||||
# Display warning unless in test mode
|
# Display warning unless in test mode
|
||||||
if not settings.TESTING:
|
if not settings.TESTING: # pragma: no cover
|
||||||
logger.debug("EMAIL_HOST is not configured")
|
logger.debug("EMAIL_HOST is not configured")
|
||||||
|
|
||||||
if not settings.EMAIL_HOST_USER:
|
if not settings.EMAIL_HOST_USER:
|
||||||
configured = False
|
configured = False
|
||||||
|
|
||||||
# Display warning unless in test mode
|
# Display warning unless in test mode
|
||||||
if not settings.TESTING:
|
if not settings.TESTING: # pragma: no cover
|
||||||
logger.debug("EMAIL_HOST_USER is not configured")
|
logger.debug("EMAIL_HOST_USER is not configured")
|
||||||
|
|
||||||
if not settings.EMAIL_HOST_PASSWORD:
|
if not settings.EMAIL_HOST_PASSWORD:
|
||||||
configured = False
|
configured = False
|
||||||
|
|
||||||
# Display warning unless in test mode
|
# Display warning unless in test mode
|
||||||
if not settings.TESTING:
|
if not settings.TESTING: # pragma: no cover
|
||||||
logger.debug("EMAIL_HOST_PASSWORD is not configured")
|
logger.debug("EMAIL_HOST_PASSWORD is not configured")
|
||||||
|
|
||||||
return configured
|
return configured
|
||||||
@@ -89,15 +89,15 @@ def check_system_health(**kwargs):
|
|||||||
|
|
||||||
result = True
|
result = True
|
||||||
|
|
||||||
if not is_worker_running(**kwargs):
|
if not is_worker_running(**kwargs): # pragma: no cover
|
||||||
result = False
|
result = False
|
||||||
logger.warning(_("Background worker check failed"))
|
logger.warning(_("Background worker check failed"))
|
||||||
|
|
||||||
if not is_email_configured():
|
if not is_email_configured(): # pragma: no cover
|
||||||
result = False
|
result = False
|
||||||
logger.warning(_("Email backend not configured"))
|
logger.warning(_("Email backend not configured"))
|
||||||
|
|
||||||
if not result:
|
if not result: # pragma: no cover
|
||||||
logger.warning(_("InvenTree system health checks failed"))
|
logger.warning(_("InvenTree system health checks failed"))
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|||||||
@@ -258,6 +258,7 @@ class StockHistoryCode(StatusCode):
|
|||||||
# Build order codes
|
# Build order codes
|
||||||
BUILD_OUTPUT_CREATED = 50
|
BUILD_OUTPUT_CREATED = 50
|
||||||
BUILD_OUTPUT_COMPLETED = 55
|
BUILD_OUTPUT_COMPLETED = 55
|
||||||
|
BUILD_CONSUMED = 57
|
||||||
|
|
||||||
# Sales order codes
|
# Sales order codes
|
||||||
|
|
||||||
@@ -298,6 +299,7 @@ class StockHistoryCode(StatusCode):
|
|||||||
|
|
||||||
BUILD_OUTPUT_CREATED: _('Build order output created'),
|
BUILD_OUTPUT_CREATED: _('Build order output created'),
|
||||||
BUILD_OUTPUT_COMPLETED: _('Build order output completed'),
|
BUILD_OUTPUT_COMPLETED: _('Build order output completed'),
|
||||||
|
BUILD_CONSUMED: _('Consumed by build order'),
|
||||||
|
|
||||||
RECEIVED_AGAINST_PURCHASE_ORDER: _('Received against purchase order')
|
RECEIVED_AGAINST_PURCHASE_ORDER: _('Received against purchase order')
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ def schedule_task(taskname, **kwargs):
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
from django_q.models import Schedule
|
from django_q.models import Schedule
|
||||||
except (AppRegistryNotReady):
|
except AppRegistryNotReady: # pragma: no cover
|
||||||
logger.info("Could not start background tasks - App registry not ready")
|
logger.info("Could not start background tasks - App registry not ready")
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -47,7 +47,7 @@ def schedule_task(taskname, **kwargs):
|
|||||||
func=taskname,
|
func=taskname,
|
||||||
**kwargs
|
**kwargs
|
||||||
)
|
)
|
||||||
except (OperationalError, ProgrammingError):
|
except (OperationalError, ProgrammingError): # pragma: no cover
|
||||||
# Required if the DB is not ready yet
|
# Required if the DB is not ready yet
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -108,10 +108,10 @@ def offload_task(taskname, *args, force_sync=False, **kwargs):
|
|||||||
# Workers are not running: run it as synchronous task
|
# Workers are not running: run it as synchronous task
|
||||||
_func(*args, **kwargs)
|
_func(*args, **kwargs)
|
||||||
|
|
||||||
except (AppRegistryNotReady):
|
except AppRegistryNotReady: # pragma: no cover
|
||||||
logger.warning(f"Could not offload task '{taskname}' - app registry not ready")
|
logger.warning(f"Could not offload task '{taskname}' - app registry not ready")
|
||||||
return
|
return
|
||||||
except (OperationalError, ProgrammingError):
|
except (OperationalError, ProgrammingError): # pragma: no cover
|
||||||
logger.warning(f"Could not offload task '{taskname}' - database not ready")
|
logger.warning(f"Could not offload task '{taskname}' - database not ready")
|
||||||
|
|
||||||
|
|
||||||
@@ -127,7 +127,7 @@ def heartbeat():
|
|||||||
try:
|
try:
|
||||||
from django_q.models import Success
|
from django_q.models import Success
|
||||||
logger.info("Could not perform heartbeat task - App registry not ready")
|
logger.info("Could not perform heartbeat task - App registry not ready")
|
||||||
except AppRegistryNotReady:
|
except AppRegistryNotReady: # pragma: no cover
|
||||||
return
|
return
|
||||||
|
|
||||||
threshold = timezone.now() - timedelta(minutes=30)
|
threshold = timezone.now() - timedelta(minutes=30)
|
||||||
@@ -150,7 +150,7 @@ def delete_successful_tasks():
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
from django_q.models import Success
|
from django_q.models import Success
|
||||||
except AppRegistryNotReady:
|
except AppRegistryNotReady: # pragma: no cover
|
||||||
logger.info("Could not perform 'delete_successful_tasks' - App registry not ready")
|
logger.info("Could not perform 'delete_successful_tasks' - App registry not ready")
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -184,7 +184,7 @@ def delete_old_error_logs():
|
|||||||
logger.info(f"Deleting {errors.count()} old error logs")
|
logger.info(f"Deleting {errors.count()} old error logs")
|
||||||
errors.delete()
|
errors.delete()
|
||||||
|
|
||||||
except AppRegistryNotReady:
|
except AppRegistryNotReady: # pragma: no cover
|
||||||
# Apps not yet loaded
|
# Apps not yet loaded
|
||||||
logger.info("Could not perform 'delete_old_error_logs' - App registry not ready")
|
logger.info("Could not perform 'delete_old_error_logs' - App registry not ready")
|
||||||
return
|
return
|
||||||
@@ -197,7 +197,7 @@ def check_for_updates():
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
import common.models
|
import common.models
|
||||||
except AppRegistryNotReady:
|
except AppRegistryNotReady: # pragma: no cover
|
||||||
# Apps not yet loaded!
|
# Apps not yet loaded!
|
||||||
logger.info("Could not perform 'check_for_updates' - App registry not ready")
|
logger.info("Could not perform 'check_for_updates' - App registry not ready")
|
||||||
return
|
return
|
||||||
@@ -244,7 +244,7 @@ def update_exchange_rates():
|
|||||||
from InvenTree.exchange import InvenTreeExchange
|
from InvenTree.exchange import InvenTreeExchange
|
||||||
from djmoney.contrib.exchange.models import ExchangeBackend, Rate
|
from djmoney.contrib.exchange.models import ExchangeBackend, Rate
|
||||||
from common.settings import currency_code_default, currency_codes
|
from common.settings import currency_code_default, currency_codes
|
||||||
except AppRegistryNotReady:
|
except AppRegistryNotReady: # pragma: no cover
|
||||||
# Apps not yet loaded!
|
# Apps not yet loaded!
|
||||||
logger.info("Could not perform 'update_exchange_rates' - App registry not ready")
|
logger.info("Could not perform 'update_exchange_rates' - App registry not ready")
|
||||||
return
|
return
|
||||||
@@ -269,10 +269,13 @@ def update_exchange_rates():
|
|||||||
|
|
||||||
logger.info(f"Using base currency '{base}'")
|
logger.info(f"Using base currency '{base}'")
|
||||||
|
|
||||||
|
try:
|
||||||
backend.update_rates(base_currency=base)
|
backend.update_rates(base_currency=base)
|
||||||
|
|
||||||
# Remove any exchange rates which are not in the provided currencies
|
# Remove any exchange rates which are not in the provided currencies
|
||||||
Rate.objects.filter(backend="InvenTreeExchange").exclude(currency__in=currency_codes()).delete()
|
Rate.objects.filter(backend="InvenTreeExchange").exclude(currency__in=currency_codes()).delete()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error updating exchange rates: {e}")
|
||||||
|
|
||||||
|
|
||||||
def send_email(subject, body, recipients, from_email=None, html_message=None):
|
def send_email(subject, body, recipients, from_email=None, html_message=None):
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ class URLTest(TestCase):
|
|||||||
result[0].strip(),
|
result[0].strip(),
|
||||||
result[1].strip()
|
result[1].strip()
|
||||||
])
|
])
|
||||||
elif len(result) == 1:
|
elif len(result) == 1: # pragma: no cover
|
||||||
urls.append([
|
urls.append([
|
||||||
result[0].strip(),
|
result[0].strip(),
|
||||||
''
|
''
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
""" Unit tests for the main web views """
|
"""
|
||||||
|
Unit tests for the main web views
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
import os
|
||||||
|
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
|
|
||||||
import os
|
|
||||||
|
|
||||||
|
|
||||||
class ViewTests(TestCase):
|
class ViewTests(TestCase):
|
||||||
""" Tests for various top-level views """
|
""" Tests for various top-level views """
|
||||||
@@ -16,9 +19,13 @@ class ViewTests(TestCase):
|
|||||||
def setUp(self):
|
def setUp(self):
|
||||||
|
|
||||||
# Create a user
|
# Create a user
|
||||||
get_user_model().objects.create_user(self.username, 'user@email.com', self.password)
|
self.user = get_user_model().objects.create_user(self.username, 'user@email.com', self.password)
|
||||||
|
self.user.set_password(self.password)
|
||||||
|
self.user.save()
|
||||||
|
|
||||||
self.client.login(username=self.username, password=self.password)
|
result = self.client.login(username=self.username, password=self.password)
|
||||||
|
|
||||||
|
self.assertEqual(result, True)
|
||||||
|
|
||||||
def test_api_doc(self):
|
def test_api_doc(self):
|
||||||
""" Test that the api-doc view works """
|
""" Test that the api-doc view works """
|
||||||
@@ -27,3 +34,51 @@ class ViewTests(TestCase):
|
|||||||
|
|
||||||
response = self.client.get(api_url)
|
response = self.client.get(api_url)
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
def test_index_redirect(self):
|
||||||
|
"""
|
||||||
|
top-level URL should redirect to "index" page
|
||||||
|
"""
|
||||||
|
|
||||||
|
response = self.client.get("/")
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
|
||||||
|
def get_index_page(self):
|
||||||
|
"""
|
||||||
|
Retrieve the index page (used for subsequent unit tests)
|
||||||
|
"""
|
||||||
|
|
||||||
|
response = self.client.get("/index/")
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
return str(response.content.decode())
|
||||||
|
|
||||||
|
def test_panels(self):
|
||||||
|
"""
|
||||||
|
Test that the required 'panels' are present
|
||||||
|
"""
|
||||||
|
|
||||||
|
content = self.get_index_page()
|
||||||
|
|
||||||
|
self.assertIn("<div id='detail-panels'>", content)
|
||||||
|
|
||||||
|
# TODO: In future, run the javascript and ensure that the panels get created!
|
||||||
|
|
||||||
|
def test_js_load(self):
|
||||||
|
"""
|
||||||
|
Test that the required javascript files are loaded correctly
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Change this number as more javascript files are added to the index page
|
||||||
|
N_SCRIPT_FILES = 35
|
||||||
|
|
||||||
|
content = self.get_index_page()
|
||||||
|
|
||||||
|
# Extract all required javascript files from the index page content
|
||||||
|
script_files = re.findall("<script type='text\\/javascript' src=\"([^\"]*)\"><\\/script>", content)
|
||||||
|
|
||||||
|
self.assertEqual(len(script_files), N_SCRIPT_FILES)
|
||||||
|
|
||||||
|
# TODO: Request the javascript files from the server, and ensure they are correcty loaded
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ from djmoney.contrib.exchange.exceptions import MissingRate
|
|||||||
from .validators import validate_overage, validate_part_name
|
from .validators import validate_overage, validate_part_name
|
||||||
from . import helpers
|
from . import helpers
|
||||||
from . import version
|
from . import version
|
||||||
|
from . import status
|
||||||
|
from . import ready
|
||||||
|
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
|
||||||
@@ -389,3 +391,19 @@ class CurrencyTests(TestCase):
|
|||||||
# Convert to a symbol which is not covered
|
# Convert to a symbol which is not covered
|
||||||
with self.assertRaises(MissingRate):
|
with self.assertRaises(MissingRate):
|
||||||
convert_money(Money(100, 'GBP'), 'ZWL')
|
convert_money(Money(100, 'GBP'), 'ZWL')
|
||||||
|
|
||||||
|
|
||||||
|
class TestStatus(TestCase):
|
||||||
|
"""
|
||||||
|
Unit tests for status functions
|
||||||
|
"""
|
||||||
|
|
||||||
|
def test_check_system_healt(self):
|
||||||
|
"""test that the system health check is false in testing -> background worker not running"""
|
||||||
|
self.assertEqual(status.check_system_health(), False)
|
||||||
|
|
||||||
|
def test_TestMode(self):
|
||||||
|
self.assertTrue(ready.isInTestMode())
|
||||||
|
|
||||||
|
def test_Importing(self):
|
||||||
|
self.assertEqual(ready.isImportingData(), False)
|
||||||
|
|||||||
@@ -204,7 +204,7 @@ if settings.DEBUG:
|
|||||||
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
|
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
|
||||||
|
|
||||||
# Debug toolbar access (only allowed in DEBUG mode)
|
# Debug toolbar access (only allowed in DEBUG mode)
|
||||||
if 'debug_toolbar' in settings.INSTALLED_APPS:
|
if 'debug_toolbar' in settings.INSTALLED_APPS: # pragma: no cover
|
||||||
import debug_toolbar
|
import debug_toolbar
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path('__debug/', include(debug_toolbar.urls)),
|
path('__debug/', include(debug_toolbar.urls)),
|
||||||
|
|||||||
@@ -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")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -9,14 +9,23 @@ import re
|
|||||||
import common.models
|
import common.models
|
||||||
|
|
||||||
# InvenTree software version
|
# InvenTree software version
|
||||||
INVENTREE_SW_VERSION = "0.6.0 dev"
|
INVENTREE_SW_VERSION = "0.7.0 dev"
|
||||||
|
|
||||||
# InvenTree API version
|
# InvenTree API version
|
||||||
INVENTREE_API_VERSION = 23
|
INVENTREE_API_VERSION = 26
|
||||||
|
|
||||||
"""
|
"""
|
||||||
Increment this API version number whenever there is a significant change to the API that any clients need to know about
|
Increment this API version number whenever there is a significant change to the API that any clients need to know about
|
||||||
|
|
||||||
|
v26 -> 2022-02-17
|
||||||
|
- Adds API endpoint for uploading a BOM file and extracting data
|
||||||
|
|
||||||
|
v25 -> 2022-02-17
|
||||||
|
- Adds ability to filter "part" list endpoint by "in_bom_for" argument
|
||||||
|
|
||||||
|
v24 -> 2022-02-10
|
||||||
|
- Adds API endpoint for deleting (cancelling) build order outputs
|
||||||
|
|
||||||
v23 -> 2022-02-02
|
v23 -> 2022-02-02
|
||||||
- Adds API endpoints for managing plugin classes
|
- Adds API endpoints for managing plugin classes
|
||||||
- Adds API endpoints for managing plugin settings
|
- Adds API endpoints for managing plugin settings
|
||||||
|
|||||||
@@ -232,6 +232,29 @@ class BuildUnallocate(generics.CreateAPIView):
|
|||||||
return ctx
|
return ctx
|
||||||
|
|
||||||
|
|
||||||
|
class BuildOutputCreate(generics.CreateAPIView):
|
||||||
|
"""
|
||||||
|
API endpoint for creating new build output(s)
|
||||||
|
"""
|
||||||
|
|
||||||
|
queryset = Build.objects.none()
|
||||||
|
|
||||||
|
serializer_class = build.serializers.BuildOutputCreateSerializer
|
||||||
|
|
||||||
|
def get_serializer_context(self):
|
||||||
|
ctx = super().get_serializer_context()
|
||||||
|
|
||||||
|
ctx['request'] = self.request
|
||||||
|
ctx['to_complete'] = True
|
||||||
|
|
||||||
|
try:
|
||||||
|
ctx['build'] = Build.objects.get(pk=self.kwargs.get('pk', None))
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return ctx
|
||||||
|
|
||||||
|
|
||||||
class BuildOutputComplete(generics.CreateAPIView):
|
class BuildOutputComplete(generics.CreateAPIView):
|
||||||
"""
|
"""
|
||||||
API endpoint for completing build outputs
|
API endpoint for completing build outputs
|
||||||
@@ -241,6 +264,29 @@ class BuildOutputComplete(generics.CreateAPIView):
|
|||||||
|
|
||||||
serializer_class = build.serializers.BuildOutputCompleteSerializer
|
serializer_class = build.serializers.BuildOutputCompleteSerializer
|
||||||
|
|
||||||
|
def get_serializer_context(self):
|
||||||
|
ctx = super().get_serializer_context()
|
||||||
|
|
||||||
|
ctx['request'] = self.request
|
||||||
|
ctx['to_complete'] = True
|
||||||
|
|
||||||
|
try:
|
||||||
|
ctx['build'] = Build.objects.get(pk=self.kwargs.get('pk', None))
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return ctx
|
||||||
|
|
||||||
|
|
||||||
|
class BuildOutputDelete(generics.CreateAPIView):
|
||||||
|
"""
|
||||||
|
API endpoint for deleting multiple build outputs
|
||||||
|
"""
|
||||||
|
|
||||||
|
queryset = Build.objects.none()
|
||||||
|
|
||||||
|
serializer_class = build.serializers.BuildOutputDeleteSerializer
|
||||||
|
|
||||||
def get_serializer_context(self):
|
def get_serializer_context(self):
|
||||||
ctx = super().get_serializer_context()
|
ctx = super().get_serializer_context()
|
||||||
|
|
||||||
@@ -432,6 +478,8 @@ build_api_urls = [
|
|||||||
url(r'^(?P<pk>\d+)/', include([
|
url(r'^(?P<pk>\d+)/', include([
|
||||||
url(r'^allocate/', BuildAllocate.as_view(), name='api-build-allocate'),
|
url(r'^allocate/', BuildAllocate.as_view(), name='api-build-allocate'),
|
||||||
url(r'^complete/', BuildOutputComplete.as_view(), name='api-build-output-complete'),
|
url(r'^complete/', BuildOutputComplete.as_view(), name='api-build-output-complete'),
|
||||||
|
url(r'^create-output/', BuildOutputCreate.as_view(), name='api-build-output-create'),
|
||||||
|
url(r'^delete-outputs/', BuildOutputDelete.as_view(), name='api-build-output-delete'),
|
||||||
url(r'^finish/', BuildFinish.as_view(), name='api-build-finish'),
|
url(r'^finish/', BuildFinish.as_view(), name='api-build-finish'),
|
||||||
url(r'^unallocate/', BuildUnallocate.as_view(), name='api-build-unallocate'),
|
url(r'^unallocate/', BuildUnallocate.as_view(), name='api-build-unallocate'),
|
||||||
url(r'^.*$', BuildDetail.as_view(), name='api-build-detail'),
|
url(r'^.*$', BuildDetail.as_view(), name='api-build-detail'),
|
||||||
|
|||||||
@@ -14,75 +14,6 @@ from InvenTree.forms import HelperForm
|
|||||||
from .models import Build
|
from .models import Build
|
||||||
|
|
||||||
|
|
||||||
class BuildOutputCreateForm(HelperForm):
|
|
||||||
"""
|
|
||||||
Form for creating a new build output.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
|
||||||
|
|
||||||
build = kwargs.pop('build', None)
|
|
||||||
|
|
||||||
if build:
|
|
||||||
self.field_placeholder['serial_numbers'] = build.part.getSerialNumberString()
|
|
||||||
|
|
||||||
super().__init__(*args, **kwargs)
|
|
||||||
|
|
||||||
field_prefix = {
|
|
||||||
'serial_numbers': 'fa-hashtag',
|
|
||||||
}
|
|
||||||
|
|
||||||
output_quantity = forms.IntegerField(
|
|
||||||
label=_('Quantity'),
|
|
||||||
help_text=_('Enter quantity for build output'),
|
|
||||||
)
|
|
||||||
|
|
||||||
serial_numbers = forms.CharField(
|
|
||||||
label=_('Serial Numbers'),
|
|
||||||
required=False,
|
|
||||||
help_text=_('Enter serial numbers for build outputs'),
|
|
||||||
)
|
|
||||||
|
|
||||||
confirm = forms.BooleanField(
|
|
||||||
required=True,
|
|
||||||
label=_('Confirm'),
|
|
||||||
help_text=_('Confirm creation of build output'),
|
|
||||||
)
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
model = Build
|
|
||||||
fields = [
|
|
||||||
'output_quantity',
|
|
||||||
'batch',
|
|
||||||
'serial_numbers',
|
|
||||||
'confirm',
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
class BuildOutputDeleteForm(HelperForm):
|
|
||||||
"""
|
|
||||||
Form for deleting a build output.
|
|
||||||
"""
|
|
||||||
|
|
||||||
confirm = forms.BooleanField(
|
|
||||||
required=False,
|
|
||||||
label=_('Confirm'),
|
|
||||||
help_text=_('Confirm deletion of build output')
|
|
||||||
)
|
|
||||||
|
|
||||||
output_id = forms.IntegerField(
|
|
||||||
required=True,
|
|
||||||
widget=forms.HiddenInput()
|
|
||||||
)
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
model = Build
|
|
||||||
fields = [
|
|
||||||
'confirm',
|
|
||||||
'output_id',
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
class CancelBuildForm(HelperForm):
|
class CancelBuildForm(HelperForm):
|
||||||
""" Form for cancelling a build """
|
""" Form for cancelling a build """
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ def update_tree(apps, schema_editor):
|
|||||||
Build.objects.rebuild()
|
Build.objects.rebuild()
|
||||||
|
|
||||||
|
|
||||||
def nupdate_tree(apps, schema_editor):
|
def nupdate_tree(apps, schema_editor): # pragma: no cover
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ def add_default_reference(apps, schema_editor):
|
|||||||
print(f"\nUpdated build reference for {count} existing BuildOrder objects")
|
print(f"\nUpdated build reference for {count} existing BuildOrder objects")
|
||||||
|
|
||||||
|
|
||||||
def reverse_default_reference(apps, schema_editor):
|
def reverse_default_reference(apps, schema_editor): # pragma: no cover
|
||||||
"""
|
"""
|
||||||
Do nothing! But we need to have a function here so the whole process is reversible.
|
Do nothing! But we need to have a function here so the whole process is reversible.
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ def assign_bom_items(apps, schema_editor):
|
|||||||
count_valid = 0
|
count_valid = 0
|
||||||
count_total = 0
|
count_total = 0
|
||||||
|
|
||||||
for build_item in BuildItem.objects.all():
|
for build_item in BuildItem.objects.all(): # pragma: no cover
|
||||||
|
|
||||||
# Try to find a BomItem which matches the BuildItem
|
# Try to find a BomItem which matches the BuildItem
|
||||||
# Note: Before this migration, variant stock assignment was not allowed,
|
# Note: Before this migration, variant stock assignment was not allowed,
|
||||||
@@ -45,11 +45,11 @@ def assign_bom_items(apps, schema_editor):
|
|||||||
except BomItem.DoesNotExist:
|
except BomItem.DoesNotExist:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
if count_total > 0:
|
if count_total > 0: # pragma: no cover
|
||||||
logger.info(f"Assigned BomItem for {count_valid}/{count_total} entries")
|
logger.info(f"Assigned BomItem for {count_valid}/{count_total} entries")
|
||||||
|
|
||||||
|
|
||||||
def unassign_bom_items(apps, schema_editor):
|
def unassign_bom_items(apps, schema_editor): # pragma: no cover
|
||||||
"""
|
"""
|
||||||
Reverse migration does not do anything.
|
Reverse migration does not do anything.
|
||||||
Function here to preserve ability to reverse migration
|
Function here to preserve ability to reverse migration
|
||||||
|
|||||||
@@ -21,13 +21,13 @@ def build_refs(apps, schema_editor):
|
|||||||
if result and len(result.groups()) == 1:
|
if result and len(result.groups()) == 1:
|
||||||
try:
|
try:
|
||||||
ref = int(result.groups()[0])
|
ref = int(result.groups()[0])
|
||||||
except:
|
except: # pragma: no cover
|
||||||
ref = 0
|
ref = 0
|
||||||
|
|
||||||
build.reference_int = ref
|
build.reference_int = ref
|
||||||
build.save()
|
build.save()
|
||||||
|
|
||||||
def unbuild_refs(apps, schema_editor):
|
def unbuild_refs(apps, schema_editor): # pragma: no cover
|
||||||
"""
|
"""
|
||||||
Provided only for reverse migration compatibility
|
Provided only for reverse migration compatibility
|
||||||
"""
|
"""
|
||||||
|
|||||||
+78
-125
@@ -30,8 +30,6 @@ from InvenTree.helpers import increment, getSetting, normalize, MakeBarcode
|
|||||||
from InvenTree.models import InvenTreeAttachment, ReferenceIndexingMixin
|
from InvenTree.models import InvenTreeAttachment, ReferenceIndexingMixin
|
||||||
from InvenTree.validators import validate_build_order_reference
|
from InvenTree.validators import validate_build_order_reference
|
||||||
|
|
||||||
import common.models
|
|
||||||
|
|
||||||
import InvenTree.fields
|
import InvenTree.fields
|
||||||
import InvenTree.helpers
|
import InvenTree.helpers
|
||||||
import InvenTree.tasks
|
import InvenTree.tasks
|
||||||
@@ -479,8 +477,6 @@ class Build(MPTTModel, ReferenceIndexingMixin):
|
|||||||
|
|
||||||
outputs = self.get_build_outputs(complete=True)
|
outputs = self.get_build_outputs(complete=True)
|
||||||
|
|
||||||
# TODO - Ordering?
|
|
||||||
|
|
||||||
return outputs
|
return outputs
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -491,8 +487,6 @@ class Build(MPTTModel, ReferenceIndexingMixin):
|
|||||||
|
|
||||||
outputs = self.get_build_outputs(complete=False)
|
outputs = self.get_build_outputs(complete=False)
|
||||||
|
|
||||||
# TODO - Order by how "complete" they are?
|
|
||||||
|
|
||||||
return outputs
|
return outputs
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -563,7 +557,7 @@ class Build(MPTTModel, ReferenceIndexingMixin):
|
|||||||
if self.remaining > 0:
|
if self.remaining > 0:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
if not self.areUntrackedPartsFullyAllocated():
|
if not self.are_untracked_parts_allocated():
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# No issues!
|
# No issues!
|
||||||
@@ -584,7 +578,7 @@ class Build(MPTTModel, ReferenceIndexingMixin):
|
|||||||
self.save()
|
self.save()
|
||||||
|
|
||||||
# Remove untracked allocated stock
|
# Remove untracked allocated stock
|
||||||
self.subtractUntrackedStock(user)
|
self.subtract_allocated_stock(user)
|
||||||
|
|
||||||
# Ensure that there are no longer any BuildItem objects
|
# Ensure that there are no longer any BuildItem objects
|
||||||
# which point to thisFcan Build Order
|
# which point to thisFcan Build Order
|
||||||
@@ -646,11 +640,13 @@ class Build(MPTTModel, ReferenceIndexingMixin):
|
|||||||
batch: Override batch code
|
batch: Override batch code
|
||||||
serials: Serial numbers
|
serials: Serial numbers
|
||||||
location: Override location
|
location: Override location
|
||||||
|
auto_allocate: Automatically allocate stock with matching serial numbers
|
||||||
"""
|
"""
|
||||||
|
|
||||||
batch = kwargs.get('batch', self.batch)
|
batch = kwargs.get('batch', self.batch)
|
||||||
location = kwargs.get('location', self.destination)
|
location = kwargs.get('location', self.destination)
|
||||||
serials = kwargs.get('serials', None)
|
serials = kwargs.get('serials', None)
|
||||||
|
auto_allocate = kwargs.get('auto_allocate', False)
|
||||||
|
|
||||||
"""
|
"""
|
||||||
Determine if we can create a single output (with quantity > 0),
|
Determine if we can create a single output (with quantity > 0),
|
||||||
@@ -672,6 +668,9 @@ class Build(MPTTModel, ReferenceIndexingMixin):
|
|||||||
Create multiple build outputs with a single quantity of 1
|
Create multiple build outputs with a single quantity of 1
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
# Quantity *must* be an integer at this point!
|
||||||
|
quantity = int(quantity)
|
||||||
|
|
||||||
for ii in range(quantity):
|
for ii in range(quantity):
|
||||||
|
|
||||||
if serials:
|
if serials:
|
||||||
@@ -679,7 +678,7 @@ class Build(MPTTModel, ReferenceIndexingMixin):
|
|||||||
else:
|
else:
|
||||||
serial = None
|
serial = None
|
||||||
|
|
||||||
StockModels.StockItem.objects.create(
|
output = StockModels.StockItem.objects.create(
|
||||||
quantity=1,
|
quantity=1,
|
||||||
location=location,
|
location=location,
|
||||||
part=self.part,
|
part=self.part,
|
||||||
@@ -689,6 +688,37 @@ class Build(MPTTModel, ReferenceIndexingMixin):
|
|||||||
is_building=True,
|
is_building=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if auto_allocate and serial is not None:
|
||||||
|
|
||||||
|
# Get a list of BomItem objects which point to "trackable" parts
|
||||||
|
|
||||||
|
for bom_item in self.part.get_trackable_parts():
|
||||||
|
|
||||||
|
parts = bom_item.get_valid_parts_for_allocation()
|
||||||
|
|
||||||
|
for part in parts:
|
||||||
|
|
||||||
|
items = StockModels.StockItem.objects.filter(
|
||||||
|
part=part,
|
||||||
|
serial=str(serial),
|
||||||
|
quantity=1,
|
||||||
|
).filter(StockModels.StockItem.IN_STOCK_FILTER)
|
||||||
|
|
||||||
|
"""
|
||||||
|
Test if there is a matching serial number!
|
||||||
|
"""
|
||||||
|
if items.exists() and items.count() == 1:
|
||||||
|
stock_item = items[0]
|
||||||
|
|
||||||
|
# Allocate the stock item
|
||||||
|
BuildItem.objects.create(
|
||||||
|
build=self,
|
||||||
|
bom_item=bom_item,
|
||||||
|
stock_item=stock_item,
|
||||||
|
quantity=quantity,
|
||||||
|
install_into=output,
|
||||||
|
)
|
||||||
|
|
||||||
else:
|
else:
|
||||||
"""
|
"""
|
||||||
Create a single build output of the given quantity
|
Create a single build output of the given quantity
|
||||||
@@ -708,7 +738,7 @@ class Build(MPTTModel, ReferenceIndexingMixin):
|
|||||||
self.save()
|
self.save()
|
||||||
|
|
||||||
@transaction.atomic
|
@transaction.atomic
|
||||||
def deleteBuildOutput(self, output):
|
def delete_output(self, output):
|
||||||
"""
|
"""
|
||||||
Remove a build output from the database:
|
Remove a build output from the database:
|
||||||
|
|
||||||
@@ -732,7 +762,7 @@ class Build(MPTTModel, ReferenceIndexingMixin):
|
|||||||
output.delete()
|
output.delete()
|
||||||
|
|
||||||
@transaction.atomic
|
@transaction.atomic
|
||||||
def subtractUntrackedStock(self, user):
|
def subtract_allocated_stock(self, user):
|
||||||
"""
|
"""
|
||||||
Called when the Build is marked as "complete",
|
Called when the Build is marked as "complete",
|
||||||
this function removes the allocated untracked items from stock.
|
this function removes the allocated untracked items from stock.
|
||||||
@@ -795,7 +825,7 @@ class Build(MPTTModel, ReferenceIndexingMixin):
|
|||||||
|
|
||||||
self.save()
|
self.save()
|
||||||
|
|
||||||
def requiredQuantity(self, part, output):
|
def required_quantity(self, bom_item, output=None):
|
||||||
"""
|
"""
|
||||||
Get the quantity of a part required to complete the particular build output.
|
Get the quantity of a part required to complete the particular build output.
|
||||||
|
|
||||||
@@ -804,12 +834,7 @@ class Build(MPTTModel, ReferenceIndexingMixin):
|
|||||||
output - The particular build output (StockItem)
|
output - The particular build output (StockItem)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# Extract the BOM line item from the database
|
|
||||||
try:
|
|
||||||
bom_item = PartModels.BomItem.objects.get(part=self.part.pk, sub_part=part.pk)
|
|
||||||
quantity = bom_item.quantity
|
quantity = bom_item.quantity
|
||||||
except (PartModels.BomItem.DoesNotExist):
|
|
||||||
quantity = 0
|
|
||||||
|
|
||||||
if output:
|
if output:
|
||||||
quantity *= output.quantity
|
quantity *= output.quantity
|
||||||
@@ -818,32 +843,32 @@ class Build(MPTTModel, ReferenceIndexingMixin):
|
|||||||
|
|
||||||
return quantity
|
return quantity
|
||||||
|
|
||||||
def allocatedItems(self, part, output):
|
def allocated_bom_items(self, bom_item, output=None):
|
||||||
"""
|
"""
|
||||||
Return all BuildItem objects which allocate stock of <part> to <output>
|
Return all BuildItem objects which allocate stock of <bom_item> to <output>
|
||||||
|
|
||||||
|
Note that the bom_item may allow variants, or direct substitutes,
|
||||||
|
making things difficult.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
part - The part object
|
bom_item - The BomItem object
|
||||||
output - Build output (StockItem).
|
output - Build output (StockItem).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# Remember, if 'variant' stock is allowed to be allocated, it becomes more complicated!
|
|
||||||
variants = part.get_descendants(include_self=True)
|
|
||||||
|
|
||||||
allocations = BuildItem.objects.filter(
|
allocations = BuildItem.objects.filter(
|
||||||
build=self,
|
build=self,
|
||||||
stock_item__part__pk__in=[p.pk for p in variants],
|
bom_item=bom_item,
|
||||||
install_into=output,
|
install_into=output,
|
||||||
)
|
)
|
||||||
|
|
||||||
return allocations
|
return allocations
|
||||||
|
|
||||||
def allocatedQuantity(self, part, output):
|
def allocated_quantity(self, bom_item, output=None):
|
||||||
"""
|
"""
|
||||||
Return the total quantity of given part allocated to a given build output.
|
Return the total quantity of given part allocated to a given build output.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
allocations = self.allocatedItems(part, output)
|
allocations = self.allocated_bom_items(bom_item, output)
|
||||||
|
|
||||||
allocated = allocations.aggregate(
|
allocated = allocations.aggregate(
|
||||||
q=Coalesce(
|
q=Coalesce(
|
||||||
@@ -855,24 +880,24 @@ class Build(MPTTModel, ReferenceIndexingMixin):
|
|||||||
|
|
||||||
return allocated['q']
|
return allocated['q']
|
||||||
|
|
||||||
def unallocatedQuantity(self, part, output):
|
def unallocated_quantity(self, bom_item, output=None):
|
||||||
"""
|
"""
|
||||||
Return the total unallocated (remaining) quantity of a part against a particular output.
|
Return the total unallocated (remaining) quantity of a part against a particular output.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
required = self.requiredQuantity(part, output)
|
required = self.required_quantity(bom_item, output)
|
||||||
allocated = self.allocatedQuantity(part, output)
|
allocated = self.allocated_quantity(bom_item, output)
|
||||||
|
|
||||||
return max(required - allocated, 0)
|
return max(required - allocated, 0)
|
||||||
|
|
||||||
def isPartFullyAllocated(self, part, output):
|
def is_bom_item_allocated(self, bom_item, output=None):
|
||||||
"""
|
"""
|
||||||
Returns True if the part has been fully allocated to the particular build output
|
Test if the supplied BomItem has been fully allocated!
|
||||||
"""
|
"""
|
||||||
|
|
||||||
return self.unallocatedQuantity(part, output) == 0
|
return self.unallocated_quantity(bom_item, output) == 0
|
||||||
|
|
||||||
def isFullyAllocated(self, output, verbose=False):
|
def is_fully_allocated(self, output):
|
||||||
"""
|
"""
|
||||||
Returns True if the particular build output is fully allocated.
|
Returns True if the particular build output is fully allocated.
|
||||||
"""
|
"""
|
||||||
@@ -883,53 +908,24 @@ class Build(MPTTModel, ReferenceIndexingMixin):
|
|||||||
else:
|
else:
|
||||||
bom_items = self.tracked_bom_items
|
bom_items = self.tracked_bom_items
|
||||||
|
|
||||||
fully_allocated = True
|
|
||||||
|
|
||||||
for bom_item in bom_items:
|
for bom_item in bom_items:
|
||||||
part = bom_item.sub_part
|
|
||||||
|
|
||||||
if not self.isPartFullyAllocated(part, output):
|
if not self.is_bom_item_allocated(bom_item, output):
|
||||||
fully_allocated = False
|
return False
|
||||||
|
|
||||||
if verbose:
|
|
||||||
print(f"Part {part} is not fully allocated for output {output}")
|
|
||||||
else:
|
|
||||||
break
|
|
||||||
|
|
||||||
# All parts must be fully allocated!
|
# All parts must be fully allocated!
|
||||||
return fully_allocated
|
return True
|
||||||
|
|
||||||
def areUntrackedPartsFullyAllocated(self):
|
def are_untracked_parts_allocated(self):
|
||||||
"""
|
"""
|
||||||
Returns True if the un-tracked parts are fully allocated for this BuildOrder
|
Returns True if the un-tracked parts are fully allocated for this BuildOrder
|
||||||
"""
|
"""
|
||||||
|
|
||||||
return self.isFullyAllocated(None)
|
return self.is_fully_allocated(None)
|
||||||
|
|
||||||
def allocatedParts(self, output):
|
def unallocated_bom_items(self, output):
|
||||||
"""
|
"""
|
||||||
Return a list of parts which have been fully allocated against a particular output
|
Return a list of bom items which have *not* been fully allocated against a particular output
|
||||||
"""
|
|
||||||
|
|
||||||
allocated = []
|
|
||||||
|
|
||||||
# If output is not specified, we are talking about "untracked" items
|
|
||||||
if output is None:
|
|
||||||
bom_items = self.untracked_bom_items
|
|
||||||
else:
|
|
||||||
bom_items = self.tracked_bom_items
|
|
||||||
|
|
||||||
for bom_item in bom_items:
|
|
||||||
part = bom_item.sub_part
|
|
||||||
|
|
||||||
if self.isPartFullyAllocated(part, output):
|
|
||||||
allocated.append(part)
|
|
||||||
|
|
||||||
return allocated
|
|
||||||
|
|
||||||
def unallocatedParts(self, output):
|
|
||||||
"""
|
|
||||||
Return a list of parts which have *not* been fully allocated against a particular output
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
unallocated = []
|
unallocated = []
|
||||||
@@ -941,10 +937,9 @@ class Build(MPTTModel, ReferenceIndexingMixin):
|
|||||||
bom_items = self.tracked_bom_items
|
bom_items = self.tracked_bom_items
|
||||||
|
|
||||||
for bom_item in bom_items:
|
for bom_item in bom_items:
|
||||||
part = bom_item.sub_part
|
|
||||||
|
|
||||||
if not self.isPartFullyAllocated(part, output):
|
if not self.is_bom_item_allocated(bom_item, output):
|
||||||
unallocated.append(part)
|
unallocated.append(bom_item)
|
||||||
|
|
||||||
return unallocated
|
return unallocated
|
||||||
|
|
||||||
@@ -972,57 +967,6 @@ class Build(MPTTModel, ReferenceIndexingMixin):
|
|||||||
|
|
||||||
return parts
|
return parts
|
||||||
|
|
||||||
def availableStockItems(self, part, output):
|
|
||||||
"""
|
|
||||||
Returns stock items which are available for allocation to this build.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
part - Part object
|
|
||||||
output - The particular build output
|
|
||||||
"""
|
|
||||||
|
|
||||||
# Grab initial query for items which are "in stock" and match the part
|
|
||||||
items = StockModels.StockItem.objects.filter(
|
|
||||||
StockModels.StockItem.IN_STOCK_FILTER
|
|
||||||
)
|
|
||||||
|
|
||||||
# Check if variants are allowed for this part
|
|
||||||
try:
|
|
||||||
bom_item = PartModels.BomItem.objects.get(part=self.part, sub_part=part)
|
|
||||||
allow_part_variants = bom_item.allow_variants
|
|
||||||
except PartModels.BomItem.DoesNotExist:
|
|
||||||
allow_part_variants = False
|
|
||||||
|
|
||||||
if allow_part_variants:
|
|
||||||
parts = part.get_descendants(include_self=True)
|
|
||||||
items = items.filter(part__pk__in=[p.pk for p in parts])
|
|
||||||
|
|
||||||
else:
|
|
||||||
items = items.filter(part=part)
|
|
||||||
|
|
||||||
# Exclude any items which have already been allocated
|
|
||||||
allocated = BuildItem.objects.filter(
|
|
||||||
build=self,
|
|
||||||
stock_item__part=part,
|
|
||||||
install_into=output,
|
|
||||||
)
|
|
||||||
|
|
||||||
items = items.exclude(
|
|
||||||
id__in=[item.stock_item.id for item in allocated.all()]
|
|
||||||
)
|
|
||||||
|
|
||||||
# Limit query to stock items which are "downstream" of the source location
|
|
||||||
if self.take_from is not None:
|
|
||||||
items = items.filter(
|
|
||||||
location__in=[loc for loc in self.take_from.getUniqueChildren()]
|
|
||||||
)
|
|
||||||
|
|
||||||
# Exclude expired stock items
|
|
||||||
if not common.models.InvenTreeSetting.get_setting('STOCK_ALLOW_EXPIRED_BUILD'):
|
|
||||||
items = items.exclude(StockModels.StockItem.EXPIRED_FILTER)
|
|
||||||
|
|
||||||
return items
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_active(self):
|
def is_active(self):
|
||||||
""" Is this build active? An active build is either:
|
""" Is this build active? An active build is either:
|
||||||
@@ -1221,7 +1165,12 @@ class BuildItem(models.Model):
|
|||||||
if item.part.trackable:
|
if item.part.trackable:
|
||||||
# Split the allocated stock if there are more available than allocated
|
# Split the allocated stock if there are more available than allocated
|
||||||
if item.quantity > self.quantity:
|
if item.quantity > self.quantity:
|
||||||
item = item.splitStock(self.quantity, None, user)
|
item = item.splitStock(
|
||||||
|
self.quantity,
|
||||||
|
None,
|
||||||
|
user,
|
||||||
|
code=StockHistoryCode.BUILD_CONSUMED,
|
||||||
|
)
|
||||||
|
|
||||||
# Make sure we are pointing to the new item
|
# Make sure we are pointing to the new item
|
||||||
self.stock_item = item
|
self.stock_item = item
|
||||||
@@ -1232,7 +1181,11 @@ class BuildItem(models.Model):
|
|||||||
item.save()
|
item.save()
|
||||||
else:
|
else:
|
||||||
# Simply remove the items from stock
|
# Simply remove the items from stock
|
||||||
item.take_stock(self.quantity, user)
|
item.take_stock(
|
||||||
|
self.quantity,
|
||||||
|
user,
|
||||||
|
code=StockHistoryCode.BUILD_CONSUMED
|
||||||
|
)
|
||||||
|
|
||||||
def getStockItemThumbnail(self):
|
def getStockItemThumbnail(self):
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ from InvenTree.serializers import InvenTreeModelSerializer, InvenTreeAttachmentS
|
|||||||
from InvenTree.serializers import UserSerializerBrief, ReferenceIndexingSerializerMixin
|
from InvenTree.serializers import UserSerializerBrief, ReferenceIndexingSerializerMixin
|
||||||
|
|
||||||
import InvenTree.helpers
|
import InvenTree.helpers
|
||||||
|
from InvenTree.helpers import extract_serial_numbers
|
||||||
from InvenTree.serializers import InvenTreeDecimalField
|
from InvenTree.serializers import InvenTreeDecimalField
|
||||||
from InvenTree.status_codes import StockStatus
|
from InvenTree.status_codes import StockStatus
|
||||||
|
|
||||||
@@ -141,6 +142,9 @@ class BuildOutputSerializer(serializers.Serializer):
|
|||||||
|
|
||||||
build = self.context['build']
|
build = self.context['build']
|
||||||
|
|
||||||
|
# As this serializer can be used in multiple contexts, we need to work out why we are here
|
||||||
|
to_complete = self.context.get('to_complete', False)
|
||||||
|
|
||||||
# The stock item must point to the build
|
# The stock item must point to the build
|
||||||
if output.build != build:
|
if output.build != build:
|
||||||
raise ValidationError(_("Build output does not match the parent build"))
|
raise ValidationError(_("Build output does not match the parent build"))
|
||||||
@@ -153,8 +157,10 @@ class BuildOutputSerializer(serializers.Serializer):
|
|||||||
if not output.is_building:
|
if not output.is_building:
|
||||||
raise ValidationError(_("This build output has already been completed"))
|
raise ValidationError(_("This build output has already been completed"))
|
||||||
|
|
||||||
|
if to_complete:
|
||||||
|
|
||||||
# The build output must have all tracked parts allocated
|
# The build output must have all tracked parts allocated
|
||||||
if not build.isFullyAllocated(output):
|
if not build.is_fully_allocated(output):
|
||||||
raise ValidationError(_("This build output is not fully allocated"))
|
raise ValidationError(_("This build output is not fully allocated"))
|
||||||
|
|
||||||
return output
|
return output
|
||||||
@@ -165,6 +171,180 @@ class BuildOutputSerializer(serializers.Serializer):
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class BuildOutputCreateSerializer(serializers.Serializer):
|
||||||
|
"""
|
||||||
|
Serializer for creating a new BuildOutput against a BuildOrder.
|
||||||
|
|
||||||
|
URL pattern is "/api/build/<pk>/create-output/", where <pk> is the PK of a Build.
|
||||||
|
|
||||||
|
The Build object is provided to the serializer context.
|
||||||
|
"""
|
||||||
|
|
||||||
|
quantity = serializers.DecimalField(
|
||||||
|
max_digits=15,
|
||||||
|
decimal_places=5,
|
||||||
|
min_value=0,
|
||||||
|
required=True,
|
||||||
|
label=_('Quantity'),
|
||||||
|
help_text=_('Enter quantity for build output'),
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_build(self):
|
||||||
|
return self.context["build"]
|
||||||
|
|
||||||
|
def get_part(self):
|
||||||
|
return self.get_build().part
|
||||||
|
|
||||||
|
def validate_quantity(self, quantity):
|
||||||
|
|
||||||
|
if quantity < 0:
|
||||||
|
raise ValidationError(_("Quantity must be greater than zero"))
|
||||||
|
|
||||||
|
part = self.get_part()
|
||||||
|
|
||||||
|
if int(quantity) != quantity:
|
||||||
|
# Quantity must be an integer value if the part being built is trackable
|
||||||
|
if part.trackable:
|
||||||
|
raise ValidationError(_("Integer quantity required for trackable parts"))
|
||||||
|
|
||||||
|
if part.has_trackable_parts():
|
||||||
|
raise ValidationError(_("Integer quantity required, as the bill of materials contains trackable parts"))
|
||||||
|
|
||||||
|
return quantity
|
||||||
|
|
||||||
|
batch_code = serializers.CharField(
|
||||||
|
required=False,
|
||||||
|
allow_blank=True,
|
||||||
|
label=_('Batch Code'),
|
||||||
|
help_text=_('Batch code for this build output'),
|
||||||
|
)
|
||||||
|
|
||||||
|
serial_numbers = serializers.CharField(
|
||||||
|
allow_blank=True,
|
||||||
|
required=False,
|
||||||
|
label=_('Serial Numbers'),
|
||||||
|
help_text=_('Enter serial numbers for build outputs'),
|
||||||
|
)
|
||||||
|
|
||||||
|
def validate_serial_numbers(self, serial_numbers):
|
||||||
|
|
||||||
|
serial_numbers = serial_numbers.strip()
|
||||||
|
|
||||||
|
# TODO: Field level validation necessary here?
|
||||||
|
return serial_numbers
|
||||||
|
|
||||||
|
auto_allocate = serializers.BooleanField(
|
||||||
|
required=False,
|
||||||
|
default=False,
|
||||||
|
allow_null=True,
|
||||||
|
label=_('Auto Allocate Serial Numbers'),
|
||||||
|
help_text=_('Automatically allocate required items with matching serial numbers'),
|
||||||
|
)
|
||||||
|
|
||||||
|
def validate(self, data):
|
||||||
|
"""
|
||||||
|
Perform form validation
|
||||||
|
"""
|
||||||
|
|
||||||
|
part = self.get_part()
|
||||||
|
|
||||||
|
# Cache a list of serial numbers (to be used in the "save" method)
|
||||||
|
self.serials = None
|
||||||
|
|
||||||
|
quantity = data['quantity']
|
||||||
|
serial_numbers = data.get('serial_numbers', '')
|
||||||
|
|
||||||
|
if serial_numbers:
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.serials = extract_serial_numbers(serial_numbers, quantity, part.getLatestSerialNumberInt())
|
||||||
|
except DjangoValidationError as e:
|
||||||
|
raise ValidationError({
|
||||||
|
'serial_numbers': e.messages,
|
||||||
|
})
|
||||||
|
|
||||||
|
# Check for conflicting serial numbesr
|
||||||
|
existing = []
|
||||||
|
|
||||||
|
for serial in self.serials:
|
||||||
|
if part.checkIfSerialNumberExists(serial):
|
||||||
|
existing.append(serial)
|
||||||
|
|
||||||
|
if len(existing) > 0:
|
||||||
|
|
||||||
|
msg = _("The following serial numbers already exist")
|
||||||
|
msg += " : "
|
||||||
|
msg += ",".join([str(e) for e in existing])
|
||||||
|
|
||||||
|
raise ValidationError({
|
||||||
|
'serial_numbers': msg,
|
||||||
|
})
|
||||||
|
|
||||||
|
return data
|
||||||
|
|
||||||
|
def save(self):
|
||||||
|
"""
|
||||||
|
Generate the new build output(s)
|
||||||
|
"""
|
||||||
|
|
||||||
|
data = self.validated_data
|
||||||
|
|
||||||
|
quantity = data['quantity']
|
||||||
|
batch_code = data.get('batch_code', '')
|
||||||
|
auto_allocate = data.get('auto_allocate', False)
|
||||||
|
|
||||||
|
build = self.get_build()
|
||||||
|
|
||||||
|
build.create_build_output(
|
||||||
|
quantity,
|
||||||
|
serials=self.serials,
|
||||||
|
batch=batch_code,
|
||||||
|
auto_allocate=auto_allocate,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class BuildOutputDeleteSerializer(serializers.Serializer):
|
||||||
|
"""
|
||||||
|
DRF serializer for deleting (cancelling) one or more build outputs
|
||||||
|
"""
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
fields = [
|
||||||
|
'outputs',
|
||||||
|
]
|
||||||
|
|
||||||
|
outputs = BuildOutputSerializer(
|
||||||
|
many=True,
|
||||||
|
required=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
def validate(self, data):
|
||||||
|
|
||||||
|
data = super().validate(data)
|
||||||
|
|
||||||
|
outputs = data.get('outputs', [])
|
||||||
|
|
||||||
|
if len(outputs) == 0:
|
||||||
|
raise ValidationError(_("A list of build outputs must be provided"))
|
||||||
|
|
||||||
|
return data
|
||||||
|
|
||||||
|
def save(self):
|
||||||
|
"""
|
||||||
|
'save' the serializer to delete the build outputs
|
||||||
|
"""
|
||||||
|
|
||||||
|
data = self.validated_data
|
||||||
|
outputs = data.get('outputs', [])
|
||||||
|
|
||||||
|
build = self.context['build']
|
||||||
|
|
||||||
|
with transaction.atomic():
|
||||||
|
for item in outputs:
|
||||||
|
output = item['output']
|
||||||
|
build.delete_output(output)
|
||||||
|
|
||||||
|
|
||||||
class BuildOutputCompleteSerializer(serializers.Serializer):
|
class BuildOutputCompleteSerializer(serializers.Serializer):
|
||||||
"""
|
"""
|
||||||
DRF serializer for completing one or more build outputs
|
DRF serializer for completing one or more build outputs
|
||||||
@@ -224,6 +404,10 @@ class BuildOutputCompleteSerializer(serializers.Serializer):
|
|||||||
|
|
||||||
data = self.validated_data
|
data = self.validated_data
|
||||||
|
|
||||||
|
location = data['location']
|
||||||
|
status = data['status']
|
||||||
|
notes = data.get('notes', '')
|
||||||
|
|
||||||
outputs = data.get('outputs', [])
|
outputs = data.get('outputs', [])
|
||||||
|
|
||||||
# Mark the specified build outputs as "complete"
|
# Mark the specified build outputs as "complete"
|
||||||
@@ -235,8 +419,9 @@ class BuildOutputCompleteSerializer(serializers.Serializer):
|
|||||||
build.complete_build_output(
|
build.complete_build_output(
|
||||||
output,
|
output,
|
||||||
request.user,
|
request.user,
|
||||||
status=data['status'],
|
location=location,
|
||||||
notes=data.get('notes', '')
|
status=status,
|
||||||
|
notes=notes,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -256,7 +441,7 @@ class BuildCompleteSerializer(serializers.Serializer):
|
|||||||
|
|
||||||
build = self.context['build']
|
build = self.context['build']
|
||||||
|
|
||||||
if not build.areUntrackedPartsFullyAllocated() and not value:
|
if not build.are_untracked_parts_allocated() and not value:
|
||||||
raise ValidationError(_('Required stock has not been fully allocated'))
|
raise ValidationError(_('Required stock has not been fully allocated'))
|
||||||
|
|
||||||
return value
|
return value
|
||||||
|
|||||||
@@ -125,7 +125,7 @@ src="{% static 'img/blank_image.png' %}"
|
|||||||
{% trans "Required build quantity has not yet been completed" %}
|
{% trans "Required build quantity has not yet been completed" %}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if not build.areUntrackedPartsFullyAllocated %}
|
{% if not build.are_untracked_parts_allocated %}
|
||||||
<div class='alert alert-block alert-warning'>
|
<div class='alert alert-block alert-warning'>
|
||||||
{% trans "Stock has not been fully allocated to this Build Order" %}
|
{% trans "Stock has not been fully allocated to this Build Order" %}
|
||||||
</div>
|
</div>
|
||||||
@@ -234,7 +234,7 @@ src="{% static 'img/blank_image.png' %}"
|
|||||||
{% else %}
|
{% else %}
|
||||||
|
|
||||||
completeBuildOrder({{ build.pk }}, {
|
completeBuildOrder({{ build.pk }}, {
|
||||||
allocated: {% if build.areUntrackedPartsFullyAllocated %}true{% else %}false{% endif %},
|
allocated: {% if build.are_untracked_parts_allocated %}true{% else %}false{% endif %},
|
||||||
completed: {% if build.remaining == 0 %}true{% else %}false{% endif %},
|
completed: {% if build.remaining == 0 %}true{% else %}false{% endif %},
|
||||||
});
|
});
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -1,20 +0,0 @@
|
|||||||
{% extends "modal_form.html" %}
|
|
||||||
{% load i18n %}
|
|
||||||
{% block pre_form_content %}
|
|
||||||
|
|
||||||
{% if build.part.has_trackable_parts %}
|
|
||||||
<div class='alert alert-block alert-warning'>
|
|
||||||
{% trans "The Bill of Materials contains trackable parts" %}<br>
|
|
||||||
{% trans "Build outputs must be generated individually." %}<br>
|
|
||||||
{% trans "Multiple build outputs will be created based on the quantity specified." %}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if build.part.trackable %}
|
|
||||||
<div class='alert alert-block alert-info'>
|
|
||||||
{% trans "Trackable parts can have serial numbers specified" %}<br>
|
|
||||||
{% trans "Enter serial numbers to generate multiple single build outputs" %}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% endblock %}
|
|
||||||
@@ -192,7 +192,7 @@
|
|||||||
<div class='panel-content'>
|
<div class='panel-content'>
|
||||||
{% if build.has_untracked_bom_items %}
|
{% if build.has_untracked_bom_items %}
|
||||||
{% if build.active %}
|
{% if build.active %}
|
||||||
{% if build.areUntrackedPartsFullyAllocated %}
|
{% if build.are_untracked_parts_allocated %}
|
||||||
<div class='alert alert-block alert-success'>
|
<div class='alert alert-block alert-success'>
|
||||||
{% trans "Untracked stock has been fully allocated for this Build Order" %}
|
{% trans "Untracked stock has been fully allocated for this Build Order" %}
|
||||||
</div>
|
</div>
|
||||||
@@ -243,13 +243,16 @@
|
|||||||
|
|
||||||
<!-- Build output actions -->
|
<!-- Build output actions -->
|
||||||
<div class='btn-group'>
|
<div class='btn-group'>
|
||||||
<button id='output-options' class='btn btn-primary dropdown-toiggle' type='button' data-bs-toggle='dropdown' title='{% trans "Output Actions" %}'>
|
<button id='output-options' class='btn btn-primary dropdown-toggle' type='button' data-bs-toggle='dropdown' title='{% trans "Output Actions" %}'>
|
||||||
<span class='fas fa-tools'></span> <span class='caret'></span>
|
<span class='fas fa-tools'></span> <span class='caret'></span>
|
||||||
</button>
|
</button>
|
||||||
<ul class='dropdown-menu'>
|
<ul class='dropdown-menu'>
|
||||||
<li><a class='dropdown-item' href='#' id='multi-output-complete' title='{% trans "Complete selected items" %}'>
|
<li><a class='dropdown-item' href='#' id='multi-output-complete' title='{% trans "Complete selected build outputs" %}'>
|
||||||
<span class='fas fa-check-circle icon-green'></span> {% trans "Complete outputs" %}
|
<span class='fas fa-check-circle icon-green'></span> {% trans "Complete outputs" %}
|
||||||
</a></li>
|
</a></li>
|
||||||
|
<li><a class='dropdown-item' href='#' id='multi-output-delete' title='{% trans "Delete selected build outputs" %}'>
|
||||||
|
<span class='fas fa-trash-alt icon-red'></span> {% trans "Delete outputs" %}
|
||||||
|
</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
{% include "filter_list.html" with id='incompletebuilditems' %}
|
{% include "filter_list.html" with id='incompletebuilditems' %}
|
||||||
@@ -318,9 +321,11 @@
|
|||||||
{{ block.super }}
|
{{ block.super }}
|
||||||
|
|
||||||
$('#btn-create-output').click(function() {
|
$('#btn-create-output').click(function() {
|
||||||
launchModalForm('{% url "build-output-create" build.id %}',
|
|
||||||
|
createBuildOutput(
|
||||||
|
{{ build.pk }},
|
||||||
{
|
{
|
||||||
reload: true,
|
trackable_parts: {% if build.part.has_trackable_parts %}true{% else %}false{% endif%},
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -372,6 +377,7 @@ inventreeGet(
|
|||||||
[
|
[
|
||||||
'#output-options',
|
'#output-options',
|
||||||
'#multi-output-complete',
|
'#multi-output-complete',
|
||||||
|
'#multi-output-delete',
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -393,6 +399,24 @@ inventreeGet(
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
$('#multi-output-delete').click(function() {
|
||||||
|
var outputs = $('#build-output-table').bootstrapTable('getSelections');
|
||||||
|
|
||||||
|
deleteBuildOutputs(
|
||||||
|
build_info.pk,
|
||||||
|
outputs,
|
||||||
|
{
|
||||||
|
success: function() {
|
||||||
|
// Reload the "in progress" table
|
||||||
|
$('#build-output-table').bootstrapTable('refresh');
|
||||||
|
|
||||||
|
// Reload the "completed" table
|
||||||
|
$('#build-stock-table').bootstrapTable('refresh');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
});
|
||||||
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if build.active and build.has_untracked_bom_items %}
|
{% if build.active and build.has_untracked_bom_items %}
|
||||||
|
|||||||
@@ -62,20 +62,20 @@ class BuildTest(TestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Create BOM item links for the parts
|
# Create BOM item links for the parts
|
||||||
BomItem.objects.create(
|
self.bom_item_1 = BomItem.objects.create(
|
||||||
part=self.assembly,
|
part=self.assembly,
|
||||||
sub_part=self.sub_part_1,
|
sub_part=self.sub_part_1,
|
||||||
quantity=5
|
quantity=5
|
||||||
)
|
)
|
||||||
|
|
||||||
BomItem.objects.create(
|
self.bom_item_2 = BomItem.objects.create(
|
||||||
part=self.assembly,
|
part=self.assembly,
|
||||||
sub_part=self.sub_part_2,
|
sub_part=self.sub_part_2,
|
||||||
quantity=3
|
quantity=3
|
||||||
)
|
)
|
||||||
|
|
||||||
# sub_part_3 is trackable!
|
# sub_part_3 is trackable!
|
||||||
BomItem.objects.create(
|
self.bom_item_3 = BomItem.objects.create(
|
||||||
part=self.assembly,
|
part=self.assembly,
|
||||||
sub_part=self.sub_part_3,
|
sub_part=self.sub_part_3,
|
||||||
quantity=2
|
quantity=2
|
||||||
@@ -83,9 +83,6 @@ class BuildTest(TestCase):
|
|||||||
|
|
||||||
ref = get_next_build_number()
|
ref = get_next_build_number()
|
||||||
|
|
||||||
if ref is None:
|
|
||||||
ref = "0001"
|
|
||||||
|
|
||||||
# Create a "Build" object to make 10x objects
|
# Create a "Build" object to make 10x objects
|
||||||
self.build = Build.objects.create(
|
self.build = Build.objects.create(
|
||||||
reference=ref,
|
reference=ref,
|
||||||
@@ -150,15 +147,15 @@ class BuildTest(TestCase):
|
|||||||
|
|
||||||
# None of the build outputs have been completed
|
# None of the build outputs have been completed
|
||||||
for output in self.build.get_build_outputs().all():
|
for output in self.build.get_build_outputs().all():
|
||||||
self.assertFalse(self.build.isFullyAllocated(output))
|
self.assertFalse(self.build.is_fully_allocated(output))
|
||||||
|
|
||||||
self.assertFalse(self.build.isPartFullyAllocated(self.sub_part_1, self.output_1))
|
self.assertFalse(self.build.is_bom_item_allocated(self.bom_item_1, self.output_1))
|
||||||
self.assertFalse(self.build.isPartFullyAllocated(self.sub_part_2, self.output_2))
|
self.assertFalse(self.build.is_bom_item_allocated(self.bom_item_2, self.output_2))
|
||||||
|
|
||||||
self.assertEqual(self.build.unallocatedQuantity(self.sub_part_1, self.output_1), 15)
|
self.assertEqual(self.build.unallocated_quantity(self.bom_item_1, self.output_1), 15)
|
||||||
self.assertEqual(self.build.unallocatedQuantity(self.sub_part_1, self.output_2), 35)
|
self.assertEqual(self.build.unallocated_quantity(self.bom_item_1, self.output_2), 35)
|
||||||
self.assertEqual(self.build.unallocatedQuantity(self.sub_part_2, self.output_1), 9)
|
self.assertEqual(self.build.unallocated_quantity(self.bom_item_2, self.output_1), 9)
|
||||||
self.assertEqual(self.build.unallocatedQuantity(self.sub_part_2, self.output_2), 21)
|
self.assertEqual(self.build.unallocated_quantity(self.bom_item_2, self.output_2), 21)
|
||||||
|
|
||||||
self.assertFalse(self.build.is_complete)
|
self.assertFalse(self.build.is_complete)
|
||||||
|
|
||||||
@@ -229,7 +226,7 @@ class BuildTest(TestCase):
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
self.assertTrue(self.build.isFullyAllocated(self.output_1))
|
self.assertTrue(self.build.is_fully_allocated(self.output_1))
|
||||||
|
|
||||||
# Partially allocate tracked stock against build output 2
|
# Partially allocate tracked stock against build output 2
|
||||||
self.allocate_stock(
|
self.allocate_stock(
|
||||||
@@ -239,7 +236,7 @@ class BuildTest(TestCase):
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
self.assertFalse(self.build.isFullyAllocated(self.output_2))
|
self.assertFalse(self.build.is_fully_allocated(self.output_2))
|
||||||
|
|
||||||
# Partially allocate untracked stock against build
|
# Partially allocate untracked stock against build
|
||||||
self.allocate_stock(
|
self.allocate_stock(
|
||||||
@@ -250,9 +247,9 @@ class BuildTest(TestCase):
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
self.assertFalse(self.build.isFullyAllocated(None, verbose=True))
|
self.assertFalse(self.build.is_fully_allocated(None))
|
||||||
|
|
||||||
unallocated = self.build.unallocatedParts(None)
|
unallocated = self.build.unallocated_bom_items(None)
|
||||||
|
|
||||||
self.assertEqual(len(unallocated), 2)
|
self.assertEqual(len(unallocated), 2)
|
||||||
|
|
||||||
@@ -263,19 +260,19 @@ class BuildTest(TestCase):
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
self.assertFalse(self.build.isFullyAllocated(None, verbose=True))
|
self.assertFalse(self.build.is_fully_allocated(None))
|
||||||
|
|
||||||
unallocated = self.build.unallocatedParts(None)
|
unallocated = self.build.unallocated_bom_items(None)
|
||||||
|
|
||||||
self.assertEqual(len(unallocated), 1)
|
self.assertEqual(len(unallocated), 1)
|
||||||
|
|
||||||
self.build.unallocateStock()
|
self.build.unallocateStock()
|
||||||
|
|
||||||
unallocated = self.build.unallocatedParts(None)
|
unallocated = self.build.unallocated_bom_items(None)
|
||||||
|
|
||||||
self.assertEqual(len(unallocated), 2)
|
self.assertEqual(len(unallocated), 2)
|
||||||
|
|
||||||
self.assertFalse(self.build.areUntrackedPartsFullyAllocated())
|
self.assertFalse(self.build.are_untracked_parts_allocated())
|
||||||
|
|
||||||
# Now we "fully" allocate the untracked untracked items
|
# Now we "fully" allocate the untracked untracked items
|
||||||
self.allocate_stock(
|
self.allocate_stock(
|
||||||
@@ -286,7 +283,7 @@ class BuildTest(TestCase):
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
self.assertTrue(self.build.areUntrackedPartsFullyAllocated())
|
self.assertTrue(self.build.are_untracked_parts_allocated())
|
||||||
|
|
||||||
def test_cancel(self):
|
def test_cancel(self):
|
||||||
"""
|
"""
|
||||||
@@ -334,9 +331,9 @@ class BuildTest(TestCase):
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
self.assertTrue(self.build.isFullyAllocated(None, verbose=True))
|
self.assertTrue(self.build.is_fully_allocated(None))
|
||||||
self.assertTrue(self.build.isFullyAllocated(self.output_1))
|
self.assertTrue(self.build.is_fully_allocated(self.output_1))
|
||||||
self.assertTrue(self.build.isFullyAllocated(self.output_2))
|
self.assertTrue(self.build.is_fully_allocated(self.output_2))
|
||||||
|
|
||||||
self.build.complete_build_output(self.output_1, None)
|
self.build.complete_build_output(self.output_1, None)
|
||||||
|
|
||||||
|
|||||||
@@ -9,8 +9,6 @@ from . import views
|
|||||||
build_detail_urls = [
|
build_detail_urls = [
|
||||||
url(r'^cancel/', views.BuildCancel.as_view(), name='build-cancel'),
|
url(r'^cancel/', views.BuildCancel.as_view(), name='build-cancel'),
|
||||||
url(r'^delete/', views.BuildDelete.as_view(), name='build-delete'),
|
url(r'^delete/', views.BuildDelete.as_view(), name='build-delete'),
|
||||||
url(r'^create-output/', views.BuildOutputCreate.as_view(), name='build-output-create'),
|
|
||||||
url(r'^delete-output/', views.BuildOutputDelete.as_view(), name='build-output-delete'),
|
|
||||||
|
|
||||||
url(r'^.*$', views.BuildDetail.as_view(), name='build-detail'),
|
url(r'^.*$', views.BuildDetail.as_view(), name='build-detail'),
|
||||||
]
|
]
|
||||||
|
|||||||
+1
-180
@@ -6,17 +6,14 @@ Django views for interacting with Build objects
|
|||||||
from __future__ import unicode_literals
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
from django.utils.translation import ugettext_lazy as _
|
from django.utils.translation import ugettext_lazy as _
|
||||||
from django.core.exceptions import ValidationError
|
|
||||||
from django.views.generic import DetailView, ListView
|
from django.views.generic import DetailView, ListView
|
||||||
from django.forms import HiddenInput
|
|
||||||
|
|
||||||
from .models import Build
|
from .models import Build
|
||||||
from . import forms
|
from . import forms
|
||||||
from stock.models import StockItem
|
|
||||||
|
|
||||||
from InvenTree.views import AjaxUpdateView, AjaxDeleteView
|
from InvenTree.views import AjaxUpdateView, AjaxDeleteView
|
||||||
from InvenTree.views import InvenTreeRoleMixin
|
from InvenTree.views import InvenTreeRoleMixin
|
||||||
from InvenTree.helpers import str2bool, extract_serial_numbers
|
from InvenTree.helpers import str2bool
|
||||||
from InvenTree.status_codes import BuildStatus
|
from InvenTree.status_codes import BuildStatus
|
||||||
|
|
||||||
|
|
||||||
@@ -77,182 +74,6 @@ class BuildCancel(AjaxUpdateView):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class BuildOutputCreate(AjaxUpdateView):
|
|
||||||
"""
|
|
||||||
Create a new build output (StockItem) for a given build.
|
|
||||||
"""
|
|
||||||
|
|
||||||
model = Build
|
|
||||||
form_class = forms.BuildOutputCreateForm
|
|
||||||
ajax_template_name = 'build/build_output_create.html'
|
|
||||||
ajax_form_title = _('Create Build Output')
|
|
||||||
|
|
||||||
def validate(self, build, form, **kwargs):
|
|
||||||
"""
|
|
||||||
Validation for the form:
|
|
||||||
"""
|
|
||||||
|
|
||||||
quantity = form.cleaned_data.get('output_quantity', None)
|
|
||||||
serials = form.cleaned_data.get('serial_numbers', None)
|
|
||||||
|
|
||||||
if quantity is not None:
|
|
||||||
build = self.get_object()
|
|
||||||
|
|
||||||
# Check that requested output don't exceed build remaining quantity
|
|
||||||
maximum_output = int(build.remaining - build.incomplete_count)
|
|
||||||
|
|
||||||
if quantity > maximum_output:
|
|
||||||
form.add_error(
|
|
||||||
'output_quantity',
|
|
||||||
_('Maximum output quantity is ') + str(maximum_output),
|
|
||||||
)
|
|
||||||
|
|
||||||
elif quantity <= 0:
|
|
||||||
form.add_error(
|
|
||||||
'output_quantity',
|
|
||||||
_('Output quantity must be greater than zero'),
|
|
||||||
)
|
|
||||||
|
|
||||||
# Check that the serial numbers are valid
|
|
||||||
if serials:
|
|
||||||
try:
|
|
||||||
extracted = extract_serial_numbers(serials, quantity, build.part.getLatestSerialNumberInt())
|
|
||||||
|
|
||||||
if extracted:
|
|
||||||
# Check for conflicting serial numbers
|
|
||||||
conflicts = build.part.find_conflicting_serial_numbers(extracted)
|
|
||||||
|
|
||||||
if len(conflicts) > 0:
|
|
||||||
msg = ",".join([str(c) for c in conflicts])
|
|
||||||
form.add_error(
|
|
||||||
'serial_numbers',
|
|
||||||
_('Serial numbers already exist') + ': ' + msg,
|
|
||||||
)
|
|
||||||
|
|
||||||
except ValidationError as e:
|
|
||||||
form.add_error('serial_numbers', e.messages)
|
|
||||||
|
|
||||||
else:
|
|
||||||
# If no serial numbers are provided, should they be?
|
|
||||||
if build.part.trackable:
|
|
||||||
form.add_error('serial_numbers', _('Serial numbers required for trackable build output'))
|
|
||||||
|
|
||||||
def save(self, build, form, **kwargs):
|
|
||||||
"""
|
|
||||||
Create a new build output
|
|
||||||
"""
|
|
||||||
|
|
||||||
data = form.cleaned_data
|
|
||||||
|
|
||||||
quantity = data.get('output_quantity', None)
|
|
||||||
batch = data.get('batch', None)
|
|
||||||
|
|
||||||
serials = data.get('serial_numbers', None)
|
|
||||||
|
|
||||||
if serials:
|
|
||||||
serial_numbers = extract_serial_numbers(serials, quantity, build.part.getLatestSerialNumberInt())
|
|
||||||
else:
|
|
||||||
serial_numbers = None
|
|
||||||
|
|
||||||
build.create_build_output(
|
|
||||||
quantity,
|
|
||||||
serials=serial_numbers,
|
|
||||||
batch=batch,
|
|
||||||
)
|
|
||||||
|
|
||||||
def get_initial(self):
|
|
||||||
|
|
||||||
initials = super().get_initial()
|
|
||||||
|
|
||||||
build = self.get_object()
|
|
||||||
|
|
||||||
# Calculate the required quantity
|
|
||||||
quantity = max(0, build.remaining - build.incomplete_count)
|
|
||||||
initials['output_quantity'] = int(quantity)
|
|
||||||
|
|
||||||
return initials
|
|
||||||
|
|
||||||
def get_form(self):
|
|
||||||
|
|
||||||
build = self.get_object()
|
|
||||||
part = build.part
|
|
||||||
|
|
||||||
context = self.get_form_kwargs()
|
|
||||||
|
|
||||||
# Pass the 'part' through to the form,
|
|
||||||
# so we can add the next serial number as a placeholder
|
|
||||||
context['build'] = build
|
|
||||||
|
|
||||||
form = self.form_class(**context)
|
|
||||||
|
|
||||||
# If the part is not trackable, hide the serial number input
|
|
||||||
if not part.trackable:
|
|
||||||
form.fields['serial_numbers'].widget = HiddenInput()
|
|
||||||
|
|
||||||
return form
|
|
||||||
|
|
||||||
|
|
||||||
class BuildOutputDelete(AjaxUpdateView):
|
|
||||||
"""
|
|
||||||
Delete a build output (StockItem) for a given build.
|
|
||||||
|
|
||||||
Form is a simple confirmation dialog
|
|
||||||
"""
|
|
||||||
|
|
||||||
model = Build
|
|
||||||
form_class = forms.BuildOutputDeleteForm
|
|
||||||
ajax_form_title = _('Delete Build Output')
|
|
||||||
|
|
||||||
role_required = 'build.delete'
|
|
||||||
|
|
||||||
def get_initial(self):
|
|
||||||
|
|
||||||
initials = super().get_initial()
|
|
||||||
|
|
||||||
output = self.get_param('output')
|
|
||||||
|
|
||||||
initials['output_id'] = output
|
|
||||||
|
|
||||||
return initials
|
|
||||||
|
|
||||||
def validate(self, build, form, **kwargs):
|
|
||||||
|
|
||||||
data = form.cleaned_data
|
|
||||||
|
|
||||||
confirm = data.get('confirm', False)
|
|
||||||
|
|
||||||
if not confirm:
|
|
||||||
form.add_error('confirm', _('Confirm unallocation of build stock'))
|
|
||||||
form.add_error(None, _('Check the confirmation box'))
|
|
||||||
|
|
||||||
output_id = data.get('output_id', None)
|
|
||||||
output = None
|
|
||||||
|
|
||||||
try:
|
|
||||||
output = StockItem.objects.get(pk=output_id)
|
|
||||||
except (ValueError, StockItem.DoesNotExist):
|
|
||||||
pass
|
|
||||||
|
|
||||||
if output:
|
|
||||||
if not output.build == build:
|
|
||||||
form.add_error(None, _('Build output does not match build'))
|
|
||||||
else:
|
|
||||||
form.add_error(None, _('Build output must be specified'))
|
|
||||||
|
|
||||||
def save(self, build, form, **kwargs):
|
|
||||||
|
|
||||||
output_id = form.cleaned_data.get('output_id')
|
|
||||||
|
|
||||||
output = StockItem.objects.get(pk=output_id)
|
|
||||||
|
|
||||||
build.deleteBuildOutput(output)
|
|
||||||
|
|
||||||
def get_data(self):
|
|
||||||
return {
|
|
||||||
'danger': _('Build output deleted'),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class BuildDetail(InvenTreeRoleMixin, DetailView):
|
class BuildDetail(InvenTreeRoleMixin, DetailView):
|
||||||
"""
|
"""
|
||||||
Detail view of a single Build object.
|
Detail view of a single Build object.
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ def delete_old_notifications():
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
from common.models import NotificationEntry
|
from common.models import NotificationEntry
|
||||||
except AppRegistryNotReady:
|
except AppRegistryNotReady: # pragma: no cover
|
||||||
logger.info("Could not perform 'delete_old_notifications' - App registry not ready")
|
logger.info("Could not perform 'delete_old_notifications' - App registry not ready")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|||||||
@@ -59,15 +59,15 @@ class SettingsTest(TestCase):
|
|||||||
name = setting.get('name', None)
|
name = setting.get('name', None)
|
||||||
|
|
||||||
if name is None:
|
if name is None:
|
||||||
raise ValueError(f'Missing GLOBAL_SETTING name for {key}')
|
raise ValueError(f'Missing GLOBAL_SETTING name for {key}') # pragma: no cover
|
||||||
|
|
||||||
description = setting.get('description', None)
|
description = setting.get('description', None)
|
||||||
|
|
||||||
if description is None:
|
if description is None:
|
||||||
raise ValueError(f'Missing GLOBAL_SETTING description for {key}')
|
raise ValueError(f'Missing GLOBAL_SETTING description for {key}') # pragma: no cover
|
||||||
|
|
||||||
if not key == key.upper():
|
if not key == key.upper():
|
||||||
raise ValueError(f"SETTINGS key '{key}' is not uppercase")
|
raise ValueError(f"SETTINGS key '{key}' is not uppercase") # pragma: no cover
|
||||||
|
|
||||||
def test_defaults(self):
|
def test_defaults(self):
|
||||||
"""
|
"""
|
||||||
@@ -87,10 +87,10 @@ class SettingsTest(TestCase):
|
|||||||
|
|
||||||
if setting.is_bool():
|
if setting.is_bool():
|
||||||
if setting.default_value in ['', None]:
|
if setting.default_value in ['', None]:
|
||||||
raise ValueError(f'Default value for boolean setting {key} not provided')
|
raise ValueError(f'Default value for boolean setting {key} not provided') # pragma: no cover
|
||||||
|
|
||||||
if setting.default_value not in [True, False]:
|
if setting.default_value not in [True, False]:
|
||||||
raise ValueError(f'Non-boolean default value specified for {key}')
|
raise ValueError(f'Non-boolean default value specified for {key}') # pragma: no cover
|
||||||
|
|
||||||
|
|
||||||
class WebhookMessageTests(TestCase):
|
class WebhookMessageTests(TestCase):
|
||||||
|
|||||||
@@ -14,11 +14,11 @@ So a simplified version of the migration is implemented.
|
|||||||
TESTING = 'test' in sys.argv
|
TESTING = 'test' in sys.argv
|
||||||
|
|
||||||
def clear():
|
def clear():
|
||||||
if not TESTING:
|
if not TESTING: # pragma: no cover
|
||||||
os.system('cls' if os.name == 'nt' else 'clear')
|
os.system('cls' if os.name == 'nt' else 'clear')
|
||||||
|
|
||||||
|
|
||||||
def reverse_association(apps, schema_editor):
|
def reverse_association(apps, schema_editor): # pragma: no cover
|
||||||
"""
|
"""
|
||||||
This is the 'reverse' operation of the manufacturer reversal.
|
This is the 'reverse' operation of the manufacturer reversal.
|
||||||
This operation is easier:
|
This operation is easier:
|
||||||
@@ -108,7 +108,7 @@ def associate_manufacturers(apps, schema_editor):
|
|||||||
|
|
||||||
if len(row) > 0:
|
if len(row) > 0:
|
||||||
return row[0]
|
return row[0]
|
||||||
return ''
|
return '' # pragma: no cover
|
||||||
|
|
||||||
cursor = connection.cursor()
|
cursor = connection.cursor()
|
||||||
|
|
||||||
@@ -139,7 +139,7 @@ def associate_manufacturers(apps, schema_editor):
|
|||||||
""" Attempt to link Part to an existing Company """
|
""" Attempt to link Part to an existing Company """
|
||||||
|
|
||||||
# Matches a company name directly
|
# Matches a company name directly
|
||||||
if name in companies.keys():
|
if name in companies.keys(): # pragma: no cover
|
||||||
print(" - Part[{pk}]: '{n}' maps to existing manufacturer".format(pk=part_id, n=name))
|
print(" - Part[{pk}]: '{n}' maps to existing manufacturer".format(pk=part_id, n=name))
|
||||||
|
|
||||||
manufacturer_id = companies[name]
|
manufacturer_id = companies[name]
|
||||||
@@ -150,7 +150,7 @@ def associate_manufacturers(apps, schema_editor):
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
# Have we already mapped this
|
# Have we already mapped this
|
||||||
if name in links.keys():
|
if name in links.keys(): # pragma: no cover
|
||||||
print(" - Part[{pk}]: Mapped '{n}' - manufacturer <{c}>".format(pk=part_id, n=name, c=links[name]))
|
print(" - Part[{pk}]: Mapped '{n}' - manufacturer <{c}>".format(pk=part_id, n=name, c=links[name]))
|
||||||
|
|
||||||
manufacturer_id = links[name]
|
manufacturer_id = links[name]
|
||||||
@@ -196,10 +196,10 @@ def associate_manufacturers(apps, schema_editor):
|
|||||||
# Case-insensitive matching
|
# Case-insensitive matching
|
||||||
ratio = fuzz.partial_ratio(name.lower(), text.lower())
|
ratio = fuzz.partial_ratio(name.lower(), text.lower())
|
||||||
|
|
||||||
if ratio > threshold:
|
if ratio > threshold: # pragma: no cover
|
||||||
matches.append({'name': name, 'match': ratio})
|
matches.append({'name': name, 'match': ratio})
|
||||||
|
|
||||||
if len(matches) > 0:
|
if len(matches) > 0: # pragma: no cover
|
||||||
return [match['name'] for match in sorted(matches, key=lambda item: item['match'], reverse=True)]
|
return [match['name'] for match in sorted(matches, key=lambda item: item['match'], reverse=True)]
|
||||||
else:
|
else:
|
||||||
return []
|
return []
|
||||||
@@ -212,12 +212,12 @@ def associate_manufacturers(apps, schema_editor):
|
|||||||
name = get_manufacturer_name(part_id)
|
name = get_manufacturer_name(part_id)
|
||||||
|
|
||||||
# Skip empty names
|
# Skip empty names
|
||||||
if not name or len(name) == 0:
|
if not name or len(name) == 0: # pragma: no cover
|
||||||
print(" - Part[{pk}]: No manufacturer_name provided, skipping".format(pk=part_id))
|
print(" - Part[{pk}]: No manufacturer_name provided, skipping".format(pk=part_id))
|
||||||
return
|
return
|
||||||
|
|
||||||
# Can be linked to an existing manufacturer
|
# Can be linked to an existing manufacturer
|
||||||
if link_part(part_id, name):
|
if link_part(part_id, name): # pragma: no cover
|
||||||
return
|
return
|
||||||
|
|
||||||
# Find a list of potential matches
|
# Find a list of potential matches
|
||||||
@@ -226,12 +226,12 @@ def associate_manufacturers(apps, schema_editor):
|
|||||||
clear()
|
clear()
|
||||||
|
|
||||||
# Present a list of options
|
# Present a list of options
|
||||||
if not TESTING:
|
if not TESTING: # pragma: no cover
|
||||||
print("----------------------------------")
|
print("----------------------------------")
|
||||||
|
|
||||||
print("Checking part [{pk}] ({idx} of {total})".format(pk=part_id, idx=idx+1, total=total))
|
print("Checking part [{pk}] ({idx} of {total})".format(pk=part_id, idx=idx+1, total=total))
|
||||||
|
|
||||||
if not TESTING:
|
if not TESTING: # pragma: no cover
|
||||||
print("Manufacturer name: '{n}'".format(n=name))
|
print("Manufacturer name: '{n}'".format(n=name))
|
||||||
print("----------------------------------")
|
print("----------------------------------")
|
||||||
print("Select an option from the list below:")
|
print("Select an option from the list below:")
|
||||||
@@ -249,7 +249,7 @@ def associate_manufacturers(apps, schema_editor):
|
|||||||
if TESTING:
|
if TESTING:
|
||||||
# When running unit tests, simply select the name of the part
|
# When running unit tests, simply select the name of the part
|
||||||
response = '0'
|
response = '0'
|
||||||
else:
|
else: # pragma: no cover
|
||||||
response = str(input("> ")).strip()
|
response = str(input("> ")).strip()
|
||||||
|
|
||||||
# Attempt to parse user response as an integer
|
# Attempt to parse user response as an integer
|
||||||
@@ -263,7 +263,7 @@ def associate_manufacturers(apps, schema_editor):
|
|||||||
return
|
return
|
||||||
|
|
||||||
# Options 1) - n) select an existing manufacturer
|
# Options 1) - n) select an existing manufacturer
|
||||||
else:
|
else: # pragma: no cover
|
||||||
n = n - 1
|
n = n - 1
|
||||||
|
|
||||||
if n < len(matches):
|
if n < len(matches):
|
||||||
@@ -287,7 +287,7 @@ def associate_manufacturers(apps, schema_editor):
|
|||||||
else:
|
else:
|
||||||
print("Please select a valid option")
|
print("Please select a valid option")
|
||||||
|
|
||||||
except ValueError:
|
except ValueError: # pragma: no cover
|
||||||
# User has typed in a custom name!
|
# User has typed in a custom name!
|
||||||
|
|
||||||
if not response or len(response) == 0:
|
if not response or len(response) == 0:
|
||||||
@@ -312,7 +312,7 @@ def associate_manufacturers(apps, schema_editor):
|
|||||||
print("")
|
print("")
|
||||||
clear()
|
clear()
|
||||||
|
|
||||||
if not TESTING:
|
if not TESTING: # pragma: no cover
|
||||||
print("---------------------------------------")
|
print("---------------------------------------")
|
||||||
print("The SupplierPart model needs to be migrated,")
|
print("The SupplierPart model needs to be migrated,")
|
||||||
print("as the new 'manufacturer' field maps to a 'Company' reference.")
|
print("as the new 'manufacturer' field maps to a 'Company' reference.")
|
||||||
@@ -339,7 +339,7 @@ def associate_manufacturers(apps, schema_editor):
|
|||||||
for index, row in enumerate(results):
|
for index, row in enumerate(results):
|
||||||
pk, MPN, SKU, manufacturer_id, manufacturer_name = row
|
pk, MPN, SKU, manufacturer_id, manufacturer_name = row
|
||||||
|
|
||||||
if manufacturer_id is not None:
|
if manufacturer_id is not None: # pragma: no cover
|
||||||
print(f" - SupplierPart <{pk}> already has a manufacturer associated (skipping)")
|
print(f" - SupplierPart <{pk}> already has a manufacturer associated (skipping)")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
from django.db import migrations, models
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
def reverse_empty_email(apps, schema_editor):
|
def reverse_empty_email(apps, schema_editor): # pragma: no cover
|
||||||
Company = apps.get_model('company', 'Company')
|
Company = apps.get_model('company', 'Company')
|
||||||
for company in Company.objects.all():
|
for company in Company.objects.all():
|
||||||
if company.email == None:
|
if company.email == None:
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ def migrate_currencies(apps, schema_editor):
|
|||||||
|
|
||||||
suffix = suffix.strip().upper()
|
suffix = suffix.strip().upper()
|
||||||
|
|
||||||
if suffix not in currency_codes:
|
if suffix not in currency_codes: # pragma: no cover
|
||||||
logger.warning(f"Missing suffix: '{suffix}'")
|
logger.warning(f"Missing suffix: '{suffix}'")
|
||||||
|
|
||||||
while suffix not in currency_codes:
|
while suffix not in currency_codes:
|
||||||
@@ -78,7 +78,7 @@ def migrate_currencies(apps, schema_editor):
|
|||||||
if count > 0:
|
if count > 0:
|
||||||
logger.info(f"Updated {count} SupplierPriceBreak rows")
|
logger.info(f"Updated {count} SupplierPriceBreak rows")
|
||||||
|
|
||||||
def reverse_currencies(apps, schema_editor):
|
def reverse_currencies(apps, schema_editor): # pragma: no cover
|
||||||
"""
|
"""
|
||||||
Reverse the "update" process.
|
Reverse the "update" process.
|
||||||
|
|
||||||
|
|||||||
@@ -15,12 +15,12 @@ def supplierpart_make_manufacturer_parts(apps, schema_editor):
|
|||||||
for supplier_part in supplier_parts:
|
for supplier_part in supplier_parts:
|
||||||
print(f'{supplier_part.supplier.name[:15].ljust(15)} | {supplier_part.SKU[:15].ljust(15)}\t', end='')
|
print(f'{supplier_part.supplier.name[:15].ljust(15)} | {supplier_part.SKU[:15].ljust(15)}\t', end='')
|
||||||
|
|
||||||
if supplier_part.manufacturer_part:
|
if supplier_part.manufacturer_part: # pragma: no cover
|
||||||
print(f'[ERROR: MANUFACTURER PART ALREADY EXISTS]')
|
print(f'[ERROR: MANUFACTURER PART ALREADY EXISTS]')
|
||||||
continue
|
continue
|
||||||
|
|
||||||
part = supplier_part.part
|
part = supplier_part.part
|
||||||
if not part:
|
if not part: # pragma: no cover
|
||||||
print(f'[ERROR: SUPPLIER PART IS NOT CONNECTED TO PART]')
|
print(f'[ERROR: SUPPLIER PART IS NOT CONNECTED TO PART]')
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@@ -67,7 +67,7 @@ def supplierpart_make_manufacturer_parts(apps, schema_editor):
|
|||||||
|
|
||||||
print(f'{"-"*10}\nDone\n')
|
print(f'{"-"*10}\nDone\n')
|
||||||
|
|
||||||
def supplierpart_populate_manufacturer_info(apps, schema_editor):
|
def supplierpart_populate_manufacturer_info(apps, schema_editor): # pragma: no cover
|
||||||
Part = apps.get_model('part', 'Part')
|
Part = apps.get_model('part', 'Part')
|
||||||
ManufacturerPart = apps.get_model('company', 'ManufacturerPart')
|
ManufacturerPart = apps.get_model('company', 'ManufacturerPart')
|
||||||
SupplierPart = apps.get_model('company', 'SupplierPart')
|
SupplierPart = apps.get_model('company', 'SupplierPart')
|
||||||
|
|||||||
@@ -3,8 +3,6 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
from __future__ import unicode_literals
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
import json
|
|
||||||
|
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
@@ -49,28 +47,6 @@ class CompanyViewTestBase(TestCase):
|
|||||||
|
|
||||||
self.client.login(username='username', password='password')
|
self.client.login(username='username', password='password')
|
||||||
|
|
||||||
def post(self, url, data, valid=None):
|
|
||||||
"""
|
|
||||||
POST against this form and return the response (as a JSON object)
|
|
||||||
"""
|
|
||||||
|
|
||||||
response = self.client.post(url, data, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
|
|
||||||
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
|
|
||||||
json_data = json.loads(response.content)
|
|
||||||
|
|
||||||
# If a particular status code is required
|
|
||||||
if valid is not None:
|
|
||||||
if valid:
|
|
||||||
self.assertEqual(json_data['form_valid'], True)
|
|
||||||
else:
|
|
||||||
self.assertEqual(json_data['form_valid'], False)
|
|
||||||
|
|
||||||
form_errors = json.loads(json_data['form_errors'])
|
|
||||||
|
|
||||||
return json_data, form_errors
|
|
||||||
|
|
||||||
|
|
||||||
class CompanyViewTest(CompanyViewTestBase):
|
class CompanyViewTest(CompanyViewTestBase):
|
||||||
"""
|
"""
|
||||||
|
|||||||
+16
-9
@@ -5,6 +5,7 @@ import hashlib
|
|||||||
|
|
||||||
from django.apps import AppConfig
|
from django.apps import AppConfig
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
from django.core.exceptions import AppRegistryNotReady
|
||||||
|
|
||||||
from InvenTree.ready import canAppAccessDatabase
|
from InvenTree.ready import canAppAccessDatabase
|
||||||
|
|
||||||
@@ -35,6 +36,12 @@ class LabelConfig(AppConfig):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
if canAppAccessDatabase():
|
if canAppAccessDatabase():
|
||||||
|
self.create_labels() # pragma: no cover
|
||||||
|
|
||||||
|
def create_labels(self):
|
||||||
|
"""
|
||||||
|
Create all default templates
|
||||||
|
"""
|
||||||
self.create_stock_item_labels()
|
self.create_stock_item_labels()
|
||||||
self.create_stock_location_labels()
|
self.create_stock_location_labels()
|
||||||
self.create_part_labels()
|
self.create_part_labels()
|
||||||
@@ -47,7 +54,7 @@ class LabelConfig(AppConfig):
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
from .models import StockItemLabel
|
from .models import StockItemLabel
|
||||||
except:
|
except AppRegistryNotReady: # pragma: no cover
|
||||||
# Database might not by ready yet
|
# Database might not by ready yet
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -98,7 +105,7 @@ class LabelConfig(AppConfig):
|
|||||||
# File already exists - let's see if it is the "same",
|
# File already exists - let's see if it is the "same",
|
||||||
# or if we need to overwrite it with a newer copy!
|
# or if we need to overwrite it with a newer copy!
|
||||||
|
|
||||||
if not hashFile(dst_file) == hashFile(src_file):
|
if not hashFile(dst_file) == hashFile(src_file): # pragma: no cover
|
||||||
logger.info(f"Hash differs for '{filename}'")
|
logger.info(f"Hash differs for '{filename}'")
|
||||||
to_copy = True
|
to_copy = True
|
||||||
|
|
||||||
@@ -112,7 +119,7 @@ class LabelConfig(AppConfig):
|
|||||||
|
|
||||||
# Check if a label matching the template already exists
|
# Check if a label matching the template already exists
|
||||||
if StockItemLabel.objects.filter(label=filename).exists():
|
if StockItemLabel.objects.filter(label=filename).exists():
|
||||||
continue
|
continue # pragma: no cover
|
||||||
|
|
||||||
logger.info(f"Creating entry for StockItemLabel '{label['name']}'")
|
logger.info(f"Creating entry for StockItemLabel '{label['name']}'")
|
||||||
|
|
||||||
@@ -134,7 +141,7 @@ class LabelConfig(AppConfig):
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
from .models import StockLocationLabel
|
from .models import StockLocationLabel
|
||||||
except:
|
except AppRegistryNotReady: # pragma: no cover
|
||||||
# Database might not yet be ready
|
# Database might not yet be ready
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -192,7 +199,7 @@ class LabelConfig(AppConfig):
|
|||||||
# File already exists - let's see if it is the "same",
|
# File already exists - let's see if it is the "same",
|
||||||
# or if we need to overwrite it with a newer copy!
|
# or if we need to overwrite it with a newer copy!
|
||||||
|
|
||||||
if not hashFile(dst_file) == hashFile(src_file):
|
if not hashFile(dst_file) == hashFile(src_file): # pragma: no cover
|
||||||
logger.info(f"Hash differs for '{filename}'")
|
logger.info(f"Hash differs for '{filename}'")
|
||||||
to_copy = True
|
to_copy = True
|
||||||
|
|
||||||
@@ -206,7 +213,7 @@ class LabelConfig(AppConfig):
|
|||||||
|
|
||||||
# Check if a label matching the template already exists
|
# Check if a label matching the template already exists
|
||||||
if StockLocationLabel.objects.filter(label=filename).exists():
|
if StockLocationLabel.objects.filter(label=filename).exists():
|
||||||
continue
|
continue # pragma: no cover
|
||||||
|
|
||||||
logger.info(f"Creating entry for StockLocationLabel '{label['name']}'")
|
logger.info(f"Creating entry for StockLocationLabel '{label['name']}'")
|
||||||
|
|
||||||
@@ -228,7 +235,7 @@ class LabelConfig(AppConfig):
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
from .models import PartLabel
|
from .models import PartLabel
|
||||||
except:
|
except AppRegistryNotReady: # pragma: no cover
|
||||||
# Database might not yet be ready
|
# Database might not yet be ready
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -277,7 +284,7 @@ class LabelConfig(AppConfig):
|
|||||||
if os.path.exists(dst_file):
|
if os.path.exists(dst_file):
|
||||||
# File already exists - let's see if it is the "same"
|
# File already exists - let's see if it is the "same"
|
||||||
|
|
||||||
if not hashFile(dst_file) == hashFile(src_file):
|
if not hashFile(dst_file) == hashFile(src_file): # pragma: no cover
|
||||||
logger.info(f"Hash differs for '{filename}'")
|
logger.info(f"Hash differs for '{filename}'")
|
||||||
to_copy = True
|
to_copy = True
|
||||||
|
|
||||||
@@ -291,7 +298,7 @@ class LabelConfig(AppConfig):
|
|||||||
|
|
||||||
# Check if a label matching the template already exists
|
# Check if a label matching the template already exists
|
||||||
if PartLabel.objects.filter(label=filename).exists():
|
if PartLabel.objects.filter(label=filename).exists():
|
||||||
continue
|
continue # pragma: no cover
|
||||||
|
|
||||||
logger.info(f"Creating entry for PartLabel '{label['name']}'")
|
logger.info(f"Creating entry for PartLabel '{label['name']}'")
|
||||||
|
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ import part.models
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
from django_weasyprint import WeasyTemplateResponseMixin
|
from django_weasyprint import WeasyTemplateResponseMixin
|
||||||
except OSError as err:
|
except OSError as err: # pragma: no cover
|
||||||
print("OSError: {e}".format(e=err))
|
print("OSError: {e}".format(e=err))
|
||||||
print("You may require some further system packages to be installed.")
|
print("You may require some further system packages to be installed.")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|||||||
@@ -66,3 +66,39 @@ class TestReportTests(InvenTreeAPITestCase):
|
|||||||
'items': [10, 11, 12],
|
'items': [10, 11, 12],
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestLabels(InvenTreeAPITestCase):
|
||||||
|
"""
|
||||||
|
Tests for the label APIs
|
||||||
|
"""
|
||||||
|
|
||||||
|
fixtures = [
|
||||||
|
'category',
|
||||||
|
'part',
|
||||||
|
'location',
|
||||||
|
'stock',
|
||||||
|
]
|
||||||
|
|
||||||
|
roles = [
|
||||||
|
'stock.view',
|
||||||
|
'stock_location.view',
|
||||||
|
]
|
||||||
|
|
||||||
|
def do_list(self, filters={}):
|
||||||
|
|
||||||
|
response = self.client.get(self.list_url, filters, format='json')
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
return response.data
|
||||||
|
|
||||||
|
def test_lists(self):
|
||||||
|
self.list_url = reverse('api-stockitem-label-list')
|
||||||
|
self.do_list()
|
||||||
|
|
||||||
|
self.list_url = reverse('api-stocklocation-label-list')
|
||||||
|
self.do_list()
|
||||||
|
|
||||||
|
self.list_url = reverse('api-part-label-list')
|
||||||
|
self.do_list()
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import os
|
|||||||
|
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
from django.apps import apps
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
|
|
||||||
from InvenTree.helpers import validateFilterString
|
from InvenTree.helpers import validateFilterString
|
||||||
@@ -17,8 +18,11 @@ from stock.models import StockItem
|
|||||||
|
|
||||||
class LabelTest(TestCase):
|
class LabelTest(TestCase):
|
||||||
|
|
||||||
# TODO - Implement this test properly. Looks like apps.py is not run first
|
def setUp(self) -> None:
|
||||||
def _test_default_labels(self):
|
# ensure the labels were created
|
||||||
|
apps.get_app_config('label').create_labels()
|
||||||
|
|
||||||
|
def test_default_labels(self):
|
||||||
"""
|
"""
|
||||||
Test that the default label templates are copied across
|
Test that the default label templates are copied across
|
||||||
"""
|
"""
|
||||||
@@ -31,8 +35,7 @@ class LabelTest(TestCase):
|
|||||||
|
|
||||||
self.assertTrue(labels.count() > 0)
|
self.assertTrue(labels.count() > 0)
|
||||||
|
|
||||||
# TODO - Implement this test properly. Looks like apps.py is not run first
|
def test_default_files(self):
|
||||||
def _test_default_files(self):
|
|
||||||
"""
|
"""
|
||||||
Test that label files exist in the MEDIA directory
|
Test that label files exist in the MEDIA directory
|
||||||
"""
|
"""
|
||||||
|
|||||||
+1540
-1402
File diff suppressed because it is too large
Load Diff
+1433
-1295
File diff suppressed because it is too large
Load Diff
+3456
-3313
File diff suppressed because it is too large
Load Diff
+1449
-1311
File diff suppressed because it is too large
Load Diff
+1433
-1295
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
+1433
-1295
File diff suppressed because it is too large
Load Diff
+1438
-1301
File diff suppressed because it is too large
Load Diff
+1433
-1295
File diff suppressed because it is too large
Load Diff
+1454
-1316
File diff suppressed because it is too large
Load Diff
+1434
-1296
File diff suppressed because it is too large
Load Diff
+1653
-1515
File diff suppressed because it is too large
Load Diff
+1433
-1295
File diff suppressed because it is too large
Load Diff
+1433
-1295
File diff suppressed because it is too large
Load Diff
+1435
-1297
File diff suppressed because it is too large
Load Diff
+1433
-1295
File diff suppressed because it is too large
Load Diff
+1433
-1295
File diff suppressed because it is too large
Load Diff
+1435
-1297
File diff suppressed because it is too large
Load Diff
+1433
-1295
File diff suppressed because it is too large
Load Diff
+1436
-1298
File diff suppressed because it is too large
Load Diff
+2
-2
@@ -6,12 +6,12 @@ if __name__ == "__main__":
|
|||||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "InvenTree.settings")
|
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "InvenTree.settings")
|
||||||
try:
|
try:
|
||||||
from django.core.management import execute_from_command_line
|
from django.core.management import execute_from_command_line
|
||||||
except ImportError:
|
except ImportError: # pragma: no cover
|
||||||
# The above import may fail for some other reason. Ensure that the
|
# The above import may fail for some other reason. Ensure that the
|
||||||
# issue is really that Django is missing to avoid masking other
|
# issue is really that Django is missing to avoid masking other
|
||||||
# exceptions on Python 2.
|
# exceptions on Python 2.
|
||||||
try:
|
try:
|
||||||
import django # NOQA
|
import django # noqa: F401
|
||||||
except ImportError:
|
except ImportError:
|
||||||
raise ImportError(
|
raise ImportError(
|
||||||
"Couldn't import Django. Are you sure it's installed and "
|
"Couldn't import Django. Are you sure it's installed and "
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ def build_refs(apps, schema_editor):
|
|||||||
if result and len(result.groups()) == 1:
|
if result and len(result.groups()) == 1:
|
||||||
try:
|
try:
|
||||||
ref = int(result.groups()[0])
|
ref = int(result.groups()[0])
|
||||||
except:
|
except: # pragma: no cover
|
||||||
ref = 0
|
ref = 0
|
||||||
|
|
||||||
order.reference_int = ref
|
order.reference_int = ref
|
||||||
@@ -37,14 +37,14 @@ def build_refs(apps, schema_editor):
|
|||||||
if result and len(result.groups()) == 1:
|
if result and len(result.groups()) == 1:
|
||||||
try:
|
try:
|
||||||
ref = int(result.groups()[0])
|
ref = int(result.groups()[0])
|
||||||
except:
|
except: # pragma: no cover
|
||||||
ref = 0
|
ref = 0
|
||||||
|
|
||||||
order.reference_int = ref
|
order.reference_int = ref
|
||||||
order.save()
|
order.save()
|
||||||
|
|
||||||
|
|
||||||
def unbuild_refs(apps, schema_editor):
|
def unbuild_refs(apps, schema_editor): # pragma: no cover
|
||||||
"""
|
"""
|
||||||
Provided only for reverse migration compatibility
|
Provided only for reverse migration compatibility
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ def add_shipment(apps, schema_editor):
|
|||||||
line__order=order
|
line__order=order
|
||||||
)
|
)
|
||||||
|
|
||||||
if allocations.count() == 0 and order.status != SalesOrderStatus.PENDING:
|
if allocations.count() == 0 and order.status != SalesOrderStatus.PENDING: # pragma: no cover
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Create a new Shipment instance against this order
|
# Create a new Shipment instance against this order
|
||||||
@@ -41,13 +41,13 @@ def add_shipment(apps, schema_editor):
|
|||||||
order=order,
|
order=order,
|
||||||
)
|
)
|
||||||
|
|
||||||
if order.status == SalesOrderStatus.SHIPPED:
|
if order.status == SalesOrderStatus.SHIPPED: # pragma: no cover
|
||||||
shipment.shipment_date = order.shipment_date
|
shipment.shipment_date = order.shipment_date
|
||||||
|
|
||||||
shipment.save()
|
shipment.save()
|
||||||
|
|
||||||
# Iterate through each allocation associated with this order
|
# Iterate through each allocation associated with this order
|
||||||
for allocation in allocations:
|
for allocation in allocations: # pragma: no cover
|
||||||
allocation.shipment = shipment
|
allocation.shipment = shipment
|
||||||
allocation.save()
|
allocation.save()
|
||||||
|
|
||||||
@@ -57,7 +57,7 @@ def add_shipment(apps, schema_editor):
|
|||||||
print(f"\nCreated SalesOrderShipment for {n} SalesOrder instances")
|
print(f"\nCreated SalesOrderShipment for {n} SalesOrder instances")
|
||||||
|
|
||||||
|
|
||||||
def reverse_add_shipment(apps, schema_editor):
|
def reverse_add_shipment(apps, schema_editor): # pragma: no cover
|
||||||
"""
|
"""
|
||||||
Reverse the migration, delete and SalesOrderShipment instances
|
Reverse the migration, delete and SalesOrderShipment instances
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ def calculate_shipped_quantity(apps, schema_editor):
|
|||||||
StockItem = apps.get_model('stock', 'stockitem')
|
StockItem = apps.get_model('stock', 'stockitem')
|
||||||
SalesOrderLineItem = apps.get_model('order', 'salesorderlineitem')
|
SalesOrderLineItem = apps.get_model('order', 'salesorderlineitem')
|
||||||
|
|
||||||
for item in SalesOrderLineItem.objects.all():
|
for item in SalesOrderLineItem.objects.all(): # pragma: no cover
|
||||||
|
|
||||||
if item.order.status == SalesOrderStatus.SHIPPED:
|
if item.order.status == SalesOrderStatus.SHIPPED:
|
||||||
item.shipped = item.quantity
|
item.shipped = item.quantity
|
||||||
@@ -40,7 +40,7 @@ def calculate_shipped_quantity(apps, schema_editor):
|
|||||||
item.save()
|
item.save()
|
||||||
|
|
||||||
|
|
||||||
def reverse_calculate_shipped_quantity(apps, schema_editor):
|
def reverse_calculate_shipped_quantity(apps, schema_editor): # pragma: no cover
|
||||||
"""
|
"""
|
||||||
Provided only for reverse migration compatibility.
|
Provided only for reverse migration compatibility.
|
||||||
This function does nothing.
|
This function does nothing.
|
||||||
|
|||||||
@@ -48,7 +48,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
{% if order.status == PurchaseOrderStatus.PENDING and order.lines.count > 0 %}
|
{% if order.status == PurchaseOrderStatus.PENDING %}
|
||||||
<button type='button' class='btn btn-outline-secondary' id='place-order' title='{% trans "Place order" %}'>
|
<button type='button' class='btn btn-outline-secondary' id='place-order' title='{% trans "Place order" %}'>
|
||||||
<span class='fas fa-shopping-cart icon-blue'></span>
|
<span class='fas fa-shopping-cart icon-blue'></span>
|
||||||
</button>
|
</button>
|
||||||
@@ -178,7 +178,7 @@ src="{% static 'img/blank_image.png' %}"
|
|||||||
{{ block.super }}
|
{{ block.super }}
|
||||||
|
|
||||||
|
|
||||||
{% if order.status == PurchaseOrderStatus.PENDING and order.lines.count > 0 %}
|
{% if order.status == PurchaseOrderStatus.PENDING %}
|
||||||
$("#place-order").click(function() {
|
$("#place-order").click(function() {
|
||||||
launchModalForm("{% url 'po-issue' order.id %}",
|
launchModalForm("{% url 'po-issue' order.id %}",
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,99 +1,2 @@
|
|||||||
{% extends "order/order_wizard/po_upload.html" %}
|
{% extends "order/order_wizard/po_upload.html" %}
|
||||||
{% load inventree_extras %}
|
{% include "patterns/wizard/match_fields.html" %}
|
||||||
{% 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-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 %}
|
|
||||||
@@ -10,54 +10,11 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block page_content %}
|
{% block page_content %}
|
||||||
|
{% trans "Upload File for Purchase Order" as header_text %}
|
||||||
<div class='panel' id='panel-upload-file'>
|
{% order.status == PurchaseOrderStatus.PENDING and roles.purchase_order.change as upload_go_ahead %}
|
||||||
<div class='panel-heading'>
|
{% trans "Order is already processed. Files cannot be uploaded." as error_text %}
|
||||||
{% block heading %}
|
{% "panel-upload-file" as panel_id %}
|
||||||
<h4>{% trans "Upload File for Purchase Order" %}</h4>
|
{% include "patterns/wizard/upload.html" with header_text=header_text upload_go_ahead=upload_go_ahead error_text=error_text panel_id=panel_id %}
|
||||||
{{ wizard.form.media }}
|
|
||||||
{% endblock %}
|
|
||||||
</div>
|
|
||||||
<div class='panel-content'>
|
|
||||||
{% block details %}
|
|
||||||
{% if order.status == PurchaseOrderStatus.PENDING and roles.purchase_order.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-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 %}
|
|
||||||
|
|
||||||
{% else %}
|
|
||||||
<div class='alert alert-danger alert-block' role='alert'>
|
|
||||||
{% trans "Order is already processed. Files cannot be uploaded." %}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
{% endblock details %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block js_ready %}
|
{% block js_ready %}
|
||||||
|
|||||||
@@ -995,6 +995,23 @@ class PartList(generics.ListCreateAPIView):
|
|||||||
except (ValueError, Part.DoesNotExist):
|
except (ValueError, Part.DoesNotExist):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
# Filter only parts which are in the "BOM" for a given part
|
||||||
|
in_bom_for = params.get('in_bom_for', None)
|
||||||
|
|
||||||
|
if in_bom_for is not None:
|
||||||
|
try:
|
||||||
|
in_bom_for = Part.objects.get(pk=in_bom_for)
|
||||||
|
|
||||||
|
# Extract a list of parts within the BOM
|
||||||
|
bom_parts = in_bom_for.get_parts_in_bom()
|
||||||
|
print("bom_parts:", bom_parts)
|
||||||
|
print([p.pk for p in bom_parts])
|
||||||
|
|
||||||
|
queryset = queryset.filter(pk__in=[p.pk for p in bom_parts])
|
||||||
|
|
||||||
|
except (ValueError, Part.DoesNotExist):
|
||||||
|
pass
|
||||||
|
|
||||||
# Filter by whether the BOM has been validated (or not)
|
# Filter by whether the BOM has been validated (or not)
|
||||||
bom_valid = params.get('bom_valid', None)
|
bom_valid = params.get('bom_valid', None)
|
||||||
|
|
||||||
@@ -1533,6 +1550,49 @@ class BomList(generics.ListCreateAPIView):
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class BomImportUpload(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.BomImportUploadSerializer
|
||||||
|
|
||||||
|
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 BomImportExtract(generics.CreateAPIView):
|
||||||
|
"""
|
||||||
|
API endpoint for extracting BOM data from a BOM file.
|
||||||
|
"""
|
||||||
|
|
||||||
|
queryset = Part.objects.none()
|
||||||
|
serializer_class = part_serializers.BomImportExtractSerializer
|
||||||
|
|
||||||
|
|
||||||
|
class BomImportSubmit(generics.CreateAPIView):
|
||||||
|
"""
|
||||||
|
API endpoint for submitting BOM data from a BOM file
|
||||||
|
"""
|
||||||
|
|
||||||
|
queryset = BomItem.objects.none()
|
||||||
|
serializer_class = part_serializers.BomImportSubmitSerializer
|
||||||
|
|
||||||
|
|
||||||
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 +1745,11 @@ bom_api_urls = [
|
|||||||
url(r'^.*$', BomDetail.as_view(), name='api-bom-item-detail'),
|
url(r'^.*$', BomDetail.as_view(), name='api-bom-item-detail'),
|
||||||
])),
|
])),
|
||||||
|
|
||||||
|
# API endpoint URLs for importing BOM data
|
||||||
|
url(r'^import/upload/', BomImportUpload.as_view(), name='api-bom-import-upload'),
|
||||||
|
url(r'^import/extract/', BomImportExtract.as_view(), name='api-bom-import-extract'),
|
||||||
|
url(r'^import/submit/', BomImportSubmit.as_view(), name='api-bom-import-submit'),
|
||||||
|
|
||||||
# Catch-all
|
# Catch-all
|
||||||
url(r'^.*$', BomList.as_view(), name='api-bom-list'),
|
url(r'^.*$', BomList.as_view(), name='api-bom-list'),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -40,6 +40,6 @@ class PartConfig(AppConfig):
|
|||||||
item.part.trackable = True
|
item.part.trackable = True
|
||||||
item.part.clean()
|
item.part.clean()
|
||||||
item.part.save()
|
item.part.save()
|
||||||
except (OperationalError, ProgrammingError):
|
except (OperationalError, ProgrammingError): # pragma: no cover
|
||||||
# Exception if the database has not been migrated yet
|
# Exception if the database has not been migrated yet
|
||||||
pass
|
pass
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import InvenTree.validators
|
|||||||
import part.models
|
import part.models
|
||||||
|
|
||||||
|
|
||||||
def attach_file(instance, filename):
|
def attach_file(instance, filename): # pragma: no cover
|
||||||
"""
|
"""
|
||||||
Generate a filename for the uploaded attachment.
|
Generate a filename for the uploaded attachment.
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ def update_tree(apps, schema_editor):
|
|||||||
Part.objects.rebuild()
|
Part.objects.rebuild()
|
||||||
|
|
||||||
|
|
||||||
def nupdate_tree(apps, schema_editor):
|
def nupdate_tree(apps, schema_editor): # pragma: no cover
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
# Generated by Django 3.0.7 on 2020-11-10 11:25
|
# Generated by Django 3.0.7 on 2020-11-10 11:25
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
from django.db import migrations
|
from django.db import migrations
|
||||||
|
|
||||||
from moneyed import CURRENCIES
|
from moneyed import CURRENCIES
|
||||||
@@ -7,6 +9,9 @@ from django.db import migrations, connection
|
|||||||
from company.models import SupplierPriceBreak
|
from company.models import SupplierPriceBreak
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger('inventree')
|
||||||
|
|
||||||
|
|
||||||
def migrate_currencies(apps, schema_editor):
|
def migrate_currencies(apps, schema_editor):
|
||||||
"""
|
"""
|
||||||
Migrate from the 'old' method of handling currencies,
|
Migrate from the 'old' method of handling currencies,
|
||||||
@@ -19,7 +24,7 @@ def migrate_currencies(apps, schema_editor):
|
|||||||
for the SupplierPriceBreak model, to a new django-money compatible currency.
|
for the SupplierPriceBreak model, to a new django-money compatible currency.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
print("Updating currency references for SupplierPriceBreak model...")
|
logger.info("Updating currency references for SupplierPriceBreak model...")
|
||||||
|
|
||||||
# A list of available currency codes
|
# A list of available currency codes
|
||||||
currency_codes = CURRENCIES.keys()
|
currency_codes = CURRENCIES.keys()
|
||||||
@@ -33,7 +38,7 @@ def migrate_currencies(apps, schema_editor):
|
|||||||
|
|
||||||
remap = {}
|
remap = {}
|
||||||
|
|
||||||
for index, row in enumerate(results):
|
for index, row in enumerate(results): # pragma: no cover
|
||||||
pk, suffix, description = row
|
pk, suffix, description = row
|
||||||
|
|
||||||
suffix = suffix.strip().upper()
|
suffix = suffix.strip().upper()
|
||||||
@@ -57,7 +62,7 @@ def migrate_currencies(apps, schema_editor):
|
|||||||
|
|
||||||
count = 0
|
count = 0
|
||||||
|
|
||||||
for index, row in enumerate(results):
|
for index, row in enumerate(results): # pragma: no cover
|
||||||
pk, cost, currency_id, price, price_currency = row
|
pk, cost, currency_id, price, price_currency = row
|
||||||
|
|
||||||
# Copy the 'cost' field across to the 'price' field
|
# Copy the 'cost' field across to the 'price' field
|
||||||
@@ -71,10 +76,10 @@ def migrate_currencies(apps, schema_editor):
|
|||||||
|
|
||||||
count += 1
|
count += 1
|
||||||
|
|
||||||
if count > 0:
|
if count > 0: # pragma: no cover
|
||||||
print(f"Updated {count} SupplierPriceBreak rows")
|
print(f"Updated {count} SupplierPriceBreak rows")
|
||||||
|
|
||||||
def reverse_currencies(apps, schema_editor):
|
def reverse_currencies(apps, schema_editor): # pragma: no cover
|
||||||
"""
|
"""
|
||||||
Reverse the "update" process.
|
Reverse the "update" process.
|
||||||
|
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ from common.models import InvenTreeSetting
|
|||||||
|
|
||||||
from InvenTree import helpers
|
from InvenTree import helpers
|
||||||
from InvenTree import validators
|
from InvenTree import validators
|
||||||
from InvenTree.models import InvenTreeTree, InvenTreeAttachment
|
from InvenTree.models import InvenTreeTree, InvenTreeAttachment, DataImportMixin
|
||||||
from InvenTree.fields import InvenTreeURLField
|
from InvenTree.fields import InvenTreeURLField
|
||||||
from InvenTree.helpers import decimal2string, normalize, decimal2money
|
from InvenTree.helpers import decimal2string, normalize, decimal2money
|
||||||
import InvenTree.tasks
|
import InvenTree.tasks
|
||||||
@@ -483,6 +483,36 @@ class Part(MPTTModel):
|
|||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{self.full_name} - {self.description}"
|
return f"{self.full_name} - {self.description}"
|
||||||
|
|
||||||
|
def get_parts_in_bom(self):
|
||||||
|
"""
|
||||||
|
Return a list of all parts in the BOM for this part.
|
||||||
|
Takes into account substitutes, variant parts, and inherited BOM items
|
||||||
|
"""
|
||||||
|
|
||||||
|
parts = set()
|
||||||
|
|
||||||
|
for bom_item in self.get_bom_items():
|
||||||
|
for part in bom_item.get_valid_parts_for_allocation():
|
||||||
|
parts.add(part)
|
||||||
|
|
||||||
|
return parts
|
||||||
|
|
||||||
|
def check_if_part_in_bom(self, other_part):
|
||||||
|
"""
|
||||||
|
Check if the other_part is in the BOM for this part.
|
||||||
|
|
||||||
|
Note:
|
||||||
|
- Accounts for substitute parts
|
||||||
|
- Accounts for variant BOMs
|
||||||
|
"""
|
||||||
|
|
||||||
|
for bom_item in self.get_bom_items():
|
||||||
|
if other_part in bom_item.get_valid_parts_for_allocation():
|
||||||
|
return True
|
||||||
|
|
||||||
|
# No matches found
|
||||||
|
return False
|
||||||
|
|
||||||
def check_add_to_bom(self, parent, raise_error=False, recursive=True):
|
def check_add_to_bom(self, parent, raise_error=False, recursive=True):
|
||||||
"""
|
"""
|
||||||
Check if this Part can be added to the BOM of another part.
|
Check if this Part can be added to the BOM of another part.
|
||||||
@@ -1498,6 +1528,16 @@ class Part(MPTTModel):
|
|||||||
def has_bom(self):
|
def has_bom(self):
|
||||||
return self.get_bom_items().count() > 0
|
return self.get_bom_items().count() > 0
|
||||||
|
|
||||||
|
def get_trackable_parts(self):
|
||||||
|
"""
|
||||||
|
Return a queryset of all trackable parts in the BOM for this part
|
||||||
|
"""
|
||||||
|
|
||||||
|
queryset = self.get_bom_items()
|
||||||
|
queryset = queryset.filter(sub_part__trackable=True)
|
||||||
|
|
||||||
|
return queryset
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def has_trackable_parts(self):
|
def has_trackable_parts(self):
|
||||||
"""
|
"""
|
||||||
@@ -1505,11 +1545,7 @@ class Part(MPTTModel):
|
|||||||
This is important when building the part.
|
This is important when building the part.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
for bom_item in self.get_bom_items().all():
|
return self.get_trackable_parts().count() > 0
|
||||||
if bom_item.sub_part.trackable:
|
|
||||||
return True
|
|
||||||
|
|
||||||
return False
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def bom_count(self):
|
def bom_count(self):
|
||||||
@@ -2544,7 +2580,7 @@ class PartCategoryParameterTemplate(models.Model):
|
|||||||
help_text=_('Default Parameter Value'))
|
help_text=_('Default Parameter Value'))
|
||||||
|
|
||||||
|
|
||||||
class BomItem(models.Model):
|
class BomItem(models.Model, DataImportMixin):
|
||||||
""" A BomItem links a part to its component items.
|
""" A BomItem links a part to its component items.
|
||||||
A part can have a BOM (bill of materials) which defines
|
A part can have a BOM (bill of materials) which defines
|
||||||
which parts are required (and in what quantity) to make it.
|
which parts are required (and in what quantity) to make it.
|
||||||
@@ -2562,6 +2598,39 @@ class BomItem(models.Model):
|
|||||||
allow_variants: Stock for part variants can be substituted for this BomItem
|
allow_variants: Stock for part variants can be substituted for this BomItem
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
# Fields available for bulk import
|
||||||
|
IMPORT_FIELDS = {
|
||||||
|
'quantity': {
|
||||||
|
'required': True
|
||||||
|
},
|
||||||
|
'reference': {},
|
||||||
|
'overage': {},
|
||||||
|
'allow_variants': {},
|
||||||
|
'inherited': {},
|
||||||
|
'optional': {},
|
||||||
|
'note': {},
|
||||||
|
'part': {
|
||||||
|
'label': _('Part'),
|
||||||
|
'help_text': _('Part ID or part name'),
|
||||||
|
},
|
||||||
|
'part_id': {
|
||||||
|
'label': _('Part ID'),
|
||||||
|
'help_text': _('Unique part ID value')
|
||||||
|
},
|
||||||
|
'part_name': {
|
||||||
|
'label': _('Part Name'),
|
||||||
|
'help_text': _('Part name'),
|
||||||
|
},
|
||||||
|
'part_ipn': {
|
||||||
|
'label': _('Part IPN'),
|
||||||
|
'help_text': _('Part IPN value'),
|
||||||
|
},
|
||||||
|
'level': {
|
||||||
|
'label': _('Level'),
|
||||||
|
'help_text': _('BOM level'),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_api_url():
|
def get_api_url():
|
||||||
return reverse('api-bom-list')
|
return reverse('api-bom-list')
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import imghdr
|
|||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
|
||||||
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 _
|
||||||
@@ -15,7 +15,9 @@ from rest_framework import serializers
|
|||||||
from sql_util.utils import SubqueryCount, SubquerySum
|
from sql_util.utils import SubqueryCount, SubquerySum
|
||||||
from djmoney.contrib.django_rest_framework import MoneyField
|
from djmoney.contrib.django_rest_framework import MoneyField
|
||||||
|
|
||||||
from InvenTree.serializers import (InvenTreeAttachmentSerializerField,
|
from InvenTree.serializers import (DataFileUploadSerializer,
|
||||||
|
DataFileExtractSerializer,
|
||||||
|
InvenTreeAttachmentSerializerField,
|
||||||
InvenTreeDecimalField,
|
InvenTreeDecimalField,
|
||||||
InvenTreeImageSerializerField,
|
InvenTreeImageSerializerField,
|
||||||
InvenTreeModelSerializer,
|
InvenTreeModelSerializer,
|
||||||
@@ -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,169 @@ 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 BomImportUploadSerializer(DataFileUploadSerializer):
|
||||||
|
"""
|
||||||
|
Serializer for uploading a file and extracting data from it.
|
||||||
|
"""
|
||||||
|
|
||||||
|
TARGET_MODEL = BomItem
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
fields = [
|
||||||
|
'data_file',
|
||||||
|
'part',
|
||||||
|
'clear_existing_bom',
|
||||||
|
]
|
||||||
|
|
||||||
|
part = serializers.PrimaryKeyRelatedField(
|
||||||
|
queryset=Part.objects.all(),
|
||||||
|
required=True,
|
||||||
|
allow_null=False,
|
||||||
|
many=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
clear_existing_bom = serializers.BooleanField(
|
||||||
|
label=_('Clear Existing BOM'),
|
||||||
|
help_text=_('Delete existing BOM items before uploading')
|
||||||
|
)
|
||||||
|
|
||||||
|
def save(self):
|
||||||
|
|
||||||
|
data = self.validated_data
|
||||||
|
|
||||||
|
if data.get('clear_existing_bom', False):
|
||||||
|
part = data['part']
|
||||||
|
|
||||||
|
with transaction.atomic():
|
||||||
|
part.bom_items.all().delete()
|
||||||
|
|
||||||
|
|
||||||
|
class BomImportExtractSerializer(DataFileExtractSerializer):
|
||||||
|
"""
|
||||||
|
"""
|
||||||
|
|
||||||
|
TARGET_MODEL = BomItem
|
||||||
|
|
||||||
|
def validate_extracted_columns(self):
|
||||||
|
super().validate_extracted_columns()
|
||||||
|
|
||||||
|
part_columns = ['part', 'part_name', 'part_ipn', 'part_id']
|
||||||
|
|
||||||
|
if not any([col in self.columns for col in part_columns]):
|
||||||
|
# At least one part column is required!
|
||||||
|
raise serializers.ValidationError(_("No part column specified"))
|
||||||
|
|
||||||
|
def process_row(self, row):
|
||||||
|
|
||||||
|
# Skip any rows which are at a lower "level"
|
||||||
|
level = row.get('level', None)
|
||||||
|
|
||||||
|
if level is not None:
|
||||||
|
try:
|
||||||
|
level = int(level)
|
||||||
|
if level != 1:
|
||||||
|
# Skip this row
|
||||||
|
return None
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Attempt to extract a valid part based on the provided data
|
||||||
|
part_id = row.get('part_id', row.get('part', None))
|
||||||
|
part_name = row.get('part_name', row.get('part', None))
|
||||||
|
part_ipn = row.get('part_ipn', None)
|
||||||
|
|
||||||
|
part = None
|
||||||
|
|
||||||
|
if part_id is not None:
|
||||||
|
try:
|
||||||
|
part = Part.objects.get(pk=part_id)
|
||||||
|
except (ValueError, Part.DoesNotExist):
|
||||||
|
pass
|
||||||
|
|
||||||
|
# No direct match, where else can we look?
|
||||||
|
if part is None and (part_name or part_ipn):
|
||||||
|
queryset = Part.objects.all()
|
||||||
|
|
||||||
|
if part_name:
|
||||||
|
queryset = queryset.filter(name=part_name)
|
||||||
|
|
||||||
|
if part_ipn:
|
||||||
|
queryset = queryset.filter(IPN=part_ipn)
|
||||||
|
|
||||||
|
if queryset.exists():
|
||||||
|
if queryset.count() == 1:
|
||||||
|
part = queryset.first()
|
||||||
|
else:
|
||||||
|
row['errors']['part'] = _('Multiple matching parts found')
|
||||||
|
|
||||||
|
if part is None:
|
||||||
|
row['errors']['part'] = _('No matching part found')
|
||||||
|
else:
|
||||||
|
if not part.component:
|
||||||
|
row['errors']['part'] = _('Part is not designated as a component')
|
||||||
|
|
||||||
|
# Update the 'part' value in the row
|
||||||
|
row['part'] = part.pk if part is not None else None
|
||||||
|
|
||||||
|
# Check the provided 'quantity' value
|
||||||
|
quantity = row.get('quantity', None)
|
||||||
|
|
||||||
|
if quantity is None:
|
||||||
|
row['errors']['quantity'] = _('Quantity not provided')
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
quantity = Decimal(quantity)
|
||||||
|
|
||||||
|
if quantity <= 0:
|
||||||
|
row['errors']['quantity'] = _('Quantity must be greater than zero')
|
||||||
|
except:
|
||||||
|
row['errors']['quantity'] = _('Invalid quantity')
|
||||||
|
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
class BomImportSubmitSerializer(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,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 %}
|
|
||||||
@@ -1,99 +1,2 @@
|
|||||||
{% extends "part/import_wizard/part_upload.html" %}
|
{% extends "part/import_wizard/part_upload.html" %}
|
||||||
{% load inventree_extras %}
|
{% include "patterns/wizard/match_fields.html" %}
|
||||||
{% 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-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 %}
|
|
||||||
@@ -10,51 +10,10 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class='panel'>
|
{% trans "Import Parts from File" as header_text %}
|
||||||
<div class='panel-heading'>
|
{% roles.part.change as upload_go_ahead %}
|
||||||
<h4>
|
{% trans "Unsuffitient privileges." as error_text %}
|
||||||
{% trans "Import Parts from File" %}
|
{% include "patterns/wizard/upload.html" with header_text=header_text upload_go_ahead=upload_go_ahead error_text=error_text %}
|
||||||
{{ 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-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 %}
|
|
||||||
|
|
||||||
{% else %}
|
|
||||||
<div class='alert alert-danger alert-block' role='alert'>
|
|
||||||
{% trans "Unsuffitient privileges." %}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block js_ready %}
|
{% block js_ready %}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block breadcrumbs %}
|
{% block breadcrumbs %}
|
||||||
<a href='#' id='breadcrumb-tree-toggle' class="breadcrumb-item"><i class="fas fa-bars"></i></a>
|
<a href='#' id='breadcrumb-tree-toggle' class="breadcrumb-item"><span class="fas fa-bars"></span></a>
|
||||||
{% if part %}
|
{% if part %}
|
||||||
{% include "part/cat_link.html" with category=part.category part=part %}
|
{% include "part/cat_link.html" with category=part.category part=part %}
|
||||||
{% else %}
|
{% else %}
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
{% 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' disabled='true' id='bom-submit-icon' style='display: none;'>
|
||||||
|
<span class="fas fa-spin fa-circle-notch"></span>
|
||||||
|
</button>
|
||||||
|
<button type='button' class='btn btn-success' id='bom-submit' style='display: none;'>
|
||||||
|
<span class='fas fa-sign-in-alt' id='bom-submit-icon'></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-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-import-upload" %}', {
|
||||||
|
method: 'POST',
|
||||||
|
fields: {
|
||||||
|
data_file: {},
|
||||||
|
part: {
|
||||||
|
value: {{ part.pk }},
|
||||||
|
hidden: true,
|
||||||
|
},
|
||||||
|
clear_existing_bom: {},
|
||||||
|
},
|
||||||
|
title: '{% trans "Upload BOM File" %}',
|
||||||
|
onSuccess: function(response) {
|
||||||
|
|
||||||
|
// Clear existing entries from the table
|
||||||
|
$('.bom-import-row').remove();
|
||||||
|
|
||||||
|
selectImportFields(
|
||||||
|
'{% url "api-bom-import-extract" %}',
|
||||||
|
response,
|
||||||
|
{
|
||||||
|
success: function(response) {
|
||||||
|
constructBomUploadTable(response);
|
||||||
|
|
||||||
|
// Show the "submit" button
|
||||||
|
$('#bom-submit').show();
|
||||||
|
|
||||||
|
$('#bom-submit').click(function() {
|
||||||
|
submitBomTable({{ part.pk }}, {
|
||||||
|
bom_data: response,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
{% endblock js_ready %}
|
||||||
@@ -855,7 +855,7 @@ class PartAPIAggregationTest(InvenTreeAPITestCase):
|
|||||||
return part
|
return part
|
||||||
|
|
||||||
# We should never get here!
|
# We should never get here!
|
||||||
self.assertTrue(False)
|
self.assertTrue(False) # pragma: no cover
|
||||||
|
|
||||||
def test_stock_quantity(self):
|
def test_stock_quantity(self):
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -0,0 +1,344 @@
|
|||||||
|
"""
|
||||||
|
Unit testing for BOM upload / import functionality
|
||||||
|
"""
|
||||||
|
|
||||||
|
import tablib
|
||||||
|
|
||||||
|
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||||
|
from django.urls import reverse
|
||||||
|
|
||||||
|
from InvenTree.api_tester import InvenTreeAPITestCase
|
||||||
|
|
||||||
|
from part.models import Part
|
||||||
|
|
||||||
|
|
||||||
|
class BomUploadTest(InvenTreeAPITestCase):
|
||||||
|
"""
|
||||||
|
Test BOM file upload API endpoint
|
||||||
|
"""
|
||||||
|
|
||||||
|
roles = [
|
||||||
|
'part.add',
|
||||||
|
'part.change',
|
||||||
|
]
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
|
||||||
|
self.part = Part.objects.create(
|
||||||
|
name='Assembly',
|
||||||
|
description='An assembled part',
|
||||||
|
assembly=True,
|
||||||
|
component=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
for i in range(10):
|
||||||
|
Part.objects.create(
|
||||||
|
name=f"Component {i}",
|
||||||
|
IPN=f"CMP_{i}",
|
||||||
|
description="A subcomponent that can be used in a BOM",
|
||||||
|
component=True,
|
||||||
|
assembly=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
def post_bom(self, filename, file_data, clear_existing=None, expected_code=None, content_type='text/plain'):
|
||||||
|
|
||||||
|
bom_file = SimpleUploadedFile(
|
||||||
|
filename,
|
||||||
|
file_data,
|
||||||
|
content_type=content_type,
|
||||||
|
)
|
||||||
|
|
||||||
|
if clear_existing is None:
|
||||||
|
clear_existing = False
|
||||||
|
|
||||||
|
response = self.post(
|
||||||
|
reverse('api-bom-import-upload'),
|
||||||
|
data={
|
||||||
|
'data_file': bom_file,
|
||||||
|
},
|
||||||
|
expected_code=expected_code,
|
||||||
|
format='multipart',
|
||||||
|
)
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
def test_missing_file(self):
|
||||||
|
"""
|
||||||
|
POST without a file
|
||||||
|
"""
|
||||||
|
|
||||||
|
response = self.post(
|
||||||
|
reverse('api-bom-import-upload'),
|
||||||
|
data={},
|
||||||
|
expected_code=400
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIn('No file was submitted', str(response.data['data_file']))
|
||||||
|
|
||||||
|
def test_unsupported_file(self):
|
||||||
|
"""
|
||||||
|
POST with an unsupported file type
|
||||||
|
"""
|
||||||
|
|
||||||
|
response = self.post_bom(
|
||||||
|
'sample.txt',
|
||||||
|
b'hello world',
|
||||||
|
expected_code=400,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIn('Unsupported file type', str(response.data['data_file']))
|
||||||
|
|
||||||
|
def test_broken_file(self):
|
||||||
|
"""
|
||||||
|
Test upload with broken (corrupted) files
|
||||||
|
"""
|
||||||
|
|
||||||
|
response = self.post_bom(
|
||||||
|
'sample.csv',
|
||||||
|
b'',
|
||||||
|
expected_code=400,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIn('The submitted file is empty', str(response.data['data_file']))
|
||||||
|
|
||||||
|
response = self.post_bom(
|
||||||
|
'test.xls',
|
||||||
|
b'hello world',
|
||||||
|
expected_code=400,
|
||||||
|
content_type='application/xls',
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIn('Unsupported format, or corrupt file', str(response.data['data_file']))
|
||||||
|
|
||||||
|
def test_missing_rows(self):
|
||||||
|
"""
|
||||||
|
Test upload of an invalid file (without data rows)
|
||||||
|
"""
|
||||||
|
|
||||||
|
dataset = tablib.Dataset()
|
||||||
|
|
||||||
|
dataset.headers = [
|
||||||
|
'apple',
|
||||||
|
'banana',
|
||||||
|
]
|
||||||
|
|
||||||
|
response = self.post_bom(
|
||||||
|
'test.csv',
|
||||||
|
bytes(dataset.csv, 'utf8'),
|
||||||
|
content_type='text/csv',
|
||||||
|
expected_code=400,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIn('No data rows found in file', str(response.data))
|
||||||
|
|
||||||
|
# Try again, with an .xlsx file
|
||||||
|
response = self.post_bom(
|
||||||
|
'bom.xlsx',
|
||||||
|
dataset.xlsx,
|
||||||
|
content_type='application/xlsx',
|
||||||
|
expected_code=400,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIn('No data rows found in file', str(response.data))
|
||||||
|
|
||||||
|
def test_missing_columns(self):
|
||||||
|
"""
|
||||||
|
Upload extracted data, but with missing columns
|
||||||
|
"""
|
||||||
|
|
||||||
|
url = reverse('api-bom-import-extract')
|
||||||
|
|
||||||
|
rows = [
|
||||||
|
['1', 'test'],
|
||||||
|
['2', 'test'],
|
||||||
|
]
|
||||||
|
|
||||||
|
# Post without columns
|
||||||
|
response = self.post(
|
||||||
|
url,
|
||||||
|
{},
|
||||||
|
expected_code=400,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIn('This field is required', str(response.data['rows']))
|
||||||
|
self.assertIn('This field is required', str(response.data['columns']))
|
||||||
|
|
||||||
|
response = self.post(
|
||||||
|
url,
|
||||||
|
{
|
||||||
|
'rows': rows,
|
||||||
|
'columns': ['part', 'reference'],
|
||||||
|
},
|
||||||
|
expected_code=400
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIn("Missing required column: 'quantity'", str(response.data))
|
||||||
|
|
||||||
|
response = self.post(
|
||||||
|
url,
|
||||||
|
{
|
||||||
|
'rows': rows,
|
||||||
|
'columns': ['quantity', 'reference'],
|
||||||
|
},
|
||||||
|
expected_code=400,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIn('No part column specified', str(response.data))
|
||||||
|
|
||||||
|
self.post(
|
||||||
|
url,
|
||||||
|
{
|
||||||
|
'rows': rows,
|
||||||
|
'columns': ['quantity', 'part'],
|
||||||
|
},
|
||||||
|
expected_code=201,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_invalid_data(self):
|
||||||
|
"""
|
||||||
|
Upload data which contains errors
|
||||||
|
"""
|
||||||
|
|
||||||
|
dataset = tablib.Dataset()
|
||||||
|
|
||||||
|
# Only these headers are strictly necessary
|
||||||
|
dataset.headers = ['part_id', 'quantity']
|
||||||
|
|
||||||
|
components = Part.objects.filter(component=True)
|
||||||
|
|
||||||
|
for idx, cmp in enumerate(components):
|
||||||
|
|
||||||
|
if idx == 5:
|
||||||
|
cmp.component = False
|
||||||
|
cmp.save()
|
||||||
|
|
||||||
|
dataset.append([cmp.pk, idx])
|
||||||
|
|
||||||
|
url = reverse('api-bom-import-extract')
|
||||||
|
|
||||||
|
response = self.post(
|
||||||
|
url,
|
||||||
|
{
|
||||||
|
'columns': dataset.headers,
|
||||||
|
'rows': [row for row in dataset],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
rows = response.data['rows']
|
||||||
|
|
||||||
|
# Returned data must be the same as the original dataset
|
||||||
|
self.assertEqual(len(rows), len(dataset))
|
||||||
|
|
||||||
|
for idx, row in enumerate(rows):
|
||||||
|
data = row['data']
|
||||||
|
cmp = components[idx]
|
||||||
|
|
||||||
|
# Should have guessed the correct part
|
||||||
|
data['part'] = cmp.pk
|
||||||
|
|
||||||
|
# Check some specific error messages
|
||||||
|
self.assertEqual(rows[0]['data']['errors']['quantity'], 'Quantity must be greater than zero')
|
||||||
|
self.assertEqual(rows[5]['data']['errors']['part'], 'Part is not designated as a component')
|
||||||
|
|
||||||
|
def test_part_guess(self):
|
||||||
|
"""
|
||||||
|
Test part 'guessing' when PK values are not supplied
|
||||||
|
"""
|
||||||
|
|
||||||
|
dataset = tablib.Dataset()
|
||||||
|
|
||||||
|
# Should be able to 'guess' the part from the name
|
||||||
|
dataset.headers = ['part_name', 'quantity']
|
||||||
|
|
||||||
|
components = Part.objects.filter(component=True)
|
||||||
|
|
||||||
|
for idx, cmp in enumerate(components):
|
||||||
|
dataset.append([
|
||||||
|
f"Component {idx}",
|
||||||
|
10,
|
||||||
|
])
|
||||||
|
|
||||||
|
url = reverse('api-bom-import-extract')
|
||||||
|
|
||||||
|
response = self.post(
|
||||||
|
url,
|
||||||
|
{
|
||||||
|
'columns': dataset.headers,
|
||||||
|
'rows': [row for row in dataset],
|
||||||
|
},
|
||||||
|
expected_code=201,
|
||||||
|
)
|
||||||
|
|
||||||
|
rows = response.data['rows']
|
||||||
|
|
||||||
|
self.assertEqual(len(rows), 10)
|
||||||
|
|
||||||
|
for idx in range(10):
|
||||||
|
self.assertEqual(rows[idx]['data']['part'], components[idx].pk)
|
||||||
|
|
||||||
|
# Should also be able to 'guess' part by the IPN value
|
||||||
|
dataset = tablib.Dataset()
|
||||||
|
|
||||||
|
dataset.headers = ['part_ipn', 'quantity']
|
||||||
|
|
||||||
|
for idx, cmp in enumerate(components):
|
||||||
|
dataset.append([
|
||||||
|
f"CMP_{idx}",
|
||||||
|
10,
|
||||||
|
])
|
||||||
|
|
||||||
|
response = self.post(
|
||||||
|
url,
|
||||||
|
{
|
||||||
|
'columns': dataset.headers,
|
||||||
|
'rows': [row for row in dataset],
|
||||||
|
},
|
||||||
|
expected_code=201,
|
||||||
|
)
|
||||||
|
|
||||||
|
rows = response.data['rows']
|
||||||
|
|
||||||
|
self.assertEqual(len(rows), 10)
|
||||||
|
|
||||||
|
for idx in range(10):
|
||||||
|
self.assertEqual(rows[idx]['data']['part'], components[idx].pk)
|
||||||
|
|
||||||
|
def test_levels(self):
|
||||||
|
"""
|
||||||
|
Test that multi-level BOMs are correctly handled during upload
|
||||||
|
"""
|
||||||
|
|
||||||
|
url = reverse('api-bom-import-extract')
|
||||||
|
|
||||||
|
dataset = tablib.Dataset()
|
||||||
|
|
||||||
|
dataset.headers = ['level', 'part', 'quantity']
|
||||||
|
|
||||||
|
components = Part.objects.filter(component=True)
|
||||||
|
|
||||||
|
for idx, cmp in enumerate(components):
|
||||||
|
dataset.append([
|
||||||
|
idx % 3,
|
||||||
|
cmp.pk,
|
||||||
|
2,
|
||||||
|
])
|
||||||
|
|
||||||
|
response = self.post(
|
||||||
|
url,
|
||||||
|
{
|
||||||
|
'rows': [row for row in dataset],
|
||||||
|
'columns': dataset.headers,
|
||||||
|
},
|
||||||
|
expected_code=201,
|
||||||
|
)
|
||||||
|
|
||||||
|
rows = response.data['rows']
|
||||||
|
|
||||||
|
# Only parts at index 1, 4, 7 should have been returned
|
||||||
|
self.assertEqual(len(response.data['rows']), 3)
|
||||||
|
|
||||||
|
# Check the returned PK values
|
||||||
|
self.assertEqual(rows[0]['data']['part'], components[1].pk)
|
||||||
|
self.assertEqual(rows[1]['data']['part'], components[4].pk)
|
||||||
|
self.assertEqual(rows[2]['data']['part'], components[7].pk)
|
||||||
@@ -55,7 +55,7 @@ class BomItemTest(TestCase):
|
|||||||
with self.assertRaises(django_exceptions.ValidationError):
|
with self.assertRaises(django_exceptions.ValidationError):
|
||||||
# A validation error should be raised here
|
# A validation error should be raised here
|
||||||
item = BomItem.objects.create(part=self.bob, sub_part=self.bob, quantity=7)
|
item = BomItem.objects.create(part=self.bob, sub_part=self.bob, quantity=7)
|
||||||
item.clean()
|
item.clean() # pragma: no cover
|
||||||
|
|
||||||
def test_integer_quantity(self):
|
def test_integer_quantity(self):
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -127,7 +127,7 @@ class CategoryTest(TestCase):
|
|||||||
|
|
||||||
with self.assertRaises(ValidationError) as err:
|
with self.assertRaises(ValidationError) as err:
|
||||||
cat.full_clean()
|
cat.full_clean()
|
||||||
cat.save()
|
cat.save() # pragma: no cover
|
||||||
|
|
||||||
self.assertIn('Illegal character in name', str(err.exception.error_dict.get('name')))
|
self.assertIn('Illegal character in name', str(err.exception.error_dict.get('name')))
|
||||||
|
|
||||||
@@ -160,10 +160,6 @@ class CategoryTest(TestCase):
|
|||||||
|
|
||||||
self.assertEqual(str(self.fasteners.default_location), 'Office/Drawer_1 - In my desk')
|
self.assertEqual(str(self.fasteners.default_location), 'Office/Drawer_1 - In my desk')
|
||||||
|
|
||||||
# Test that parts in this location return the same default location, too
|
|
||||||
for p in self.fasteners.children.all():
|
|
||||||
self.assert_equal(p.get_default_location().pathstring, 'Office/Drawer_1')
|
|
||||||
|
|
||||||
# Any part under electronics should default to 'Home'
|
# Any part under electronics should default to 'Home'
|
||||||
r1 = Part.objects.get(name='R_2K2_0805')
|
r1 = Part.objects.get(name='R_2K2_0805')
|
||||||
self.assertIsNone(r1.default_location)
|
self.assertIsNone(r1.default_location)
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ class TestParams(TestCase):
|
|||||||
with self.assertRaises(django_exceptions.ValidationError):
|
with self.assertRaises(django_exceptions.ValidationError):
|
||||||
t3 = PartParameterTemplate(name='aBcde', units='dd')
|
t3 = PartParameterTemplate(name='aBcde', units='dd')
|
||||||
t3.full_clean()
|
t3.full_clean()
|
||||||
t3.save()
|
t3.save() # pragma: no cover
|
||||||
|
|
||||||
|
|
||||||
class TestCategoryTemplates(TransactionTestCase):
|
class TestCategoryTemplates(TransactionTestCase):
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ class PartTest(TestCase):
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
part.save()
|
part.save()
|
||||||
self.assertTrue(False)
|
self.assertTrue(False) # pragma: no cover
|
||||||
except:
|
except:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
|||||||
+7
-317
@@ -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 """
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ class PluginAppConfig(AppConfig):
|
|||||||
def ready(self):
|
def ready(self):
|
||||||
if settings.PLUGINS_ENABLED:
|
if settings.PLUGINS_ENABLED:
|
||||||
|
|
||||||
if isImportingData():
|
if isImportingData(): # pragma: no cover
|
||||||
logger.info('Skipping plugin loading for data import')
|
logger.info('Skipping plugin loading for data import')
|
||||||
else:
|
else:
|
||||||
logger.info('Loading InvenTree plugins')
|
logger.info('Loading InvenTree plugins')
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ class SettingsMixin:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
plugin, _ = PluginConfig.objects.get_or_create(key=self.plugin_slug(), name=self.plugin_name())
|
plugin, _ = PluginConfig.objects.get_or_create(key=self.plugin_slug(), name=self.plugin_name())
|
||||||
except (OperationalError, ProgrammingError):
|
except (OperationalError, ProgrammingError): # pragma: no cover
|
||||||
plugin = None
|
plugin = None
|
||||||
|
|
||||||
if not plugin:
|
if not plugin:
|
||||||
|
|||||||
+10
-13
@@ -23,7 +23,7 @@ class IntegrationPluginError(Exception):
|
|||||||
self.message = message
|
self.message = message
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.message
|
return self.message # pragma: no cover
|
||||||
|
|
||||||
|
|
||||||
class MixinImplementationError(ValueError):
|
class MixinImplementationError(ValueError):
|
||||||
@@ -55,7 +55,7 @@ def log_error(error, reference: str = 'general'):
|
|||||||
registry.errors[reference].append(error)
|
registry.errors[reference].append(error)
|
||||||
|
|
||||||
|
|
||||||
def handle_error(error, do_raise: bool = True, do_log: bool = True, do_return: bool = False, log_name: str = ''):
|
def handle_error(error, do_raise: bool = True, do_log: bool = True, log_name: str = ''):
|
||||||
"""
|
"""
|
||||||
Handles an error and casts it as an IntegrationPluginError
|
Handles an error and casts it as an IntegrationPluginError
|
||||||
"""
|
"""
|
||||||
@@ -69,7 +69,7 @@ def handle_error(error, do_raise: bool = True, do_log: bool = True, do_return: b
|
|||||||
path_parts = [*path_obj.parts]
|
path_parts = [*path_obj.parts]
|
||||||
path_parts[-1] = path_parts[-1].replace(path_obj.suffix, '') # remove suffix
|
path_parts[-1] = path_parts[-1].replace(path_obj.suffix, '') # remove suffix
|
||||||
|
|
||||||
# remove path preixes
|
# remove path prefixes
|
||||||
if path_parts[0] == 'plugin':
|
if path_parts[0] == 'plugin':
|
||||||
path_parts.remove('plugin')
|
path_parts.remove('plugin')
|
||||||
path_parts.pop(0)
|
path_parts.pop(0)
|
||||||
@@ -84,13 +84,8 @@ def handle_error(error, do_raise: bool = True, do_log: bool = True, do_return: b
|
|||||||
log_kwargs['reference'] = log_name
|
log_kwargs['reference'] = log_name
|
||||||
log_error({package_name: str(error)}, **log_kwargs)
|
log_error({package_name: str(error)}, **log_kwargs)
|
||||||
|
|
||||||
new_error = IntegrationPluginError(package_name, str(error))
|
|
||||||
|
|
||||||
if do_raise:
|
if do_raise:
|
||||||
raise IntegrationPluginError(package_name, str(error))
|
raise IntegrationPluginError(package_name, str(error))
|
||||||
|
|
||||||
if do_return:
|
|
||||||
return new_error
|
|
||||||
# endregion
|
# endregion
|
||||||
|
|
||||||
|
|
||||||
@@ -101,14 +96,16 @@ def get_git_log(path):
|
|||||||
"""
|
"""
|
||||||
path = path.replace(os.path.dirname(settings.BASE_DIR), '')[1:]
|
path = path.replace(os.path.dirname(settings.BASE_DIR), '')[1:]
|
||||||
command = ['git', 'log', '-n', '1', "--pretty=format:'%H%n%aN%n%aE%n%aI%n%f%n%G?%n%GK'", '--follow', '--', path]
|
command = ['git', 'log', '-n', '1', "--pretty=format:'%H%n%aN%n%aE%n%aI%n%f%n%G?%n%GK'", '--follow', '--', path]
|
||||||
|
output = None
|
||||||
try:
|
try:
|
||||||
output = str(subprocess.check_output(command, cwd=os.path.dirname(settings.BASE_DIR)), 'utf-8')[1:-1]
|
output = str(subprocess.check_output(command, cwd=os.path.dirname(settings.BASE_DIR)), 'utf-8')[1:-1]
|
||||||
if output:
|
if output:
|
||||||
output = output.split('\n')
|
output = output.split('\n')
|
||||||
else:
|
except subprocess.CalledProcessError: # pragma: no cover
|
||||||
output = 7 * ['']
|
pass
|
||||||
except subprocess.CalledProcessError:
|
|
||||||
output = 7 * ['']
|
if not output:
|
||||||
|
output = 7 * [''] # pragma: no cover
|
||||||
return {'hash': output[0], 'author': output[1], 'mail': output[2], 'date': output[3], 'message': output[4], 'verified': output[5], 'key': output[6]}
|
return {'hash': output[0], 'author': output[1], 'mail': output[2], 'date': output[3], 'message': output[4], 'verified': output[5], 'key': output[6]}
|
||||||
|
|
||||||
|
|
||||||
@@ -153,7 +150,7 @@ def get_modules(pkg):
|
|||||||
if not k.startswith('_') and (pkg_names is None or k in pkg_names):
|
if not k.startswith('_') and (pkg_names is None or k in pkg_names):
|
||||||
context[k] = v
|
context[k] = v
|
||||||
context[name] = module
|
context[name] = module
|
||||||
except AppRegistryNotReady:
|
except AppRegistryNotReady: # pragma: no cover
|
||||||
pass
|
pass
|
||||||
except Exception as error:
|
except Exception as error:
|
||||||
# this 'protects' against malformed plugin modules by more or less silently failing
|
# this 'protects' against malformed plugin modules by more or less silently failing
|
||||||
|
|||||||
@@ -135,7 +135,7 @@ class IntegrationPluginBase(MixinBase, plugin_base.InvenTreePluginBase):
|
|||||||
if not author:
|
if not author:
|
||||||
author = self.package.get('author')
|
author = self.package.get('author')
|
||||||
if not author:
|
if not author:
|
||||||
author = _('No author found')
|
author = _('No author found') # pragma: no cover
|
||||||
return author
|
return author
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -149,7 +149,7 @@ class IntegrationPluginBase(MixinBase, plugin_base.InvenTreePluginBase):
|
|||||||
else:
|
else:
|
||||||
pub_date = datetime.fromisoformat(str(pub_date))
|
pub_date = datetime.fromisoformat(str(pub_date))
|
||||||
if not pub_date:
|
if not pub_date:
|
||||||
pub_date = _('No date found')
|
pub_date = _('No date found') # pragma: no cover
|
||||||
return pub_date
|
return pub_date
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -226,7 +226,7 @@ class IntegrationPluginBase(MixinBase, plugin_base.InvenTreePluginBase):
|
|||||||
"""
|
"""
|
||||||
Get package metadata for plugin
|
Get package metadata for plugin
|
||||||
"""
|
"""
|
||||||
return {}
|
return {} # pragma: no cover # TODO add usage for package metadata
|
||||||
|
|
||||||
def define_package(self):
|
def define_package(self):
|
||||||
"""
|
"""
|
||||||
@@ -241,11 +241,11 @@ class IntegrationPluginBase(MixinBase, plugin_base.InvenTreePluginBase):
|
|||||||
# process sign state
|
# process sign state
|
||||||
sign_state = getattr(GitStatus, str(package.get('verified')), GitStatus.N)
|
sign_state = getattr(GitStatus, str(package.get('verified')), GitStatus.N)
|
||||||
if sign_state.status == 0:
|
if sign_state.status == 0:
|
||||||
self.sign_color = 'success'
|
self.sign_color = 'success' # pragma: no cover
|
||||||
elif sign_state.status == 1:
|
elif sign_state.status == 1:
|
||||||
self.sign_color = 'warning'
|
self.sign_color = 'warning'
|
||||||
else:
|
else:
|
||||||
self.sign_color = 'danger'
|
self.sign_color = 'danger' # pragma: no cover
|
||||||
|
|
||||||
# set variables
|
# set variables
|
||||||
self.package = package
|
self.package = package
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user