Merge branch 'inventree:master' into matmair/issue2279

This commit is contained in:
Matthias Mair 2022-02-16 02:02:02 +01:00 committed by GitHub
commit 626f44d195
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
91 changed files with 848 additions and 724 deletions

View File

@ -38,7 +38,7 @@ class InvenTreeConfig(AppConfig):
try: try:
from django_q.models import Schedule from django_q.models import Schedule
except (AppRegistryNotReady): except AppRegistryNotReady: # pragma: no cover
return return
# Remove any existing obsolete tasks # Remove any existing obsolete tasks
@ -48,7 +48,7 @@ class InvenTreeConfig(AppConfig):
try: try:
from django_q.models import Schedule from django_q.models import Schedule
except (AppRegistryNotReady): except AppRegistryNotReady: # pragma: no cover
return return
logger.info("Starting background tasks...") logger.info("Starting background tasks...")
@ -103,7 +103,7 @@ class InvenTreeConfig(AppConfig):
from InvenTree.tasks import update_exchange_rates from InvenTree.tasks import update_exchange_rates
from common.settings import currency_code_default from common.settings import currency_code_default
except AppRegistryNotReady: except AppRegistryNotReady: # pragma: no cover
pass pass
base_currency = currency_code_default() base_currency = currency_code_default()

View File

@ -1,5 +1,6 @@
""" """
Pull rendered copies of the templated Pull rendered copies of the templated
only used for testing the js files! - This file is omited from coverage
""" """
from django.test import TestCase from django.test import TestCase

View File

@ -23,7 +23,7 @@ def health_status(request):
if request.path.endswith('.js'): if request.path.endswith('.js'):
# Do not provide to script requests # Do not provide to script requests
return {} return {} # pragma: no cover
if hasattr(request, '_inventree_health_status'): if hasattr(request, '_inventree_health_status'):
# Do not duplicate efforts # Do not duplicate efforts

View File

