mirror of
https://github.com/inventree/InvenTree.git
synced 2026-04-01 17:11:12 +00:00
Enhancements for recort import/export (#11630)
* Add management command to list installed apps * Add metadata to exported data file * Validate metadata for imported file * Update CHANGELOG.md * Update docs * Use internal codes * Refactor and add more metadata * Adjust github action workflow * Run with --force option to setup demo dataset
This commit is contained in:
215
tasks.py
215
tasks.py
@@ -18,6 +18,14 @@ from invoke import Collection, task
|
||||
from invoke.exceptions import UnexpectedExit
|
||||
|
||||
|
||||
def safe_value(fnc):
|
||||
"""Helper function to safely get value from function, catching import exceptions."""
|
||||
try:
|
||||
return fnc()
|
||||
except (ModuleNotFoundError, ImportError):
|
||||
return wrap_color('N/A', '93') # Yellow color for "Not Available"
|
||||
|
||||
|
||||
def is_true(x):
|
||||
"""Shortcut function to determine if a value "looks" like a boolean."""
|
||||
return str(x).strip().lower() in ['1', 'y', 'yes', 't', 'true', 'on']
|
||||
@@ -45,6 +53,51 @@ def is_debug_environment():
|
||||
)
|
||||
|
||||
|
||||
def get_django_version():
|
||||
"""Return the current Django version."""
|
||||
from src.backend.InvenTree.InvenTree.version import (
|
||||
inventreeDjangoVersion, # type: ignore[import]
|
||||
)
|
||||
|
||||
return safe_value(inventreeDjangoVersion)
|
||||
|
||||
|
||||
def get_inventree_version():
|
||||
"""Return the current InvenTree version."""
|
||||
from src.backend.InvenTree.InvenTree.version import (
|
||||
inventreeVersion, # type: ignore[import]
|
||||
)
|
||||
|
||||
return safe_value(inventreeVersion)
|
||||
|
||||
|
||||
def get_inventree_api_version():
|
||||
"""Return the current InvenTree API version."""
|
||||
from src.backend.InvenTree.InvenTree.version import (
|
||||
inventreeApiVersion, # type: ignore[import]
|
||||
)
|
||||
|
||||
return safe_value(inventreeApiVersion)
|
||||
|
||||
|
||||
def get_commit_hash():
|
||||
"""Return the current git commit hash."""
|
||||
from src.backend.InvenTree.InvenTree.version import (
|
||||
inventreeCommitHash, # type: ignore[import]
|
||||
)
|
||||
|
||||
return safe_value(inventreeCommitHash)
|
||||
|
||||
|
||||
def get_commit_date():
|
||||
"""Return the current git commit date."""
|
||||
from src.backend.InvenTree.InvenTree.version import (
|
||||
inventreeCommitDate, # type: ignore[import]
|
||||
)
|
||||
|
||||
return safe_value(inventreeCommitDate)
|
||||
|
||||
|
||||
def get_version_vals():
|
||||
"""Get values from the VERSION file."""
|
||||
version_file = local_dir().joinpath('VERSION')
|
||||
@@ -249,7 +302,7 @@ def main():
|
||||
# endregion
|
||||
|
||||
|
||||
def apps():
|
||||
def builtin_apps():
|
||||
"""Returns a list of installed apps."""
|
||||
return [
|
||||
'build',
|
||||
@@ -384,7 +437,9 @@ if __name__ in ['__main__', 'tasks']:
|
||||
main()
|
||||
|
||||
|
||||
def run(c, cmd, path: Optional[Path] = None, pty=False, env=None):
|
||||
def run(
|
||||
c, cmd, path: Optional[Path] = None, pty: bool = False, hide: bool = False, env=None
|
||||
):
|
||||
"""Runs a given command a given path.
|
||||
|
||||
Args:
|
||||
@@ -392,20 +447,23 @@ def run(c, cmd, path: Optional[Path] = None, pty=False, env=None):
|
||||
cmd: Command to run.
|
||||
path: Path to run the command in.
|
||||
pty (bool, optional): Run an interactive session. Defaults to False.
|
||||
hide (bool, optional): Hide the command output. Defaults to False.
|
||||
env (dict, optional): Environment variables to pass to the command. Defaults to None.
|
||||
"""
|
||||
env = env or {}
|
||||
path = path or local_dir()
|
||||
|
||||
try:
|
||||
c.run(f'cd "{path}" && {cmd}', pty=pty, env=env)
|
||||
result = c.run(f'cd "{path}" && {cmd}', pty=pty, env=env, hide=hide)
|
||||
except UnexpectedExit as e:
|
||||
error(f"ERROR: InvenTree command failed: '{cmd}'")
|
||||
warning('- Refer to the error messages in the log above for more information')
|
||||
raise e
|
||||
|
||||
return result
|
||||
|
||||
def manage(c, cmd, pty: bool = False, env=None):
|
||||
|
||||
def manage(c, cmd, pty: bool = False, env=None, **kwargs):
|
||||
"""Runs a given command against django's "manage.py" script.
|
||||
|
||||
Args:
|
||||
@@ -414,7 +472,23 @@ def manage(c, cmd, pty: bool = False, env=None):
|
||||
pty (bool, optional): Run an interactive session. Defaults to False.
|
||||
env (dict, optional): Environment variables to pass to the command. Defaults to None.
|
||||
"""
|
||||
run(c, f'python3 manage.py {cmd}', manage_py_dir(), pty, env)
|
||||
return run(
|
||||
c, f'python3 manage.py {cmd}', manage_py_dir(), pty=pty, env=env, **kwargs
|
||||
)
|
||||
|
||||
|
||||
def installed_apps(c) -> list[str]:
|
||||
"""Returns a list of all installed apps, including plugins."""
|
||||
result = manage(c, 'list_apps', pty=False, hide=True)
|
||||
output = result.stdout.strip()
|
||||
|
||||
# Look for the expected pattern
|
||||
match = re.findall(r'>>> (.*) <<<', output)
|
||||
|
||||
if not match:
|
||||
raise ValueError(f"Unexpected output from 'list_apps' command: {output}")
|
||||
|
||||
return match[0].split(',')
|
||||
|
||||
|
||||
def run_install(
|
||||
@@ -1030,7 +1104,20 @@ def export_records(
|
||||
with open(tmpfile, encoding='utf-8') as f_in:
|
||||
data = json.loads(f_in.read())
|
||||
|
||||
data_out = []
|
||||
data_out = [
|
||||
{
|
||||
'metadata': True,
|
||||
'comment': 'This file contains a dump of the InvenTree database',
|
||||
'exported_at': datetime.datetime.now().isoformat(),
|
||||
'exported_at_utc': datetime.datetime.utcnow().isoformat(),
|
||||
'source_version': get_inventree_version(),
|
||||
'api_version': get_inventree_api_version(),
|
||||
'django_version': get_django_version(),
|
||||
'python_version': python_version(),
|
||||
'source_commit': get_commit_hash(),
|
||||
'installed_apps': installed_apps(c),
|
||||
}
|
||||
]
|
||||
|
||||
for entry in data:
|
||||
model_name = entry.get('model', None)
|
||||
@@ -1060,17 +1147,77 @@ def export_records(
|
||||
success('Data export completed')
|
||||
|
||||
|
||||
def validate_import_metadata(c, metadata: dict, strict: bool = False) -> bool:
|
||||
"""Validate the metadata associated with an import file.
|
||||
|
||||
Arguments:
|
||||
c: The context or connection object
|
||||
metadata (dict): The metadata to validate
|
||||
strict (bool): If True, the import process will fail if any issues are detected.
|
||||
"""
|
||||
info('Validating import metadata...')
|
||||
|
||||
valid = True
|
||||
|
||||
def metadata_issue(message: str):
|
||||
"""Handle an issue with the metadata."""
|
||||
nonlocal valid
|
||||
valid = False
|
||||
|
||||
if strict:
|
||||
error(f'INVE-E16 Data Import Error: {message}')
|
||||
sys.exit(1)
|
||||
else:
|
||||
warning(f'INVE-W13 Data Import Warning: {message}')
|
||||
|
||||
if not metadata:
|
||||
metadata_issue(
|
||||
'No metadata found in the import file - cannot validate source version'
|
||||
)
|
||||
return False
|
||||
|
||||
source_version = metadata.get('source_version')
|
||||
|
||||
if source_version != get_inventree_version():
|
||||
metadata_issue(
|
||||
f"Source version '{source_version}' does not match the current InvenTree version '{get_inventree_version()}' - this may lead to issues with the import process"
|
||||
)
|
||||
|
||||
local_apps = set(installed_apps(c))
|
||||
source_apps = set(metadata.get('installed_apps', []))
|
||||
|
||||
for app in source_apps:
|
||||
if app not in local_apps:
|
||||
metadata_issue(
|
||||
f"Source app '{app}' is not installed in the current environment - this may break the import process"
|
||||
)
|
||||
|
||||
if valid:
|
||||
success('Metadata validation succeeded - no issues detected')
|
||||
|
||||
return valid
|
||||
|
||||
|
||||
@task(
|
||||
help={
|
||||
'filename': 'Input filename',
|
||||
'clear': 'Clear existing data before import',
|
||||
'force': 'Force deletion of existing data without confirmation (only applies if --clear is set)',
|
||||
'strict': 'Strict mode - fail if any issues are detected with the metadata (default = False)',
|
||||
'retain_temp': 'Retain temporary files at end of process (default = False)',
|
||||
'ignore_nonexistent': 'Ignore non-existent database models (default = False)',
|
||||
},
|
||||
pre=[wait],
|
||||
post=[rebuild_models, rebuild_thumbnails],
|
||||
)
|
||||
def import_records(
|
||||
c, filename='data.json', clear: bool = False, retain_temp: bool = False
|
||||
c,
|
||||
filename='data.json',
|
||||
clear: bool = False,
|
||||
retain_temp: bool = False,
|
||||
strict: bool = False,
|
||||
force: bool = False,
|
||||
ignore_nonexistent: bool = False,
|
||||
):
|
||||
"""Import database records from a file."""
|
||||
# Get an absolute path to the supplied filename
|
||||
@@ -1083,7 +1230,7 @@ def import_records(
|
||||
sys.exit(1)
|
||||
|
||||
if clear:
|
||||
delete_data(c, force=True, migrate=True)
|
||||
delete_data(c, force=force, migrate=True)
|
||||
|
||||
info(f"Importing database records from '{target}'")
|
||||
|
||||
@@ -1104,7 +1251,14 @@ def import_records(
|
||||
auth_data = []
|
||||
load_data = []
|
||||
|
||||
# A dict containing metadata associated with the data file
|
||||
metadata = {}
|
||||
|
||||
for entry in data:
|
||||
if entry.get('metadata', False):
|
||||
metadata = entry
|
||||
continue
|
||||
|
||||
if 'model' in entry:
|
||||
# Clear out any permissions specified for a group
|
||||
if entry['model'] == 'auth.group':
|
||||
@@ -1123,6 +1277,9 @@ def import_records(
|
||||
warning('WARNING: Invalid entry in data file')
|
||||
print(entry)
|
||||
|
||||
# Check the metadata associated with the imported data
|
||||
validate_import_metadata(c, metadata, strict=strict)
|
||||
|
||||
# Write the auth file data
|
||||
with open(authfile, 'w', encoding='utf-8') as f_out:
|
||||
f_out.write(json.dumps(auth_data, indent=2))
|
||||
@@ -1131,17 +1288,25 @@ def import_records(
|
||||
with open(datafile, 'w', encoding='utf-8') as f_out:
|
||||
f_out.write(json.dumps(load_data, indent=2))
|
||||
|
||||
# A set of content types to exclude from the import process
|
||||
excludes = content_excludes(allow_auth=False)
|
||||
|
||||
# Import auth models first
|
||||
info('Importing user auth data...')
|
||||
cmd = f"loaddata '{authfile}'"
|
||||
|
||||
if ignore_nonexistent:
|
||||
cmd += ' --ignorenonexistent'
|
||||
|
||||
manage(c, cmd, pty=True)
|
||||
|
||||
# Import everything else next
|
||||
info('Importing database records...')
|
||||
cmd = f"loaddata '{datafile}' -i {excludes}"
|
||||
|
||||
if ignore_nonexistent:
|
||||
cmd += ' --ignorenonexistent'
|
||||
|
||||
manage(c, cmd, pty=True)
|
||||
|
||||
if not retain_temp:
|
||||
@@ -1388,7 +1553,7 @@ def test(
|
||||
|
||||
pty = not disable_pty
|
||||
|
||||
tested_apps = ' '.join(apps())
|
||||
tested_apps = ' '.join(builtin_apps())
|
||||
|
||||
cmd = 'test'
|
||||
|
||||
@@ -1475,7 +1640,9 @@ def setup_test(
|
||||
|
||||
# Load data
|
||||
info('Loading database records ...')
|
||||
import_records(c, filename=template_dir.joinpath('inventree_data.json'), clear=True)
|
||||
import_records(
|
||||
c, filename=template_dir.joinpath('inventree_data.json'), clear=True, force=True
|
||||
)
|
||||
|
||||
# Copy media files
|
||||
src = template_dir.joinpath('media')
|
||||
@@ -1597,7 +1764,6 @@ def export_definitions(c, basedir: str = ''):
|
||||
@task(default=True)
|
||||
def version(c):
|
||||
"""Show the current version of InvenTree."""
|
||||
import src.backend.InvenTree.InvenTree.version as InvenTreeVersion # type: ignore[import]
|
||||
from src.backend.InvenTree.InvenTree.config import ( # type: ignore[import]
|
||||
get_backup_dir,
|
||||
get_config_file,
|
||||
@@ -1606,13 +1772,6 @@ def version(c):
|
||||
get_static_dir,
|
||||
)
|
||||
|
||||
def get_value(fnc):
|
||||
"""Helper function to safely get value from function, catching import exceptions."""
|
||||
try:
|
||||
return fnc()
|
||||
except (ModuleNotFoundError, ImportError):
|
||||
return wrap_color('ENVIRONMENT ERROR', '91')
|
||||
|
||||
# Gather frontend version information
|
||||
_, node, yarn = node_available(versions=True)
|
||||
|
||||
@@ -1645,17 +1804,17 @@ Invoke Tool {invoke_path}
|
||||
|
||||
Installation paths:
|
||||
Base {local_dir()}
|
||||
Config {get_value(get_config_file)}
|
||||
Plugin File {get_value(get_plugin_file) or NOT_SPECIFIED}
|
||||
Media {get_value(lambda: get_media_dir(error=False)) or NOT_SPECIFIED}
|
||||
Static {get_value(lambda: get_static_dir(error=False)) or NOT_SPECIFIED}
|
||||
Backup {get_value(lambda: get_backup_dir(error=False)) or NOT_SPECIFIED}
|
||||
Config {safe_value(get_config_file)}
|
||||
Plugin File {safe_value(get_plugin_file) or NOT_SPECIFIED}
|
||||
Media {safe_value(lambda: get_media_dir(error=False)) or NOT_SPECIFIED}
|
||||
Static {safe_value(lambda: get_static_dir(error=False)) or NOT_SPECIFIED}
|
||||
Backup {safe_value(lambda: get_backup_dir(error=False)) or NOT_SPECIFIED}
|
||||
|
||||
Versions:
|
||||
InvenTree {InvenTreeVersion.inventreeVersion()}
|
||||
API {InvenTreeVersion.inventreeApiVersion()}
|
||||
InvenTree {get_inventree_version() or NOT_SPECIFIED}
|
||||
API {get_inventree_api_version() or NOT_SPECIFIED}
|
||||
Python {python_version()}
|
||||
Django {get_value(InvenTreeVersion.inventreeDjangoVersion)}
|
||||
Django {get_django_version() or NOT_SPECIFIED}
|
||||
Node {node if node else NA}
|
||||
Yarn {yarn if yarn else NA}
|
||||
|
||||
@@ -1663,8 +1822,8 @@ Environment:
|
||||
Platform {platform}
|
||||
Debug {is_debug_environment()}
|
||||
|
||||
Commit hash: {InvenTreeVersion.inventreeCommitHash()}
|
||||
Commit date: {InvenTreeVersion.inventreeCommitDate()}"""
|
||||
Commit hash: {get_commit_hash() or NOT_SPECIFIED}
|
||||
Commit date: {get_commit_date() or NOT_SPECIFIED}"""
|
||||
)
|
||||
if is_pkg_installer_by_path():
|
||||
print(
|
||||
|
||||
Reference in New Issue
Block a user