2
0
mirror of https://github.com/inventree/InvenTree.git synced 2026-03-21 11:44:42 +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')
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):
"""Return the InvenTree logo image, or a custom logo if available."""
if custom and settings.CUSTOM_LOGO:

View File

@@ -1,6 +1,8 @@
"""Base Class for InvenTree plugins."""
import inspect
import json
import re
import warnings
from datetime import datetime
from distutils.sysconfig import get_python_lib # type: ignore[unresolved-import]
@@ -9,6 +11,7 @@ from pathlib import Path
from typing import Optional
from django.conf import settings
from django.contrib.staticfiles.storage import StaticFilesStorage
from django.utils.text import slugify
from django.utils.translation import gettext_lazy as _
@@ -578,35 +581,119 @@ class InvenTreePlugin(VersionMixin, MixinBase, MetaBase):
# 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
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.
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
- 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
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.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 (
settings.DEBUG
and settings.PLUGIN_DEV_HOST
and 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))
url = f'{settings.PLUGIN_DEV_HOST}/src/{pathname}'
url = url.replace('.js', '.tsx')
else:
# Otherwise, construct the URL using the STATIC_URL setting
url = os.path.join(settings.STATIC_URL, 'plugins', self.SLUG, *args)
return url
if not url.startswith('/'):
url = '/' + url
storage = StaticFilesStorage()
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

View File

@@ -209,6 +209,54 @@ class InvenTreePluginTests(TestCase):
if plug:
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):
"""Tests for registry loading methods."""