From 3e740626239b74b585412f71c3521e3eb3fd9d96 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Fri, 4 Sep 2026 06:20:42 +0000 Subject: [PATCH 01/21] skip plugin events if importing or migrating data --- src/backend/InvenTree/plugin/base/event/events.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/backend/InvenTree/plugin/base/event/events.py b/src/backend/InvenTree/plugin/base/event/events.py index c834e02ae435..19f242e55df1 100644 --- a/src/backend/InvenTree/plugin/base/event/events.py +++ b/src/backend/InvenTree/plugin/base/event/events.py @@ -14,7 +14,7 @@ import InvenTree.exceptions from common.settings import get_global_setting -from InvenTree.ready import canAppAccessDatabase, isImportingData +from InvenTree.ready import canAppAccessDatabase, isImportingData, isRunningMigrations from InvenTree.tasks import bulk_offload_task, offload_task from plugin import PluginMixinEnum from plugin.registry import registry @@ -227,6 +227,10 @@ def process_event(plugin_slug, event, *args, **kwargs): This function is run by the background worker process. This function may queue multiple functions to be handled by the background worker. """ + if isRunningMigrations() or isImportingData(): + # Do not trigger events during migrations or data import + return + plugin = registry.get_plugin(plugin_slug, active=True) if plugin is None: # pragma: no cover @@ -293,6 +297,10 @@ def allow_table_event(table_name): @receiver(post_save) def after_save(sender, instance, created, **kwargs): """Trigger an event whenever a database entry is saved.""" + if isRunningMigrations() or isImportingData(): + # Do not trigger events during migrations or data import + return + table = sender.objects.model._meta.db_table instance_id = getattr(instance, 'id', None) @@ -312,6 +320,10 @@ def after_save(sender, instance, created, **kwargs): @receiver(post_delete) def after_delete(sender, instance, **kwargs): """Trigger an event whenever a database entry is deleted.""" + if isRunningMigrations() or isImportingData(): + # Do not trigger events during migrations or data import + return + table = sender.objects.model._meta.db_table if not allow_table_event(table): From 8b798c9f27c6828e20813751c6e305ba6d95ca02 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Fri, 4 Sep 2026 07:39:51 +0000 Subject: [PATCH 02/21] Add bulkloaddata option --- .../management/commands/bulkloaddata.py | 105 ++++++++++++++++++ src/backend/InvenTree/InvenTree/ready.py | 2 +- .../InvenTree/InvenTree/test_commands.py | 45 ++++++++ tasks.py | 17 ++- 4 files changed, 167 insertions(+), 2 deletions(-) create mode 100644 src/backend/InvenTree/InvenTree/management/commands/bulkloaddata.py diff --git a/src/backend/InvenTree/InvenTree/management/commands/bulkloaddata.py b/src/backend/InvenTree/InvenTree/management/commands/bulkloaddata.py new file mode 100644 index 000000000000..fd60dcba5b4d --- /dev/null +++ b/src/backend/InvenTree/InvenTree/management/commands/bulkloaddata.py @@ -0,0 +1,105 @@ +"""Custom management command to load fixtures faster using bulk_create().""" + +from django.core.management.base import CommandError +from django.core.management.commands.loaddata import Command as LoadDataCommand +from django.db import DatabaseError, IntegrityError, router + +import structlog + +logger = structlog.get_logger('inventree') + +DEFAULT_BATCH_SIZE = 500 + + +class Command(LoadDataCommand): + """Load fixtures using bulk_create() for improved performance. + + Behaves like the built-in 'loaddata' command, with two differences to be + aware of: + + - pre_save / post_save signals are not sent, and Model.save() / full_clean() + are bypassed entirely (this is a Django bulk_create() limitation). + - Natural-key-based foreign key / many-to-many resolution and multi-table + inheritance are not supported. Neither is currently used by any InvenTree + fixture/model, but a fixture or model that requires either will fail + loudly with a bulk_create() error rather than being silently mishandled. + + Based on the django forum thread: + - https://forum.djangoproject.com/t/feature-proposal-faster-fixture-loading-via-loaddata-command/36972/21 + """ + + def add_arguments(self, parser): + """Add bulkloaddata-specific arguments, on top of loaddata's own.""" + super().add_arguments(parser) + parser.add_argument( + '--batch-size', + type=int, + default=DEFAULT_BATCH_SIZE, + help=f'Number of records per bulk_create() batch (default: {DEFAULT_BATCH_SIZE})', + ) + parser.add_argument( + '--ignore-conflicts', + action='store_true', + help='Skip records that violate a unique constraint, instead of raising an error', + ) + + def handle(self, *fixture_labels, **options): + """Store bulk-loading options before delegating to the base command.""" + self.batch_size = options['batch_size'] + self.ignore_conflicts = options['ignore_conflicts'] + self.pending_objs = {} + super().handle(*fixture_labels, **options) + + def save_obj(self, obj): + """Buffer an object for bulk insertion, instead of saving it immediately.""" + if ( + obj.object._meta.app_config in self.excluded_apps + or type(obj.object) in self.excluded_models + ): + return False + + if not router.allow_migrate_model(self.using, obj.object.__class__): + return False + + self.models.add(obj.object.__class__) + self.pending_objs.setdefault(obj.object.__class__, []).append(obj) + + if obj.deferred_fields: + self.objs_with_deferred_fields.append(obj) + + return True + + def flush_pending(self): + """Bulk-create every object buffered so far, grouped by model.""" + for model, objs in self.pending_objs.items(): + try: + model._default_manager.db_manager(self.using).bulk_create( + [obj.object for obj in objs], + batch_size=self.batch_size, + ignore_conflicts=self.ignore_conflicts, + ) + except (DatabaseError, IntegrityError, ValueError) as e: + e.args = ( + f'Could not bulk-create {len(objs)} object(s) of {model._meta.label}: {e}', + ) + raise + + # bulk_create() cannot populate many-to-many relations - apply them here, + # same as DeserializedObject.save() does for the non-bulk path. + for obj in objs: + if obj.m2m_data: + for accessor_name, values in obj.m2m_data.items(): + getattr(obj.object, accessor_name).set(values) + obj.m2m_data = None + + self.pending_objs = {} + + def load_label(self, fixture_label): + """Load one fixture label, then flush the records it buffered.""" + super().load_label(fixture_label) + try: + self.flush_pending() + except Exception as e: + if not isinstance(e, CommandError): + e.args = (f"Problem installing fixture '{fixture_label}': {e}",) + raise diff --git a/src/backend/InvenTree/InvenTree/ready.py b/src/backend/InvenTree/InvenTree/ready.py index 8007e30a8183..f2e1cc9e9d5c 100644 --- a/src/backend/InvenTree/InvenTree/ready.py +++ b/src/backend/InvenTree/InvenTree/ready.py @@ -49,7 +49,7 @@ def isWaitingForDatabase(): def isImportingData(): """Returns True if the database is currently importing (or exporting) data, e.g. 'loaddata' command is performed.""" - return any(x in sys.argv for x in ['flush', 'loaddata', 'dumpdata']) + return any(x in sys.argv for x in ['flush', 'loaddata', 'bulkloaddata', 'dumpdata']) def isRunningMigrations(): diff --git a/src/backend/InvenTree/InvenTree/test_commands.py b/src/backend/InvenTree/InvenTree/test_commands.py index 7554f16db091..a63537b33ada 100644 --- a/src/backend/InvenTree/InvenTree/test_commands.py +++ b/src/backend/InvenTree/InvenTree/test_commands.py @@ -7,6 +7,7 @@ from django.conf import settings from django.contrib.auth.models import User from django.core.management import call_command +from django.db import IntegrityError from django.test import TestCase from opentelemetry.instrumentation.sqlite3 import SQLite3Instrumentor @@ -116,6 +117,50 @@ def get_dummyuser(uname='admin'): self.assertEqual(output, 'done') self.assertEqual(my_admin3.authenticator_set.all().count(), 0) + def test_bulkloaddata(self): + """Test the bulkloaddata command.""" + from django.contrib.contenttypes.models import ContentType + from django.core import serializers + + # ContentType has no custom signals and a real unique_together constraint + # (app_label, model), making it a convenient, side-effect-free test model. + entries = [ + ContentType.objects.create(app_label='bulkloaddata_test', model=f'model{i}') + for i in range(5) + ] + pks = [e.pk for e in entries] + data = serializers.serialize('json', entries) + ContentType.objects.filter(pk__in=pks).delete() + + tmp_file = get_testfolder_dir().joinpath('bulkloaddata_test.json') + tmp_file.write_text(data, encoding='utf-8') + + try: + # Basic load - all records should be recreated + call_command('bulkloaddata', str(tmp_file), verbosity=0) + self.assertEqual(ContentType.objects.filter(pk__in=pks).count(), 5) + + # Re-loading without --ignore-conflicts should raise (unique app_label/model) + with self.assertRaises(IntegrityError): + call_command('bulkloaddata', str(tmp_file), verbosity=0) + + # Re-loading with --ignore-conflicts should succeed, without duplicating rows + call_command( + 'bulkloaddata', str(tmp_file), verbosity=0, ignore_conflicts=True + ) + self.assertEqual(ContentType.objects.filter(pk__in=pks).count(), 5) + + # A small batch size should still load every record correctly + ContentType.objects.filter(pk__in=pks).delete() + call_command('bulkloaddata', str(tmp_file), verbosity=0, batch_size=2) + models = set( + ContentType.objects.filter(pk__in=pks).values_list('model', flat=True) + ) + self.assertEqual(models, {e.model for e in entries}) + finally: + ContentType.objects.filter(pk__in=pks).delete() + tmp_file.unlink(missing_ok=True) + def test_backup_metadata(self): """Test the backup metadata functions.""" from InvenTree.backup import ( diff --git a/tasks.py b/tasks.py index 3a650e1732f7..7a8ae5b135c5 100644 --- a/tasks.py +++ b/tasks.py @@ -1358,6 +1358,8 @@ def metadata_issue(message: str): 'exclude_plugins': 'Exclude plugin data from the import process (default = False)', 'skip_migrations': 'Skip the migration step after clearing data (default = False)', 'verbose': 'Print verbose output from management commands', + 'bulk': 'Use the faster bulkloaddata command instead of loaddata (default = False)', + 'ignore_conflicts': 'Skip records that violate a unique constraint, instead of raising an error (requires --bulk, default = False)', }, pre=[wait], post=[rebuild_models, rebuild_thumbnails], @@ -1371,6 +1373,8 @@ def import_records( ignore_nonexistent: bool = False, skip_migrations: bool = False, verbose: bool = False, + bulk: bool = False, + ignore_conflicts: bool = False, ): """Import database records from a file.""" # Get an absolute path to the supplied filename @@ -1383,6 +1387,10 @@ def import_records( error(f"ERROR: File '{target}' does not exist") sys.exit(1) + if ignore_conflicts and not bulk: + warning('--ignore-conflicts has no effect without --bulk - ignoring') + ignore_conflicts = False + if clear: delete_data(c, force=True, migrate=True, verbose=verbose) @@ -1416,6 +1424,8 @@ def load_data( """Helper function to save data to a temporary file, and then load into the database.""" nonlocal ignore_nonexistent nonlocal verbose + nonlocal bulk + nonlocal ignore_conflicts nonlocal c # Skip if there is no data to load @@ -1429,7 +1439,9 @@ def load_data( ) as f_out: f_out.write(json.dumps(data, indent=2)) - cmd = f'loaddata {f_out.name} -v 0 --force-color' + cmd = ( + f'{"bulkloaddata" if bulk else "loaddata"} {f_out.name} -v 0 --force-color' + ) if app: cmd += f' --app {app}' @@ -1437,6 +1449,9 @@ def load_data( if ignore_nonexistent: cmd += ' --ignorenonexistent' + if bulk and ignore_conflicts: + cmd += ' --ignore-conflicts' + # A set of content types to exclude from the import process if excludes: cmd += f' -i {excludes}' From 80deaf10fc8eae1bc459d8a9470953cb3da74038 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Fri, 4 Sep 2026 07:45:16 +0000 Subject: [PATCH 03/21] Skip signals if importing --- src/backend/InvenTree/order/models.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/backend/InvenTree/order/models.py b/src/backend/InvenTree/order/models.py index 544f9976746e..373b13eb8647 100644 --- a/src/backend/InvenTree/order/models.py +++ b/src/backend/InvenTree/order/models.py @@ -4287,6 +4287,9 @@ def complete_allocation(self, user): def _touch_order_updated_at(instance): """Bump updated_at on the parent order without triggering a full save.""" + if InvenTree.ready.isRunningMigrations() or InvenTree.ready.isImportingData(): + # Do not touch the order during migrations or data import + return if not InvenTree.ready.canAppAccessDatabase(allow_test=True): return instance.order.__class__.objects.filter(pk=instance.order_id).update( From 2083485b40404b865ee8e4f8b4282bdf1738aeb8 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sat, 5 Sep 2026 00:19:01 +0000 Subject: [PATCH 04/21] Improve bulkloaddata command --- .../management/commands/bulkloaddata.py | 37 +++++++++++++++++-- 1 file changed, 34 insertions(+), 3 deletions(-) diff --git a/src/backend/InvenTree/InvenTree/management/commands/bulkloaddata.py b/src/backend/InvenTree/InvenTree/management/commands/bulkloaddata.py index fd60dcba5b4d..5efc45491573 100644 --- a/src/backend/InvenTree/InvenTree/management/commands/bulkloaddata.py +++ b/src/backend/InvenTree/InvenTree/management/commands/bulkloaddata.py @@ -1,8 +1,10 @@ """Custom management command to load fixtures faster using bulk_create().""" +import time + from django.core.management.base import CommandError from django.core.management.commands.loaddata import Command as LoadDataCommand -from django.db import DatabaseError, IntegrityError, router +from django.db import DatabaseError, IntegrityError, connections, router import structlog @@ -48,7 +50,26 @@ def handle(self, *fixture_labels, **options): self.batch_size = options['batch_size'] self.ignore_conflicts = options['ignore_conflicts'] self.pending_objs = {} - super().handle(*fixture_labels, **options) + + connection = connections[options['database']] + self.query_count = 0 + + def count_queries(execute, sql, params, many, context): + """Count every query executed against this connection, without the overhead of recording each query's SQL text (unlike e.g. CaptureQueriesContext).""" + self.query_count += 1 + return execute(sql, params, many, context) + + start_time = time.monotonic() + + with connection.execute_wrapper(count_queries): + super().handle(*fixture_labels, **options) + + elapsed = time.monotonic() - start_time + + if self.verbosity >= 1: + self.stdout.write( + f'Executed {self.query_count} database queries in {elapsed:.2f}s' + ) def save_obj(self, obj): """Buffer an object for bulk insertion, instead of saving it immediately.""" @@ -62,10 +83,20 @@ def save_obj(self, obj): return False self.models.add(obj.object.__class__) - self.pending_objs.setdefault(obj.object.__class__, []).append(obj) if obj.deferred_fields: + # This object has an unresolved forward reference (e.g. a natural-key + # FK to an object that has not been saved yet - 'export_records' uses + # --natural-foreign, and e.g. auth.User defines a natural_key(), so + # this does occur in practice). It cannot be bulk_create()'d as-is, since + # the deferred field would be written as blank/null. The base loaddata() + # command already resolves and saves objects like this individually, + # via save_deferred_fields(), once every fixture file has been buffered + # (see 'handle' -> 'loaddata') - so just hand it off for that, rather + # than also bulk-inserting it here with the field left unresolved. self.objs_with_deferred_fields.append(obj) + else: + self.pending_objs.setdefault(obj.object.__class__, []).append(obj) return True From 7f88ae11ecd7a1ee7436b44a90d551cc59b1e99f Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sat, 5 Sep 2026 00:19:18 +0000 Subject: [PATCH 05/21] Optionally rebuild thumbnails --- tasks.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/tasks.py b/tasks.py index 7a8ae5b135c5..925725cb0140 100644 --- a/tasks.py +++ b/tasks.py @@ -1360,9 +1360,11 @@ def metadata_issue(message: str): 'verbose': 'Print verbose output from management commands', 'bulk': 'Use the faster bulkloaddata command instead of loaddata (default = False)', 'ignore_conflicts': 'Skip records that violate a unique constraint, instead of raising an error (requires --bulk, default = False)', + 'rebuild_models': 'Rebuild database models after import (default = True)', + 'rebuild_thumbnails': 'Rebuild image thumbnails after import (default = True)', }, pre=[wait], - post=[rebuild_models, rebuild_thumbnails], + post=[], ) def import_records( c, @@ -1375,6 +1377,8 @@ def import_records( verbose: bool = False, bulk: bool = False, ignore_conflicts: bool = False, + rebuild_models: bool = True, + rebuild_thumbnails: bool = True, ): """Import database records from a file.""" # Get an absolute path to the supplied filename @@ -1513,6 +1517,12 @@ def load_data( load_data('remaining', all_data, excludes=content_excludes(allow_auth=False)) + if rebuild_models: + rebuild_models(c) + + if rebuild_thumbnails: + rebuild_thumbnails(c) + success('Data import completed') From f78699aa10fd6bc329e0ac6d9a7f121ad44656e2 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sat, 5 Sep 2026 00:57:15 +0000 Subject: [PATCH 06/21] enhancements for import_records task --- tasks.py | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/tasks.py b/tasks.py index 925725cb0140..37720a22a88d 100644 --- a/tasks.py +++ b/tasks.py @@ -1360,12 +1360,13 @@ def metadata_issue(message: str): 'verbose': 'Print verbose output from management commands', 'bulk': 'Use the faster bulkloaddata command instead of loaddata (default = False)', 'ignore_conflicts': 'Skip records that violate a unique constraint, instead of raising an error (requires --bulk, default = False)', - 'rebuild_models': 'Rebuild database models after import (default = True)', - 'rebuild_thumbnails': 'Rebuild image thumbnails after import (default = True)', + 'rebuild_trees': 'Rebuild MPTT tree structures after import (default = True)', + 'rebuild_images': 'Rebuild image thumbnails after import (default = True)', }, pre=[wait], post=[], ) +@state_logger def import_records( c, filename='data.json', @@ -1377,8 +1378,8 @@ def import_records( verbose: bool = False, bulk: bool = False, ignore_conflicts: bool = False, - rebuild_models: bool = True, - rebuild_thumbnails: bool = True, + rebuild_trees: bool = True, + rebuild_images: bool = True, ): """Import database records from a file.""" # Get an absolute path to the supplied filename @@ -1479,9 +1480,7 @@ def load_data( entry['fields']['user_permissions'] = [] # Handle certain model types separately, to ensure they are loaded in the correct order - if model.startswith('auth.'): - auth_data.append(entry) - if model.startswith('users.'): + if model.startswith(('auth.', 'users.')): auth_data.append(entry) elif model.startswith('common.'): common_data.append(entry) @@ -1517,10 +1516,10 @@ def load_data( load_data('remaining', all_data, excludes=content_excludes(allow_auth=False)) - if rebuild_models: + if rebuild_trees: rebuild_models(c) - if rebuild_thumbnails: + if rebuild_images: rebuild_thumbnails(c) success('Data import completed') From 634eab1887fa4155645c22a279a8db066cf6964c Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sat, 5 Sep 2026 01:30:27 +0000 Subject: [PATCH 07/21] cache natural key references in bulkloaddata --- .../management/commands/bulkloaddata.py | 69 +++++++++++++++++-- 1 file changed, 64 insertions(+), 5 deletions(-) diff --git a/src/backend/InvenTree/InvenTree/management/commands/bulkloaddata.py b/src/backend/InvenTree/InvenTree/management/commands/bulkloaddata.py index 5efc45491573..1dd82d72fd04 100644 --- a/src/backend/InvenTree/InvenTree/management/commands/bulkloaddata.py +++ b/src/backend/InvenTree/InvenTree/management/commands/bulkloaddata.py @@ -1,9 +1,11 @@ """Custom management command to load fixtures faster using bulk_create().""" import time +from contextlib import contextmanager from django.core.management.base import CommandError from django.core.management.commands.loaddata import Command as LoadDataCommand +from django.core.serializers import base as serializers_base from django.db import DatabaseError, IntegrityError, connections, router import structlog @@ -21,10 +23,15 @@ class Command(LoadDataCommand): - pre_save / post_save signals are not sent, and Model.save() / full_clean() are bypassed entirely (this is a Django bulk_create() limitation). - - Natural-key-based foreign key / many-to-many resolution and multi-table - inheritance are not supported. Neither is currently used by any InvenTree - fixture/model, but a fixture or model that requires either will fail - loudly with a bulk_create() error rather than being silently mishandled. + - Multi-table inheritance is not supported by bulk_create() and will fail + loudly rather than being silently mishandled. Natural-key foreign key / + many-to-many resolution *is* supported (falling back to an individual, + non-bulk save for any row that needs it - see save_obj()) and is further + sped up by caching each resolved natural key for the life of the command + (see _cached_natural_keys()), since 'export_records' uses + --natural-foreign and a large fixture can have many rows referencing the + same handful of natural-keyed objects (e.g. stock.StockItemTracking.user + -> auth.User) - without caching, each one costs a separate DB query. Based on the django forum thread: - https://forum.djangoproject.com/t/feature-proposal-faster-fixture-loading-via-loaddata-command/36972/21 @@ -61,7 +68,7 @@ def count_queries(execute, sql, params, many, context): start_time = time.monotonic() - with connection.execute_wrapper(count_queries): + with connection.execute_wrapper(count_queries), self._cached_natural_keys(): super().handle(*fixture_labels, **options) elapsed = time.monotonic() - start_time @@ -71,6 +78,58 @@ def count_queries(execute, sql, params, many, context): f'Executed {self.query_count} database queries in {elapsed:.2f}s' ) + @contextmanager + def _cached_natural_keys(self): + """Cache natural-key foreign key resolutions for the duration of this block. + + Django's deserializer (deserialize_fk_value) issues a fresh DB query + every time it resolves a natural-key FK reference, with no caching of + its own. A fixture with many rows referencing the same handful of + natural-keyed objects (e.g. thousands of stock.StockItemTracking rows + all pointing at a few auth.User accounts) would otherwise cost one + query per row instead of one query per distinct value. + """ + cache = {} + original = serializers_base.deserialize_fk_value + + def cached_deserialize_fk_value( + field, field_value, using, handle_forward_references + ): + default_manager = field.remote_field.model._default_manager + + is_natural_key = ( + field_value is not None + and hasattr(default_manager, 'get_by_natural_key') + and hasattr(field_value, '__iter__') + and not isinstance(field_value, str) + ) + + if not is_natural_key: + # Plain (non natural-key) FK values never reach a DB query in + # the first place - nothing to cache, delegate as normal + return original(field, field_value, using, handle_forward_references) + + cache_key = (field.remote_field.model, using, tuple(field_value)) + + if cache_key in cache: + return cache[cache_key] + + value = original(field, field_value, using, handle_forward_references) + + # Only cache a fully-resolved value - a deferred lookup (the + # referenced object doesn't exist yet) may well succeed on a later + # call, once that object has actually been saved. + if value is not serializers_base.DEFER_FIELD: + cache[cache_key] = value + + return value + + serializers_base.deserialize_fk_value = cached_deserialize_fk_value + try: + yield + finally: + serializers_base.deserialize_fk_value = original + def save_obj(self, obj): """Buffer an object for bulk insertion, instead of saving it immediately.""" if ( From 942caef684e6bbe847bbdccf2ff685f1260d6406 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sat, 5 Sep 2026 01:32:19 +0000 Subject: [PATCH 08/21] wrap export_records in @state_logger --- tasks.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tasks.py b/tasks.py index 37720a22a88d..3f5ab2103815 100644 --- a/tasks.py +++ b/tasks.py @@ -1208,6 +1208,7 @@ def update( 'verbose': 'Print verbose output from management commands', } ) +@state_logger def export_records( c, filename='data.json', From 46b484e81d3c84bf4cc1fb74d2354c5c439e9691 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sat, 5 Sep 2026 02:37:30 +0000 Subject: [PATCH 09/21] Reduce file size of exported data --- tasks.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/tasks.py b/tasks.py index 3f5ab2103815..569c1aa48814 100644 --- a/tasks.py +++ b/tasks.py @@ -1205,6 +1205,7 @@ def update( 'exclude_plugins': 'Exclude plugin data from the output file (default = False)', 'include_sso': 'Include SSO token data in the output file (default = False)', 'include_session': 'Include user session data in the output file (default = False)', + 'prettify': 'Pretty-print the output file with indentation (default = False)', 'verbose': 'Print verbose output from management commands', } ) @@ -1219,6 +1220,7 @@ def export_records( exclude_plugins: bool = False, include_sso: bool = False, include_session: bool = False, + prettify: bool = False, verbose: bool = False, ): """Export all database records to a file.""" @@ -1244,7 +1246,10 @@ def export_records( with tempfile.NamedTemporaryFile( suffix='.json', encoding='utf-8', mode='w+t', delete=True ) as tmpfile: - cmd = f"dumpdata --natural-foreign --indent 2 --output '{tmpfile.name}' {excludes}" + cmd = f"dumpdata --natural-foreign --output '{tmpfile.name}' {excludes}" + + if prettify: + cmd += ' --indent 2' # Dump data to temporary file manage(c, cmd, pty=True, verbose=verbose) @@ -1259,7 +1264,7 @@ def export_records( '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(), + 'exported_at_utc': datetime.datetime.now(datetime.UTC).isoformat(), 'source_version': get_inventree_version(), 'api_version': get_inventree_api_version(), 'django_version': get_django_version(), @@ -1288,7 +1293,7 @@ def export_records( # Write the processed data to file with open(target, 'w', encoding='utf-8') as f_out: - f_out.write(json.dumps(data_out, indent=2)) + f_out.write(json.dumps(data_out, indent=2 if prettify else None)) success('Data export completed') From f4a4d170d5182172385e11a849e4e2e7561e2012 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sat, 5 Sep 2026 03:00:23 +0000 Subject: [PATCH 10/21] Added docs --- docs/docs/start/migrate.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/docs/docs/start/migrate.md b/docs/docs/start/migrate.md index 808ce776bc2f..1b4a797986bf 100644 --- a/docs/docs/start/migrate.md +++ b/docs/docs/start/migrate.md @@ -31,6 +31,9 @@ This will create JSON file at the specified location which contains all database !!! info "Specifying filename" The filename of the exported file can be specified using the `-f` option. To see all available options, run `invoke export-records --help` +!!! info "File Size" + By default the exported file is written as compact JSON, to keep its size down. Add the `-p` / `--prettify` option to pretty-print the output with indentation, which is easier to read manually but can roughly double the file size for a large database. + ``` {{ invoke_commands('export-records --help') }} ``` @@ -67,6 +70,16 @@ invoke import-records -c -f data.json !!! warning "Character Encoding" If the character encoding of the data file does not exactly match the target database, the import operation may not succeed. In this case, some manual editing of the database JSON file may be required. +!!! tip "Faster Imports" + For very large datasets, add the `-b` / `--bulk` option to use a faster import path (the `bulkloaddata` management command) which inserts records in large batches and skips per-record signal processing, rather than saving each record individually: + + ``` + invoke import-records -c -b -f data.json + ``` + +!!! tip "Strict Metadata Validation" + By default, a mismatch between the source and target InvenTree versions (see the "Database Versions" warning above) only produces a warning, and the import continues. Add the `-s` / `--strict` option to fail immediately instead, if you want to guarantee the versions match exactly before any data is written. + ``` {{ invoke_commands('import-records --help') }} ``` @@ -220,6 +233,9 @@ When running the `import-records` command, the import process will also attempt 2. The plugin *version* must be the same in both installations. If the plugin version is different, then the database schema may be different, and thus the import process may fail. 3. The InvenTree software version must be the same in both installations. If the InvenTree version is different, then the database schema may be different, and thus the import process may fail. +!!! tip "Skipping Missing Data" + If the import file references a plugin (or any other model) that cannot be matched to the current installation - for example, condition 1 above is not met - add the `-i` / `--ignore-nonexistent` option to skip those records instead of failing the entire import. + If all of the above conditions are met, then the plugin data *should* be imported correctly into the new database. To achieve this reliably, the following process steps are implemented in the `import-records` command: 1. The database is cleaned of all existing records (if the `-c` option is used). From c3ea6044fc02410b273a7e13738dd35b5e8ddc34 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sat, 5 Sep 2026 03:04:09 +0000 Subject: [PATCH 11/21] Test bulk workflow as part of CI --- .github/workflows/import_export.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/import_export.yaml b/.github/workflows/import_export.yaml index 18dfb0dc8882..7e094c7d64f0 100644 --- a/.github/workflows/import_export.yaml +++ b/.github/workflows/import_export.yaml @@ -112,7 +112,7 @@ jobs: test -f /home/runner/work/InvenTree/test_inventree_db.sqlite3 || (echo "Sqlite database not created" && exit 1) - name: Import Sqlite Dataset run: | - invoke import-records -c -f ${{ env.DATA_FILE }} --strict + invoke import-records -c -f ${{ env.DATA_FILE }} --strict --bulk cd src/backend/InvenTree && python manage.py check_dummy_data - name: Export Sqlite Dataset run: | From 6787a59d7dc21921004fb52ad2d207b95a1d915b Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sat, 5 Sep 2026 04:53:27 +0000 Subject: [PATCH 12/21] Add progress bar for data import --- .../management/commands/bulkloaddata.py | 20 ++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/src/backend/InvenTree/InvenTree/management/commands/bulkloaddata.py b/src/backend/InvenTree/InvenTree/management/commands/bulkloaddata.py index 1dd82d72fd04..ec7f20e21dc1 100644 --- a/src/backend/InvenTree/InvenTree/management/commands/bulkloaddata.py +++ b/src/backend/InvenTree/InvenTree/management/commands/bulkloaddata.py @@ -9,11 +9,18 @@ from django.db import DatabaseError, IntegrityError, connections, router import structlog +from tqdm import tqdm logger = structlog.get_logger('inventree') DEFAULT_BATCH_SIZE = 500 +# Number of records to process between progress bar updates. Calling tqdm's +# update() for every single record adds measurable overhead of its own (well +# beyond just the display refresh) once there are a million or more of them - +# so update it in batches instead. +PROGRESS_UPDATE_INTERVAL = 100 + class Command(LoadDataCommand): """Load fixtures using bulk_create() for improved performance. @@ -57,6 +64,7 @@ def handle(self, *fixture_labels, **options): self.batch_size = options['batch_size'] self.ignore_conflicts = options['ignore_conflicts'] self.pending_objs = {} + self.pending_progress = 0 connection = connections[options['database']] self.query_count = 0 @@ -68,8 +76,13 @@ def count_queries(execute, sql, params, many, context): start_time = time.monotonic() - with connection.execute_wrapper(count_queries), self._cached_natural_keys(): + with ( + connection.execute_wrapper(count_queries), + self._cached_natural_keys(), + tqdm(desc='Importing', unit=' records') as self.progress, + ): super().handle(*fixture_labels, **options) + self.progress.update(self.pending_progress) elapsed = time.monotonic() - start_time @@ -132,6 +145,11 @@ def cached_deserialize_fk_value( def save_obj(self, obj): """Buffer an object for bulk insertion, instead of saving it immediately.""" + self.pending_progress += 1 + if self.pending_progress >= PROGRESS_UPDATE_INTERVAL: + self.progress.update(self.pending_progress) + self.pending_progress = 0 + if ( obj.object._meta.app_config in self.excluded_apps or type(obj.object) in self.excluded_models From 61c78bf2c60ff6943f68ed696a8025667642c64d Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sun, 6 Sep 2026 00:05:44 +0000 Subject: [PATCH 13/21] fix for import workflow bug --- .github/workflows/import_export.yaml | 1 + src/backend/InvenTree/InvenTree/ready.py | 1 + tasks.py | 2 ++ 3 files changed, 4 insertions(+) diff --git a/.github/workflows/import_export.yaml b/.github/workflows/import_export.yaml index 7e094c7d64f0..b64d33d3026f 100644 --- a/.github/workflows/import_export.yaml +++ b/.github/workflows/import_export.yaml @@ -112,6 +112,7 @@ jobs: test -f /home/runner/work/InvenTree/test_inventree_db.sqlite3 || (echo "Sqlite database not created" && exit 1) - name: Import Sqlite Dataset run: | + invoke import-records -c -f ${{ env.DATA_FILE }} --strict invoke import-records -c -f ${{ env.DATA_FILE }} --strict --bulk cd src/backend/InvenTree && python manage.py check_dummy_data - name: Export Sqlite Dataset diff --git a/src/backend/InvenTree/InvenTree/ready.py b/src/backend/InvenTree/InvenTree/ready.py index f2e1cc9e9d5c..10ad973276a9 100644 --- a/src/backend/InvenTree/InvenTree/ready.py +++ b/src/backend/InvenTree/InvenTree/ready.py @@ -271,6 +271,7 @@ def canAppAccessDatabase( 'compilemessages', 'createsuperuser', 'collectstatic', + 'list_apps', 'makemessages', 'spectactular', 'wait_for_db', diff --git a/tasks.py b/tasks.py index 569c1aa48814..05aa80e39ce2 100644 --- a/tasks.py +++ b/tasks.py @@ -1478,10 +1478,12 @@ def load_data( if model := entry.get('model', None): # Clear out any permissions specified for a group + # (these are regenerated after import) if model == 'auth.group': entry['fields']['permissions'] = [] # Clear out any permissions specified for a user + # (these are regenerated after import) if model == 'auth.user': entry['fields']['user_permissions'] = [] From d7ee09919f682ca71306cf112c65657968218386 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sun, 6 Sep 2026 00:11:32 +0000 Subject: [PATCH 14/21] Separately test bulk import workflow --- .github/workflows/import_export.yaml | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/.github/workflows/import_export.yaml b/.github/workflows/import_export.yaml index b64d33d3026f..252f1b2b69a8 100644 --- a/.github/workflows/import_export.yaml +++ b/.github/workflows/import_export.yaml @@ -112,10 +112,16 @@ jobs: test -f /home/runner/work/InvenTree/test_inventree_db.sqlite3 || (echo "Sqlite database not created" && exit 1) - name: Import Sqlite Dataset run: | + # Run two imports back-to-back to ensure that the import process is idempotent + invoke import-records -c -f ${{ env.DATA_FILE }} --strict invoke import-records -c -f ${{ env.DATA_FILE }} --strict - invoke import-records -c -f ${{ env.DATA_FILE }} --strict --bulk cd src/backend/InvenTree && python manage.py check_dummy_data - - name: Export Sqlite Dataset + invoke export-records -o -f ${{ env.DATA_FILE }} + python .github/scripts/check_exported_data.py ${{ env.DATA_FILE }} + - name: Bulk Import Sqlite Dataset run: | + # Ensure that the 'bulk' import process works as expected + invoke import-records -c -f ${{ env.DATA_FILE }} --strict --bulk + cd src/backend/InvenTree && python manage.py check_dummy_data invoke export-records -o -f ${{ env.DATA_FILE }} python .github/scripts/check_exported_data.py ${{ env.DATA_FILE }} From ec6e16d81e82754d806fbed4a2e8111b96263289 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sun, 6 Sep 2026 00:14:51 +0000 Subject: [PATCH 15/21] Exercise --prettify option --- .github/workflows/import_export.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/import_export.yaml b/.github/workflows/import_export.yaml index 252f1b2b69a8..1f398d193c9f 100644 --- a/.github/workflows/import_export.yaml +++ b/.github/workflows/import_export.yaml @@ -123,5 +123,5 @@ jobs: # Ensure that the 'bulk' import process works as expected invoke import-records -c -f ${{ env.DATA_FILE }} --strict --bulk cd src/backend/InvenTree && python manage.py check_dummy_data - invoke export-records -o -f ${{ env.DATA_FILE }} + invoke export-records -o -f ${{ env.DATA_FILE }} --prettify python .github/scripts/check_exported_data.py ${{ env.DATA_FILE }} From 416eaf84d6eb305e31ab1e1d801da8e9af091508 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sun, 6 Sep 2026 00:41:20 +0000 Subject: [PATCH 16/21] Additional CI checks for content excludes --- .github/scripts/check_exported_data.py | 161 ++++++++++++++++-- .github/scripts/seed_content_excludes_data.py | 112 ++++++++++++ .github/workflows/import_export.yaml | 63 +++++++ 3 files changed, 324 insertions(+), 12 deletions(-) create mode 100644 .github/scripts/seed_content_excludes_data.py diff --git a/.github/scripts/check_exported_data.py b/.github/scripts/check_exported_data.py index d7fb78083a7a..cefc7b0c41fa 100644 --- a/.github/scripts/check_exported_data.py +++ b/.github/scripts/check_exported_data.py @@ -10,6 +10,14 @@ - The file contains the expected plugin configuration - The file contains the expected plugin database records +It can also optionally check the presence / absence of several categories of +data which 'export-records' can include or exclude via --include-x / +--exclude-x flags (email logs, API tokens, SSO app/token data, user sessions, +and non-empty group/user permissions) - pass e.g. '--check-email include' or +'--check-email exclude' to assert that category was (or was not) found in the +exported data. Any '--check-x' option which is *not* passed is simply not +checked at all (not even implicitly assumed absent) - so existing invocations +which don't pass any of them keep working unchanged. """ PLUGIN_KEY = 'dummy_app_plugin' @@ -19,10 +27,112 @@ import json import os + +def check_category( + data: list[dict], label: str, model_names: list[str], expect: str | None +): + """Check that all of the given model names are present / absent as expected. + + Arguments: + data: The loaded (parsed) exported data file. + label: A human-readable label for this category, for error messages. + model_names: The Django model labels (e.g. 'common.emailmessage') + which make up this category. + expect: 'include' - at least one entry for *each* model name must be + present. 'exclude' - *no* entry for *any* model name may be + present. None - this category was not requested to be checked; + do nothing. + """ + if expect is None: + return + + expect_present = expect == 'include' + + counts = dict.fromkeys(model_names, 0) + + for entry in data: + model = entry.get('model', None) + if model in counts: + counts[model] += 1 + + if expect_present: + for model, count in counts.items(): + if count == 0: + print(f"Error: Expected '{label}' data ('{model}') was not found") + exit(1) + print(f"Found expected '{label}' data ({counts})") + else: + for model, count in counts.items(): + if count > 0: + print( + f"Error: '{label}' data ('{model}') was found, but should have been excluded ({count} record(s))" + ) + exit(1) + print(f"Confirmed '{label}' data was correctly excluded") + + +def check_permissions(data: list[dict], expect: str | None): + """Check that auth.group / auth.user permission fields are stripped or preserved as expected. + + Arguments: + data: The loaded (parsed) exported data file. + expect: 'include' - at least one auth.group / auth.user entry must + have non-empty permissions. 'exclude' - all such entries must have + empty permissions. None - not checked; do nothing. + """ + if expect is None: + return + + expect_present = expect == 'include' + + group_perms = [ + entry['fields'].get('permissions', []) + for entry in data + if entry.get('model') == 'auth.group' + ] + user_perms = [ + entry['fields'].get('user_permissions', []) + for entry in data + if entry.get('model') == 'auth.user' + ] + + any_group_perms = any(group_perms) + any_user_perms = any(user_perms) + + if expect_present: + if not any_group_perms and not any_user_perms: + print( + 'Error: Expected at least one auth.group / auth.user entry with ' + 'non-empty permissions, but all were empty' + ) + exit(1) + print('Found expected non-empty group/user permissions') + else: + if any_group_perms or any_user_perms: + print( + 'Error: Found non-empty group/user permissions, but they should ' + 'have been stripped' + ) + exit(1) + print('Confirmed group/user permissions were correctly stripped') + + if __name__ == '__main__': parser = argparse.ArgumentParser(description='Check exported data file') parser.add_argument('datafile', help='Path to the exported data file (JSON)') + # Plugin data is checked unconditionally below (it always has been) - this + # just controls which direction is expected, mirroring export-records' own + # --exclude-plugins flag (plugin data is included by default). + parser.add_argument('--exclude-plugins', action='store_true') + + # The remaining categories are only checked when explicitly requested - + # pass 'include' or 'exclude' to assert that direction, or omit the flag + # entirely to skip checking that category (the default, for backwards + # compatibility with existing invocations that don't pass any of these). + for flag in ('email', 'tokens', 'sso', 'session', 'permissions'): + parser.add_argument(f'--check-{flag}', choices=['include', 'exclude']) + args = parser.parse_args() if not os.path.isfile(args.datafile): @@ -84,18 +194,45 @@ ) exit(1) - if not found_plugin_config: - print(f'Error: No plugin configuration found for plugin "{PLUGIN_KEY}"') - exit(1) - - # Check the extracted plugin records - expected_keys = ['alpha', 'beta', 'gamma', 'delta'] - - for key in expected_keys: - if key not in plugin_data_records: - print( - f'Error: Expected plugin record with key "{key}" not found in exported data' - ) + # Plugin data is included by default (export-records only excludes it when + # given --exclude-plugins), so preserve that as the default expectation here + if not args.exclude_plugins: + if not found_plugin_config: + print(f'Error: No plugin configuration found for plugin "{PLUGIN_KEY}"') exit(1) + # Check the extracted plugin records + expected_keys = ['alpha', 'beta', 'gamma', 'delta'] + + for key in expected_keys: + if key not in plugin_data_records: + print( + f'Error: Expected plugin record with key "{key}" not found in exported data' + ) + exit(1) + elif found_plugin_config or plugin_data_records: + print('Error: Plugin data was found, but should have been excluded') + exit(1) + else: + print('Confirmed plugin data was correctly excluded') + + # Content-excludes checks - only run for '--check-x' flags that were actually passed + check_category( + data, 'email', ['common.emailmessage', 'common.emailthread'], args.check_email + ) + check_category(data, 'tokens', ['users.apitoken'], args.check_tokens) + check_category( + data, + 'sso', + ['socialaccount.socialapp', 'socialaccount.socialtoken'], + args.check_sso, + ) + check_category( + data, + 'session', + ['sessions.session', 'usersessions.usersession'], + args.check_session, + ) + check_permissions(data, args.check_permissions) + print('All checks passed successfully!') diff --git a/.github/scripts/seed_content_excludes_data.py b/.github/scripts/seed_content_excludes_data.py new file mode 100644 index 000000000000..0d118ee568c1 --- /dev/null +++ b/.github/scripts/seed_content_excludes_data.py @@ -0,0 +1,112 @@ +"""Script to seed test data for the 'content-excludes' export CI job. + +'export-records' can optionally include/exclude several categories of data +(email logs, API tokens, SSO app/token data, user sessions, and group/user +permissions) via --include-x / --exclude-x flags. Toggling one of those flags +only proves anything if the source database actually contains a row in that +category to begin with - otherwise "the export doesn't contain it" is true +regardless of whether the flag/exclusion logic works at all. + +This script creates exactly one row in each such category, so the +import_export.yaml workflow's content-excludes job can meaningfully assert +both "included when asked for" and "excluded by default". + +Intended to be run from 'src/backend/InvenTree', e.g.: + cd src/backend/InvenTree && python ../../../.github/scripts/seed_content_excludes_data.py +""" + +import os +import sys + +sys.path.insert(0, os.getcwd()) +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'InvenTree.settings') + +import django + +django.setup() + +from django.contrib.auth import get_user_model +from django.contrib.auth.models import Group, Permission +from django.contrib.sessions.backends.db import SessionStore + +from allauth.socialaccount.models import SocialAccount, SocialApp, SocialToken +from allauth.usersessions.models import UserSession + +from common.models import EmailMessage, Priority +from users.models import ApiToken + +User = get_user_model() + + +def main(): + """Seed one row of test data in each optional export/import category.""" + user = User.objects.filter(is_superuser=True).first() + + if user is None: + print('Error: no superuser found - run `invoke dev.setup-test` first') + sys.exit(1) + + # Ensure at least one group has a non-empty permission set, so toggling + # --include-permissions has something real to include/strip. Doesn't need + # to be a group the superuser belongs to - InvenTree's RuleSet groups + # already have permissions assigned regardless of membership. + group = Group.objects.first() + + if group is None: + print('Error: no groups found - run `invoke dev.setup-test` first') + sys.exit(1) + + if not group.permissions.exists(): + group.permissions.add(Permission.objects.first()) + print(f"- Added a permission to group '{group.name}' (was empty)") + else: + print(f"- Group '{group.name}' already has permissions") + + # Email log entry (thread is auto-created by EmailMessage.save() if omitted) + EmailMessage.objects.get_or_create( + subject='CI content-excludes test email', + defaults={ + 'body': 'CI content-excludes test email body', + 'to': 'ci-recipient@example.com', + 'sender': 'ci-sender@example.com', + 'priority': Priority.NORMAL, + }, + ) + print('- Created email log entry') + + # API token + ApiToken.objects.get_or_create(user=user, name='ci-content-excludes-token') + print('- Created API token') + + # SSO application + linked account + token + app, _ = SocialApp.objects.get_or_create( + provider='google', + name='CI Content-Excludes Test App', + defaults={'client_id': 'ci-test-client-id'}, + ) + account, _ = SocialAccount.objects.get_or_create( + user=user, provider='google', uid='ci-test-external-uid' + ) + SocialToken.objects.get_or_create( + app=app, account=account, defaults={'token': 'ci-test-token-value'} + ) + print('- Created SSO application, account and token') + + # A real, properly-encoded session (avoids writing an undecodable session_data blob) + store = SessionStore() + store['ci_content_excludes_test'] = True + store.create() + print('- Created session entry') + + # allauth user-session record (tracked separately from the raw Session table) + UserSession.objects.get_or_create( + session_key='ci-content-excludes-user-session', + defaults={'user': user, 'ip': '127.0.0.1', 'user_agent': 'ci-test-agent'}, + ) + print('- Created user session entry') + + print('Content-excludes seed data created successfully') + + +if __name__ == '__main__': + main() diff --git a/.github/workflows/import_export.yaml b/.github/workflows/import_export.yaml index 1f398d193c9f..72891ddc67ae 100644 --- a/.github/workflows/import_export.yaml +++ b/.github/workflows/import_export.yaml @@ -58,6 +58,7 @@ jobs: server: - .github/workflows/import_export.yaml - .github/scripts/check_exported_data.py + - .github/scripts/seed_content_excludes_data.py - 'src/backend/**' - 'tasks.py' test: @@ -125,3 +126,65 @@ jobs: cd src/backend/InvenTree && python manage.py check_dummy_data invoke export-records -o -f ${{ env.DATA_FILE }} --prettify python .github/scripts/check_exported_data.py ${{ env.DATA_FILE }} + + content-excludes: + # Ensure that 'export-records' correctly includes / excludes each optional + # category of data (email logs, API tokens, SSO app/token data, user + # sessions, and group/user permissions) according to its --include-x / + # --exclude-x flags. Separate from the 'test' job above since it exercises + # a different axis of behaviour (export content, not the import/export + # round-trip) and doesn't need the Sqlite half at all. + runs-on: ubuntu-latest + needs: paths-filter + if: needs.paths-filter.outputs.server == 'true' || contains(github.event.pull_request.labels.*.name, 'full-run') + + services: + postgres: + image: postgres:17 + env: + POSTGRES_USER: inventree + POSTGRES_PASSWORD: password + ports: + - 5432:5432 + + steps: + - name: Checkout code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + - name: Environment Setup + uses: ./.github/actions/setup + with: + apt-dependency: gettext poppler-utils libpq-dev + pip-dependency: psycopg + update: true + static: false + - name: Setup Postgres Database + run: | + invoke migrate + invoke dev.setup-test -i + - name: Create Plugin Data + run: | + pip install -U inventree-dummy-app-plugin==0.1.0 + invoke migrate + cd src/backend/InvenTree && python manage.py create_dummy_data + - name: Seed Content-Excludes Test Data + run: | + # Creates one row in each optional export category (email log, API + # token, SSO app/token, session, group permissions), so that toggling + # the corresponding flag below has real data to prove it actually works + cd src/backend/InvenTree + python ../../../.github/scripts/seed_content_excludes_data.py + - name: Export - All Optional Categories Included + run: | + invoke export-records -o -f ${{ env.DATA_FILE }} --include-email --include-permissions --include-tokens --include-sso --include-session + python .github/scripts/check_exported_data.py ${{ env.DATA_FILE }} \ + --check-email include --check-tokens include --check-sso include \ + --check-session include --check-permissions include + - name: Export - All Optional Categories Excluded + run: | + invoke export-records -o -f ${{ env.DATA_FILE }} --exclude-plugins + python .github/scripts/check_exported_data.py ${{ env.DATA_FILE }} --exclude-plugins \ + --check-email exclude --check-tokens exclude --check-sso exclude \ + --check-session exclude --check-permissions exclude From 129d68338000dbf90b3d34475d9507c2d46ff48a Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sun, 6 Sep 2026 00:44:46 +0000 Subject: [PATCH 17/21] Allow plugin loading for list_apps --- src/backend/InvenTree/InvenTree/ready.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/backend/InvenTree/InvenTree/ready.py b/src/backend/InvenTree/InvenTree/ready.py index 10ad973276a9..3fe70bfd714a 100644 --- a/src/backend/InvenTree/InvenTree/ready.py +++ b/src/backend/InvenTree/InvenTree/ready.py @@ -271,7 +271,6 @@ def canAppAccessDatabase( 'compilemessages', 'createsuperuser', 'collectstatic', - 'list_apps', 'makemessages', 'spectactular', 'wait_for_db', @@ -286,7 +285,7 @@ def canAppAccessDatabase( excluded_commands.append('test') if not allow_plugins: - excluded_commands.extend(['collectplugins']) + excluded_commands.extend(['collectplugins', 'list_apps']) return all(cmd not in sys.argv for cmd in excluded_commands) From a8ed3ff4ecc65d04375cba12514c834e4cd98502 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sun, 6 Sep 2026 04:55:55 +0000 Subject: [PATCH 18/21] Additional CI unit tests --- .github/workflows/import_export.yaml | 86 ++++++++++++++++++++++++++++ tasks.py | 2 +- 2 files changed, 87 insertions(+), 1 deletion(-) diff --git a/.github/workflows/import_export.yaml b/.github/workflows/import_export.yaml index 72891ddc67ae..8e064969b8d0 100644 --- a/.github/workflows/import_export.yaml +++ b/.github/workflows/import_export.yaml @@ -188,3 +188,89 @@ jobs: python .github/scripts/check_exported_data.py ${{ env.DATA_FILE }} --exclude-plugins \ --check-email exclude --check-tokens exclude --check-sso exclude \ --check-session exclude --check-permissions exclude + + plugin-absent: + # Ensure that importing data referencing a plugin's own models degrades + # gracefully (skipping just those records) when that plugin isn't + # installed on the target - and fails loudly without --ignore-nonexistent. + # See docs/docs/start/migrate.md's "Importing Plugin Data" section, + # condition 1 ("the plugin code must be present in the new installation"). + # + # Note: --strict is deliberately *not* used for the imports below, + # as the source metadata's installed_apps list includes the plugin. + runs-on: ubuntu-latest + needs: paths-filter + if: needs.paths-filter.outputs.server == 'true' || contains(github.event.pull_request.labels.*.name, 'full-run') + + services: + postgres: + image: postgres:17 + env: + POSTGRES_USER: inventree + POSTGRES_PASSWORD: password + ports: + - 5432:5432 + + steps: + - name: Checkout code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + - name: Environment Setup + uses: ./.github/actions/setup + with: + apt-dependency: gettext poppler-utils libpq-dev + pip-dependency: psycopg + update: true + static: false + - name: Setup Postgres Database + run: | + invoke migrate + invoke dev.setup-test -i + - name: Create Plugin Data + run: | + pip install -U inventree-dummy-app-plugin==0.1.0 + invoke migrate + cd src/backend/InvenTree && python manage.py create_dummy_data + - name: Export Postgres Dataset (plugin installed) + run: | + invoke export-records -o -f ${{ env.DATA_FILE }} + - name: Uninstall Plugin + run: | + pip uninstall -y inventree-dummy-app-plugin + - name: Update Environment Variables for Sqlite + run: | + echo "INVENTREE_DB_ENGINE=sqlite" >> $GITHUB_ENV + echo "INVENTREE_DB_NAME=/home/runner/work/InvenTree/test_inventree_db.sqlite3" >> $GITHUB_ENV + - name: Setup Sqlite Database (plugin not installed) + run: | + invoke migrate + test -f /home/runner/work/InvenTree/test_inventree_db.sqlite3 || (echo "Sqlite database not created" && exit 1) + - name: Import Without --ignore-nonexistent Should Fail + run: | + if invoke import-records -c -f ${{ env.DATA_FILE }}; then + echo "ERROR: import-records succeeded without --ignore-nonexistent, but the plugin is not installed on this target - it should have failed" + exit 1 + fi + echo "Confirmed: import correctly failed without --ignore-nonexistent" + - name: Import With --ignore-nonexistent Should Succeed + run: | + invoke import-records -c -f ${{ env.DATA_FILE }} --ignore-nonexistent + cd src/backend/InvenTree + python manage.py shell -c " + from part.models import Part + count = Part.objects.count() + assert count > 0, 'Expected core Part data to be imported' + print(f'Confirmed {count} Part record(s) imported despite the missing plugin') + " + - name: Bulk Import With --ignore-nonexistent Should Also Succeed + run: | + invoke import-records -c -f ${{ env.DATA_FILE }} --ignore-nonexistent --bulk + cd src/backend/InvenTree + python manage.py shell -c " + from part.models import Part + count = Part.objects.count() + assert count > 0, 'Expected core Part data to be imported' + print(f'Confirmed {count} Part record(s) imported despite the missing plugin (bulk)') + " diff --git a/tasks.py b/tasks.py index 05aa80e39ce2..8c583d7ff803 100644 --- a/tasks.py +++ b/tasks.py @@ -1465,7 +1465,7 @@ def load_data( # A set of content types to exclude from the import process if excludes: - cmd += f' -i {excludes}' + cmd += f' {excludes}' manage(c, cmd, pty=True, verbose=verbose) From cdd8a4581b3e164179e9fc8c40a16bf1c70b152b Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sun, 6 Sep 2026 05:19:14 +0000 Subject: [PATCH 19/21] Test for importing with conflicting records --- .github/workflows/import_export.yaml | 63 ++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/.github/workflows/import_export.yaml b/.github/workflows/import_export.yaml index 8e064969b8d0..7e57a87cd4b2 100644 --- a/.github/workflows/import_export.yaml +++ b/.github/workflows/import_export.yaml @@ -274,3 +274,66 @@ jobs: assert count > 0, 'Expected core Part data to be imported' print(f'Confirmed {count} Part record(s) imported despite the missing plugin (bulk)') " + + bulk-conflicts: + # Check for expected conflict behaviour when re-importing a dataset into a database that already contains that dataset. + runs-on: ubuntu-latest + needs: paths-filter + if: needs.paths-filter.outputs.server == 'true' || contains(github.event.pull_request.labels.*.name, 'full-run') + + env: + INVENTREE_DB_ENGINE: sqlite + INVENTREE_DB_NAME: /home/runner/work/InvenTree/test_inventree_bulk_conflicts_db.sqlite3 + + steps: + - name: Checkout code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + - name: Environment Setup + uses: ./.github/actions/setup + with: + apt-dependency: gettext poppler-utils libpq-dev + pip-dependency: psycopg + update: true + static: false + - name: Setup Sqlite Database + run: | + invoke migrate + invoke dev.setup-test -i + - name: Export Dataset + run: | + invoke export-records -o -f ${{ env.DATA_FILE }} + cd src/backend/InvenTree + python manage.py shell -c " + from part.models import Part + print(Part.objects.count()) + " | tail -n 1 > /home/runner/work/InvenTree/test_inventree_baseline_part_count.txt + echo "Baseline Part count: $(cat /home/runner/work/InvenTree/test_inventree_baseline_part_count.txt)" + - name: Bulk Re-Import Without --ignore-conflicts Should Fail + run: | + # Deliberately no -c/--clear - the database already contains this + # exact data, so every row bulk_create() tries to insert conflicts + # with one already there + if invoke import-records -f ${{ env.DATA_FILE }} --skip-migrations --strict --bulk; then + echo "ERROR: bulk import succeeded against a database with conflicting rows - it should have failed without --ignore-conflicts" + exit 1 + fi + echo "Confirmed: bulk import correctly failed on conflicting rows without --ignore-conflicts" + - name: Bulk Re-Import With --ignore-conflicts Should Succeed + run: | + invoke import-records -f ${{ env.DATA_FILE }} --skip-migrations --strict --bulk --ignore-conflicts + cd src/backend/InvenTree + python manage.py shell -c " + from part.models import Part + print(Part.objects.count()) + " | tail -n 1 > /home/runner/work/InvenTree/test_inventree_after_part_count.txt + BASELINE=$(cat /home/runner/work/InvenTree/test_inventree_baseline_part_count.txt) + AFTER=$(cat /home/runner/work/InvenTree/test_inventree_after_part_count.txt) + echo "Part count: baseline=$BASELINE, after --ignore-conflicts re-import=$AFTER" + if [ "$BASELINE" != "$AFTER" ]; then + echo "ERROR: Part count changed after --ignore-conflicts re-import (expected conflicting rows to be skipped, not duplicated or lost)" + exit 1 + fi + echo "Confirmed: --ignore-conflicts skipped every conflicting row without duplicating or losing any data" From c80861ac0909c09719df4d260319af84eeec956c Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sun, 6 Sep 2026 06:24:13 +0000 Subject: [PATCH 20/21] path fixes --- .github/workflows/import_export.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/import_export.yaml b/.github/workflows/import_export.yaml index 7e57a87cd4b2..ae933a0fdfb7 100644 --- a/.github/workflows/import_export.yaml +++ b/.github/workflows/import_export.yaml @@ -61,7 +61,7 @@ jobs: - .github/scripts/seed_content_excludes_data.py - 'src/backend/**' - 'tasks.py' - test: + import-export: runs-on: ubuntu-latest needs: paths-filter if: needs.paths-filter.outputs.server == 'true' || contains(github.event.pull_request.labels.*.name, 'full-run') @@ -118,14 +118,14 @@ jobs: invoke import-records -c -f ${{ env.DATA_FILE }} --strict cd src/backend/InvenTree && python manage.py check_dummy_data invoke export-records -o -f ${{ env.DATA_FILE }} - python .github/scripts/check_exported_data.py ${{ env.DATA_FILE }} + python ../../../.github/scripts/check_exported_data.py ${{ env.DATA_FILE }} - name: Bulk Import Sqlite Dataset run: | # Ensure that the 'bulk' import process works as expected invoke import-records -c -f ${{ env.DATA_FILE }} --strict --bulk cd src/backend/InvenTree && python manage.py check_dummy_data invoke export-records -o -f ${{ env.DATA_FILE }} --prettify - python .github/scripts/check_exported_data.py ${{ env.DATA_FILE }} + python ../../../.github/scripts/check_exported_data.py ${{ env.DATA_FILE }} content-excludes: # Ensure that 'export-records' correctly includes / excludes each optional From c5d5ce95bcc02eb5f9028c9c84795576b21fd138 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sun, 6 Sep 2026 06:25:45 +0000 Subject: [PATCH 21/21] Adjust test conditions --- .github/scripts/check_exported_data.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/scripts/check_exported_data.py b/.github/scripts/check_exported_data.py index cefc7b0c41fa..07e72308f925 100644 --- a/.github/scripts/check_exported_data.py +++ b/.github/scripts/check_exported_data.py @@ -210,7 +210,7 @@ def check_permissions(data: list[dict], expect: str | None): f'Error: Expected plugin record with key "{key}" not found in exported data' ) exit(1) - elif found_plugin_config or plugin_data_records: + elif found_plugin_config: print('Error: Plugin data was found, but should have been excluded') exit(1) else: