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