mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +00:00
Merge branch 'inventree:master' into matmair/issue2279
This commit is contained in:
commit
626f44d195
@ -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()
|
||||
|
@ -1,5 +1,6 @@
|
||||
"""
|
||||
Pull rendered copies of the templated
|
||||
only used for testing the js files! - This file is omited from coverage
|
||||
"""
|
||||
|
||||
from django.test import TestCase
|
||||
|
@ -23,7 +23,7 @@ def health_status(request):
|
||||
|
||||
if request.path.endswith('.js'):
|
||||
# Do not provide to script requests
|
||||
return {}
|
||||
return {} # pragma: no cover
|
||||
|
||||
if hasattr(request, '_inventree_health_status'):
|
||||
# Do not duplicate efforts
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -92,7 +92,7 @@ class URLTest(TestCase):
|
||||
result[0].strip(),
|
||||
result[1].strip()
|
||||
])
|
||||
elif len(result) == 1:
|
||||
elif len(result) == 1: # pragma: no cover
|
||||
urls.append([
|
||||
result[0].strip(),
|
||||
''
|
||||
|
@ -1,11 +1,14 @@
|
||||
""" Unit tests for the main web views """
|
||||
"""
|
||||
Unit tests for the main web views
|
||||
"""
|
||||
|
||||
import re
|
||||
import os
|
||||
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
from django.contrib.auth import get_user_model
|
||||
|
||||
import os
|
||||
|
||||
|
||||
class ViewTests(TestCase):
|
||||
""" Tests for various top-level views """
|
||||
@ -16,9 +19,13 @@ class ViewTests(TestCase):
|
||||
def setUp(self):
|
||||
|
||||
# Create a user
|
||||
get_user_model().objects.create_user(self.username, 'user@email.com', self.password)
|
||||
self.user = get_user_model().objects.create_user(self.username, 'user@email.com', self.password)
|
||||
self.user.set_password(self.password)
|
||||
self.user.save()
|
||||
|
||||
self.client.login(username=self.username, password=self.password)
|
||||
result = self.client.login(username=self.username, password=self.password)
|
||||
|
||||
self.assertEqual(result, True)
|
||||
|
||||
def test_api_doc(self):
|
||||
""" Test that the api-doc view works """
|
||||
@ -27,3 +34,51 @@ class ViewTests(TestCase):
|
||||
|
||||
response = self.client.get(api_url)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_index_redirect(self):
|
||||
"""
|
||||
top-level URL should redirect to "index" page
|
||||
"""
|
||||
|
||||
response = self.client.get("/")
|
||||
|
||||
self.assertEqual(response.status_code, 302)
|
||||
|
||||
def get_index_page(self):
|
||||
"""
|
||||
Retrieve the index page (used for subsequent unit tests)
|
||||
"""
|
||||
|
||||
response = self.client.get("/index/")
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
return str(response.content.decode())
|
||||
|
||||
def test_panels(self):
|
||||
"""
|
||||
Test that the required 'panels' are present
|
||||
"""
|
||||
|
||||
content = self.get_index_page()
|
||||
|
||||
self.assertIn("<div id='detail-panels'>", content)
|
||||
|
||||
# TODO: In future, run the javascript and ensure that the panels get created!
|
||||
|
||||
def test_js_load(self):
|
||||
"""
|
||||
Test that the required javascript files are loaded correctly
|
||||
"""
|
||||
|
||||
# Change this number as more javascript files are added to the index page
|
||||
N_SCRIPT_FILES = 35
|
||||
|
||||
content = self.get_index_page()
|
||||
|
||||
# Extract all required javascript files from the index page content
|
||||
script_files = re.findall("<script type='text\\/javascript' src=\"([^\"]*)\"><\\/script>", content)
|
||||
|
||||
self.assertEqual(len(script_files), N_SCRIPT_FILES)
|
||||
|
||||
# TODO: Request the javascript files from the server, and ensure they are correcty loaded
|
||||
|
@ -12,6 +12,8 @@ from djmoney.contrib.exchange.exceptions import MissingRate
|
||||
from .validators import validate_overage, validate_part_name
|
||||
from . import helpers
|
||||
from . import version
|
||||
from . import status
|
||||
from . import ready
|
||||
|
||||
from decimal import Decimal
|
||||
|
||||
@ -389,3 +391,19 @@ class CurrencyTests(TestCase):
|
||||
# Convert to a symbol which is not covered
|
||||
with self.assertRaises(MissingRate):
|
||||
convert_money(Money(100, 'GBP'), 'ZWL')
|
||||
|
||||
|
||||
class TestStatus(TestCase):
|
||||
"""
|
||||
Unit tests for status functions
|
||||
"""
|
||||
|
||||
def test_check_system_healt(self):
|
||||
"""test that the system health check is false in testing -> background worker not running"""
|
||||
self.assertEqual(status.check_system_health(), False)
|
||||
|
||||
def test_TestMode(self):
|
||||
self.assertTrue(ready.isInTestMode())
|
||||
|
||||
def test_Importing(self):
|
||||
self.assertEqual(ready.isImportingData(), False)
|
||||
|
@ -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)),
|
||||
|
@ -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'),
|
||||
|
@ -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 """
|
||||
|
||||
|
@ -11,7 +11,7 @@ def update_tree(apps, schema_editor):
|
||||
Build.objects.rebuild()
|
||||
|
||||
|
||||
def nupdate_tree(apps, schema_editor):
|
||||
def nupdate_tree(apps, schema_editor): # pragma: no cover
|
||||
pass
|
||||
|
||||
|
||||
|
@ -23,7 +23,7 @@ def add_default_reference(apps, schema_editor):
|
||||
print(f"\nUpdated build reference for {count} existing BuildOrder objects")
|
||||
|
||||
|
||||
def reverse_default_reference(apps, schema_editor):
|
||||
def reverse_default_reference(apps, schema_editor): # pragma: no cover
|
||||
"""
|
||||
Do nothing! But we need to have a function here so the whole process is reversible.
|
||||
"""
|
||||
|
@ -23,7 +23,7 @@ def assign_bom_items(apps, schema_editor):
|
||||
count_valid = 0
|
||||
count_total = 0
|
||||
|
||||
for build_item in BuildItem.objects.all():
|
||||
for build_item in BuildItem.objects.all(): # pragma: no cover
|
||||
|
||||
# Try to find a BomItem which matches the BuildItem
|
||||
# Note: Before this migration, variant stock assignment was not allowed,
|
||||
@ -45,11 +45,11 @@ def assign_bom_items(apps, schema_editor):
|
||||
except BomItem.DoesNotExist:
|
||||
pass
|
||||
|
||||
if count_total > 0:
|
||||
if count_total > 0: # pragma: no cover
|
||||
logger.info(f"Assigned BomItem for {count_valid}/{count_total} entries")
|
||||
|
||||
|
||||
def unassign_bom_items(apps, schema_editor):
|
||||
def unassign_bom_items(apps, schema_editor): # pragma: no cover
|
||||
"""
|
||||
Reverse migration does not do anything.
|
||||
Function here to preserve ability to reverse migration
|
||||
|
@ -21,13 +21,13 @@ def build_refs(apps, schema_editor):
|
||||
if result and len(result.groups()) == 1:
|
||||
try:
|
||||
ref = int(result.groups()[0])
|
||||
except:
|
||||
except: # pragma: no cover
|
||||
ref = 0
|
||||
|
||||
build.reference_int = ref
|
||||
build.save()
|
||||
|
||||
def unbuild_refs(apps, schema_editor):
|
||||
def unbuild_refs(apps, schema_editor): # pragma: no cover
|
||||
"""
|
||||
Provided only for reverse migration compatibility
|
||||
"""
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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 %}
|
@ -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%},
|
||||
}
|
||||
);
|
||||
});
|
||||
|
@ -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,
|
||||
|
@ -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'),
|
||||
]
|
||||
|
@ -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.
|
||||
|
@ -19,7 +19,7 @@ def delete_old_notifications():
|
||||
|
||||
try:
|
||||
from common.models import NotificationEntry
|
||||
except AppRegistryNotReady:
|
||||
except AppRegistryNotReady: # pragma: no cover
|
||||
logger.info("Could not perform 'delete_old_notifications' - App registry not ready")
|
||||
return
|
||||
|
||||
|
@ -59,15 +59,15 @@ class SettingsTest(TestCase):
|
||||
name = setting.get('name', None)
|
||||
|
||||
if name is None:
|
||||
raise ValueError(f'Missing GLOBAL_SETTING name for {key}')
|
||||
raise ValueError(f'Missing GLOBAL_SETTING name for {key}') # pragma: no cover
|
||||
|
||||
description = setting.get('description', None)
|
||||
|
||||
if description is None:
|
||||
raise ValueError(f'Missing GLOBAL_SETTING description for {key}')
|
||||
raise ValueError(f'Missing GLOBAL_SETTING description for {key}') # pragma: no cover
|
||||
|
||||
if not key == key.upper():
|
||||
raise ValueError(f"SETTINGS key '{key}' is not uppercase")
|
||||
raise ValueError(f"SETTINGS key '{key}' is not uppercase") # pragma: no cover
|
||||
|
||||
def test_defaults(self):
|
||||
"""
|
||||
@ -87,10 +87,10 @@ class SettingsTest(TestCase):
|
||||
|
||||
if setting.is_bool():
|
||||
if setting.default_value in ['', None]:
|
||||
raise ValueError(f'Default value for boolean setting {key} not provided')
|
||||
raise ValueError(f'Default value for boolean setting {key} not provided') # pragma: no cover
|
||||
|
||||
if setting.default_value not in [True, False]:
|
||||
raise ValueError(f'Non-boolean default value specified for {key}')
|
||||
raise ValueError(f'Non-boolean default value specified for {key}') # pragma: no cover
|
||||
|
||||
|
||||
class WebhookMessageTests(TestCase):
|
||||
|
@ -14,11 +14,11 @@ So a simplified version of the migration is implemented.
|
||||
TESTING = 'test' in sys.argv
|
||||
|
||||
def clear():
|
||||
if not TESTING:
|
||||
if not TESTING: # pragma: no cover
|
||||
os.system('cls' if os.name == 'nt' else 'clear')
|
||||
|
||||
|
||||
def reverse_association(apps, schema_editor):
|
||||
def reverse_association(apps, schema_editor): # pragma: no cover
|
||||
"""
|
||||
This is the 'reverse' operation of the manufacturer reversal.
|
||||
This operation is easier:
|
||||
@ -108,7 +108,7 @@ def associate_manufacturers(apps, schema_editor):
|
||||
|
||||
if len(row) > 0:
|
||||
return row[0]
|
||||
return ''
|
||||
return '' # pragma: no cover
|
||||
|
||||
cursor = connection.cursor()
|
||||
|
||||
@ -139,7 +139,7 @@ def associate_manufacturers(apps, schema_editor):
|
||||
""" Attempt to link Part to an existing Company """
|
||||
|
||||
# Matches a company name directly
|
||||
if name in companies.keys():
|
||||
if name in companies.keys(): # pragma: no cover
|
||||
print(" - Part[{pk}]: '{n}' maps to existing manufacturer".format(pk=part_id, n=name))
|
||||
|
||||
manufacturer_id = companies[name]
|
||||
@ -150,7 +150,7 @@ def associate_manufacturers(apps, schema_editor):
|
||||
return True
|
||||
|
||||
# Have we already mapped this
|
||||
if name in links.keys():
|
||||
if name in links.keys(): # pragma: no cover
|
||||
print(" - Part[{pk}]: Mapped '{n}' - manufacturer <{c}>".format(pk=part_id, n=name, c=links[name]))
|
||||
|
||||
manufacturer_id = links[name]
|
||||
@ -196,10 +196,10 @@ def associate_manufacturers(apps, schema_editor):
|
||||
# Case-insensitive matching
|
||||
ratio = fuzz.partial_ratio(name.lower(), text.lower())
|
||||
|
||||
if ratio > threshold:
|
||||
if ratio > threshold: # pragma: no cover
|
||||
matches.append({'name': name, 'match': ratio})
|
||||
|
||||
if len(matches) > 0:
|
||||
if len(matches) > 0: # pragma: no cover
|
||||
return [match['name'] for match in sorted(matches, key=lambda item: item['match'], reverse=True)]
|
||||
else:
|
||||
return []
|
||||
@ -212,12 +212,12 @@ def associate_manufacturers(apps, schema_editor):
|
||||
name = get_manufacturer_name(part_id)
|
||||
|
||||
# Skip empty names
|
||||
if not name or len(name) == 0:
|
||||
if not name or len(name) == 0: # pragma: no cover
|
||||
print(" - Part[{pk}]: No manufacturer_name provided, skipping".format(pk=part_id))
|
||||
return
|
||||
|
||||
# Can be linked to an existing manufacturer
|
||||
if link_part(part_id, name):
|
||||
if link_part(part_id, name): # pragma: no cover
|
||||
return
|
||||
|
||||
# Find a list of potential matches
|
||||
@ -226,12 +226,12 @@ def associate_manufacturers(apps, schema_editor):
|
||||
clear()
|
||||
|
||||
# Present a list of options
|
||||
if not TESTING:
|
||||
if not TESTING: # pragma: no cover
|
||||
print("----------------------------------")
|
||||
|
||||
print("Checking part [{pk}] ({idx} of {total})".format(pk=part_id, idx=idx+1, total=total))
|
||||
|
||||
if not TESTING:
|
||||
if not TESTING: # pragma: no cover
|
||||
print("Manufacturer name: '{n}'".format(n=name))
|
||||
print("----------------------------------")
|
||||
print("Select an option from the list below:")
|
||||
@ -249,7 +249,7 @@ def associate_manufacturers(apps, schema_editor):
|
||||
if TESTING:
|
||||
# When running unit tests, simply select the name of the part
|
||||
response = '0'
|
||||
else:
|
||||
else: # pragma: no cover
|
||||
response = str(input("> ")).strip()
|
||||
|
||||
# Attempt to parse user response as an integer
|
||||
@ -263,7 +263,7 @@ def associate_manufacturers(apps, schema_editor):
|
||||
return
|
||||
|
||||
# Options 1) - n) select an existing manufacturer
|
||||
else:
|
||||
else: # pragma: no cover
|
||||
n = n - 1
|
||||
|
||||
if n < len(matches):
|
||||
@ -287,7 +287,7 @@ def associate_manufacturers(apps, schema_editor):
|
||||
else:
|
||||
print("Please select a valid option")
|
||||
|
||||
except ValueError:
|
||||
except ValueError: # pragma: no cover
|
||||
# User has typed in a custom name!
|
||||
|
||||
if not response or len(response) == 0:
|
||||
@ -312,7 +312,7 @@ def associate_manufacturers(apps, schema_editor):
|
||||
print("")
|
||||
clear()
|
||||
|
||||
if not TESTING:
|
||||
if not TESTING: # pragma: no cover
|
||||
print("---------------------------------------")
|
||||
print("The SupplierPart model needs to be migrated,")
|
||||
print("as the new 'manufacturer' field maps to a 'Company' reference.")
|
||||
@ -339,7 +339,7 @@ def associate_manufacturers(apps, schema_editor):
|
||||
for index, row in enumerate(results):
|
||||
pk, MPN, SKU, manufacturer_id, manufacturer_name = row
|
||||
|
||||
if manufacturer_id is not None:
|
||||
if manufacturer_id is not None: # pragma: no cover
|
||||
print(f" - SupplierPart <{pk}> already has a manufacturer associated (skipping)")
|
||||
continue
|
||||
|
||||
|
@ -1,7 +1,7 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
def reverse_empty_email(apps, schema_editor):
|
||||
def reverse_empty_email(apps, schema_editor): # pragma: no cover
|
||||
Company = apps.get_model('company', 'Company')
|
||||
for company in Company.objects.all():
|
||||
if company.email == None:
|
||||
|
@ -42,7 +42,7 @@ def migrate_currencies(apps, schema_editor):
|
||||
|
||||
suffix = suffix.strip().upper()
|
||||
|
||||
if suffix not in currency_codes:
|
||||
if suffix not in currency_codes: # pragma: no cover
|
||||
logger.warning(f"Missing suffix: '{suffix}'")
|
||||
|
||||
while suffix not in currency_codes:
|
||||
@ -78,7 +78,7 @@ def migrate_currencies(apps, schema_editor):
|
||||
if count > 0:
|
||||
logger.info(f"Updated {count} SupplierPriceBreak rows")
|
||||
|
||||
def reverse_currencies(apps, schema_editor):
|
||||
def reverse_currencies(apps, schema_editor): # pragma: no cover
|
||||
"""
|
||||
Reverse the "update" process.
|
||||
|
||||
|
@ -15,12 +15,12 @@ def supplierpart_make_manufacturer_parts(apps, schema_editor):
|
||||
for supplier_part in supplier_parts:
|
||||
print(f'{supplier_part.supplier.name[:15].ljust(15)} | {supplier_part.SKU[:15].ljust(15)}\t', end='')
|
||||
|
||||
if supplier_part.manufacturer_part:
|
||||
if supplier_part.manufacturer_part: # pragma: no cover
|
||||
print(f'[ERROR: MANUFACTURER PART ALREADY EXISTS]')
|
||||
continue
|
||||
|
||||
part = supplier_part.part
|
||||
if not part:
|
||||
if not part: # pragma: no cover
|
||||
print(f'[ERROR: SUPPLIER PART IS NOT CONNECTED TO PART]')
|
||||
continue
|
||||
|
||||
@ -67,7 +67,7 @@ def supplierpart_make_manufacturer_parts(apps, schema_editor):
|
||||
|
||||
print(f'{"-"*10}\nDone\n')
|
||||
|
||||
def supplierpart_populate_manufacturer_info(apps, schema_editor):
|
||||
def supplierpart_populate_manufacturer_info(apps, schema_editor): # pragma: no cover
|
||||
Part = apps.get_model('part', 'Part')
|
||||
ManufacturerPart = apps.get_model('company', 'ManufacturerPart')
|
||||
SupplierPart = apps.get_model('company', 'SupplierPart')
|
||||
|
@ -3,8 +3,6 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import json
|
||||
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
from django.contrib.auth import get_user_model
|
||||
@ -49,28 +47,6 @@ class CompanyViewTestBase(TestCase):
|
||||
|
||||
self.client.login(username='username', password='password')
|
||||
|
||||
def post(self, url, data, valid=None):
|
||||
"""
|
||||
POST against this form and return the response (as a JSON object)
|
||||
"""
|
||||
|
||||
response = self.client.post(url, data, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
json_data = json.loads(response.content)
|
||||
|
||||
# If a particular status code is required
|
||||
if valid is not None:
|
||||
if valid:
|
||||
self.assertEqual(json_data['form_valid'], True)
|
||||
else:
|
||||
self.assertEqual(json_data['form_valid'], False)
|
||||
|
||||
form_errors = json.loads(json_data['form_errors'])
|
||||
|
||||
return json_data, form_errors
|
||||
|
||||
|
||||
class CompanyViewTest(CompanyViewTestBase):
|
||||
"""
|
||||
|
@ -5,6 +5,7 @@ import hashlib
|
||||
|
||||
from django.apps import AppConfig
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import AppRegistryNotReady
|
||||
|
||||
from InvenTree.ready import canAppAccessDatabase
|
||||
|
||||
@ -35,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']}'")
|
||||
|
||||
|
@ -30,7 +30,7 @@ import part.models
|
||||
|
||||
try:
|
||||
from django_weasyprint import WeasyTemplateResponseMixin
|
||||
except OSError as err:
|
||||
except OSError as err: # pragma: no cover
|
||||
print("OSError: {e}".format(e=err))
|
||||
print("You may require some further system packages to be installed.")
|
||||
sys.exit(1)
|
||||
|
@ -66,3 +66,39 @@ class TestReportTests(InvenTreeAPITestCase):
|
||||
'items': [10, 11, 12],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class TestLabels(InvenTreeAPITestCase):
|
||||
"""
|
||||
Tests for the label APIs
|
||||
"""
|
||||
|
||||
fixtures = [
|
||||
'category',
|
||||
'part',
|
||||
'location',
|
||||
'stock',
|
||||
]
|
||||
|
||||
roles = [
|
||||
'stock.view',
|
||||
'stock_location.view',
|
||||
]
|
||||
|
||||
def do_list(self, filters={}):
|
||||
|
||||
response = self.client.get(self.list_url, filters, format='json')
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
return response.data
|
||||
|
||||
def test_lists(self):
|
||||
self.list_url = reverse('api-stockitem-label-list')
|
||||
self.do_list()
|
||||
|
||||
self.list_url = reverse('api-stocklocation-label-list')
|
||||
self.do_list()
|
||||
|
||||
self.list_url = reverse('api-part-label-list')
|
||||
self.do_list()
|
||||
|
@ -7,6 +7,7 @@ import os
|
||||
|
||||
from django.test import TestCase
|
||||
from django.conf import settings
|
||||
from django.apps import apps
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
from InvenTree.helpers import validateFilterString
|
||||
@ -17,8 +18,11 @@ from stock.models import StockItem
|
||||
|
||||
class LabelTest(TestCase):
|
||||
|
||||
# TODO - Implement this test properly. Looks like apps.py is not run first
|
||||
def _test_default_labels(self):
|
||||
def setUp(self) -> None:
|
||||
# ensure the labels were created
|
||||
apps.get_app_config('label').create_labels()
|
||||
|
||||
def test_default_labels(self):
|
||||
"""
|
||||
Test that the default label templates are copied across
|
||||
"""
|
||||
@ -31,8 +35,7 @@ class LabelTest(TestCase):
|
||||
|
||||
self.assertTrue(labels.count() > 0)
|
||||
|
||||
# TODO - Implement this test properly. Looks like apps.py is not run first
|
||||
def _test_default_files(self):
|
||||
def test_default_files(self):
|
||||
"""
|
||||
Test that label files exist in the MEDIA directory
|
||||
"""
|
||||
|
@ -6,12 +6,12 @@ if __name__ == "__main__":
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "InvenTree.settings")
|
||||
try:
|
||||
from django.core.management import execute_from_command_line
|
||||
except ImportError:
|
||||
except ImportError: # pragma: no cover
|
||||
# The above import may fail for some other reason. Ensure that the
|
||||
# issue is really that Django is missing to avoid masking other
|
||||
# exceptions on Python 2.
|
||||
try:
|
||||
import django # NOQA
|
||||
import django # noqa: F401
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
"Couldn't import Django. Are you sure it's installed and "
|
||||
|
@ -20,7 +20,7 @@ def build_refs(apps, schema_editor):
|
||||
if result and len(result.groups()) == 1:
|
||||
try:
|
||||
ref = int(result.groups()[0])
|
||||
except:
|
||||
except: # pragma: no cover
|
||||
ref = 0
|
||||
|
||||
order.reference_int = ref
|
||||
@ -37,14 +37,14 @@ def build_refs(apps, schema_editor):
|
||||
if result and len(result.groups()) == 1:
|
||||
try:
|
||||
ref = int(result.groups()[0])
|
||||
except:
|
||||
except: # pragma: no cover
|
||||
ref = 0
|
||||
|
||||
order.reference_int = ref
|
||||
order.save()
|
||||
|
||||
|
||||
def unbuild_refs(apps, schema_editor):
|
||||
def unbuild_refs(apps, schema_editor): # pragma: no cover
|
||||
"""
|
||||
Provided only for reverse migration compatibility
|
||||
"""
|
||||
|
@ -33,7 +33,7 @@ def add_shipment(apps, schema_editor):
|
||||
line__order=order
|
||||
)
|
||||
|
||||
if allocations.count() == 0 and order.status != SalesOrderStatus.PENDING:
|
||||
if allocations.count() == 0 and order.status != SalesOrderStatus.PENDING: # pragma: no cover
|
||||
continue
|
||||
|
||||
# Create a new Shipment instance against this order
|
||||
@ -41,13 +41,13 @@ def add_shipment(apps, schema_editor):
|
||||
order=order,
|
||||
)
|
||||
|
||||
if order.status == SalesOrderStatus.SHIPPED:
|
||||
if order.status == SalesOrderStatus.SHIPPED: # pragma: no cover
|
||||
shipment.shipment_date = order.shipment_date
|
||||
|
||||
shipment.save()
|
||||
|
||||
# Iterate through each allocation associated with this order
|
||||
for allocation in allocations:
|
||||
for allocation in allocations: # pragma: no cover
|
||||
allocation.shipment = shipment
|
||||
allocation.save()
|
||||
|
||||
@ -57,7 +57,7 @@ def add_shipment(apps, schema_editor):
|
||||
print(f"\nCreated SalesOrderShipment for {n} SalesOrder instances")
|
||||
|
||||
|
||||
def reverse_add_shipment(apps, schema_editor):
|
||||
def reverse_add_shipment(apps, schema_editor): # pragma: no cover
|
||||
"""
|
||||
Reverse the migration, delete and SalesOrderShipment instances
|
||||
"""
|
||||
|
@ -22,7 +22,7 @@ def calculate_shipped_quantity(apps, schema_editor):
|
||||
StockItem = apps.get_model('stock', 'stockitem')
|
||||
SalesOrderLineItem = apps.get_model('order', 'salesorderlineitem')
|
||||
|
||||
for item in SalesOrderLineItem.objects.all():
|
||||
for item in SalesOrderLineItem.objects.all(): # pragma: no cover
|
||||
|
||||
if item.order.status == SalesOrderStatus.SHIPPED:
|
||||
item.shipped = item.quantity
|
||||
@ -40,7 +40,7 @@ def calculate_shipped_quantity(apps, schema_editor):
|
||||
item.save()
|
||||
|
||||
|
||||
def reverse_calculate_shipped_quantity(apps, schema_editor):
|
||||
def reverse_calculate_shipped_quantity(apps, schema_editor): # pragma: no cover
|
||||
"""
|
||||
Provided only for reverse migration compatibility.
|
||||
This function does nothing.
|
||||
|
@ -1,99 +1,2 @@
|
||||
{% extends "order/order_wizard/po_upload.html" %}
|
||||
{% load inventree_extras %}
|
||||
{% load i18n %}
|
||||
{% load static %}
|
||||
|
||||
{% block form_alert %}
|
||||
{% if missing_columns and missing_columns|length > 0 %}
|
||||
<div class='alert alert-danger alert-block' role='alert'>
|
||||
{% trans "Missing selections for the following required columns" %}:
|
||||
<br>
|
||||
<ul>
|
||||
{% for col in missing_columns %}
|
||||
<li>{{ col }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if duplicates and duplicates|length > 0 %}
|
||||
<div class='alert alert-danger alert-block' role='alert'>
|
||||
{% trans "Duplicate selections found, see below. Fix them then retry submitting." %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock form_alert %}
|
||||
|
||||
{% block form_buttons_top %}
|
||||
{% if wizard.steps.prev %}
|
||||
<button name="wizard_goto_step" type="submit" value="{{ wizard.steps.prev }}" class="save btn btn-outline-secondary">{% trans "Previous Step" %}</button>
|
||||
{% endif %}
|
||||
<button type="submit" class="save btn btn-outline-secondary">{% trans "Submit Selections" %}</button>
|
||||
{% endblock form_buttons_top %}
|
||||
|
||||
{% block form_content %}
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{% trans "File Fields" %}</th>
|
||||
<th></th>
|
||||
{% for col in form %}
|
||||
<th>
|
||||
<div>
|
||||
<input type='hidden' name='col_name_{{ forloop.counter0 }}' value='{{ col.name }}'/>
|
||||
{{ col.name }}
|
||||
<button class='btn btn-outline-secondary btn-remove' onClick='removeColFromBomWizard()' id='del_col_{{ forloop.counter0 }}' style='display: inline; float: right;' title='{% trans "Remove column" %}'>
|
||||
<span col_id='{{ forloop.counter0 }}' class='fas fa-trash-alt icon-red'></span>
|
||||
</button>
|
||||
</div>
|
||||
</th>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>{% trans "Match Fields" %}</td>
|
||||
<td></td>
|
||||
{% for col in form %}
|
||||
<td>
|
||||
{{ col }}
|
||||
{% for duplicate in duplicates %}
|
||||
{% if duplicate == col.value %}
|
||||
<div class='alert alert-danger alert-block text-center' role='alert' style='padding:2px; margin-top:6px; margin-bottom:2px'>
|
||||
<strong>{% trans "Duplicate selection" %}</strong>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</td>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
{% for row in rows %}
|
||||
{% with forloop.counter as row_index %}
|
||||
<tr>
|
||||
<td style='width: 32px;'>
|
||||
<button class='btn btn-outline-secondary btn-remove' onClick='removeRowFromBomWizard()' id='del_row_{{ row_index }}' style='display: inline; float: left;' title='{% trans "Remove row" %}'>
|
||||
<span row_id='{{ row_index }}' class='fas fa-trash-alt icon-red'></span>
|
||||
</button>
|
||||
</td>
|
||||
<td style='text-align: left;'>{{ row_index }}</td>
|
||||
{% for item in row.data %}
|
||||
<td>
|
||||
<input type='hidden' name='row_{{ row_index }}_col_{{ forloop.counter0 }}' value='{{ item }}'/>
|
||||
{{ item }}
|
||||
</td>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
{% endwith %}
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
{% endblock form_content %}
|
||||
|
||||
{% block form_buttons_bottom %}
|
||||
{% endblock form_buttons_bottom %}
|
||||
|
||||
{% block js_ready %}
|
||||
{{ block.super }}
|
||||
|
||||
$('.fieldselect').select2({
|
||||
width: '100%',
|
||||
matcher: partialMatcher,
|
||||
});
|
||||
|
||||
{% endblock %}
|
||||
{% include "patterns/wizard/match_fields.html" %}
|
@ -10,54 +10,11 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block page_content %}
|
||||
|
||||
<div class='panel' id='panel-upload-file'>
|
||||
<div class='panel-heading'>
|
||||
{% block heading %}
|
||||
<h4>{% trans "Upload File for Purchase Order" %}</h4>
|
||||
{{ wizard.form.media }}
|
||||
{% endblock %}
|
||||
</div>
|
||||
<div class='panel-content'>
|
||||
{% block details %}
|
||||
{% if order.status == PurchaseOrderStatus.PENDING and roles.purchase_order.change %}
|
||||
|
||||
<p>{% blocktrans with step=wizard.steps.step1 count=wizard.steps.count %}Step {{step}} of {{count}}{% endblocktrans %}
|
||||
{% if description %}- {{ description }}{% endif %}</p>
|
||||
|
||||
{% block form_alert %}
|
||||
{% endblock form_alert %}
|
||||
|
||||
<form action="" method="post" class='js-modal-form' enctype="multipart/form-data">
|
||||
{% csrf_token %}
|
||||
{% load crispy_forms_tags %}
|
||||
|
||||
{% block form_buttons_top %}
|
||||
{% endblock form_buttons_top %}
|
||||
|
||||
<table class='table table-striped' style='margin-top: 12px; margin-bottom: 0px'>
|
||||
{{ wizard.management_form }}
|
||||
{% block form_content %}
|
||||
{% crispy wizard.form %}
|
||||
{% endblock form_content %}
|
||||
</table>
|
||||
|
||||
{% block form_buttons_bottom %}
|
||||
{% if wizard.steps.prev %}
|
||||
<button name="wizard_goto_step" type="submit" value="{{ wizard.steps.prev }}" class="save btn btn-outline-secondary">{% trans "Previous Step" %}</button>
|
||||
{% endif %}
|
||||
<button type="submit" class="save btn btn-outline-secondary">{% trans "Upload File" %}</button>
|
||||
</form>
|
||||
{% endblock form_buttons_bottom %}
|
||||
|
||||
{% else %}
|
||||
<div class='alert alert-danger alert-block' role='alert'>
|
||||
{% trans "Order is already processed. Files cannot be uploaded." %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock details %}
|
||||
</div>
|
||||
|
||||
{% trans "Upload File for Purchase Order" as header_text %}
|
||||
{% order.status == PurchaseOrderStatus.PENDING and roles.purchase_order.change as upload_go_ahead %}
|
||||
{% trans "Order is already processed. Files cannot be uploaded." as error_text %}
|
||||
{% "panel-upload-file" as panel_id %}
|
||||
{% include "patterns/wizard/upload.html" with header_text=header_text upload_go_ahead=upload_go_ahead error_text=error_text panel_id=panel_id %}
|
||||
{% endblock %}
|
||||
|
||||
{% block js_ready %}
|
||||
|
@ -40,6 +40,6 @@ class PartConfig(AppConfig):
|
||||
item.part.trackable = True
|
||||
item.part.clean()
|
||||
item.part.save()
|
||||
except (OperationalError, ProgrammingError):
|
||||
except (OperationalError, ProgrammingError): # pragma: no cover
|
||||
# Exception if the database has not been migrated yet
|
||||
pass
|
||||
|
@ -11,7 +11,7 @@ import InvenTree.validators
|
||||
import part.models
|
||||
|
||||
|
||||
def attach_file(instance, filename):
|
||||
def attach_file(instance, filename): # pragma: no cover
|
||||
"""
|
||||
Generate a filename for the uploaded attachment.
|
||||
|
||||
|
@ -10,7 +10,7 @@ def update_tree(apps, schema_editor):
|
||||
Part.objects.rebuild()
|
||||
|
||||
|
||||
def nupdate_tree(apps, schema_editor):
|
||||
def nupdate_tree(apps, schema_editor): # pragma: no cover
|
||||
pass
|
||||
|
||||
|
||||
|
@ -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.
|
||||
|
||||
|
@ -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):
|
||||
|
@ -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:
|
||||
|
@ -1,99 +1,2 @@
|
||||
{% extends "part/import_wizard/part_upload.html" %}
|
||||
{% load inventree_extras %}
|
||||
{% load i18n %}
|
||||
{% load static %}
|
||||
|
||||
{% block form_alert %}
|
||||
{% if missing_columns and missing_columns|length > 0 %}
|
||||
<div class='alert alert-danger alert-block' role='alert'>
|
||||
{% trans "Missing selections for the following required columns" %}:
|
||||
<br>
|
||||
<ul>
|
||||
{% for col in missing_columns %}
|
||||
<li>{{ col }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if duplicates and duplicates|length > 0 %}
|
||||
<div class='alert alert-danger alert-block' role='alert'>
|
||||
{% trans "Duplicate selections found, see below. Fix them then retry submitting." %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock form_alert %}
|
||||
|
||||
{% block form_buttons_top %}
|
||||
{% if wizard.steps.prev %}
|
||||
<button name="wizard_goto_step" type="submit" value="{{ wizard.steps.prev }}" class="save btn btn-outline-secondary">{% trans "Previous Step" %}</button>
|
||||
{% endif %}
|
||||
<button type="submit" class="save btn btn-outline-secondary">{% trans "Submit Selections" %}</button>
|
||||
{% endblock form_buttons_top %}
|
||||
|
||||
{% block form_content %}
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{% trans "File Fields" %}</th>
|
||||
<th></th>
|
||||
{% for col in form %}
|
||||
<th>
|
||||
<div>
|
||||
<input type='hidden' name='col_name_{{ forloop.counter0 }}' value='{{ col.name }}'/>
|
||||
{{ col.name }}
|
||||
<button class='btn btn-outline-secondary btn-remove' onClick='removeColFromBomWizard()' id='del_col_{{ forloop.counter0 }}' style='display: inline; float: right;' title='{% trans "Remove column" %}'>
|
||||
<span col_id='{{ forloop.counter0 }}' class='fas fa-trash-alt icon-red'></span>
|
||||
</button>
|
||||
</div>
|
||||
</th>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>{% trans "Match Fields" %}</td>
|
||||
<td></td>
|
||||
{% for col in form %}
|
||||
<td>
|
||||
{{ col }}
|
||||
{% for duplicate in duplicates %}
|
||||
{% if duplicate == col.value %}
|
||||
<div class='alert alert-danger alert-block text-center' role='alert' style='padding:2px; margin-top:6px; margin-bottom:2px'>
|
||||
<strong>{% trans "Duplicate selection" %}</strong>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</td>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
{% for row in rows %}
|
||||
{% with forloop.counter as row_index %}
|
||||
<tr>
|
||||
<td style='width: 32px;'>
|
||||
<button class='btn btn-outline-secondary btn-remove' onClick='removeRowFromBomWizard()' id='del_row_{{ row_index }}' style='display: inline; float: left;' title='{% trans "Remove row" %}'>
|
||||
<span row_id='{{ row_index }}' class='fas fa-trash-alt icon-red'></span>
|
||||
</button>
|
||||
</td>
|
||||
<td style='text-align: left;'>{{ row_index }}</td>
|
||||
{% for item in row.data %}
|
||||
<td>
|
||||
<input type='hidden' name='row_{{ row_index }}_col_{{ forloop.counter0 }}' value='{{ item }}'/>
|
||||
{{ item }}
|
||||
</td>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
{% endwith %}
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
{% endblock form_content %}
|
||||
|
||||
{% block form_buttons_bottom %}
|
||||
{% endblock form_buttons_bottom %}
|
||||
|
||||
{% block js_ready %}
|
||||
{{ block.super }}
|
||||
|
||||
$('.fieldselect').select2({
|
||||
width: '100%',
|
||||
matcher: partialMatcher,
|
||||
});
|
||||
|
||||
{% endblock %}
|
||||
{% include "patterns/wizard/match_fields.html" %}
|
@ -10,51 +10,10 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class='panel'>
|
||||
<div class='panel-heading'>
|
||||
<h4>
|
||||
{% trans "Import Parts from File" %}
|
||||
{{ wizard.form.media }}
|
||||
</h4>
|
||||
</div>
|
||||
<div class='panel-content'>
|
||||
{% if roles.part.change %}
|
||||
|
||||
<p>{% blocktrans with step=wizard.steps.step1 count=wizard.steps.count %}Step {{step}} of {{count}}{% endblocktrans %}
|
||||
{% if description %}- {{ description }}{% endif %}</p>
|
||||
|
||||
{% block form_alert %}
|
||||
{% endblock form_alert %}
|
||||
|
||||
<form action="" method="post" class='js-modal-form' enctype="multipart/form-data">
|
||||
{% csrf_token %}
|
||||
{% load crispy_forms_tags %}
|
||||
|
||||
{% block form_buttons_top %}
|
||||
{% endblock form_buttons_top %}
|
||||
|
||||
<table class='table table-striped' style='margin-top: 12px; margin-bottom: 0px'>
|
||||
{{ wizard.management_form }}
|
||||
{% block form_content %}
|
||||
{% crispy wizard.form %}
|
||||
{% endblock form_content %}
|
||||
</table>
|
||||
|
||||
{% block form_buttons_bottom %}
|
||||
{% if wizard.steps.prev %}
|
||||
<button name="wizard_goto_step" type="submit" value="{{ wizard.steps.prev }}" class="save btn btn-outline-secondary">{% trans "Previous Step" %}</button>
|
||||
{% endif %}
|
||||
<button type="submit" class="save btn btn-outline-secondary">{% trans "Upload File" %}</button>
|
||||
</form>
|
||||
{% endblock form_buttons_bottom %}
|
||||
|
||||
{% else %}
|
||||
<div class='alert alert-danger alert-block' role='alert'>
|
||||
{% trans "Unsuffitient privileges." %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% trans "Import Parts from File" as header_text %}
|
||||
{% roles.part.change as upload_go_ahead %}
|
||||
{% trans "Unsuffitient privileges." as error_text %}
|
||||
{% include "patterns/wizard/upload.html" with header_text=header_text upload_go_ahead=upload_go_ahead error_text=error_text %}
|
||||
{% endblock %}
|
||||
|
||||
{% block js_ready %}
|
||||
|
@ -14,7 +14,7 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block breadcrumbs %}
|
||||
<a href='#' id='breadcrumb-tree-toggle' class="breadcrumb-item"><i class="fas fa-bars"></i></a>
|
||||
<a href='#' id='breadcrumb-tree-toggle' class="breadcrumb-item"><span class="fas fa-bars"></span></a>
|
||||
{% if part %}
|
||||
{% include "part/cat_link.html" with category=part.category part=part %}
|
||||
{% else %}
|
||||
|
@ -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);
|
||||
|
@ -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):
|
||||
"""
|
||||
|
@ -55,7 +55,7 @@ class BomItemTest(TestCase):
|
||||
with self.assertRaises(django_exceptions.ValidationError):
|
||||
# A validation error should be raised here
|
||||
item = BomItem.objects.create(part=self.bob, sub_part=self.bob, quantity=7)
|
||||
item.clean()
|
||||
item.clean() # pragma: no cover
|
||||
|
||||
def test_integer_quantity(self):
|
||||
"""
|
||||
|
@ -127,7 +127,7 @@ class CategoryTest(TestCase):
|
||||
|
||||
with self.assertRaises(ValidationError) as err:
|
||||
cat.full_clean()
|
||||
cat.save()
|
||||
cat.save() # pragma: no cover
|
||||
|
||||
self.assertIn('Illegal character in name', str(err.exception.error_dict.get('name')))
|
||||
|
||||
@ -160,10 +160,6 @@ class CategoryTest(TestCase):
|
||||
|
||||
self.assertEqual(str(self.fasteners.default_location), 'Office/Drawer_1 - In my desk')
|
||||
|
||||
# Test that parts in this location return the same default location, too
|
||||
for p in self.fasteners.children.all():
|
||||
self.assert_equal(p.get_default_location().pathstring, 'Office/Drawer_1')
|
||||
|
||||
# Any part under electronics should default to 'Home'
|
||||
r1 = Part.objects.get(name='R_2K2_0805')
|
||||
self.assertIsNone(r1.default_location)
|
||||
|
@ -44,7 +44,7 @@ class TestParams(TestCase):
|
||||
with self.assertRaises(django_exceptions.ValidationError):
|
||||
t3 = PartParameterTemplate(name='aBcde', units='dd')
|
||||
t3.full_clean()
|
||||
t3.save()
|
||||
t3.save() # pragma: no cover
|
||||
|
||||
|
||||
class TestCategoryTemplates(TransactionTestCase):
|
||||
|
@ -111,7 +111,7 @@ class PartTest(TestCase):
|
||||
|
||||
try:
|
||||
part.save()
|
||||
self.assertTrue(False)
|
||||
self.assertTrue(False) # pragma: no cover
|
||||
except:
|
||||
pass
|
||||
|
||||
|
@ -21,7 +21,7 @@ class PluginAppConfig(AppConfig):
|
||||
def ready(self):
|
||||
if settings.PLUGINS_ENABLED:
|
||||
|
||||
if isImportingData():
|
||||
if isImportingData(): # pragma: no cover
|
||||
logger.info('Skipping plugin loading for data import')
|
||||
else:
|
||||
logger.info('Loading InvenTree plugins')
|
||||
|
@ -51,7 +51,7 @@ class SettingsMixin:
|
||||
|
||||
try:
|
||||
plugin, _ = PluginConfig.objects.get_or_create(key=self.plugin_slug(), name=self.plugin_name())
|
||||
except (OperationalError, ProgrammingError):
|
||||
except (OperationalError, ProgrammingError): # pragma: no cover
|
||||
plugin = None
|
||||
|
||||
if not plugin:
|
||||
|
@ -23,7 +23,7 @@ class IntegrationPluginError(Exception):
|
||||
self.message = message
|
||||
|
||||
def __str__(self):
|
||||
return self.message
|
||||
return self.message # pragma: no cover
|
||||
|
||||
|
||||
class MixinImplementationError(ValueError):
|
||||
@ -55,7 +55,7 @@ def log_error(error, reference: str = 'general'):
|
||||
registry.errors[reference].append(error)
|
||||
|
||||
|
||||
def handle_error(error, do_raise: bool = True, do_log: bool = True, do_return: bool = False, log_name: str = ''):
|
||||
def handle_error(error, do_raise: bool = True, do_log: bool = True, log_name: str = ''):
|
||||
"""
|
||||
Handles an error and casts it as an IntegrationPluginError
|
||||
"""
|
||||
@ -69,7 +69,7 @@ def handle_error(error, do_raise: bool = True, do_log: bool = True, do_return: b
|
||||
path_parts = [*path_obj.parts]
|
||||
path_parts[-1] = path_parts[-1].replace(path_obj.suffix, '') # remove suffix
|
||||
|
||||
# remove path preixes
|
||||
# remove path prefixes
|
||||
if path_parts[0] == 'plugin':
|
||||
path_parts.remove('plugin')
|
||||
path_parts.pop(0)
|
||||
@ -84,13 +84,8 @@ def handle_error(error, do_raise: bool = True, do_log: bool = True, do_return: b
|
||||
log_kwargs['reference'] = log_name
|
||||
log_error({package_name: str(error)}, **log_kwargs)
|
||||
|
||||
new_error = IntegrationPluginError(package_name, str(error))
|
||||
|
||||
if do_raise:
|
||||
raise IntegrationPluginError(package_name, str(error))
|
||||
|
||||
if do_return:
|
||||
return new_error
|
||||
# endregion
|
||||
|
||||
|
||||
@ -101,14 +96,16 @@ def get_git_log(path):
|
||||
"""
|
||||
path = path.replace(os.path.dirname(settings.BASE_DIR), '')[1:]
|
||||
command = ['git', 'log', '-n', '1', "--pretty=format:'%H%n%aN%n%aE%n%aI%n%f%n%G?%n%GK'", '--follow', '--', path]
|
||||
output = None
|
||||
try:
|
||||
output = str(subprocess.check_output(command, cwd=os.path.dirname(settings.BASE_DIR)), 'utf-8')[1:-1]
|
||||
if output:
|
||||
output = output.split('\n')
|
||||
else:
|
||||
output = 7 * ['']
|
||||
except subprocess.CalledProcessError:
|
||||
output = 7 * ['']
|
||||
except subprocess.CalledProcessError: # pragma: no cover
|
||||
pass
|
||||
|
||||
if not output:
|
||||
output = 7 * [''] # pragma: no cover
|
||||
return {'hash': output[0], 'author': output[1], 'mail': output[2], 'date': output[3], 'message': output[4], 'verified': output[5], 'key': output[6]}
|
||||
|
||||
|
||||
@ -153,7 +150,7 @@ def get_modules(pkg):
|
||||
if not k.startswith('_') and (pkg_names is None or k in pkg_names):
|
||||
context[k] = v
|
||||
context[name] = module
|
||||
except AppRegistryNotReady:
|
||||
except AppRegistryNotReady: # pragma: no cover
|
||||
pass
|
||||
except Exception as error:
|
||||
# this 'protects' against malformed plugin modules by more or less silently failing
|
||||
|
@ -135,7 +135,7 @@ class IntegrationPluginBase(MixinBase, plugin_base.InvenTreePluginBase):
|
||||
if not author:
|
||||
author = self.package.get('author')
|
||||
if not author:
|
||||
author = _('No author found')
|
||||
author = _('No author found') # pragma: no cover
|
||||
return author
|
||||
|
||||
@property
|
||||
@ -149,7 +149,7 @@ class IntegrationPluginBase(MixinBase, plugin_base.InvenTreePluginBase):
|
||||
else:
|
||||
pub_date = datetime.fromisoformat(str(pub_date))
|
||||
if not pub_date:
|
||||
pub_date = _('No date found')
|
||||
pub_date = _('No date found') # pragma: no cover
|
||||
return pub_date
|
||||
|
||||
@property
|
||||
@ -226,7 +226,7 @@ class IntegrationPluginBase(MixinBase, plugin_base.InvenTreePluginBase):
|
||||
"""
|
||||
Get package metadata for plugin
|
||||
"""
|
||||
return {}
|
||||
return {} # pragma: no cover # TODO add usage for package metadata
|
||||
|
||||
def define_package(self):
|
||||
"""
|
||||
@ -241,11 +241,11 @@ class IntegrationPluginBase(MixinBase, plugin_base.InvenTreePluginBase):
|
||||
# process sign state
|
||||
sign_state = getattr(GitStatus, str(package.get('verified')), GitStatus.N)
|
||||
if sign_state.status == 0:
|
||||
self.sign_color = 'success'
|
||||
self.sign_color = 'success' # pragma: no cover
|
||||
elif sign_state.status == 1:
|
||||
self.sign_color = 'warning'
|
||||
else:
|
||||
self.sign_color = 'danger'
|
||||
self.sign_color = 'danger' # pragma: no cover
|
||||
|
||||
# set variables
|
||||
self.package = package
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
||||
|
@ -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()
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
||||
|
@ -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)
|
||||
|
@ -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
|
||||
|
@ -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):
|
||||
|
@ -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!
|
||||
"""
|
||||
|
@ -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
|
||||
"""
|
||||
|
@ -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
|
||||
|
||||
|
||||
|
@ -1,2 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
@ -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 %}
|
||||
|
||||
|
@ -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 %}
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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>
|
||||
|
@ -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 %}
|
||||
|
@ -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" %}
|
||||
|
@ -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
|
||||
|
||||
|
@ -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) {
|
||||
|
98
InvenTree/templates/patterns/wizard/match_fields.html
Normal file
98
InvenTree/templates/patterns/wizard/match_fields.html
Normal 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 %}
|
45
InvenTree/templates/patterns/wizard/upload.html
Normal file
45
InvenTree/templates/patterns/wizard/upload.html
Normal 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>
|
@ -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>
|
||||
|
@ -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 %}
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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()
|
||||
|
@ -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()
|
||||
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
|
Loading…
Reference in New Issue
Block a user