diff --git a/src/backend/InvenTree/InvenTree/api.py b/src/backend/InvenTree/InvenTree/api.py index 87edf69474e3..d125e232cc8a 100644 --- a/src/backend/InvenTree/InvenTree/api.py +++ b/src/backend/InvenTree/InvenTree/api.py @@ -6,10 +6,13 @@ from django.conf import settings from django.contrib.contenttypes.models import ContentType +from django.core.cache import cache from django.db import transaction from django.http import JsonResponse from django.urls import path, reverse +from django.utils.decorators import method_decorator from django.utils.translation import gettext_lazy as _ +from django.views.decorators.cache import cache_page from django.views.generic.base import RedirectView import structlog @@ -42,45 +45,6 @@ logger = structlog.get_logger('inventree') -def read_license_file(path: Path) -> list: - """Extract license information from the provided file. - - Arguments: - path: Path to the license file - - Returns: A list of items containing the license information - """ - # Check if the file exists - if not path.exists(): - logger.error("License file not found at '%s'", path) - return [] - - try: - data = json.loads(path.read_text(encoding='utf-8')) - except Exception as e: - logger.exception("Failed to parse license file '%s': %s", path, e) - return [] - - output = [] - names = set() - - # Ensure we do not have any duplicate 'name' values in the list - for entry in data: - name = None - for key in entry: - if key.lower() == 'name': - name = entry[key] - break - - if name is None or name in names: - continue - - names.add(name) - output.append({key.lower(): value for key, value in entry.items()}) - - return sorted(output, key=lambda x: x.get('name', '').lower()) - - class LicenseViewSerializer(serializers.Serializer): """Serializer for license information.""" @@ -95,6 +59,44 @@ class LicenseView(APIView): permission_classes = [InvenTree.permissions.IsAuthenticatedOrReadScope] + def read_license_file(self, path: Path) -> list: + """Extract license information from the provided file. + + Arguments: + path: Path to the license file + + Returns: A list of items containing the license information + """ + # Check if the file exists + if not path.exists(): + logger.error("License file not found at '%s'", path) + return [] + + try: + data = json.loads(path.read_text(encoding='utf-8')) + except Exception as e: + logger.exception("Failed to parse license file '%s': %s", path, e) + return [] + + output = [] + names = set() + + # Ensure we do not have any duplicate 'name' values in the list + for entry in data: + name = None + for key in entry: + if key.lower() == 'name': + name = entry[key] + break + + if name is None or name in names: + continue + + names.add(name) + output.append({key.lower(): value for key, value in entry.items()}) + + return sorted(output, key=lambda x: x.get('name', '').lower()) + @extend_schema(responses={200: OpenApiResponse(response=LicenseViewSerializer)}) def get(self, request, *args, **kwargs): """Return information about the InvenTree server.""" @@ -102,9 +104,10 @@ def get(self, request, *args, **kwargs): frontend = InvenTree.config.get_base_dir().joinpath( 'web/static/web/.vite/dependencies.json' ) + return JsonResponse({ - 'backend': read_license_file(backend), - 'frontend': read_license_file(frontend), + 'backend': self.read_license_file(backend), + 'frontend': self.read_license_file(frontend), }) @@ -136,6 +139,7 @@ class LinkSerializer(serializers.Serializer): links = LinkSerializer() +@method_decorator(cache_page(60 * 15), name='dispatch') class VersionView(APIView): """Simple JSON endpoint for InvenTree version information.""" @@ -290,48 +294,59 @@ def get(self, request, *args, **kwargs): # Might be Token auth - check if so is_staff = self.check_auth_header(request) - data = { - 'server': 'InvenTree', - 'id': InvenTree.version.inventree_identifier(), - 'version': InvenTree.version.inventreeVersion(), - 'instance': InvenTree.version.inventreeInstanceName(), - 'apiVersion': InvenTree.version.inventreeApiVersion(), - 'worker_running': is_worker_running(), - 'worker_count': settings.Q_CLUSTER['workers'], - 'worker_pending_tasks': self.worker_pending_tasks(), - 'plugins_enabled': settings.PLUGINS_ENABLED, - 'plugins_install_disabled': settings.PLUGINS_INSTALL_DISABLED, - 'email_configured': is_email_configured(), - 'debug_mode': settings.DEBUG, - 'docker_mode': settings.DOCKER, - 'default_locale': settings.LANGUAGE_CODE, - 'customize': { - 'logo': helpers.getLogoImage(), - 'splash': helpers.getSplashScreen(), - 'login_message': helpers.getCustomOption('login_message'), - 'navbar_message': helpers.getCustomOption('navbar_message'), - 'disable_theme_storage': str2bool( - helpers.getCustomOption('disable_theme_storage') - ), - }, - 'active_plugins': plugins_info(), - # Following fields are only available to staff users - 'system_health': check_system_health() if is_staff else None, - 'database': InvenTree.version.inventreeDatabase() if is_staff else None, - 'platform': InvenTree.version.inventreePlatform() if is_staff else None, - 'installer': InvenTree.config.inventreeInstaller() if is_staff else None, - 'target': InvenTree.version.inventreeTarget() if is_staff else None, - 'django_admin': settings.INVENTREE_ADMIN_URL - if (is_staff and settings.INVENTREE_ADMIN_ENABLED) - else None, - 'settings': { - 'sso_registration': registration_enabled('LOGIN_ENABLE_SSO_REG'), - 'registration_enabled': registration_enabled('LOGIN_ENABLE_REG'), - 'password_forgotten_enabled': get_global_setting( - 'LOGIN_ENABLE_PWD_FORGOT' - ), - }, - } + # Cache the results of this view, per user + cache_key = f'api-info-{request.user.pk if request.user.is_authenticated else "A"}-{"Y" if is_staff else "N"}' + + data = cache.get(cache_key, None) + + if not data: + data = { + 'server': 'InvenTree', + 'id': InvenTree.version.inventree_identifier(), + 'version': InvenTree.version.inventreeVersion(), + 'instance': InvenTree.version.inventreeInstanceName(), + 'apiVersion': InvenTree.version.inventreeApiVersion(), + 'worker_running': is_worker_running(), + 'worker_count': settings.Q_CLUSTER['workers'], + 'worker_pending_tasks': self.worker_pending_tasks(), + 'plugins_enabled': settings.PLUGINS_ENABLED, + 'plugins_install_disabled': settings.PLUGINS_INSTALL_DISABLED, + 'email_configured': is_email_configured(), + 'debug_mode': settings.DEBUG, + 'docker_mode': settings.DOCKER, + 'default_locale': settings.LANGUAGE_CODE, + 'customize': { + 'logo': helpers.getLogoImage(), + 'splash': helpers.getSplashScreen(), + 'login_message': helpers.getCustomOption('login_message'), + 'navbar_message': helpers.getCustomOption('navbar_message'), + 'disable_theme_storage': str2bool( + helpers.getCustomOption('disable_theme_storage') + ), + }, + 'active_plugins': plugins_info(), + # Following fields are only available to staff users + 'system_health': check_system_health() if is_staff else None, + 'database': InvenTree.version.inventreeDatabase() if is_staff else None, + 'platform': InvenTree.version.inventreePlatform() if is_staff else None, + 'installer': InvenTree.config.inventreeInstaller() + if is_staff + else None, + 'target': InvenTree.version.inventreeTarget() if is_staff else None, + 'django_admin': settings.INVENTREE_ADMIN_URL + if (is_staff and settings.INVENTREE_ADMIN_ENABLED) + else None, + 'settings': { + 'sso_registration': registration_enabled('LOGIN_ENABLE_SSO_REG'), + 'registration_enabled': registration_enabled('LOGIN_ENABLE_REG'), + 'password_forgotten_enabled': get_global_setting( + 'LOGIN_ENABLE_PWD_FORGOT' + ), + }, + } + + # Cache for one minute - this will speed up repeated calls to this endpoint + cache.set(cache_key, data, 60) return JsonResponse(data) diff --git a/src/backend/InvenTree/generic/states/api.py b/src/backend/InvenTree/generic/states/api.py index 4047c69161b5..45272954281e 100644 --- a/src/backend/InvenTree/generic/states/api.py +++ b/src/backend/InvenTree/generic/states/api.py @@ -3,6 +3,8 @@ import inspect from django.urls import include, path +from django.utils.decorators import method_decorator +from django.views.decorators.cache import cache_page from drf_spectacular.types import OpenApiTypes from drf_spectacular.utils import OpenApiResponse, extend_schema @@ -104,6 +106,8 @@ def get(self, request, *args, **kwargs): return Response(serializer.data) +# Cache the AllStatusViews response - these values are not expected to change frequently +@method_decorator(cache_page(60), name='dispatch') class AllStatusViews(StatusView): """Endpoint for listing all defined status models.""" diff --git a/src/frontend/src/functions/auth.tsx b/src/frontend/src/functions/auth.tsx index 6f541f049c0d..5deb8c329b7c 100644 --- a/src/frontend/src/functions/auth.tsx +++ b/src/frontend/src/functions/auth.tsx @@ -156,8 +156,8 @@ export async function doBasicLogin( // we are successfully logged in - gather required states for app if (loginDone) { await fetchUserState(); - await fetchGlobalStates(); observeProfile(); + await fetchGlobalStates(); } else if (!success) { clearUserState(); } @@ -441,7 +441,6 @@ export const checkLoginState = async ( if (isOk) { observeProfile(); await fetchGlobalStates(); - followRedirect(navigate, redirect); } }); diff --git a/src/frontend/src/pages/Auth/Login.tsx b/src/frontend/src/pages/Auth/Login.tsx index 9e9d0098ec36..f1b672f25bd3 100644 --- a/src/frontend/src/pages/Auth/Login.tsx +++ b/src/frontend/src/pages/Auth/Login.tsx @@ -21,6 +21,7 @@ import { } from '../../functions/auth'; import { useLocalState } from '../../states/LocalState'; import { useServerApiState } from '../../states/ServerApiState'; +import { useUserState } from '../../states/UserState'; import { Wrapper } from './Layout'; export default function Login() { @@ -45,6 +46,9 @@ export default function Login() { state.registration_enabled ]) ); + const [loginChecked] = useUserState( + useShallow((state) => [state.login_checked]) + ); const any_reg_enabled = registration_enabled() || sso_registration() || false; const LoginMessage = useMemo(() => { @@ -79,7 +83,9 @@ export default function Login() { ChangeHost(defaultHostKey); } - checkLoginState(navigate, location?.state, true); + if (!loginChecked) { + checkLoginState(navigate, location?.state, true); + } // check if we got login params (login and password) if (searchParams.has('login') && searchParams.has('password')) { diff --git a/src/frontend/src/states/states.tsx b/src/frontend/src/states/states.tsx index 4e9a1cfe338a..ddeec1d854b5 100644 --- a/src/frontend/src/states/states.tsx +++ b/src/frontend/src/states/states.tsx @@ -56,10 +56,13 @@ export async function fetchGlobalStates() { const traceId = setTraceId(); await Promise.all([ useServerApiState.getState().fetchServerApiState(), - useUserSettingsState.getState().fetchSettings(), - useGlobalSettingsState.getState().fetchSettings(), useGlobalStatusState.getState().fetchStatus(), useIconState.getState().fetchIcons() ]); + removeTraceId(traceId); + + // Load settings in the background + useUserSettingsState.getState().fetchSettings(); + useGlobalSettingsState.getState().fetchSettings(); } diff --git a/src/frontend/vite.config.ts b/src/frontend/vite.config.ts index 627a1ade6e1b..a96c79d4c13d 100644 --- a/src/frontend/vite.config.ts +++ b/src/frontend/vite.config.ts @@ -63,7 +63,19 @@ export default defineConfig(({ command, mode }) => { build: { manifest: true, outDir: OUTPUT_DIR, - sourcemap: true + sourcemap: true, + rollupOptions: { + output: { + manualChunks(id) { + if (id.includes('@tabler/icons-react')) { + return 'tabler-icons'; + } + if (id.includes('@mantine')) { + return 'mantine'; + } + } + } + } }, resolve: { alias: {