Merge remote-tracking branch 'inventree/master'

This commit is contained in:
Oliver Walters 2022-02-25 20:09:06 +11:00
commit dc6996290f
151 changed files with 42448 additions and 28966 deletions

2
.github/FUNDING.yml vendored Normal file
View File

@ -0,0 +1,2 @@
patreon: inventree
ko_fi: inventree

View File

@ -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 }}

View File

@ -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 }}

View File

@ -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)

View File

@ -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}")

View File

@ -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

View File

@ -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

View File

@ -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())

View File

@ -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:

View File

@ -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.

View File

@ -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
"""
...

View File

@ -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

View File

@ -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

View File

@ -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')

View File

@ -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):

View File

@ -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(),
''

View File

@ -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

View File

@ -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)

View File

@ -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)),

View File

@ -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")
)

View File

@ -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

View File

@ -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'),

View File

@ -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 """

View File

@ -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

View File

@ -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.
"""

View File

@ -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

View File

@ -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
"""

View File

@ -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):
"""

View File

@ -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

View File

@ -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 %}

View File

@ -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 %}

View File

@ -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 %}

View File

@ -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)

View File

@ -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'),
]

View File

@ -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.

View File

@ -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

View File

@ -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):

View File

@ -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

View File

@ -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:

View File

@ -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.

View File

@ -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')

View File

@ -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):
"""

View File

@ -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']}'")

View File

@ -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)

View File

@ -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()

View File

@ -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

Binary file not shown.

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

View File

@ -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 "

View File

@ -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
"""

View File

@ -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
"""

View File

@ -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.

View File

@ -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 %}",
{

View File

@ -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" %}

View File

@ -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 %}

View File

@ -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'),
]

View File

@ -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

View File

@ -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.

View File

@ -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

View File

@ -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.

View File

@ -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')

View File

@ -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))

View File

@ -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 %}

View File

@ -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 %}

View File

@ -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" %}

View File

@ -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 %}

View File

@ -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 %}

View 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 %}

View File

@ -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):
"""

View File

@ -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,

View 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)

View File

@ -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):
"""

View File

@ -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)

View File

@ -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):

View File

@ -110,7 +110,7 @@ class PartTest(TestCase):
try:
part.save()
self.assertTrue(False)
self.assertTrue(False) # pragma: no cover
except:
pass

View File

@ -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'),

View File

@ -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 += '&parameter_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 """

View File

@ -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')

View File

@ -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:

View File

@ -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

View File

@ -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