Revert "Merge branch 'master' of https://github.com/inventree/InvenTree into matmair/issue5729"

This reverts commit 9b6e8fff88, reversing
changes made to 481cd70aca.
This commit is contained in:
Matthias Mair 2023-10-25 23:40:53 +02:00
parent 01bf8ee229
commit 143a531c1e
No known key found for this signature in database
GPG Key ID: A593429DDA23B66A
95 changed files with 61375 additions and 59606 deletions

View File

@ -2,18 +2,11 @@
# InvenTree API version
INVENTREE_API_VERSION = 141
INVENTREE_API_VERSION = 139
"""
Increment this API version number whenever there is a significant change to the API that any clients need to know about
v141 -> 2023-10-23 : https://github.com/inventree/InvenTree/pull/5774
- Changed 'part.responsible' from User to Owner
v140 -> 2023-10-20 : https://github.com/inventree/InvenTree/pull/5664
- Expand API token functionality
- Multiple API tokens can be generated per user
v139 -> 2023-10-11 : https://github.com/inventree/InvenTree/pull/5509
- Add new BarcodePOReceive endpoint to receive line items by scanning supplier barcodes

View File

@ -12,9 +12,9 @@ from django.urls import Resolver404, include, re_path, resolve, reverse_lazy
from allauth_2fa.middleware import (AllauthTwoFactorMiddleware,
BaseRequire2FAMiddleware)
from error_report.middleware import ExceptionProcessor
from rest_framework.authtoken.models import Token
from InvenTree.urls import frontendpatterns
from users.models import ApiToken
logger = logging.getLogger("inventree")
@ -75,15 +75,13 @@ class AuthRequiredMiddleware(object):
# Does the provided token match a valid user?
try:
token = ApiToken.objects.get(key=token_key)
token = Token.objects.get(key=token_key)
if token.active and token.user:
# Provide the user information to the request
request.user = token.user
authorized = True
# Provide the user information to the request
request.user = token.user
authorized = True
except ApiToken.DoesNotExist:
except Token.DoesNotExist:
logger.warning("Access denied for unknown token %s", token_key)
# No authorization was found for the request

View File

@ -197,18 +197,7 @@ if DBBACKUP_STORAGE_OPTIONS is None:
'location': config.get_backup_dir(),
}
INVENTREE_ADMIN_ENABLED = get_boolean_setting(
'INVENTREE_ADMIN_ENABLED',
config_key='admin_enabled',
default_value=True
)
# Base URL for admin pages (default="admin")
INVENTREE_ADMIN_URL = get_setting(
'INVENTREE_ADMIN_URL',
config_key='admin_url',
default_value='admin'
)
# Application definition
INSTALLED_APPS = [
# Admin site integration
@ -243,6 +232,7 @@ INSTALLED_APPS = [
# Third part add-ons
'django_filters', # Extended filter functionality
'rest_framework', # DRF (Django Rest Framework)
'rest_framework.authtoken', # Token authentication for API
'corsheaders', # Cross-origin Resource Sharing for DRF
'crispy_forms', # Improved form rendering
'import_export', # Import / export tables to file
@ -389,6 +379,14 @@ if DEBUG:
INSTALLED_APPS.append('sslserver')
# InvenTree URL configuration
# Base URL for admin pages (default="admin")
INVENTREE_ADMIN_URL = get_setting(
'INVENTREE_ADMIN_URL',
config_key='admin_url',
default_value='admin'
)
ROOT_URLCONF = 'InvenTree.urls'
TEMPLATES = [
@ -435,7 +433,7 @@ REST_FRAMEWORK = {
'DEFAULT_AUTHENTICATION_CLASSES': (
'rest_framework.authentication.BasicAuthentication',
'rest_framework.authentication.SessionAuthentication',
'users.authentication.ApiTokenAuthentication',
'rest_framework.authentication.TokenAuthentication',
),
'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.LimitOffsetPagination',
'DEFAULT_PERMISSION_CLASSES': (
@ -447,8 +445,7 @@ REST_FRAMEWORK = {
'DEFAULT_METADATA_CLASS': 'InvenTree.metadata.InvenTreeMetadata',
'DEFAULT_RENDERER_CLASSES': [
'rest_framework.renderers.JSONRenderer',
],
'TOKEN_MODEL': 'users.models.ApiToken',
]
}
if DEBUG:

View File

@ -209,14 +209,11 @@ classic_frontendpatterns = [
new_frontendpatterns = platform_urls
urlpatterns = []
if settings.INVENTREE_ADMIN_ENABLED:
admin_url = settings.INVENTREE_ADMIN_URL,
urlpatterns += [
path(f'{admin_url}/error_log/', include('error_report.urls')),
path(f'{admin_url}/', admin.site.urls, name='inventree-admin'),
]
urlpatterns = [
# admin sites
re_path(f'^{settings.INVENTREE_ADMIN_URL}/error_log/', include('error_report.urls')),
re_path(f'^{settings.INVENTREE_ADMIN_URL}/', admin.site.urls, name='inventree-admin'),
]
urlpatterns += backendpatterns

View File

@ -29,9 +29,10 @@ src="{% static 'img/blank_image.png' %}"
{% block actions %}
<!-- Admin Display -->
{% admin_url user "build.build" build.pk as url %}
{% if user.is_staff and roles.build.change %}
{% url 'admin:build_build_change' build.pk as url %}
{% include "admin_button.html" with url=url %}
{% endif %}
{% if barcodes %}
<!-- Barcode actions menu -->
<div class='btn-group' role='group'>

View File

@ -14,9 +14,10 @@
{% block actions %}
<!-- Admin View -->
{% admin_url user "company.company" company.pk as url %}
{% if user.is_staff and perms.company.change_company %}
{% url 'admin:company_company_change' company.pk as url %}
{% include "admin_button.html" with url=url %}
{% endif %}
{% if company.is_supplier and roles.purchase_order.add %}
<button type='button' class='btn btn-outline-primary' id='company-order-2' title='{% trans "Create Purchase Order" %}'>
<span class='fas fa-shopping-cart'/>

View File

@ -26,10 +26,10 @@
{% endblock heading %}
{% block actions %}
{% admin_url user 'company.manufacturerpart' part.pk as url %}
{% if user.is_staff and perms.company.change_company %}
{% url 'admin:company_manufacturerpart_change' part.pk as url %}
{% include "admin_button.html" with url=url %}
{% endif %}
{% if roles.purchase_order.change %}
{% if roles.purchase_order.add and part.part.purchaseable %}
<button type='button' class='btn btn-outline-secondary' id='order-part' title='{% trans "Order part" %}'>

View File

@ -26,9 +26,10 @@
{% endblock heading %}
{% block actions %}
{% admin_url user "company.supplierpart" part.pk as url %}
{% if user.is_staff and perms.company.change_company %}
{% url 'admin:company_supplierpart_change' part.pk as url %}
{% include "admin_button.html" with url=url %}
{% endif %}
{% if barcodes %}
<!-- Barcode actions menu -->
<div class='btn-group' role='group'>

View File

@ -58,14 +58,6 @@ database:
# Use the environment variable INVENTREE_DEBUG
debug: True
# Set to False to disable the admin interface (default = True)
# Or, use the environment variable INVENTREE_ADMIN_ENABLED
#admin_enabled: True
# Set the admin URL (default is 'admin')
# Or, use the environment variable INVENTREE_ADMIN_URL
#admin_url: 'admin'
# Set enabled frontends
# Use the environment variable INVENTREE_CLASSIC_FRONTEND
# classic_frontend: True

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -19,10 +19,10 @@
{% endblock heading %}
{% block actions %}
{% admin_url user "order.purchaseorder" order.pk as url %}
{% if user.is_staff and roles.purchase_order.change %}
{% url 'admin:order_purchaseorder_change' order.pk as url %}
{% include "admin_button.html" with url=url %}
{% endif %}
{% if barcodes %}
<!-- Barcode actions menu -->
<div class='btn-group' role='group'>

View File

@ -29,9 +29,10 @@ src="{% static 'img/blank_image.png' %}"
{% endblock heading %}
{% block actions %}
{% admin_url user "order.returnorder" order.pk as url %}
{% if user.is_staff and roles.return_order.change %}
{% url 'admin:order_returnorder_change' order.pk as url %}
{% include "admin_button.html" with url=url %}
{% endif %}
{% if barcodes %}
<!-- Barcode actions menu -->
<div class='btn-group' role='group'>

View File

@ -29,9 +29,10 @@ src="{% static 'img/blank_image.png' %}"
{% endblock heading %}
{% block actions %}
{% admin_url user "order.salesorder" order.pk as url %}
{% if user.is_staff and roles.sales_order.change %}
{% url 'admin:order_salesorder_change' order.pk as url %}
{% include "admin_button.html" with url=url %}
{% endif %}
{% if barcodes %}
<!-- Barcode actions menu -->
<div class='btn-group' role='group'>

View File

@ -25,11 +25,10 @@
{% endblock heading %}
{% block actions %}
{% if category %}
{% admin_url user "part.partcategory" category.pk as url %}
{% if category and user.is_staff and roles.part_category.change %}
{% url 'admin:part_partcategory_change' category.pk as url %}
{% include "admin_button.html" with url=url %}
{% endif %}
{% settings_value "STOCKTAKE_ENABLE" as stocktake_enable %}
{% if stocktake_enable and roles.stocktake.add %}
<button type='button' class='btn btn-outline-secondary' id='category-stocktake' title='{% trans "Perform stocktake for this part category" %}'>

View File

@ -18,8 +18,10 @@
{% block actions %}
<!-- Admin View -->
{% admin_url user "part.part" part.pk as url %}
{% if user.is_staff and roles.part.change %}
{% url 'admin:part_part_change' part.pk as url %}
{% include "admin_button.html" with url=url %}
{% endif %}
{% if starred_directly %}
<button type='button' class='btn btn-outline-secondary' id='toggle-starred' title='{% trans "You are subscribed to notifications for this part" %}'>

View File

@ -8,15 +8,15 @@ from datetime import date, datetime
from django import template
from django.conf import settings as djangosettings
from django.templatetags.static import StaticNode
from django.urls import NoReverseMatch, reverse
from django.urls import reverse
from django.utils.html import format_html
from django.utils.safestring import mark_safe
from django.utils.translation import gettext_lazy as _
import common.models
import InvenTree.helpers
import InvenTree.helpers_model
import plugin.models
import plugin
from common.models import ColorTheme, InvenTreeSetting, InvenTreeUserSetting
from common.settings import currency_code_default
from InvenTree import settings, version
from plugin import registry
@ -79,7 +79,7 @@ def render_date(context, date_object):
if user and user.is_authenticated:
# User is specified - look for their date display preference
user_date_format = common.models.InvenTreeUserSetting.get_setting('DATE_DISPLAY_FORMAT', user=user)
user_date_format = InvenTreeUserSetting.get_setting('DATE_DISPLAY_FORMAT', user=user)
else:
user_date_format = 'YYYY-MM-DD'
@ -141,7 +141,7 @@ def inventree_in_debug_mode(*args, **kwargs):
@register.simple_tag()
def inventree_show_about(user, *args, **kwargs):
"""Return True if the about modal should be shown."""
if common.models.InvenTreeSetting.get_setting('INVENTREE_RESTRICT_ABOUT'):
if InvenTreeSetting.get_setting('INVENTREE_RESTRICT_ABOUT'):
# Return False if the user is not a superuser, or no user information is provided
if not user or not user.is_superuser:
return False
@ -356,10 +356,10 @@ def setting_object(key, *args, **kwargs):
return plugin.models.NotificationUserSetting.get_setting_object(key, user=kwargs['user'], method=kwargs['method'], cache=cache)
elif 'user' in kwargs:
return common.models.InvenTreeUserSetting.get_setting_object(key, user=kwargs['user'], cache=cache)
return InvenTreeUserSetting.get_setting_object(key, user=kwargs['user'], cache=cache)
else:
return common.models.InvenTreeSetting.get_setting_object(key, cache=cache)
return InvenTreeSetting.get_setting_object(key, cache=cache)
@register.simple_tag()
@ -367,28 +367,28 @@ def settings_value(key, *args, **kwargs):
"""Return a settings value specified by the given key."""
if 'user' in kwargs:
if not kwargs['user'] or (kwargs['user'] and kwargs['user'].is_authenticated is False):
return common.models.InvenTreeUserSetting.get_setting(key)
return common.models.InvenTreeUserSetting.get_setting(key, user=kwargs['user'])
return InvenTreeUserSetting.get_setting(key)
return InvenTreeUserSetting.get_setting(key, user=kwargs['user'])
return common.models.InvenTreeSetting.get_setting(key)
return InvenTreeSetting.get_setting(key)
@register.simple_tag()
def user_settings(user, *args, **kwargs):
"""Return all USER settings as a key:value dict."""
return common.models.InvenTreeUserSetting.allValues(user=user)
return InvenTreeUserSetting.allValues(user=user)
@register.simple_tag()
def global_settings(*args, **kwargs):
"""Return all GLOBAL InvenTree settings as a key:value dict."""
return common.models.InvenTreeSetting.allValues()
return InvenTreeSetting.allValues()
@register.simple_tag()
def visible_global_settings(*args, **kwargs):
"""Return any global settings which are not marked as 'hidden'."""
return common.models.InvenTreeSetting.allValues(exclude_hidden=True)
return InvenTreeSetting.allValues(exclude_hidden=True)
@register.simple_tag()
@ -435,7 +435,7 @@ def progress_bar(val, max_val, *args, **kwargs):
@register.simple_tag()
def get_color_theme_css(username):
"""Return the custom theme .css file for the selected user"""
"""Return the cutsom theme .css file for the selected user"""
user_theme_name = get_user_color_theme(username)
# Build path to CSS sheet
inventree_css_sheet = os.path.join('css', 'color-themes', user_theme_name + '.css')
@ -449,9 +449,6 @@ def get_color_theme_css(username):
@register.simple_tag()
def get_user_color_theme(username):
"""Get current user color theme."""
from common.models import ColorTheme
try:
user_theme = ColorTheme.objects.filter(user=username).get()
user_theme_name = user_theme.name
@ -468,8 +465,6 @@ def get_available_themes(*args, **kwargs):
"""Return the available theme choices."""
themes = []
from common.models import ColorTheme
for key, name in ColorTheme.get_color_themes_choices():
themes.append({
'key': key,
@ -631,52 +626,3 @@ else: # pragma: no cover
token.contents = ' '.join(bits)
return I18nStaticNode.handle_token(parser, token)
@register.simple_tag()
def admin_index(user):
"""Return a URL for the admin interface"""
if not djangosettings.INVENTREE_ADMIN_ENABLED:
return ''
if not user.is_staff:
return ''
return reverse('admin:index')
@register.simple_tag()
def admin_url(user, table, pk):
"""Generate a link to the admin site for the given model instance.
- If the admin site is disabled, an empty URL is returned
- If the user is not a staff user, an empty URL is returned
- If the user does not have the correct permission, an empty URL is returned
"""
app, model = table.strip().split('.')
from django.urls import reverse
if not djangosettings.INVENTREE_ADMIN_ENABLED:
return ""
if not user.is_staff:
return ""
# Check the user has the correct permission
perm_string = f"{app}.change_{model}"
if not user.has_perm(perm_string):
return ''
# Fallback URL
url = reverse(f"admin:{app}_{model}_changelist")
if pk:
try:
url = reverse(f'admin:{app}_{model}_change', args=(pk,))
except NoReverseMatch:
pass
return url

View File

@ -17,7 +17,7 @@ from django.apps import apps
from django.conf import settings
from django.contrib import admin
from django.db.utils import IntegrityError, OperationalError, ProgrammingError
from django.urls import clear_url_caches, path
from django.urls import clear_url_caches, re_path
from django.utils.text import slugify
from django.utils.translation import gettext_lazy as _
@ -620,17 +620,13 @@ class PluginsRegistry:
app_name = getattr(url, 'app_name', None)
admin_url = settings.INVENTREE_ADMIN_URL
if app_name == 'admin':
urlpatterns[index] = path(f'{admin_url}/', admin.site.urls, name='inventree-admin')
urlpatterns[index] = re_path(r'^admin/', admin.site.urls, name='inventree-admin')
if app_name == 'plugin':
urlpatterns[index] = get_plugin_urls()
# Refresh the URL cache
clear_url_caches()
# endregion
# region plugin registry hash calculations

View File

@ -2206,7 +2206,7 @@ class StockItemTracking(models.Model):
Note: 2021-05-11
The legacy StockTrackingItem model contained very little information about the "history" of the item.
In fact, only the "quantity" of the item was recorded at each interaction.
Also, the "title" was translated at time of generation, and thus was not really translatable.
Also, the "title" was translated at time of generation, and thus was not really translateable.
The "new" system tracks all 'delta' changes to the model,
and tracks change "type" which can then later be translated
@ -2233,8 +2233,7 @@ class StockItemTracking(models.Model):
"""Return label."""
if self.tracking_type in StockHistoryCode.keys():
return StockHistoryCode.label(self.tracking_type)
return getattr(self, 'title', '')
return self.title
tracking_type = models.IntegerField(
default=StockHistoryCode.LEGACY,

View File

@ -25,9 +25,10 @@
{% block actions %}
{% admin_url user "stock.stockitem" item.pk as url %}
{% if user.is_staff and roles.stock.change %}
{% url 'admin:stock_stockitem_change' item.pk as url %}
{% include "admin_button.html" with url=url %}
{% endif %}
{% mixin_available "locate" as locate_available %}
{% if plugins_enabled and locate_available %}
<button id='locate-item-button' title='{% trans "Locate stock item" %}' class='btn btn-outline-secondary' typy='button'>

View File

@ -28,11 +28,10 @@
{% block actions %}
<!-- Admin view -->
{% if location %}
{% admin_url user "stock.stocklocation" location.pk as url %}
{% if location and user.is_staff and roles.stock_location.change %}
{% url 'admin:stock_stocklocation_change' location.pk as url %}
{% include "admin_button.html" with url=url %}
{% endif %}
{% settings_value "STOCKTAKE_ENABLE" as stocktake_enable %}
{% if stocktake_enable and roles.stocktake.add %}
<button type='button' class='btn btn-outline-secondary' id='location-stocktake' title='{% trans "Perform stocktake for this stock location" %}'>

View File

@ -35,7 +35,7 @@
<h4>{% trans "Plugins" %}</h4>
{% include "spacer.html" %}
<div class='btn-group' role='group'>
{% admin_url user "plugin.pluginconfig" None as url %}
{% url 'admin:plugin_pluginconfig_changelist' as url %}
{% include "admin_button.html" with url=url %}
{% if plug %}
<button class="btn btn-success" id="install-plugin" title="{% trans 'Install Plugin' %}"><span class='fas fa-plus-circle'></span> {% trans "Install Plugin" %}</button>

View File

@ -3,7 +3,7 @@
{% inventree_customize 'hide_admin_link' as hidden %}
{% if url and not hidden and user.is_staff %}
{% if not hidden and user.is_staff %}
<a href='{{ url }}'>
<button id='admin-button' href='{{ url }}' title='{% trans "View in administration panel" %}' type='button' class='btn btn-primary admin-button'>
<span class='fas fa-user-shield'></span>

View File

@ -139,10 +139,7 @@
<ul class='dropdown-menu dropdown-menu-end inventree-navbar-menu'>
{% if user.is_authenticated %}
{% if user.is_staff and not hide_admin_link %}
{% admin_index user as admin_idx %}
{% if admin_idx %}
<li><a class='dropdown-item' href="{{ admin_idx }}"><span class="fas fa-user-shield"></span> {% trans "Admin" %}</a></li>
{% endif %}
<li><a class='dropdown-item' href="{% url 'admin:index' %}"><span class="fas fa-user-shield"></span> {% trans "Admin" %}</a></li>
{% endif %}
<li><a class='dropdown-item' href="{% url 'settings' %}"><span class="fas fa-cog"></span> {% trans "Settings" %}</a></li>
<li><a class='dropdown-item' href="{% url 'account_logout' %}"><span class="fas fa-sign-out-alt"></span> {% trans "Logout" %}</a></li>

View File

@ -1,11 +1,10 @@
{% extends "registration/logged_out.html" %}
{% load i18n %}
{% load inventree_extras %}
{% block content %}
<p>{% translate "You were logged out successfully." %}</p>
<p><a href="{% url 'index' %}">{% translate 'Log in again' %}</a></p>
<p><a href="{% url 'admin:index' %}">{% translate 'Log in again' %}</a></p>
{% endblock content %}

View File

@ -8,29 +8,11 @@ from django.contrib.auth.admin import UserAdmin
from django.contrib.auth.models import Group
from django.utils.translation import gettext_lazy as _
from users.models import ApiToken, Owner, RuleSet
from users.models import Owner, RuleSet
User = get_user_model()
class ApiTokenAdmin(admin.ModelAdmin):
"""Admin class for the ApiToken model."""
list_display = ('token', 'user', 'name', 'expiry', 'active')
list_filter = ('user', 'revoked')
fields = ('token', 'user', 'name', 'created', 'last_seen', 'revoked', 'expiry', 'metadata')
def get_readonly_fields(self, request, obj=None):
"""Some fields are read-only after creation"""
ro = ['token', 'created', 'last_seen']
if obj:
ro += ['user', 'expiry', 'name']
return ro
class RuleSetInline(admin.TabularInline):
"""Class for displaying inline RuleSet data in the Group admin page."""
@ -258,5 +240,3 @@ admin.site.unregister(User)
admin.site.register(User, InvenTreeUserAdmin)
admin.site.register(Owner, OwnerAdmin)
admin.site.register(ApiToken, ApiTokenAdmin)

View File

@ -1,24 +1,21 @@
"""DRF API definition for the 'users' app"""
import datetime
import logging
from django.contrib.auth.models import Group, User
from django.core.exceptions import ObjectDoesNotExist
from django.urls import include, path, re_path
from django_filters.rest_framework import DjangoFilterBackend
from rest_framework import exceptions, permissions
from rest_framework import permissions, status
from rest_framework.authtoken.models import Token
from rest_framework.response import Response
from rest_framework.views import APIView
from InvenTree.filters import InvenTreeSearchFilter
from InvenTree.mixins import ListAPI, RetrieveAPI, RetrieveUpdateAPI
from InvenTree.serializers import UserSerializer
from users.models import ApiToken, Owner, RuleSet, check_user_role
from users.models import Owner, RuleSet, check_user_role
from users.serializers import GroupSerializer, OwnerSerializer
logger = logging.getLogger('inventree')
class OwnerList(ListAPI):
"""List API endpoint for Owner model.
@ -87,7 +84,7 @@ class RoleDetails(APIView):
for ruleset in RuleSet.RULESET_CHOICES:
role, _text = ruleset
role, text = ruleset
permissions = []
@ -190,47 +187,25 @@ class GetAuthToken(APIView):
def get(self, request, *args, **kwargs):
"""Return an API token if the user is authenticated
- If the user already has a matching token, delete it and create a new one
- Existing tokens are *never* exposed again via the API
- Once the token is provided, it can be used for auth until it expires
- If the user already has a token, return it
- Otherwise, create a new token
"""
if request.user.is_authenticated:
user = request.user
name = request.query_params.get('name', '')
name = ApiToken.sanitize_name(name)
today = datetime.date.today()
# Find existing token, which has not expired
token = ApiToken.objects.filter(user=user, name=name, revoked=False, expiry__gte=today).first()
if not token:
# User is authenticated, and requesting a token against the provided name.
token = ApiToken.objects.create(user=request.user, name=name)
# Add some metadata about the request
token.set_metadata('user_agent', request.META.get('HTTP_USER_AGENT', ''))
token.set_metadata('remote_addr', request.META.get('REMOTE_ADDR', ''))
token.set_metadata('remote_host', request.META.get('REMOTE_HOST', ''))
token.set_metadata('remote_user', request.META.get('REMOTE_USER', ''))
token.set_metadata('server_name', request.META.get('SERVER_NAME', ''))
token.set_metadata('server_port', request.META.get('SERVER_PORT', ''))
data = {
# Get the user token (or create one if it does not exist)
token, created = Token.objects.get_or_create(user=request.user)
return Response({
'token': token.key,
'name': token.name,
'expiry': token.expiry,
}
})
logger.info("Created new API token for user '%s' (name='%s')", user.username, name)
return Response(data)
else:
raise exceptions.NotAuthenticated()
def delete(self, request):
"""User has requested deletion of API token"""
try:
request.user.auth_token.delete()
return Response({"success": "Successfully logged out."},
status=status.HTTP_202_ACCEPTED)
except (AttributeError, ObjectDoesNotExist):
return Response({"error": "Bad request"},
status=status.HTTP_400_BAD_REQUEST)
user_urls = [

View File

@ -1,39 +0,0 @@
"""Custom token authentication class for InvenTree API"""
import datetime
from django.utils.translation import gettext_lazy as _
from rest_framework import exceptions
from rest_framework.authentication import TokenAuthentication
from users.models import ApiToken
class ApiTokenAuthentication(TokenAuthentication):
"""Custom implementation of TokenAuthentication class, with custom features:
- Tokens can be revoked
- Tokens can expire
"""
model = ApiToken
def authenticate_credentials(self, key):
"""Adds additional checks to the default token authentication method."""
# If this runs without error, then the token is valid (so far)
(user, token) = super().authenticate_credentials(key)
if token.revoked:
raise exceptions.AuthenticationFailed(_("Token has been revoked"))
if token.expired:
raise exceptions.AuthenticationFailed(_("Token has expired"))
if token.last_seen != datetime.date.today():
# Update the last-seen date
token.last_seen = datetime.date.today()
token.save()
return (user, token)

View File

@ -1,35 +0,0 @@
# Generated by Django 3.2.22 on 2023-10-20 01:09
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import users.models
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('users', '0007_alter_ruleset_name'),
]
operations = [
migrations.CreateModel(
name='ApiToken',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created', models.DateTimeField(auto_now_add=True, verbose_name='Created')),
('key', models.CharField(db_index=True, default=users.models.default_token, max_length=100, unique=True, verbose_name='Key')),
('name', models.CharField(blank=True, help_text='Custom token name', max_length=100, verbose_name='Token Name')),
('expiry', models.DateField(default=users.models.default_token_expiry, help_text='Token expiry date', verbose_name='Expiry Date')),
('revoked', models.BooleanField(default=False, help_text='Token has been revoked', verbose_name='Revoked')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='api_tokens', to=settings.AUTH_USER_MODEL, verbose_name='User')),
],
options={
'verbose_name': 'API Token',
'verbose_name_plural': 'API Tokens',
'abstract': False,
'unique_together': {('user', 'name')},
},
),
]

