diff --git a/src/backend/InvenTree/InvenTree/management/commands/collectplugins.py b/src/backend/InvenTree/InvenTree/management/commands/collectplugins.py new file mode 100644 index 0000000000..77b00f73b5 --- /dev/null +++ b/src/backend/InvenTree/InvenTree/management/commands/collectplugins.py @@ -0,0 +1,13 @@ +"""Management command to collect plugin static files.""" + +from django.core.management import BaseCommand + + +class Command(BaseCommand): + """Collect static files for all installed plugins.""" + + def handle(self, *args, **kwargs): + """Run the management command.""" + from plugin.staticfiles import collect_plugins_static_files + + collect_plugins_static_files() diff --git a/src/backend/InvenTree/InvenTree/ready.py b/src/backend/InvenTree/InvenTree/ready.py index 41b3058fc8..0ae788282c 100644 --- a/src/backend/InvenTree/InvenTree/ready.py +++ b/src/backend/InvenTree/InvenTree/ready.py @@ -125,7 +125,7 @@ def canAppAccessDatabase( excluded_commands.append('test') if not allow_plugins: - excluded_commands.extend(['collectstatic']) + excluded_commands.extend(['collectstatic', 'collectplugins']) for cmd in excluded_commands: if cmd in sys.argv: diff --git a/src/backend/InvenTree/plugin/models.py b/src/backend/InvenTree/plugin/models.py index c6fee27fd3..4989f56af2 100644 --- a/src/backend/InvenTree/plugin/models.py +++ b/src/backend/InvenTree/plugin/models.py @@ -12,6 +12,7 @@ from django.utils.translation import gettext_lazy as _ import common.models import InvenTree.models +import plugin.staticfiles from plugin import InvenTreePlugin, registry @@ -186,6 +187,20 @@ class PluginConfig(InvenTree.models.MetadataMixin, models.Model): return getattr(self.plugin, 'is_package', False) + def activate(self, active: bool) -> None: + """Set the 'active' status of this plugin instance.""" + from InvenTree.tasks import check_for_migrations, offload_task + + if self.active == active: + return + + self.active = active + self.save() + + if active: + offload_task(check_for_migrations) + offload_task(plugin.staticfiles.copy_plugin_static_files, self.key) + class PluginSetting(common.models.BaseInvenTreeSetting): """This model represents settings for individual plugins.""" diff --git a/src/backend/InvenTree/plugin/serializers.py b/src/backend/InvenTree/plugin/serializers.py index aa4deffa1a..30fd5325ee 100644 --- a/src/backend/InvenTree/plugin/serializers.py +++ b/src/backend/InvenTree/plugin/serializers.py @@ -208,15 +208,7 @@ class PluginActivateSerializer(serializers.Serializer): def update(self, instance, validated_data): """Apply the new 'active' value to the plugin instance.""" - from InvenTree.tasks import check_for_migrations, offload_task - - instance.active = validated_data.get('active', True) - instance.save() - - if instance.active: - # A plugin has just been activated - check for database migrations - offload_task(check_for_migrations) - + instance.activate(validated_data.get('active', True)) return instance diff --git a/src/backend/InvenTree/plugin/staticfiles.py b/src/backend/InvenTree/plugin/staticfiles.py new file mode 100644 index 0000000000..e80f4a25f5 --- /dev/null +++ b/src/backend/InvenTree/plugin/staticfiles.py @@ -0,0 +1,92 @@ +"""Static files management for InvenTree plugins.""" + +import logging +from pathlib import Path + +from django.contrib.staticfiles.storage import staticfiles_storage + +from plugin.registry import registry + +logger = logging.getLogger('inventree') + + +def clear_static_dir(path, recursive=True): + """Clear the specified directory from the 'static' output directory. + + Arguments: + path: The path to the directory to clear + recursive: If True, clear the directory recursively + """ + if not staticfiles_storage.exists(path): + return + + dirs, files = staticfiles_storage.listdir(path) + + for f in files: + staticfiles_storage.delete(f'{path}/{f}') + + if recursive: + for d in dirs: + clear_static_dir(f'{path}/{d}', recursive=True) + staticfiles_storage.delete(d) + + +def collect_plugins_static_files(): + """Copy static files from all installed plugins into the static directory.""" + registry.check_reload() + + logger.info('Collecting static files for all installed plugins.') + + for slug in registry.plugins.keys(): + copy_plugin_static_files(slug) + + +def copy_plugin_static_files(slug): + """Copy static files for the specified plugin.""" + registry.check_reload() + + plugin = registry.get_plugin(slug) + + if not plugin: + return + + logger.info("Copying static files for plugin '%s'") + + # Get the source path for the plugin + source_path = plugin.path().joinpath('static') + + if not source_path.is_dir(): + return + + # Create prefix for the destination path + destination_prefix = f'plugins/{slug}/' + + # Clear the destination path + clear_static_dir(destination_prefix) + + items = list(source_path.glob('*')) + + idx = 0 + copied = 0 + + while idx < len(items): + item = items[idx] + + idx += 1 + + if item.is_dir(): + items.extend(item.glob('*')) + continue + + if item.is_file(): + relative_path = item.relative_to(source_path) + + destination_path = f'{destination_prefix}{relative_path}' + + with item.open('rb') as src: + staticfiles_storage.save(destination_path, src) + + logger.debug(f'- copied {item} to {destination_path}') + copied += 1 + + logger.info(f"Copied %s static files for plugin '%s'.", copied, slug) diff --git a/tasks.py b/tasks.py index 3ef8e47300..d585f103f8 100644 --- a/tasks.py +++ b/tasks.py @@ -227,6 +227,9 @@ def plugins(c, uv=False): c.run('pip3 install --no-cache-dir --disable-pip-version-check uv') c.run(f"uv pip install -r '{plugin_file}'") + # Collect plugin static files + manage(c, 'collectplugins') + @task(help={'uv': 'Use UV package manager (experimental)'}) def install(c, uv=False): @@ -317,8 +320,8 @@ def remove_mfa(c, mail=''): manage(c, f'remove_mfa {mail}') -@task(help={'frontend': 'Build the frontend'}) -def static(c, frontend=False): +@task(help={'frontend': 'Build the frontend', 'clear': 'Remove existing static files'}) +def static(c, frontend=False, clear=True): """Copies required static files to the STATIC_ROOT directory, as per Django requirements.""" manage(c, 'prerender') @@ -327,7 +330,16 @@ def static(c, frontend=False): frontend_build(c) print('Collecting static files...') - manage(c, 'collectstatic --no-input --clear --verbosity 0') + + cmd = 'collectstatic --no-input --verbosity 0' + + if clear: + cmd += ' --clear' + + manage(c, cmd) + + # Collect plugin static files + manage(c, 'collectplugins') @task