mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +00:00
Merge remote-tracking branch 'inventree/master'
This commit is contained in:
commit
dc6996290f
2
.github/FUNDING.yml
vendored
Normal file
2
.github/FUNDING.yml
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
patreon: inventree
|
||||
ko_fi: inventree
|
2
.github/workflows/docker_stable.yaml
vendored
2
.github/workflows/docker_stable.yaml
vendored
@ -36,7 +36,7 @@ jobs:
|
||||
push: true
|
||||
target: production
|
||||
build-args:
|
||||
branch: stable
|
||||
branch=stable
|
||||
tags: inventree/inventree:stable
|
||||
- name: Image Digest
|
||||
run: echo ${{ steps.docker_build.outputs.digest }}
|
||||
|
2
.github/workflows/docker_tag.yaml
vendored
2
.github/workflows/docker_tag.yaml
vendored
@ -34,5 +34,5 @@ jobs:
|
||||
push: true
|
||||
target: production
|
||||
build-args:
|
||||
tag: ${{ github.event.release.tag_name }}
|
||||
tag=${{ github.event.release.tag_name }}
|
||||
tags: inventree/inventree:${{ github.event.release.tag_name }}
|
||||
|
@ -106,12 +106,12 @@ class InvenTreeAPITestCase(APITestCase):
|
||||
|
||||
return response
|
||||
|
||||
def post(self, url, data, expected_code=None):
|
||||
def post(self, url, data, expected_code=None, format='json'):
|
||||
"""
|
||||
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:
|
||||
self.assertEqual(response.status_code, expected_code)
|
||||
@ -130,12 +130,12 @@ class InvenTreeAPITestCase(APITestCase):
|
||||
|
||||
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
|
||||
"""
|
||||
|
||||
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:
|
||||
self.assertEqual(response.status_code, expected_code)
|
||||
|
@ -38,7 +38,7 @@ class InvenTreeConfig(AppConfig):
|
||||
|
||||
try:
|
||||
from django_q.models import Schedule
|
||||
except (AppRegistryNotReady):
|
||||
except AppRegistryNotReady: # pragma: no cover
|
||||
return
|
||||
|
||||
# Remove any existing obsolete tasks
|
||||
@ -48,7 +48,7 @@ class InvenTreeConfig(AppConfig):
|
||||
|
||||
try:
|
||||
from django_q.models import Schedule
|
||||
except (AppRegistryNotReady):
|
||||
except AppRegistryNotReady: # pragma: no cover
|
||||
return
|
||||
|
||||
logger.info("Starting background tasks...")
|
||||
@ -100,10 +100,10 @@ class InvenTreeConfig(AppConfig):
|
||||
|
||||
try:
|
||||
from djmoney.contrib.exchange.models import ExchangeBackend
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from InvenTree.tasks import update_exchange_rates
|
||||
from common.settings import currency_code_default
|
||||
except AppRegistryNotReady:
|
||||
except AppRegistryNotReady: # pragma: no cover
|
||||
pass
|
||||
|
||||
base_currency = currency_code_default()
|
||||
@ -115,23 +115,18 @@ class InvenTreeConfig(AppConfig):
|
||||
|
||||
last_update = backend.last_update
|
||||
|
||||
if last_update is not None:
|
||||
delta = datetime.now().date() - last_update.date()
|
||||
if delta > timedelta(days=1):
|
||||
print(f"Last update was {last_update}")
|
||||
update = True
|
||||
else:
|
||||
if last_update is None:
|
||||
# Never been updated
|
||||
print("Exchange backend has never been updated")
|
||||
logger.info("Exchange backend has never been updated")
|
||||
update = True
|
||||
|
||||
# Backend currency has changed?
|
||||
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
|
||||
|
||||
except (ExchangeBackend.DoesNotExist):
|
||||
print("Exchange backend not found - updating")
|
||||
logger.info("Exchange backend not found - updating")
|
||||
update = True
|
||||
|
||||
except:
|
||||
@ -139,4 +134,7 @@ class InvenTreeConfig(AppConfig):
|
||||
return
|
||||
|
||||
if update:
|
||||
update_exchange_rates()
|
||||
try:
|
||||
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
|
||||
only used for testing the js files! - This file is omited from coverage
|
||||
"""
|
||||
|
||||
from django.test import TestCase
|
||||
|
@ -23,7 +23,7 @@ def health_status(request):
|
||||
|
||||
if request.path.endswith('.js'):
|
||||
# Do not provide to script requests
|
||||
return {}
|
||||
return {} # pragma: no cover
|
||||
|
||||
if hasattr(request, '_inventree_health_status'):
|
||||
# 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 urllib.error import URLError
|
||||
|
||||
@ -24,6 +28,22 @@ class InvenTreeExchange(SimpleExchangeBackend):
|
||||
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()):
|
||||
|
||||
symbols = ','.join(currency_codes())
|
||||
|
@ -475,7 +475,6 @@ def extract_serial_numbers(serials, expected_quantity, next_number: int):
|
||||
continue
|
||||
else:
|
||||
errors.append(_("Invalid group: {g}").format(g=group))
|
||||
continue
|
||||
|
||||
# plus signals either
|
||||
# 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
|
||||
else:
|
||||
errors.append(_("Invalid group: {g}").format(g=group))
|
||||
continue
|
||||
|
||||
# Group should be a number
|
||||
elif group:
|
||||
|
@ -45,6 +45,62 @@ def rename_attachment(instance, 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):
|
||||
"""
|
||||
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 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
|
||||
import os
|
||||
import tablib
|
||||
|
||||
from decimal import Decimal
|
||||
|
||||
@ -328,4 +328,313 @@ class InvenTreeDecimalField(serializers.FloatField):
|
||||
def to_internal_value(self, data):
|
||||
|
||||
# Convert the value to a string, and then a decimal
|
||||
return Decimal(str(data))
|
||||
try:
|
||||
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']:
|
||||
log_level = 'WARNING'
|
||||
log_level = 'WARNING' # pragma: no cover
|
||||
|
||||
LOGGING = {
|
||||
'version': 1,
|
||||
@ -119,20 +119,20 @@ d) Create "secret_key.txt" if it does not exist
|
||||
|
||||
if os.getenv("INVENTREE_SECRET_KEY"):
|
||||
# Secret key passed in directly
|
||||
SECRET_KEY = os.getenv("INVENTREE_SECRET_KEY").strip()
|
||||
logger.info("SECRET_KEY loaded by INVENTREE_SECRET_KEY")
|
||||
SECRET_KEY = os.getenv("INVENTREE_SECRET_KEY").strip() # pragma: no cover
|
||||
logger.info("SECRET_KEY loaded by INVENTREE_SECRET_KEY") # pragma: no cover
|
||||
else:
|
||||
# Secret key passed in by file location
|
||||
key_file = os.getenv("INVENTREE_SECRET_KEY_FILE")
|
||||
|
||||
if key_file:
|
||||
key_file = os.path.abspath(key_file)
|
||||
key_file = os.path.abspath(key_file) # pragma: no cover
|
||||
else:
|
||||
# default secret key location
|
||||
key_file = os.path.join(BASE_DIR, "secret_key.txt")
|
||||
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}'")
|
||||
# Create a random key file
|
||||
with open(key_file, 'w') as f:
|
||||
@ -144,7 +144,7 @@ else:
|
||||
|
||||
try:
|
||||
SECRET_KEY = open(key_file, "r").read().strip()
|
||||
except Exception:
|
||||
except Exception: # pragma: no cover
|
||||
logger.exception(f"Couldn't load keyfile {key_file}")
|
||||
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")
|
||||
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")
|
||||
sys.exit(1)
|
||||
|
||||
@ -187,7 +187,7 @@ if cors_opt:
|
||||
CORS_ORIGIN_ALLOW_ALL = cors_opt.get('allow_all', False)
|
||||
|
||||
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
|
||||
STATIC_URL = '/static/'
|
||||
@ -215,7 +215,7 @@ if DEBUG:
|
||||
logger.info("InvenTree running with DEBUG enabled")
|
||||
|
||||
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"STATIC_ROOT: '{STATIC_ROOT}'")
|
||||
@ -304,7 +304,7 @@ AUTHENTICATION_BACKENDS = CONFIG.get('authentication_backends', [
|
||||
])
|
||||
|
||||
# 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")
|
||||
INSTALLED_APPS.append('debug_toolbar')
|
||||
MIDDLEWARE.append('debug_toolbar.middleware.DebugToolbarMiddleware')
|
||||
@ -396,7 +396,7 @@ for key in db_keys:
|
||||
reqiured_keys = ['ENGINE', 'NAME']
|
||||
|
||||
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}'
|
||||
logger.error(error_msg)
|
||||
|
||||
@ -415,7 +415,7 @@ db_engine = db_config['ENGINE'].lower()
|
||||
|
||||
# Correct common misspelling
|
||||
if db_engine == 'sqlite':
|
||||
db_engine = 'sqlite3'
|
||||
db_engine = 'sqlite3' # pragma: no cover
|
||||
|
||||
if db_engine in ['sqlite3', 'postgresql', 'mysql']:
|
||||
# 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", {}))
|
||||
|
||||
# Specific options for postgres backend
|
||||
if "postgres" in db_engine:
|
||||
if "postgres" in db_engine: # pragma: no cover
|
||||
from psycopg2.extensions import (
|
||||
ISOLATION_LEVEL_READ_COMMITTED,
|
||||
ISOLATION_LEVEL_SERIALIZABLE,
|
||||
@ -505,7 +505,7 @@ if "postgres" in db_engine:
|
||||
)
|
||||
|
||||
# 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
|
||||
|
||||
# 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")
|
||||
)
|
||||
|
||||
if _cache_host:
|
||||
if _cache_host: # pragma: no cover
|
||||
# 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
|
||||
# irreplacable.
|
||||
@ -591,7 +591,7 @@ else:
|
||||
try:
|
||||
# 4 background workers seems like a sensible default
|
||||
background_workers = int(os.environ.get('INVENTREE_BACKGROUND_WORKERS', 4))
|
||||
except ValueError:
|
||||
except ValueError: # pragma: no cover
|
||||
background_workers = 4
|
||||
|
||||
# django-q configuration
|
||||
@ -606,7 +606,7 @@ Q_CLUSTER = {
|
||||
'sync': False,
|
||||
}
|
||||
|
||||
if _cache_host:
|
||||
if _cache_host: # pragma: no cover
|
||||
# If using external redis cache, make the cache the broker for Django Q
|
||||
# as well
|
||||
Q_CLUSTER["django_redis"] = "worker"
|
||||
@ -641,7 +641,7 @@ AUTH_PASSWORD_VALIDATORS = [
|
||||
|
||||
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")
|
||||
EXTRA_URL_SCHEMES = []
|
||||
|
||||
@ -659,6 +659,7 @@ LANGUAGES = [
|
||||
('es-mx', _('Spanish (Mexican)')),
|
||||
('fr', _('French')),
|
||||
('he', _('Hebrew')),
|
||||
('hu', _('Hungarian')),
|
||||
('it', _('Italian')),
|
||||
('ja', _('Japanese')),
|
||||
('ko', _('Korean')),
|
||||
@ -675,7 +676,7 @@ LANGUAGES = [
|
||||
]
|
||||
|
||||
# Testing interface translations
|
||||
if get_setting('TEST_TRANSLATIONS', False):
|
||||
if get_setting('TEST_TRANSLATIONS', False): # pragma: no cover
|
||||
# Set default language
|
||||
LANGUAGE_CODE = 'xx'
|
||||
|
||||
@ -703,7 +704,7 @@ CURRENCIES = CONFIG.get(
|
||||
|
||||
# Check that each provided currency is supported
|
||||
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")
|
||||
sys.exit(1)
|
||||
|
||||
@ -777,7 +778,7 @@ USE_L10N = True
|
||||
# Do not use native timezone support in "test" mode
|
||||
# It generates a *lot* of cruft in the logs
|
||||
if not TESTING:
|
||||
USE_TZ = True
|
||||
USE_TZ = True # pragma: no cover
|
||||
|
||||
DATE_INPUT_FORMATS = [
|
||||
"%Y-%m-%d",
|
||||
@ -805,7 +806,7 @@ SITE_ID = 1
|
||||
# Load the allauth social backends
|
||||
SOCIAL_BACKENDS = CONFIG.get('social_backends', [])
|
||||
for app in SOCIAL_BACKENDS:
|
||||
INSTALLED_APPS.append(app)
|
||||
INSTALLED_APPS.append(app) # pragma: no cover
|
||||
|
||||
SOCIALACCOUNT_PROVIDERS = CONFIG.get('social_providers', [])
|
||||
|
||||
@ -879,7 +880,7 @@ PLUGIN_DIRS = ['plugin.builtin', 'barcodes.plugins', ]
|
||||
|
||||
if not TESTING:
|
||||
# load local deploy directory in prod
|
||||
PLUGIN_DIRS.append('plugins')
|
||||
PLUGIN_DIRS.append('plugins') # pragma: no cover
|
||||
|
||||
if DEBUG or TESTING:
|
||||
# load samples in debug mode
|
||||
|
@ -60,21 +60,21 @@ def is_email_configured():
|
||||
configured = False
|
||||
|
||||
# Display warning unless in test mode
|
||||
if not settings.TESTING:
|
||||
if not settings.TESTING: # pragma: no cover
|
||||
logger.debug("EMAIL_HOST is not configured")
|
||||
|
||||
if not settings.EMAIL_HOST_USER:
|
||||
configured = False
|
||||
|
||||
# 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")
|
||||
|
||||
if not settings.EMAIL_HOST_PASSWORD:
|
||||
configured = False
|
||||
|
||||
# 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")
|
||||
|
||||
return configured
|
||||
@ -89,15 +89,15 @@ def check_system_health(**kwargs):
|
||||
|
||||
result = True
|
||||
|
||||
if not is_worker_running(**kwargs):
|
||||
if not is_worker_running(**kwargs): # pragma: no cover
|
||||
result = False
|
||||
logger.warning(_("Background worker check failed"))
|
||||
|
||||
if not is_email_configured():
|
||||
if not is_email_configured(): # pragma: no cover
|
||||
result = False
|
||||
logger.warning(_("Email backend not configured"))
|
||||
|
||||
if not result:
|
||||
if not result: # pragma: no cover
|
||||
logger.warning(_("InvenTree system health checks failed"))
|
||||
|
||||
return result
|
||||
|
@ -258,6 +258,7 @@ class StockHistoryCode(StatusCode):
|
||||
# Build order codes
|
||||
BUILD_OUTPUT_CREATED = 50
|
||||
BUILD_OUTPUT_COMPLETED = 55
|
||||
BUILD_CONSUMED = 57
|
||||
|
||||
# Sales order codes
|
||||
|
||||
@ -298,6 +299,7 @@ class StockHistoryCode(StatusCode):
|
||||
|
||||
BUILD_OUTPUT_CREATED: _('Build order output created'),
|
||||
BUILD_OUTPUT_COMPLETED: _('Build order output completed'),
|
||||
BUILD_CONSUMED: _('Consumed by build order'),
|
||||
|
||||
RECEIVED_AGAINST_PURCHASE_ORDER: _('Received against purchase order')
|
||||
|
||||
|
@ -28,7 +28,7 @@ def schedule_task(taskname, **kwargs):
|
||||
|
||||
try:
|
||||
from django_q.models import Schedule
|
||||
except (AppRegistryNotReady):
|
||||
except AppRegistryNotReady: # pragma: no cover
|
||||
logger.info("Could not start background tasks - App registry not ready")
|
||||
return
|
||||
|
||||
@ -47,7 +47,7 @@ def schedule_task(taskname, **kwargs):
|
||||
func=taskname,
|
||||
**kwargs
|
||||
)
|
||||
except (OperationalError, ProgrammingError):
|
||||
except (OperationalError, ProgrammingError): # pragma: no cover
|
||||
# Required if the DB is not ready yet
|
||||
pass
|
||||
|
||||
@ -108,10 +108,10 @@ def offload_task(taskname, *args, force_sync=False, **kwargs):
|
||||
# Workers are not running: run it as synchronous task
|
||||
_func(*args, **kwargs)
|
||||
|
||||
except (AppRegistryNotReady):
|
||||
except AppRegistryNotReady: # pragma: no cover
|
||||
logger.warning(f"Could not offload task '{taskname}' - app registry not ready")
|
||||
return
|
||||
except (OperationalError, ProgrammingError):
|
||||
except (OperationalError, ProgrammingError): # pragma: no cover
|
||||
logger.warning(f"Could not offload task '{taskname}' - database not ready")
|
||||
|
||||
|
||||
@ -127,7 +127,7 @@ def heartbeat():
|
||||
try:
|
||||
from django_q.models import Success
|
||||
logger.info("Could not perform heartbeat task - App registry not ready")
|
||||
except AppRegistryNotReady:
|
||||
except AppRegistryNotReady: # pragma: no cover
|
||||
return
|
||||
|
||||
threshold = timezone.now() - timedelta(minutes=30)
|
||||
@ -150,7 +150,7 @@ def delete_successful_tasks():
|
||||
|
||||
try:
|
||||
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")
|
||||
return
|
||||
|
||||
@ -184,7 +184,7 @@ def delete_old_error_logs():
|
||||
logger.info(f"Deleting {errors.count()} old error logs")
|
||||
errors.delete()
|
||||
|
||||
except AppRegistryNotReady:
|
||||
except AppRegistryNotReady: # pragma: no cover
|
||||
# Apps not yet loaded
|
||||
logger.info("Could not perform 'delete_old_error_logs' - App registry not ready")
|
||||
return
|
||||
@ -197,7 +197,7 @@ def check_for_updates():
|
||||
|
||||
try:
|
||||
import common.models
|
||||
except AppRegistryNotReady:
|
||||
except AppRegistryNotReady: # pragma: no cover
|
||||
# Apps not yet loaded!
|
||||
logger.info("Could not perform 'check_for_updates' - App registry not ready")
|
||||
return
|
||||
@ -244,7 +244,7 @@ def update_exchange_rates():
|
||||
from InvenTree.exchange import InvenTreeExchange
|
||||
from djmoney.contrib.exchange.models import ExchangeBackend, Rate
|
||||
from common.settings import currency_code_default, currency_codes
|
||||
except AppRegistryNotReady:
|
||||
except AppRegistryNotReady: # pragma: no cover
|
||||
# Apps not yet loaded!
|
||||
logger.info("Could not perform 'update_exchange_rates' - App registry not ready")
|
||||
return
|
||||
@ -269,10 +269,13 @@ def update_exchange_rates():
|
||||
|
||||
logger.info(f"Using base currency '{base}'")
|
||||
|
||||
backend.update_rates(base_currency=base)
|
||||
try:
|
||||
backend.update_rates(base_currency=base)
|
||||
|
||||
# Remove any exchange rates which are not in the provided currencies
|
||||
Rate.objects.filter(backend="InvenTreeExchange").exclude(currency__in=currency_codes()).delete()
|
||||
# Remove any exchange rates which are not in the provided currencies
|
||||
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):
|
||||
|
@ -92,7 +92,7 @@ class URLTest(TestCase):
|
||||
result[0].strip(),
|
||||
result[1].strip()
|
||||
])
|
||||
elif len(result) == 1:
|
||||
elif len(result) == 1: # pragma: no cover
|
||||
urls.append([
|
||||
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.urls import reverse
|
||||
from django.contrib.auth import get_user_model
|
||||
|
||||
import os
|
||||
|
||||
|
||||
class ViewTests(TestCase):
|
||||
""" Tests for various top-level views """
|
||||
@ -16,9 +19,13 @@ class ViewTests(TestCase):
|
||||
def setUp(self):
|
||||
|
||||
# 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):
|
||||
""" Test that the api-doc view works """
|
||||
@ -27,3 +34,51 @@ class ViewTests(TestCase):
|
||||
|
||||
response = self.client.get(api_url)
|
||||
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 . import helpers
|
||||
from . import version
|
||||
from . import status
|
||||
from . import ready
|
||||
|
||||
from decimal import Decimal
|
||||
|
||||
@ -389,3 +391,19 @@ class CurrencyTests(TestCase):
|
||||
# Convert to a symbol which is not covered
|
||||
with self.assertRaises(MissingRate):
|
||||
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)
|
||||
|
||||
# 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
|
||||
urlpatterns = [
|
||||
path('__debug/', include(debug_toolbar.urls)),
|
||||
|
@ -2,6 +2,8 @@
|
||||
Custom field validators for InvenTree
|
||||
"""
|
||||
|
||||
from decimal import Decimal, InvalidOperation
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
@ -115,26 +117,28 @@ def validate_tree_name(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 integer number ('1' / 3 / 4)
|
||||
- A decimal number ('0.123')
|
||||
- A percentage ('5%' / '10 %')
|
||||
"""
|
||||
|
||||
value = str(value).lower().strip()
|
||||
|
||||
# First look for a simple integer value
|
||||
# First look for a simple numerical value
|
||||
try:
|
||||
i = int(value)
|
||||
i = Decimal(value)
|
||||
|
||||
if i < 0:
|
||||
raise ValidationError(_("Overage value must not be negative"))
|
||||
|
||||
# Looks like an integer!
|
||||
# Looks like a number
|
||||
return True
|
||||
except ValueError:
|
||||
except (ValueError, InvalidOperation):
|
||||
pass
|
||||
|
||||
# Now look for a percentage value
|
||||
@ -155,7 +159,7 @@ def validate_overage(value):
|
||||
pass
|
||||
|
||||
raise ValidationError(
|
||||
_("Overage must be an integer value or a percentage")
|
||||
_("Invalid value for overage")
|
||||
)
|
||||
|
||||
|
||||
|
@ -9,14 +9,23 @@ import re
|
||||
import common.models
|
||||
|
||||
# InvenTree software version
|
||||
INVENTREE_SW_VERSION = "0.6.0 dev"
|
||||
INVENTREE_SW_VERSION = "0.7.0 dev"
|
||||
|
||||
# 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
|
||||
|
||||
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
|
||||
- Adds API endpoints for managing plugin classes
|
||||
- Adds API endpoints for managing plugin settings
|
||||
|
@ -232,6 +232,29 @@ class BuildUnallocate(generics.CreateAPIView):
|
||||
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):
|
||||
"""
|
||||
API endpoint for completing build outputs
|
||||
@ -241,6 +264,29 @@ class BuildOutputComplete(generics.CreateAPIView):
|
||||
|
||||
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):
|
||||
ctx = super().get_serializer_context()
|
||||
|
||||
@ -432,6 +478,8 @@ build_api_urls = [
|
||||
url(r'^(?P<pk>\d+)/', include([
|
||||
url(r'^allocate/', BuildAllocate.as_view(), name='api-build-allocate'),
|
||||
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'^unallocate/', BuildUnallocate.as_view(), name='api-build-unallocate'),
|
||||
url(r'^.*$', BuildDetail.as_view(), name='api-build-detail'),
|
||||
|
@ -14,75 +14,6 @@ from InvenTree.forms import HelperForm
|
||||
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):
|
||||
""" Form for cancelling a build """
|
||||
|
||||
|
@ -11,7 +11,7 @@ def update_tree(apps, schema_editor):
|
||||
Build.objects.rebuild()
|
||||
|
||||
|
||||
def nupdate_tree(apps, schema_editor):
|
||||
def nupdate_tree(apps, schema_editor): # pragma: no cover
|
||||
pass
|
||||
|
||||
|
||||
|
@ -23,7 +23,7 @@ def add_default_reference(apps, schema_editor):
|
||||
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.
|
||||
"""
|
||||
|
@ -23,7 +23,7 @@ def assign_bom_items(apps, schema_editor):
|
||||
count_valid = 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
|
||||
# Note: Before this migration, variant stock assignment was not allowed,
|
||||
@ -45,11 +45,11 @@ def assign_bom_items(apps, schema_editor):
|
||||
except BomItem.DoesNotExist:
|
||||
pass
|
||||
|
||||
if count_total > 0:
|
||||
if count_total > 0: # pragma: no cover
|
||||
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.
|
||||
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:
|
||||
try:
|
||||
ref = int(result.groups()[0])
|
||||
except:
|
||||
except: # pragma: no cover
|
||||
ref = 0
|
||||
|
||||
build.reference_int = ref
|
||||
build.save()
|
||||
|
||||
def unbuild_refs(apps, schema_editor):
|
||||
def unbuild_refs(apps, schema_editor): # pragma: no cover
|
||||
"""
|
||||
Provided only for reverse migration compatibility
|
||||
"""
|
||||
|
@ -30,8 +30,6 @@ from InvenTree.helpers import increment, getSetting, normalize, MakeBarcode
|
||||
from InvenTree.models import InvenTreeAttachment, ReferenceIndexingMixin
|
||||
from InvenTree.validators import validate_build_order_reference
|
||||
|
||||
import common.models
|
||||
|
||||
import InvenTree.fields
|
||||
import InvenTree.helpers
|
||||
import InvenTree.tasks
|
||||
@ -479,8 +477,6 @@ class Build(MPTTModel, ReferenceIndexingMixin):
|
||||
|
||||
outputs = self.get_build_outputs(complete=True)
|
||||
|
||||
# TODO - Ordering?
|
||||
|
||||
return outputs
|
||||
|
||||
@property
|
||||
@ -491,8 +487,6 @@ class Build(MPTTModel, ReferenceIndexingMixin):
|
||||
|
||||
outputs = self.get_build_outputs(complete=False)
|
||||
|
||||
# TODO - Order by how "complete" they are?
|
||||
|
||||
return outputs
|
||||
|
||||
@property
|
||||
@ -563,7 +557,7 @@ class Build(MPTTModel, ReferenceIndexingMixin):
|
||||
if self.remaining > 0:
|
||||
return False
|
||||
|
||||
if not self.areUntrackedPartsFullyAllocated():
|
||||
if not self.are_untracked_parts_allocated():
|
||||
return False
|
||||
|
||||
# No issues!
|
||||
@ -584,7 +578,7 @@ class Build(MPTTModel, ReferenceIndexingMixin):
|
||||
self.save()
|
||||
|
||||
# Remove untracked allocated stock
|
||||
self.subtractUntrackedStock(user)
|
||||
self.subtract_allocated_stock(user)
|
||||
|
||||
# Ensure that there are no longer any BuildItem objects
|
||||
# which point to thisFcan Build Order
|
||||
@ -646,11 +640,13 @@ class Build(MPTTModel, ReferenceIndexingMixin):
|
||||
batch: Override batch code
|
||||
serials: Serial numbers
|
||||
location: Override location
|
||||
auto_allocate: Automatically allocate stock with matching serial numbers
|
||||
"""
|
||||
|
||||
batch = kwargs.get('batch', self.batch)
|
||||
location = kwargs.get('location', self.destination)
|
||||
serials = kwargs.get('serials', None)
|
||||
auto_allocate = kwargs.get('auto_allocate', False)
|
||||
|
||||
"""
|
||||
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
|
||||
"""
|
||||
|
||||
# Quantity *must* be an integer at this point!
|
||||
quantity = int(quantity)
|
||||
|
||||
for ii in range(quantity):
|
||||
|
||||
if serials:
|
||||
@ -679,7 +678,7 @@ class Build(MPTTModel, ReferenceIndexingMixin):
|
||||
else:
|
||||
serial = None
|
||||
|
||||
StockModels.StockItem.objects.create(
|
||||
output = StockModels.StockItem.objects.create(
|
||||
quantity=1,
|
||||
location=location,
|
||||
part=self.part,
|
||||
@ -689,6 +688,37 @@ class Build(MPTTModel, ReferenceIndexingMixin):
|
||||
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:
|
||||
"""
|
||||
Create a single build output of the given quantity
|
||||
@ -708,7 +738,7 @@ class Build(MPTTModel, ReferenceIndexingMixin):
|
||||
self.save()
|
||||
|
||||
@transaction.atomic
|
||||
def deleteBuildOutput(self, output):
|
||||
def delete_output(self, output):
|
||||
"""
|
||||
Remove a build output from the database:
|
||||
|
||||
@ -732,7 +762,7 @@ class Build(MPTTModel, ReferenceIndexingMixin):
|
||||
output.delete()
|
||||
|
||||
@transaction.atomic
|
||||
def subtractUntrackedStock(self, user):
|
||||
def subtract_allocated_stock(self, user):
|
||||
"""
|
||||
Called when the Build is marked as "complete",
|
||||
this function removes the allocated untracked items from stock.
|
||||
@ -795,7 +825,7 @@ class Build(MPTTModel, ReferenceIndexingMixin):
|
||||
|
||||
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.
|
||||
|
||||
@ -804,12 +834,7 @@ class Build(MPTTModel, ReferenceIndexingMixin):
|
||||
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
|
||||
except (PartModels.BomItem.DoesNotExist):
|
||||
quantity = 0
|
||||
quantity = bom_item.quantity
|
||||
|
||||
if output:
|
||||
quantity *= output.quantity
|
||||
@ -818,32 +843,32 @@ class Build(MPTTModel, ReferenceIndexingMixin):
|
||||
|
||||
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:
|
||||
part - The part object
|
||||
bom_item - The BomItem object
|
||||
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(
|
||||
build=self,
|
||||
stock_item__part__pk__in=[p.pk for p in variants],
|
||||
bom_item=bom_item,
|
||||
install_into=output,
|
||||
)
|
||||
|
||||
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.
|
||||
"""
|
||||
|
||||
allocations = self.allocatedItems(part, output)
|
||||
allocations = self.allocated_bom_items(bom_item, output)
|
||||
|
||||
allocated = allocations.aggregate(
|
||||
q=Coalesce(
|
||||
@ -855,24 +880,24 @@ class Build(MPTTModel, ReferenceIndexingMixin):
|
||||
|
||||
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.
|
||||
"""
|
||||
|
||||
required = self.requiredQuantity(part, output)
|
||||
allocated = self.allocatedQuantity(part, output)
|
||||
required = self.required_quantity(bom_item, output)
|
||||
allocated = self.allocated_quantity(bom_item, output)
|
||||
|
||||
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.
|
||||
"""
|
||||
@ -883,53 +908,24 @@ class Build(MPTTModel, ReferenceIndexingMixin):
|
||||
else:
|
||||
bom_items = self.tracked_bom_items
|
||||
|
||||
fully_allocated = True
|
||||
|
||||
for bom_item in bom_items:
|
||||
part = bom_item.sub_part
|
||||
|
||||
if not self.isPartFullyAllocated(part, output):
|
||||
fully_allocated = False
|
||||
|
||||
if verbose:
|
||||
print(f"Part {part} is not fully allocated for output {output}")
|
||||
else:
|
||||
break
|
||||
if not self.is_bom_item_allocated(bom_item, output):
|
||||
return False
|
||||
|
||||
# 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
|
||||
"""
|
||||
|
||||
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
|
||||
"""
|
||||
|
||||
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
|
||||
Return a list of bom items which have *not* been fully allocated against a particular output
|
||||
"""
|
||||
|
||||
unallocated = []
|
||||
@ -941,10 +937,9 @@ class Build(MPTTModel, ReferenceIndexingMixin):
|
||||
bom_items = self.tracked_bom_items
|
||||
|
||||
for bom_item in bom_items:
|
||||
part = bom_item.sub_part
|
||||
|
||||
if not self.isPartFullyAllocated(part, output):
|
||||
unallocated.append(part)
|
||||
if not self.is_bom_item_allocated(bom_item, output):
|
||||
unallocated.append(bom_item)
|
||||
|
||||
return unallocated
|
||||
|
||||
@ -972,57 +967,6 @@ class Build(MPTTModel, ReferenceIndexingMixin):
|
||||
|
||||
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
|
||||
def is_active(self):
|
||||
""" Is this build active? An active build is either:
|
||||
@ -1221,7 +1165,12 @@ class BuildItem(models.Model):
|
||||
if item.part.trackable:
|
||||
# Split the allocated stock if there are more available than allocated
|
||||
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
|
||||
self.stock_item = item
|
||||
@ -1232,7 +1181,11 @@ class BuildItem(models.Model):
|
||||
item.save()
|
||||
else:
|
||||
# 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):
|
||||
"""
|
||||
|
@ -19,6 +19,7 @@ from InvenTree.serializers import InvenTreeModelSerializer, InvenTreeAttachmentS
|
||||
from InvenTree.serializers import UserSerializerBrief, ReferenceIndexingSerializerMixin
|
||||
|
||||
import InvenTree.helpers
|
||||
from InvenTree.helpers import extract_serial_numbers
|
||||
from InvenTree.serializers import InvenTreeDecimalField
|
||||
from InvenTree.status_codes import StockStatus
|
||||
|
||||
@ -141,6 +142,9 @@ class BuildOutputSerializer(serializers.Serializer):
|
||||
|
||||
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
|
||||
if output.build != build:
|
||||
raise ValidationError(_("Build output does not match the parent build"))
|
||||
@ -153,9 +157,11 @@ class BuildOutputSerializer(serializers.Serializer):
|
||||
if not output.is_building:
|
||||
raise ValidationError(_("This build output has already been completed"))
|
||||
|
||||
# The build output must have all tracked parts allocated
|
||||
if not build.isFullyAllocated(output):
|
||||
raise ValidationError(_("This build output is not fully allocated"))
|
||||
if to_complete:
|
||||
|
||||
# The build output must have all tracked parts allocated
|
||||
if not build.is_fully_allocated(output):
|
||||
raise ValidationError(_("This build output is not fully allocated"))
|
||||
|
||||
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):
|
||||
"""
|
||||
DRF serializer for completing one or more build outputs
|
||||
@ -224,6 +404,10 @@ class BuildOutputCompleteSerializer(serializers.Serializer):
|
||||
|
||||
data = self.validated_data
|
||||
|
||||
location = data['location']
|
||||
status = data['status']
|
||||
notes = data.get('notes', '')
|
||||
|
||||
outputs = data.get('outputs', [])
|
||||
|
||||
# Mark the specified build outputs as "complete"
|
||||
@ -235,8 +419,9 @@ class BuildOutputCompleteSerializer(serializers.Serializer):
|
||||
build.complete_build_output(
|
||||
output,
|
||||
request.user,
|
||||
status=data['status'],
|
||||
notes=data.get('notes', '')
|
||||
location=location,
|
||||
status=status,
|
||||
notes=notes,
|
||||
)
|
||||
|
||||
|
||||
@ -256,7 +441,7 @@ class BuildCompleteSerializer(serializers.Serializer):
|
||||
|
||||
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'))
|
||||
|
||||
return value
|
||||
|
@ -125,7 +125,7 @@ src="{% static 'img/blank_image.png' %}"
|
||||
{% trans "Required build quantity has not yet been completed" %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if not build.areUntrackedPartsFullyAllocated %}
|
||||
{% if not build.are_untracked_parts_allocated %}
|
||||
<div class='alert alert-block alert-warning'>
|
||||
{% trans "Stock has not been fully allocated to this Build Order" %}
|
||||
</div>
|
||||
@ -234,7 +234,7 @@ src="{% static 'img/blank_image.png' %}"
|
||||
{% else %}
|
||||
|
||||
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 %},
|
||||
});
|
||||
{% 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'>
|
||||
{% if build.has_untracked_bom_items %}
|
||||
{% if build.active %}
|
||||
{% if build.areUntrackedPartsFullyAllocated %}
|
||||
{% if build.are_untracked_parts_allocated %}
|
||||
<div class='alert alert-block alert-success'>
|
||||
{% trans "Untracked stock has been fully allocated for this Build Order" %}
|
||||
</div>
|
||||
@ -243,13 +243,16 @@
|
||||
|
||||
<!-- Build output actions -->
|
||||
<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>
|
||||
</button>
|
||||
<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" %}
|
||||
</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>
|
||||
</div>
|
||||
{% include "filter_list.html" with id='incompletebuilditems' %}
|
||||
@ -318,9 +321,11 @@
|
||||
{{ block.super }}
|
||||
|
||||
$('#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',
|
||||
'#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 %}
|
||||
|
||||
{% if build.active and build.has_untracked_bom_items %}
|
||||
|
@ -62,20 +62,20 @@ class BuildTest(TestCase):
|
||||
)
|
||||
|
||||
# Create BOM item links for the parts
|
||||
BomItem.objects.create(
|
||||
self.bom_item_1 = BomItem.objects.create(
|
||||
part=self.assembly,
|
||||
sub_part=self.sub_part_1,
|
||||
quantity=5
|
||||
)
|
||||
|
||||
BomItem.objects.create(
|
||||
self.bom_item_2 = BomItem.objects.create(
|
||||
part=self.assembly,
|
||||
sub_part=self.sub_part_2,
|
||||
quantity=3
|
||||
)
|
||||
|
||||
# sub_part_3 is trackable!
|
||||
BomItem.objects.create(
|
||||
self.bom_item_3 = BomItem.objects.create(
|
||||
part=self.assembly,
|
||||
sub_part=self.sub_part_3,
|
||||
quantity=2
|
||||
@ -83,9 +83,6 @@ class BuildTest(TestCase):
|
||||
|
||||
ref = get_next_build_number()
|
||||
|
||||
if ref is None:
|
||||
ref = "0001"
|
||||
|
||||
# Create a "Build" object to make 10x objects
|
||||
self.build = Build.objects.create(
|
||||
reference=ref,
|
||||
@ -150,15 +147,15 @@ class BuildTest(TestCase):
|
||||
|
||||
# None of the build outputs have been completed
|
||||
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.isPartFullyAllocated(self.sub_part_2, self.output_2))
|
||||
self.assertFalse(self.build.is_bom_item_allocated(self.bom_item_1, self.output_1))
|
||||
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.unallocatedQuantity(self.sub_part_1, self.output_2), 35)
|
||||
self.assertEqual(self.build.unallocatedQuantity(self.sub_part_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_1, self.output_1), 15)
|
||||
self.assertEqual(self.build.unallocated_quantity(self.bom_item_1, self.output_2), 35)
|
||||
self.assertEqual(self.build.unallocated_quantity(self.bom_item_2, self.output_1), 9)
|
||||
self.assertEqual(self.build.unallocated_quantity(self.bom_item_2, self.output_2), 21)
|
||||
|
||||
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
|
||||
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
|
||||
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)
|
||||
|
||||
@ -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.build.unallocateStock()
|
||||
|
||||
unallocated = self.build.unallocatedParts(None)
|
||||
unallocated = self.build.unallocated_bom_items(None)
|
||||
|
||||
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
|
||||
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):
|
||||
"""
|
||||
@ -334,9 +331,9 @@ class BuildTest(TestCase):
|
||||
}
|
||||
)
|
||||
|
||||
self.assertTrue(self.build.isFullyAllocated(None, verbose=True))
|
||||
self.assertTrue(self.build.isFullyAllocated(self.output_1))
|
||||
self.assertTrue(self.build.isFullyAllocated(self.output_2))
|
||||
self.assertTrue(self.build.is_fully_allocated(None))
|
||||
self.assertTrue(self.build.is_fully_allocated(self.output_1))
|
||||
self.assertTrue(self.build.is_fully_allocated(self.output_2))
|
||||
|
||||
self.build.complete_build_output(self.output_1, None)
|
||||
|
||||
|
@ -9,8 +9,6 @@ from . import views
|
||||
build_detail_urls = [
|
||||
url(r'^cancel/', views.BuildCancel.as_view(), name='build-cancel'),
|
||||
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'),
|
||||
]
|
||||
|
@ -6,17 +6,14 @@ Django views for interacting with Build objects
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.views.generic import DetailView, ListView
|
||||
from django.forms import HiddenInput
|
||||
|
||||
from .models import Build
|
||||
from . import forms
|
||||
from stock.models import StockItem
|
||||
|
||||
from InvenTree.views import AjaxUpdateView, AjaxDeleteView
|
||||
from InvenTree.views import InvenTreeRoleMixin
|
||||
from InvenTree.helpers import str2bool, extract_serial_numbers
|
||||
from InvenTree.helpers import str2bool
|
||||
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):
|
||||
"""
|
||||
Detail view of a single Build object.
|
||||
|
@ -19,7 +19,7 @@ def delete_old_notifications():
|
||||
|
||||
try:
|
||||
from common.models import NotificationEntry
|
||||
except AppRegistryNotReady:
|
||||
except AppRegistryNotReady: # pragma: no cover
|
||||
logger.info("Could not perform 'delete_old_notifications' - App registry not ready")
|
||||
return
|
||||
|
||||
|
@ -59,15 +59,15 @@ class SettingsTest(TestCase):
|
||||
name = setting.get('name', 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)
|
||||
|
||||
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():
|
||||
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):
|
||||
"""
|
||||
@ -87,10 +87,10 @@ class SettingsTest(TestCase):
|
||||
|
||||
if setting.is_bool():
|
||||
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]:
|
||||
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):
|
||||
|
@ -14,11 +14,11 @@ So a simplified version of the migration is implemented.
|
||||
TESTING = 'test' in sys.argv
|
||||
|
||||
def clear():
|
||||
if not TESTING:
|
||||
if not TESTING: # pragma: no cover
|
||||
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 operation is easier:
|
||||
@ -108,7 +108,7 @@ def associate_manufacturers(apps, schema_editor):
|
||||
|
||||
if len(row) > 0:
|
||||
return row[0]
|
||||
return ''
|
||||
return '' # pragma: no cover
|
||||
|
||||
cursor = connection.cursor()
|
||||
|
||||
@ -139,7 +139,7 @@ def associate_manufacturers(apps, schema_editor):
|
||||
""" Attempt to link Part to an existing Company """
|
||||
|
||||
# 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))
|
||||
|
||||
manufacturer_id = companies[name]
|
||||
@ -150,7 +150,7 @@ def associate_manufacturers(apps, schema_editor):
|
||||
return True
|
||||
|
||||
# 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]))
|
||||
|
||||
manufacturer_id = links[name]
|
||||
@ -196,10 +196,10 @@ def associate_manufacturers(apps, schema_editor):
|
||||
# Case-insensitive matching
|
||||
ratio = fuzz.partial_ratio(name.lower(), text.lower())
|
||||
|
||||
if ratio > threshold:
|
||||
if ratio > threshold: # pragma: no cover
|
||||
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)]
|
||||
else:
|
||||
return []
|
||||
@ -212,12 +212,12 @@ def associate_manufacturers(apps, schema_editor):
|
||||
name = get_manufacturer_name(part_id)
|
||||
|
||||
# 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))
|
||||
return
|
||||
|
||||
# Can be linked to an existing manufacturer
|
||||
if link_part(part_id, name):
|
||||
if link_part(part_id, name): # pragma: no cover
|
||||
return
|
||||
|
||||
# Find a list of potential matches
|
||||
@ -226,12 +226,12 @@ def associate_manufacturers(apps, schema_editor):
|
||||
clear()
|
||||
|
||||
# Present a list of options
|
||||
if not TESTING:
|
||||
if not TESTING: # pragma: no cover
|
||||
print("----------------------------------")
|
||||
|
||||
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("----------------------------------")
|
||||
print("Select an option from the list below:")
|
||||
@ -249,7 +249,7 @@ def associate_manufacturers(apps, schema_editor):
|
||||
if TESTING:
|
||||
# When running unit tests, simply select the name of the part
|
||||
response = '0'
|
||||
else:
|
||||
else: # pragma: no cover
|
||||
response = str(input("> ")).strip()
|
||||
|
||||
# Attempt to parse user response as an integer
|
||||
@ -263,7 +263,7 @@ def associate_manufacturers(apps, schema_editor):
|
||||
return
|
||||
|
||||
# Options 1) - n) select an existing manufacturer
|
||||
else:
|
||||
else: # pragma: no cover
|
||||
n = n - 1
|
||||
|
||||
if n < len(matches):
|
||||
@ -287,7 +287,7 @@ def associate_manufacturers(apps, schema_editor):
|
||||
else:
|
||||
print("Please select a valid option")
|
||||
|
||||
except ValueError:
|
||||
except ValueError: # pragma: no cover
|
||||
# User has typed in a custom name!
|
||||
|
||||
if not response or len(response) == 0:
|
||||
@ -312,7 +312,7 @@ def associate_manufacturers(apps, schema_editor):
|
||||
print("")
|
||||
clear()
|
||||
|
||||
if not TESTING:
|
||||
if not TESTING: # pragma: no cover
|
||||
print("---------------------------------------")
|
||||
print("The SupplierPart model needs to be migrated,")
|
||||
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):
|
||||
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)")
|
||||
continue
|
||||
|
||||
|
@ -1,7 +1,7 @@
|
||||
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')
|
||||
for company in Company.objects.all():
|
||||
if company.email == None:
|
||||
|
@ -42,7 +42,7 @@ def migrate_currencies(apps, schema_editor):
|
||||
|
||||
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}'")
|
||||
|
||||
while suffix not in currency_codes:
|
||||
@ -78,7 +78,7 @@ def migrate_currencies(apps, schema_editor):
|
||||
if count > 0:
|
||||
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.
|
||||
|
||||
|
@ -15,12 +15,12 @@ def supplierpart_make_manufacturer_parts(apps, schema_editor):
|
||||
for supplier_part in supplier_parts:
|
||||
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]')
|
||||
continue
|
||||
|
||||
part = supplier_part.part
|
||||
if not part:
|
||||
if not part: # pragma: no cover
|
||||
print(f'[ERROR: SUPPLIER PART IS NOT CONNECTED TO PART]')
|
||||
continue
|
||||
|
||||
@ -67,7 +67,7 @@ def supplierpart_make_manufacturer_parts(apps, schema_editor):
|
||||
|
||||
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')
|
||||
ManufacturerPart = apps.get_model('company', 'ManufacturerPart')
|
||||
SupplierPart = apps.get_model('company', 'SupplierPart')
|
||||
|
@ -3,8 +3,6 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import json
|
||||
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
from django.contrib.auth import get_user_model
|
||||
@ -49,28 +47,6 @@ class CompanyViewTestBase(TestCase):
|
||||
|
||||
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):
|
||||
"""
|
||||
|
@ -5,6 +5,7 @@ import hashlib
|
||||
|
||||
from django.apps import AppConfig
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import AppRegistryNotReady
|
||||
|
||||
from InvenTree.ready import canAppAccessDatabase
|
||||
|
||||
@ -35,9 +36,15 @@ class LabelConfig(AppConfig):
|
||||
"""
|
||||
|
||||
if canAppAccessDatabase():
|
||||
self.create_stock_item_labels()
|
||||
self.create_stock_location_labels()
|
||||
self.create_part_labels()
|
||||
self.create_labels() # pragma: no cover
|
||||
|
||||
def create_labels(self):
|
||||
"""
|
||||
Create all default templates
|
||||
"""
|
||||
self.create_stock_item_labels()
|
||||
self.create_stock_location_labels()
|
||||
self.create_part_labels()
|
||||
|
||||
def create_stock_item_labels(self):
|
||||
"""
|
||||
@ -47,7 +54,7 @@ class LabelConfig(AppConfig):
|
||||
|
||||
try:
|
||||
from .models import StockItemLabel
|
||||
except:
|
||||
except AppRegistryNotReady: # pragma: no cover
|
||||
# Database might not by ready yet
|
||||
return
|
||||
|
||||
@ -98,7 +105,7 @@ class LabelConfig(AppConfig):
|
||||
# File already exists - let's see if it is the "same",
|
||||
# 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}'")
|
||||
to_copy = True
|
||||
|
||||
@ -112,7 +119,7 @@ class LabelConfig(AppConfig):
|
||||
|
||||
# Check if a label matching the template already exists
|
||||
if StockItemLabel.objects.filter(label=filename).exists():
|
||||
continue
|
||||
continue # pragma: no cover
|
||||
|
||||
logger.info(f"Creating entry for StockItemLabel '{label['name']}'")
|
||||
|
||||
@ -134,7 +141,7 @@ class LabelConfig(AppConfig):
|
||||
|
||||
try:
|
||||
from .models import StockLocationLabel
|
||||
except:
|
||||
except AppRegistryNotReady: # pragma: no cover
|
||||
# Database might not yet be ready
|
||||
return
|
||||
|
||||
@ -192,7 +199,7 @@ class LabelConfig(AppConfig):
|
||||
# File already exists - let's see if it is the "same",
|
||||
# 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}'")
|
||||
to_copy = True
|
||||
|
||||
@ -206,7 +213,7 @@ class LabelConfig(AppConfig):
|
||||
|
||||
# Check if a label matching the template already exists
|
||||
if StockLocationLabel.objects.filter(label=filename).exists():
|
||||
continue
|
||||
continue # pragma: no cover
|
||||
|
||||
logger.info(f"Creating entry for StockLocationLabel '{label['name']}'")
|
||||
|
||||
@ -228,7 +235,7 @@ class LabelConfig(AppConfig):
|
||||
|
||||
try:
|
||||
from .models import PartLabel
|
||||
except:
|
||||
except AppRegistryNotReady: # pragma: no cover
|
||||
# Database might not yet be ready
|
||||
return
|
||||
|
||||
@ -277,7 +284,7 @@ class LabelConfig(AppConfig):
|
||||
if os.path.exists(dst_file):
|
||||
# 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}'")
|
||||
to_copy = True
|
||||
|
||||
@ -291,7 +298,7 @@ class LabelConfig(AppConfig):
|
||||
|
||||
# Check if a label matching the template already exists
|
||||
if PartLabel.objects.filter(label=filename).exists():
|
||||
continue
|
||||
continue # pragma: no cover
|
||||
|
||||
logger.info(f"Creating entry for PartLabel '{label['name']}'")
|
||||
|
||||
|
@ -30,7 +30,7 @@ import part.models
|
||||
|
||||
try:
|
||||
from django_weasyprint import WeasyTemplateResponseMixin
|
||||
except OSError as err:
|
||||
except OSError as err: # pragma: no cover
|
||||
print("OSError: {e}".format(e=err))
|
||||
print("You may require some further system packages to be installed.")
|
||||
sys.exit(1)
|
||||
|
@ -66,3 +66,39 @@ class TestReportTests(InvenTreeAPITestCase):
|
||||
'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.conf import settings
|
||||
from django.apps import apps
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
from InvenTree.helpers import validateFilterString
|
||||
@ -17,8 +18,11 @@ from stock.models import StockItem
|
||||
|
||||
class LabelTest(TestCase):
|
||||
|
||||
# TODO - Implement this test properly. Looks like apps.py is not run first
|
||||
def _test_default_labels(self):
|
||||
def setUp(self) -> None:
|
||||
# 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
|
||||
"""
|
||||
@ -31,8 +35,7 @@ class LabelTest(TestCase):
|
||||
|
||||
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
|
||||
"""
|
||||
|
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
BIN
InvenTree/locale/hu/LC_MESSAGES/django.mo
Normal file
BIN
InvenTree/locale/hu/LC_MESSAGES/django.mo
Normal file
Binary file not shown.
9652
InvenTree/locale/hu/LC_MESSAGES/django.po
Normal file
9652
InvenTree/locale/hu/LC_MESSAGES/django.po
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -6,12 +6,12 @@ if __name__ == "__main__":
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "InvenTree.settings")
|
||||
try:
|
||||
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
|
||||
# issue is really that Django is missing to avoid masking other
|
||||
# exceptions on Python 2.
|
||||
try:
|
||||
import django # NOQA
|
||||
import django # noqa: F401
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
"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:
|
||||
try:
|
||||
ref = int(result.groups()[0])
|
||||
except:
|
||||
except: # pragma: no cover
|
||||
ref = 0
|
||||
|
||||
order.reference_int = ref
|
||||
@ -37,14 +37,14 @@ def build_refs(apps, schema_editor):
|
||||
if result and len(result.groups()) == 1:
|
||||
try:
|
||||
ref = int(result.groups()[0])
|
||||
except:
|
||||
except: # pragma: no cover
|
||||
ref = 0
|
||||
|
||||
order.reference_int = ref
|
||||
order.save()
|
||||
|
||||
|
||||
def unbuild_refs(apps, schema_editor):
|
||||
def unbuild_refs(apps, schema_editor): # pragma: no cover
|
||||
"""
|
||||
Provided only for reverse migration compatibility
|
||||
"""
|
||||
|
@ -33,7 +33,7 @@ def add_shipment(apps, schema_editor):
|
||||
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
|
||||
|
||||
# Create a new Shipment instance against this order
|
||||
@ -41,13 +41,13 @@ def add_shipment(apps, schema_editor):
|
||||
order=order,
|
||||
)
|
||||
|
||||
if order.status == SalesOrderStatus.SHIPPED:
|
||||
if order.status == SalesOrderStatus.SHIPPED: # pragma: no cover
|
||||
shipment.shipment_date = order.shipment_date
|
||||
|
||||
shipment.save()
|
||||
|
||||
# Iterate through each allocation associated with this order
|
||||
for allocation in allocations:
|
||||
for allocation in allocations: # pragma: no cover
|
||||
allocation.shipment = shipment
|
||||
allocation.save()
|
||||
|
||||
@ -57,7 +57,7 @@ def add_shipment(apps, schema_editor):
|
||||
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
|
||||
"""
|
||||
|
@ -22,7 +22,7 @@ def calculate_shipped_quantity(apps, schema_editor):
|
||||
StockItem = apps.get_model('stock', 'stockitem')
|
||||
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:
|
||||
item.shipped = item.quantity
|
||||
@ -40,7 +40,7 @@ def calculate_shipped_quantity(apps, schema_editor):
|
||||
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.
|
||||
This function does nothing.
|
||||
|
@ -48,7 +48,7 @@
|
||||
{% endif %}
|
||||
</ul>
|
||||
</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" %}'>
|
||||
<span class='fas fa-shopping-cart icon-blue'></span>
|
||||
</button>
|
||||
@ -178,7 +178,7 @@ src="{% static 'img/blank_image.png' %}"
|
||||
{{ block.super }}
|
||||
|
||||
|
||||
{% if order.status == PurchaseOrderStatus.PENDING and order.lines.count > 0 %}
|
||||
{% if order.status == PurchaseOrderStatus.PENDING %}
|
||||
$("#place-order").click(function() {
|
||||
launchModalForm("{% url 'po-issue' order.id %}",
|
||||
{
|
||||
|
@ -1,99 +1,2 @@
|
||||
{% extends "order/order_wizard/po_upload.html" %}
|
||||
{% load inventree_extras %}
|
||||
{% load i18n %}
|
||||
{% load static %}
|
||||
|
||||
{% block form_alert %}
|
||||
{% if missing_columns and missing_columns|length > 0 %}
|
||||
<div class='alert alert-danger alert-block' role='alert'>
|
||||
{% trans "Missing selections for the following required columns" %}:
|
||||
<br>
|
||||
<ul>
|
||||
{% for col in missing_columns %}
|
||||
<li>{{ col }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if duplicates and duplicates|length > 0 %}
|
||||
<div class='alert alert-danger alert-block' role='alert'>
|
||||
{% trans "Duplicate selections found, see below. Fix them then retry submitting." %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock form_alert %}
|
||||
|
||||
{% block form_buttons_top %}
|
||||
{% if wizard.steps.prev %}
|
||||
<button name="wizard_goto_step" type="submit" value="{{ wizard.steps.prev }}" class="save btn btn-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 %}
|
||||
{% include "patterns/wizard/match_fields.html" %}
|
@ -10,54 +10,11 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block page_content %}
|
||||
|
||||
<div class='panel' id='panel-upload-file'>
|
||||
<div class='panel-heading'>
|
||||
{% block heading %}
|
||||
<h4>{% trans "Upload File for Purchase Order" %}</h4>
|
||||
{{ 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>
|
||||
|
||||
{% trans "Upload File for Purchase Order" as header_text %}
|
||||
{% order.status == PurchaseOrderStatus.PENDING and roles.purchase_order.change as upload_go_ahead %}
|
||||
{% trans "Order is already processed. Files cannot be uploaded." as error_text %}
|
||||
{% "panel-upload-file" as panel_id %}
|
||||
{% include "patterns/wizard/upload.html" with header_text=header_text upload_go_ahead=upload_go_ahead error_text=error_text panel_id=panel_id %}
|
||||
{% endblock %}
|
||||
|
||||
{% block js_ready %}
|
||||
|
@ -995,6 +995,23 @@ class PartList(generics.ListCreateAPIView):
|
||||
except (ValueError, Part.DoesNotExist):
|
||||
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)
|
||||
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):
|
||||
""" 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'),
|
||||
])),
|
||||
|
||||
# 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
|
||||
url(r'^.*$', BomList.as_view(), name='api-bom-list'),
|
||||
]
|
||||
|
@ -40,6 +40,6 @@ class PartConfig(AppConfig):
|
||||
item.part.trackable = True
|
||||
item.part.clean()
|
||||
item.part.save()
|
||||
except (OperationalError, ProgrammingError):
|
||||
except (OperationalError, ProgrammingError): # pragma: no cover
|
||||
# Exception if the database has not been migrated yet
|
||||
pass
|
||||
|
@ -11,7 +11,7 @@ import InvenTree.validators
|
||||
import part.models
|
||||
|
||||
|
||||
def attach_file(instance, filename):
|
||||
def attach_file(instance, filename): # pragma: no cover
|
||||
"""
|
||||
Generate a filename for the uploaded attachment.
|
||||
|
||||
|
@ -10,7 +10,7 @@ def update_tree(apps, schema_editor):
|
||||
Part.objects.rebuild()
|
||||
|
||||
|
||||
def nupdate_tree(apps, schema_editor):
|
||||
def nupdate_tree(apps, schema_editor): # pragma: no cover
|
||||
pass
|
||||
|
||||
|
||||
|
@ -1,5 +1,7 @@
|
||||
# Generated by Django 3.0.7 on 2020-11-10 11:25
|
||||
|
||||
import logging
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
from moneyed import CURRENCIES
|
||||
@ -7,6 +9,9 @@ from django.db import migrations, connection
|
||||
from company.models import SupplierPriceBreak
|
||||
|
||||
|
||||
logger = logging.getLogger('inventree')
|
||||
|
||||
|
||||
def migrate_currencies(apps, schema_editor):
|
||||
"""
|
||||
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.
|
||||
"""
|
||||
|
||||
print("Updating currency references for SupplierPriceBreak model...")
|
||||
logger.info("Updating currency references for SupplierPriceBreak model...")
|
||||
|
||||
# A list of available currency codes
|
||||
currency_codes = CURRENCIES.keys()
|
||||
@ -33,7 +38,7 @@ def migrate_currencies(apps, schema_editor):
|
||||
|
||||
remap = {}
|
||||
|
||||
for index, row in enumerate(results):
|
||||
for index, row in enumerate(results): # pragma: no cover
|
||||
pk, suffix, description = row
|
||||
|
||||
suffix = suffix.strip().upper()
|
||||
@ -57,7 +62,7 @@ def migrate_currencies(apps, schema_editor):
|
||||
|
||||
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
|
||||
|
||||
# Copy the 'cost' field across to the 'price' field
|
||||
@ -71,10 +76,10 @@ def migrate_currencies(apps, schema_editor):
|
||||
|
||||
count += 1
|
||||
|
||||
if count > 0:
|
||||
if count > 0: # pragma: no cover
|
||||
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.
|
||||
|
||||
|
@ -46,7 +46,7 @@ from common.models import InvenTreeSetting
|
||||
|
||||
from InvenTree import helpers
|
||||
from InvenTree import validators
|
||||
from InvenTree.models import InvenTreeTree, InvenTreeAttachment
|
||||
from InvenTree.models import InvenTreeTree, InvenTreeAttachment, DataImportMixin
|
||||
from InvenTree.fields import InvenTreeURLField
|
||||
from InvenTree.helpers import decimal2string, normalize, decimal2money
|
||||
import InvenTree.tasks
|
||||
@ -483,6 +483,36 @@ class Part(MPTTModel):
|
||||
def __str__(self):
|
||||
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):
|
||||
"""
|
||||
Check if this Part can be added to the BOM of another part.
|
||||
@ -1498,6 +1528,16 @@ class Part(MPTTModel):
|
||||
def has_bom(self):
|
||||
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
|
||||
def has_trackable_parts(self):
|
||||
"""
|
||||
@ -1505,11 +1545,7 @@ class Part(MPTTModel):
|
||||
This is important when building the part.
|
||||
"""
|
||||
|
||||
for bom_item in self.get_bom_items().all():
|
||||
if bom_item.sub_part.trackable:
|
||||
return True
|
||||
|
||||
return False
|
||||
return self.get_trackable_parts().count() > 0
|
||||
|
||||
@property
|
||||
def bom_count(self):
|
||||
@ -2544,7 +2580,7 @@ class PartCategoryParameterTemplate(models.Model):
|
||||
help_text=_('Default Parameter Value'))
|
||||
|
||||
|
||||
class BomItem(models.Model):
|
||||
class BomItem(models.Model, DataImportMixin):
|
||||
""" A BomItem links a part to its component items.
|
||||
A part can have a BOM (bill of materials) which defines
|
||||
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
|
||||
"""
|
||||
|
||||
# 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
|
||||
def get_api_url():
|
||||
return reverse('api-bom-list')
|
||||
|
@ -6,7 +6,7 @@ import imghdr
|
||||
from decimal import Decimal
|
||||
|
||||
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.functions import Coalesce
|
||||
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 djmoney.contrib.django_rest_framework import MoneyField
|
||||
|
||||
from InvenTree.serializers import (InvenTreeAttachmentSerializerField,
|
||||
from InvenTree.serializers import (DataFileUploadSerializer,
|
||||
DataFileExtractSerializer,
|
||||
InvenTreeAttachmentSerializerField,
|
||||
InvenTreeDecimalField,
|
||||
InvenTreeImageSerializerField,
|
||||
InvenTreeModelSerializer,
|
||||
@ -462,7 +464,13 @@ class BomItemSerializer(InvenTreeModelSerializer):
|
||||
|
||||
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))
|
||||
|
||||
@ -699,3 +707,169 @@ class PartCopyBOMSerializer(serializers.Serializer):
|
||||
skip_invalid=data.get('skip_invalid', 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" %}
|
||||
{% load inventree_extras %}
|
||||
{% load i18n %}
|
||||
{% load static %}
|
||||
|
||||
{% block form_alert %}
|
||||
{% if missing_columns and missing_columns|length > 0 %}
|
||||
<div class='alert alert-danger alert-block' role='alert'>
|
||||
{% trans "Missing selections for the following required columns" %}:
|
||||
<br>
|
||||
<ul>
|
||||
{% for col in missing_columns %}
|
||||
<li>{{ col }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if duplicates and duplicates|length > 0 %}
|
||||
<div class='alert alert-danger alert-block' role='alert'>
|
||||
{% trans "Duplicate selections found, see below. Fix them then retry submitting." %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock form_alert %}
|
||||
|
||||
{% block form_buttons_top %}
|
||||
{% if wizard.steps.prev %}
|
||||
<button name="wizard_goto_step" type="submit" value="{{ wizard.steps.prev }}" class="save btn btn-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 %}
|
||||
{% include "patterns/wizard/match_fields.html" %}
|
@ -10,51 +10,10 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class='panel'>
|
||||
<div class='panel-heading'>
|
||||
<h4>
|
||||
{% trans "Import Parts from File" %}
|
||||
{{ wizard.form.media }}
|
||||
</h4>
|
||||
</div>
|
||||
<div class='panel-content'>
|
||||
{% if roles.part.change %}
|
||||
|
||||
<p>{% blocktrans with step=wizard.steps.step1 count=wizard.steps.count %}Step {{step}} of {{count}}{% endblocktrans %}
|
||||
{% if description %}- {{ description }}{% endif %}</p>
|
||||
|
||||
{% block form_alert %}
|
||||
{% endblock form_alert %}
|
||||
|
||||
<form action="" method="post" class='js-modal-form' enctype="multipart/form-data">
|
||||
{% csrf_token %}
|
||||
{% load crispy_forms_tags %}
|
||||
|
||||
{% block form_buttons_top %}
|
||||
{% endblock form_buttons_top %}
|
||||
|
||||
<table class='table table-striped' style='margin-top: 12px; margin-bottom: 0px'>
|
||||
{{ wizard.management_form }}
|
||||
{% block form_content %}
|
||||
{% crispy wizard.form %}
|
||||
{% endblock form_content %}
|
||||
</table>
|
||||
|
||||
{% block form_buttons_bottom %}
|
||||
{% if wizard.steps.prev %}
|
||||
<button name="wizard_goto_step" type="submit" value="{{ wizard.steps.prev }}" class="save btn btn-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>
|
||||
{% trans "Import Parts from File" as header_text %}
|
||||
{% roles.part.change as upload_go_ahead %}
|
||||
{% trans "Unsuffitient privileges." as error_text %}
|
||||
{% include "patterns/wizard/upload.html" with header_text=header_text upload_go_ahead=upload_go_ahead error_text=error_text %}
|
||||
{% endblock %}
|
||||
|
||||
{% block js_ready %}
|
||||
|
@ -14,7 +14,7 @@
|
||||
{% endblock %}
|
||||
|
||||
{% 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 %}
|
||||
{% include "part/cat_link.html" with category=part.category part=part %}
|
||||
{% else %}
|
||||
|
119
InvenTree/part/templates/part/upload_bom.html
Normal file
119
InvenTree/part/templates/part/upload_bom.html
Normal file
@ -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
|
||||
|
||||
# We should never get here!
|
||||
self.assertTrue(False)
|
||||
self.assertTrue(False) # pragma: no cover
|
||||
|
||||
def test_stock_quantity(self):
|
||||
"""
|
||||
|
@ -107,7 +107,7 @@ class BomExportTest(TestCase):
|
||||
"""
|
||||
|
||||
params = {
|
||||
'file_format': 'csv',
|
||||
'format': 'csv',
|
||||
'cascade': True,
|
||||
'parameter_data': True,
|
||||
'stock_data': True,
|
||||
@ -171,7 +171,7 @@ class BomExportTest(TestCase):
|
||||
"""
|
||||
|
||||
params = {
|
||||
'file_format': 'xls',
|
||||
'format': 'xls',
|
||||
'cascade': True,
|
||||
'parameter_data': True,
|
||||
'stock_data': True,
|
||||
@ -192,7 +192,7 @@ class BomExportTest(TestCase):
|
||||
"""
|
||||
|
||||
params = {
|
||||
'file_format': 'xlsx',
|
||||
'format': 'xlsx',
|
||||
'cascade': True,
|
||||
'parameter_data': True,
|
||||
'stock_data': True,
|
||||
@ -210,7 +210,7 @@ class BomExportTest(TestCase):
|
||||
"""
|
||||
|
||||
params = {
|
||||
'file_format': 'json',
|
||||
'format': 'json',
|
||||
'cascade': True,
|
||||
'parameter_data': True,
|
||||
'stock_data': True,
|
||||
|
344
InvenTree/part/test_bom_import.py
Normal file
344
InvenTree/part/test_bom_import.py
Normal file
@ -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):
|
||||
# A validation error should be raised here
|
||||
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):
|
||||
"""
|
||||
|
@ -127,7 +127,7 @@ class CategoryTest(TestCase):
|
||||
|
||||
with self.assertRaises(ValidationError) as err:
|
||||
cat.full_clean()
|
||||
cat.save()
|
||||
cat.save() # pragma: no cover
|
||||
|
||||
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')
|
||||
|
||||
# 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'
|
||||
r1 = Part.objects.get(name='R_2K2_0805')
|
||||
self.assertIsNone(r1.default_location)
|
||||
|
@ -44,7 +44,7 @@ class TestParams(TestCase):
|
||||
with self.assertRaises(django_exceptions.ValidationError):
|
||||
t3 = PartParameterTemplate(name='aBcde', units='dd')
|
||||
t3.full_clean()
|
||||
t3.save()
|
||||
t3.save() # pragma: no cover
|
||||
|
||||
|
||||
class TestCategoryTemplates(TransactionTestCase):
|
||||
|
@ -110,7 +110,7 @@ class PartTest(TestCase):
|
||||
|
||||
try:
|
||||
part.save()
|
||||
self.assertTrue(False)
|
||||
self.assertTrue(False) # pragma: no cover
|
||||
except:
|
||||
pass
|
||||
|
||||
|
@ -33,7 +33,6 @@ part_parameter_urls = [
|
||||
|
||||
part_detail_urls = [
|
||||
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'^pricing/', views.PartPricing.as_view(), name='part-pricing'),
|
||||
|
@ -28,20 +28,17 @@ import requests
|
||||
import os
|
||||
import io
|
||||
|
||||
from rapidfuzz import fuzz
|
||||
from decimal import Decimal, InvalidOperation
|
||||
from decimal import Decimal
|
||||
|
||||
from .models import PartCategory, Part
|
||||
from .models import PartParameterTemplate
|
||||
from .models import PartCategoryParameterTemplate
|
||||
from .models import BomItem
|
||||
from .models import PartSellPriceBreak, PartInternalPriceBreak
|
||||
|
||||
from common.models import InvenTreeSetting
|
||||
from company.models import SupplierPart
|
||||
from common.files import FileManager
|
||||
from common.views import FileManagementFormView, FileManagementAjaxView
|
||||
from common.forms import UploadFileForm, MatchFieldForm
|
||||
|
||||
from stock.models import StockItem, StockLocation
|
||||
|
||||
@ -704,270 +701,12 @@ class PartImageSelect(AjaxUpdateView):
|
||||
return self.renderJsonResponse(request, form, data)
|
||||
|
||||
|
||||
class BomUpload(InvenTreeRoleMixin, FileManagementFormView):
|
||||
""" View for uploading a BOM file, and handling BOM data importing.
|
||||
class BomUpload(InvenTreeRoleMixin, DetailView):
|
||||
""" View for uploading a BOM file, and handling BOM data importing. """
|
||||
|
||||
The BOM upload process is as follows:
|
||||
|
||||
1. (Client) Select and upload BOM file
|
||||
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']}))
|
||||
context_object_name = 'part'
|
||||
queryset = Part.objects.all()
|
||||
template_name = 'part/upload_bom.html'
|
||||
|
||||
|
||||
class PartExport(AjaxView):
|
||||
@ -1060,7 +799,7 @@ class BomDownload(AjaxView):
|
||||
|
||||
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))
|
||||
|
||||
@ -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):
|
||||
""" View to delete a Part object """
|
||||
|
||||
|
@ -21,7 +21,7 @@ class PluginAppConfig(AppConfig):
|
||||
def ready(self):
|
||||
if settings.PLUGINS_ENABLED:
|
||||
|
||||
if isImportingData():
|
||||
if isImportingData(): # pragma: no cover
|
||||
logger.info('Skipping plugin loading for data import')
|
||||
else:
|
||||
logger.info('Loading InvenTree plugins')
|
||||
|
@ -51,7 +51,7 @@ class SettingsMixin:
|
||||
|
||||
try:
|
||||
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
|
||||
|
||||
if not plugin:
|
||||
|
@ -23,7 +23,7 @@ class IntegrationPluginError(Exception):
|
||||
self.message = message
|
||||
|
||||
def __str__(self):
|
||||
return self.message
|
||||
return self.message # pragma: no cover
|
||||
|
||||
|
||||
class MixinImplementationError(ValueError):
|
||||
@ -55,7 +55,7 @@ def log_error(error, reference: str = 'general'):
|
||||
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
|
||||
"""
|
||||
@ -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[-1] = path_parts[-1].replace(path_obj.suffix, '') # remove suffix
|
||||
|
||||
# remove path preixes
|
||||
# remove path prefixes
|
||||
if path_parts[0] == 'plugin':
|
||||
path_parts.remove('plugin')
|
||||
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_error({package_name: str(error)}, **log_kwargs)
|
||||
|
||||
new_error = IntegrationPluginError(package_name, str(error))
|
||||
|
||||
if do_raise:
|
||||
raise IntegrationPluginError(package_name, str(error))
|
||||
|
||||
if do_return:
|
||||
return new_error
|
||||
# endregion
|
||||
|
||||
|
||||
@ -101,14 +96,16 @@ def get_git_log(path):
|
||||
"""
|
||||
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]
|
||||
output = None
|
||||
try:
|
||||
output = str(subprocess.check_output(command, cwd=os.path.dirname(settings.BASE_DIR)), 'utf-8')[1:-1]
|
||||
if output:
|
||||
output = output.split('\n')
|
||||
else:
|
||||
output = 7 * ['']
|
||||
except subprocess.CalledProcessError:
|
||||
output = 7 * ['']
|
||||
except subprocess.CalledProcessError: # pragma: no cover
|
||||
pass
|
||||
|
||||
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]}
|
||||
|
||||
|
||||
@ -153,7 +150,7 @@ def get_modules(pkg):
|
||||
if not k.startswith('_') and (pkg_names is None or k in pkg_names):
|
||||
context[k] = v
|
||||
context[name] = module
|
||||
except AppRegistryNotReady:
|
||||
except AppRegistryNotReady: # pragma: no cover
|
||||
pass
|
||||
except Exception as error:
|
||||
# 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:
|
||||
author = self.package.get('author')
|
||||
if not author:
|
||||
author = _('No author found')
|
||||
author = _('No author found') # pragma: no cover
|
||||
return author
|
||||
|
||||
@property
|
||||
@ -149,7 +149,7 @@ class IntegrationPluginBase(MixinBase, plugin_base.InvenTreePluginBase):
|
||||
else:
|
||||
pub_date = datetime.fromisoformat(str(pub_date))
|
||||
if not pub_date:
|
||||
pub_date = _('No date found')
|
||||
pub_date = _('No date found') # pragma: no cover
|
||||
return pub_date
|
||||
|
||||
@property
|
||||
@ -226,7 +226,7 @@ class IntegrationPluginBase(MixinBase, plugin_base.InvenTreePluginBase):
|
||||
"""
|
||||
Get package metadata for plugin
|
||||
"""
|
||||
return {}
|
||||
return {} # pragma: no cover # TODO add usage for package metadata
|
||||
|
||||
def define_package(self):
|
||||
"""
|
||||
@ -241,11 +241,11 @@ class IntegrationPluginBase(MixinBase, plugin_base.InvenTreePluginBase):
|
||||
# process sign state
|
||||
sign_state = getattr(GitStatus, str(package.get('verified')), GitStatus.N)
|
||||
if sign_state.status == 0:
|
||||
self.sign_color = 'success'
|
||||
self.sign_color = 'success' # pragma: no cover
|
||||
elif sign_state.status == 1:
|
||||
self.sign_color = 'warning'
|
||||
else:
|
||||
self.sign_color = 'danger'
|
||||
self.sign_color = 'danger' # pragma: no cover
|
||||
|
||||
# set variables
|
||||
self.package = package
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user