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 random
import shutil import shutil
import string import string
import warnings
from pathlib import Path from pathlib import Path
logger = logging.getLogger('inventree') 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 value = False
return value 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/'): elif request.path_info.startswith('/accounts/'):
authorized = True 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 authorized = True
elif 'Authorization' in request.headers.keys() or 'authorization' in request.headers.keys(): 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', {}) CUSTOMIZE = get_setting('INVENTREE_CUSTOMIZE', 'customize', {})
# Frontend settings # Load settings for the frontend interface
PUI_URL_BASE = get_setting('INVENTREE_PUI_URL_BASE', 'pui_url_base', 'platform') FRONTEND_SETTINGS = config.get_frontend_settings(debug=DEBUG)
PUI_SETTINGS = get_setting("INVENTREE_PUI_SETTINGS", "pui_settings", {}) FRONTEND_URL_BASE = FRONTEND_SETTINGS.get('base_url', 'platform')
if DEBUG: if DEBUG:
logger.info("InvenTree running with DEBUG enabled") logger.info("InvenTree running with DEBUG enabled")
@ -1076,5 +1076,5 @@ if CUSTOM_FLAGS:
# Magic login django-sesame # Magic login django-sesame
SESAME_MAX_AGE = 300 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/" LOGIN_REDIRECT_URL = "/index/"

View File

@ -1216,6 +1216,6 @@ class MagicLoginTest(InvenTreeTestCase):
self.assertEqual(resp.url, '/index/') self.assertEqual(resp.url, '/index/')
# Note: 2023-08-08 - This test has been changed because "platform UI" is not generally available yet # 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 # 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 # And we should be logged in again
self.assertEqual(resp.wsgi_request.user, self.user) 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 # logo: img/custom_logo.png
# splash: img/custom_splash.jpg # splash: img/custom_splash.jpg
# Platform UI options # Frontend UI settings
# pui_settings: # frontend_settings:
# base_url: 'frontend'
# server_list: # server_list:
# my_server1: # my_server1:
# host: https://demo.inventree.org/api/ # host: https://demo.inventree.org/
# name: InvenTree Demo # name: InvenTree Demo
# default_server: my_server1 # default_server: my_server1
# show_server_selector: false # show_server_selector: false
# sentry_dsn: https://84f0c3ea90c64e5092e2bf5dfe325725@o1047628.ingest.sentry.io/4504160008273920 # sentry_dsn: https://84f0c3ea90c64e5092e2bf5dfe325725@o1047628.ingest.sentry.io/4504160008273920
# environment: development # environment: development
# Base URL for serving Platform UI
# pui_url_base: 'platform'
# Custom flags # Custom flags
# InvenTree uses django-flags; read more in their docs at https://cfpb.github.io/django-flags/conditions/ # 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 import get_user_model
from django.contrib.auth.admin import UserAdmin from django.contrib.auth.admin import UserAdmin
from django.contrib.auth.models import Group from django.contrib.auth.models import Group
from django.utils.safestring import mark_safe
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from users.models import ApiToken, Owner, RuleSet from users.models import ApiToken, Owner, RuleSet
@ -203,20 +202,22 @@ class RoleGroupAdmin(admin.ModelAdmin): # pragma: no cover
users = form.cleaned_data['users'] users = form.cleaned_data['users']
# Check for users who are members of multiple groups # Check for users who are members of multiple groups
warning_message = '' multiple_group_users = []
for user in users: for user in users:
if user.groups.all().count() > 1: if user.groups.all().count() > 1:
warning_message += f'<br>- <b>{user.username}</b> is member of: ' multiple_group_users.append(user.username)
for idx, group in enumerate(user.groups.all()):
warning_message += f'<b>{group.name}</b>'
if idx < len(user.groups.all()) - 1:
warning_message += ', '
# If any, display warning message when group is saved # If any, display warning message when group is saved
if warning_message: if len(multiple_group_users) > 0:
warning_message = mark_safe(_(f'The following users are members of multiple groups:'
f'{warning_message}')) msg = _("The following users are members of multiple groups") + ": " + ", ".join(multiple_group_users)
messages.add_message(request, messages.WARNING, warning_message)
messages.add_message(
request,
messages.WARNING,
msg
)
def save_formset(self, request, form, formset, change): def save_formset(self, request, form, formset, change):
"""Save the inline formset""" """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.""" """Construct the correctly formatted permission string, given the app_model name, and the permission type."""
model, app = split_model(model) model, app = split_model(model)
return "{app}.{perm}_{model}".format( return f"{app}.{permission}_{model}"
app=app,
perm=permission,
model=model
)
def __str__(self, debug=False): # pragma: no cover def __str__(self, debug=False): # pragma: no cover
"""Ruleset string representation.""" """Ruleset string representation."""
@ -504,12 +500,7 @@ def update_group_roles(group, debug=False):
# and create a simplified permission key string # and create a simplified permission key string
for p in group.permissions.all().prefetch_related('content_type'): for p in group.permissions.all().prefetch_related('content_type'):
(permission, app, model) = p.natural_key() (permission, app, model) = p.natural_key()
permission_string = f"{app}.{permission}"
permission_string = '{app}.{perm}'.format(
app=app,
perm=permission
)
group_permissions.add(permission_string) group_permissions.add(permission_string)
# List of permissions which must be added to the group # 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 allowed: Whether or not the action is allowed
""" """
if action not in ['view', 'add', 'change', 'delete']: # pragma: no cover 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) permission_string = RuleSet.get_model_permission_string(model, action)

