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:
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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."""
|
||||||
|
|||||||
Reference in New Issue
Block a user