2
0
mirror of https://github.com/inventree/InvenTree.git synced 2026-04-07 03:50:52 +00:00

[plugin] Cache busting for plugin static files (#11565)

* Add helper to check the existence of a static file

* Log error if plugin static file does not exist

* Support cache busting for plugin files

* Use Pathlib instead

* Improve generic URL resolution

* Add unit test
This commit is contained in:
Oliver
2026-03-20 15:42:15 +11:00
committed by GitHub
parent fc730b9af7
commit 5f9972e75e
3 changed files with 151 additions and 9 deletions

View File

@@ -248,6 +248,13 @@ def getBlankThumbnail():
return getStaticUrl('img/blank_image.thumbnail.png') return getStaticUrl('img/blank_image.thumbnail.png')
def checkStaticFile(*args) -> bool:
"""Check if a file exists in the static storage."""
static_storage = StaticFilesStorage()
fn = os.path.join(*args)
return static_storage.exists(fn)
def getLogoImage(as_file=False, custom=True): def getLogoImage(as_file=False, custom=True):
"""Return the InvenTree logo image, or a custom logo if available.""" """Return the InvenTree logo image, or a custom logo if available."""
if custom and settings.CUSTOM_LOGO: if custom and settings.CUSTOM_LOGO:

View File

@@ -1,6 +1,8 @@
"""Base Class for InvenTree plugins.""" """Base Class for InvenTree plugins."""
import inspect import inspect
import json
import re
import warnings import warnings
from datetime import datetime from datetime import datetime
from distutils.sysconfig import get_python_lib # type: ignore[unresolved-import] from distutils.sysconfig import get_python_lib # type: ignore[unresolved-import]
@@ -9,6 +11,7 @@ from pathlib import Path
from typing import Optional from typing import Optional
from django.conf import settings from django.conf import settings
from django.contrib.staticfiles.storage import StaticFilesStorage
from django.utils.text import slugify from django.utils.text import slugify
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
@@ -578,35 +581,119 @@ class InvenTreePlugin(VersionMixin, MixinBase, MetaBase):
# endregion # endregion
def get_static_path(self) -> list[str]:
"""Return the path components to the plugin's static files."""
return ['plugins', self.slug]
def hashed_file_lookup(self, *args) -> str | None:
"""Find a hashed version of the given file, if it exists.
This is used to support cache busting for static files.
Arguments:
*args: Path components to the static file (e.g. 'js', 'admin.js')
Returns:
str: Path to the hashed version of the file if it exists, else None
"""
storage = StaticFilesStorage()
# First, try to find a manifest file which maps original filenames to hashed filenames
# We only support vite manifest files - as generated by the plugin creator framework
manifest_file = str(Path(*self.get_static_path(), '.vite', 'manifest.json'))
if not storage.exists(manifest_file):
return None
# Read the contents of the manifest file
try:
with storage.open(manifest_file) as f:
manifest = json.load(f)
except json.JSONDecodeError:
logger.error(f"Failed to parse manifest file for plugin '{self.SLUG}'")
return None
# Find the entry associated with the requested file
# Remove the file extension, as the manifest may contain hashed files with different extensions (e.g. .js, .css)
filename = str(args[-1] or '').split('.')[0]
pattern = re.compile(rf'{re.escape(filename)}\.(js|jsx|tsx)')
for key, value in manifest.items():
if re.search(pattern, key):
return value.get('file')
return None
@mark_final @mark_final
def plugin_static_file(self, *args) -> str: def plugin_static_file(
self, *args, check_exists: bool = True, check_hash: bool = True
) -> str:
"""Construct a path to a static file within the plugin directory. """Construct a path to a static file within the plugin directory.
Arguments:
*args: Path components to the static file (e.g. 'js', 'admin.js')
check_exists: If True, will check if the file actually exists on disk
check_hash: If True, will fallback to checking if the file has a hash in its name (for cache busting)
- This will return a URL can be used to access the static file - This will return a URL can be used to access the static file
- The path is constructed using the STATIC_URL setting and the plugin slug - The path is constructed using the STATIC_URL setting and the plugin slug
- Note: If the plugin is selected for "development" mode, the path will point to a vite server URL - Note: If the plugin is selected for "development" mode, the path will point to a vite server URL
Hash Checking:
- Plugins may distribute static files with a hash in the filename for cache busting purposes (e.g. 'file-abc123.js').
- If available, this file is priorities, and the non-hashed version is ignored.
- If no hashed file is available, the non-hashed version will be used (if it exists).
- If check_hash is False, the non-hashed version of the file will be used (even if a hashed version exists).
""" """
import os
from django.conf import settings from django.conf import settings
from django.contrib.staticfiles.storage import StaticFilesStorage
# If the plugin is selected for development mode, use the development host
# This allows the plugin developer to run a local vite server and have the plugin load files directly from that server
if ( if (
settings.DEBUG settings.DEBUG
and settings.PLUGIN_DEV_HOST and settings.PLUGIN_DEV_HOST
and settings.PLUGIN_DEV_SLUG and settings.PLUGIN_DEV_SLUG
and self.SLUG == settings.PLUGIN_DEV_SLUG and self.SLUG == settings.PLUGIN_DEV_SLUG
): ):
# If the plugin is selected for development mode, use the development host
pathname = '/'.join(list(args)) pathname = '/'.join(list(args))
url = f'{settings.PLUGIN_DEV_HOST}/src/{pathname}' url = f'{settings.PLUGIN_DEV_HOST}/src/{pathname}'
url = url.replace('.js', '.tsx') url = url.replace('.js', '.tsx')
else: return url
# Otherwise, construct the URL using the STATIC_URL setting
url = os.path.join(settings.STATIC_URL, 'plugins', self.SLUG, *args)
if not url.startswith('/'): storage = StaticFilesStorage()
url = '/' + url
file_name = args[-1] or ''
# The file may be specified with a function, e.g. 'file.js:renderFunction'
if ':' in file_name:
file_name, function_name = file_name.split(':')[:2]
else:
function_name = ''
# Determine the preceding path to the file (if any)
file_path = args[:-1]
# If enabled, check for a hashed version of the file
if check_hash:
file_name = self.hashed_file_lookup(*file_path, file_name) or file_name
full_path = str(Path(*self.get_static_path(), *file_path, file_name))
if check_exists:
if not storage.exists(full_path):
logger.error(
f"Static file not found for plugin '{self.SLUG}': {full_path}"
)
# Resolve the URL to the static file
url = storage.url(full_path)
# Re-append the function name (if provided)
if function_name:
url += f':{function_name}'
return url return url

View File

@@ -209,6 +209,54 @@ class InvenTreePluginTests(TestCase):
if plug: if plug:
self.assertEqual(plug.is_active(), False) self.assertEqual(plug.is_active(), False)
def test_plugin_static_file_lookup(self):
"""Test that the plugin static file lookup works as expected."""
from django.contrib.staticfiles.storage import StaticFilesStorage
from django.core.files.base import ContentFile
# Create a sample plugin with a known static file
class StaticFilePlugin(InvenTreePlugin):
NAME = 'StaticFilePlugin'
SLUG = 'static-file-test'
def get_static_file_url(self, file_name):
return self.get_plugin_static_file(file_name)
plugin = StaticFilePlugin()
storage = StaticFilesStorage()
# A simple test to ensure the path is correctly resolved
self.assertEqual(
plugin.plugin_static_file(
'sample.js', check_exists=False, check_hash=False
),
storage.url('plugins/static-file-test/sample.js'),
)
manifest_path = 'plugins/static-file-test/.vite/manifest.json'
manifest_data = textwrap.dedent("""{
"src/sample.js": {
"file": "sample.123456.js",
"name": "sample",
"src": "src/sample.js",
"isEntry": true
}
}""")
# A more comprehensive test - to find a hashed version of the file
# Note: This requires a manifest file to be present - let's create one
if not storage.exists(manifest_path):
storage.save(manifest_path, content=ContentFile(manifest_data))
lookup = plugin.plugin_static_file(
'sample.js', check_exists=False, check_hash=True
)
self.assertEqual(
lookup, storage.url('plugins/static-file-test/sample.123456.js')
)
class RegistryTests(TestQueryMixin, PluginRegistryMixin, TestCase): class RegistryTests(TestQueryMixin, PluginRegistryMixin, TestCase):
"""Tests for registry loading methods.""" """Tests for registry loading methods."""