diff --git a/src/backend/InvenTree/InvenTree/apps.py b/src/backend/InvenTree/InvenTree/apps.py index 59f480577c..ea13d092cc 100644 --- a/src/backend/InvenTree/InvenTree/apps.py +++ b/src/backend/InvenTree/InvenTree/apps.py @@ -1,5 +1,6 @@ """AppConfig for InvenTree app.""" +import sys from importlib import import_module from pathlib import Path @@ -20,6 +21,7 @@ from common.settings import get_global_setting, set_global_setting from InvenTree.config import get_setting logger = structlog.get_logger('inventree') +MIGRATIONS_CHECK_DONE = False class InvenTreeConfig(AppConfig): @@ -56,6 +58,9 @@ class InvenTreeConfig(AppConfig): return if InvenTree.ready.canAppAccessDatabase() or settings.TESTING_ENV: + # Ensure there are no open migrations + self.ensure_migrations_done() + self.remove_obsolete_tasks() self.collect_tasks() self.start_background_tasks() @@ -382,3 +387,14 @@ class InvenTreeConfig(AppConfig): from generic.states import storage storage.collect() + + def ensure_migrations_done(self=None): + """Ensures there are no open migrations, stop if inconsistent state.""" + global MIGRATIONS_CHECK_DONE + if MIGRATIONS_CHECK_DONE: + return + + if not InvenTree.tasks.check_for_migrations(): + logger.error('INVE-W8: Database Migrations required') + sys.exit(1) + MIGRATIONS_CHECK_DONE = True diff --git a/src/backend/InvenTree/InvenTree/tasks.py b/src/backend/InvenTree/InvenTree/tasks.py index dbf9757dd9..5c39b8e083 100644 --- a/src/backend/InvenTree/InvenTree/tasks.py +++ b/src/backend/InvenTree/InvenTree/tasks.py @@ -629,10 +629,12 @@ def get_migration_plan(): @scheduled_task(ScheduledTask.DAILY) -def check_for_migrations(force: bool = False, reload_registry: bool = True): +def check_for_migrations(force: bool = False, reload_registry: bool = True) -> bool: """Checks if migrations are needed. If the setting auto_update is enabled we will start updating. + + Returns bool indicating if migrations are up to date """ from plugin import registry @@ -654,14 +656,14 @@ def check_for_migrations(force: bool = False, reload_registry: bool = True): # Check if there are any open migrations if not plan: set_pending_migrations(0) - return + return True set_pending_migrations(n) # Test if auto-updates are enabled if not force and not get_setting('INVENTREE_AUTO_UPDATE', 'auto_update'): logger.info('Auto-update is disabled - skipping migrations') - return + return False # Log open migrations for migration in plan: @@ -696,6 +698,8 @@ def check_for_migrations(force: bool = False, reload_registry: bool = True): # are loaded fully in their new state. registry.reload_plugins(full_reload=True, force_reload=True, collect=True) + return True + def email_user(user_id: int, subject: str, message: str) -> None: """Send a message to a user.""" diff --git a/src/backend/InvenTree/part/apps.py b/src/backend/InvenTree/part/apps.py index 72aa03ab6d..e11fc9ed1e 100644 --- a/src/backend/InvenTree/part/apps.py +++ b/src/backend/InvenTree/part/apps.py @@ -6,6 +6,7 @@ from django.db.utils import OperationalError, ProgrammingError import structlog import InvenTree.ready +from InvenTree.apps import InvenTreeConfig logger = structlog.get_logger('inventree') @@ -28,6 +29,9 @@ class PartConfig(AppConfig): return if InvenTree.ready.canAppAccessDatabase(): + # Ensure there are no open migrations + InvenTreeConfig.ensure_migrations_done() + self.update_trackable_status() self.reset_part_pricing_flags() diff --git a/tasks.py b/tasks.py index eebb73d939..84c9a881b5 100644 --- a/tasks.py +++ b/tasks.py @@ -7,6 +7,7 @@ import re import shutil import subprocess import sys +from functools import wraps from pathlib import Path from platform import python_version from typing import Optional @@ -30,6 +31,15 @@ def is_rtd_environment(): return is_true(os.environ.get('READTHEDOCS', 'False')) +def is_deb_environment(): + """Check if the InvenTree environment is running in a debug environment.""" + from src.backend.InvenTree.InvenTree.config import is_true + + return is_true(os.environ.get('INVENTREE_DEBUG', 'False')) or is_true( + os.environ.get('RUNNER_DEBUG', 'False') + ) + + def task_exception_handler(t, v, tb): """Handle exceptions raised by tasks. @@ -82,6 +92,30 @@ def info(*args): print(wrap_color(msg, '94')) +def state_logger(fn=None, method_name=None): + """Decorator to log state markers before/after function execution, optionally accepting arguments.""" + + def decorator(func): + func.method_name = method_name or f'invoke task named `{func.__name__}`' + + @wraps(func) + def wrapped(c, *args, **kwargs): + do_log = is_deb_environment() + if do_log: + info(f'# {func.method_name}| start') + func(c, *args, **kwargs) + if do_log: + info(f'# {func.method_name}| done') + + return wrapped + + if fn and callable(fn): + return decorator(fn) + elif fn and isinstance(fn, str): + method_name = fn + return decorator + + def checkInvokeVersion(): """Check that the installed invoke version meets minimum requirements.""" MIN_INVOKE_VERSION = '2.0.0' @@ -315,6 +349,7 @@ def node_available(versions: bool = False, bypass_yarn: bool = False): return ret(yarn_passes and node_version, node_version, yarn_version) +@state_logger def check_file_existence(filename: Path, overwrite: bool = False): """Checks if a file exists and asks the user if it should be overwritten. @@ -335,6 +370,7 @@ def check_file_existence(filename: Path, overwrite: bool = False): # Install tasks @task(help={'uv': 'Use UV (experimental package manager)'}) +@state_logger('TSK01') def plugins(c, uv=False): """Installs all plugins as specified in 'plugins.txt'.""" from src.backend.InvenTree.InvenTree.config import get_plugin_file @@ -360,6 +396,7 @@ def plugins(c, uv=False): 'skip_plugins': 'Skip plugin installation', } ) +@state_logger('TSK02') def install(c, uv=False, skip_plugins=False): """Installs required python packages.""" # Ensure path is relative to *this* directory @@ -447,6 +484,7 @@ def rebuild_thumbnails(c): @task +@state_logger('TSK09') def clean_settings(c): """Clean the setting tables of old settings.""" info('Cleaning old settings from the database') @@ -471,6 +509,7 @@ def remove_mfa(c, mail=''): 'skip_plugins': 'Ignore collection of plugin static files', } ) +@state_logger('TSK08') def static(c, frontend=False, clear=True, skip_plugins=False): """Copies required static files to the STATIC_ROOT directory, as per Django requirements.""" if frontend and node_available(): @@ -521,6 +560,7 @@ def translate(c, ignore_static=False, no_frontend=False): 'path': 'Specify path for generated backup files (leave blank for default path)', } ) +@state_logger('TSK04') def backup(c, clean=False, path=None): """Backup the database and media files.""" info('Backing up InvenTree database...') @@ -597,6 +637,7 @@ def restore( @task(post=[rebuild_models, rebuild_thumbnails]) +@state_logger('TSK05') def migrate(c): """Performs database migrations. @@ -629,6 +670,7 @@ def showmigrations(c, app=''): 'uv': 'Use UV (experimental package manager)', }, ) +@state_logger('TSK03') def update( c, skip_backup: bool = False, @@ -935,6 +977,7 @@ def import_fixtures(c): # Execution tasks @task +@state_logger('TSK10') def wait(c): """Wait until the database connection is ready.""" info('Waiting for database connection...') @@ -1224,6 +1267,7 @@ def setup_test( 'no_default': 'Do not use default settings for schema (default = off/False)', } ) +@state_logger('TSK11') def schema( c, filename='schema.yml', overwrite=False, ignore_warnings=False, no_default=False ): @@ -1366,6 +1410,7 @@ def frontend_check(c): @task +@state_logger('TSK06') def frontend_compile(c): """Generate react frontend. @@ -1437,6 +1482,7 @@ def frontend_server(c): 'clean': 'Delete old files from InvenTree/web/static/web first, default: True', } ) +@state_logger('TSK07') def frontend_download( c, ref=None,