@ -82,7 +82,7 @@ logging.basicConfig(
) )
if log_level not in ['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL']: if log_level not in ['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL']:
log_level = 'WARNING' log_level = 'WARNING' # pragma: no cover
LOGGING = { LOGGING = {
'version': 1, 'version': 1,
@ -119,20 +119,20 @@ d) Create "secret_key.txt" if it does not exist
if os.getenv("INVENTREE_SECRET_KEY"): if os.getenv("INVENTREE_SECRET_KEY"):
# Secret key passed in directly # Secret key passed in directly
SECRET_KEY = os.getenv("INVENTREE_SECRET_KEY").strip() SECRET_KEY = os.getenv("INVENTREE_SECRET_KEY").strip() # pragma: no cover
logger.info("SECRET_KEY loaded by INVENTREE_SECRET_KEY") logger.info("SECRET_KEY loaded by INVENTREE_SECRET_KEY") # pragma: no cover
else: else:
# Secret key passed in by file location # Secret key passed in by file location
key_file = os.getenv("INVENTREE_SECRET_KEY_FILE") key_file = os.getenv("INVENTREE_SECRET_KEY_FILE")
if key_file: if key_file:
key_file = os.path.abspath(key_file) key_file = os.path.abspath(key_file) # pragma: no cover
else: else:
# default secret key location # default secret key location
key_file = os.path.join(BASE_DIR, "secret_key.txt") key_file = os.path.join(BASE_DIR, "secret_key.txt")
key_file = os.path.abspath(key_file) key_file = os.path.abspath(key_file)
if not os.path.exists(key_file): if not os.path.exists(key_file): # pragma: no cover
logger.info(f"Generating random key file at '{key_file}'") logger.info(f"Generating random key file at '{key_file}'")
# Create a random key file # Create a random key file
with open(key_file, 'w') as f: with open(key_file, 'w') as f:
@ -144,7 +144,7 @@ else:
try: try:
SECRET_KEY = open(key_file, "r").read().strip() SECRET_KEY = open(key_file, "r").read().strip()
except Exception: except Exception: # pragma: no cover
logger.exception(f"Couldn't load keyfile {key_file}") logger.exception(f"Couldn't load keyfile {key_file}")
sys.exit(-1) sys.exit(-1)
@ -156,7 +156,7 @@ STATIC_ROOT = os.path.abspath(
) )
) )
if STATIC_ROOT is None: if STATIC_ROOT is None: # pragma: no cover
print("ERROR: INVENTREE_STATIC_ROOT directory not defined") print("ERROR: INVENTREE_STATIC_ROOT directory not defined")
sys.exit(1) sys.exit(1)
@ -168,7 +168,7 @@ MEDIA_ROOT = os.path.abspath(
) )
) )
if MEDIA_ROOT is None: if MEDIA_ROOT is None: # pragma: no cover
print("ERROR: INVENTREE_MEDIA_ROOT directory is not defined") print("ERROR: INVENTREE_MEDIA_ROOT directory is not defined")
sys.exit(1) sys.exit(1)
@ -187,7 +187,7 @@ if cors_opt:
CORS_ORIGIN_ALLOW_ALL = cors_opt.get('allow_all', False) CORS_ORIGIN_ALLOW_ALL = cors_opt.get('allow_all', False)
if not CORS_ORIGIN_ALLOW_ALL: if not CORS_ORIGIN_ALLOW_ALL:
CORS_ORIGIN_WHITELIST = cors_opt.get('whitelist', []) CORS_ORIGIN_WHITELIST = cors_opt.get('whitelist', []) # pragma: no cover
# Web URL endpoint for served static files # Web URL endpoint for served static files
STATIC_URL = '/static/' STATIC_URL = '/static/'
@ -215,7 +215,7 @@ if DEBUG:
logger.info("InvenTree running with DEBUG enabled") logger.info("InvenTree running with DEBUG enabled")
if DEMO_MODE: if DEMO_MODE:
logger.warning("InvenTree running in DEMO mode") logger.warning("InvenTree running in DEMO mode") # pragma: no cover
logger.debug(f"MEDIA_ROOT: '{MEDIA_ROOT}'") logger.debug(f"MEDIA_ROOT: '{MEDIA_ROOT}'")
logger.debug(f"STATIC_ROOT: '{STATIC_ROOT}'") logger.debug(f"STATIC_ROOT: '{STATIC_ROOT}'")
@ -304,7 +304,7 @@ AUTHENTICATION_BACKENDS = CONFIG.get('authentication_backends', [
]) ])
# If the debug toolbar is enabled, add the modules # If the debug toolbar is enabled, add the modules
if DEBUG and CONFIG.get('debug_toolbar', False): if DEBUG and CONFIG.get('debug_toolbar', False): # pragma: no cover
logger.info("Running with DEBUG_TOOLBAR enabled") logger.info("Running with DEBUG_TOOLBAR enabled")
INSTALLED_APPS.append('debug_toolbar') INSTALLED_APPS.append('debug_toolbar')
MIDDLEWARE.append('debug_toolbar.middleware.DebugToolbarMiddleware') MIDDLEWARE.append('debug_toolbar.middleware.DebugToolbarMiddleware')
@ -396,7 +396,7 @@ for key in db_keys:
reqiured_keys = ['ENGINE', 'NAME'] reqiured_keys = ['ENGINE', 'NAME']
for key in reqiured_keys: for key in reqiured_keys:
if key not in db_config: if key not in db_config: # pragma: no cover
error_msg = f'Missing required database configuration value {key}' error_msg = f'Missing required database configuration value {key}'
logger.error(error_msg) logger.error(error_msg)
@ -415,7 +415,7 @@ db_engine = db_config['ENGINE'].lower()
# Correct common misspelling # Correct common misspelling
if db_engine == 'sqlite': if db_engine == 'sqlite':
db_engine = 'sqlite3' db_engine = 'sqlite3' # pragma: no cover
if db_engine in ['sqlite3', 'postgresql', 'mysql']: if db_engine in ['sqlite3', 'postgresql', 'mysql']:
# Prepend the required python module string # Prepend the required python module string
@ -443,7 +443,7 @@ Ref: https://docs.djangoproject.com/en/3.2/ref/settings/#std:setting-OPTIONS
db_options = db_config.get("OPTIONS", db_config.get("options", {})) db_options = db_config.get("OPTIONS", db_config.get("options", {}))
# Specific options for postgres backend # Specific options for postgres backend
if "postgres" in db_engine: if "postgres" in db_engine: # pragma: no cover
from psycopg2.extensions import ( from psycopg2.extensions import (
ISOLATION_LEVEL_READ_COMMITTED, ISOLATION_LEVEL_READ_COMMITTED,
ISOLATION_LEVEL_SERIALIZABLE, ISOLATION_LEVEL_SERIALIZABLE,
@ -505,7 +505,7 @@ if "postgres" in db_engine:
) )
# Specific options for MySql / MariaDB backend # Specific options for MySql / MariaDB backend
if "mysql" in db_engine: if "mysql" in db_engine: # pragma: no cover
# TODO TCP time outs and keepalives # TODO TCP time outs and keepalives
# MariaDB's default isolation level is Repeatable Read which is # MariaDB's default isolation level is Repeatable Read which is
@ -546,7 +546,7 @@ _cache_port = _cache_config.get(
"port", os.getenv("INVENTREE_CACHE_PORT", "6379") "port", os.getenv("INVENTREE_CACHE_PORT", "6379")
) )
if _cache_host: if _cache_host: # pragma: no cover
# We are going to rely upon a possibly non-localhost for our cache, # We are going to rely upon a possibly non-localhost for our cache,
# so don't wait too long for the cache as nothing in the cache should be # so don't wait too long for the cache as nothing in the cache should be
# irreplacable. # irreplacable.
@ -591,7 +591,7 @@ else:
try: try:
# 4 background workers seems like a sensible default # 4 background workers seems like a sensible default
background_workers = int(os.environ.get('INVENTREE_BACKGROUND_WORKERS', 4)) background_workers = int(os.environ.get('INVENTREE_BACKGROUND_WORKERS', 4))
except ValueError: except ValueError: # pragma: no cover
background_workers = 4 background_workers = 4
# django-q configuration # django-q configuration
@ -606,7 +606,7 @@ Q_CLUSTER = {
'sync': False, 'sync': False,
} }
if _cache_host: if _cache_host: # pragma: no cover
# If using external redis cache, make the cache the broker for Django Q # If using external redis cache, make the cache the broker for Django Q
# as well # as well
Q_CLUSTER["django_redis"] = "worker" Q_CLUSTER["django_redis"] = "worker"
@ -641,7 +641,7 @@ AUTH_PASSWORD_VALIDATORS = [
EXTRA_URL_SCHEMES = CONFIG.get('extra_url_schemes', []) EXTRA_URL_SCHEMES = CONFIG.get('extra_url_schemes', [])
if not type(EXTRA_URL_SCHEMES) in [list]: if not type(EXTRA_URL_SCHEMES) in [list]: # pragma: no cover
logger.warning("extra_url_schemes not correctly formatted") logger.warning("extra_url_schemes not correctly formatted")
EXTRA_URL_SCHEMES = [] EXTRA_URL_SCHEMES = []
@ -675,7 +675,7 @@ LANGUAGES = [
] ]
# Testing interface translations # Testing interface translations
if get_setting('TEST_TRANSLATIONS', False): if get_setting('TEST_TRANSLATIONS', False): # pragma: no cover
# Set default language # Set default language
LANGUAGE_CODE = 'xx' LANGUAGE_CODE = 'xx'
@ -703,7 +703,7 @@ CURRENCIES = CONFIG.get(
# Check that each provided currency is supported # Check that each provided currency is supported
for currency in CURRENCIES: for currency in CURRENCIES:
if currency not in moneyed.CURRENCIES: if currency not in moneyed.CURRENCIES: # pragma: no cover
print(f"Currency code '{currency}' is not supported") print(f"Currency code '{currency}' is not supported")
sys.exit(1) sys.exit(1)
@ -777,7 +777,7 @@ USE_L10N = True
# Do not use native timezone support in "test" mode # Do not use native timezone support in "test" mode
# It generates a *lot* of cruft in the logs # It generates a *lot* of cruft in the logs
if not TESTING: if not TESTING:
USE_TZ = True USE_TZ = True # pragma: no cover
DATE_INPUT_FORMATS = [ DATE_INPUT_FORMATS = [
"%Y-%m-%d", "%Y-%m-%d",
@ -805,7 +805,7 @@ SITE_ID = 1
# Load the allauth social backends # Load the allauth social backends
SOCIAL_BACKENDS = CONFIG.get('social_backends', []) SOCIAL_BACKENDS = CONFIG.get('social_backends', [])
for app in SOCIAL_BACKENDS: for app in SOCIAL_BACKENDS:
INSTALLED_APPS.append(app) INSTALLED_APPS.append(app) # pragma: no cover
SOCIALACCOUNT_PROVIDERS = CONFIG.get('social_providers', []) SOCIALACCOUNT_PROVIDERS = CONFIG.get('social_providers', [])
@ -879,7 +879,7 @@ PLUGIN_DIRS = ['plugin.builtin', 'barcodes.plugins', ]
if not TESTING: if not TESTING:
# load local deploy directory in prod # load local deploy directory in prod
PLUGIN_DIRS.append('plugins') PLUGIN_DIRS.append('plugins') # pragma: no cover
if DEBUG or TESTING: if DEBUG or TESTING:
# load samples in debug mode # load samples in debug mode

View File

@ -60,21 +60,21 @@ def is_email_configured():
configured = False configured = False
# Display warning unless in test mode # Display warning unless in test mode
if not settings.TESTING: if not settings.TESTING: # pragma: no cover
logger.debug("EMAIL_HOST is not configured") logger.debug("EMAIL_HOST is not configured")
if not settings.EMAIL_HOST_USER: if not settings.EMAIL_HOST_USER:
configured = False configured = False
# Display warning unless in test mode # Display warning unless in test mode
if not settings.TESTING: if not settings.TESTING: # pragma: no cover
logger.debug("EMAIL_HOST_USER is not configured") logger.debug("EMAIL_HOST_USER is not configured")
if not settings.EMAIL_HOST_PASSWORD: if not settings.EMAIL_HOST_PASSWORD:
configured = False configured = False
# Display warning unless in test mode # Display warning unless in test mode
if not settings.TESTING: if not settings.TESTING: # pragma: no cover
logger.debug("EMAIL_HOST_PASSWORD is not configured") logger.debug("EMAIL_HOST_PASSWORD is not configured")
return configured return configured
@ -89,15 +89,15 @@ def check_system_health(**kwargs):
result = True result = True
if not is_worker_running(**kwargs): if not is_worker_running(**kwargs): # pragma: no cover
result = False result = False
logger.warning(_("Background worker check failed")) logger.warning(_("Background worker check failed"))
if not is_email_configured(): if not is_email_configured(): # pragma: no cover
result = False result = False
logger.warning(_("Email backend not configured")) logger.warning(_("Email backend not configured"))
if not result: if not result: # pragma: no cover
logger.warning(_("InvenTree system health checks failed")) logger.warning(_("InvenTree system health checks failed"))
return result return result

View File

@ -28,7 +28,7 @@ def schedule_task(taskname, **kwargs):
try: try:
from django_q.models import Schedule from django_q.models import Schedule
except (AppRegistryNotReady): except AppRegistryNotReady: # pragma: no cover
logger.info("Could not start background tasks - App registry not ready") logger.info("Could not start background tasks - App registry not ready")
return return
@ -47,7 +47,7 @@ def schedule_task(taskname, **kwargs):
func=taskname, func=taskname,
**kwargs **kwargs
) )
except (OperationalError, ProgrammingError): except (OperationalError, ProgrammingError): # pragma: no cover
# Required if the DB is not ready yet # Required if the DB is not ready yet
pass pass
@ -108,10 +108,10 @@ def offload_task(taskname, *args, force_sync=False, **kwargs):
# Workers are not running: run it as synchronous task # Workers are not running: run it as synchronous task
_func(*args, **kwargs) _func(*args, **kwargs)
except (AppRegistryNotReady): except AppRegistryNotReady: # pragma: no cover
logger.warning(f"Could not offload task '{taskname}' - app registry not ready") logger.warning(f"Could not offload task '{taskname}' - app registry not ready")
return return
except (OperationalError, ProgrammingError): except (OperationalError, ProgrammingError): # pragma: no cover
logger.warning(f"Could not offload task '{taskname}' - database not ready") logger.warning(f"Could not offload task '{taskname}' - database not ready")
@ -127,7 +127,7 @@ def heartbeat():
try: try:
from django_q.models import Success from django_q.models import Success
logger.info("Could not perform heartbeat task - App registry not ready") logger.info("Could not perform heartbeat task - App registry not ready")
except AppRegistryNotReady: except AppRegistryNotReady: # pragma: no cover
return return
threshold = timezone.now() - timedelta(minutes=30) threshold = timezone.now() - timedelta(minutes=30)
@ -150,7 +150,7 @@ def delete_successful_tasks():
try: try:
from django_q.models import Success from django_q.models import Success
except AppRegistryNotReady: except AppRegistryNotReady: # pragma: no cover
logger.info("Could not perform 'delete_successful_tasks' - App registry not ready") logger.info("Could not perform 'delete_successful_tasks' - App registry not ready")
return return
@ -184,7 +184,7 @@ def delete_old_error_logs():
logger.info(f"Deleting {errors.count()} old error logs") logger.info(f"Deleting {errors.count()} old error logs")
errors.delete() errors.delete()
except AppRegistryNotReady: except AppRegistryNotReady: # pragma: no cover
# Apps not yet loaded # Apps not yet loaded
logger.info("Could not perform 'delete_old_error_logs' - App registry not ready") logger.info("Could not perform 'delete_old_error_logs' - App registry not ready")
return return
@ -197,7 +197,7 @@ def check_for_updates():
try: try:
import common.models import common.models
except AppRegistryNotReady: except AppRegistryNotReady: # pragma: no cover
# Apps not yet loaded! # Apps not yet loaded!
logger.info("Could not perform 'check_for_updates' - App registry not ready") logger.info("Could not perform 'check_for_updates' - App registry not ready")
return return
@ -244,7 +244,7 @@ def update_exchange_rates():
from InvenTree.exchange import InvenTreeExchange from InvenTree.exchange import InvenTreeExchange
from djmoney.contrib.exchange.models import ExchangeBackend, Rate from djmoney.contrib.exchange.models import ExchangeBackend, Rate
from common.settings import currency_code_default, currency_codes from common.settings import currency_code_default, currency_codes
except AppRegistryNotReady: except AppRegistryNotReady: # pragma: no cover
# Apps not yet loaded! # Apps not yet loaded!
logger.info("Could not perform 'update_exchange_rates' - App registry not ready") logger.info("Could not perform 'update_exchange_rates' - App registry not ready")
return return

View File

@ -92,7 +92,7 @@ class URLTest(TestCase):
result[0].strip(), result[0].strip(),
result[1].strip() result[1].strip()
]) ])
elif len(result) == 1: elif len(result) == 1: # pragma: no cover
urls.append([ urls.append([
result[0].strip(), result[0].strip(),
'' ''

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.test import TestCase
from django.urls import reverse from django.urls import reverse
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
import os
class ViewTests(TestCase): class ViewTests(TestCase):
""" Tests for various top-level views """ """ Tests for various top-level views """
@ -16,9 +19,13 @@ class ViewTests(TestCase):
def setUp(self): def setUp(self):
# Create a user # Create a user
get_user_model().objects.create_user(self.username, 'user@email.com', self.password) self.user = get_user_model().objects.create_user(self.username, 'user@email.com', self.password)
self.user.set_password(self.password)
self.user.save()
self.client.login(username=self.username, password=self.password) result = self.client.login(username=self.username, password=self.password)
self.assertEqual(result, True)
def test_api_doc(self): def test_api_doc(self):
""" Test that the api-doc view works """ """ Test that the api-doc view works """
@ -27,3 +34,51 @@ class ViewTests(TestCase):
response = self.client.get(api_url) response = self.client.get(api_url)
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
def test_index_redirect(self):
"""
top-level URL should redirect to "index" page
"""
response = self.client.get("/")
self.assertEqual(response.status_code, 302)
def get_index_page(self):
"""
Retrieve the index page (used for subsequent unit tests)
"""
response = self.client.get("/index/")
self.assertEqual(response.status_code, 200)
return str(response.content.decode())
def test_panels(self):
"""
Test that the required 'panels' are present
"""
content = self.get_index_page()
self.assertIn("<div id='detail-panels'>", content)
# TODO: In future, run the javascript and ensure that the panels get created!
def test_js_load(self):
"""
Test that the required javascript files are loaded correctly
"""
# Change this number as more javascript files are added to the index page
N_SCRIPT_FILES = 35
content = self.get_index_page()
# Extract all required javascript files from the index page content
script_files = re.findall("<script type='text\\/javascript' src=\"([^\"]*)\"><\\/script>", content)
self.assertEqual(len(script_files), N_SCRIPT_FILES)
# TODO: Request the javascript files from the server, and ensure they are correcty loaded

View File

@ -12,6 +12,8 @@ from djmoney.contrib.exchange.exceptions import MissingRate
from .validators import validate_overage, validate_part_name from .validators import validate_overage, validate_part_name
from . import helpers from . import helpers
from . import version from . import version
from . import status
from . import ready
from decimal import Decimal from decimal import Decimal
@ -389,3 +391,19 @@ class CurrencyTests(TestCase):
# Convert to a symbol which is not covered # Convert to a symbol which is not covered
with self.assertRaises(MissingRate): with self.assertRaises(MissingRate):
convert_money(Money(100, 'GBP'), 'ZWL') convert_money(Money(100, 'GBP'), 'ZWL')
class TestStatus(TestCase):
"""
Unit tests for status functions
"""
def test_check_system_healt(self):
"""test that the system health check is false in testing -> background worker not running"""
self.assertEqual(status.check_system_health(), False)
def test_TestMode(self):
self.assertTrue(ready.isInTestMode())
def test_Importing(self):
self.assertEqual(ready.isImportingData(), False)

View File

@ -213,7 +213,7 @@ if settings.DEBUG:
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
# Debug toolbar access (only allowed in DEBUG mode) # Debug toolbar access (only allowed in DEBUG mode)
if 'debug_toolbar' in settings.INSTALLED_APPS: if 'debug_toolbar' in settings.INSTALLED_APPS: # pragma: no cover
import debug_toolbar import debug_toolbar
urlpatterns = [ urlpatterns = [
path('__debug/', include(debug_toolbar.urls)), path('__debug/', include(debug_toolbar.urls)),

View File

@ -232,6 +232,29 @@ class BuildUnallocate(generics.CreateAPIView):
return ctx return ctx
class BuildOutputCreate(generics.CreateAPIView):
"""
API endpoint for creating new build output(s)
"""
queryset = Build.objects.none()
serializer_class = build.serializers.BuildOutputCreateSerializer
def get_serializer_context(self):
ctx = super().get_serializer_context()
ctx['request'] = self.request
ctx['to_complete'] = True
try:
ctx['build'] = Build.objects.get(pk=self.kwargs.get('pk', None))
except:
pass
return ctx
class BuildOutputComplete(generics.CreateAPIView): class BuildOutputComplete(generics.CreateAPIView):
""" """
API endpoint for completing build outputs API endpoint for completing build outputs
@ -455,6 +478,7 @@ build_api_urls = [
url(r'^(?P<pk>\d+)/', include([ url(r'^(?P<pk>\d+)/', include([
url(r'^allocate/', BuildAllocate.as_view(), name='api-build-allocate'), url(r'^allocate/', BuildAllocate.as_view(), name='api-build-allocate'),
url(r'^complete/', BuildOutputComplete.as_view(), name='api-build-output-complete'), url(r'^complete/', BuildOutputComplete.as_view(), name='api-build-output-complete'),
url(r'^create-output/', BuildOutputCreate.as_view(), name='api-build-output-create'),
url(r'^delete-outputs/', BuildOutputDelete.as_view(), name='api-build-output-delete'), url(r'^delete-outputs/', BuildOutputDelete.as_view(), name='api-build-output-delete'),
url(r'^finish/', BuildFinish.as_view(), name='api-build-finish'), url(r'^finish/', BuildFinish.as_view(), name='api-build-finish'),
url(r'^unallocate/', BuildUnallocate.as_view(), name='api-build-unallocate'), url(r'^unallocate/', BuildUnallocate.as_view(), name='api-build-unallocate'),

View File

@ -14,51 +14,6 @@ from InvenTree.forms import HelperForm
from .models import Build from .models import Build
class BuildOutputCreateForm(HelperForm):
"""
Form for creating a new build output.
"""
def __init__(self, *args, **kwargs):
build = kwargs.pop('build', None)
if build:
self.field_placeholder['serial_numbers'] = build.part.getSerialNumberString()
super().__init__(*args, **kwargs)
field_prefix = {
'serial_numbers': 'fa-hashtag',
}
output_quantity = forms.IntegerField(
label=_('Quantity'),
help_text=_('Enter quantity for build output'),
)
serial_numbers = forms.CharField(
label=_('Serial Numbers'),
required=False,
help_text=_('Enter serial numbers for build outputs'),
)
confirm = forms.BooleanField(
required=True,
label=_('Confirm'),
help_text=_('Confirm creation of build output'),
)
class Meta:
model = Build
fields = [
'output_quantity',
'batch',
'serial_numbers',
'confirm',
]
class CancelBuildForm(HelperForm): class CancelBuildForm(HelperForm):
""" Form for cancelling a build """ """ Form for cancelling a build """

View File

@ -11,7 +11,7 @@ def update_tree(apps, schema_editor):
Build.objects.rebuild() Build.objects.rebuild()
def nupdate_tree(apps, schema_editor): def nupdate_tree(apps, schema_editor): # pragma: no cover
pass pass

View File

@ -23,7 +23,7 @@ def add_default_reference(apps, schema_editor):
print(f"\nUpdated build reference for {count} existing BuildOrder objects") print(f"\nUpdated build reference for {count} existing BuildOrder objects")
def reverse_default_reference(apps, schema_editor): def reverse_default_reference(apps, schema_editor): # pragma: no cover
""" """
Do nothing! But we need to have a function here so the whole process is reversible. Do nothing! But we need to have a function here so the whole process is reversible.
""" """

View File

@ -23,7 +23,7 @@ def assign_bom_items(apps, schema_editor):
count_valid = 0 count_valid = 0
count_total = 0 count_total = 0
for build_item in BuildItem.objects.all(): for build_item in BuildItem.objects.all(): # pragma: no cover
# Try to find a BomItem which matches the BuildItem # Try to find a BomItem which matches the BuildItem
# Note: Before this migration, variant stock assignment was not allowed, # Note: Before this migration, variant stock assignment was not allowed,
@ -45,11 +45,11 @@ def assign_bom_items(apps, schema_editor):
except BomItem.DoesNotExist: except BomItem.DoesNotExist:
pass pass
if count_total > 0: if count_total > 0: # pragma: no cover
logger.info(f"Assigned BomItem for {count_valid}/{count_total} entries") logger.info(f"Assigned BomItem for {count_valid}/{count_total} entries")
def unassign_bom_items(apps, schema_editor): def unassign_bom_items(apps, schema_editor): # pragma: no cover
""" """
Reverse migration does not do anything. Reverse migration does not do anything.
Function here to preserve ability to reverse migration Function here to preserve ability to reverse migration

View File

@ -21,13 +21,13 @@ def build_refs(apps, schema_editor):
if result and len(result.groups()) == 1: if result and len(result.groups()) == 1:
try: try:
ref = int(result.groups()[0]) ref = int(result.groups()[0])
except: except: # pragma: no cover
ref = 0 ref = 0
build.reference_int = ref build.reference_int = ref
build.save() build.save()
def unbuild_refs(apps, schema_editor): def unbuild_refs(apps, schema_editor): # pragma: no cover
""" """
Provided only for reverse migration compatibility Provided only for reverse migration compatibility
""" """

View File

@ -646,11 +646,13 @@ class Build(MPTTModel, ReferenceIndexingMixin):
batch: Override batch code batch: Override batch code
serials: Serial numbers serials: Serial numbers
location: Override location location: Override location
auto_allocate: Automatically allocate stock with matching serial numbers
""" """
batch = kwargs.get('batch', self.batch) batch = kwargs.get('batch', self.batch)
location = kwargs.get('location', self.destination) location = kwargs.get('location', self.destination)
serials = kwargs.get('serials', None) serials = kwargs.get('serials', None)
auto_allocate = kwargs.get('auto_allocate', False)
""" """
Determine if we can create a single output (with quantity > 0), Determine if we can create a single output (with quantity > 0),
@ -672,6 +674,9 @@ class Build(MPTTModel, ReferenceIndexingMixin):
Create multiple build outputs with a single quantity of 1 Create multiple build outputs with a single quantity of 1
""" """
# Quantity *must* be an integer at this point!
quantity = int(quantity)
for ii in range(quantity): for ii in range(quantity):
if serials: if serials:
@ -679,7 +684,7 @@ class Build(MPTTModel, ReferenceIndexingMixin):
else: else:
serial = None serial = None
StockModels.StockItem.objects.create( output = StockModels.StockItem.objects.create(
quantity=1, quantity=1,
location=location, location=location,
part=self.part, part=self.part,
@ -689,6 +694,37 @@ class Build(MPTTModel, ReferenceIndexingMixin):
is_building=True, is_building=True,
) )
if auto_allocate and serial is not None:
# Get a list of BomItem objects which point to "trackable" parts
for bom_item in self.part.get_trackable_parts():
parts = bom_item.get_valid_parts_for_allocation()
for part in parts:
items = StockModels.StockItem.objects.filter(
part=part,
serial=str(serial),
quantity=1,
).filter(StockModels.StockItem.IN_STOCK_FILTER)
"""
Test if there is a matching serial number!
"""
if items.exists() and items.count() == 1:
stock_item = items[0]
# Allocate the stock item
BuildItem.objects.create(
build=self,
bom_item=bom_item,
stock_item=stock_item,
quantity=quantity,
install_into=output,
)
else: else:
""" """
Create a single build output of the given quantity Create a single build output of the given quantity

View File

@ -19,6 +19,7 @@ from InvenTree.serializers import InvenTreeModelSerializer, InvenTreeAttachmentS
from InvenTree.serializers import UserSerializerBrief, ReferenceIndexingSerializerMixin from InvenTree.serializers import UserSerializerBrief, ReferenceIndexingSerializerMixin
import InvenTree.helpers import InvenTree.helpers
from InvenTree.helpers import extract_serial_numbers
from InvenTree.serializers import InvenTreeDecimalField from InvenTree.serializers import InvenTreeDecimalField
from InvenTree.status_codes import StockStatus from InvenTree.status_codes import StockStatus
@ -170,6 +171,137 @@ 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 tracakble 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,
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): class BuildOutputDeleteSerializer(serializers.Serializer):
""" """
DRF serializer for deleting (cancelling) one or more build outputs DRF serializer for deleting (cancelling) one or more build outputs

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

@ -321,9 +321,11 @@
{{ block.super }} {{ block.super }}
$('#btn-create-output').click(function() { $('#btn-create-output').click(function() {
launchModalForm('{% url "build-output-create" build.id %}',
createBuildOutput(
{{ build.pk }},
{ {
reload: true, trackable_parts: {% if build.part.has_trackable_parts %}true{% else %}false{% endif%},
} }
); );
}); });

View File

@ -83,9 +83,6 @@ class BuildTest(TestCase):
ref = get_next_build_number() ref = get_next_build_number()
if ref is None:
ref = "0001"
# Create a "Build" object to make 10x objects # Create a "Build" object to make 10x objects
self.build = Build.objects.create( self.build = Build.objects.create(
reference=ref, reference=ref,

View File

@ -9,7 +9,6 @@ from . import views
build_detail_urls = [ build_detail_urls = [
url(r'^cancel/', views.BuildCancel.as_view(), name='build-cancel'), url(r'^cancel/', views.BuildCancel.as_view(), name='build-cancel'),
url(r'^delete/', views.BuildDelete.as_view(), name='build-delete'), url(r'^delete/', views.BuildDelete.as_view(), name='build-delete'),
url(r'^create-output/', views.BuildOutputCreate.as_view(), name='build-output-create'),
url(r'^.*$', views.BuildDetail.as_view(), name='build-detail'), url(r'^.*$', views.BuildDetail.as_view(), name='build-detail'),
] ]

View File

@ -6,16 +6,14 @@ Django views for interacting with Build objects
from __future__ import unicode_literals from __future__ import unicode_literals
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from django.core.exceptions import ValidationError
from django.views.generic import DetailView, ListView from django.views.generic import DetailView, ListView
from django.forms import HiddenInput
from .models import Build from .models import Build
from . import forms from . import forms
from InvenTree.views import AjaxUpdateView, AjaxDeleteView from InvenTree.views import AjaxUpdateView, AjaxDeleteView
from InvenTree.views import InvenTreeRoleMixin from InvenTree.views import InvenTreeRoleMixin
from InvenTree.helpers import str2bool, extract_serial_numbers from InvenTree.helpers import str2bool
from InvenTree.status_codes import BuildStatus from InvenTree.status_codes import BuildStatus
@ -76,121 +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 BuildDetail(InvenTreeRoleMixin, DetailView): class BuildDetail(InvenTreeRoleMixin, DetailView):
""" """
Detail view of a single Build object. Detail view of a single Build object.

View File

@ -19,7 +19,7 @@ def delete_old_notifications():
try: try:
from common.models import NotificationEntry from common.models import NotificationEntry
except AppRegistryNotReady: except AppRegistryNotReady: # pragma: no cover
logger.info("Could not perform 'delete_old_notifications' - App registry not ready") logger.info("Could not perform 'delete_old_notifications' - App registry not ready")
return return

View File

@ -59,15 +59,15 @@ class SettingsTest(TestCase):
name = setting.get('name', None) name = setting.get('name', None)
if name is None: if name is None:
raise ValueError(f'Missing GLOBAL_SETTING name for {key}') raise ValueError(f'Missing GLOBAL_SETTING name for {key}') # pragma: no cover
description = setting.get('description', None) description = setting.get('description', None)
if description is None: if description is None:
raise ValueError(f'Missing GLOBAL_SETTING description for {key}') raise ValueError(f'Missing GLOBAL_SETTING description for {key}') # pragma: no cover
if not key == key.upper(): if not key == key.upper():
raise ValueError(f"SETTINGS key '{key}' is not uppercase") raise ValueError(f"SETTINGS key '{key}' is not uppercase") # pragma: no cover
def test_defaults(self): def test_defaults(self):
""" """
@ -87,10 +87,10 @@ class SettingsTest(TestCase):
if setting.is_bool(): if setting.is_bool():
if setting.default_value in ['', None]: if setting.default_value in ['', None]:
raise ValueError(f'Default value for boolean setting {key} not provided') raise ValueError(f'Default value for boolean setting {key} not provided') # pragma: no cover
if setting.default_value not in [True, False]: if setting.default_value not in [True, False]:
raise ValueError(f'Non-boolean default value specified for {key}') raise ValueError(f'Non-boolean default value specified for {key}') # pragma: no cover
class WebhookMessageTests(TestCase): class WebhookMessageTests(TestCase):

View File

@ -14,11 +14,11 @@ So a simplified version of the migration is implemented.
TESTING = 'test' in sys.argv TESTING = 'test' in sys.argv
def clear(): def clear():
if not TESTING: if not TESTING: # pragma: no cover
os.system('cls' if os.name == 'nt' else 'clear') os.system('cls' if os.name == 'nt' else 'clear')
def reverse_association(apps, schema_editor): def reverse_association(apps, schema_editor): # pragma: no cover
""" """
This is the 'reverse' operation of the manufacturer reversal. This is the 'reverse' operation of the manufacturer reversal.
This operation is easier: This operation is easier:
@ -108,7 +108,7 @@ def associate_manufacturers(apps, schema_editor):
if len(row) > 0: if len(row) > 0:
return row[0] return row[0]
return '' return '' # pragma: no cover
cursor = connection.cursor() cursor = connection.cursor()
@ -139,7 +139,7 @@ def associate_manufacturers(apps, schema_editor):
""" Attempt to link Part to an existing Company """ """ Attempt to link Part to an existing Company """
# Matches a company name directly # Matches a company name directly
if name in companies.keys(): if name in companies.keys(): # pragma: no cover
print(" - Part[{pk}]: '{n}' maps to existing manufacturer".format(pk=part_id, n=name)) print(" - Part[{pk}]: '{n}' maps to existing manufacturer".format(pk=part_id, n=name))
manufacturer_id = companies[name] manufacturer_id = companies[name]
@ -150,7 +150,7 @@ def associate_manufacturers(apps, schema_editor):
return True return True
# Have we already mapped this # Have we already mapped this
if name in links.keys(): if name in links.keys(): # pragma: no cover
print(" - Part[{pk}]: Mapped '{n}' - manufacturer <{c}>".format(pk=part_id, n=name, c=links[name])) print(" - Part[{pk}]: Mapped '{n}' - manufacturer <{c}>".format(pk=part_id, n=name, c=links[name]))
manufacturer_id = links[name] manufacturer_id = links[name]
@ -196,10 +196,10 @@ def associate_manufacturers(apps, schema_editor):
# Case-insensitive matching # Case-insensitive matching
ratio = fuzz.partial_ratio(name.lower(), text.lower()) ratio = fuzz.partial_ratio(name.lower(), text.lower())
if ratio > threshold: if ratio > threshold: # pragma: no cover
matches.append({'name': name, 'match': ratio}) matches.append({'name': name, 'match': ratio})
if len(matches) > 0: if len(matches) > 0: # pragma: no cover
return [match['name'] for match in sorted(matches, key=lambda item: item['match'], reverse=True)] return [match['name'] for match in sorted(matches, key=lambda item: item['match'], reverse=True)]
else: else:
return [] return []
@ -212,12 +212,12 @@ def associate_manufacturers(apps, schema_editor):
name = get_manufacturer_name(part_id) name = get_manufacturer_name(part_id)
# Skip empty names # Skip empty names
if not name or len(name) == 0: if not name or len(name) == 0: # pragma: no cover
print(" - Part[{pk}]: No manufacturer_name provided, skipping".format(pk=part_id)) print(" - Part[{pk}]: No manufacturer_name provided, skipping".format(pk=part_id))
return return
# Can be linked to an existing manufacturer # Can be linked to an existing manufacturer
if link_part(part_id, name): if link_part(part_id, name): # pragma: no cover
return return
# Find a list of potential matches # Find a list of potential matches
@ -226,12 +226,12 @@ def associate_manufacturers(apps, schema_editor):
clear() clear()
# Present a list of options # Present a list of options
if not TESTING: if not TESTING: # pragma: no cover
print("----------------------------------") print("----------------------------------")
print("Checking part [{pk}] ({idx} of {total})".format(pk=part_id, idx=idx+1, total=total)) print("Checking part [{pk}] ({idx} of {total})".format(pk=part_id, idx=idx+1, total=total))
if not TESTING: if not TESTING: # pragma: no cover
print("Manufacturer name: '{n}'".format(n=name)) print("Manufacturer name: '{n}'".format(n=name))
print("----------------------------------") print("----------------------------------")
print("Select an option from the list below:") print("Select an option from the list below:")
@ -249,7 +249,7 @@ def associate_manufacturers(apps, schema_editor):
if TESTING: if TESTING:
# When running unit tests, simply select the name of the part # When running unit tests, simply select the name of the part
response = '0' response = '0'
else: else: # pragma: no cover
response = str(input("> ")).strip() response = str(input("> ")).strip()
# Attempt to parse user response as an integer # Attempt to parse user response as an integer
@ -263,7 +263,7 @@ def associate_manufacturers(apps, schema_editor):
return return
# Options 1) - n) select an existing manufacturer # Options 1) - n) select an existing manufacturer
else: else: # pragma: no cover
n = n - 1 n = n - 1
if n < len(matches): if n < len(matches):
@ -287,7 +287,7 @@ def associate_manufacturers(apps, schema_editor):
else: else:
print("Please select a valid option") print("Please select a valid option")
except ValueError: except ValueError: # pragma: no cover
# User has typed in a custom name! # User has typed in a custom name!
if not response or len(response) == 0: if not response or len(response) == 0:
@ -312,7 +312,7 @@ def associate_manufacturers(apps, schema_editor):
print("") print("")
clear() clear()
if not TESTING: if not TESTING: # pragma: no cover
print("---------------------------------------") print("---------------------------------------")
print("The SupplierPart model needs to be migrated,") print("The SupplierPart model needs to be migrated,")
print("as the new 'manufacturer' field maps to a 'Company' reference.") print("as the new 'manufacturer' field maps to a 'Company' reference.")
@ -339,7 +339,7 @@ def associate_manufacturers(apps, schema_editor):
for index, row in enumerate(results): for index, row in enumerate(results):
pk, MPN, SKU, manufacturer_id, manufacturer_name = row pk, MPN, SKU, manufacturer_id, manufacturer_name = row
if manufacturer_id is not None: if manufacturer_id is not None: # pragma: no cover
print(f" - SupplierPart <{pk}> already has a manufacturer associated (skipping)") print(f" - SupplierPart <{pk}> already has a manufacturer associated (skipping)")
continue continue

View File

@ -1,7 +1,7 @@
from django.db import migrations, models from django.db import migrations, models
def reverse_empty_email(apps, schema_editor): def reverse_empty_email(apps, schema_editor): # pragma: no cover
Company = apps.get_model('company', 'Company') Company = apps.get_model('company', 'Company')
for company in Company.objects.all(): for company in Company.objects.all():
if company.email == None: if company.email == None:

View File

@ -42,7 +42,7 @@ def migrate_currencies(apps, schema_editor):
suffix = suffix.strip().upper() suffix = suffix.strip().upper()
if suffix not in currency_codes: if suffix not in currency_codes: # pragma: no cover
logger.warning(f"Missing suffix: '{suffix}'") logger.warning(f"Missing suffix: '{suffix}'")
while suffix not in currency_codes: while suffix not in currency_codes:
@ -78,7 +78,7 @@ def migrate_currencies(apps, schema_editor):
if count > 0: if count > 0:
logger.info(f"Updated {count} SupplierPriceBreak rows") logger.info(f"Updated {count} SupplierPriceBreak rows")
def reverse_currencies(apps, schema_editor): def reverse_currencies(apps, schema_editor): # pragma: no cover
""" """
Reverse the "update" process. Reverse the "update" process.

View File

@ -15,12 +15,12 @@ def supplierpart_make_manufacturer_parts(apps, schema_editor):
for supplier_part in supplier_parts: for supplier_part in supplier_parts:
print(f'{supplier_part.supplier.name[:15].ljust(15)} | {supplier_part.SKU[:15].ljust(15)}\t', end='') print(f'{supplier_part.supplier.name[:15].ljust(15)} | {supplier_part.SKU[:15].ljust(15)}\t', end='')
if supplier_part.manufacturer_part: if supplier_part.manufacturer_part: # pragma: no cover
print(f'[ERROR: MANUFACTURER PART ALREADY EXISTS]') print(f'[ERROR: MANUFACTURER PART ALREADY EXISTS]')
continue continue
part = supplier_part.part part = supplier_part.part
if not part: if not part: # pragma: no cover
print(f'[ERROR: SUPPLIER PART IS NOT CONNECTED TO PART]') print(f'[ERROR: SUPPLIER PART IS NOT CONNECTED TO PART]')
continue continue
@ -67,7 +67,7 @@ def supplierpart_make_manufacturer_parts(apps, schema_editor):
print(f'{"-"*10}\nDone\n') print(f'{"-"*10}\nDone\n')
def supplierpart_populate_manufacturer_info(apps, schema_editor): def supplierpart_populate_manufacturer_info(apps, schema_editor): # pragma: no cover
Part = apps.get_model('part', 'Part') Part = apps.get_model('part', 'Part')
ManufacturerPart = apps.get_model('company', 'ManufacturerPart') ManufacturerPart = apps.get_model('company', 'ManufacturerPart')
SupplierPart = apps.get_model('company', 'SupplierPart') SupplierPart = apps.get_model('company', 'SupplierPart')

View File

@ -3,8 +3,6 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from __future__ import unicode_literals from __future__ import unicode_literals
import json
from django.test import TestCase from django.test import TestCase
from django.urls import reverse from django.urls import reverse
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
@ -49,28 +47,6 @@ class CompanyViewTestBase(TestCase):
self.client.login(username='username', password='password') self.client.login(username='username', password='password')
def post(self, url, data, valid=None):
"""
POST against this form and return the response (as a JSON object)
"""
response = self.client.post(url, data, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
self.assertEqual(response.status_code, 200)
json_data = json.loads(response.content)
# If a particular status code is required
if valid is not None:
if valid:
self.assertEqual(json_data['form_valid'], True)
else:
self.assertEqual(json_data['form_valid'], False)
form_errors = json.loads(json_data['form_errors'])
return json_data, form_errors
class CompanyViewTest(CompanyViewTestBase): class CompanyViewTest(CompanyViewTestBase):
""" """

View File

@ -5,6 +5,7 @@ import hashlib
from django.apps import AppConfig from django.apps import AppConfig
from django.conf import settings from django.conf import settings
from django.core.exceptions import AppRegistryNotReady
from InvenTree.ready import canAppAccessDatabase from InvenTree.ready import canAppAccessDatabase
@ -35,6 +36,12 @@ class LabelConfig(AppConfig):
""" """
if canAppAccessDatabase(): if canAppAccessDatabase():
self.create_labels() # pragma: no cover
def create_labels(self):
"""
Create all default templates
"""
self.create_stock_item_labels() self.create_stock_item_labels()
self.create_stock_location_labels() self.create_stock_location_labels()
self.create_part_labels() self.create_part_labels()
@ -47,7 +54,7 @@ class LabelConfig(AppConfig):
try: try:
from .models import StockItemLabel from .models import StockItemLabel
except: except AppRegistryNotReady: # pragma: no cover
# Database might not by ready yet # Database might not by ready yet
return return
@ -98,7 +105,7 @@ class LabelConfig(AppConfig):
# File already exists - let's see if it is the "same", # File already exists - let's see if it is the "same",
# or if we need to overwrite it with a newer copy! # or if we need to overwrite it with a newer copy!
if not hashFile(dst_file) == hashFile(src_file): if not hashFile(dst_file) == hashFile(src_file): # pragma: no cover
logger.info(f"Hash differs for '{filename}'") logger.info(f"Hash differs for '{filename}'")
to_copy = True to_copy = True
@ -112,7 +119,7 @@ class LabelConfig(AppConfig):
# Check if a label matching the template already exists # Check if a label matching the template already exists
if StockItemLabel.objects.filter(label=filename).exists(): if StockItemLabel.objects.filter(label=filename).exists():
continue continue # pragma: no cover
logger.info(f"Creating entry for StockItemLabel '{label['name']}'") logger.info(f"Creating entry for StockItemLabel '{label['name']}'")
@ -134,7 +141,7 @@ class LabelConfig(AppConfig):
try: try:
from .models import StockLocationLabel from .models import StockLocationLabel
except: except AppRegistryNotReady: # pragma: no cover
# Database might not yet be ready # Database might not yet be ready
return return
@ -192,7 +199,7 @@ class LabelConfig(AppConfig):
# File already exists - let's see if it is the "same", # File already exists - let's see if it is the "same",
# or if we need to overwrite it with a newer copy! # or if we need to overwrite it with a newer copy!
if not hashFile(dst_file) == hashFile(src_file): if not hashFile(dst_file) == hashFile(src_file): # pragma: no cover
logger.info(f"Hash differs for '{filename}'") logger.info(f"Hash differs for '{filename}'")
to_copy = True to_copy = True
@ -206,7 +213,7 @@ class LabelConfig(AppConfig):
# Check if a label matching the template already exists # Check if a label matching the template already exists
if StockLocationLabel.objects.filter(label=filename).exists(): if StockLocationLabel.objects.filter(label=filename).exists():
continue continue # pragma: no cover
logger.info(f"Creating entry for StockLocationLabel '{label['name']}'") logger.info(f"Creating entry for StockLocationLabel '{label['name']}'")
@ -228,7 +235,7 @@ class LabelConfig(AppConfig):
try: try:
from .models import PartLabel from .models import PartLabel
except: except AppRegistryNotReady: # pragma: no cover
# Database might not yet be ready # Database might not yet be ready
return return
@ -277,7 +284,7 @@ class LabelConfig(AppConfig):
if os.path.exists(dst_file): if os.path.exists(dst_file):
# File already exists - let's see if it is the "same" # File already exists - let's see if it is the "same"
if not hashFile(dst_file) == hashFile(src_file): if not hashFile(dst_file) == hashFile(src_file): # pragma: no cover
logger.info(f"Hash differs for '{filename}'") logger.info(f"Hash differs for '{filename}'")
to_copy = True to_copy = True
@ -291,7 +298,7 @@ class LabelConfig(AppConfig):
# Check if a label matching the template already exists # Check if a label matching the template already exists
if PartLabel.objects.filter(label=filename).exists(): if PartLabel.objects.filter(label=filename).exists():
continue continue # pragma: no cover
logger.info(f"Creating entry for PartLabel '{label['name']}'") logger.info(f"Creating entry for PartLabel '{label['name']}'")

View File

@ -30,7 +30,7 @@ import part.models
try: try:
from django_weasyprint import WeasyTemplateResponseMixin from django_weasyprint import WeasyTemplateResponseMixin
except OSError as err: except OSError as err: # pragma: no cover
print("OSError: {e}".format(e=err)) print("OSError: {e}".format(e=err))
print("You may require some further system packages to be installed.") print("You may require some further system packages to be installed.")
sys.exit(1) sys.exit(1)

View File

@ -66,3 +66,39 @@ class TestReportTests(InvenTreeAPITestCase):
'items': [10, 11, 12], 'items': [10, 11, 12],
} }
) )
class TestLabels(InvenTreeAPITestCase):
"""
Tests for the label APIs
"""
fixtures = [
'category',
'part',
'location',
'stock',
]
roles = [
'stock.view',
'stock_location.view',
]
def do_list(self, filters={}):
response = self.client.get(self.list_url, filters, format='json')
self.assertEqual(response.status_code, 200)
return response.data
def test_lists(self):
self.list_url = reverse('api-stockitem-label-list')
self.do_list()
self.list_url = reverse('api-stocklocation-label-list')
self.do_list()
self.list_url = reverse('api-part-label-list')
self.do_list()

View File

@ -7,6 +7,7 @@ import os
from django.test import TestCase from django.test import TestCase
from django.conf import settings from django.conf import settings
from django.apps import apps
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from InvenTree.helpers import validateFilterString from InvenTree.helpers import validateFilterString
@ -17,8 +18,11 @@ from stock.models import StockItem
class LabelTest(TestCase): class LabelTest(TestCase):
# TODO - Implement this test properly. Looks like apps.py is not run first def setUp(self) -> None:
def _test_default_labels(self): # ensure the labels were created
apps.get_app_config('label').create_labels()
def test_default_labels(self):
""" """
Test that the default label templates are copied across Test that the default label templates are copied across
""" """
@ -31,8 +35,7 @@ class LabelTest(TestCase):
self.assertTrue(labels.count() > 0) self.assertTrue(labels.count() > 0)
# TODO - Implement this test properly. Looks like apps.py is not run first def test_default_files(self):
def _test_default_files(self):
""" """
Test that label files exist in the MEDIA directory Test that label files exist in the MEDIA directory
""" """

View File

@ -6,12 +6,12 @@ if __name__ == "__main__":
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "InvenTree.settings") os.environ.setdefault("DJANGO_SETTINGS_MODULE", "InvenTree.settings")
try: try:
from django.core.management import execute_from_command_line from django.core.management import execute_from_command_line
except ImportError: except ImportError: # pragma: no cover
# The above import may fail for some other reason. Ensure that the # The above import may fail for some other reason. Ensure that the
# issue is really that Django is missing to avoid masking other # issue is really that Django is missing to avoid masking other
# exceptions on Python 2. # exceptions on Python 2.
try: try:
import django # NOQA import django # noqa: F401
except ImportError: except ImportError:
raise ImportError( raise ImportError(
"Couldn't import Django. Are you sure it's installed and " "Couldn't import Django. Are you sure it's installed and "

View File

@ -20,7 +20,7 @@ def build_refs(apps, schema_editor):
if result and len(result.groups()) == 1: if result and len(result.groups()) == 1:
try: try:
ref = int(result.groups()[0]) ref = int(result.groups()[0])
except: except: # pragma: no cover
ref = 0 ref = 0
order.reference_int = ref order.reference_int = ref
@ -37,14 +37,14 @@ def build_refs(apps, schema_editor):
if result and len(result.groups()) == 1: if result and len(result.groups()) == 1:
try: try:
ref = int(result.groups()[0]) ref = int(result.groups()[0])
except: except: # pragma: no cover
ref = 0 ref = 0
order.reference_int = ref order.reference_int = ref
order.save() order.save()
def unbuild_refs(apps, schema_editor): def unbuild_refs(apps, schema_editor): # pragma: no cover
""" """
Provided only for reverse migration compatibility Provided only for reverse migration compatibility
""" """

View File

@ -33,7 +33,7 @@ def add_shipment(apps, schema_editor):
line__order=order line__order=order
) )
if allocations.count() == 0 and order.status != SalesOrderStatus.PENDING: if allocations.count() == 0 and order.status != SalesOrderStatus.PENDING: # pragma: no cover
continue continue
# Create a new Shipment instance against this order # Create a new Shipment instance against this order
@ -41,13 +41,13 @@ def add_shipment(apps, schema_editor):
order=order, order=order,
) )
if order.status == SalesOrderStatus.SHIPPED: if order.status == SalesOrderStatus.SHIPPED: # pragma: no cover
shipment.shipment_date = order.shipment_date shipment.shipment_date = order.shipment_date
shipment.save() shipment.save()
# Iterate through each allocation associated with this order # Iterate through each allocation associated with this order
for allocation in allocations: for allocation in allocations: # pragma: no cover
allocation.shipment = shipment allocation.shipment = shipment
allocation.save() allocation.save()
@ -57,7 +57,7 @@ def add_shipment(apps, schema_editor):
print(f"\nCreated SalesOrderShipment for {n} SalesOrder instances") print(f"\nCreated SalesOrderShipment for {n} SalesOrder instances")
def reverse_add_shipment(apps, schema_editor): def reverse_add_shipment(apps, schema_editor): # pragma: no cover
""" """
Reverse the migration, delete and SalesOrderShipment instances Reverse the migration, delete and SalesOrderShipment instances
""" """

View File

@ -22,7 +22,7 @@ def calculate_shipped_quantity(apps, schema_editor):
StockItem = apps.get_model('stock', 'stockitem') StockItem = apps.get_model('stock', 'stockitem')
SalesOrderLineItem = apps.get_model('order', 'salesorderlineitem') SalesOrderLineItem = apps.get_model('order', 'salesorderlineitem')
for item in SalesOrderLineItem.objects.all(): for item in SalesOrderLineItem.objects.all(): # pragma: no cover
if item.order.status == SalesOrderStatus.SHIPPED: if item.order.status == SalesOrderStatus.SHIPPED:
item.shipped = item.quantity item.shipped = item.quantity
@ -40,7 +40,7 @@ def calculate_shipped_quantity(apps, schema_editor):
item.save() item.save()
def reverse_calculate_shipped_quantity(apps, schema_editor): def reverse_calculate_shipped_quantity(apps, schema_editor): # pragma: no cover
""" """
Provided only for reverse migration compatibility. Provided only for reverse migration compatibility.
This function does nothing. This function does nothing.

View File

@ -1,99 +1,2 @@
{% extends "order/order_wizard/po_upload.html" %} {% extends "order/order_wizard/po_upload.html" %}
{% load inventree_extras %} {% include "patterns/wizard/match_fields.html" %}
{% load i18n %}
{% load static %}
{% block form_alert %}
{% if missing_columns and missing_columns|length > 0 %}
<div class='alert alert-danger alert-block' role='alert'>
{% trans "Missing selections for the following required columns" %}:
<br>
<ul>
{% for col in missing_columns %}
<li>{{ col }}</li>
{% endfor %}
</ul>
</div>
{% endif %}
{% if duplicates and duplicates|length > 0 %}
<div class='alert alert-danger alert-block' role='alert'>
{% trans "Duplicate selections found, see below. Fix them then retry submitting." %}
</div>
{% endif %}
{% endblock form_alert %}
{% block form_buttons_top %}
{% if wizard.steps.prev %}
<button name="wizard_goto_step" type="submit" value="{{ wizard.steps.prev }}" class="save btn btn-outline-secondary">{% trans "Previous Step" %}</button>
{% endif %}
<button type="submit" class="save btn btn-outline-secondary">{% trans "Submit Selections" %}</button>
{% endblock form_buttons_top %}
{% block form_content %}
<thead>
<tr>
<th>{% trans "File Fields" %}</th>
<th></th>
{% for col in form %}
<th>
<div>
<input type='hidden' name='col_name_{{ forloop.counter0 }}' value='{{ col.name }}'/>
{{ col.name }}
<button class='btn btn-outline-secondary btn-remove' onClick='removeColFromBomWizard()' id='del_col_{{ forloop.counter0 }}' style='display: inline; float: right;' title='{% trans "Remove column" %}'>
<span col_id='{{ forloop.counter0 }}' class='fas fa-trash-alt icon-red'></span>
</button>
</div>
</th>
{% endfor %}
</tr>
</thead>
<tbody>
<tr>
<td>{% trans "Match Fields" %}</td>
<td></td>
{% for col in form %}
<td>
{{ col }}
{% for duplicate in duplicates %}
{% if duplicate == col.value %}
<div class='alert alert-danger alert-block text-center' role='alert' style='padding:2px; margin-top:6px; margin-bottom:2px'>
<strong>{% trans "Duplicate selection" %}</strong>
</div>
{% endif %}
{% endfor %}
</td>
{% endfor %}
</tr>
{% for row in rows %}
{% with forloop.counter as row_index %}
<tr>
<td style='width: 32px;'>
<button class='btn btn-outline-secondary btn-remove' onClick='removeRowFromBomWizard()' id='del_row_{{ row_index }}' style='display: inline; float: left;' title='{% trans "Remove row" %}'>
<span row_id='{{ row_index }}' class='fas fa-trash-alt icon-red'></span>
</button>
</td>
<td style='text-align: left;'>{{ row_index }}</td>
{% for item in row.data %}
<td>
<input type='hidden' name='row_{{ row_index }}_col_{{ forloop.counter0 }}' value='{{ item }}'/>
{{ item }}
</td>
{% endfor %}
</tr>
{% endwith %}
{% endfor %}
</tbody>
{% endblock form_content %}
{% block form_buttons_bottom %}
{% endblock form_buttons_bottom %}
{% block js_ready %}
{{ block.super }}
$('.fieldselect').select2({
width: '100%',
matcher: partialMatcher,
});
{% endblock %}

View File

@ -10,54 +10,11 @@
{% endblock %} {% endblock %}
{% block page_content %} {% block page_content %}
{% trans "Upload File for Purchase Order" as header_text %}
<div class='panel' id='panel-upload-file'> {% order.status == PurchaseOrderStatus.PENDING and roles.purchase_order.change as upload_go_ahead %}
<div class='panel-heading'> {% trans "Order is already processed. Files cannot be uploaded." as error_text %}
{% block heading %} {% "panel-upload-file" as panel_id %}
<h4>{% trans "Upload File for Purchase Order" %}</h4> {% include "patterns/wizard/upload.html" with header_text=header_text upload_go_ahead=upload_go_ahead error_text=error_text panel_id=panel_id %}
{{ wizard.form.media }}
{% endblock %}
</div>
<div class='panel-content'>
{% block details %}
{% if order.status == PurchaseOrderStatus.PENDING and roles.purchase_order.change %}
<p>{% blocktrans with step=wizard.steps.step1 count=wizard.steps.count %}Step {{step}} of {{count}}{% endblocktrans %}
{% if description %}- {{ description }}{% endif %}</p>
{% block form_alert %}
{% endblock form_alert %}
<form action="" method="post" class='js-modal-form' enctype="multipart/form-data">
{% csrf_token %}
{% load crispy_forms_tags %}
{% block form_buttons_top %}
{% endblock form_buttons_top %}
<table class='table table-striped' style='margin-top: 12px; margin-bottom: 0px'>
{{ wizard.management_form }}
{% block form_content %}
{% crispy wizard.form %}
{% endblock form_content %}
</table>
{% block form_buttons_bottom %}
{% if wizard.steps.prev %}
<button name="wizard_goto_step" type="submit" value="{{ wizard.steps.prev }}" class="save btn btn-outline-secondary">{% trans "Previous Step" %}</button>
{% endif %}
<button type="submit" class="save btn btn-outline-secondary">{% trans "Upload File" %}</button>
</form>
{% endblock form_buttons_bottom %}
{% else %}
<div class='alert alert-danger alert-block' role='alert'>
{% trans "Order is already processed. Files cannot be uploaded." %}
</div>
{% endif %}
{% endblock details %}
</div>
{% endblock %} {% endblock %}
{% block js_ready %} {% block js_ready %}

View File

@ -40,6 +40,6 @@ class PartConfig(AppConfig):
item.part.trackable = True item.part.trackable = True
item.part.clean() item.part.clean()
item.part.save() item.part.save()
except (OperationalError, ProgrammingError): except (OperationalError, ProgrammingError): # pragma: no cover
# Exception if the database has not been migrated yet # Exception if the database has not been migrated yet
pass pass

View File

@ -11,7 +11,7 @@ import InvenTree.validators
import part.models import part.models
def attach_file(instance, filename): def attach_file(instance, filename): # pragma: no cover
""" """
Generate a filename for the uploaded attachment. Generate a filename for the uploaded attachment.

View File

@ -10,7 +10,7 @@ def update_tree(apps, schema_editor):
Part.objects.rebuild() Part.objects.rebuild()
def nupdate_tree(apps, schema_editor): def nupdate_tree(apps, schema_editor): # pragma: no cover
pass pass

View File

@ -33,7 +33,7 @@ def migrate_currencies(apps, schema_editor):
remap = {} remap = {}
for index, row in enumerate(results): for index, row in enumerate(results): # pragma: no cover
pk, suffix, description = row pk, suffix, description = row
suffix = suffix.strip().upper() suffix = suffix.strip().upper()
@ -57,7 +57,7 @@ def migrate_currencies(apps, schema_editor):
count = 0 count = 0
for index, row in enumerate(results): for index, row in enumerate(results): # pragma: no cover
pk, cost, currency_id, price, price_currency = row pk, cost, currency_id, price, price_currency = row
# Copy the 'cost' field across to the 'price' field # Copy the 'cost' field across to the 'price' field
@ -71,10 +71,10 @@ def migrate_currencies(apps, schema_editor):
count += 1 count += 1
if count > 0: if count > 0: # pragma: no cover
print(f"Updated {count} SupplierPriceBreak rows") print(f"Updated {count} SupplierPriceBreak rows")
def reverse_currencies(apps, schema_editor): def reverse_currencies(apps, schema_editor): # pragma: no cover
""" """
Reverse the "update" process. Reverse the "update" process.

View File

@ -1498,6 +1498,16 @@ class Part(MPTTModel):
def has_bom(self): def has_bom(self):
return self.get_bom_items().count() > 0 return self.get_bom_items().count() > 0
def get_trackable_parts(self):
"""
Return a queryset of all trackable parts in the BOM for this part
"""
queryset = self.get_bom_items()
queryset = queryset.filter(sub_part__trackable=True)
return queryset
@property @property
def has_trackable_parts(self): def has_trackable_parts(self):
""" """
@ -1505,11 +1515,7 @@ class Part(MPTTModel):
This is important when building the part. This is important when building the part.
""" """
for bom_item in self.get_bom_items().all(): return self.get_trackable_parts().count() > 0
if bom_item.sub_part.trackable:
return True
return False
@property @property
def bom_count(self): def bom_count(self):

View File

@ -960,6 +960,9 @@ class BomExtractSerializer(serializers.Serializer):
""" """
quantity = self.find_matching_data(row, 'quantity', self.dataset.headers) quantity = self.find_matching_data(row, 'quantity', self.dataset.headers)
# Ensure quantity field is provided
row['quantity'] = quantity
if quantity is None: if quantity is None:
row_error['quantity'] = _('Quantity not provided') row_error['quantity'] = _('Quantity not provided')
else: else:

View File

@ -1,99 +1,2 @@
{% extends "part/import_wizard/part_upload.html" %} {% extends "part/import_wizard/part_upload.html" %}
{% load inventree_extras %} {% include "patterns/wizard/match_fields.html" %}
{% load i18n %}
{% load static %}
{% block form_alert %}
{% if missing_columns and missing_columns|length > 0 %}
<div class='alert alert-danger alert-block' role='alert'>
{% trans "Missing selections for the following required columns" %}:
<br>
<ul>
{% for col in missing_columns %}
<li>{{ col }}</li>
{% endfor %}
</ul>
</div>
{% endif %}
{% if duplicates and duplicates|length > 0 %}
<div class='alert alert-danger alert-block' role='alert'>
{% trans "Duplicate selections found, see below. Fix them then retry submitting." %}
</div>
{% endif %}
{% endblock form_alert %}
{% block form_buttons_top %}
{% if wizard.steps.prev %}
<button name="wizard_goto_step" type="submit" value="{{ wizard.steps.prev }}" class="save btn btn-outline-secondary">{% trans "Previous Step" %}</button>
{% endif %}
<button type="submit" class="save btn btn-outline-secondary">{% trans "Submit Selections" %}</button>
{% endblock form_buttons_top %}
{% block form_content %}
<thead>
<tr>
<th>{% trans "File Fields" %}</th>
<th></th>
{% for col in form %}
<th>
<div>
<input type='hidden' name='col_name_{{ forloop.counter0 }}' value='{{ col.name }}'/>
{{ col.name }}
<button class='btn btn-outline-secondary btn-remove' onClick='removeColFromBomWizard()' id='del_col_{{ forloop.counter0 }}' style='display: inline; float: right;' title='{% trans "Remove column" %}'>
<span col_id='{{ forloop.counter0 }}' class='fas fa-trash-alt icon-red'></span>
</button>
</div>
</th>
{% endfor %}
</tr>
</thead>
<tbody>
<tr>
<td>{% trans "Match Fields" %}</td>
<td></td>
{% for col in form %}
<td>
{{ col }}
{% for duplicate in duplicates %}
{% if duplicate == col.value %}
<div class='alert alert-danger alert-block text-center' role='alert' style='padding:2px; margin-top:6px; margin-bottom:2px'>
<strong>{% trans "Duplicate selection" %}</strong>
</div>
{% endif %}
{% endfor %}
</td>
{% endfor %}
</tr>
{% for row in rows %}
{% with forloop.counter as row_index %}
<tr>
<td style='width: 32px;'>
<button class='btn btn-outline-secondary btn-remove' onClick='removeRowFromBomWizard()' id='del_row_{{ row_index }}' style='display: inline; float: left;' title='{% trans "Remove row" %}'>
<span row_id='{{ row_index }}' class='fas fa-trash-alt icon-red'></span>
</button>
</td>
<td style='text-align: left;'>{{ row_index }}</td>
{% for item in row.data %}
<td>
<input type='hidden' name='row_{{ row_index }}_col_{{ forloop.counter0 }}' value='{{ item }}'/>
{{ item }}
</td>
{% endfor %}
</tr>
{% endwith %}
{% endfor %}
</tbody>
{% endblock form_content %}
{% block form_buttons_bottom %}
{% endblock form_buttons_bottom %}
{% block js_ready %}
{{ block.super }}
$('.fieldselect').select2({
width: '100%',
matcher: partialMatcher,
});
{% endblock %}

View File

@ -10,51 +10,10 @@
{% endblock %} {% endblock %}
{% block content %} {% block content %}
<div class='panel'> {% trans "Import Parts from File" as header_text %}
<div class='panel-heading'> {% roles.part.change as upload_go_ahead %}
<h4> {% trans "Unsuffitient privileges." as error_text %}
{% trans "Import Parts from File" %} {% include "patterns/wizard/upload.html" with header_text=header_text upload_go_ahead=upload_go_ahead error_text=error_text %}
{{ wizard.form.media }}
</h4>
</div>
<div class='panel-content'>
{% if roles.part.change %}
<p>{% blocktrans with step=wizard.steps.step1 count=wizard.steps.count %}Step {{step}} of {{count}}{% endblocktrans %}
{% if description %}- {{ description }}{% endif %}</p>
{% block form_alert %}
{% endblock form_alert %}
<form action="" method="post" class='js-modal-form' enctype="multipart/form-data">
{% csrf_token %}
{% load crispy_forms_tags %}
{% block form_buttons_top %}
{% endblock form_buttons_top %}
<table class='table table-striped' style='margin-top: 12px; margin-bottom: 0px'>
{{ wizard.management_form }}
{% block form_content %}
{% crispy wizard.form %}
{% endblock form_content %}
</table>
{% block form_buttons_bottom %}
{% if wizard.steps.prev %}
<button name="wizard_goto_step" type="submit" value="{{ wizard.steps.prev }}" class="save btn btn-outline-secondary">{% trans "Previous Step" %}</button>
{% endif %}
<button type="submit" class="save btn btn-outline-secondary">{% trans "Upload File" %}</button>
</form>
{% endblock form_buttons_bottom %}
{% else %}
<div class='alert alert-danger alert-block' role='alert'>
{% trans "Unsuffitient privileges." %}
</div>
{% endif %}
</div>
</div>
{% endblock %} {% endblock %}
{% block js_ready %} {% block js_ready %}

View File

@ -14,7 +14,7 @@
{% endblock %} {% endblock %}
{% block breadcrumbs %} {% block breadcrumbs %}
<a href='#' id='breadcrumb-tree-toggle' class="breadcrumb-item"><i class="fas fa-bars"></i></a> <a href='#' id='breadcrumb-tree-toggle' class="breadcrumb-item"><span class="fas fa-bars"></span></a>
{% if part %} {% if part %}
{% include "part/cat_link.html" with category=part.category part=part %} {% include "part/cat_link.html" with category=part.category part=part %}
{% else %} {% else %}

View File

@ -89,8 +89,11 @@ $('#bom-upload').click(function() {
}, },
title: '{% trans "Upload BOM File" %}', title: '{% trans "Upload BOM File" %}',
onSuccess: function(response) { onSuccess: function(response) {
$('#bom-upload').hide();
// Clear existing entries from the table
$('.bom-import-row').remove();
// Disable the "submit" button
$('#bom-submit').show(); $('#bom-submit').show();
constructBomUploadTable(response); constructBomUploadTable(response);

View File

@ -855,7 +855,7 @@ class PartAPIAggregationTest(InvenTreeAPITestCase):
return part return part
# We should never get here! # We should never get here!
self.assertTrue(False) self.assertTrue(False) # pragma: no cover
def test_stock_quantity(self): def test_stock_quantity(self):
""" """

View File

@ -55,7 +55,7 @@ class BomItemTest(TestCase):
with self.assertRaises(django_exceptions.ValidationError): with self.assertRaises(django_exceptions.ValidationError):
# A validation error should be raised here # A validation error should be raised here
item = BomItem.objects.create(part=self.bob, sub_part=self.bob, quantity=7) item = BomItem.objects.create(part=self.bob, sub_part=self.bob, quantity=7)
item.clean() item.clean() # pragma: no cover
def test_integer_quantity(self): def test_integer_quantity(self):
""" """

View File

@ -127,7 +127,7 @@ class CategoryTest(TestCase):
with self.assertRaises(ValidationError) as err: with self.assertRaises(ValidationError) as err:
cat.full_clean() cat.full_clean()
cat.save() cat.save() # pragma: no cover
self.assertIn('Illegal character in name', str(err.exception.error_dict.get('name'))) self.assertIn('Illegal character in name', str(err.exception.error_dict.get('name')))
@ -160,10 +160,6 @@ class CategoryTest(TestCase):
self.assertEqual(str(self.fasteners.default_location), 'Office/Drawer_1 - In my desk') self.assertEqual(str(self.fasteners.default_location), 'Office/Drawer_1 - In my desk')
# Test that parts in this location return the same default location, too
for p in self.fasteners.children.all():
self.assert_equal(p.get_default_location().pathstring, 'Office/Drawer_1')
# Any part under electronics should default to 'Home' # Any part under electronics should default to 'Home'
r1 = Part.objects.get(name='R_2K2_0805') r1 = Part.objects.get(name='R_2K2_0805')
self.assertIsNone(r1.default_location) self.assertIsNone(r1.default_location)

View File

@ -44,7 +44,7 @@ class TestParams(TestCase):
with self.assertRaises(django_exceptions.ValidationError): with self.assertRaises(django_exceptions.ValidationError):
t3 = PartParameterTemplate(name='aBcde', units='dd') t3 = PartParameterTemplate(name='aBcde', units='dd')
t3.full_clean() t3.full_clean()
t3.save() t3.save() # pragma: no cover
class TestCategoryTemplates(TransactionTestCase): class TestCategoryTemplates(TransactionTestCase):

View File

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

View File

@ -21,7 +21,7 @@ class PluginAppConfig(AppConfig):
def ready(self): def ready(self):
if settings.PLUGINS_ENABLED: if settings.PLUGINS_ENABLED:
if isImportingData(): if isImportingData(): # pragma: no cover
logger.info('Skipping plugin loading for data import') logger.info('Skipping plugin loading for data import')
else: else:
logger.info('Loading InvenTree plugins') logger.info('Loading InvenTree plugins')

View File

@ -51,7 +51,7 @@ class SettingsMixin:
try: try:
plugin, _ = PluginConfig.objects.get_or_create(key=self.plugin_slug(), name=self.plugin_name()) plugin, _ = PluginConfig.objects.get_or_create(key=self.plugin_slug(), name=self.plugin_name())
except (OperationalError, ProgrammingError): except (OperationalError, ProgrammingError): # pragma: no cover
plugin = None plugin = None
if not plugin: if not plugin:

View File

@ -23,7 +23,7 @@ class IntegrationPluginError(Exception):
self.message = message self.message = message
def __str__(self): def __str__(self):
return self.message return self.message # pragma: no cover
class MixinImplementationError(ValueError): class MixinImplementationError(ValueError):
@ -55,7 +55,7 @@ def log_error(error, reference: str = 'general'):
registry.errors[reference].append(error) registry.errors[reference].append(error)
def handle_error(error, do_raise: bool = True, do_log: bool = True, do_return: bool = False, log_name: str = ''): def handle_error(error, do_raise: bool = True, do_log: bool = True, log_name: str = ''):
""" """
Handles an error and casts it as an IntegrationPluginError Handles an error and casts it as an IntegrationPluginError
""" """
@ -69,7 +69,7 @@ def handle_error(error, do_raise: bool = True, do_log: bool = True, do_return: b
path_parts = [*path_obj.parts] path_parts = [*path_obj.parts]
path_parts[-1] = path_parts[-1].replace(path_obj.suffix, '') # remove suffix path_parts[-1] = path_parts[-1].replace(path_obj.suffix, '') # remove suffix
# remove path preixes # remove path prefixes
if path_parts[0] == 'plugin': if path_parts[0] == 'plugin':
path_parts.remove('plugin') path_parts.remove('plugin')
path_parts.pop(0) path_parts.pop(0)
@ -84,13 +84,8 @@ def handle_error(error, do_raise: bool = True, do_log: bool = True, do_return: b
log_kwargs['reference'] = log_name log_kwargs['reference'] = log_name
log_error({package_name: str(error)}, **log_kwargs) log_error({package_name: str(error)}, **log_kwargs)
new_error = IntegrationPluginError(package_name, str(error))
if do_raise: if do_raise:
raise IntegrationPluginError(package_name, str(error)) raise IntegrationPluginError(package_name, str(error))
if do_return:
return new_error
# endregion # endregion
@ -101,14 +96,16 @@ def get_git_log(path):
""" """
path = path.replace(os.path.dirname(settings.BASE_DIR), '')[1:] path = path.replace(os.path.dirname(settings.BASE_DIR), '')[1:]
command = ['git', 'log', '-n', '1', "--pretty=format:'%H%n%aN%n%aE%n%aI%n%f%n%G?%n%GK'", '--follow', '--', path] command = ['git', 'log', '-n', '1', "--pretty=format:'%H%n%aN%n%aE%n%aI%n%f%n%G?%n%GK'", '--follow', '--', path]
output = None
try: try:
output = str(subprocess.check_output(command, cwd=os.path.dirname(settings.BASE_DIR)), 'utf-8')[1:-1] output = str(subprocess.check_output(command, cwd=os.path.dirname(settings.BASE_DIR)), 'utf-8')[1:-1]
if output: if output:
output = output.split('\n') output = output.split('\n')
else: except subprocess.CalledProcessError: # pragma: no cover
output = 7 * [''] pass
except subprocess.CalledProcessError:
output = 7 * [''] if not output:
output = 7 * [''] # pragma: no cover
return {'hash': output[0], 'author': output[1], 'mail': output[2], 'date': output[3], 'message': output[4], 'verified': output[5], 'key': output[6]} return {'hash': output[0], 'author': output[1], 'mail': output[2], 'date': output[3], 'message': output[4], 'verified': output[5], 'key': output[6]}
@ -153,7 +150,7 @@ def get_modules(pkg):
if not k.startswith('_') and (pkg_names is None or k in pkg_names): if not k.startswith('_') and (pkg_names is None or k in pkg_names):
context[k] = v context[k] = v
context[name] = module context[name] = module
except AppRegistryNotReady: except AppRegistryNotReady: # pragma: no cover
pass pass
except Exception as error: except Exception as error:
# this 'protects' against malformed plugin modules by more or less silently failing # this 'protects' against malformed plugin modules by more or less silently failing

View File

@ -135,7 +135,7 @@ class IntegrationPluginBase(MixinBase, plugin_base.InvenTreePluginBase):
if not author: if not author:
author = self.package.get('author') author = self.package.get('author')
if not author: if not author:
author = _('No author found') author = _('No author found') # pragma: no cover
return author return author
@property @property
@ -149,7 +149,7 @@ class IntegrationPluginBase(MixinBase, plugin_base.InvenTreePluginBase):
else: else:
pub_date = datetime.fromisoformat(str(pub_date)) pub_date = datetime.fromisoformat(str(pub_date))
if not pub_date: if not pub_date:
pub_date = _('No date found') pub_date = _('No date found') # pragma: no cover
return pub_date return pub_date
@property @property
@ -226,7 +226,7 @@ class IntegrationPluginBase(MixinBase, plugin_base.InvenTreePluginBase):
""" """
Get package metadata for plugin Get package metadata for plugin
""" """
return {} return {} # pragma: no cover # TODO add usage for package metadata
def define_package(self): def define_package(self):
""" """
@ -241,11 +241,11 @@ class IntegrationPluginBase(MixinBase, plugin_base.InvenTreePluginBase):
# process sign state # process sign state
sign_state = getattr(GitStatus, str(package.get('verified')), GitStatus.N) sign_state = getattr(GitStatus, str(package.get('verified')), GitStatus.N)
if sign_state.status == 0: if sign_state.status == 0:
self.sign_color = 'success' self.sign_color = 'success' # pragma: no cover
elif sign_state.status == 1: elif sign_state.status == 1:
self.sign_color = 'warning' self.sign_color = 'warning'
else: else:
self.sign_color = 'danger' self.sign_color = 'danger' # pragma: no cover
# set variables # set variables
self.package = package self.package = package

View File

@ -102,7 +102,7 @@ class PluginsRegistry:
self._init_plugins(blocked_plugin) self._init_plugins(blocked_plugin)
self._activate_plugins() self._activate_plugins()
registered_successful = True registered_successful = True
except (OperationalError, ProgrammingError): except (OperationalError, ProgrammingError): # pragma: no cover
# Exception if the database has not been migrated yet # Exception if the database has not been migrated yet
logger.info('Database not accessible while loading plugins') logger.info('Database not accessible while loading plugins')
break break

View File

@ -117,7 +117,7 @@ class PluginConfigInstallSerializer(serializers.Serializer):
ret['result'] = str(result, 'utf-8') ret['result'] = str(result, 'utf-8')
ret['success'] = True ret['success'] = True
success = True success = True
except subprocess.CalledProcessError as error: except subprocess.CalledProcessError as error: # pragma: no cover
ret['result'] = str(error.output, 'utf-8') ret['result'] = str(error.output, 'utf-8')
ret['error'] = True ret['error'] = True

View File

@ -52,7 +52,7 @@ def navigation_enabled(*args, **kwargs):
""" """
if djangosettings.PLUGIN_TESTING: if djangosettings.PLUGIN_TESTING:
return True return True
return InvenTreeSetting.get_setting('ENABLE_PLUGINS_NAVIGATION') return InvenTreeSetting.get_setting('ENABLE_PLUGINS_NAVIGATION') # pragma: no cover
@register.simple_tag() @register.simple_tag()

View File

@ -22,6 +22,7 @@ class PluginDetailAPITest(InvenTreeAPITestCase):
self.MSG_NO_PKG = 'Either packagename of URL must be provided' self.MSG_NO_PKG = 'Either packagename of URL must be provided'
self.PKG_NAME = 'minimal' self.PKG_NAME = 'minimal'
self.PKG_URL = 'git+https://github.com/geoffrey-a-reed/minimal'
super().setUp() super().setUp()
def test_plugin_install(self): def test_plugin_install(self):
@ -35,7 +36,13 @@ class PluginDetailAPITest(InvenTreeAPITestCase):
'confirm': True, 'confirm': True,
'packagename': self.PKG_NAME 'packagename': self.PKG_NAME
}, expected_code=201).data }, expected_code=201).data
self.assertEqual(data['success'], True)
# valid - github url
data = self.post(url, {
'confirm': True,
'url': self.PKG_URL
}, expected_code=201).data
self.assertEqual(data['success'], True) self.assertEqual(data['success'], True)
# invalid tries # invalid tries

View File

@ -90,7 +90,7 @@ class ReportConfig(AppConfig):
try: try:
from .models import TestReport from .models import TestReport
except: except: # pragma: no cover
# Database is not ready yet # Database is not ready yet
return return
@ -113,7 +113,7 @@ class ReportConfig(AppConfig):
try: try:
from .models import BuildReport from .models import BuildReport
except: except: # pragma: no cover
# Database is not ready yet # Database is not ready yet
return return

View File

@ -34,7 +34,7 @@ from django.utils.translation import gettext_lazy as _
try: try:
from django_weasyprint import WeasyTemplateResponseMixin from django_weasyprint import WeasyTemplateResponseMixin
except OSError as err: except OSError as err: # pragma: no cover
print("OSError: {e}".format(e=err)) print("OSError: {e}".format(e=err))
print("You may require some further system packages to be installed.") print("You may require some further system packages to be installed.")
sys.exit(1) sys.exit(1)

View File

@ -60,13 +60,13 @@ class ReportTest(InvenTreeAPITestCase):
template_dir template_dir
) )
if not os.path.exists(dst_dir): if not os.path.exists(dst_dir): # pragma: no cover
os.makedirs(dst_dir, exist_ok=True) os.makedirs(dst_dir, exist_ok=True)
src_file = os.path.join(src_dir, filename) src_file = os.path.join(src_dir, filename)
dst_file = os.path.join(dst_dir, filename) dst_file = os.path.join(dst_dir, filename)
if not os.path.exists(dst_file): if not os.path.exists(dst_file): # pragma: no cover
shutil.copyfile(src_file, dst_file) shutil.copyfile(src_file, dst_file)
# Convert to an "internal" filename # Convert to an "internal" filename

View File

@ -21,7 +21,7 @@ def update_history(apps, schema_editor):
locations = StockLocation.objects.all() locations = StockLocation.objects.all()
for location in locations: for location in locations: # pragma: no cover
# Pre-calculate pathstring # Pre-calculate pathstring
# Note we cannot use the 'pathstring' function here as we don't have access to model functions! # Note we cannot use the 'pathstring' function here as we don't have access to model functions!
@ -35,7 +35,7 @@ def update_history(apps, schema_editor):
location._path = '/'.join(path) location._path = '/'.join(path)
for item in StockItem.objects.all(): for item in StockItem.objects.all(): # pragma: no cover
history = StockItemTracking.objects.filter(item=item).order_by('date') history = StockItemTracking.objects.filter(item=item).order_by('date')
@ -200,13 +200,13 @@ def update_history(apps, schema_editor):
if update_count > 0: if update_count > 0:
print(f"\n==========================\nUpdated {update_count} StockItemHistory entries") print(f"\n==========================\nUpdated {update_count} StockItemHistory entries") # pragma: no cover
def reverse_update(apps, schema_editor): def reverse_update(apps, schema_editor):
""" """
""" """
pass pass # pragma: no cover
class Migration(migrations.Migration): class Migration(migrations.Migration):

View File

@ -26,12 +26,12 @@ def extract_purchase_price(apps, schema_editor):
# Find all the StockItem objects without a purchase_price which point to a PurchaseOrder # Find all the StockItem objects without a purchase_price which point to a PurchaseOrder
items = StockItem.objects.filter(purchase_price=None).exclude(purchase_order=None) items = StockItem.objects.filter(purchase_price=None).exclude(purchase_order=None)
if items.count() > 0: if items.count() > 0: # pragma: no cover
print(f"Found {items.count()} stock items with missing purchase price information") print(f"Found {items.count()} stock items with missing purchase price information")
update_count = 0 update_count = 0
for item in items: for item in items: # pragma: no cover
part_id = item.part part_id = item.part
@ -57,10 +57,10 @@ def extract_purchase_price(apps, schema_editor):
break break
if update_count > 0: if update_count > 0: # pragma: no cover
print(f"Updated pricing for {update_count} stock items") print(f"Updated pricing for {update_count} stock items")
def reverse_operation(apps, schema_editor): def reverse_operation(apps, schema_editor): # pragma: no cover
""" """
DO NOTHING! DO NOTHING!
""" """

View File

@ -12,7 +12,7 @@ def update_serials(apps, schema_editor):
StockItem = apps.get_model('stock', 'stockitem') StockItem = apps.get_model('stock', 'stockitem')
for item in StockItem.objects.all(): for item in StockItem.objects.all(): # pragma: no cover
if item.serial is None: if item.serial is None:
# Skip items without existing serial numbers # Skip items without existing serial numbers
@ -33,7 +33,7 @@ def update_serials(apps, schema_editor):
item.save() item.save()
def nupdate_serials(apps, schema_editor): def nupdate_serials(apps, schema_editor): # pragma: no cover
""" """
Provided only for reverse migration compatibility Provided only for reverse migration compatibility
""" """

View File

@ -29,7 +29,7 @@ def delete_scheduled(apps, schema_editor):
Task.objects.filter(func='stock.tasks.delete_old_stock_items').delete() Task.objects.filter(func='stock.tasks.delete_old_stock_items').delete()
def reverse(apps, schema_editor): def reverse(apps, schema_editor): # pragma: no cover
pass pass

View File

@ -1,2 +0,0 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals

View File

@ -10,7 +10,7 @@
{% endblock %} {% endblock %}
{% block breadcrumbs %} {% block breadcrumbs %}
<a href='#' id='breadcrumb-tree-toggle' class="breadcrumb-item"><i class="fas fa-bars"></i></a> <a href='#' id='breadcrumb-tree-toggle' class="breadcrumb-item"><span class="fas fa-bars"></span></a>
{% include 'stock/loc_link.html' with location=item.location %} {% include 'stock/loc_link.html' with location=item.location %}
{% endblock %} {% endblock %}

View File

@ -18,7 +18,7 @@
{% endblock %} {% endblock %}
{% block breadcrumbs %} {% block breadcrumbs %}
<a href='#' id='breadcrumb-tree-toggle' class="breadcrumb-item"><i class="fas fa-bars"></i></a> <a href='#' id='breadcrumb-tree-toggle' class="breadcrumb-item"><span class="fas fa-bars"></span></a>
{% if item %} {% if item %}
{% include 'stock/loc_link.html' with location=item.location %} {% include 'stock/loc_link.html' with location=item.location %}
{% else %} {% else %}

View File

@ -269,9 +269,6 @@ class StockItemTest(StockAPITestCase):
list_url = reverse('api-stock-list') list_url = reverse('api-stock-list')
def detail_url(self, pk):
return reverse('api-stock-detail', kwargs={'pk': pk})
def setUp(self): def setUp(self):
super().setUp() super().setUp()
# Create some stock locations # Create some stock locations

View File

@ -5,7 +5,7 @@ from django.urls import reverse
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.contrib.auth.models import Group from django.contrib.auth.models import Group
from common.models import InvenTreeSetting # from common.models import InvenTreeSetting
class StockViewTestCase(TestCase): class StockViewTestCase(TestCase):
@ -64,6 +64,9 @@ class StockOwnershipTest(StockViewTestCase):
def setUp(self): def setUp(self):
""" Add another user for ownership tests """ """ Add another user for ownership tests """
"""
TODO: Refactor this following test to use the new API form
super().setUp() super().setUp()
# Promote existing user with staff, admin and superuser statuses # Promote existing user with staff, admin and superuser statuses
@ -100,8 +103,6 @@ class StockOwnershipTest(StockViewTestCase):
InvenTreeSetting.set_setting('STOCK_OWNERSHIP_CONTROL', True, self.user) InvenTreeSetting.set_setting('STOCK_OWNERSHIP_CONTROL', True, self.user)
self.assertEqual(True, InvenTreeSetting.get_setting('STOCK_OWNERSHIP_CONTROL')) self.assertEqual(True, InvenTreeSetting.get_setting('STOCK_OWNERSHIP_CONTROL'))
"""
TODO: Refactor this following test to use the new API form
def test_owner_control(self): def test_owner_control(self):
# Test stock location and item ownership # Test stock location and item ownership
from .models import StockLocation from .models import StockLocation

View File

@ -77,7 +77,7 @@
{% endif %} {% endif %}
{% if plugin.website %} {% if plugin.website %}
<a href="{{ plugin.website }}"><i class="fas fa-globe"></i></a> <a href="{{ plugin.website }}"><span class="fas fa-globe"></span></a>
{% endif %} {% endif %}
</td> </td>
<td>{{ plugin.author }}</td> <td>{{ plugin.author }}</td>

View File

@ -65,7 +65,7 @@
<input id="email_radio_{{forloop.counter}}" type="radio" name="email" {% if emailaddress.primary or user.emailaddress_set.count == 1 %}checked="checked" {%endif %} value="{{emailaddress.email}}" /> <input id="email_radio_{{forloop.counter}}" type="radio" name="email" {% if emailaddress.primary or user.emailaddress_set.count == 1 %}checked="checked" {%endif %} value="{{emailaddress.email}}" />
{% if emailaddress.primary %} {% if emailaddress.primary %}
<b>{{ emailaddress.email }}</b> <strong>{{ emailaddress.email }}</strong>
{% else %} {% else %}
{{ emailaddress.email }} {{ emailaddress.email }}
{% endif %} {% endif %}

View File

@ -94,7 +94,7 @@
{% if server_restart_required and not demo_mode %} {% if server_restart_required and not demo_mode %}
<div id='alert-restart-server' class='alert alert-danger' role='alert'> <div id='alert-restart-server' class='alert alert-danger' role='alert'>
<span class='fas fa-server'></span> <span class='fas fa-server'></span>
<b>{% trans "Server Restart Required" %}</b> <strong>{% trans "Server Restart Required" %}</strong>
<small> <small>
<br> <br>
{% trans "A configuration option has been changed which requires a server restart" %}. {% trans "Contact your system administrator for further information" %} {% trans "A configuration option has been changed which requires a server restart" %}. {% trans "Contact your system administrator for further information" %}

View File

@ -21,6 +21,7 @@
/* exported /* exported
allocateStockToBuild, allocateStockToBuild,
completeBuildOrder, completeBuildOrder,
createBuildOutput,
editBuildOrder, editBuildOrder,
loadAllocationTable, loadAllocationTable,
loadBuildOrderAllocationTable, loadBuildOrderAllocationTable,
@ -175,6 +176,85 @@ function completeBuildOrder(build_id, options={}) {
} }
/*
* Construct a new build output against the provided build
*/
function createBuildOutput(build_id, options) {
// Request build order information from the server
inventreeGet(
`/api/build/${build_id}/`,
{},
{
success: function(build) {
var html = '';
var trackable = build.part_detail.trackable;
var remaining = Math.max(0, build.quantity - build.completed);
var fields = {
quantity: {
value: remaining,
},
serial_numbers: {
hidden: !trackable,
required: options.trackable_parts || trackable,
},
batch_code: {},
auto_allocate: {
hidden: !trackable,
},
};
// Work out the next available serial numbers
inventreeGet(`/api/part/${build.part}/serial-numbers/`, {}, {
success: function(data) {
if (data.next) {
fields.serial_numbers.placeholder = `{% trans "Next available serial number" %}: ${data.next}`;
} else {
fields.serial_numbers.placeholder = `{% trans "Latest serial number" %}: ${data.latest}`;
}
},
async: false,
});
if (options.trackable_parts) {
html += `
<div class='alert alert-block alert-info'>
{% trans "The Bill of Materials contains trackable parts" %}.<br>
{% trans "Build outputs must be generated individually" %}.
</div>
`;
}
if (trackable) {
html += `
<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>
`;
}
constructForm(`/api/build/${build_id}/create-output/`, {
method: 'POST',
title: '{% trans "Create Build Output" %}',
confirm: true,
fields: fields,
preFormContent: html,
onSuccess: function(response) {
location.reload();
},
});
}
}
);
}
/* /*
* Construct a set of output buttons for a particular build output * Construct a set of output buttons for a particular build output
*/ */
@ -1435,8 +1515,22 @@ function allocateStockToBuild(build_id, part_id, bom_items, options={}) {
// ID of the associated "build output" (or null) // ID of the associated "build output" (or null)
var output_id = options.output || null; var output_id = options.output || null;
var auto_fill_filters = {};
var source_location = options.source_location; var source_location = options.source_location;
if (output_id) {
// Request information on the particular build output (stock item)
inventreeGet(`/api/stock/${output_id}/`, {}, {
success: function(output) {
if (output.quantity == 1 && output.serial != null) {
auto_fill_filters.serial = output.serial;
}
},
async: false,
});
}
function renderBomItemRow(bom_item, quantity) { function renderBomItemRow(bom_item, quantity) {
var pk = bom_item.pk; var pk = bom_item.pk;
@ -1623,7 +1717,9 @@ function allocateStockToBuild(build_id, part_id, bom_items, options={}) {
required: true, required: true,
render_part_detail: true, render_part_detail: true,
render_location_detail: true, render_location_detail: true,
render_stock_id: false,
auto_fill: true, auto_fill: true,
auto_fill_filters: auto_fill_filters,
onSelect: function(data, field, opts) { onSelect: function(data, field, opts) {
// Adjust the 'quantity' field based on availability // Adjust the 'quantity' field based on availability

View File

@ -1696,13 +1696,19 @@ function initializeRelatedField(field, fields, options={}) {
} else if (field.auto_fill) { } else if (field.auto_fill) {
// Attempt to auto-fill the field // Attempt to auto-fill the field
var filters = field.filters || {}; var filters = {};
// Update with nominal field fields
Object.assign(filters, field.filters || {});
// Update with filters only used for initial filtering
Object.assign(filters, field.auto_fill_filters || {});
// Enforce pagination, limit to a single return (for fast query) // Enforce pagination, limit to a single return (for fast query)
filters.limit = 1; filters.limit = 1;
filters.offset = 0; filters.offset = 0;
inventreeGet(field.api_url, field.filters || {}, { inventreeGet(field.api_url, filters || {}, {
success: function(data) { success: function(data) {
// Only a single result is available, given the provided filters // Only a single result is available, given the provided filters
@ -2014,7 +2020,7 @@ function constructField(name, parameters, options) {
if (parameters.help_text && !options.hideLabels) { if (parameters.help_text && !options.hideLabels) {
// Boolean values are handled differently! // Boolean values are handled differently!
if (parameters.type != 'boolean') { if (parameters.type != 'boolean' && !parameters.hidden) {
html += constructHelpText(name, parameters, options); html += constructHelpText(name, parameters, options);
} }
} }
@ -2022,7 +2028,6 @@ function constructField(name, parameters, options) {
// Div for error messages // Div for error messages
html += `<div id='errors-${field_name}'></div>`; html += `<div id='errors-${field_name}'></div>`;
html += `</div>`; // controls html += `</div>`; // controls
html += `</div>`; // form-group html += `</div>`; // form-group
@ -2212,6 +2217,10 @@ function constructInputOptions(name, classes, type, parameters, options={}) {
return `<textarea ${opts.join(' ')}></textarea>`; return `<textarea ${opts.join(' ')}></textarea>`;
} else if (parameters.type == 'boolean') { } else if (parameters.type == 'boolean') {
if (parameters.hidden) {
return '';
}
var help_text = ''; var help_text = '';
if (!options.hideLabels && parameters.help_text) { if (!options.hideLabels && parameters.help_text) {

View File

@ -0,0 +1,98 @@
{% load inventree_extras %}
{% load i18n %}
{% load static %}
{% block form_alert %}
{% if missing_columns and missing_columns|length > 0 %}
<div class='alert alert-danger alert-block' style='margin-top:12px;' role='alert'>
{% trans "Missing selections for the following required columns" %}:
<br>
<ul>
{% for col in missing_columns %}
<li>{{ col }}</li>
{% endfor %}
</ul>
</div>
{% endif %}
{% if duplicates and duplicates|length > 0 %}
<div class='alert alert-danger alert-block' role='alert'>
{% trans "Duplicate selections found, see below. Fix them then retry submitting." %}
</div>
{% endif %}
{% endblock form_alert %}
{% block form_buttons_top %}
{% if wizard.steps.prev %}
<button name='wizard_goto_step' type='submit' value='{{ wizard.steps.prev }}' class='save btn btn-outline-secondary'>{% trans "Previous Step" %}</button>
{% endif %}
<button type='submit' class='save btn btn-outline-secondary'>{% trans "Submit Selections" %}</button>
{% endblock form_buttons_top %}
{% block form_content %}
<thead>
<tr>
<th>{% trans "File Fields" %}</th>
<th></th>
{% for col in form %}
<th>
<div>
<input type='hidden' name='col_name_{{ forloop.counter0 }}' value='{{ col.name }}'/>
{{ col.name }}
<button class='btn btn-outline-secondary btn-remove' onClick='removeColFromBomWizard()' id='del_col_{{ forloop.counter0 }}' style='display: inline; float: right;' title='{% trans "Remove column" %}'>
<span col_id='{{ forloop.counter0 }}' class='fas fa-trash-alt icon-red'></span>
</button>
</div>
</th>
{% endfor %}
</tr>
</thead>
<tbody>
<tr>
<td>{% trans "Match Fields" %}</td>
<td></td>
{% for col in form %}
<td>
{{ col }}
{% for duplicate in duplicates %}
{% if duplicate == col.value %}
<div class='alert alert-danger alert-block text-center' role='alert' style='padding:2px; margin-top:6px; margin-bottom:2px'>
<strong>{% trans "Duplicate selection" %}</strong>
</div>
{% endif %}
{% endfor %}
</td>
{% endfor %}
</tr>
{% for row in rows %}
{% with forloop.counter as row_index %}
<tr>
<td style='width: 32px;'>
<button class='btn btn-outline-secondary btn-remove' onClick='removeRowFromBomWizard()' id='del_row_{{ row_index }}' style='display: inline; float: left;' title='{% trans "Remove row" %}'>
<span row_id='{{ row_index }}' class='fas fa-trash-alt icon-red'></span>
</button>
</td>
<td style='text-align: left;'>{{ row_index }}</td>
{% for item in row.data %}
<td>
<input type='hidden' name='row_{{ row_index }}_col_{{ forloop.counter0 }}' value='{{ item }}'/>
{{ item }}
</td>
{% endfor %}
</tr>
{% endwith %}
{% endfor %}
</tbody>
{% endblock form_content %}
{% block form_buttons_bottom %}
{% endblock form_buttons_bottom %}
{% block js_ready %}
{{ block.super }}
$('.fieldselect').select2({
width: '100%',
matcher: partialMatcher,
});
{% endblock %}

View File

@ -0,0 +1,45 @@
<div class='panel' id='{{ panel_id }}'>
<div class='panel-heading'>
<h4>
{{ header_text }}
{{ wizard.form.media }}
</h4>
</div>
<div class='panel-content'>
{% if upload_go_ahead %}
<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'>
{{ error_text }}
</div>
{% endif %}
</div>
</div>

View File

@ -1,7 +1,7 @@
{% load i18n %} {% load i18n %}
<span title='{{ text }}' class="list-group-item sidebar-list-group-item border-end d-inline-block text-truncate bg-light" data-bs-parent="#sidebar"> <span title='{{ text }}' class="list-group-item sidebar-list-group-item border-end d-inline-block text-truncate bg-light" data-bs-parent="#sidebar">
<h6> <h6>
<i class="bi bi-bootstrap"></i> <span class="bi bi-bootstrap"></span>
{% if icon %}<span class='sidebar-item-icon fas {{ icon }}'></span>{% endif %} {% if icon %}<span class='sidebar-item-icon fas {{ icon }}'></span>{% endif %}
{% if text %}<span class='sidebar-item-text' style='display: none;'><strong>{{ text }}</strong></span>{% endif %} {% if text %}<span class='sidebar-item-text' style='display: none;'><strong>{{ text }}</strong></span>{% endif %}
</h6> </h6>

View File

@ -1,6 +1,6 @@
{% load i18n %} {% load i18n %}
<a href="#" id='select-{{ label }}' title='{{ text }}' class="list-group-item sidebar-list-group-item border-end d-inline-block text-truncate sidebar-selector" data-bs-parent="#sidebar"> <a href="#" id='select-{{ label }}' title='{{ text }}' class="list-group-item sidebar-list-group-item border-end d-inline-block text-truncate sidebar-selector" data-bs-parent="#sidebar">
<i class="bi bi-bootstrap"></i> <span class="bi bi-bootstrap"></span>
<span class='sidebar-item-icon fas {{ icon|default:"fa-circle" }}'></span> <span class='sidebar-item-icon fas {{ icon|default:"fa-circle" }}'></span>
<span class='sidebar-item-text' style='display: none;'>{{ text }}</span> <span class='sidebar-item-text' style='display: none;'>{{ text }}</span>
{% if badge %} {% if badge %}

View File

@ -1,4 +1,4 @@
{% load i18n %} {% load i18n %}
<a href="{{ url }}" class="list-group-item sidebar-list-group-item border-end d-inline-block text-truncate" data-bs-parent="#sidebar"> <a href="{{ url }}" class="list-group-item sidebar-list-group-item border-end d-inline-block text-truncate" data-bs-parent="#sidebar">
<i class="bi bi-bootstrap"></i><span class='sidebar-item-icon fas {{ icon }}'></span><span class='sidebar-item-text' style='display: none;'>{{ text }}</span> <span class="bi bi-bootstrap"></span><span class='sidebar-item-icon fas {{ icon }}'></span><span class='sidebar-item-text' style='display: none;'>{{ text }}</span>
</a> </a>

View File

@ -1,4 +1,4 @@
<a href="#" id='{{ target }}-toggle' class="list-group-item sidebar-list-group-item border-end d-inline-block text-truncate sidebar-toggle" data-bs-parent="#sidebar" style='display: none;'> <a href="#" id='{{ target }}-toggle' class="list-group-item sidebar-list-group-item border-end d-inline-block text-truncate sidebar-toggle" data-bs-parent="#sidebar" style='display: none;'>
<i class="bi bi-bootstrap"></i><span id='sidebar-toggle-icon' class='sidebar-item-icon fas fa-chevron-left'></span> <span class="bi bi-bootstrap"></span><span id='sidebar-toggle-icon' class='sidebar-item-icon fas fa-chevron-left'></span>
{% if text %}<span class='sidebar-item-text' style='display: none;'>{{ text }}</span>{% endif %} {% if text %}<span class='sidebar-item-text' style='display: none;'>{{ text }}</span>{% endif %}
</a> </a>

View File

@ -162,11 +162,6 @@ class GetAuthToken(APIView):
'token': token.key, 'token': token.key,
}) })
else:
return Response({
'error': 'User not authenticated',
})
def logout(self, request): def logout(self, request):
try: try:
request.user.auth_token.delete() request.user.auth_token.delete()

View File

@ -32,7 +32,7 @@ class UsersConfig(AppConfig):
# First, delete any rule_set objects which have become outdated! # First, delete any rule_set objects which have become outdated!
for rule in RuleSet.objects.all(): for rule in RuleSet.objects.all():
if rule.name not in RuleSet.RULESET_NAMES: if rule.name not in RuleSet.RULESET_NAMES: # pragma: no cover # can not change ORM without the app beeing loaded
print("need to delete:", rule.name) print("need to delete:", rule.name)
rule.delete() rule.delete()

View File

@ -268,7 +268,7 @@ class RuleSet(models.Model):
def __str__(self, debug=False): def __str__(self, debug=False):
""" Ruleset string representation """ """ Ruleset string representation """
if debug: if debug: # pragma: no cover
# Makes debugging easier # Makes debugging easier
return f'{str(self.group).ljust(15)}: {self.name.title().ljust(15)} | ' \ return f'{str(self.group).ljust(15)}: {self.name.title().ljust(15)} | ' \
f'v: {str(self.can_view).ljust(5)} | a: {str(self.can_add).ljust(5)} | ' \ f'v: {str(self.can_view).ljust(5)} | a: {str(self.can_add).ljust(5)} | ' \
@ -341,7 +341,7 @@ def update_group_roles(group, debug=False):
""" """
if not canAppAccessDatabase(allow_test=True): if not canAppAccessDatabase(allow_test=True):
return return # pragma: no cover
# List of permissions already associated with this group # List of permissions already associated with this group
group_permissions = set() group_permissions = set()
@ -433,7 +433,7 @@ def update_group_roles(group, debug=False):
try: try:
content_type = ContentType.objects.get(app_label=app, model=model) content_type = ContentType.objects.get(app_label=app, model=model)
permission = Permission.objects.get(content_type=content_type, codename=perm) permission = Permission.objects.get(content_type=content_type, codename=perm)
except ContentType.DoesNotExist: except ContentType.DoesNotExist: # pragma: no cover
logger.warning(f"Error: Could not find permission matching '{permission_string}'") logger.warning(f"Error: Could not find permission matching '{permission_string}'")
permission = None permission = None
@ -451,7 +451,7 @@ def update_group_roles(group, debug=False):
if permission: if permission:
group.permissions.add(permission) group.permissions.add(permission)
if debug: if debug: # pragma: no cover
print(f"Adding permission {perm} to group {group.name}") print(f"Adding permission {perm} to group {group.name}")
# Remove any extra permissions from the group # Remove any extra permissions from the group
@ -466,7 +466,7 @@ def update_group_roles(group, debug=False):
if permission: if permission:
group.permissions.remove(permission) group.permissions.remove(permission)
if debug: if debug: # pragma: no cover
print(f"Removing permission {perm} from group {group.name}") print(f"Removing permission {perm} from group {group.name}")
# Enable all action permissions for certain children models # Enable all action permissions for certain children models
@ -618,7 +618,7 @@ class Owner(models.Model):
# Create new owner # Create new owner
try: try:
return cls.objects.create(owner=obj) return cls.objects.create(owner=obj)
except IntegrityError: except IntegrityError: # pragma: no cover
return None return None
return existing_owner return existing_owner

View File

@ -3,9 +3,12 @@ from __future__ import unicode_literals
from django.test import TestCase from django.test import TestCase
from django.apps import apps from django.apps import apps
from django.urls import reverse
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.contrib.auth.models import Group from django.contrib.auth.models import Group
from rest_framework.authtoken.models import Token
from users.models import RuleSet, Owner from users.models import RuleSet, Owner
@ -22,7 +25,7 @@ class RuleSetModelTest(TestCase):
missing = [name for name in RuleSet.RULESET_NAMES if name not in keys] missing = [name for name in RuleSet.RULESET_NAMES if name not in keys]
if len(missing) > 0: if len(missing) > 0: # pragma: no cover
print("The following rulesets do not have models assigned:") print("The following rulesets do not have models assigned:")
for m in missing: for m in missing:
print("-", m) print("-", m)
@ -30,7 +33,7 @@ class RuleSetModelTest(TestCase):
# Check if models have been defined for a ruleset which is incorrect # Check if models have been defined for a ruleset which is incorrect
extra = [name for name in keys if name not in RuleSet.RULESET_NAMES] extra = [name for name in keys if name not in RuleSet.RULESET_NAMES]
if len(extra) > 0: if len(extra) > 0: # pragma: no cover
print("The following rulesets have been improperly added to RULESET_MODELS:") print("The following rulesets have been improperly added to RULESET_MODELS:")
for e in extra: for e in extra:
print("-", e) print("-", e)
@ -38,7 +41,7 @@ class RuleSetModelTest(TestCase):
# Check that each ruleset has models assigned # Check that each ruleset has models assigned
empty = [key for key in keys if len(RuleSet.RULESET_MODELS[key]) == 0] empty = [key for key in keys if len(RuleSet.RULESET_MODELS[key]) == 0]
if len(empty) > 0: if len(empty) > 0: # pragma: no cover
print("The following rulesets have empty entries in RULESET_MODELS:") print("The following rulesets have empty entries in RULESET_MODELS:")
for e in empty: for e in empty:
print("-", e) print("-", e)
@ -77,10 +80,10 @@ class RuleSetModelTest(TestCase):
missing_models = set() missing_models = set()
for model in available_tables: for model in available_tables:
if model not in assigned_models and model not in RuleSet.RULESET_IGNORE: if model not in assigned_models and model not in RuleSet.RULESET_IGNORE: # pragma: no cover
missing_models.add(model) missing_models.add(model)
if len(missing_models) > 0: if len(missing_models) > 0: # pragma: no cover
print("The following database models are not covered by the defined RuleSet permissions:") print("The following database models are not covered by the defined RuleSet permissions:")
for m in missing_models: for m in missing_models:
print("-", m) print("-", m)
@ -95,11 +98,11 @@ class RuleSetModelTest(TestCase):
for model in RuleSet.RULESET_IGNORE: for model in RuleSet.RULESET_IGNORE:
defined_models.add(model) defined_models.add(model)
for model in defined_models: for model in defined_models: # pragma: no cover
if model not in available_tables: if model not in available_tables:
extra_models.add(model) extra_models.add(model)
if len(extra_models) > 0: if len(extra_models) > 0: # pragma: no cover
print("The following RuleSet permissions do not match a database model:") print("The following RuleSet permissions do not match a database model:")
for m in extra_models: for m in extra_models:
print("-", m) print("-", m)
@ -169,16 +172,16 @@ class OwnerModelTest(TestCase):
""" Add users and groups """ """ Add users and groups """
# Create a new user # Create a new user
self.user = get_user_model().objects.create_user( self.user = get_user_model().objects.create_user('username', 'user@email.com', 'password')
username='john',
email='john@email.com',
password='custom123',
)
# Put the user into a new group # Put the user into a new group
self.group = Group.objects.create(name='new_group') self.group = Group.objects.create(name='new_group')
self.user.groups.add(self.group) self.user.groups.add(self.group)
def do_request(self, endpoint, filters, status_code=200):
response = self.client.get(endpoint, filters, format='json')
self.assertEqual(response.status_code, status_code)
return response.data
def test_owner(self): def test_owner(self):
# Check that owner was created for user # Check that owner was created for user
@ -203,3 +206,43 @@ class OwnerModelTest(TestCase):
self.group.delete() self.group.delete()
group_as_owner = Owner.get_owner(self.group) group_as_owner = Owner.get_owner(self.group)
self.assertEqual(group_as_owner, None) self.assertEqual(group_as_owner, None)
def test_api(self):
"""
Test user APIs
"""
# not authed
self.do_request(reverse('api-owner-list'), {}, 401)
self.do_request(reverse('api-owner-detail', kwargs={'pk': self.user.id}), {}, 401)
self.client.login(username='username', password='password')
# user list
self.do_request(reverse('api-owner-list'), {})
# user list with search
self.do_request(reverse('api-owner-list'), {'search': 'user'})
# user detail
# TODO fix this test
# self.do_request(reverse('api-owner-detail', kwargs={'pk': self.user.id}), {})
def test_token(self):
"""
Test token mechanisms
"""
token = Token.objects.filter(user=self.user)
# not authed
self.do_request(reverse('api-token'), {}, 401)
self.client.login(username='username', password='password')
# token get
response = self.do_request(reverse('api-token'), {})
self.assertEqual(response['token'], token.first().key)
# token delete
response = self.client.delete(reverse('api-token'), {}, format='json')
self.assertEqual(response.status_code, 202)
self.assertEqual(len(token), 0)
# token second delete
response = self.client.delete(reverse('api-token'), {}, format='json')
self.assertEqual(response.status_code, 400)

View File

@ -20,3 +20,7 @@ max-complexity = 20
[coverage:run] [coverage:run]
source = ./InvenTree source = ./InvenTree
[coverage:report]
omit=
InvenTree/wsgi.py
InvenTree/ci_render_js.py