[WIP] Site ID Fixes (#6390)

* Fix docs for INVENTREE_SITE_URL

* Adjust default SITE_ID

* Optional support for multi-site

- Disable by default

* Prevent site setting from being changed if set by config parameter

* Update site url setting on server launch

* Update log messages

* Update RULESET_MODELS

* Update unit tests

* More fixes for unit tests

* Update docs

* Update SSO image
This commit is contained in:
Oliver 2024-02-03 22:51:29 +11:00 committed by GitHub
parent 538ff9be7b
commit 5bc00298c6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 361 additions and 237 deletions

View File

@ -58,6 +58,7 @@ class InvenTreeConfig(AppConfig):
# Let the background worker check for migrations
InvenTree.tasks.offload_task(InvenTree.tasks.check_for_migrations)
self.update_site_url()
self.collect_notification_methods()
self.collect_state_transition_methods()
@ -223,6 +224,46 @@ class InvenTreeConfig(AppConfig):
except Exception as e:
logger.exception('Error updating exchange rates: %s (%s)', e, type(e))
def update_site_url(self):
"""Update the site URL setting.
- If a fixed SITE_URL is specified (via configuration), it should override the INVENTREE_BASE_URL setting
- If multi-site support is enabled, update the site URL for the current site
"""
import common.models
if not InvenTree.ready.canAppAccessDatabase():
return
if InvenTree.ready.isImportingData() or InvenTree.ready.isRunningMigrations():
return
if settings.SITE_URL:
try:
if (
common.models.InvenTreeSetting.get_setting('INVENTREE_BASE_URL')
!= settings.SITE_URL
):
common.models.InvenTreeSetting.set_setting(
'INVENTREE_BASE_URL', settings.SITE_URL
)
logger.info('Updated INVENTREE_SITE_URL to %s', settings.SITE_URL)
except Exception:
pass
# If multi-site support is enabled, update the site URL for the current site
try:
from django.contrib.sites.models import Site
site = Site.objects.get_current()
site.domain = settings.SITE_URL
site.save()
logger.info('Updated current site URL to %s', settings.SITE_URL)
except Exception:
pass
def add_user_on_startup(self):
"""Add a user on startup."""
# stop if checks were already created

View File

@ -72,7 +72,7 @@ def user_roles(request):
roles = {}
for role in RuleSet.RULESET_MODELS.keys():
for role in RuleSet.get_ruleset_models().keys():
permissions = {}
for perm in ['view', 'add', 'change', 'delete']:

View File

@ -6,7 +6,6 @@ from urllib.parse import urlencode
from django import forms
from django.conf import settings
from django.contrib.auth.models import Group, User
from django.contrib.sites.models import Site
from django.http import HttpResponseRedirect
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
@ -23,6 +22,7 @@ from crispy_forms.layout import Field, Layout
from dj_rest_auth.registration.serializers import RegisterSerializer
from rest_framework import serializers
import InvenTree.helpers_model
import InvenTree.sso
from common.models import InvenTreeSetting
from InvenTree.exceptions import log_error
@ -293,7 +293,8 @@ class CustomUrlMixin:
def get_email_confirmation_url(self, request, emailconfirmation):
"""Custom email confirmation (activation) url."""
url = reverse('account_confirm_email', args=[emailconfirmation.key])
return Site.objects.get_current().domain + url
return InvenTree.helpers_model.construct_absolute_url(url)
class CustomAccountAdapter(

View File

@ -34,47 +34,59 @@ def getSetting(key, backup_value=None):
return common.models.InvenTreeSetting.get_setting(key, backup_value=backup_value)
def construct_absolute_url(*arg, **kwargs):
"""Construct (or attempt to construct) an absolute URL from a relative URL.
def get_base_url(request=None):
"""Return the base URL for the InvenTree server.
This is useful when (for example) sending an email to a user with a link
to something in the InvenTree web framework.
A URL is constructed in the following order:
1. If settings.SITE_URL is set (e.g. in the Django settings), use that
2. If the InvenTree setting INVENTREE_BASE_URL is set, use that
3. Otherwise, use the current request URL (if available)
The base URL is determined in the following order of decreasing priority:
1. If a request object is provided, use the request URL
2. Multi-site is enabled, and the current site has a valid URL
3. If settings.SITE_URL is set (e.g. in the Django settings), use that
4. If the InvenTree setting INVENTREE_BASE_URL is set, use that
"""
relative_url = '/'.join(arg)
# Check if a request is provided
if request:
return request.build_absolute_uri('/')
# If a site URL is provided, use that
site_url = getattr(settings, 'SITE_URL', None)
if not site_url:
# Otherwise, try to use the InvenTree setting
# Check if multi-site is enabled
try:
site_url = common.models.InvenTreeSetting.get_setting(
from django.contrib.sites.models import Site
return Site.objects.get_current().domain
except (ImportError, RuntimeError):
pass
# Check if a global site URL is provided
if site_url := getattr(settings, 'SITE_URL', None):
return site_url
# Check if a global InvenTree setting is provided
try:
if site_url := common.models.InvenTreeSetting.get_setting(
'INVENTREE_BASE_URL', create=False, cache=False
)
):
return site_url
except (ProgrammingError, OperationalError):
pass
if not site_url:
# Otherwise, try to use the current request
request = kwargs.get('request', None)
if request:
site_url = request.build_absolute_uri('/')
if not site_url:
# No site URL available, return the relative URL
return relative_url
return urljoin(site_url, relative_url)
# No base URL available
return ''
def get_base_url(**kwargs):
"""Return the base URL for the InvenTree server."""
return construct_absolute_url('', **kwargs)
def construct_absolute_url(*arg, base_url=None, request=None):
"""Construct (or attempt to construct) an absolute URL from a relative URL.
Args:
*arg: The relative URL to construct
base_url: The base URL to use for the construction (if not provided, will attempt to determine from settings)
request: The request object to use for the construction (optional)
"""
relative_url = '/'.join(arg)
if not base_url:
base_url = get_base_url(request=request)
return urljoin(base_url, relative_url)
def download_image_from_url(remote_url, timeout=2.5):

View File

@ -2,7 +2,6 @@
from django.conf import settings
from django.contrib.auth.models import User
from django.contrib.sites.models import Site
from django.core.mail import send_mail
from django.template.loader import render_to_string
from django.urls import reverse
@ -13,18 +12,20 @@ from rest_framework import serializers
from rest_framework.response import Response
from rest_framework.views import APIView
import InvenTree.version
def send_simple_login_email(user, link):
"""Send an email with the login link to this user."""
site = Site.objects.get_current()
site_name = InvenTree.version.inventreeInstanceName()
context = {'username': user.username, 'site_name': site.name, 'link': link}
context = {'username': user.username, 'site_name': site_name, 'link': link}
email_plaintext_message = render_to_string(
'InvenTree/user_simple_login.txt', context
)
send_mail(
_(f'[{site.name}] Log in to the app'),
_(f'[{site_name}] Log in to the app'),
email_plaintext_message,
settings.DEFAULT_FROM_EMAIL,
[user.email],

View File

@ -7,7 +7,6 @@ from decimal import Decimal
from django.conf import settings
from django.contrib.auth.models import User
from django.contrib.sites.models import Site
from django.core.exceptions import ValidationError as DjangoValidationError
from django.db import models
from django.utils.translation import gettext_lazy as _
@ -26,7 +25,7 @@ from taggit.serializers import TaggitSerializer
import common.models as common_models
from common.settings import currency_code_default, currency_code_mappings
from InvenTree.fields import InvenTreeRestURLField, InvenTreeURLField
from InvenTree.helpers_model import download_image_from_url
from InvenTree.helpers_model import download_image_from_url, get_base_url
class InvenTreeMoneySerializer(MoneyField):
@ -445,19 +444,23 @@ class UserCreateSerializer(ExendedUserSerializer):
def create(self, validated_data):
"""Send an e email to the user after creation."""
base_url = get_base_url()
instance = super().create(validated_data)
# Make sure the user cannot login until they have set a password
instance.set_unusable_password()
# Send the user an onboarding email (from current site)
current_site = Site.objects.get_current()
domain = current_site.domain
instance.email_user(
subject=_(f'Welcome to {current_site.name}'),
message = _(
f'Your account has been created.\n\nPlease use the password reset function to get access (at https://{domain}).'
),
'Your account has been created.\n\nPlease use the password reset function to login'
)
if base_url:
message += f'\nURL: {base_url}'
# Send the user an onboarding email (from current site)
instance.email_user(subject=_('Welcome to InvenTree'), message=message)
return instance

View File

@ -978,13 +978,30 @@ CRISPY_TEMPLATE_PACK = 'bootstrap4'
# Use database transactions when importing / exporting data
IMPORT_EXPORT_USE_TRANSACTIONS = True
SITE_ID = 1
# Site URL can be specified statically, or via a run-time setting
SITE_URL = get_setting('INVENTREE_SITE_URL', 'site_url', None)
if SITE_URL:
logger.info('Using Site URL: %s', SITE_URL)
# Check that the site URL is valid
validator = URLValidator()
validator(SITE_URL)
# Enable or disable multi-site framework
SITE_MULTI = get_boolean_setting('INVENTREE_SITE_MULTI', 'site_multi', False)
# If a SITE_ID is specified
SITE_ID = get_setting('INVENTREE_SITE_ID', 'site_id', 1 if SITE_MULTI else None)
# Load the allauth social backends
SOCIAL_BACKENDS = get_setting(
'INVENTREE_SOCIAL_BACKENDS', 'social_backends', [], typecast=list
)
if not SITE_MULTI:
INSTALLED_APPS.remove('django.contrib.sites')
for app in SOCIAL_BACKENDS:
# Ensure that the app starts with 'allauth.socialaccount.providers'
social_prefix = 'allauth.socialaccount.providers.'
@ -1096,16 +1113,6 @@ PLUGIN_RETRY = get_setting(
) # How often should plugin loading be tried?
PLUGIN_FILE_CHECKED = False # Was the plugin file checked?
# Site URL can be specified statically, or via a run-time setting
SITE_URL = get_setting('INVENTREE_SITE_URL', 'site_url', None)
if SITE_URL:
logger.info('Site URL: %s', SITE_URL)
# Check that the site URL is valid
validator = URLValidator()
validator(SITE_URL)
# User interface customization values
CUSTOM_LOGO = get_custom_file(
'INVENTREE_CUSTOM_LOGO', 'customize.logo', 'custom logo', lookup_media=True

View File

@ -10,7 +10,6 @@ from unittest import mock
import django.core.exceptions as django_exceptions
from django.conf import settings
from django.contrib.auth import get_user_model
from django.contrib.sites.models import Site
from django.core import mail
from django.core.exceptions import ValidationError
from django.test import TestCase, override_settings
@ -372,7 +371,7 @@ class TestHelpers(TestCase):
for url, expected in tests.items():
# Test with supplied base URL
self.assertEqual(
InvenTree.helpers_model.construct_absolute_url(url, site_url=base),
InvenTree.helpers_model.construct_absolute_url(url, base_url=base),
expected,
)
@ -1049,6 +1048,12 @@ class TestInstanceName(InvenTreeTestCase):
self.assertEqual(version.inventreeInstanceTitle(), 'Testing title')
try:
from django.contrib.sites.models import Site
except (ImportError, RuntimeError):
# Multi-site support not enabled
return
# The site should also be changed
site_obj = Site.objects.all().order_by('id').first()
self.assertEqual(site_obj.name, 'Testing title')
@ -1060,9 +1065,18 @@ class TestInstanceName(InvenTreeTestCase):
'INVENTREE_BASE_URL', 'http://127.1.2.3', self.user
)
# No further tests if multi-site support is not enabled
if not settings.SITE_MULTI:
return
# The site should also be changed
try:
from django.contrib.sites.models import Site
site_obj = Site.objects.all().order_by('id').first()
self.assertEqual(site_obj.domain, 'http://127.1.2.3')
except Exception:
pass
class TestOffloadTask(InvenTreeTestCase):
@ -1234,7 +1248,7 @@ class MagicLoginTest(InvenTreeTestCase):
self.assertEqual(resp.status_code, 200)
self.assertEqual(resp.data, {'status': 'ok'})
self.assertEqual(len(mail.outbox), 1)
self.assertEqual(mail.outbox[0].subject, '[example.com] Log in to the app')
self.assertEqual(mail.outbox[0].subject, '[InvenTree] Log in to the app')
# Check that the token is in the email
self.assertTrue('http://testserver/api/email/login/' in mail.outbox[0].body)

View File

@ -24,7 +24,6 @@ from django.contrib.auth.models import Group, User
from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType
from django.contrib.humanize.templatetags.humanize import naturaltime
from django.contrib.sites.models import Site
from django.core.cache import cache
from django.core.exceptions import AppRegistryNotReady, ValidationError
from django.core.validators import MaxValueValidator, MinValueValidator, URLValidator
@ -101,6 +100,10 @@ class BaseURLValidator(URLValidator):
"""Make sure empty values pass."""
value = str(value).strip()
# If a configuration level value has been specified, prevent change
if settings.SITE_URL:
raise ValidationError(_('Site URL is locked by configuration'))
if len(value) == 0:
pass
@ -647,7 +650,7 @@ class BaseInvenTreeSetting(models.Model):
return value
@classmethod
def set_setting(cls, key, value, change_user, create=True, **kwargs):
def set_setting(cls, key, value, change_user=None, create=True, **kwargs):
"""Set the value of a particular setting. If it does not exist, option to create it.
Args:
@ -1065,6 +1068,15 @@ def settings_group_options():
def update_instance_url(setting):
"""Update the first site objects domain to url."""
if not settings.SITE_MULTI:
return
try:
from django.contrib.sites.models import Site
except (ImportError, RuntimeError):
# Multi-site support not enabled
return
site_obj = Site.objects.all().order_by('id').first()
site_obj.domain = setting.value
site_obj.save()
@ -1072,6 +1084,15 @@ def update_instance_url(setting):
def update_instance_name(setting):
"""Update the first site objects name to instance name."""
if not settings.SITE_MULTI:
return
try:
from django.contrib.sites.models import Site
except (ImportError, RuntimeError):
# Multi-site support not enabled
return
site_obj = Site.objects.all().order_by('id').first()
site_obj.name = setting.value
site_obj.save()

View File

@ -90,8 +90,8 @@ language: en-us
timezone: UTC
# Base URL for the InvenTree server
# Use the environment variable INVENTREE_BASE_URL
# base_url: 'http://localhost:8000'
# Use the environment variable INVENTREE_SITE_URL
# site_url: 'http://localhost:8000'
# Base currency code (or use env var INVENTREE_BASE_CURRENCY)
base_currency: USD

View File

@ -201,7 +201,10 @@ class RuleSet(models.Model):
RULESET_PERMISSIONS = ['view', 'add', 'change', 'delete']
RULESET_MODELS = {
@staticmethod
def get_ruleset_models():
"""Return a dictionary of models associated with each ruleset."""
ruleset_models = {
'admin': [
'auth_group',
'auth_user',
@ -215,7 +218,6 @@ class RuleSet(models.Model):
'report_salesorderreport',
'account_emailaddress',
'account_emailconfirmation',
'sites_site',
'socialaccount_socialaccount',
'socialaccount_socialapp',
'socialaccount_socialtoken',
@ -325,8 +327,16 @@ class RuleSet(models.Model):
],
}
if settings.SITE_MULTI:
ruleset_models['admin'].append('sites_site')
return ruleset_models
# Database models we ignore permission sets for
RULESET_IGNORE = [
@staticmethod
def get_ruleset_ignore():
"""Return a list of database tables which do not require permissions."""
return [
# Core django models (not user configurable)
'admin_logentry',
'contenttypes_contenttype',
@ -409,12 +419,12 @@ class RuleSet(models.Model):
return True
# If the table does *not* require permissions
if table in cls.RULESET_IGNORE:
if table in cls.get_ruleset_ignore():
return True
# Work out which roles touch the given table
for role in cls.RULESET_NAMES:
if table in cls.RULESET_MODELS[role]:
if table in cls.get_ruleset_models()[role]:
if check_user_role(user, role, permission):
return True
@ -474,7 +484,7 @@ class RuleSet(models.Model):
def get_models(self):
"""Return the database tables / models that this ruleset covers."""
return self.RULESET_MODELS.get(self.name, [])
return self.get_ruleset_models().get(self.name, [])
def split_model(model):
@ -669,7 +679,7 @@ def clear_user_role_cache(user):
Args:
user: The User object to be expunged from the cache
"""
for role in RuleSet.RULESET_MODELS.keys():
for role in RuleSet.get_ruleset_models().keys():
for perm in ['add', 'change', 'view', 'delete']:
key = f'role_{user}_{role}_{perm}'
cache.delete(key)

View File

@ -14,7 +14,7 @@ class RuleSetModelTest(TestCase):
def test_ruleset_models(self):
"""Test that the role rulesets work as intended."""
keys = RuleSet.RULESET_MODELS.keys()
keys = RuleSet.get_ruleset_models().keys()
# Check if there are any rulesets which do not have models defined
@ -30,16 +30,16 @@ class RuleSetModelTest(TestCase):
if len(extra) > 0: # pragma: no cover
print(
'The following rulesets have been improperly added to RULESET_MODELS:'
'The following rulesets have been improperly added to get_ruleset_models():'
)
for e in extra:
print('-', e)
# Check that each ruleset has models assigned
empty = [key for key in keys if len(RuleSet.RULESET_MODELS[key]) == 0]
empty = [key for key in keys if len(RuleSet.get_ruleset_models()[key]) == 0]
if len(empty) > 0: # pragma: no cover
print('The following rulesets have empty entries in RULESET_MODELS:')
print('The following rulesets have empty entries in get_ruleset_models():')
for e in empty:
print('-', e)
@ -62,8 +62,8 @@ class RuleSetModelTest(TestCase):
assigned_models = set()
# Now check that each defined model is a valid table name
for key in RuleSet.RULESET_MODELS.keys():
models = RuleSet.RULESET_MODELS[key]
for key in RuleSet.get_ruleset_models().keys():
models = RuleSet.get_ruleset_models()[key]
for m in models:
assigned_models.add(m)
@ -72,7 +72,8 @@ class RuleSetModelTest(TestCase):
for model in available_tables:
if (
model not in assigned_models and model not in RuleSet.RULESET_IGNORE
model not in assigned_models
and model not in RuleSet.get_ruleset_ignore()
): # pragma: no cover
missing_models.add(model)
@ -90,7 +91,7 @@ class RuleSetModelTest(TestCase):
for model in assigned_models:
defined_models.add(model)
for model in RuleSet.RULESET_IGNORE:
for model in RuleSet.get_ruleset_ignore():
defined_models.add(model)
for model in defined_models: # pragma: no cover
@ -118,7 +119,7 @@ class RuleSetModelTest(TestCase):
# Check that all permissions have been assigned permissions?
permission_set = set()
for models in RuleSet.RULESET_MODELS.values():
for models in RuleSet.get_ruleset_models().values():
for model in models:
permission_set.add(model)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 84 KiB

View File

@ -88,3 +88,15 @@ This can be used to track usage and performance of the InvenTree backend and con
| `tracing.append_http` | `INVENTREE_TRACING_APPEND_HTTP` | Append default url routes (v1) to `tracing.endpoint` |
| `tracing.console` | `INVENTREE_TRACING_CONSOLE` | Print out all exports (additionally) to the console for debugging. Do not use in production |
| `tracing.resources` | `INVENTREE_TRACING_RESOURCES` | Add additional resources to all exports. This can be used to add custom tags to the traces. Format as a dict. |
## Multi Site Support
If your InvenTree instance is used in a multi-site environment, you can enable multi-site support. Note that supporting multiple sites is well outside the scope of most InvenTree installations. If you know what you are doing, and have a good reason to enable multi-site support, you can do so by setting the `INVENTREE_SITE_MULTI` environment variable to `True`.
!!! tip "Django Documentation"
For more information on multi-site support, refer to the [Django documentation](https://docs.djangoproject.com/en/3.2/ref/contrib/sites/).
| Environment Variable | Config Key | Description | Default |
| --- | --- | --- | --- |
| INVENTREE_SITE_MULTI | site_multi | Enable multiple sites | False |
| INVENTREE_SITE_ID | site_id | Specify a fixed site ID | *Not specified* |

View File

@ -55,6 +55,7 @@ The following basic options are available:
| INVENTREE_LOG_LEVEL | log_level | Set level of logging to terminal | WARNING |
| INVENTREE_DB_LOGGING | db_logging | Enable logging of database messages | False |
| INVENTREE_TIMEZONE | timezone | Server timezone | UTC |
| INVENTREE_SITE_URL | site_url | Specify a fixed site URL | *Not specified* |
| INVENTREE_ADMIN_ENABLED | admin_enabled | Enable the [django administrator interface](https://docs.djangoproject.com/en/4.2/ref/contrib/admin/) | True |
| INVENTREE_ADMIN_URL | admin_url | URL for accessing [admin interface](../settings/admin.md) | admin |
| INVENTREE_LANGUAGE | language | Default language | en-us |