View File

@ -1,27 +0,0 @@
# Generated by Django 3.2.21 on 2023-10-20 23:56
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('users', '0008_apitoken'),
]
operations = [
migrations.AddField(
model_name='apitoken',
name='last_seen',
field=models.DateField(blank=True, help_text='Last time the token was used', null=True, verbose_name='Last Seen'),
),
migrations.AddField(
model_name='apitoken',
name='metadata',
field=models.JSONField(blank=True, help_text='JSON metadata field, for use by external plugins', null=True, verbose_name='Plugin Metadata'),
),
migrations.AlterUniqueTogether(
name='apitoken',
unique_together=set(),
),
]

View File

@ -1,10 +1,7 @@
"""Database model definitions for the 'users' app"""
import datetime
import logging
from django.conf import settings
from django.contrib import admin
from django.contrib.auth import get_user_model
from django.contrib.auth.models import Group, Permission
from django.contrib.contenttypes.fields import GenericForeignKey
@ -18,136 +15,11 @@ from django.dispatch import receiver
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
from rest_framework.authtoken.models import Token as AuthToken
import InvenTree.helpers
import InvenTree.models
from InvenTree.ready import canAppAccessDatabase
logger = logging.getLogger("inventree")
def default_token():
"""Generate a default value for the token"""
return ApiToken.generate_key()
def default_token_expiry():
"""Generate an expiry date for a newly created token"""
# TODO: Custom value for default expiry timeout
# TODO: For now, tokens last for 1 year
return datetime.datetime.now().date() + datetime.timedelta(days=365)
class ApiToken(AuthToken, InvenTree.models.MetadataMixin):
"""Extends the default token model provided by djangorestframework.authtoken, as follows:
- Adds an 'expiry' date - tokens can be set to expire after a certain date
- Adds a 'name' field - tokens can be given a custom name (in addition to the user information)
"""
class Meta:
"""Metaclass defines model properties"""
verbose_name = _('API Token')
verbose_name_plural = _('API Tokens')
abstract = False
def __str__(self):
"""String representation uses the redacted token"""
return self.token
@classmethod
def generate_key(cls, prefix='inv-'):
"""Generate a new token key - with custom prefix"""
# Suffix is the date of creation
suffix = '-' + str(datetime.datetime.now().date().isoformat().replace('-', ''))
return prefix + str(AuthToken.generate_key()) + suffix
# Override the 'key' field - force it to be unique
key = models.CharField(default=default_token, verbose_name=_('Key'), max_length=100, db_index=True, unique=True)
# Override the 'user' field, to allow multiple tokens per user
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
verbose_name=_('User'),
related_name='api_tokens',
)
name = models.CharField(
max_length=100,
blank=True,
verbose_name=_('Token Name'),
help_text=_('Custom token name'),
)
expiry = models.DateField(
default=default_token_expiry,
verbose_name=_('Expiry Date'),
help_text=_('Token expiry date'),
auto_now=False, auto_now_add=False,
)
last_seen = models.DateField(
blank=True, null=True,
verbose_name=_('Last Seen'),
help_text=_('Last time the token was used'),
)
revoked = models.BooleanField(
default=False,
verbose_name=_('Revoked'),
help_text=_('Token has been revoked'),
)
@staticmethod
def sanitize_name(name: str):
"""Sanitize the provide name value"""
name = str(name).strip()
# Remove any non-printable chars
name = InvenTree.helpers.remove_non_printable_characters(name, remove_newline=True)
name = InvenTree.helpers.strip_html_tags(name)
name = name.replace(' ', '-')
# Limit to 100 characters
name = name[:100]
return name
@property
@admin.display(description=_('Token'))
def token(self):
"""Provide a redacted version of the token.
The *raw* key value should never be displayed anywhere!
"""
# If the token has not yet been saved, return the raw key
if self.pk is None:
return self.key
M = len(self.key) - 20
return self.key[:8] + '*' * M + self.key[-12:]
@property
@admin.display(boolean=True, description=_('Expired'))
def expired(self):
"""Test if this token has expired"""
return self.expiry is not None and self.expiry < datetime.datetime.now().date()
@property
@admin.display(boolean=True, description=_('Active'))
def active(self):
"""Test if this token is active"""
return not self.revoked and not self.expired
class RuleSet(models.Model):
"""A RuleSet is somewhat like a superset of the django permission class, in that in encapsulates a bunch of permissions.
@ -186,7 +58,8 @@ class RuleSet(models.Model):
'auth_group',
'auth_user',
'auth_permission',
'users_apitoken',
'authtoken_token',
'authtoken_tokenproxy',
'users_ruleset',
'report_reportasset',
'report_reportsnippet',

View File

@ -1,12 +1,9 @@
"""API tests for various user / auth API endpoints"""
import datetime
from django.contrib.auth.models import Group, User
from django.urls import reverse
from InvenTree.unit_test import InvenTreeAPITestCase
from users.models import ApiToken
class UserAPITests(InvenTreeAPITestCase):
@ -54,105 +51,3 @@ class UserAPITests(InvenTreeAPITestCase):
)
self.assertIn('name', response.data)
class UserTokenTests(InvenTreeAPITestCase):
"""Tests for user token functionality"""
def test_token_generation(self):
"""Test user token generation"""
url = reverse('api-token')
self.assertEqual(ApiToken.objects.count(), 0)
# Generate multiple tokens with different names
for name in ['cat', 'dog', 'biscuit']:
data = self.get(url, data={'name': name}, expected_code=200).data
self.assertTrue(data['token'].startswith('inv-'))
self.assertEqual(data['name'], name)
# Check that the tokens were created
self.assertEqual(ApiToken.objects.count(), 3)
# If we re-generate a token, the value changes
token = ApiToken.objects.filter(name='cat').first()
# Request the token with the same name
data = self.get(url, data={'name': 'cat'}, expected_code=200).data
self.assertEqual(data['token'], token.key)
self.assertEqual(ApiToken.objects.count(), 3)
# Revoke the token, and then request again
token.revoked = True
token.save()
data = self.get(url, data={'name': 'cat'}, expected_code=200).data
self.assertNotEqual(data['token'], token.key)
# A new token has been generated
self.assertEqual(ApiToken.objects.count(), 4)
# Test with a really long name
data = self.get(url, data={'name': 'cat' * 100}, expected_code=200).data
# Name should be truncated
self.assertEqual(len(data['name']), 100)
token.refresh_from_db()
# Check that the metadata has been updated
keys = ['user_agent', 'remote_addr', 'remote_host', 'remote_user', 'server_name', 'server_port']
for k in keys:
self.assertIn(k, token.metadata)
def test_token_auth(self):
"""Test user token authentication"""
# Create a new token
token_key = self.get(url=reverse('api-token'), data={'name': 'test'}, expected_code=200).data['token']
# Check that we can use the token to authenticate
self.client.logout()
self.client.credentials(HTTP_AUTHORIZATION='Token ' + token_key)
me = reverse('api-user-me')
response = self.client.get(me, expected_code=200)
# Grab the token, and update
token = ApiToken.objects.first()
self.assertEqual(token.key, token_key)
self.assertIsNotNone(token.last_seen)
# Revoke the token
token.revoked = True
token.save()
self.assertFalse(token.active)
response = self.client.get(me, expected_code=401)
self.assertIn('Token has been revoked', str(response.data))
# Expire the token
token.revoked = False
token.expiry = datetime.datetime.now().date() - datetime.timedelta(days=10)
token.save()
self.assertTrue(token.expired)
self.assertFalse(token.active)
response = self.client.get(me, expected_code=401)
self.assertIn('Token has expired', str(response.data))
# Re-enable the token
token.revoked = False
token.expiry = datetime.datetime.now().date() + datetime.timedelta(days=10)
token.save()
self.client.get(me, expected_code=200)

View File

@ -5,8 +5,10 @@ from django.contrib.auth.models import Group
from django.test import TestCase
from django.urls import reverse
from rest_framework.authtoken.models import Token
from InvenTree.unit_test import InvenTreeTestCase
from users.models import ApiToken, Owner, RuleSet
from users.models import Owner, RuleSet
class RuleSetModelTest(TestCase):
@ -240,7 +242,7 @@ class OwnerModelTest(InvenTreeTestCase):
"""Test token mechanisms."""
self.client.logout()
token = ApiToken.objects.filter(user=self.user)
token = Token.objects.filter(user=self.user)
# not authed
self.do_request(reverse('api-token'), {}, 401)
@ -250,6 +252,15 @@ class OwnerModelTest(InvenTreeTestCase):
response = self.do_request(reverse('api-token'), {})
self.assertEqual(response['token'], token.first().key)
# token delete
response = self.client.delete(reverse('api-token'), {}, format='json')
self.assertEqual(response.status_code, 202)
self.assertEqual(len(token), 0)
# token second delete
response = self.client.delete(reverse('api-token'), {}, format='json')
self.assertEqual(response.status_code, 400)
# test user is associated with token
response = self.do_request(reverse('api-user-me'), {'name': 'another-token'}, 200)
response = self.do_request(reverse('api-user-me'), {}, 200)
self.assertEqual(response['username'], self.username)

View File

@ -55,22 +55,10 @@ 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_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 |
| ADMIN_URL | admin_url | URL for accessing [admin interface](../settings/admin.md) | admin |
| INVENTREE_LANGUAGE | language | Default language | en-us |
| INVENTREE_BASE_URL | base_url | Server base URL | *Not specified* |
### Admin Site
Django provides a powerful [administrator interface](https://docs.djangoproject.com/en/4.2/ref/contrib/admin/) which can be used to manage the InvenTree database. This interface is enabled by default, but can be disabled by setting `INVENTREE_ADMIN_ENABLED` to `False`.
#### Custom Admin URL
By default, the admin interface is available at the `/admin/` URL. This can be changed by setting the `INVENTREE_ADMIN_URL` environment variable.
!!! warning "Security"
Changing the admin URL is a simple way to improve security, but it is not a substitute for proper security practices.
### Base URL Configuration
The base URL of the InvenTree site is required for constructing absolute URLs in a number of circumstances. To construct a URL, the InvenTree iterates through the following options in decreasing order of importance:

View File

@ -81,12 +81,12 @@ export function NotificationDrawer({
<Stack spacing="xs">
<Divider />
<LoadingOverlay visible={notificationQuery.isFetching} />
{(notificationQuery.data?.results?.length ?? 0) == 0 && (
{notificationQuery.data?.results?.length == 0 && (
<Alert color="green">
<Text size="sm">{t`You have no unread notifications.`}</Text>
</Alert>
)}
{notificationQuery.data?.results?.map((notification: any) => (
{notificationQuery.data?.results.map((notification: any) => (
<Group position="apart">
<Stack spacing="3">
<Text size="sm">{notification.target?.name ?? 'target'}</Text>

View File

@ -20,11 +20,8 @@ export const doClassicLogin = async (username: string, password: string) => {
const token = await axios
.get(apiUrl(ApiPaths.user_token), {
auth: { username, password },
baseURL: host,
timeout: 2000,
params: {
name: 'inventree-web-app'
}
baseURL: host.toString(),
timeout: 5000
})
.then((response) => response.data.token)
.catch((error) => {
@ -120,10 +117,7 @@ export function handleReset(navigate: any, values: { email: string }) {
export function checkLoginState(navigate: any, redirect?: string) {
api
.get(apiUrl(ApiPaths.user_token), {
timeout: 2000,
params: {
name: 'inventree-web-app'
}
timeout: 5000
})
.then((val) => {
if (val.status === 200 && val.data.token) {

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -70,7 +70,7 @@ function PlaygroundArea({
}) {
return (
<>
<Accordion.Item value={`accordion-playground-${title}`}>
<Accordion.Item value={`accordion-playground-{title}`}>
<Accordion.Control>
<Text>{title}</Text>
</Accordion.Control>

View File

@ -35,7 +35,7 @@ def content_excludes():
excludes = [
"contenttypes",
"auth.permission",
"users.apitoken",
"authtoken.token",
"error_report.error",
"admin.logentry",
"django_q.schedule",