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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -19,6 +19,7 @@ from InvenTree.serializers import InvenTreeModelSerializer, InvenTreeAttachmentS
from InvenTree.serializers import UserSerializerBrief, ReferenceIndexingSerializerMixin
import InvenTree.helpers
from InvenTree.helpers import extract_serial_numbers
from InvenTree.serializers import InvenTreeDecimalField
from InvenTree.status_codes import StockStatus
@ -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):
"""
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 }}
$('#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()
if ref is None:
ref = "0001"
# Create a "Build" object to make 10x objects
self.build = Build.objects.create(
reference=ref,

View File

@ -9,7 +9,6 @@ from . import views
build_detail_urls = [
url(r'^cancel/', views.BuildCancel.as_view(), name='build-cancel'),
url(r'^delete/', views.BuildDelete.as_view(), name='build-delete'),
url(r'^create-output/', views.BuildOutputCreate.as_view(), name='build-output-create'),
url(r'^.*$', 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 django.utils.translation import ugettext_lazy as _
from django.core.exceptions import ValidationError
from django.views.generic import DetailView, ListView
from django.forms import HiddenInput
from .models import Build
from . import forms
from InvenTree.views import AjaxUpdateView, AjaxDeleteView
from InvenTree.views import InvenTreeRoleMixin
from InvenTree.helpers import str2bool, extract_serial_numbers
from InvenTree.helpers import str2bool
from InvenTree.status_codes import BuildStatus
@ -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):
"""
Detail view of a single Build object.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -117,7 +117,7 @@ class PluginConfigInstallSerializer(serializers.Serializer):
ret['result'] = str(result, 'utf-8')
ret['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['error'] = True

View File

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

View File

@ -22,6 +22,7 @@ class PluginDetailAPITest(InvenTreeAPITestCase):
self.MSG_NO_PKG = 'Either packagename of URL must be provided'
self.PKG_NAME = 'minimal'
self.PKG_URL = 'git+https://github.com/geoffrey-a-reed/minimal'
super().setUp()
def test_plugin_install(self):
@ -35,7 +36,13 @@ class PluginDetailAPITest(InvenTreeAPITestCase):
'confirm': True,
'packagename': self.PKG_NAME
}, 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)
# invalid tries

View File

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

View File

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

View File

@ -60,13 +60,13 @@ class ReportTest(InvenTreeAPITestCase):
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)
src_file = os.path.join(src_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)
# Convert to an "internal" filename

View File

@ -21,7 +21,7 @@ def update_history(apps, schema_editor):
locations = StockLocation.objects.all()
for location in locations:
for location in locations: # pragma: no cover
# Pre-calculate pathstring
# 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)
for item in StockItem.objects.all():
for item in StockItem.objects.all(): # pragma: no cover
history = StockItemTracking.objects.filter(item=item).order_by('date')
@ -200,13 +200,13 @@ def update_history(apps, schema_editor):
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):
"""
"""
pass
pass # pragma: no cover
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
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")
update_count = 0
for item in items:
for item in items: # pragma: no cover
part_id = item.part
@ -57,10 +57,10 @@ def extract_purchase_price(apps, schema_editor):
break
if update_count > 0:
if update_count > 0: # pragma: no cover
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!
"""

View File

@ -12,7 +12,7 @@ def update_serials(apps, schema_editor):
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:
# Skip items without existing serial numbers
@ -33,7 +33,7 @@ def update_serials(apps, schema_editor):
item.save()
def nupdate_serials(apps, schema_editor):
def nupdate_serials(apps, schema_editor): # pragma: no cover
"""
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()
def reverse(apps, schema_editor):
def reverse(apps, schema_editor): # pragma: no cover
pass

View File

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

View File

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

View File

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

View File

