Frontend server settings (#5765)

* Update ApiToken model

- Add metadata
- Remove unique_together requirement
- Add last_seen field

* Update admin page for token

* Store metadata against token on creation

* Track last-seen date

* Allow match against existing valid token

- If token is expired or revoked, create a new one
- Prevents duplication of tokens

* Update unit tests

* Fix default server

* Improve functionality for extracting frontend settings

* Update default server list

* Use f-strings

* Revert logger name

* Remove mark_safe warning
This commit is contained in:
Oliver 2023-10-23 22:33:16 +11:00 committed by GitHub
parent 39c499622d
commit 679b49b4f7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 99 additions and 52 deletions

View File

@ -7,6 +7,7 @@ import os
import random
import shutil
import string
import warnings
from pathlib import Path
logger = logging.getLogger('inventree')
@ -341,3 +342,58 @@ def get_custom_file(env_ref: str, conf_ref: str, log_ref: str, lookup_media: boo
value = False
return value
def get_frontend_settings(debug=True):
"""Return a dictionary of settings for the frontend interface.
Note that the new config settings use the 'FRONTEND' key,
whereas the legacy key was 'PUI' (platform UI) which is now deprecated
"""
# Legacy settings
pui_settings = get_setting('INVENTREE_PUI_SETTINGS', 'pui_settings', {}, typecast=dict)
if len(pui_settings) > 0:
warnings.warn(
"The 'INVENTREE_PUI_SETTINGS' key is deprecated. Please use 'INVENTREE_FRONTEND_SETTINGS' instead",
DeprecationWarning, stacklevel=2
)
# New settings
frontend_settings = get_setting('INVENTREE_FRONTEND_SETTINGS', 'frontend_settings', {}, typecast=dict)
# Merge settings
settings = {**pui_settings, **frontend_settings}
# Set the base URL
if 'base_url' not in settings:
base_url = get_setting('INVENTREE_PUI_URL_BASE', 'pui_url_base', '')
if base_url:
warnings.warn(
"The 'INVENTREE_PUI_URL_BASE' key is deprecated. Please use 'INVENTREE_FRONTEND_URL_BASE' instead",
DeprecationWarning, stacklevel=2
)
else:
base_url = get_setting('INVENTREE_FRONTEND_URL_BASE', 'frontend_url_base', 'platform')
settings['base_url'] = base_url
# Set the server list
settings['server_list'] = settings.get('server_list', [])
# Set the debug flag
settings['debug'] = debug
if 'environment' not in settings:
settings['environment'] = 'development' if debug else 'production'
if debug and 'show_server_selector' not in settings:
# In debug mode, show server selector by default
settings['show_server_selector'] = True
elif len(settings['server_list']) == 0:
# If no servers are specified, show server selector
settings['show_server_selector'] = True
return settings

View File

@ -64,7 +64,7 @@ class AuthRequiredMiddleware(object):
elif request.path_info.startswith('/accounts/'):
authorized = True
elif request.path_info.startswith(f'/{settings.PUI_URL_BASE}/') or request.path_info.startswith('/assets/') or request.path_info == f'/{settings.PUI_URL_BASE}':
elif request.path_info.startswith(f'/{settings.FRONTEND_URL_BASE}/') or request.path_info.startswith('/assets/') or request.path_info == f'/{settings.FRONTEND_URL_BASE}':
authorized = True
elif 'Authorization' in request.headers.keys() or 'authorization' in request.headers.keys():

View File

@ -1044,9 +1044,9 @@ CUSTOM_SPLASH = get_custom_file('INVENTREE_CUSTOM_SPLASH', 'customize.splash', '
CUSTOMIZE = get_setting('INVENTREE_CUSTOMIZE', 'customize', {})
# Frontend settings
PUI_URL_BASE = get_setting('INVENTREE_PUI_URL_BASE', 'pui_url_base', 'platform')
PUI_SETTINGS = get_setting("INVENTREE_PUI_SETTINGS", "pui_settings", {})
# Load settings for the frontend interface
FRONTEND_SETTINGS = config.get_frontend_settings(debug=DEBUG)
FRONTEND_URL_BASE = FRONTEND_SETTINGS.get('base_url', 'platform')
if DEBUG:
logger.info("InvenTree running with DEBUG enabled")
@ -1076,5 +1076,5 @@ if CUSTOM_FLAGS:
# Magic login django-sesame
SESAME_MAX_AGE = 300
# LOGIN_REDIRECT_URL = f"/{PUI_URL_BASE}/logged-in/"
# LOGIN_REDIRECT_URL = f"/{FRONTEND_URL_BASE}/logged-in/"
LOGIN_REDIRECT_URL = "/index/"

View File

@ -1216,6 +1216,6 @@ class MagicLoginTest(InvenTreeTestCase):
self.assertEqual(resp.url, '/index/')
# Note: 2023-08-08 - This test has been changed because "platform UI" is not generally available yet
# TODO: In the future, the URL comparison will need to be reverted
# self.assertEqual(resp.url, f'/{settings.PUI_URL_BASE}/logged-in/')
# self.assertEqual(resp.url, f'/{settings.FRONTEND_URL_BASE}/logged-in/')
# And we should be logged in again
self.assertEqual(resp.wsgi_request.user, self.user)

View File

@ -290,18 +290,17 @@ remote_login_header: HTTP_REMOTE_USER
# logo: img/custom_logo.png
# splash: img/custom_splash.jpg
# Platform UI options
# pui_settings:
# Frontend UI settings
# frontend_settings:
# base_url: 'frontend'
# server_list:
# my_server1:
# host: https://demo.inventree.org/api/
# host: https://demo.inventree.org/
# name: InvenTree Demo
# default_server: my_server1
# show_server_selector: false
# sentry_dsn: https://84f0c3ea90c64e5092e2bf5dfe325725@o1047628.ingest.sentry.io/4504160008273920
# environment: development
# Base URL for serving Platform UI
# pui_url_base: 'platform'
# Custom flags
# InvenTree uses django-flags; read more in their docs at https://cfpb.github.io/django-flags/conditions/

View File

@ -6,7 +6,6 @@ from django.contrib.admin.widgets import FilteredSelectMultiple
from django.contrib.auth import get_user_model
from django.contrib.auth.admin import UserAdmin
from django.contrib.auth.models import Group
from django.utils.safestring import mark_safe
from django.utils.translation import gettext_lazy as _
from users.models import ApiToken, Owner, RuleSet
@ -203,20 +202,22 @@ class RoleGroupAdmin(admin.ModelAdmin): # pragma: no cover
users = form.cleaned_data['users']
# Check for users who are members of multiple groups
warning_message = ''
multiple_group_users = []
for user in users:
if user.groups.all().count() > 1:
warning_message += f'<br>- <b>{user.username}</b> is member of: '
for idx, group in enumerate(user.groups.all()):
warning_message += f'<b>{group.name}</b>'
if idx < len(user.groups.all()) - 1:
warning_message += ', '
multiple_group_users.append(user.username)
# If any, display warning message when group is saved
if warning_message:
warning_message = mark_safe(_(f'The following users are members of multiple groups:'
f'{warning_message}'))
messages.add_message(request, messages.WARNING, warning_message)
if len(multiple_group_users) > 0:
msg = _("The following users are members of multiple groups") + ": " + ", ".join(multiple_group_users)
messages.add_message(
request,
messages.WARNING,
msg
)
def save_formset(self, request, form, formset, change):
"""Save the inline formset"""

View File

@ -422,11 +422,7 @@ class RuleSet(models.Model):
"""Construct the correctly formatted permission string, given the app_model name, and the permission type."""
model, app = split_model(model)
return "{app}.{perm}_{model}".format(
app=app,
perm=permission,
model=model
)
return f"{app}.{permission}_{model}"
def __str__(self, debug=False): # pragma: no cover
"""Ruleset string representation."""
@ -504,12 +500,7 @@ def update_group_roles(group, debug=False):
# and create a simplified permission key string
for p in group.permissions.all().prefetch_related('content_type'):
(permission, app, model) = p.natural_key()
permission_string = '{app}.{perm}'.format(
app=app,
perm=permission
)
permission_string = f"{app}.{permission}"
group_permissions.add(permission_string)
# List of permissions which must be added to the group
@ -527,7 +518,7 @@ def update_group_roles(group, debug=False):
allowed: Whether or not the action is allowed
"""
if action not in ['view', 'add', 'change', 'delete']: # pragma: no cover
raise ValueError("Action {a} is invalid".format(a=action))
raise ValueError(f"Action {action} is invalid")
permission_string = RuleSet.get_model_permission_string(model, action)

View File

@ -1,4 +1,5 @@
"""Template tag to render SPA imports."""
import json
from logging import getLogger
from pathlib import Path
@ -10,11 +11,7 @@ from django.utils.safestring import mark_safe
logger = getLogger("InvenTree")
register = template.Library()
PUI_DEFAULTS = {
'url_base': settings.PUI_URL_BASE,
}
PUI_DEFAULTS.update(getattr(settings, 'PUI_SETTINGS', {}))
PUI_SETTINGS = json.dumps(PUI_DEFAULTS)
FRONTEND_SETTINGS = json.dumps(settings.FRONTEND_SETTINGS)
@register.simple_tag
@ -30,11 +27,11 @@ def spa_bundle():
index = manifest_data.get("index.html")
css_index = manifest_data.get("index.css")
dynmanic_files = index.get("dynamicImports", [])
dynamic_files = index.get("dynamicImports", [])
imports_files = "".join(
[
f'<script type="module" src="{settings.STATIC_URL}web/{manifest_data[file]["file"]}"></script>'
for file in dynmanic_files
for file in dynamic_files
]
)
@ -47,4 +44,4 @@ def spa_bundle():
@register.simple_tag
def spa_settings():
"""Render settings for spa."""
return mark_safe(f"""<script>window.INVENTREE_SETTINGS={PUI_SETTINGS}</script>""")
return mark_safe(f"""<script>window.INVENTREE_SETTINGS={FRONTEND_SETTINGS}</script>""")

View File

@ -20,12 +20,12 @@ spa_view = ensure_csrf_cookie(TemplateView.as_view(template_name="web/index.html
urlpatterns = [
path(f'{settings.PUI_URL_BASE}/', include([
path(f'{settings.FRONTEND_URL_BASE}/', include([
path("assets/<path:path>", RedirectAssetView.as_view()),
re_path(r"^(?P<path>.*)/$", spa_view),
path("set-password?uid=<uid>&token=<token>", spa_view, name="password_reset_confirm"),
path("", spa_view),]
)),
path(settings.PUI_URL_BASE, spa_view, name='platform'),
path(settings.FRONTEND_URL_BASE, spa_view, name='platform'),
path("assets/<path:path>", RedirectAssetView.as_view()),
]

View File

@ -43,6 +43,9 @@ export const doClassicLogin = async (username: string, password: string) => {
return true;
};
/**
* Logout the user (invalidate auth token)
*/
export const doClassicLogout = async () => {
// TODO @matmair - logout from the server session
// Set token in context

View File

@ -14,7 +14,7 @@ declare global {
server_list: HostList;
default_server: string;
show_server_selector: boolean;
url_base: string;
base_url: string;
sentry_dsn?: string;
environment?: string;
};
@ -27,20 +27,20 @@ export const IS_DEV_OR_DEMO = IS_DEV || IS_DEMO;
window.INVENTREE_SETTINGS = {
server_list: {
'mantine-cqj63coxn': {
localhost: {
host: `${window.location.origin}/`,
name: 'Current Server'
},
...(IS_DEV_OR_DEMO
? {
'mantine-u56l5jt85': {
demo: {
host: 'https://demo.inventree.org/',
name: 'InvenTree Demo'
}
}
: {})
},
default_server: IS_DEMO ? 'mantine-u56l5jt85' : 'mantine-cqj63coxn', // use demo server for demo mode
default_server: IS_DEMO ? 'demo' : 'localhost',
show_server_selector: IS_DEV_OR_DEMO,
// merge in settings that are already set via django's spa_view or for development
@ -56,7 +56,7 @@ if (window.INVENTREE_SETTINGS.sentry_dsn) {
});
}
export const url_base = window.INVENTREE_SETTINGS.url_base || 'platform';
export const base_url = window.INVENTREE_SETTINGS.base_url || 'platform';
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
<React.StrictMode>
@ -66,5 +66,5 @@ ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
// Redirect to base url if on /
if (window.location.pathname === '/') {
window.location.replace(`/${url_base}`);
window.location.replace(`/${base_url}`);
}

View File

@ -5,7 +5,7 @@ import { BrowserRouter } from 'react-router-dom';
import { queryClient, setApiDefaults } from '../App';
import { BaseContext } from '../contexts/BaseContext';
import { defaultHostList } from '../defaults/defaultHostList';
import { url_base } from '../main';
import { base_url } from '../main';
import { routes } from '../router';
import { useLocalState } from '../states/LocalState';
import { useSessionState } from '../states/SessionState';
@ -49,7 +49,7 @@ export default function DesktopAppView() {
return (
<BaseContext>
<QueryClientProvider client={queryClient}>
<BrowserRouter basename={url_base}>{routes}</BrowserRouter>
<BrowserRouter basename={base_url}>{routes}</BrowserRouter>
</QueryClientProvider>
</BaseContext>
);