From ed5c23fcead62b114a12f55740f7530760087338 Mon Sep 17 00:00:00 2001 From: Matthias Date: Mon, 28 Feb 2022 18:50:16 +0100 Subject: [PATCH 01/66] Add template defaults --- InvenTree/config_template.yaml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/InvenTree/config_template.yaml b/InvenTree/config_template.yaml index b14c1224ce..153cf2f16b 100644 --- a/InvenTree/config_template.yaml +++ b/InvenTree/config_template.yaml @@ -154,6 +154,11 @@ static_root: '/home/inventree/data/static' # Use environment variable INVENTREE_LOGIN_ATTEMPTS #login_attempts: 5 +# Add new user on first startup +#set_user: admin +#set_email: info@example.com +#set_password: inventree + # Permit custom authentication backends #authentication_backends: # - 'django.contrib.auth.backends.ModelBackend' From a42674256738acd06f4cb196fa1ec8ac39f49095 Mon Sep 17 00:00:00 2001 From: Matthias Date: Mon, 28 Feb 2022 19:08:52 +0100 Subject: [PATCH 02/66] [FR] Non-interactive, environment variable based set-up in Docker deployments Fixes #2501 --- InvenTree/InvenTree/apps.py | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/InvenTree/InvenTree/apps.py b/InvenTree/InvenTree/apps.py index 76b918459c..667da9b185 100644 --- a/InvenTree/InvenTree/apps.py +++ b/InvenTree/InvenTree/apps.py @@ -4,8 +4,11 @@ import logging from django.apps import AppConfig from django.core.exceptions import AppRegistryNotReady +from django.conf import settings +from django.contrib.auth import get_user_model from InvenTree.ready import isInTestMode, canAppAccessDatabase +from .config import get_setting import InvenTree.tasks @@ -26,6 +29,8 @@ class InvenTreeConfig(AppConfig): if not isInTestMode(): self.update_exchange_rates() + self.add_user_on_startup() + def remove_obsolete_tasks(self): """ Delete any obsolete scheduled tasks in the database @@ -138,3 +143,33 @@ class InvenTreeConfig(AppConfig): update_exchange_rates() except Exception as e: logger.error(f"Error updating exchange rates: {e}") + + def add_user_on_startup(self): + """Add a user on startup""" + + # get values + add_user = get_setting( + 'INVENTREE_SET_USER', + settings.CONFIG.get('set_user', False) + ) + add_email = get_setting( + 'INVENTREE_SET_EMAIL', + settings.CONFIG.get('set_email', False) + ) + add_password = get_setting( + 'INVENTREE_SET_PASSWORD', + settings.CONFIG.get('set_password', False) + ) + + # check if all values are present + if not (add_user and add_email and add_password): + logger.warn('Not all required settings for adding a user on startup are present:\nINVENTREE_SET_USER, INVENTREE_SET_EMAIL, INVENTREE_SET_PASSWORD') + return + + # create user + user = get_user_model() + try: + new_user = user.objects.create_user(add_user, add_email, add_password) + logger.info(f'User {str(new_user)} was created!') + except Exception as _e: + print(_e) From 28012a3ceadc563d176a04d271bfdff6db86ac40 Mon Sep 17 00:00:00 2001 From: Matthias Date: Mon, 28 Feb 2022 19:19:10 +0100 Subject: [PATCH 03/66] only add once --- InvenTree/InvenTree/apps.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/InvenTree/InvenTree/apps.py b/InvenTree/InvenTree/apps.py index 667da9b185..e984e4f178 100644 --- a/InvenTree/InvenTree/apps.py +++ b/InvenTree/InvenTree/apps.py @@ -146,6 +146,9 @@ class InvenTreeConfig(AppConfig): def add_user_on_startup(self): """Add a user on startup""" + # stop if already created + if hasattr(settings, 'USER_ADDED') and settings.USER_ADDED: + return # get values add_user = get_setting( @@ -171,5 +174,6 @@ class InvenTreeConfig(AppConfig): try: new_user = user.objects.create_user(add_user, add_email, add_password) logger.info(f'User {str(new_user)} was created!') + settings.USER_ADDED = True except Exception as _e: print(_e) From ac2d54a1501420815d32bec43c0c03e91ae634ff Mon Sep 17 00:00:00 2001 From: Matthias Date: Mon, 28 Feb 2022 19:29:54 +0100 Subject: [PATCH 04/66] catch uniqueness --- InvenTree/InvenTree/apps.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/InvenTree/InvenTree/apps.py b/InvenTree/InvenTree/apps.py index e984e4f178..c15a5c4d0a 100644 --- a/InvenTree/InvenTree/apps.py +++ b/InvenTree/InvenTree/apps.py @@ -6,6 +6,7 @@ from django.apps import AppConfig from django.core.exceptions import AppRegistryNotReady from django.conf import settings from django.contrib.auth import get_user_model +from django.db.utils import IntegrityError from InvenTree.ready import isInTestMode, canAppAccessDatabase from .config import get_setting @@ -174,6 +175,10 @@ class InvenTreeConfig(AppConfig): try: new_user = user.objects.create_user(add_user, add_email, add_password) logger.info(f'User {str(new_user)} was created!') - settings.USER_ADDED = True + except IntegrityError as _e: + logger.warning(f'The user "{add_user}" could not be created due to the following error:\n{str(_e)}') except Exception as _e: - print(_e) + raise _e + + # do not try again this round + settings.USER_ADDED = True From 9552482471136c1244c882786b00db0a95edfcbf Mon Sep 17 00:00:00 2001 From: Matthias Date: Mon, 28 Feb 2022 19:38:14 +0100 Subject: [PATCH 05/66] fix initial checks --- InvenTree/InvenTree/apps.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/InvenTree/InvenTree/apps.py b/InvenTree/InvenTree/apps.py index c15a5c4d0a..fad6b2a9a5 100644 --- a/InvenTree/InvenTree/apps.py +++ b/InvenTree/InvenTree/apps.py @@ -166,7 +166,15 @@ class InvenTreeConfig(AppConfig): ) # check if all values are present - if not (add_user and add_email and add_password): + set_variables = 0 + for tested_var in [add_user, add_email, add_password]: + if tested_var: + set_variables += 1 + + if set_variables == 0: + return + + if set_variables < 3: logger.warn('Not all required settings for adding a user on startup are present:\nINVENTREE_SET_USER, INVENTREE_SET_EMAIL, INVENTREE_SET_PASSWORD') return From 63977d47ce684a039d514c806c04b4e7e89dc0b3 Mon Sep 17 00:00:00 2001 From: Matthias Date: Mon, 28 Feb 2022 19:41:23 +0100 Subject: [PATCH 06/66] only test once --- InvenTree/InvenTree/apps.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/InvenTree/InvenTree/apps.py b/InvenTree/InvenTree/apps.py index fad6b2a9a5..c209ac4203 100644 --- a/InvenTree/InvenTree/apps.py +++ b/InvenTree/InvenTree/apps.py @@ -172,10 +172,12 @@ class InvenTreeConfig(AppConfig): set_variables += 1 if set_variables == 0: + settings.USER_ADDED = True return if set_variables < 3: logger.warn('Not all required settings for adding a user on startup are present:\nINVENTREE_SET_USER, INVENTREE_SET_EMAIL, INVENTREE_SET_PASSWORD') + settings.USER_ADDED = True return # create user From 7d9edaea8bd87c5e7bf446e3db27ddb05797aece Mon Sep 17 00:00:00 2001 From: Matthias Date: Mon, 28 Feb 2022 19:43:00 +0100 Subject: [PATCH 07/66] add docstrings --- InvenTree/InvenTree/apps.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/InvenTree/InvenTree/apps.py b/InvenTree/InvenTree/apps.py index c209ac4203..c970315dbd 100644 --- a/InvenTree/InvenTree/apps.py +++ b/InvenTree/InvenTree/apps.py @@ -171,16 +171,18 @@ class InvenTreeConfig(AppConfig): if tested_var: set_variables += 1 + # no variable set -> do not try anything if set_variables == 0: settings.USER_ADDED = True return + # not all needed variables set if set_variables < 3: logger.warn('Not all required settings for adding a user on startup are present:\nINVENTREE_SET_USER, INVENTREE_SET_EMAIL, INVENTREE_SET_PASSWORD') settings.USER_ADDED = True return - # create user + # good to go -> create user user = get_user_model() try: new_user = user.objects.create_user(add_user, add_email, add_password) From ad814391404233cee0a3649fd10479f21057aaf2 Mon Sep 17 00:00:00 2001 From: Matthias Date: Mon, 28 Feb 2022 20:29:04 +0100 Subject: [PATCH 08/66] add testss for user creation --- InvenTree/InvenTree/apps.py | 3 +++ InvenTree/InvenTree/tests.py | 48 ++++++++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+) diff --git a/InvenTree/InvenTree/apps.py b/InvenTree/InvenTree/apps.py index c970315dbd..a2757c6f58 100644 --- a/InvenTree/InvenTree/apps.py +++ b/InvenTree/InvenTree/apps.py @@ -30,6 +30,7 @@ class InvenTreeConfig(AppConfig): if not isInTestMode(): self.update_exchange_rates() + if canAppAccessDatabase() or settings.TESTING: self.add_user_on_startup() def remove_obsolete_tasks(self): @@ -189,6 +190,8 @@ class InvenTreeConfig(AppConfig): logger.info(f'User {str(new_user)} was created!') except IntegrityError as _e: logger.warning(f'The user "{add_user}" could not be created due to the following error:\n{str(_e)}') + if settings.TESTING: + raise _e except Exception as _e: raise _e diff --git a/InvenTree/InvenTree/tests.py b/InvenTree/InvenTree/tests.py index 205231eb7b..67b087f201 100644 --- a/InvenTree/InvenTree/tests.py +++ b/InvenTree/InvenTree/tests.py @@ -1,9 +1,13 @@ import json +from test.support import EnvironmentVarGuard from django.test import TestCase import django.core.exceptions as django_exceptions from django.core.exceptions import ValidationError +from django.contrib.auth import get_user_model +from django.conf import settings +from django.db.utils import IntegrityError from djmoney.money import Money from djmoney.contrib.exchange.models import Rate, convert_money @@ -407,3 +411,47 @@ class TestStatus(TestCase): def test_Importing(self): self.assertEqual(ready.isImportingData(), False) + + +class TestSettings(TestCase): + """ + Unit tests for settings + """ + + def setUp(self) -> None: + self.user_mdl = get_user_model() + self.env = EnvironmentVarGuard() + + def run_reload(self): + from plugin import registry + + with self.env: + settings.USER_ADDED = False + registry.reload_plugins() + + def test_set_user_to_few(self): + # add shortcut + user_count = self.user_mdl.objects.count + + # nothing set + self.assertEqual(user_count(), 0) + + # not enough set + self.env.set('INVENTREE_SET_USER', 'admin') # set username + self.run_reload() + self.assertEqual(user_count(), 0) + + # enough set + self.env.set('INVENTREE_SET_USER', 'admin') # set username + self.env.set('INVENTREE_SET_EMAIL', 'info@example.com') # set email + self.env.set('INVENTREE_SET_PASSWORD', 'password123') # set password + self.run_reload() + self.assertEqual(user_count(), 1) + + # double adding should not work + self.env.set('INVENTREE_SET_USER', 'admin') # set username + self.env.set('INVENTREE_SET_EMAIL', 'info@example.com') # set email + self.env.set('INVENTREE_SET_PASSWORD', 'password123') # set password + with self.assertRaises(IntegrityError): + self.run_reload() + self.assertEqual(user_count(), 1) From 23531e0a5d2fdccfefd06d0ac6382e00cbb3d5ee Mon Sep 17 00:00:00 2001 From: Matthias Date: Mon, 28 Feb 2022 20:29:45 +0100 Subject: [PATCH 09/66] remove dead code --- InvenTree/InvenTree/apps.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/InvenTree/InvenTree/apps.py b/InvenTree/InvenTree/apps.py index a2757c6f58..62afa9f3f2 100644 --- a/InvenTree/InvenTree/apps.py +++ b/InvenTree/InvenTree/apps.py @@ -192,8 +192,6 @@ class InvenTreeConfig(AppConfig): logger.warning(f'The user "{add_user}" could not be created due to the following error:\n{str(_e)}') if settings.TESTING: raise _e - except Exception as _e: - raise _e # do not try again this round settings.USER_ADDED = True From d6a42d64d8f26d9eb0f8c9494565796b89446bcb Mon Sep 17 00:00:00 2001 From: Matthias Date: Mon, 28 Feb 2022 20:33:10 +0100 Subject: [PATCH 10/66] make docs clearer --- InvenTree/InvenTree/apps.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/InvenTree/InvenTree/apps.py b/InvenTree/InvenTree/apps.py index 62afa9f3f2..295ab1b9f4 100644 --- a/InvenTree/InvenTree/apps.py +++ b/InvenTree/InvenTree/apps.py @@ -148,7 +148,7 @@ class InvenTreeConfig(AppConfig): def add_user_on_startup(self): """Add a user on startup""" - # stop if already created + # stop if checks were already created if hasattr(settings, 'USER_ADDED') and settings.USER_ADDED: return @@ -193,5 +193,5 @@ class InvenTreeConfig(AppConfig): if settings.TESTING: raise _e - # do not try again this round + # do not try again settings.USER_ADDED = True From 3b1bfddd8b85ed61d29b7ed7dae10a27d33b2f43 Mon Sep 17 00:00:00 2001 From: Matthias Date: Mon, 28 Feb 2022 20:33:36 +0100 Subject: [PATCH 11/66] also expect IntegrationsPluginError --- InvenTree/InvenTree/tests.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/InvenTree/InvenTree/tests.py b/InvenTree/InvenTree/tests.py index 67b087f201..66178e7322 100644 --- a/InvenTree/InvenTree/tests.py +++ b/InvenTree/InvenTree/tests.py @@ -25,6 +25,7 @@ import InvenTree.tasks from stock.models import StockLocation from common.settings import currency_codes +from plugin.helpers import IntegrationPluginError class ValidatorTest(TestCase): @@ -452,6 +453,6 @@ class TestSettings(TestCase): self.env.set('INVENTREE_SET_USER', 'admin') # set username self.env.set('INVENTREE_SET_EMAIL', 'info@example.com') # set email self.env.set('INVENTREE_SET_PASSWORD', 'password123') # set password - with self.assertRaises(IntegrityError): + with self.assertRaises(IntegrityError, IntegrationPluginError): self.run_reload() self.assertEqual(user_count(), 1) From 4e898d5eac38d51b5d0eadf993b7d237e2e855fa Mon Sep 17 00:00:00 2001 From: Matthias Date: Mon, 28 Feb 2022 20:42:20 +0100 Subject: [PATCH 12/66] fix test transactions --- InvenTree/InvenTree/apps.py | 4 +++- InvenTree/InvenTree/tests.py | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/InvenTree/InvenTree/apps.py b/InvenTree/InvenTree/apps.py index 295ab1b9f4..a290811b97 100644 --- a/InvenTree/InvenTree/apps.py +++ b/InvenTree/InvenTree/apps.py @@ -6,6 +6,7 @@ from django.apps import AppConfig from django.core.exceptions import AppRegistryNotReady from django.conf import settings from django.contrib.auth import get_user_model +from django.db import transaction from django.db.utils import IntegrityError from InvenTree.ready import isInTestMode, canAppAccessDatabase @@ -186,7 +187,8 @@ class InvenTreeConfig(AppConfig): # good to go -> create user user = get_user_model() try: - new_user = user.objects.create_user(add_user, add_email, add_password) + with transaction.atomic(): + new_user = user.objects.create_user(add_user, add_email, add_password) logger.info(f'User {str(new_user)} was created!') except IntegrityError as _e: logger.warning(f'The user "{add_user}" could not be created due to the following error:\n{str(_e)}') diff --git a/InvenTree/InvenTree/tests.py b/InvenTree/InvenTree/tests.py index 66178e7322..107a6fa317 100644 --- a/InvenTree/InvenTree/tests.py +++ b/InvenTree/InvenTree/tests.py @@ -453,6 +453,6 @@ class TestSettings(TestCase): self.env.set('INVENTREE_SET_USER', 'admin') # set username self.env.set('INVENTREE_SET_EMAIL', 'info@example.com') # set email self.env.set('INVENTREE_SET_PASSWORD', 'password123') # set password - with self.assertRaises(IntegrityError, IntegrationPluginError): + with self.assertRaises(IntegrationPluginError): self.run_reload() self.assertEqual(user_count(), 1) From 737d3977054d22fa125b5e7793d68a44da85fcb5 Mon Sep 17 00:00:00 2001 From: Matthias Date: Mon, 28 Feb 2022 20:44:13 +0100 Subject: [PATCH 13/66] PEP fix --- InvenTree/InvenTree/tests.py | 1 - 1 file changed, 1 deletion(-) diff --git a/InvenTree/InvenTree/tests.py b/InvenTree/InvenTree/tests.py index 107a6fa317..e9d40f5297 100644 --- a/InvenTree/InvenTree/tests.py +++ b/InvenTree/InvenTree/tests.py @@ -7,7 +7,6 @@ import django.core.exceptions as django_exceptions from django.core.exceptions import ValidationError from django.contrib.auth import get_user_model from django.conf import settings -from django.db.utils import IntegrityError from djmoney.money import Money from djmoney.contrib.exchange.models import Rate, convert_money From 12c42962612bd846d732eb7f6470aa6b9bb8f0ce Mon Sep 17 00:00:00 2001 From: Matthias Date: Mon, 28 Feb 2022 21:08:27 +0100 Subject: [PATCH 14/66] add special env testing flag --- InvenTree/InvenTree/apps.py | 4 ++-- InvenTree/InvenTree/settings.py | 1 + InvenTree/InvenTree/tests.py | 5 +++++ 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/InvenTree/InvenTree/apps.py b/InvenTree/InvenTree/apps.py index a290811b97..a6116f0ec2 100644 --- a/InvenTree/InvenTree/apps.py +++ b/InvenTree/InvenTree/apps.py @@ -31,7 +31,7 @@ class InvenTreeConfig(AppConfig): if not isInTestMode(): self.update_exchange_rates() - if canAppAccessDatabase() or settings.TESTING: + if canAppAccessDatabase() or settings.TESTING_ENV: self.add_user_on_startup() def remove_obsolete_tasks(self): @@ -192,7 +192,7 @@ class InvenTreeConfig(AppConfig): logger.info(f'User {str(new_user)} was created!') except IntegrityError as _e: logger.warning(f'The user "{add_user}" could not be created due to the following error:\n{str(_e)}') - if settings.TESTING: + if settings.TESTING_ENV: raise _e # do not try again diff --git a/InvenTree/InvenTree/settings.py b/InvenTree/InvenTree/settings.py index c45be06f4b..7291178e4a 100644 --- a/InvenTree/InvenTree/settings.py +++ b/InvenTree/InvenTree/settings.py @@ -37,6 +37,7 @@ def _is_true(x): # Determine if we are running in "test" mode e.g. "manage.py test" TESTING = 'test' in sys.argv +TESTING_ENV = False # New requirement for django 3.2+ DEFAULT_AUTO_FIELD = 'django.db.models.AutoField' diff --git a/InvenTree/InvenTree/tests.py b/InvenTree/InvenTree/tests.py index e9d40f5297..9014ee1591 100644 --- a/InvenTree/InvenTree/tests.py +++ b/InvenTree/InvenTree/tests.py @@ -432,6 +432,8 @@ class TestSettings(TestCase): def test_set_user_to_few(self): # add shortcut user_count = self.user_mdl.objects.count + # enable testing mode + settings.TESTING_ENV = True # nothing set self.assertEqual(user_count(), 0) @@ -455,3 +457,6 @@ class TestSettings(TestCase): with self.assertRaises(IntegrationPluginError): self.run_reload() self.assertEqual(user_count(), 1) + + # make sure to clean up + settings.TESTING_ENV = False From 5cc214dfbccf682ee4bc3c06835b23344cce563a Mon Sep 17 00:00:00 2001 From: Matthias Mair <66015116+matmair@users.noreply.github.com> Date: Tue, 1 Mar 2022 12:23:17 +0100 Subject: [PATCH 15/66] Update settings.py Add comment to explain usage --- InvenTree/InvenTree/settings.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/InvenTree/InvenTree/settings.py b/InvenTree/InvenTree/settings.py index 7291178e4a..069d0d3363 100644 --- a/InvenTree/InvenTree/settings.py +++ b/InvenTree/InvenTree/settings.py @@ -36,7 +36,8 @@ def _is_true(x): # Determine if we are running in "test" mode e.g. "manage.py test" -TESTING = 'test' in sys.argv +TESTING = 'test' in sys. +# Are enviroment variables manipulated by tests? Needs to be set by testing code TESTING_ENV = False # New requirement for django 3.2+ From 2f4cae4c9dccb3a9ce954c01c6642bb206b2d927 Mon Sep 17 00:00:00 2001 From: Matthias Mair <66015116+matmair@users.noreply.github.com> Date: Tue, 1 Mar 2022 12:25:04 +0100 Subject: [PATCH 16/66] Update config_template.yaml Change name for config name --- InvenTree/config_template.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/InvenTree/config_template.yaml b/InvenTree/config_template.yaml index 153cf2f16b..65dd20d3e8 100644 --- a/InvenTree/config_template.yaml +++ b/InvenTree/config_template.yaml @@ -155,9 +155,9 @@ static_root: '/home/inventree/data/static' #login_attempts: 5 # Add new user on first startup -#set_user: admin -#set_email: info@example.com -#set_password: inventree +#admin_user: admin +#admin_email: info@example.com +#admin_password: inventree # Permit custom authentication backends #authentication_backends: From 484a53ff8e8c0850794203bef5e0c58a74ef07de Mon Sep 17 00:00:00 2001 From: Matthias Mair <66015116+matmair@users.noreply.github.com> Date: Tue, 1 Mar 2022 12:26:16 +0100 Subject: [PATCH 17/66] Update apps.py Update env names --- InvenTree/InvenTree/apps.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/InvenTree/InvenTree/apps.py b/InvenTree/InvenTree/apps.py index a6116f0ec2..ff95d74345 100644 --- a/InvenTree/InvenTree/apps.py +++ b/InvenTree/InvenTree/apps.py @@ -155,16 +155,16 @@ class InvenTreeConfig(AppConfig): # get values add_user = get_setting( - 'INVENTREE_SET_USER', - settings.CONFIG.get('set_user', False) + 'INVENTREE_ADMIN_USER', + settings.CONFIG.get('admin_user', False) ) add_email = get_setting( - 'INVENTREE_SET_EMAIL', - settings.CONFIG.get('set_email', False) + 'INVENTREE_ADMIN_EMAIL', + settings.CONFIG.get('admin_email', False) ) add_password = get_setting( - 'INVENTREE_SET_PASSWORD', - settings.CONFIG.get('set_password', False) + 'INVENTREE_ADMIN_PASSWORD', + settings.CONFIG.get('admin_password', False) ) # check if all values are present From a500e8cf542a71a73ee2bba2cf2114c89676fd4d Mon Sep 17 00:00:00 2001 From: Matthias Mair <66015116+matmair@users.noreply.github.com> Date: Tue, 1 Mar 2022 12:28:31 +0100 Subject: [PATCH 18/66] Fix typo --- InvenTree/InvenTree/settings.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/InvenTree/InvenTree/settings.py b/InvenTree/InvenTree/settings.py index 069d0d3363..d820dfb716 100644 --- a/InvenTree/InvenTree/settings.py +++ b/InvenTree/InvenTree/settings.py @@ -36,7 +36,7 @@ def _is_true(x): # Determine if we are running in "test" mode e.g. "manage.py test" -TESTING = 'test' in sys. +TESTING = 'test' in sys.argv # Are enviroment variables manipulated by tests? Needs to be set by testing code TESTING_ENV = False From 56e0b5f98dfb3b00d72e86cdf1820522cd9a9794 Mon Sep 17 00:00:00 2001 From: Matthias Mair <66015116+matmair@users.noreply.github.com> Date: Tue, 1 Mar 2022 12:31:56 +0100 Subject: [PATCH 19/66] Add as superuser --- InvenTree/InvenTree/apps.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/InvenTree/InvenTree/apps.py b/InvenTree/InvenTree/apps.py index ff95d74345..80389bda95 100644 --- a/InvenTree/InvenTree/apps.py +++ b/InvenTree/InvenTree/apps.py @@ -188,7 +188,7 @@ class InvenTreeConfig(AppConfig): user = get_user_model() try: with transaction.atomic(): - new_user = user.objects.create_user(add_user, add_email, add_password) + new_user = user.objects.create_superuser(add_user, add_email, add_password) logger.info(f'User {str(new_user)} was created!') except IntegrityError as _e: logger.warning(f'The user "{add_user}" could not be created due to the following error:\n{str(_e)}') From 0957feafa148ef2caccf64c081e72234316cb52f Mon Sep 17 00:00:00 2001 From: Matthias Mair <66015116+matmair@users.noreply.github.com> Date: Tue, 1 Mar 2022 20:12:38 +0100 Subject: [PATCH 20/66] fix test --- InvenTree/InvenTree/tests.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/InvenTree/InvenTree/tests.py b/InvenTree/InvenTree/tests.py index 9014ee1591..cd6986e8d0 100644 --- a/InvenTree/InvenTree/tests.py +++ b/InvenTree/InvenTree/tests.py @@ -439,21 +439,21 @@ class TestSettings(TestCase): self.assertEqual(user_count(), 0) # not enough set - self.env.set('INVENTREE_SET_USER', 'admin') # set username + self.env.set('INVENTREE_ADMIN_USER', 'admin') # set username self.run_reload() self.assertEqual(user_count(), 0) # enough set - self.env.set('INVENTREE_SET_USER', 'admin') # set username - self.env.set('INVENTREE_SET_EMAIL', 'info@example.com') # set email - self.env.set('INVENTREE_SET_PASSWORD', 'password123') # set password + self.env.set('INVENTREE_ADMIN_USER', 'admin') # set username + self.env.set('INVENTREE_ADMIN_EMAIL', 'info@example.com') # set email + self.env.set('INVENTREE_ADMIN_PASSWORD', 'password123') # set password self.run_reload() self.assertEqual(user_count(), 1) # double adding should not work - self.env.set('INVENTREE_SET_USER', 'admin') # set username - self.env.set('INVENTREE_SET_EMAIL', 'info@example.com') # set email - self.env.set('INVENTREE_SET_PASSWORD', 'password123') # set password + self.env.set('INVENTREE_ADMIN_USER', 'admin') # set username + self.env.set('INVENTREE_ADMIN_EMAIL', 'info@example.com') # set email + self.env.set('INVENTREE_ADMIN_PASSWORD', 'password123') # set password with self.assertRaises(IntegrationPluginError): self.run_reload() self.assertEqual(user_count(), 1) From d018654c9ce53c0db4b28fa71c8396f3b0bdfd73 Mon Sep 17 00:00:00 2001 From: Matthias Date: Wed, 2 Mar 2022 00:17:00 +0100 Subject: [PATCH 21/66] fix settings override? --- InvenTree/InvenTree/tests.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/InvenTree/InvenTree/tests.py b/InvenTree/InvenTree/tests.py index cd6986e8d0..c31dad4d95 100644 --- a/InvenTree/InvenTree/tests.py +++ b/InvenTree/InvenTree/tests.py @@ -2,7 +2,7 @@ import json from test.support import EnvironmentVarGuard -from django.test import TestCase +from django.test import TestCase, override_settings import django.core.exceptions as django_exceptions from django.core.exceptions import ValidationError from django.contrib.auth import get_user_model @@ -429,11 +429,10 @@ class TestSettings(TestCase): settings.USER_ADDED = False registry.reload_plugins() + @override_settings(TESTING_ENV=True) def test_set_user_to_few(self): # add shortcut user_count = self.user_mdl.objects.count - # enable testing mode - settings.TESTING_ENV = True # nothing set self.assertEqual(user_count(), 0) @@ -457,6 +456,3 @@ class TestSettings(TestCase): with self.assertRaises(IntegrationPluginError): self.run_reload() self.assertEqual(user_count(), 1) - - # make sure to clean up - settings.TESTING_ENV = False From 48583f470c607b2cdb5d0c6b4e44bebe58e26cb7 Mon Sep 17 00:00:00 2001 From: Matthias Date: Wed, 2 Mar 2022 19:54:40 +0100 Subject: [PATCH 22/66] remove testing code --- InvenTree/InvenTree/tests.py | 21 --------------------- 1 file changed, 21 deletions(-) diff --git a/InvenTree/InvenTree/tests.py b/InvenTree/InvenTree/tests.py index c31dad4d95..0b50404396 100644 --- a/InvenTree/InvenTree/tests.py +++ b/InvenTree/InvenTree/tests.py @@ -24,7 +24,6 @@ import InvenTree.tasks from stock.models import StockLocation from common.settings import currency_codes -from plugin.helpers import IntegrationPluginError class ValidatorTest(TestCase): @@ -436,23 +435,3 @@ class TestSettings(TestCase): # nothing set self.assertEqual(user_count(), 0) - - # not enough set - self.env.set('INVENTREE_ADMIN_USER', 'admin') # set username - self.run_reload() - self.assertEqual(user_count(), 0) - - # enough set - self.env.set('INVENTREE_ADMIN_USER', 'admin') # set username - self.env.set('INVENTREE_ADMIN_EMAIL', 'info@example.com') # set email - self.env.set('INVENTREE_ADMIN_PASSWORD', 'password123') # set password - self.run_reload() - self.assertEqual(user_count(), 1) - - # double adding should not work - self.env.set('INVENTREE_ADMIN_USER', 'admin') # set username - self.env.set('INVENTREE_ADMIN_EMAIL', 'info@example.com') # set email - self.env.set('INVENTREE_ADMIN_PASSWORD', 'password123') # set password - with self.assertRaises(IntegrationPluginError): - self.run_reload() - self.assertEqual(user_count(), 1) From 2c6731df0cd6899f7b2159e955d73adc242b76dc Mon Sep 17 00:00:00 2001 From: Matthias Date: Wed, 2 Mar 2022 22:49:05 +0100 Subject: [PATCH 23/66] add ENVs back in --- InvenTree/InvenTree/tests.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/InvenTree/InvenTree/tests.py b/InvenTree/InvenTree/tests.py index 0b50404396..f65f4c3952 100644 --- a/InvenTree/InvenTree/tests.py +++ b/InvenTree/InvenTree/tests.py @@ -432,6 +432,11 @@ class TestSettings(TestCase): def test_set_user_to_few(self): # add shortcut user_count = self.user_mdl.objects.count + # enable testing mode + settings.TESTING_ENV = True # nothing set self.assertEqual(user_count(), 0) + + # make sure to clean up + settings.TESTING_ENV = False From d03af8f5b015c26f5360826e0f898491b8a1b1c2 Mon Sep 17 00:00:00 2001 From: Matthias Date: Wed, 2 Mar 2022 22:50:36 +0100 Subject: [PATCH 24/66] nothing test --- InvenTree/InvenTree/tests.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/InvenTree/InvenTree/tests.py b/InvenTree/InvenTree/tests.py index f65f4c3952..5a21cbc87b 100644 --- a/InvenTree/InvenTree/tests.py +++ b/InvenTree/InvenTree/tests.py @@ -438,5 +438,10 @@ class TestSettings(TestCase): # nothing set self.assertEqual(user_count(), 0) + # not enough set + self.env.set('INVENTREE_ADMIN_USER', 'admin') # set username + self.run_reload() + self.assertEqual(user_count(), 0) + # make sure to clean up settings.TESTING_ENV = False From 7d58db4336b29b7def1f02aeb75262ad836bb558 Mon Sep 17 00:00:00 2001 From: Matthias Date: Wed, 2 Mar 2022 22:52:14 +0100 Subject: [PATCH 25/66] full set again --- InvenTree/InvenTree/tests.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/InvenTree/InvenTree/tests.py b/InvenTree/InvenTree/tests.py index 5a21cbc87b..53e8bdb618 100644 --- a/InvenTree/InvenTree/tests.py +++ b/InvenTree/InvenTree/tests.py @@ -24,6 +24,7 @@ import InvenTree.tasks from stock.models import StockLocation from common.settings import currency_codes +from plugin.helpers import IntegrationPluginError class ValidatorTest(TestCase): @@ -443,5 +444,20 @@ class TestSettings(TestCase): self.run_reload() self.assertEqual(user_count(), 0) + # enough set + self.env.set('INVENTREE_ADMIN_USER', 'admin') # set username + self.env.set('INVENTREE_ADMIN_EMAIL', 'info@example.com') # set email + self.env.set('INVENTREE_ADMIN_PASSWORD', 'password123') # set password + self.run_reload() + self.assertEqual(user_count(), 1) + + # double adding should not work + self.env.set('INVENTREE_ADMIN_USER', 'admin') # set username + self.env.set('INVENTREE_ADMIN_EMAIL', 'info@example.com') # set email + self.env.set('INVENTREE_ADMIN_PASSWORD', 'password123') # set password + with self.assertRaises(IntegrationPluginError): + self.run_reload() + self.assertEqual(user_count(), 1) + # make sure to clean up settings.TESTING_ENV = False From c893b81314e210f1fdfaaf397bf7ef7c9f9c025c Mon Sep 17 00:00:00 2001 From: Matthias Date: Wed, 2 Mar 2022 23:34:44 +0100 Subject: [PATCH 26/66] remove the wrong setting --- InvenTree/InvenTree/tests.py | 8 -------- 1 file changed, 8 deletions(-) diff --git a/InvenTree/InvenTree/tests.py b/InvenTree/InvenTree/tests.py index 53e8bdb618..46f23578ba 100644 --- a/InvenTree/InvenTree/tests.py +++ b/InvenTree/InvenTree/tests.py @@ -451,13 +451,5 @@ class TestSettings(TestCase): self.run_reload() self.assertEqual(user_count(), 1) - # double adding should not work - self.env.set('INVENTREE_ADMIN_USER', 'admin') # set username - self.env.set('INVENTREE_ADMIN_EMAIL', 'info@example.com') # set email - self.env.set('INVENTREE_ADMIN_PASSWORD', 'password123') # set password - with self.assertRaises(IntegrationPluginError): - self.run_reload() - self.assertEqual(user_count(), 1) - # make sure to clean up settings.TESTING_ENV = False From 941efd08d13154f4168939a3587a9808c70caf41 Mon Sep 17 00:00:00 2001 From: Matthias Date: Wed, 2 Mar 2022 23:36:53 +0100 Subject: [PATCH 27/66] change type of error --- InvenTree/InvenTree/tests.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/InvenTree/InvenTree/tests.py b/InvenTree/InvenTree/tests.py index 46f23578ba..9747a6741f 100644 --- a/InvenTree/InvenTree/tests.py +++ b/InvenTree/InvenTree/tests.py @@ -7,6 +7,7 @@ import django.core.exceptions as django_exceptions from django.core.exceptions import ValidationError from django.contrib.auth import get_user_model from django.conf import settings +from django.db.utils import IntegrityError from djmoney.money import Money from djmoney.contrib.exchange.models import Rate, convert_money @@ -451,5 +452,13 @@ class TestSettings(TestCase): self.run_reload() self.assertEqual(user_count(), 1) + # double adding should not work + self.env.set('INVENTREE_ADMIN_USER', 'admin') # set username + self.env.set('INVENTREE_ADMIN_EMAIL', 'info@example.com') # set email + self.env.set('INVENTREE_ADMIN_PASSWORD', 'password123') # set password + with self.assertRaises(IntegrityError): + self.run_reload() + self.assertEqual(user_count(), 1) + # make sure to clean up settings.TESTING_ENV = False From 771d82bdc20bb03d74b660395147bcba95ec8bcb Mon Sep 17 00:00:00 2001 From: Matthias Date: Wed, 2 Mar 2022 23:40:46 +0100 Subject: [PATCH 28/66] style fix --- InvenTree/InvenTree/tests.py | 1 - 1 file changed, 1 deletion(-) diff --git a/InvenTree/InvenTree/tests.py b/InvenTree/InvenTree/tests.py index 9747a6741f..eb6f3e6605 100644 --- a/InvenTree/InvenTree/tests.py +++ b/InvenTree/InvenTree/tests.py @@ -25,7 +25,6 @@ import InvenTree.tasks from stock.models import StockLocation from common.settings import currency_codes -from plugin.helpers import IntegrationPluginError class ValidatorTest(TestCase): From a158ecf4016d0ef7963f39ba0a57a7a256e03039 Mon Sep 17 00:00:00 2001 From: Matthias Date: Wed, 2 Mar 2022 23:41:41 +0100 Subject: [PATCH 29/66] reset to previous --- InvenTree/InvenTree/tests.py | 9 --------- 1 file changed, 9 deletions(-) diff --git a/InvenTree/InvenTree/tests.py b/InvenTree/InvenTree/tests.py index eb6f3e6605..571a4c5012 100644 --- a/InvenTree/InvenTree/tests.py +++ b/InvenTree/InvenTree/tests.py @@ -7,7 +7,6 @@ import django.core.exceptions as django_exceptions from django.core.exceptions import ValidationError from django.contrib.auth import get_user_model from django.conf import settings -from django.db.utils import IntegrityError from djmoney.money import Money from djmoney.contrib.exchange.models import Rate, convert_money @@ -451,13 +450,5 @@ class TestSettings(TestCase): self.run_reload() self.assertEqual(user_count(), 1) - # double adding should not work - self.env.set('INVENTREE_ADMIN_USER', 'admin') # set username - self.env.set('INVENTREE_ADMIN_EMAIL', 'info@example.com') # set email - self.env.set('INVENTREE_ADMIN_PASSWORD', 'password123') # set password - with self.assertRaises(IntegrityError): - self.run_reload() - self.assertEqual(user_count(), 1) - # make sure to clean up settings.TESTING_ENV = False From e11fa338b67032a62bb131dd5ada1498cecbc30a Mon Sep 17 00:00:00 2001 From: Matthias Date: Thu, 3 Mar 2022 00:52:10 +0100 Subject: [PATCH 30/66] also test empty --- InvenTree/InvenTree/tests.py | 1 + 1 file changed, 1 insertion(+) diff --git a/InvenTree/InvenTree/tests.py b/InvenTree/InvenTree/tests.py index 571a4c5012..f89a8b073d 100644 --- a/InvenTree/InvenTree/tests.py +++ b/InvenTree/InvenTree/tests.py @@ -436,6 +436,7 @@ class TestSettings(TestCase): settings.TESTING_ENV = True # nothing set + self.run_reload() self.assertEqual(user_count(), 0) # not enough set From 67d0033ab3985e3add1be827485c6dbcc98d5861 Mon Sep 17 00:00:00 2001 From: Oliver Date: Thu, 3 Mar 2022 13:19:25 +1100 Subject: [PATCH 31/66] Allows BOM to be copied for any part, not just variant parts --- InvenTree/part/templates/part/detail.html | 2 -- 1 file changed, 2 deletions(-) diff --git a/InvenTree/part/templates/part/detail.html b/InvenTree/part/templates/part/detail.html index a6cfda757f..e7cd586353 100644 --- a/InvenTree/part/templates/part/detail.html +++ b/InvenTree/part/templates/part/detail.html @@ -281,9 +281,7 @@ From 549f16b7aa27b2cc28f0db748349907c4f2f6a25 Mon Sep 17 00:00:00 2001 From: Oliver Date: Thu, 3 Mar 2022 13:53:09 +1100 Subject: [PATCH 32/66] Adds "export" option to StockItem API endpoint, allowing export to file --- InvenTree/stock/api.py | 29 ++++++++++++++++++++++++++--- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/InvenTree/stock/api.py b/InvenTree/stock/api.py index ed7a4b8c40..f60f1c535f 100644 --- a/InvenTree/stock/api.py +++ b/InvenTree/stock/api.py @@ -30,6 +30,7 @@ from company.models import Company, SupplierPart from company.serializers import CompanySerializer, SupplierPartSerializer from InvenTree.helpers import str2bool, isNull, extract_serial_numbers +from InvenTree.helpers import DownloadFile from InvenTree.api import AttachmentMixin from InvenTree.filters import InvenTreeOrderingFilter @@ -40,6 +41,7 @@ from order.serializers import POSerializer from part.models import BomItem, Part, PartCategory from part.serializers import PartBriefSerializer +from stock.admin import StockItemResource from stock.models import StockLocation, StockItem from stock.models import StockItemTracking from stock.models import StockItemAttachment @@ -611,6 +613,27 @@ class StockList(generics.ListCreateAPIView): queryset = self.filter_queryset(self.get_queryset()) + params = request.query_params + + # Check if we wish to export the queried data to a file. + # If so, skip pagination! + export_format = params.get('export', None) + + if export_format: + export_format = str(export_format).strip().lower() + + if export_format in ['csv', 'tsv', 'xls', 'xlsx']: + dataset = StockItemResource().export(queryset=queryset) + + filedata = dataset.export(export_format) + + filename = 'InvenTree_Stocktake_{date}.{fmt}'.format( + date=datetime.now().strftime("%d-%b-%Y"), + fmt=export_format + ) + + return DownloadFile(filedata, filename) + page = self.paginate_queryset(queryset) if page is not None: @@ -641,7 +664,7 @@ class StockList(generics.ListCreateAPIView): supplier_part_ids.add(sp) # Do we wish to include Part detail? - if str2bool(request.query_params.get('part_detail', False)): + if str2bool(params.get('part_detail', False)): # Fetch only the required Part objects from the database parts = Part.objects.filter(pk__in=part_ids).prefetch_related( @@ -659,7 +682,7 @@ class StockList(generics.ListCreateAPIView): stock_item['part_detail'] = part_map.get(part_id, None) # Do we wish to include SupplierPart detail? - if str2bool(request.query_params.get('supplier_part_detail', False)): + if str2bool(params.get('supplier_part_detail', False)): supplier_parts = SupplierPart.objects.filter(pk__in=supplier_part_ids) @@ -673,7 +696,7 @@ class StockList(generics.ListCreateAPIView): stock_item['supplier_part_detail'] = supplier_part_map.get(part_id, None) # Do we wish to include StockLocation detail? - if str2bool(request.query_params.get('location_detail', False)): + if str2bool(params.get('location_detail', False)): # Fetch only the required StockLocation objects from the database locations = StockLocation.objects.filter(pk__in=location_ids).prefetch_related( From 6b7a0fde1bc4afca1f8484d29a9c00ffdae6fe4a Mon Sep 17 00:00:00 2001 From: Oliver Date: Thu, 3 Mar 2022 17:17:27 +1100 Subject: [PATCH 33/66] Store table query parameters when performing a bootstrap-table query - For now it only supports .csv format --- InvenTree/stock/templates/stock/location.html | 7 +-- InvenTree/templates/js/translated/tables.js | 45 +++++++++++++++++++ 2 files changed, 46 insertions(+), 6 deletions(-) diff --git a/InvenTree/stock/templates/stock/location.html b/InvenTree/stock/templates/stock/location.html index 4c98db529b..575a798fb2 100644 --- a/InvenTree/stock/templates/stock/location.html +++ b/InvenTree/stock/templates/stock/location.html @@ -240,12 +240,7 @@ {% endif %} $("#stock-export").click(function() { - - exportStock({ - {% if location %} - location: {{ location.pk }} - {% endif %} - }); + downloadTableData($('#stock-table')); }); $('#location-create').click(function () { diff --git a/InvenTree/templates/js/translated/tables.js b/InvenTree/templates/js/translated/tables.js index c2418dbe78..b295c3f89a 100644 --- a/InvenTree/templates/js/translated/tables.js +++ b/InvenTree/templates/js/translated/tables.js @@ -7,6 +7,7 @@ /* exported customGroupSorter, + downloadTableData, reloadtable, renderLink, reloadTableFilters, @@ -21,6 +22,42 @@ function reloadtable(table) { } +/** + * Download data from a table, via the API. + * This requires a number of conditions to be met: + * + * - The API endpoint supports data download (on the server side) + * - The table is "flat" (does not support multi-level loading, etc) + * - The table has been loaded using the inventreeTable() function, not bootstrapTable() + * (Refer to the "reloadTableFilters" function to see why!) + */ +function downloadTableData(table) { + + // Extract table configuration options + var options = table.bootstrapTable('getOptions'); + + var url = options.url; + + if (!url) { + console.log("Error: downloadTableData could not find 'url' parameter"); + } + + var query_params = options.query_params || {}; + + url += '?'; + + for (const [key, value] of Object.entries(query_params)) { + url += `${key}=${value}&`; + } + + var format = 'csv'; + + url += `export=${format}`; + + location.href = url; +} + + /** * Render a URL for display * @param {String} text @@ -114,6 +151,10 @@ function reloadTableFilters(table, filters) { } } + // Store the total set of query params + // This is necessary for the "downloadTableData" function to work + options.query_params = params; + options.queryParams = function(tableParams) { return convertQueryParameters(tableParams, params); }; @@ -221,7 +262,11 @@ $.fn.inventreeTable = function(options) { // Extract query params var filters = options.queryParams || options.filters || {}; + // Store the total set of query params + options.query_params = filters; + options.queryParams = function(params) { + // Update the query parameters callback with the *new* filters return convertQueryParameters(params, filters); }; From 51de436d425da4afd142d9c7726704b083cbbaec Mon Sep 17 00:00:00 2001 From: Oliver Date: Thu, 3 Mar 2022 17:21:04 +1100 Subject: [PATCH 34/66] User can select export format --- InvenTree/templates/js/translated/tables.js | 44 +++++++++++++++------ 1 file changed, 32 insertions(+), 12 deletions(-) diff --git a/InvenTree/templates/js/translated/tables.js b/InvenTree/templates/js/translated/tables.js index b295c3f89a..9fb5b668d1 100644 --- a/InvenTree/templates/js/translated/tables.js +++ b/InvenTree/templates/js/translated/tables.js @@ -31,33 +31,53 @@ function reloadtable(table) { * - The table has been loaded using the inventreeTable() function, not bootstrapTable() * (Refer to the "reloadTableFilters" function to see why!) */ -function downloadTableData(table) { +function downloadTableData(table, opts={}) { // Extract table configuration options - var options = table.bootstrapTable('getOptions'); + var table_options = table.bootstrapTable('getOptions'); - var url = options.url; + var url = table_options.url; if (!url) { console.log("Error: downloadTableData could not find 'url' parameter"); } - var query_params = options.query_params || {}; + var query_params = table_options.query_params || {}; url += '?'; - for (const [key, value] of Object.entries(query_params)) { - url += `${key}=${value}&`; - } + constructFormBody({}, { + title: opts.title || '{% trans "Export Table Data" %}', + fields: { + format: { + label: '{% trans "Format" %}', + help_text: '{% trans "Select File Format" %}', + required: true, + type: 'choice', + value: 'csv', + choices: exportFormatOptions(), + } + }, + onSubmit: function(fields, form_options) { + var format = getFormFieldValue('format', fields['format'], form_options); + + // Hide the modal + $(form_options.modal).modal('hide'); - var format = 'csv'; - - url += `export=${format}`; - - location.href = url; + for (const [key, value] of Object.entries(query_params)) { + url += `${key}=${value}&`; + } + + url += `export=${format}`; + + location.href = url; + } + }); } + + /** * Render a URL for display * @param {String} text From 8bf84ec217d6f9f8f124a74ad2f165843ca1a152 Mon Sep 17 00:00:00 2001 From: Oliver Date: Thu, 3 Mar 2022 17:25:31 +1100 Subject: [PATCH 35/66] Remove calls to "exportStock" --- .../company/templates/company/detail.html | 4 +- .../templates/company/supplier_part.html | 6 +-- InvenTree/part/templates/part/detail.html | 4 +- InvenTree/templates/js/translated/stock.js | 44 ------------------- 4 files changed, 3 insertions(+), 55 deletions(-) diff --git a/InvenTree/company/templates/company/detail.html b/InvenTree/company/templates/company/detail.html index e63f217f4b..190efd6cd4 100644 --- a/InvenTree/company/templates/company/detail.html +++ b/InvenTree/company/templates/company/detail.html @@ -283,9 +283,7 @@ }); $("#stock-export").click(function() { - exportStock({ - supplier: {{ company.id }} - }); + downloadTableData($("#stock-table")); }); {% if company.is_manufacturer %} diff --git a/InvenTree/company/templates/company/supplier_part.html b/InvenTree/company/templates/company/supplier_part.html index 89e8f91493..1fe1ea86b4 100644 --- a/InvenTree/company/templates/company/supplier_part.html +++ b/InvenTree/company/templates/company/supplier_part.html @@ -309,11 +309,7 @@ loadStockTable($("#stock-table"), { }); $("#stock-export").click(function() { - - exportStock({ - supplier_part: {{ part.pk }}, - }); - + downloadTableData($("#stock-table")); }); $("#item-create").click(function() { diff --git a/InvenTree/part/templates/part/detail.html b/InvenTree/part/templates/part/detail.html index a6cfda757f..172315b50e 100644 --- a/InvenTree/part/templates/part/detail.html +++ b/InvenTree/part/templates/part/detail.html @@ -834,9 +834,7 @@ $("#stock-export").click(function() { - exportStock({ - part: {{ part.pk }} - }); + downloadTableData($("#stock-table")); }); $('#item-create').click(function () { diff --git a/InvenTree/templates/js/translated/stock.js b/InvenTree/templates/js/translated/stock.js index 1ca89368dc..6239a886ab 100644 --- a/InvenTree/templates/js/translated/stock.js +++ b/InvenTree/templates/js/translated/stock.js @@ -43,7 +43,6 @@ duplicateStockItem, editStockItem, editStockLocation, - exportStock, findStockItemBySerialNumber, installStockItem, loadInstalledInTable, @@ -506,49 +505,6 @@ function stockStatusCodes() { } -/* - * Export stock table - */ -function exportStock(params={}) { - - constructFormBody({}, { - title: '{% trans "Export Stock" %}', - fields: { - format: { - label: '{% trans "Format" %}', - help_text: '{% trans "Select file format" %}', - required: true, - type: 'choice', - value: 'csv', - choices: exportFormatOptions(), - }, - sublocations: { - label: '{% trans "Include Sublocations" %}', - help_text: '{% trans "Include stock items in sublocations" %}', - type: 'boolean', - value: 'true', - } - }, - onSubmit: function(fields, form_options) { - - var format = getFormFieldValue('format', fields['format'], form_options); - var cascade = getFormFieldValue('sublocations', fields['sublocations'], form_options); - - // Hide the modal - $(form_options.modal).modal('hide'); - - var url = `{% url "stock-export" %}?format=${format}&cascade=${cascade}`; - - for (var key in params) { - url += `&${key}=${params[key]}`; - } - - location.href = url; - } - }); -} - - /** * Assign multiple stock items to a customer */ From 10cc72910d42b118553d5c840226ea0959c6f44c Mon Sep 17 00:00:00 2001 From: Oliver Date: Thu, 3 Mar 2022 17:25:41 +1100 Subject: [PATCH 36/66] Remove StockExport view (no longer required!) --- InvenTree/stock/urls.py | 2 - InvenTree/stock/views.py | 89 ---------------------------------------- 2 files changed, 91 deletions(-) diff --git a/InvenTree/stock/urls.py b/InvenTree/stock/urls.py index b2536e0b97..bb4a56e2dc 100644 --- a/InvenTree/stock/urls.py +++ b/InvenTree/stock/urls.py @@ -47,8 +47,6 @@ stock_urls = [ url(r'^track/', include(stock_tracking_urls)), - url(r'^export/?', views.StockExport.as_view(), name='stock-export'), - # Individual stock items url(r'^item/(?P\d+)/', include(stock_item_detail_urls)), diff --git a/InvenTree/stock/views.py b/InvenTree/stock/views.py index d1fde25b0a..f4cc6bc05d 100644 --- a/InvenTree/stock/views.py +++ b/InvenTree/stock/views.py @@ -380,95 +380,6 @@ class StockItemDeleteTestData(AjaxUpdateView): return self.renderJsonResponse(request, form, data) -class StockExport(AjaxView): - """ Export stock data from a particular location. - Returns a file containing stock information for that location. - """ - - model = StockItem - role_required = 'stock.view' - - def get(self, request, *args, **kwargs): - - export_format = request.GET.get('format', 'csv').lower() - - # Check if a particular location was specified - loc_id = request.GET.get('location', None) - location = None - - if loc_id: - try: - location = StockLocation.objects.get(pk=loc_id) - except (ValueError, StockLocation.DoesNotExist): - pass - - # Check if a particular supplier was specified - sup_id = request.GET.get('supplier', None) - supplier = None - - if sup_id: - try: - supplier = Company.objects.get(pk=sup_id) - except (ValueError, Company.DoesNotExist): - pass - - # Check if a particular supplier_part was specified - sup_part_id = request.GET.get('supplier_part', None) - supplier_part = None - - if sup_part_id: - try: - supplier_part = SupplierPart.objects.get(pk=sup_part_id) - except (ValueError, SupplierPart.DoesNotExist): - pass - - # Check if a particular part was specified - part_id = request.GET.get('part', None) - part = None - - if part_id: - try: - part = Part.objects.get(pk=part_id) - except (ValueError, Part.DoesNotExist): - pass - - if export_format not in GetExportFormats(): - export_format = 'csv' - - filename = 'InvenTree_Stocktake_{date}.{fmt}'.format( - date=datetime.now().strftime("%d-%b-%Y"), - fmt=export_format - ) - - if location: - # Check if locations should be cascading - cascade = str2bool(request.GET.get('cascade', True)) - stock_items = location.get_stock_items(cascade) - else: - stock_items = StockItem.objects.all() - - if part: - stock_items = stock_items.filter(part=part) - - if supplier: - stock_items = stock_items.filter(supplier_part__supplier=supplier) - - if supplier_part: - stock_items = stock_items.filter(supplier_part=supplier_part) - - # Filter out stock items that are not 'in stock' - stock_items = stock_items.filter(StockItem.IN_STOCK_FILTER) - - # Pre-fetch related fields to reduce DB queries - stock_items = stock_items.prefetch_related('part', 'supplier_part__supplier', 'location', 'purchase_order', 'build') - - dataset = StockItemResource().export(queryset=stock_items) - - filedata = dataset.export(export_format) - - return DownloadFile(filedata, filename) - - class StockItemQRCode(QRCodeView): """ View for displaying a QR code for a StockItem object """ From 73a32f66c8bb920870244b1013cbe7f30021a41d Mon Sep 17 00:00:00 2001 From: Oliver Date: Thu, 3 Mar 2022 17:31:25 +1100 Subject: [PATCH 37/66] Adds ability to export stock "assigned" to a particular customer --- InvenTree/company/templates/company/detail.html | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/InvenTree/company/templates/company/detail.html b/InvenTree/company/templates/company/detail.html index 190efd6cd4..3c9aa92c9a 100644 --- a/InvenTree/company/templates/company/detail.html +++ b/InvenTree/company/templates/company/detail.html @@ -169,7 +169,12 @@
- {% include "filter_list.html" with id="customerstock" %} +
+ + {% include "filter_list.html" with id="customerstock" %} +
@@ -228,6 +233,10 @@ filterTarget: '#filter-list-customerstock', }); + $('#assigned-stock-export').click(function() { + downloadTableData($('#assigned-stock-table')); + }); + {% if company.is_customer %} loadSalesOrderTable("#sales-order-table", { url: "{% url 'api-so-list' %}", From 4f74a27e1af8ac95552028e919d704ae8144e9e3 Mon Sep 17 00:00:00 2001 From: Oliver Date: Thu, 3 Mar 2022 17:42:31 +1100 Subject: [PATCH 38/66] Exporting data from a Part table now uses the API too - Makes use of the existing table filters - Exported data matches exactly what you see in the table! --- InvenTree/part/api.py | 19 +++++++ InvenTree/part/templates/part/category.html | 11 ++-- InvenTree/part/test_views.py | 8 --- InvenTree/part/urls.py | 3 - InvenTree/part/views.py | 63 --------------------- 5 files changed, 23 insertions(+), 81 deletions(-) diff --git a/InvenTree/part/api.py b/InvenTree/part/api.py index 954060c456..4136014a84 100644 --- a/InvenTree/part/api.py +++ b/InvenTree/part/api.py @@ -26,6 +26,8 @@ from djmoney.contrib.exchange.exceptions import MissingRate from decimal import Decimal, InvalidOperation +from part.admin import PartResource + from .models import Part, PartCategory, PartRelated from .models import BomItem, BomItemSubstitute from .models import PartParameter, PartParameterTemplate @@ -43,6 +45,7 @@ from build.models import Build from . import serializers as part_serializers from InvenTree.helpers import str2bool, isNull, increment +from InvenTree.helpers import DownloadFile from InvenTree.api import AttachmentMixin from InvenTree.status_codes import BuildStatus @@ -726,6 +729,22 @@ class PartList(generics.ListCreateAPIView): queryset = self.filter_queryset(self.get_queryset()) + # Check if we wish to export the queried data to a file. + # If so, skip pagination! + export_format = request.query_params.get('export', None) + + if export_format: + export_format = str(export_format).strip().lower() + + if export_format in ['csv', 'tsv', 'xls', 'xlsx']: + dataset = PartResource().export(queryset=queryset) + + filedata = dataset.export(export_format) + + filename = f"InvenTree_Parts.{export_format}" + + return DownloadFile(filedata, filename) + page = self.paginate_queryset(queryset) if page is not None: diff --git a/InvenTree/part/templates/part/category.html b/InvenTree/part/templates/part/category.html index e02c77509d..8f30b9b46a 100644 --- a/InvenTree/part/templates/part/category.html +++ b/InvenTree/part/templates/part/category.html @@ -153,9 +153,6 @@

{% trans "Parts" %}

{% include "spacer.html" %}
- {% if roles.part.add %} @@ -291,10 +291,7 @@ }); $("#part-export").click(function() { - - var url = "{% url 'part-export' %}?category={{ category.id }}"; - - location.href = url; + downloadTableData($('#part-table')); }); {% if roles.part.add %} diff --git a/InvenTree/part/test_views.py b/InvenTree/part/test_views.py index 5b6c460e1b..2171a09b17 100644 --- a/InvenTree/part/test_views.py +++ b/InvenTree/part/test_views.py @@ -58,14 +58,6 @@ class PartListTest(PartViewTestCase): self.assertIn('parts', keys) self.assertIn('user', keys) - def test_export(self): - """ Export part data to CSV """ - - response = self.client.get(reverse('part-export'), {'parts': '1,2,3,4,5,6,7,8,9,10'}, HTTP_X_REQUESTED_WITH='XMLHttpRequest') - - self.assertEqual(response.status_code, 200) - self.assertIn('streaming_content', dir(response)) - class PartDetailTest(PartViewTestCase): diff --git a/InvenTree/part/urls.py b/InvenTree/part/urls.py index ba843f7d4b..55a3dc52eb 100644 --- a/InvenTree/part/urls.py +++ b/InvenTree/part/urls.py @@ -80,9 +80,6 @@ part_urls = [ # Download a BOM upload template url(r'^bom_template/?', views.BomUploadTemplate.as_view(), name='bom-upload-template'), - # Export data for multiple parts - url(r'^export/', views.PartExport.as_view(), name='part-export'), - # Individual part using pk url(r'^(?P\d+)/', include(part_detail_urls)), diff --git a/InvenTree/part/views.py b/InvenTree/part/views.py index e0992364dd..bba84e2d24 100644 --- a/InvenTree/part/views.py +++ b/InvenTree/part/views.py @@ -709,69 +709,6 @@ class BomUpload(InvenTreeRoleMixin, DetailView): template_name = 'part/upload_bom.html' -class PartExport(AjaxView): - """ Export a CSV file containing information on multiple parts """ - - role_required = 'part.view' - - def get_parts(self, request): - """ Extract part list from the POST parameters. - Parts can be supplied as: - - - Part category - - List of part PK values - """ - - # Filter by part category - cat_id = request.GET.get('category', None) - - part_list = None - - if cat_id is not None: - try: - category = PartCategory.objects.get(pk=cat_id) - part_list = category.get_parts() - except (ValueError, PartCategory.DoesNotExist): - pass - - # Backup - All parts - if part_list is None: - part_list = Part.objects.all() - - # Also optionally filter by explicit list of part IDs - part_ids = request.GET.get('parts', '') - parts = [] - - for pk in part_ids.split(','): - try: - parts.append(int(pk)) - except ValueError: - pass - - if len(parts) > 0: - part_list = part_list.filter(pk__in=parts) - - # Prefetch related fields to reduce DB hits - part_list = part_list.prefetch_related( - 'category', - 'used_in', - 'builds', - 'supplier_parts__purchase_order_line_items', - 'stock_items__allocations', - ) - - return part_list - - def get(self, request, *args, **kwargs): - - parts = self.get_parts(request) - - dataset = PartResource().export(queryset=parts) - - csv = dataset.export('csv') - return DownloadFile(csv, 'InvenTree_Parts.csv') - - class BomUploadTemplate(AjaxView): """ Provide a BOM upload template file for download. From 0ec0f55e17f4958dcb7e7b36a1d87f550c138a5f Mon Sep 17 00:00:00 2001 From: Oliver Date: Thu, 3 Mar 2022 17:44:12 +1100 Subject: [PATCH 39/66] Style fixes --- InvenTree/part/views.py | 4 +--- InvenTree/stock/api.py | 2 +- InvenTree/stock/views.py | 6 ++---- 3 files changed, 4 insertions(+), 8 deletions(-) diff --git a/InvenTree/part/views.py b/InvenTree/part/views.py index bba84e2d24..4b1e0eca33 100644 --- a/InvenTree/part/views.py +++ b/InvenTree/part/views.py @@ -49,13 +49,11 @@ from . import settings as part_settings from .bom import MakeBomTemplate, ExportBom, IsValidBOMFormat from order.models import PurchaseOrderLineItem -from .admin import PartResource - from InvenTree.views import AjaxView, AjaxCreateView, AjaxUpdateView, AjaxDeleteView from InvenTree.views import QRCodeView from InvenTree.views import InvenTreeRoleMixin -from InvenTree.helpers import DownloadFile, str2bool +from InvenTree.helpers import str2bool class PartIndex(InvenTreeRoleMixin, ListView): diff --git a/InvenTree/stock/api.py b/InvenTree/stock/api.py index f60f1c535f..34563b38d7 100644 --- a/InvenTree/stock/api.py +++ b/InvenTree/stock/api.py @@ -626,7 +626,7 @@ class StockList(generics.ListCreateAPIView): dataset = StockItemResource().export(queryset=queryset) filedata = dataset.export(export_format) - + filename = 'InvenTree_Stocktake_{date}.{fmt}'.format( date=datetime.now().strftime("%d-%b-%Y"), fmt=export_format diff --git a/InvenTree/stock/views.py b/InvenTree/stock/views.py index f4cc6bc05d..95cb498739 100644 --- a/InvenTree/stock/views.py +++ b/InvenTree/stock/views.py @@ -25,13 +25,13 @@ from InvenTree.views import QRCodeView from InvenTree.views import InvenTreeRoleMixin from InvenTree.forms import ConfirmForm -from InvenTree.helpers import str2bool, DownloadFile, GetExportFormats +from InvenTree.helpers import str2bool from InvenTree.helpers import extract_serial_numbers from decimal import Decimal, InvalidOperation from datetime import datetime, timedelta -from company.models import Company, SupplierPart +from company.models import SupplierPart from part.models import Part from .models import StockItem, StockLocation, StockItemTracking @@ -39,8 +39,6 @@ import common.settings from common.models import InvenTreeSetting from users.models import Owner -from .admin import StockItemResource - from . import forms as StockForms From 85e9c4d3ca514891c8e85b4706c40eac5f9fa4ac Mon Sep 17 00:00:00 2001 From: Oliver Date: Thu, 3 Mar 2022 17:47:23 +1100 Subject: [PATCH 40/66] JS linting --- InvenTree/templates/js/translated/tables.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/InvenTree/templates/js/translated/tables.js b/InvenTree/templates/js/translated/tables.js index 9fb5b668d1..8a6674299c 100644 --- a/InvenTree/templates/js/translated/tables.js +++ b/InvenTree/templates/js/translated/tables.js @@ -39,7 +39,7 @@ function downloadTableData(table, opts={}) { var url = table_options.url; if (!url) { - console.log("Error: downloadTableData could not find 'url' parameter"); + console.log('Error: downloadTableData could not find "url" parameter.'); } var query_params = table_options.query_params || {}; From ba406a4da9289756849c7f40a3098757de1f77ad Mon Sep 17 00:00:00 2001 From: Oliver Date: Thu, 3 Mar 2022 21:16:27 +1100 Subject: [PATCH 41/66] Refactorin' - Add the "download" button into the "filters" list - Cuts down on boilerplate code --- InvenTree/company/templates/company/detail.html | 11 ----------- .../company/templates/company/supplier_part.html | 4 ---- InvenTree/part/templates/part/category.html | 7 ------- InvenTree/part/templates/part/detail.html | 7 +------ InvenTree/stock/templates/stock/location.html | 4 ---- InvenTree/templates/js/translated/filters.js | 16 ++++++++++++++-- InvenTree/templates/js/translated/part.js | 2 +- InvenTree/templates/js/translated/stock.js | 2 +- InvenTree/templates/stock_table.html | 3 --- 9 files changed, 17 insertions(+), 39 deletions(-) diff --git a/InvenTree/company/templates/company/detail.html b/InvenTree/company/templates/company/detail.html index 3c9aa92c9a..0717d02d4d 100644 --- a/InvenTree/company/templates/company/detail.html +++ b/InvenTree/company/templates/company/detail.html @@ -170,9 +170,6 @@
- {% include "filter_list.html" with id="customerstock" %}
@@ -233,10 +230,6 @@ filterTarget: '#filter-list-customerstock', }); - $('#assigned-stock-export').click(function() { - downloadTableData($('#assigned-stock-table')); - }); - {% if company.is_customer %} loadSalesOrderTable("#sales-order-table", { url: "{% url 'api-so-list' %}", @@ -291,10 +284,6 @@ filterKey: "companystock", }); - $("#stock-export").click(function() { - downloadTableData($("#stock-table")); - }); - {% if company.is_manufacturer %} function reloadManufacturerPartTable() { diff --git a/InvenTree/company/templates/company/supplier_part.html b/InvenTree/company/templates/company/supplier_part.html index 1fe1ea86b4..44e6756845 100644 --- a/InvenTree/company/templates/company/supplier_part.html +++ b/InvenTree/company/templates/company/supplier_part.html @@ -308,10 +308,6 @@ loadStockTable($("#stock-table"), { url: "{% url 'api-stock-list' %}", }); -$("#stock-export").click(function() { - downloadTableData($("#stock-table")); -}); - $("#item-create").click(function() { createNewStockItem({ data: { diff --git a/InvenTree/part/templates/part/category.html b/InvenTree/part/templates/part/category.html index 8f30b9b46a..acbd0b16f1 100644 --- a/InvenTree/part/templates/part/category.html +++ b/InvenTree/part/templates/part/category.html @@ -164,9 +164,6 @@
- @@ -290,10 +287,6 @@ }); }); - $("#part-export").click(function() { - downloadTableData($('#part-table')); - }); - {% if roles.part.add %} $("#part-create").click(function() { diff --git a/InvenTree/part/templates/part/detail.html b/InvenTree/part/templates/part/detail.html index 172315b50e..9007d91839 100644 --- a/InvenTree/part/templates/part/detail.html +++ b/InvenTree/part/templates/part/detail.html @@ -831,12 +831,7 @@ ], url: "{% url 'api-stock-list' %}", }); - - $("#stock-export").click(function() { - - downloadTableData($("#stock-table")); - }); - + $('#item-create').click(function () { createNewStockItem({ data: { diff --git a/InvenTree/stock/templates/stock/location.html b/InvenTree/stock/templates/stock/location.html index 575a798fb2..1da0030bc6 100644 --- a/InvenTree/stock/templates/stock/location.html +++ b/InvenTree/stock/templates/stock/location.html @@ -239,10 +239,6 @@ }); {% endif %} - $("#stock-export").click(function() { - downloadTableData($('#stock-table')); - }); - $('#location-create').click(function () { createStockLocation({ diff --git a/InvenTree/templates/js/translated/filters.js b/InvenTree/templates/js/translated/filters.js index 78ed30eefa..1a8c0267ee 100644 --- a/InvenTree/templates/js/translated/filters.js +++ b/InvenTree/templates/js/translated/filters.js @@ -256,7 +256,7 @@ function generateFilterInput(tableKey, filterKey) { * @param {*} table - bootstrapTable element to update * @param {*} target - name of target element on page */ -function setupFilterList(tableKey, table, target) { +function setupFilterList(tableKey, table, target, options={}) { var addClicked = false; @@ -283,6 +283,11 @@ function setupFilterList(tableKey, table, target) { var buttons = ''; + // Add download button + if (options.download) { + buttons += ``; + } + buttons += ``; // If there are filters defined for this table, add more buttons @@ -295,7 +300,7 @@ function setupFilterList(tableKey, table, target) { } element.html(` -
+
${buttons}
`); @@ -322,6 +327,13 @@ function setupFilterList(tableKey, table, target) { $(table).bootstrapTable('refresh'); }); + // Add a callback for downloading table data + if (options.download) { + element.find(`#download-${tableKey}`).click(function() { + downloadTableData($(table)); + }); + } + // Add a callback for adding a new filter element.find(`#${add}`).click(function clicked() { diff --git a/InvenTree/templates/js/translated/part.js b/InvenTree/templates/js/translated/part.js index d42755a0f0..a0887f9473 100644 --- a/InvenTree/templates/js/translated/part.js +++ b/InvenTree/templates/js/translated/part.js @@ -1218,7 +1218,7 @@ function loadPartTable(table, url, options={}) { filters[key] = params[key]; } - setupFilterList('parts', $(table), options.filterTarget || null); + setupFilterList('parts', $(table), options.filterTarget, {download: true}); var columns = [ { diff --git a/InvenTree/templates/js/translated/stock.js b/InvenTree/templates/js/translated/stock.js index 6239a886ab..dcca969a28 100644 --- a/InvenTree/templates/js/translated/stock.js +++ b/InvenTree/templates/js/translated/stock.js @@ -1571,7 +1571,7 @@ function loadStockTable(table, options) { original[k] = params[k]; } - setupFilterList(filterKey, table, filterTarget); + setupFilterList(filterKey, table, filterTarget, {download: true}); // Override the default values, or add new ones for (var key in params) { diff --git a/InvenTree/templates/stock_table.html b/InvenTree/templates/stock_table.html index a8a4ec6691..d609e78253 100644 --- a/InvenTree/templates/stock_table.html +++ b/InvenTree/templates/stock_table.html @@ -11,9 +11,6 @@
- {% if barcodes %}
From 0af636d2b1d2e038225fd7ed29928ffb22a9d662 Mon Sep 17 00:00:00 2001 From: Oliver Date: Thu, 3 Mar 2022 21:23:01 +1100 Subject: [PATCH 42/66] Pass options back through when re-creating filter list --- InvenTree/templates/js/translated/filters.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/InvenTree/templates/js/translated/filters.js b/InvenTree/templates/js/translated/filters.js index 1a8c0267ee..ceef79f66d 100644 --- a/InvenTree/templates/js/translated/filters.js +++ b/InvenTree/templates/js/translated/filters.js @@ -370,14 +370,14 @@ function setupFilterList(tableKey, table, target, options={}) { reloadTableFilters(table, filters); // Run this function again - setupFilterList(tableKey, table, target); + setupFilterList(tableKey, table, target, options); } }); } else { addClicked = false; - setupFilterList(tableKey, table, target); + setupFilterList(tableKey, table, target, options); } }); @@ -388,7 +388,7 @@ function setupFilterList(tableKey, table, target, options={}) { reloadTableFilters(table, filters); - setupFilterList(tableKey, table, target); + setupFilterList(tableKey, table, target, options); }); // Add callback for deleting each filter @@ -402,7 +402,7 @@ function setupFilterList(tableKey, table, target, options={}) { reloadTableFilters(table, filters); // Run this function again! - setupFilterList(tableKey, table, target); + setupFilterList(tableKey, table, target, options); }); } From 0ba71956cd0ac0af9f3773311f34c7407337dbcf Mon Sep 17 00:00:00 2001 From: Oliver Date: Fri, 4 Mar 2022 00:02:30 +1100 Subject: [PATCH 43/66] Add unit tests --- InvenTree/part/templates/part/detail.html | 5 --- InvenTree/stock/test_api.py | 53 +++++++++++++++++++++++ 2 files changed, 53 insertions(+), 5 deletions(-) diff --git a/InvenTree/part/templates/part/detail.html b/InvenTree/part/templates/part/detail.html index 9007d91839..462389c476 100644 --- a/InvenTree/part/templates/part/detail.html +++ b/InvenTree/part/templates/part/detail.html @@ -28,11 +28,6 @@
- {% if part.is_template %} -
- {% blocktrans with full_name=part.full_name%}Showing stock for all variants of {{full_name}}{% endblocktrans %} -
- {% endif %} {% include "stock_table.html" %}
diff --git a/InvenTree/stock/test_api.py b/InvenTree/stock/test_api.py index a9dbe9e723..81973aed31 100644 --- a/InvenTree/stock/test_api.py +++ b/InvenTree/stock/test_api.py @@ -6,9 +6,12 @@ Unit testing for the Stock API from __future__ import unicode_literals import os +import io +import tablib from datetime import datetime, timedelta +import django.http from django.urls import reverse from rest_framework import status @@ -261,6 +264,56 @@ class StockItemListTest(StockAPITestCase): self.assertEqual(len(response['results']), n) + def export_data(self, filters=None): + + if not filters: + filters = {} + + filters['export'] = 'csv' + + response = self.client.get(self.list_url, data=filters) + + self.assertEqual(response.status_code, 200) + + self.assertTrue(isinstance(response, django.http.response.StreamingHttpResponse)) + + file_object = io.StringIO(response.getvalue().decode('utf-8')) + + dataset = tablib.Dataset().load(file_object, 'csv', headers=True) + + return dataset + + def test_export(self): + """ + Test exporting of Stock data via the API + """ + + dataset = self.export_data({}) + + self.assertEqual(len(dataset), 20) + + # Expected headers + headers = [ + 'part', + 'customer', + 'location', + 'parent', + 'quantity', + 'status', + ] + + for h in headers: + self.assertIn(h, dataset.headers) + + # Now, add a filter to the results + dataset = self.export_data({'location': 1}) + + self.assertEqual(len(dataset), 2) + + dataset = self.export_data({'part': 25}) + + self.assertEqual(len(dataset), 8) + class StockItemTest(StockAPITestCase): """ From 0ac86417a7c3a110838d370ea16224fabde24f41 Mon Sep 17 00:00:00 2001 From: Oliver Date: Fri, 4 Mar 2022 00:36:26 +1100 Subject: [PATCH 44/66] Adds "duplicate line item" button to purchase order and sales order tables --- .../order/purchase_order_detail.html | 32 ++--- .../templates/order/sales_order_detail.html | 26 ++-- InvenTree/templates/js/translated/order.js | 111 ++++++++++++++++++ 3 files changed, 127 insertions(+), 42 deletions(-) diff --git a/InvenTree/order/templates/order/purchase_order_detail.html b/InvenTree/order/templates/order/purchase_order_detail.html index b9972d73fc..53f973ee20 100644 --- a/InvenTree/order/templates/order/purchase_order_detail.html +++ b/InvenTree/order/templates/order/purchase_order_detail.html @@ -152,32 +152,16 @@ {% if order.status == PurchaseOrderStatus.PENDING %} $('#new-po-line').click(function() { + var fields = poLineItemFields({ + order: {{ order.pk }}, + supplier: {{ order.supplier.pk }}, + {% if order.supplier.currency %} + currency: '{{ order.supplier.currency }}', + {% endif %} + }); constructForm('{% url "api-po-line-list" %}', { - fields: { - order: { - value: {{ order.pk }}, - hidden: true, - }, - part: { - filters: { - part_detail: true, - supplier_detail: true, - supplier: {{ order.supplier.pk }}, - }, - }, - quantity: {}, - reference: {}, - purchase_price: {}, - purchase_price_currency: { - {% if order.supplier.currency %} - value: '{{ order.supplier.currency }}', - {% endif %} - }, - target_date: {}, - destination: {}, - notes: {}, - }, + fields: fields, method: 'POST', title: '{% trans "Add Line Item" %}', onSuccess: function() { diff --git a/InvenTree/order/templates/order/sales_order_detail.html b/InvenTree/order/templates/order/sales_order_detail.html index 3676268f5c..9797c8dedf 100644 --- a/InvenTree/order/templates/order/sales_order_detail.html +++ b/InvenTree/order/templates/order/sales_order_detail.html @@ -221,29 +221,19 @@ }, }); - function reloadTable() { - $("#so-lines-table").bootstrapTable("refresh"); - } - $("#new-so-line").click(function() { + var fields = soLineItemFields({ + order: {{ order.pk }}, + }); + constructForm('{% url "api-so-line-list" %}', { - fields: { - order: { - value: {{ order.pk }}, - hidden: true, - }, - part: {}, - quantity: {}, - reference: {}, - sale_price: {}, - sale_price_currency: {}, - target_date: {}, - notes: {}, - }, + fields: fields, method: 'POST', title: '{% trans "Add Line Item" %}', - onSuccess: reloadTable, + onSuccess: function() { + $("#so-lines-table").bootstrapTable("refresh"); + }, }); }); diff --git a/InvenTree/templates/js/translated/order.js b/InvenTree/templates/js/translated/order.js index 5f825c6738..f20d244cc0 100644 --- a/InvenTree/templates/js/translated/order.js +++ b/InvenTree/templates/js/translated/order.js @@ -281,6 +281,65 @@ function createPurchaseOrder(options={}) { } +/* Construct a set of fields for the SalesOrderLineItem form */ +function soLineItemFields(options={}) { + + var fields = { + order: { + hidden: true, + }, + part: {}, + quantity: {}, + reference: {}, + sale_price: {}, + sale_price_currency: {}, + target_date: {}, + notes: {}, + }; + + if (options.order) { + fields.order.value = options.order; + } + + return fields; +} + + +/* Construct a set of fields for the PurchaseOrderLineItem form */ +function poLineItemFields(options={}) { + + var fields = { + order: { + hidden: true, + }, + part: { + filters: { + part_detail: true, + supplier_detail: true, + supplier: options.supplier, + } + }, + quantity: {}, + reference: {}, + purchase_price: {}, + purchase_price_currency: {}, + target_date: {}, + destination: {}, + notes: {}, + }; + + if (options.order) { + fields.order.value = options.order; + } + + if (options.currency) { + fields.purchase_price_currency.value = options.currency; + } + + return fields; +} + + function removeOrderRowFromOrderWizard(e) { /* Remove a part selection from an order form. */ @@ -293,6 +352,7 @@ function removeOrderRowFromOrderWizard(e) { $('#' + row).remove(); } + function newSupplierPartFromOrderWizard(e) { /* Create a new supplier part directly from an order form. * Launches a secondary modal and (if successful), @@ -995,6 +1055,32 @@ function loadPurchaseOrderLineItemTable(table, options={}) { function setupCallbacks() { if (options.allow_edit) { + + // Callback for "duplicate" button + $(table).find('.button-line-duplicate').click(function() { + var pk = $(this).attr('pk'); + + inventreeGet(`/api/order/po-line/${pk}/`, {}, { + success: function(data) { + + var fields = poLineItemFields({ + supplier: options.supplier, + }); + + constructForm('{% url "api-po-line-list" %}', { + method: 'POST', + fields: fields, + data: data, + title: '{% trans "Duplicate Line Item" %}', + onSuccess: function(response) { + $(table).bootstrapTable('refresh'); + } + }); + } + }); + }) + + // Callback for "edit" button $(table).find('.button-line-edit').click(function() { var pk = $(this).attr('pk'); @@ -1022,6 +1108,7 @@ function loadPurchaseOrderLineItemTable(table, options={}) { }); }); + // Callback for "delete" button $(table).find('.button-line-delete').click(function() { var pk = $(this).attr('pk'); @@ -1270,6 +1357,7 @@ function loadPurchaseOrderLineItemTable(table, options={}) { } if (options.allow_edit) { + html += makeIconButton('fa-clone', 'button-line-duplicate', pk, '{% trans "Duplicate line item" %}'); html += makeIconButton('fa-edit icon-blue', 'button-line-edit', pk, '{% trans "Edit line item" %}'); html += makeIconButton('fa-trash-alt icon-red', 'button-line-delete', pk, '{% trans "Delete line item" %}'); } @@ -2449,6 +2537,7 @@ function loadSalesOrderLineItemTable(table, options={}) { html += makeIconButton('fa-dollar-sign icon-green', 'button-price', pk, '{% trans "Calculate price" %}'); } + html += makeIconButton('fa-clone', 'button-duplicate', pk, '{% trans "Duplicate line item" %}'); html += makeIconButton('fa-edit icon-blue', 'button-edit', pk, '{% trans "Edit line item" %}'); var delete_disabled = false; @@ -2480,6 +2569,28 @@ function loadSalesOrderLineItemTable(table, options={}) { // Configure callback functions once the table is loaded function setupCallbacks() { + // Callback for duplicating line items + $(table).find('.button-duplicate').click(function() { + var pk = $(this).attr('pk'); + + inventreeGet(`/api/order/so-line/${pk}/`, {}, { + success: function(data) { + + var fields = soLineItemFields(); + + constructForm('{% url "api-so-line-list" %}', { + method: 'POST', + fields: fields, + data: data, + title: '{% trans "Duplicate Line Item" %}', + onSuccess: function(response) { + $(table).bootstrapTable('refresh'); + } + }); + } + }); + }); + // Callback for editing line items $(table).find('.button-edit').click(function() { var pk = $(this).attr('pk'); From 846899fa53c584a744bcef4fc9fdaf8e8c000a49 Mon Sep 17 00:00:00 2001 From: Oliver Date: Fri, 4 Mar 2022 00:45:30 +1100 Subject: [PATCH 45/66] Adds ability to download purchase order line item table --- InvenTree/order/api.py | 31 +++++++++++++++++++++- InvenTree/templates/js/translated/order.js | 6 ++--- 2 files changed, 33 insertions(+), 4 deletions(-) diff --git a/InvenTree/order/api.py b/InvenTree/order/api.py index 66c61bd0d7..2d079f8d45 100644 --- a/InvenTree/order/api.py +++ b/InvenTree/order/api.py @@ -16,10 +16,11 @@ from rest_framework.response import Response from company.models import SupplierPart from InvenTree.filters import InvenTreeOrderingFilter -from InvenTree.helpers import str2bool +from InvenTree.helpers import str2bool, DownloadFile from InvenTree.api import AttachmentMixin from InvenTree.status_codes import PurchaseOrderStatus, SalesOrderStatus +from order.admin import POLineItemResource import order.models as models import order.serializers as serializers from part.models import Part @@ -370,6 +371,34 @@ class POLineItemList(generics.ListCreateAPIView): return queryset + def list(self, request, *args, **kwargs): + + queryset = self.filter_queryset(self.get_queryset()) + + # Check if we wish to export the queried data to a file + export_format = request.query_params.get('export', None) + + if export_format: + export_format = str(export_format).strip().lower() + + if export_format in ['csv', 'tsv', 'xls', 'xlsx']: + dataset = POLineItemResource().export(queryset=queryset) + + filedata = dataset.export(export_format) + + filename = f"InvenTree_PurchaseOrderData.{export_format}" + + return DownloadFile(filedata, filename) + + page = self.paginate_queryset(queryset) + + if page is not None: + serializer = self.get_serializer(page, many=True) + return self.get_paginated_response(serializer.data) + + serializer = self.get_serializer(queryset, many=True) + return Response(serializer.data) + filter_backends = [ rest_filters.DjangoFilterBackend, filters.SearchFilter, diff --git a/InvenTree/templates/js/translated/order.js b/InvenTree/templates/js/translated/order.js index f20d244cc0..b4757e2494 100644 --- a/InvenTree/templates/js/translated/order.js +++ b/InvenTree/templates/js/translated/order.js @@ -1051,7 +1051,7 @@ function loadPurchaseOrderLineItemTable(table, options={}) { var target = options.filter_target || '#filter-list-purchase-order-lines'; - setupFilterList('purchaseorderlineitem', $(table), target); + setupFilterList('purchaseorderlineitem', $(table), target, {download: true}); function setupCallbacks() { if (options.allow_edit) { @@ -1078,7 +1078,7 @@ function loadPurchaseOrderLineItemTable(table, options={}) { }); } }); - }) + }); // Callback for "edit" button $(table).find('.button-line-edit').click(function() { @@ -2583,7 +2583,7 @@ function loadSalesOrderLineItemTable(table, options={}) { fields: fields, data: data, title: '{% trans "Duplicate Line Item" %}', - onSuccess: function(response) { + onSuccess: function(response) { $(table).bootstrapTable('refresh'); } }); From 64dd8704368f1184096d4c30dd3ea0c2148ae20a Mon Sep 17 00:00:00 2001 From: Oliver Date: Fri, 4 Mar 2022 12:23:47 +1100 Subject: [PATCH 46/66] Fix broken URL --- InvenTree/templates/js/translated/order.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/InvenTree/templates/js/translated/order.js b/InvenTree/templates/js/translated/order.js index b4757e2494..6ea4e9ebb6 100644 --- a/InvenTree/templates/js/translated/order.js +++ b/InvenTree/templates/js/translated/order.js @@ -959,7 +959,7 @@ function loadPurchaseOrderTable(table, options) { sortable: true, sortName: 'supplier__name', formatter: function(value, row) { - return imageHoverIcon(row.supplier_detail.image) + renderLink(row.supplier_detail.name, `/company/${row.supplier}/purchase-orders/`); + return imageHoverIcon(row.supplier_detail.image) + renderLink(row.supplier_detail.name, `/company/${row.supplier}/?display=purchase-orders`); } }, { From 434f563a41f6dcf78365c96e634d941b0c63e3ab Mon Sep 17 00:00:00 2001 From: Oliver Date: Fri, 4 Mar 2022 15:26:00 +1100 Subject: [PATCH 47/66] Adds API endpoint for "auto allocating" stock items against a build order. - If stock exists in multiple locations, and the user "does not care" where to take from, simply iterate through and take --- InvenTree/build/api.py | 32 ++++++++ InvenTree/build/models.py | 82 +++++++++++++++++++++ InvenTree/build/serializers.py | 48 ++++++++++++ InvenTree/build/templates/build/detail.html | 19 ++++- InvenTree/part/models.py | 9 ++- InvenTree/templates/js/translated/build.js | 29 ++++++++ 6 files changed, 214 insertions(+), 5 deletions(-) diff --git a/InvenTree/build/api.py b/InvenTree/build/api.py index 310d4d7f09..114268fa2b 100644 --- a/InvenTree/build/api.py +++ b/InvenTree/build/api.py @@ -322,6 +322,37 @@ class BuildFinish(generics.CreateAPIView): return ctx +class BuildAutoAllocate(generics.CreateAPIView): + """ + API endpoint for 'automatically' allocating stock against a build order. + + - Only looks at 'untracked' parts + - If stock exists in a single location, easy! + - If user decides that stock items are "fungible", allocate against multiple stock items + - If the user wants to, allocate substite parts if the primary parts are not available. + """ + + queryset = Build.objects.none() + + serializer_class = build.serializers.BuildAutoAllocationSerializer + + def get_serializer_context(self): + """ + Provide the Build object to the serializer context + """ + + context = super().get_serializer_context() + + try: + context['build'] = Build.objects.get(pk=self.kwargs.get('pk', None)) + except: + pass + + context['request'] = self.request + + return context + + class BuildAllocate(generics.CreateAPIView): """ API endpoint to allocate stock items to a build order @@ -477,6 +508,7 @@ build_api_urls = [ # Build Detail url(r'^(?P\d+)/', include([ url(r'^allocate/', BuildAllocate.as_view(), name='api-build-allocate'), + url(r'^auto-allocate/', BuildAutoAllocate.as_view(), name='api-build-auto-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'), diff --git a/InvenTree/build/models.py b/InvenTree/build/models.py index 01c2c781e9..e2a7be0c45 100644 --- a/InvenTree/build/models.py +++ b/InvenTree/build/models.py @@ -25,6 +25,8 @@ from markdownx.models import MarkdownxField from mptt.models import MPTTModel, TreeForeignKey from mptt.exceptions import InvalidMove +from rest_framework import serializers + from InvenTree.status_codes import BuildStatus, StockStatus, StockHistoryCode from InvenTree.helpers import increment, getSetting, normalize, MakeBarcode from InvenTree.models import InvenTreeAttachment, ReferenceIndexingMixin @@ -823,6 +825,86 @@ class Build(MPTTModel, ReferenceIndexingMixin): self.save() + @transaction.atomic + def auto_allocate_stock(self, user, **kwargs): + """ + Automatically allocate stock items against this build order, + following a number of 'guidelines': + + - Only "untracked" BOM items are considered (tracked BOM items must be manually allocated) + - If a particular BOM item is already fully allocated, it is skipped + - Extract all available stock items for the BOM part + - If variant stock is allowed, extract stock for those too + - If substitute parts are available, extract stock for those also + - If a single stock item is found, we can allocate that and move on! + - If multiple stock items are found, we *may* be able to allocate: + - If the calling function has specified that items are interchangeable + """ + + location = kwargs.get('location', None) + interchangeable = kwargs.get('interchangeable', False) + substitutes = kwargs.get('substitutes', True) + + # Get a list of all 'untracked' BOM items + for bom_item in self.untracked_bom_items: + + unallocated_quantity = self.unallocated_quantity(bom_item) + + if unallocated_quantity <= 0: + # This BomItem is fully allocated, we can continue + continue + + # Check which parts we can "use" (may include variants and substitutes) + available_parts = bom_item.get_valid_parts_for_allocation( + allow_variants=True, + allow_substitutes=substitutes, + ) + + # Look for available stock items + available_stock = StockModels.StockItem.objects.filter(StockModels.StockItem.IN_STOCK_FILTER) + + # Filter by list of available parts + available_stock = available_stock.filter( + part__in=[p for p in available_parts], + ) + + if location: + # Filter only stock items located "below" the specified location + sublocations = location.get_descendants(include_self=True) + available_stock = available_stock.filter(location__in=[loc for loc in sublocations]) + + if available_stock.count() == 0: + # No stock items are available + continue + elif available_stock.count() == 1 or interchangeable: + # Either there is only a single stock item available, + # or all items are "interchangeable" and we don't care where we take stock from + + for stock_item in available_stock: + # How much of the stock item is "available" for allocation? + quantity = min(unallocated_quantity, stock_item.unallocated_quantity()) + + if quantity > 0: + + try: + BuildItem.objects.create( + build=self, + bom_item=bom_item, + stock_item=stock_item, + quantity=quantity, + ) + + # Subtract the required quantity + unallocated_quantity -= quantity + + except (ValidationError, serializers.ValidationError) as exc: + # Catch model errors and re-throw as DRF errors + raise ValidationError(detail=serializers.as_serializer_error(exc)) + + if unallocated_quantity <= 0: + # We have now fully-allocated this BomItem - no need to continue! + break + def required_quantity(self, bom_item, output=None): """ Get the quantity of a part required to complete the particular build output. diff --git a/InvenTree/build/serializers.py b/InvenTree/build/serializers.py index 0a8964ee82..9085a25f30 100644 --- a/InvenTree/build/serializers.py +++ b/InvenTree/build/serializers.py @@ -709,6 +709,54 @@ class BuildAllocationSerializer(serializers.Serializer): raise ValidationError(detail=serializers.as_serializer_error(exc)) +class BuildAutoAllocationSerializer(serializers.Serializer): + """ + DRF serializer for auto allocating stock items against a build order + """ + + class Meta: + fields = [ + 'location', + 'interchangeable', + 'substitutes', + ] + + location = serializers.PrimaryKeyRelatedField( + queryset=StockLocation.objects.all(), + many=False, + allow_null=True, + required=False, + label=_('Source Location'), + help_text=_('Stock location where parts are to be sourced (leave blank to take from any location)'), + ) + + interchangeable = serializers.BooleanField( + default=False, + label=_('Interchangeable Stock'), + help_text=_('Stock items in multiple locations can be used interchangeably'), + ) + + substitutes = serializers.BooleanField( + default=True, + label=_('Substitute Stock'), + help_text=_('Allow allocation of substitute parts'), + ) + + def save(self): + + data = self.validated_data + + request = self.context['request'] + build = self.context['build'] + + build.auto_allocate_stock( + request.user, + location=data.get('location', None), + interchangeable=data['interchangeable'], + substitutes=data['substitutes'], + ) + + class BuildItemSerializer(InvenTreeModelSerializer): """ Serializes a BuildItem object """ diff --git a/InvenTree/build/templates/build/detail.html b/InvenTree/build/templates/build/detail.html index 1e31857ba5..ca909f82f7 100644 --- a/InvenTree/build/templates/build/detail.html +++ b/InvenTree/build/templates/build/detail.html @@ -177,7 +177,10 @@ - +