Repository: inventree/InvenTree

Issue:
[FR] Non-interactive, environment variable based set-up in Docker deployments
**Is your feature request the result of a bug?**
No. It is a quality of life improvement.

**Problem**
As discussed on the side in #2454, Docker-based deployment could be simplified a little bit. Some systems, in particular Synology NAS, do not have docker-compose nor do they allow for easy terminal access.

**Suggested solution**
I could also see an approach where bootstrapping is done through environment variables which could be removed by the user once it's set up (and then heavily complains about it if the user doesn't unset them afterwards), e.g., `INVENTREE_ADMIN_USER` and `INVENTREE_ADMIN_PASSWORD`. A start-up script could check for the presence of these, run migrations and create user accounts accordingly. That would probably be a more generic approach that might also work for other non-Synology deployment situations.

**Describe alternatives you've considered**
None on a generic level; for Synology NAS systems, a Synology package could work.

**Examples of other systems**
Show how other software handles your FR if you have examples.

**Do you want to develop this?**
Please no, my bash is terrible 😅


Discussion:
I believe that @matmair was looking into a Synology docker setup, it seems that this would be a popular feature!
@SchrodingersGat very much so! Already looking into more container stuff as most new issues seem to be associated with that. This also plays into #2309, possible self-updating and a getting started setup in general.
@SchrodingersGat: sounds great! If you want to, you can reject this FR. I personally think this approach would be slightly more open to other deployment targets where the initial assumptions hold true (lack of docker-compose and/or interactive terminals are difficult).
@MrManny due to our architecture it will never be a great experience using inventree with only one container as long running actions will become blocking then.
I think there is a misunderstanding. I am not asking to throw everything into one container. I am asking for environment variables to automate the steps that would require an interactive terminal, i.e., `invoke update` and `invoke superuser` using input provided through environment variables.
@MrManny I understood that but without an orchestrator like docker-compose or Kubernetes, you will run into errors as all things need to be running for everything to work. So a simple interface like for example Synology might not be enough / cause random errors.
@mrmanny I have a running package now for a DS920 / DS720. One question: where would you recommend publishing? It seems quite hard to publish to the official store itself and there are a ton of other options. Not sure what would be applicable.
@matmair: that is an excellent question that I am unfortunately unable to answer 😅 I have not published anything on Synology before. But if you want me to test a package on my DS218+ running DSM 7.0.1-42218, I am all yours!
@MrManny there seem to be so many different open stores, I am a bit confused. The Synology store itself has quite a rigorous user agreement so I am not really a fan of accepting that. I will think of a solution.
Will have to find the right format for your platform, I will post a link to the store I choose once I figure it all out.

Resolving patch:
diff --git a/InvenTree/InvenTree/apps.py b/InvenTree/InvenTree/apps.py
--- a/InvenTree/InvenTree/apps.py
+++ b/InvenTree/InvenTree/apps.py
@@ -4,8 +4,13 @@
 
 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
+from .config import get_setting
 import InvenTree.tasks
 
 
@@ -26,6 +31,9 @@ def ready(self):
             if not isInTestMode():
                 self.update_exchange_rates()
 
+        if canAppAccessDatabase() or settings.TESTING_ENV:
+            self.add_user_on_startup()
+
     def remove_obsolete_tasks(self):
         """
         Delete any obsolete scheduled tasks in the database
@@ -138,3 +146,54 @@ def update_exchange_rates(self):
                 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"""
+        # stop if checks were already created
+        if hasattr(settings, 'USER_ADDED') and settings.USER_ADDED:
+            return
+
+        # get values
+        add_user = get_setting(
+            'INVENTREE_ADMIN_USER',
+            settings.CONFIG.get('admin_user', False)
+        )
+        add_email = get_setting(
+            'INVENTREE_ADMIN_EMAIL',
+            settings.CONFIG.get('admin_email', False)
+        )
+        add_password = get_setting(
+            'INVENTREE_ADMIN_PASSWORD',
+            settings.CONFIG.get('admin_password', False)
+        )
+
+        # check if all values are present
+        set_variables = 0
+        for tested_var in [add_user, add_email, add_password]:
+            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
+
+        # good to go -> create user
+        user = get_user_model()
+        try:
+            with transaction.atomic():
+                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)}')
+            if settings.TESTING_ENV:
+                raise _e
+
+        # do not try again
+        settings.USER_ADDED = True
diff --git a/InvenTree/InvenTree/settings.py b/InvenTree/InvenTree/settings.py
--- a/InvenTree/InvenTree/settings.py
+++ b/InvenTree/InvenTree/settings.py
@@ -37,6 +37,8 @@ def _is_true(x):
 
 # Determine if we are running in "test" mode e.g. "manage.py test"
 TESTING = 'test' in sys.argv
+# Are enviroment variables manipulated by tests? Needs to be set by testing code
+TESTING_ENV = False
 
 # New requirement for django 3.2+
 DEFAULT_AUTO_FIELD = 'django.db.models.AutoField'


Test patch:
diff --git a/InvenTree/InvenTree/tests.py b/InvenTree/InvenTree/tests.py
--- a/InvenTree/InvenTree/tests.py
+++ b/InvenTree/InvenTree/tests.py
@@ -1,9 +1,12 @@
 
 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
+from django.conf import settings
 
 from djmoney.money import Money
 from djmoney.contrib.exchange.models import Rate, convert_money
@@ -407,3 +410,46 @@ def test_TestMode(self):
 
     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()
+
+    @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.run_reload()
+        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)
+
+        # make sure to clean up
+        settings.TESTING_ENV = False