View File

@ -1,4 +1,5 @@
"""Template tag to render SPA imports.""" """Template tag to render SPA imports."""
import json import json
from logging import getLogger from logging import getLogger
from pathlib import Path from pathlib import Path
@ -10,11 +11,7 @@ from django.utils.safestring import mark_safe
logger = getLogger("InvenTree") logger = getLogger("InvenTree")
register = template.Library() register = template.Library()
PUI_DEFAULTS = { FRONTEND_SETTINGS = json.dumps(settings.FRONTEND_SETTINGS)
'url_base': settings.PUI_URL_BASE,
}
PUI_DEFAULTS.update(getattr(settings, 'PUI_SETTINGS', {}))
PUI_SETTINGS = json.dumps(PUI_DEFAULTS)
@register.simple_tag @register.simple_tag
@ -30,11 +27,11 @@ def spa_bundle():
index = manifest_data.get("index.html") index = manifest_data.get("index.html")
css_index = manifest_data.get("index.css") css_index = manifest_data.get("index.css")
dynmanic_files = index.get("dynamicImports", []) dynamic_files = index.get("dynamicImports", [])
imports_files = "".join( imports_files = "".join(
[ [
f'<script type="module" src="{settings.STATIC_URL}web/{manifest_data[file]["file"]}"></script>' 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 @register.simple_tag
def spa_settings(): def spa_settings():
"""Render settings for spa.""" """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 = [ urlpatterns = [
path(f'{settings.PUI_URL_BASE}/', include([ path(f'{settings.FRONTEND_URL_BASE}/', include([
path("assets/<path:path>", RedirectAssetView.as_view()), path("assets/<path:path>", RedirectAssetView.as_view()),
re_path(r"^(?P<path>.*)/$", spa_view), re_path(r"^(?P<path>.*)/$", spa_view),
path("set-password?uid=<uid>&token=<token>", spa_view, name="password_reset_confirm"), path("set-password?uid=<uid>&token=<token>", spa_view, name="password_reset_confirm"),
path("", spa_view),] 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()), path("assets/<path:path>", RedirectAssetView.as_view()),
] ]

View File

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

View File

@ -14,7 +14,7 @@ declare global {
server_list: HostList; server_list: HostList;
default_server: string; default_server: string;
show_server_selector: boolean; show_server_selector: boolean;
url_base: string; base_url: string;
sentry_dsn?: string; sentry_dsn?: string;
environment?: string; environment?: string;
}; };
@ -27,20 +27,20 @@ export const IS_DEV_OR_DEMO = IS_DEV || IS_DEMO;
window.INVENTREE_SETTINGS = { window.INVENTREE_SETTINGS = {
server_list: { server_list: {
'mantine-cqj63coxn': { localhost: {
host: `${window.location.origin}/`, host: `${window.location.origin}/`,
name: 'Current Server' name: 'Current Server'
}, },
...(IS_DEV_OR_DEMO ...(IS_DEV_OR_DEMO
? { ? {
'mantine-u56l5jt85': { demo: {
host: 'https://demo.inventree.org/', host: 'https://demo.inventree.org/',
name: 'InvenTree Demo' 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, show_server_selector: IS_DEV_OR_DEMO,
// merge in settings that are already set via django's spa_view or for development // 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( ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
<React.StrictMode> <React.StrictMode>
@ -66,5 +66,5 @@ ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
// Redirect to base url if on / // Redirect to base url if on /
if (window.location.pathname === '/') { 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 { queryClient, setApiDefaults } from '../App';
import { BaseContext } from '../contexts/BaseContext'; import { BaseContext } from '../contexts/BaseContext';
import { defaultHostList } from '../defaults/defaultHostList'; import { defaultHostList } from '../defaults/defaultHostList';
import { url_base } from '../main'; import { base_url } from '../main';
import { routes } from '../router'; import { routes } from '../router';
import { useLocalState } from '../states/LocalState'; import { useLocalState } from '../states/LocalState';
import { useSessionState } from '../states/SessionState'; import { useSessionState } from '../states/SessionState';
@ -49,7 +49,7 @@ export default function DesktopAppView() {
return ( return (
<BaseContext> <BaseContext>
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<BrowserRouter basename={url_base}>{routes}</BrowserRouter> <BrowserRouter basename={base_url}>{routes}</BrowserRouter>
</QueryClientProvider> </QueryClientProvider>
</BaseContext> </BaseContext>
); );