@ -269,9 +269,6 @@ class StockItemTest(StockAPITestCase):
list_url = reverse('api-stock-list')
def detail_url(self, pk):
return reverse('api-stock-detail', kwargs={'pk': pk})
def setUp(self):
super().setUp()
# 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.models import Group
from common.models import InvenTreeSetting
# from common.models import InvenTreeSetting
class StockViewTestCase(TestCase):
@ -64,6 +64,9 @@ class StockOwnershipTest(StockViewTestCase):
def setUp(self):
""" Add another user for ownership tests """
"""
TODO: Refactor this following test to use the new API form
super().setUp()
# 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)
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):
# Test stock location and item ownership
from .models import StockLocation

View File

@ -77,7 +77,7 @@
{% endif %}
{% 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 %}
</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}}" />
{% if emailaddress.primary %}
<b>{{ emailaddress.email }}</b>
<strong>{{ emailaddress.email }}</strong>
{% else %}
{{ emailaddress.email }}
{% endif %}

View File

@ -94,7 +94,7 @@
{% if server_restart_required and not demo_mode %}
<div id='alert-restart-server' class='alert alert-danger' role='alert'>
<span class='fas fa-server'></span>
<b>{% trans "Server Restart Required" %}</b>
<strong>{% trans "Server Restart Required" %}</strong>
<small>
<br>
{% 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
allocateStockToBuild,
completeBuildOrder,
createBuildOutput,
editBuildOrder,
loadAllocationTable,
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
*/
@ -1435,8 +1515,22 @@ function allocateStockToBuild(build_id, part_id, bom_items, options={}) {
// ID of the associated "build output" (or null)
var output_id = options.output || null;
var auto_fill_filters = {};
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) {
var pk = bom_item.pk;
@ -1623,7 +1717,9 @@ function allocateStockToBuild(build_id, part_id, bom_items, options={}) {
required: true,
render_part_detail: true,
render_location_detail: true,
render_stock_id: false,
auto_fill: true,
auto_fill_filters: auto_fill_filters,
onSelect: function(data, field, opts) {
// Adjust the 'quantity' field based on availability

View File

@ -1696,13 +1696,19 @@ function initializeRelatedField(field, fields, options={}) {
} else if (field.auto_fill) {
// 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)
filters.limit = 1;
filters.offset = 0;
inventreeGet(field.api_url, field.filters || {}, {
inventreeGet(field.api_url, filters || {}, {
success: function(data) {
// 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) {
// Boolean values are handled differently!
if (parameters.type != 'boolean') {
if (parameters.type != 'boolean' && !parameters.hidden) {
html += constructHelpText(name, parameters, options);
}
}
@ -2022,7 +2028,6 @@ function constructField(name, parameters, options) {
// Div for error messages
html += `<div id='errors-${field_name}'></div>`;
html += `</div>`; // controls
html += `</div>`; // form-group
@ -2212,6 +2217,10 @@ function constructInputOptions(name, classes, type, parameters, options={}) {
return `<textarea ${opts.join(' ')}></textarea>`;
} else if (parameters.type == 'boolean') {
if (parameters.hidden) {
return '';
}
var 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 %}
<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>
<i class="bi bi-bootstrap"></i>
<span class="bi bi-bootstrap"></span>
{% 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 %}
</h6>

View File

@ -1,6 +1,6 @@
{% 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">
<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-text' style='display: none;'>{{ text }}</span>
{% if badge %}

View File

@ -1,4 +1,4 @@
{% load i18n %}
<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>

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;'>
<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 %}
</a>

View File

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

View File

@ -32,7 +32,7 @@ class UsersConfig(AppConfig):
# First, delete any rule_set objects which have become outdated!
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)
rule.delete()

View File

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

View File

@ -3,9 +3,12 @@ from __future__ import unicode_literals
from django.test import TestCase
from django.apps import apps
from django.urls import reverse
from django.contrib.auth import get_user_model
from django.contrib.auth.models import Group
from rest_framework.authtoken.models import Token
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]
if len(missing) > 0:
if len(missing) > 0: # pragma: no cover
print("The following rulesets do not have models assigned:")
for m in missing:
print("-", m)
@ -30,7 +33,7 @@ class RuleSetModelTest(TestCase):
# 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]
if len(extra) > 0:
if len(extra) > 0: # pragma: no cover
print("The following rulesets have been improperly added to RULESET_MODELS:")
for e in extra:
print("-", e)
@ -38,7 +41,7 @@ class RuleSetModelTest(TestCase):
# Check that each ruleset has models assigned
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:")
for e in empty:
print("-", e)
@ -77,10 +80,10 @@ class RuleSetModelTest(TestCase):
missing_models = set()
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)
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:")
for m in missing_models:
print("-", m)
@ -95,11 +98,11 @@ class RuleSetModelTest(TestCase):
for model in RuleSet.RULESET_IGNORE:
defined_models.add(model)
for model in defined_models:
for model in defined_models: # pragma: no cover
if model not in available_tables:
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:")
for m in extra_models:
print("-", m)
@ -169,16 +172,16 @@ class OwnerModelTest(TestCase):
""" Add users and groups """
# Create a new user
self.user = get_user_model().objects.create_user(
username='john',
email='john@email.com',
password='custom123',
)
self.user = get_user_model().objects.create_user('username', 'user@email.com', 'password')
# Put the user into a new group
self.group = Group.objects.create(name='new_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):
# Check that owner was created for user
@ -203,3 +206,43 @@ class OwnerModelTest(TestCase):
self.group.delete()
group_as_owner = Owner.get_owner(self.group)
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]
source = ./InvenTree
[coverage:report]
omit=
InvenTree/wsgi.py
InvenTree/ci_render_js.py