mirror of
https://github.com/inventree/InvenTree.git
synced 2025-12-19 13:20:37 -06:00
Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
295c4f3e5d | ||
|
|
c6ecd019dc | ||
|
|
005d9850b8 | ||
|
|
a585f5407a | ||
|
|
974a7d5510 | ||
|
|
39623ddf98 | ||
|
|
1890589a43 | ||
|
|
8cbce3f335 | ||
|
|
56f09e1aa6 | ||
|
|
a1a2a47bba | ||
|
|
e2eeaa991d | ||
|
|
4bb1354b68 | ||
|
|
68e3216b7b | ||
|
|
1573d5ff40 | ||
|
|
89287d56ff | ||
|
|
1885caa744 |
@@ -4,9 +4,9 @@ asgiref==3.10.0 \
|
||||
--hash=sha256:aef8a81283a34d0ab31630c9b7dfe70c812c95eba78171367ca8745e88124734 \
|
||||
--hash=sha256:d89f2d8cd8b56dada7d52fa7dc8075baa08fb836560710d38c292a7a3f78c04e
|
||||
# via django
|
||||
django==4.2.25 \
|
||||
--hash=sha256:2391ab3d78191caaae2c963c19fd70b99e9751008da22a0adcc667c5a4f8d311 \
|
||||
--hash=sha256:9584cf26b174b35620e53c2558b09d7eb180a655a3470474f513ff9acb494f8c
|
||||
django==4.2.26 \
|
||||
--hash=sha256:9398e487bcb55e3f142cb56d19fbd9a83e15bb03a97edc31f408361ee76d9d7a \
|
||||
--hash=sha256:c96e64fc3c359d051a6306871bd26243db1bd02317472a62ffdbe6c3cae14280
|
||||
# via
|
||||
# -r contrib/container/requirements.in
|
||||
# django-auth-ldap
|
||||
|
||||
@@ -223,6 +223,11 @@ def do_typecast(value, type, var_name=None):
|
||||
elif type is dict:
|
||||
value = to_dict(value)
|
||||
|
||||
# Special handling for boolean typecasting
|
||||
elif type is bool:
|
||||
val = is_true(value)
|
||||
return val
|
||||
|
||||
elif type is not None:
|
||||
# Try to typecast the value
|
||||
try:
|
||||
|
||||
22
src/backend/InvenTree/InvenTree/helpers_mfa.py
Normal file
22
src/backend/InvenTree/InvenTree/helpers_mfa.py
Normal file
@@ -0,0 +1,22 @@
|
||||
"""Helper functions for allauth MFA testing."""
|
||||
|
||||
from allauth.mfa.recovery_codes.internal.auth import RecoveryCodes
|
||||
from allauth.mfa.totp.internal import auth as allauth_totp_auth
|
||||
|
||||
|
||||
def get_codes(user):
|
||||
"""Generate active TOTP and recovery codes for a user.
|
||||
|
||||
Args:
|
||||
user: User instance
|
||||
|
||||
Returns:
|
||||
Tuple of (TOTP Authenticator instance, list of recovery codes, TOTP secret)
|
||||
"""
|
||||
secret = allauth_totp_auth.generate_totp_secret()
|
||||
totp_auth = allauth_totp_auth.TOTP.activate(user, secret).instance
|
||||
rc_auth = RecoveryCodes.activate(user).instance
|
||||
|
||||
# Get usable codes
|
||||
rc_codes = rc_auth.wrap().get_unused_codes()
|
||||
return totp_auth, rc_codes, secret
|
||||
@@ -1,21 +1,22 @@
|
||||
"""Middleware for InvenTree."""
|
||||
|
||||
import sys
|
||||
from typing import Optional
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.middleware import PersistentRemoteUserMiddleware
|
||||
from django.http import HttpResponse
|
||||
from django.http import HttpRequest, HttpResponse, JsonResponse
|
||||
from django.shortcuts import redirect, render
|
||||
from django.urls import resolve, reverse_lazy
|
||||
from django.urls import resolve, reverse, reverse_lazy
|
||||
from django.utils.deprecation import MiddlewareMixin
|
||||
from django.utils.http import is_same_domain
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
import structlog
|
||||
from error_report.middleware import ExceptionProcessor
|
||||
|
||||
from common.settings import get_global_setting
|
||||
from InvenTree.AllUserRequire2FAMiddleware import AllUserRequire2FAMiddleware
|
||||
from InvenTree.cache import create_session_cache, delete_session_cache
|
||||
from InvenTree.config import CONFIG_LOOKUPS, inventreeInstaller
|
||||
from users.models import ApiToken
|
||||
@@ -40,6 +41,15 @@ def get_token_from_request(request):
|
||||
return None
|
||||
|
||||
|
||||
def ensure_slashes(path: str):
|
||||
"""Ensure that slashes are suroudning the passed path."""
|
||||
if not path.startswith('/'):
|
||||
path = f'/{path}'
|
||||
if not path.endswith('/'):
|
||||
path = f'{path}/'
|
||||
return path
|
||||
|
||||
|
||||
# List of target URL endpoints where *do not* want to redirect to
|
||||
urls = [
|
||||
reverse_lazy('account_login'),
|
||||
@@ -47,8 +57,46 @@ urls = [
|
||||
reverse_lazy('admin:logout'),
|
||||
]
|
||||
|
||||
# Do not redirect requests to any of these paths
|
||||
paths_ignore = ['/api/', '/auth/', settings.MEDIA_URL, settings.STATIC_URL]
|
||||
paths_ignore_handling = [
|
||||
'/api/',
|
||||
reverse('auth-check'),
|
||||
settings.MEDIA_URL,
|
||||
settings.STATIC_URL,
|
||||
]
|
||||
"""Paths that should not use InvenTrees own auth rejection behaviour, no host checking or redirecting. Security
|
||||
are still enforced."""
|
||||
paths_own_security = [
|
||||
'/api/', # DRF handles API
|
||||
'/o/', # oAuth2 library - has its own auth model
|
||||
'/anymail/', # Mails - wehbhooks etc
|
||||
'/accounts/', # allauth account management - has its own auth model
|
||||
'/assets/', # Web assets - only used for testing, no security model needed
|
||||
ensure_slashes(
|
||||
settings.STATIC_URL
|
||||
), # Static files - static files are considered safe to serve
|
||||
ensure_slashes(
|
||||
settings.FRONTEND_URL_BASE
|
||||
), # Frontend files - frontend paths have their own security model
|
||||
]
|
||||
"""Paths that handle their own security model."""
|
||||
pages_mfa_bypass = [
|
||||
'api-user-meta',
|
||||
'api-user-me',
|
||||
'api-user-roles',
|
||||
'api-inventree-info',
|
||||
'api-token',
|
||||
# web platform urls
|
||||
'password_reset_confirm',
|
||||
'index',
|
||||
'web',
|
||||
'web-wildcard',
|
||||
'web-assets',
|
||||
]
|
||||
"""Exact page names that bypass MFA enforcement - normal security model is still enforced."""
|
||||
apps_mfa_bypass = [
|
||||
'headless' # Headless allauth app - has its own security model
|
||||
]
|
||||
"""App namespaces that bypass MFA enforcement - normal security model is still enforced."""
|
||||
|
||||
|
||||
class AuthRequiredMiddleware:
|
||||
@@ -61,6 +109,7 @@ class AuthRequiredMiddleware:
|
||||
def check_token(self, request) -> bool:
|
||||
"""Check if the user is authenticated via token."""
|
||||
if token := get_token_from_request(request):
|
||||
request.token = token
|
||||
# Does the provided token match a valid user?
|
||||
try:
|
||||
token = ApiToken.objects.get(key=token)
|
||||
@@ -69,8 +118,10 @@ class AuthRequiredMiddleware:
|
||||
# Provide the user information to the request
|
||||
request.user = token.user
|
||||
return True
|
||||
except ApiToken.DoesNotExist:
|
||||
logger.warning('Access denied for unknown token %s', token)
|
||||
except ApiToken.DoesNotExist: # pragma: no cover
|
||||
logger.warning(
|
||||
'Access denied for unknown token %s', token
|
||||
) # pragma: no cover
|
||||
|
||||
return False
|
||||
|
||||
@@ -79,79 +130,113 @@ class AuthRequiredMiddleware:
|
||||
|
||||
Redirects to login if not authenticated.
|
||||
"""
|
||||
path: str = request.path_info
|
||||
# Code to be executed for each request before
|
||||
# the view (and later middleware) are called.
|
||||
|
||||
assert hasattr(request, 'user')
|
||||
|
||||
# API requests are handled by the DRF library
|
||||
if request.path_info.startswith('/api/'):
|
||||
return self.get_response(request)
|
||||
|
||||
# oAuth2 requests are handled by the oAuth2 library
|
||||
if request.path_info.startswith('/o/'):
|
||||
return self.get_response(request)
|
||||
|
||||
# anymail requests are handled by the anymail library
|
||||
if request.path_info.startswith('/anymail/'):
|
||||
# API requests that are handled elsewhere
|
||||
if any(path.startswith(a) for a in paths_own_security):
|
||||
return self.get_response(request)
|
||||
|
||||
# Is the function exempt from auth requirements?
|
||||
path_func = resolve(request.path).func
|
||||
|
||||
if getattr(path_func, 'auth_exempt', False) is True:
|
||||
return self.get_response(request)
|
||||
|
||||
if not request.user.is_authenticated:
|
||||
if not request.user.is_authenticated and not (
|
||||
path == f'/{settings.FRONTEND_URL_BASE}' or self.check_token(request)
|
||||
):
|
||||
"""
|
||||
Normally, a web-based session would use csrftoken based authentication.
|
||||
|
||||
However when running an external application (e.g. the InvenTree app or Python library),
|
||||
we must validate the user token manually.
|
||||
"""
|
||||
|
||||
authorized = False
|
||||
|
||||
# Allow static files to be accessed without auth
|
||||
# Important for e.g. login page
|
||||
if (
|
||||
request.path_info.startswith('/static/')
|
||||
or request.path_info.startswith('/accounts/')
|
||||
or (
|
||||
request.path_info.startswith(f'/{settings.FRONTEND_URL_BASE}/')
|
||||
or request.path_info.startswith('/assets/')
|
||||
or request.path_info == f'/{settings.FRONTEND_URL_BASE}'
|
||||
)
|
||||
or self.check_token(request)
|
||||
if path not in urls and not any(
|
||||
path.startswith(p) for p in paths_ignore_handling
|
||||
):
|
||||
authorized = True
|
||||
# Save the 'next' parameter to pass through to the login view
|
||||
|
||||
# No authorization was found for the request
|
||||
if not authorized:
|
||||
path = request.path_info
|
||||
|
||||
if path not in urls and not any(
|
||||
path.startswith(p) for p in paths_ignore
|
||||
):
|
||||
# Save the 'next' parameter to pass through to the login view
|
||||
|
||||
return redirect(
|
||||
f'{reverse_lazy("account_login")}?next={request.path}'
|
||||
)
|
||||
# Return a 401 (Unauthorized) response code for this request
|
||||
return HttpResponse('Unauthorized', status=401)
|
||||
return redirect(f'{reverse_lazy("account_login")}?next={request.path}')
|
||||
# Return a 401 (Unauthorized) response code for this request
|
||||
return HttpResponse('Unauthorized', status=401)
|
||||
|
||||
response = self.get_response(request)
|
||||
|
||||
return response
|
||||
|
||||
|
||||
class Check2FAMiddleware(AllUserRequire2FAMiddleware):
|
||||
"""Ensure that mfa is enforced if set so."""
|
||||
class Check2FAMiddleware(MiddlewareMixin):
|
||||
"""Ensure that users have two-factor authentication enabled before they have access restricted endpoints.
|
||||
|
||||
Adapted from https://github.com/pennersr/django-allauth/issues/3649
|
||||
"""
|
||||
|
||||
require_2fa_message = _(
|
||||
'You must enable two-factor authentication before doing anything else.'
|
||||
)
|
||||
|
||||
def on_require_2fa(self, request: HttpRequest) -> HttpResponse:
|
||||
"""Force user to mfa activation."""
|
||||
return JsonResponse(
|
||||
{'id': 'mfa_register', 'error': self.require_2fa_message}, status=401
|
||||
)
|
||||
|
||||
def is_allowed_page(self, request: HttpRequest) -> bool:
|
||||
"""Check if the current page can be accessed without mfa."""
|
||||
match = request.resolver_match
|
||||
return (
|
||||
False
|
||||
if match is None
|
||||
else any(ref in apps_mfa_bypass for ref in match.app_names)
|
||||
or match.url_name in pages_mfa_bypass
|
||||
or match.route == 'favicon.ico'
|
||||
)
|
||||
|
||||
def is_multifactor_logged_in(self, request: HttpRequest) -> bool:
|
||||
"""Check if the user is logged in with multifactor authentication."""
|
||||
from allauth.account.authentication import get_authentication_records
|
||||
from allauth.mfa.utils import is_mfa_enabled
|
||||
from allauth.mfa.webauthn.internal.flows import did_use_passwordless_login
|
||||
|
||||
authns = get_authentication_records(request)
|
||||
|
||||
return is_mfa_enabled(request.user) and (
|
||||
did_use_passwordless_login(request)
|
||||
or any(record.get('method') == 'mfa' for record in authns)
|
||||
)
|
||||
|
||||
def process_view(
|
||||
self, request: HttpRequest, view_func, view_args, view_kwargs
|
||||
) -> Optional[HttpResponse]:
|
||||
"""Determine if the server is set up enforce 2fa registration."""
|
||||
from django.conf import settings
|
||||
|
||||
# Exit early if MFA is not enabled
|
||||
if not settings.MFA_ENABLED:
|
||||
return None
|
||||
|
||||
if request.user.is_anonymous:
|
||||
return None
|
||||
if self.is_allowed_page(request):
|
||||
return None
|
||||
if self.is_multifactor_logged_in(request):
|
||||
return None
|
||||
if getattr(
|
||||
request, 'token', get_token_from_request(request)
|
||||
): # Token based login can not do MFA
|
||||
return None
|
||||
|
||||
if self.enforce_2fa(request):
|
||||
return self.on_require_2fa(request)
|
||||
return None
|
||||
|
||||
def enforce_2fa(self, request):
|
||||
"""Use setting to check if MFA should be enforced."""
|
||||
return get_global_setting('LOGIN_ENFORCE_MFA')
|
||||
return get_global_setting(
|
||||
'LOGIN_ENFORCE_MFA', None, 'INVENTREE_LOGIN_ENFORCE_MFA'
|
||||
)
|
||||
|
||||
|
||||
class InvenTreeRemoteUserMiddleware(PersistentRemoteUserMiddleware):
|
||||
@@ -232,7 +317,7 @@ class InvenTreeHostSettingsMiddleware(MiddlewareMixin):
|
||||
|
||||
# Handle commonly ignored paths that might also work without a correct setup (api, auth)
|
||||
path = request.path_info
|
||||
if path in urls or any(path.startswith(p) for p in paths_ignore):
|
||||
if path in urls or any(path.startswith(p) for p in paths_ignore_handling):
|
||||
return None
|
||||
|
||||
# treat the accessed scheme and host
|
||||
|
||||
@@ -108,6 +108,13 @@ class ApiAccessTests(InvenTreeAPITestCase):
|
||||
self.tokenAuth()
|
||||
self.assertIsNotNone(self.token)
|
||||
|
||||
# Run explicit test with token auth
|
||||
url = reverse('api-license')
|
||||
response = self.get(
|
||||
url, headers={'Authorization': f'Token {self.token}'}, expected_code=200
|
||||
)
|
||||
self.assertIn('backend', response.json())
|
||||
|
||||
def test_role_view(self):
|
||||
"""Test that we can access the 'roles' view for the logged in user.
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ from django.contrib.auth.models import Group, User
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.test import override_settings
|
||||
from django.test.testcases import TransactionTestCase
|
||||
from django.urls import reverse
|
||||
|
||||
from allauth.socialaccount.models import SocialAccount, SocialLogin
|
||||
|
||||
@@ -139,13 +140,15 @@ class TestAuth(InvenTreeAPITestCase):
|
||||
"""Test authentication functionality."""
|
||||
|
||||
reg_url = '/api/auth/v1/auth/signup'
|
||||
login_url = '/api/auth/v1/auth/login'
|
||||
test_email = 'tester@example.com'
|
||||
|
||||
def test_buildin_token(self):
|
||||
"""Test the built-in token authentication."""
|
||||
self.logout()
|
||||
|
||||
response = self.post(
|
||||
'/api/auth/v1/auth/login',
|
||||
self.login_url,
|
||||
{'username': self.username, 'password': self.password},
|
||||
expected_code=200,
|
||||
)
|
||||
@@ -155,7 +158,7 @@ class TestAuth(InvenTreeAPITestCase):
|
||||
|
||||
# Test for conflicting login
|
||||
self.post(
|
||||
'/api/auth/v1/auth/login',
|
||||
self.login_url,
|
||||
{'username': self.username, 'password': self.password},
|
||||
expected_code=409,
|
||||
)
|
||||
@@ -222,3 +225,22 @@ class TestAuth(InvenTreeAPITestCase):
|
||||
):
|
||||
resp = self.post(self.reg_url, self.email_args(), expected_code=200)
|
||||
self.assertEqual(resp.json()['data']['user']['email'], self.test_email)
|
||||
|
||||
def test_auth_request(self):
|
||||
"""Test the auth_request view."""
|
||||
url = reverse('auth-check')
|
||||
|
||||
# Logged in user
|
||||
self.get(url)
|
||||
|
||||
# Inactive user
|
||||
# TODO @matmair - this part of auth_request is not triggering currently
|
||||
# self.user.is_active = False
|
||||
# self.user.save()
|
||||
# self.get(url, expected_code=403)
|
||||
# self.user.is_active = True
|
||||
# self.user.save()
|
||||
|
||||
# Logged out user
|
||||
self.client.logout()
|
||||
self.get(url, expected_code=401)
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"""Tests for middleware functions."""
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.conf import settings
|
||||
from django.http import Http404
|
||||
from django.urls import reverse
|
||||
@@ -7,17 +9,19 @@ from django.urls import reverse
|
||||
from error_report.models import Error
|
||||
|
||||
from InvenTree.exceptions import log_error
|
||||
from InvenTree.helpers_mfa import get_codes
|
||||
from InvenTree.unit_test import InvenTreeTestCase
|
||||
|
||||
|
||||
class MiddlewareTests(InvenTreeTestCase):
|
||||
"""Test for middleware functions."""
|
||||
|
||||
def check_path(self, url, code=200, **kwargs):
|
||||
def check_path(self, url, code=200, auth_header=None, **kwargs):
|
||||
"""Helper function to run a request."""
|
||||
response = self.client.get(
|
||||
url, headers={'accept': 'application/json'}, **kwargs
|
||||
)
|
||||
headers = {'accept': 'application/json'}
|
||||
if auth_header:
|
||||
headers['Authorization'] = auth_header
|
||||
response = self.client.get(url, headers=headers, **kwargs)
|
||||
self.assertEqual(response.status_code, code)
|
||||
return response
|
||||
|
||||
@@ -36,13 +40,62 @@ class MiddlewareTests(InvenTreeTestCase):
|
||||
response = self.check_path(reverse('index'), 302)
|
||||
self.assertEqual(response.url, '/accounts/login/?next=/')
|
||||
|
||||
def test_Check2FAMiddleware(self):
|
||||
"""Test the 2FA middleware."""
|
||||
url = reverse('api-part-list')
|
||||
|
||||
self.assignRole(role='part.view', group=self.group)
|
||||
# Ensure that normal access works with mfa enabled
|
||||
with self.settings(MFA_ENABLED=True):
|
||||
self.check_path(url)
|
||||
# Ensure that normal access works with mfa disabled
|
||||
with self.settings(MFA_ENABLED=False):
|
||||
self.check_path(url)
|
||||
|
||||
# Now enforce MFA for the user
|
||||
with self.settings(MFA_ENABLED=True) and patch.dict(
|
||||
'os.environ', {'INVENTREE_LOGIN_ENFORCE_MFA': 'True'}
|
||||
):
|
||||
# Enforced but not logged in via mfa -> should give 403
|
||||
response = self.check_path(url, 401)
|
||||
self.assertContains(
|
||||
response,
|
||||
'You must enable two-factor authentication before doing anything else.',
|
||||
status_code=401,
|
||||
)
|
||||
|
||||
# Register a token and try again
|
||||
rc_codes = get_codes(self.user)[1]
|
||||
self.client.logout()
|
||||
# Login step 1
|
||||
self.client.post(
|
||||
reverse('browser:account:login'),
|
||||
{'username': self.username, 'password': self.password},
|
||||
content_type='application/json',
|
||||
)
|
||||
# Login step 2
|
||||
self.client.post(
|
||||
reverse('browser:mfa:authenticate'),
|
||||
{'code': rc_codes[0]},
|
||||
expected_code=401,
|
||||
content_type='application/json',
|
||||
)
|
||||
rsp3 = self.client.post(
|
||||
reverse('browser:mfa:trust'),
|
||||
{'trust': False},
|
||||
expected_code=200,
|
||||
content_type='application/json',
|
||||
)
|
||||
self.assertEqual(rsp3.status_code, 200)
|
||||
self.check_path(url)
|
||||
|
||||
def test_token_auth(self):
|
||||
"""Test auth with token auth."""
|
||||
target = reverse('api-license')
|
||||
|
||||
# get token
|
||||
# response = self.client.get(reverse('api-token'), format='json', data={})
|
||||
# token = response.data['token']
|
||||
response = self.client.get(reverse('api-token'), format='json', data={})
|
||||
token = response.data['token']
|
||||
|
||||
# logout
|
||||
self.client.logout()
|
||||
@@ -51,13 +104,16 @@ class MiddlewareTests(InvenTreeTestCase):
|
||||
self.check_path(target, 401)
|
||||
|
||||
# Request with broken token
|
||||
self.check_path(target, 401, HTTP_Authorization='Token abcd123')
|
||||
self.check_path(target, 401, auth_header='Token abcd123')
|
||||
|
||||
# should still fail without token
|
||||
self.check_path(target, 401)
|
||||
|
||||
# request with token
|
||||
# self.check_path(target, HTTP_Authorization=f'Token {token}')
|
||||
# request with token - should work
|
||||
self.check_path(target, auth_header=f'Token {token}')
|
||||
|
||||
# Request something that is not on the API - should still work
|
||||
self.check_path(reverse('auth-check'), auth_header=f'Token {token}')
|
||||
|
||||
def test_error_exceptions(self):
|
||||
"""Test that ignored errors are not logged."""
|
||||
|
||||
@@ -188,7 +188,7 @@ class CorsTest(TestCase):
|
||||
Here, we are not authorized by default,
|
||||
but the CORS headers should still be included.
|
||||
"""
|
||||
url = '/auth/'
|
||||
url = reverse('auth-check')
|
||||
|
||||
# First, a preflight request with a "valid" origin
|
||||
|
||||
|
||||
@@ -130,7 +130,9 @@ backendpatterns = [
|
||||
path(
|
||||
'auth/', include('rest_framework.urls', namespace='rest_framework')
|
||||
), # Used for (DRF) browsable API auth
|
||||
path('auth/', auth_request), # Used for proxies to check if user is authenticated
|
||||
path(
|
||||
'auth/', auth_request, name='auth-check'
|
||||
), # Used for proxies to check if user is authenticated
|
||||
path('accounts/', include('allauth.urls')),
|
||||
# OAuth2
|
||||
flagged_path('OIDC', 'o/', include(oauth2_urls)),
|
||||
|
||||
@@ -18,7 +18,7 @@ from django.conf import settings
|
||||
from .api_version import INVENTREE_API_TEXT, INVENTREE_API_VERSION
|
||||
|
||||
# InvenTree software version
|
||||
INVENTREE_SW_VERSION = '1.1.2'
|
||||
INVENTREE_SW_VERSION = '1.1.4'
|
||||
|
||||
|
||||
logger = logging.getLogger('inventree')
|
||||
|
||||
@@ -12,12 +12,13 @@ def auth_request(request):
|
||||
|
||||
Useful for (for example) redirecting authentication requests through django's permission framework.
|
||||
"""
|
||||
if not request.user or not request.user.is_authenticated:
|
||||
return HttpResponse(status=403)
|
||||
|
||||
if not request.user.is_active:
|
||||
# Reject requests from inactive users
|
||||
return HttpResponse(status=403)
|
||||
if (
|
||||
not request.user
|
||||
or not request.user.is_authenticated
|
||||
or not request.user.is_active
|
||||
):
|
||||
# This is very unlikely to be reached, as the middleware stack should intercept unauthenticated requests
|
||||
return HttpResponse(status=403) # pragma: no cover
|
||||
|
||||
# User is authenticated and active
|
||||
return HttpResponse(status=200)
|
||||
|
||||
@@ -309,13 +309,23 @@ class StatusCodeMixin:
|
||||
|
||||
return status is not None and status == self.get_custom_status()
|
||||
|
||||
def set_status(self, status: int) -> bool:
|
||||
"""Set the status code for this object."""
|
||||
def set_status(self, status: int, custom_values=None) -> bool:
|
||||
"""Set the status code for this object.
|
||||
|
||||
Arguments:
|
||||
status: The status code to set
|
||||
custom_values: Optional list of custom values to consider (can be used to avoid DB queries)
|
||||
"""
|
||||
if not self.status_class:
|
||||
raise NotImplementedError('Status class not defined')
|
||||
|
||||
base_values = self.status_class.values()
|
||||
custom_value_set = self.status_class.custom_values()
|
||||
|
||||
custom_value_set = (
|
||||
self.status_class.custom_values()
|
||||
if custom_values is None
|
||||
else custom_values
|
||||
)
|
||||
|
||||
custom_field = f'{self.STATUS_FIELD}_custom_key'
|
||||
|
||||
|
||||
@@ -951,7 +951,7 @@ class PurchaseOrder(TotalPriceMixin, Order):
|
||||
batch_code: Optional batch code for the item (optional)
|
||||
expiry_date: Optional expiry date for the item (optional)
|
||||
serials: Optional list of serial numbers (optional)
|
||||
notes: Optional notes for the item (optional)
|
||||
note: Optional notes for the item (optional)
|
||||
"""
|
||||
if self.status != PurchaseOrderStatus.PLACED:
|
||||
raise ValidationError(
|
||||
@@ -976,6 +976,9 @@ class PurchaseOrder(TotalPriceMixin, Order):
|
||||
# Prefetch line item objects for DB efficiency
|
||||
line_items_ids = [item['line_item'].pk for item in items]
|
||||
|
||||
# Cache the custom status options for the StockItem model
|
||||
custom_stock_status_values = stock.models.StockItem.STATUS_CLASS.custom_values()
|
||||
|
||||
line_items = PurchaseOrderLineItem.objects.filter(
|
||||
pk__in=line_items_ids
|
||||
).prefetch_related('part', 'part__part', 'order')
|
||||
@@ -1050,14 +1053,17 @@ class PurchaseOrder(TotalPriceMixin, Order):
|
||||
'supplier_part': supplier_part,
|
||||
'purchase_order': self,
|
||||
'purchase_price': purchase_price,
|
||||
'status': item.get('status', StockStatus.OK.value),
|
||||
'location': stock_location,
|
||||
'quantity': 1 if serialize else stock_quantity,
|
||||
'batch': item.get('batch_code', ''),
|
||||
'expiry_date': item.get('expiry_date', None),
|
||||
'notes': item.get('note', '') or item.get('notes', ''),
|
||||
'packaging': item.get('packaging') or supplier_part.packaging,
|
||||
}
|
||||
|
||||
# Extract the "status" field
|
||||
status = item.get('status', StockStatus.OK.value)
|
||||
|
||||
# Check linked build order
|
||||
# This is for receiving against an *external* build order
|
||||
if build_order := line.build_order:
|
||||
@@ -1098,11 +1104,14 @@ class PurchaseOrder(TotalPriceMixin, Order):
|
||||
|
||||
# Now, create the new stock items
|
||||
if serialize:
|
||||
stock_items.extend(
|
||||
stock.models.StockItem._create_serial_numbers(
|
||||
serials=serials, **stock_data
|
||||
)
|
||||
new_items = stock.models.StockItem._create_serial_numbers(
|
||||
serials=serials, **stock_data
|
||||
)
|
||||
|
||||
for item in new_items:
|
||||
item.set_status(status, custom_values=custom_stock_status_values)
|
||||
stock_items.append(item)
|
||||
|
||||
else:
|
||||
new_item = stock.models.StockItem(
|
||||
**stock_data,
|
||||
@@ -1114,10 +1123,11 @@ class PurchaseOrder(TotalPriceMixin, Order):
|
||||
rght=2,
|
||||
)
|
||||
|
||||
new_item.set_status(status, custom_values=custom_stock_status_values)
|
||||
|
||||
if barcode:
|
||||
new_item.assign_barcode(barcode_data=barcode, save=False)
|
||||
|
||||
# new_item.save()
|
||||
bulk_create_items.append(new_item)
|
||||
|
||||
# Update the line item quantity
|
||||
@@ -1143,9 +1153,11 @@ class PurchaseOrder(TotalPriceMixin, Order):
|
||||
item.add_tracking_entry(
|
||||
StockHistoryCode.RECEIVED_AGAINST_PURCHASE_ORDER,
|
||||
user,
|
||||
location=item.location,
|
||||
purchaseorder=self,
|
||||
quantity=float(item.quantity),
|
||||
deltas={
|
||||
'location': item.location.pk if item.location else None,
|
||||
'purchaseorder': self.pk,
|
||||
'quantity': float(item.quantity),
|
||||
},
|
||||
commit=False,
|
||||
)
|
||||
)
|
||||
@@ -1376,8 +1388,13 @@ class SalesOrder(TotalPriceMixin, Order):
|
||||
return any(line.is_overallocated() for line in self.lines.all())
|
||||
|
||||
def is_completed(self) -> bool:
|
||||
"""Check if this order is "shipped" (all line items delivered)."""
|
||||
return all(line.is_completed() for line in self.lines.all())
|
||||
"""Check if this order is "shipped" (all line items delivered).
|
||||
|
||||
Note: Any "virtual" parts are ignored in this calculation.
|
||||
"""
|
||||
lines = self.lines.all().filter(part__virtual=False)
|
||||
|
||||
return all(line.is_completed() for line in lines)
|
||||
|
||||
def can_complete(
|
||||
self, raise_error: bool = False, allow_incomplete_lines: bool = False
|
||||
@@ -1412,10 +1429,15 @@ class SalesOrder(TotalPriceMixin, Order):
|
||||
_('Order cannot be completed as there are incomplete allocations')
|
||||
)
|
||||
|
||||
if not allow_incomplete_lines and self.pending_line_count > 0:
|
||||
raise ValidationError(
|
||||
_('Order cannot be completed as there are incomplete line items')
|
||||
)
|
||||
if not allow_incomplete_lines:
|
||||
pending_lines = self.pending_line_items().exclude(part__virtual=True)
|
||||
|
||||
if pending_lines.count() > 0:
|
||||
raise ValidationError(
|
||||
_(
|
||||
'Order cannot be completed as there are incomplete line items'
|
||||
)
|
||||
)
|
||||
|
||||
except ValidationError as e:
|
||||
if raise_error:
|
||||
@@ -1472,6 +1494,7 @@ class SalesOrder(TotalPriceMixin, Order):
|
||||
|
||||
trigger_event(SalesOrderEvents.HOLD, id=self.pk)
|
||||
|
||||
@transaction.atomic
|
||||
def _action_complete(self, *args, **kwargs):
|
||||
"""Mark this order as "complete."""
|
||||
user = kwargs.pop('user', None)
|
||||
@@ -1483,6 +1506,16 @@ class SalesOrder(TotalPriceMixin, Order):
|
||||
get_global_setting('SALESORDER_SHIP_COMPLETE')
|
||||
)
|
||||
|
||||
# Update line items
|
||||
for line in self.lines.all():
|
||||
# Mark any "virtual" parts as shipped at this point
|
||||
if line.part and line.part.virtual and line.shipped != line.quantity:
|
||||
line.shipped = line.quantity
|
||||
line.save()
|
||||
|
||||
if line.part:
|
||||
line.part.schedule_pricing_update(create=True)
|
||||
|
||||
if bypass_shipped or self.status == SalesOrderStatus.SHIPPED:
|
||||
self.status = SalesOrderStatus.COMPLETE.value
|
||||
else:
|
||||
@@ -1494,11 +1527,6 @@ class SalesOrder(TotalPriceMixin, Order):
|
||||
|
||||
self.save()
|
||||
|
||||
# Schedule pricing update for any referenced parts
|
||||
for line in self.lines.all():
|
||||
if line.part:
|
||||
line.part.schedule_pricing_update(create=True)
|
||||
|
||||
trigger_event(SalesOrderEvents.COMPLETED, id=self.pk)
|
||||
|
||||
return True
|
||||
@@ -1562,7 +1590,7 @@ class SalesOrder(TotalPriceMixin, Order):
|
||||
"""Attempt to transition to COMPLETED status."""
|
||||
return self.handle_transition(
|
||||
self.status,
|
||||
SalesOrderStatus.COMPLETED.value,
|
||||
SalesOrderStatus.COMPLETE.value,
|
||||
self,
|
||||
self._action_complete,
|
||||
user=user,
|
||||
|
||||
@@ -1226,6 +1226,8 @@ class PurchaseOrderReceiveTest(OrderTest):
|
||||
|
||||
def test_receive_large_quantity(self):
|
||||
"""Test receipt of a large number of items."""
|
||||
from stock.status_codes import StockStatus
|
||||
|
||||
sp = SupplierPart.objects.first()
|
||||
|
||||
# Create a new order
|
||||
@@ -1256,7 +1258,12 @@ class PurchaseOrderReceiveTest(OrderTest):
|
||||
url,
|
||||
{
|
||||
'items': [
|
||||
{'line_item': line.pk, 'quantity': line.quantity} for line in lines
|
||||
{
|
||||
'line_item': line.pk,
|
||||
'quantity': line.quantity,
|
||||
'status': StockStatus.QUARANTINED.value,
|
||||
}
|
||||
for line in lines
|
||||
],
|
||||
'location': location.pk,
|
||||
},
|
||||
@@ -1269,6 +1276,7 @@ class PurchaseOrderReceiveTest(OrderTest):
|
||||
|
||||
for item in response:
|
||||
self.assertEqual(item['purchase_order'], po.pk)
|
||||
self.assertEqual(item['status'], StockStatus.QUARANTINED)
|
||||
|
||||
# Check that the order has been completed
|
||||
po.refresh_from_db()
|
||||
|
||||
@@ -488,3 +488,45 @@ class SalesOrderTest(InvenTreeTestCase):
|
||||
p.set_metadata(k, k)
|
||||
|
||||
self.assertEqual(len(p.metadata.keys()), 4)
|
||||
|
||||
def test_virtual_parts(self):
|
||||
"""Test shipment of virtual parts against an order."""
|
||||
vp = Part.objects.create(
|
||||
name='Virtual Part',
|
||||
salable=True,
|
||||
virtual=True,
|
||||
description='A virtual part that I sell',
|
||||
)
|
||||
|
||||
so = SalesOrder.objects.create(
|
||||
customer=self.customer,
|
||||
reference='SO-VIRTUAL-1',
|
||||
customer_reference='VIRT-001',
|
||||
)
|
||||
|
||||
for qty in [5, 10, 15]:
|
||||
SalesOrderLineItem.objects.create(order=so, part=vp, quantity=qty)
|
||||
|
||||
# Delete pending shipments (if any)
|
||||
so.shipments.all().delete()
|
||||
|
||||
for line in so.lines.all():
|
||||
self.assertEqual(line.part.virtual, True)
|
||||
self.assertEqual(line.shipped, 0)
|
||||
self.assertGreater(line.quantity, 0)
|
||||
self.assertTrue(line.is_fully_allocated())
|
||||
self.assertTrue(line.is_completed())
|
||||
|
||||
# Complete the order
|
||||
so.ship_order(None)
|
||||
|
||||
so.refresh_from_db()
|
||||
self.assertEqual(so.status, status.SalesOrderStatus.SHIPPED)
|
||||
|
||||
so.complete_order(None)
|
||||
so.refresh_from_db()
|
||||
self.assertEqual(so.status, status.SalesOrderStatus.COMPLETE)
|
||||
|
||||
# Ensure that virtual line item quantity values have been updated
|
||||
for line in so.lines.all():
|
||||
self.assertEqual(line.shipped, line.quantity)
|
||||
|
||||
@@ -828,7 +828,7 @@ class StockItem(
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
# If user information is provided, and no existing note exists, create one!
|
||||
if user and add_note and self.tracking_info.count() == 0:
|
||||
if add_note and self.tracking_info.count() == 0:
|
||||
tracking_info = {'status': self.status}
|
||||
|
||||
self.add_tracking_entry(
|
||||
@@ -1908,7 +1908,12 @@ class StockItem(
|
||||
data = dict(StockItem.objects.filter(pk=self.pk).values()[0])
|
||||
|
||||
if location:
|
||||
data['location'] = location
|
||||
if location.structural:
|
||||
raise ValidationError({
|
||||
'location': _('Cannot assign stock to structural location')
|
||||
})
|
||||
|
||||
data['location_id'] = location.pk
|
||||
|
||||
# Set the parent ID correctly
|
||||
data['parent'] = self
|
||||
@@ -1921,7 +1926,17 @@ class StockItem(
|
||||
history_items = []
|
||||
|
||||
for item in items:
|
||||
# Construct a tracking entry for the new StockItem
|
||||
# Construct tracking entries for the new StockItem
|
||||
if entry := item.add_tracking_entry(
|
||||
StockHistoryCode.SPLIT_FROM_PARENT,
|
||||
user,
|
||||
quantity=1,
|
||||
notes=notes,
|
||||
location=location,
|
||||
commit=False,
|
||||
):
|
||||
history_items.append(entry)
|
||||
|
||||
if entry := item.add_tracking_entry(
|
||||
StockHistoryCode.ASSIGNED_SERIAL,
|
||||
user,
|
||||
@@ -1938,7 +1953,9 @@ class StockItem(
|
||||
StockItemTracking.objects.bulk_create(history_items)
|
||||
|
||||
# Remove the equivalent number of items
|
||||
self.take_stock(quantity, user, notes=notes)
|
||||
self.take_stock(
|
||||
quantity, user, code=StockHistoryCode.STOCK_SERIZALIZED, notes=notes
|
||||
)
|
||||
|
||||
return items
|
||||
|
||||
|
||||
@@ -1060,12 +1060,19 @@ class StockChangeStatusSerializer(serializers.Serializer):
|
||||
# Instead of performing database updates for each item,
|
||||
# perform bulk database updates (much more efficient)
|
||||
|
||||
# Pre-cache the custom status values (to reduce DB hits)
|
||||
custom_status_codes = StockItem.STATUS_CLASS.custom_values()
|
||||
|
||||
for item in items:
|
||||
# Ignore items which are already in the desired status
|
||||
if item.compare_status(status):
|
||||
continue
|
||||
|
||||
item.set_status(status)
|
||||
# Careful check for custom status codes also
|
||||
if item.compare_status(status):
|
||||
custom_status = item.get_custom_status()
|
||||
if status == custom_status or custom_status is None:
|
||||
continue
|
||||
|
||||
item.set_status(status, custom_values=custom_status_codes)
|
||||
item.save(add_note=False)
|
||||
|
||||
# Create a new transaction note for each item
|
||||
|
||||
@@ -53,6 +53,7 @@ class StockHistoryCode(StatusCode):
|
||||
STOCK_COUNT = 10, _('Stock counted')
|
||||
STOCK_ADD = 11, _('Stock manually added')
|
||||
STOCK_REMOVE = 12, _('Stock manually removed')
|
||||
STOCK_SERIZALIZED = 13, _('Serialized stock items')
|
||||
|
||||
RETURNED_TO_STOCK = 15, _('Returned to stock') # Stock item returned to stock
|
||||
|
||||
|
||||
@@ -1701,12 +1701,18 @@ class StockItemTest(StockAPITestCase):
|
||||
|
||||
prt = Part.objects.first()
|
||||
|
||||
# Number of items to create
|
||||
N_ITEMS = 10
|
||||
|
||||
# Create a bunch of items
|
||||
items = [StockItem.objects.create(part=prt, quantity=10) for _ in range(10)]
|
||||
items = [
|
||||
StockItem.objects.create(part=prt, quantity=10) for _ in range(N_ITEMS)
|
||||
]
|
||||
|
||||
for item in items:
|
||||
item.refresh_from_db()
|
||||
self.assertEqual(item.status, StockStatus.OK.value)
|
||||
self.assertEqual(item.tracking_info.count(), 1)
|
||||
|
||||
data = {
|
||||
'items': [item.pk for item in items],
|
||||
@@ -1719,10 +1725,10 @@ class StockItemTest(StockAPITestCase):
|
||||
for item in items:
|
||||
item.refresh_from_db()
|
||||
self.assertEqual(item.status, StockStatus.DAMAGED.value)
|
||||
self.assertEqual(item.tracking_info.count(), 1)
|
||||
self.assertEqual(item.tracking_info.count(), 2)
|
||||
|
||||
# Same test, but with one item unchanged
|
||||
items[0].status = StockStatus.ATTENTION.value
|
||||
items[0].set_status(StockStatus.ATTENTION.value)
|
||||
items[0].save()
|
||||
|
||||
data['status'] = StockStatus.ATTENTION.value
|
||||
@@ -1732,7 +1738,7 @@ class StockItemTest(StockAPITestCase):
|
||||
for item in items:
|
||||
item.refresh_from_db()
|
||||
self.assertEqual(item.status, StockStatus.ATTENTION.value)
|
||||
self.assertEqual(item.tracking_info.count(), 2)
|
||||
self.assertEqual(item.tracking_info.count(), 3)
|
||||
|
||||
tracking = item.tracking_info.last()
|
||||
self.assertEqual(tracking.tracking_type, StockHistoryCode.EDITED.value)
|
||||
|
||||
@@ -1271,9 +1271,11 @@ class StockTreeTest(StockTestBase):
|
||||
self.assertEqual(item_1.get_children().count(), 1)
|
||||
self.assertEqual(item_2.parent, item_1)
|
||||
|
||||
loc = StockLocation.objects.filter(structural=False).first()
|
||||
|
||||
# Serialize the secondary item
|
||||
serials = [str(i) for i in range(20)]
|
||||
items = item_2.serializeStock(20, serials)
|
||||
items = item_2.serializeStock(20, serials, location=loc)
|
||||
|
||||
self.assertEqual(len(items), 20)
|
||||
self.assertEqual(StockItem.objects.count(), N + 22)
|
||||
@@ -1290,6 +1292,9 @@ class StockTreeTest(StockTestBase):
|
||||
self.assertEqual(child.parent, item_2)
|
||||
self.assertGreater(child.lft, item_2.lft)
|
||||
self.assertLess(child.rght, item_2.rght)
|
||||
self.assertEqual(child.location, loc)
|
||||
self.assertIsNotNone(child.location)
|
||||
self.assertEqual(child.tracking_info.count(), 2)
|
||||
|
||||
# Delete item_2 : we expect that all children will be re-parented to item_1
|
||||
item_2.delete()
|
||||
|
||||
@@ -5,9 +5,8 @@ from django.contrib.auth.models import Group
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
|
||||
from allauth.mfa.totp.internal import auth as totp_auth
|
||||
|
||||
from common.settings import set_global_setting
|
||||
from InvenTree.helpers_mfa import get_codes
|
||||
from InvenTree.unit_test import AdminTestCase, InvenTreeAPITestCase, InvenTreeTestCase
|
||||
from users.models import ApiToken, Owner
|
||||
from users.oauth2_scopes import _roles
|
||||
@@ -334,8 +333,6 @@ class OwnerModelTest(InvenTreeTestCase):
|
||||
class MFALoginTest(InvenTreeAPITestCase):
|
||||
"""Some simplistic tests to ensure that MFA is working."""
|
||||
|
||||
mfa_secret = None
|
||||
|
||||
def test_api(self):
|
||||
"""Test that the API is working."""
|
||||
auth_data = {'username': self.username, 'password': self.password}
|
||||
@@ -349,13 +346,8 @@ class MFALoginTest(InvenTreeAPITestCase):
|
||||
response = self.post(login_url, auth_data, expected_code=200)
|
||||
self._helper_meta_val(response)
|
||||
|
||||
return # TODO @matmair re-enable MFA tests once stable
|
||||
# Add MFA - trying in a limited loop in case of timing issues
|
||||
response = self.post(
|
||||
reverse('browser:mfa:manage_totp'),
|
||||
{'code': self.get_topt()},
|
||||
expected_code=200,
|
||||
)
|
||||
rc_code = get_codes(user=self.user)[1][0]
|
||||
|
||||
# There must be a TOTP device now - success
|
||||
self.get(reverse('browser:mfa:manage_totp'), expected_code=200)
|
||||
@@ -373,11 +365,9 @@ class MFALoginTest(InvenTreeAPITestCase):
|
||||
response = self.post(login_url, auth_data, expected_code=401)
|
||||
# MFA not finished - no access allowed
|
||||
self.get(reverse('api-token'), expected_code=401)
|
||||
# Complete
|
||||
# Complete MFA (with recovery code to avoid timing issues)
|
||||
self.post(
|
||||
reverse('browser:mfa:authenticate'),
|
||||
{'code': self.get_topt()},
|
||||
expected_code=401,
|
||||
reverse('browser:mfa:authenticate'), {'code': rc_code}, expected_code=401
|
||||
)
|
||||
self.post(reverse('browser:mfa:trust'), {'trust': False}, expected_code=200)
|
||||
# and run through trust
|
||||
@@ -405,15 +395,6 @@ class MFALoginTest(InvenTreeAPITestCase):
|
||||
flows = response.json()['data']['flows']
|
||||
return next(a for a in flows if a['id'] == flow_id)
|
||||
|
||||
def get_topt(self):
|
||||
"""Helper to get a current totp code."""
|
||||
if not self.mfa_secret:
|
||||
mfa_init = self.get(reverse('browser:mfa:manage_totp'), expected_code=404)
|
||||
self.mfa_secret = mfa_init.json()['meta']['secret']
|
||||
return totp_auth.hotp_value(
|
||||
self.mfa_secret, next(totp_auth.yield_hotp_counters_from_time())
|
||||
)
|
||||
|
||||
|
||||
class AdminTest(AdminTestCase):
|
||||
"""Tests for the admin interface integration."""
|
||||
|
||||
@@ -382,9 +382,9 @@ distlib==0.4.0 \
|
||||
--hash=sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16 \
|
||||
--hash=sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d
|
||||
# via virtualenv
|
||||
django==4.2.25 \
|
||||
--hash=sha256:2391ab3d78191caaae2c963c19fd70b99e9751008da22a0adcc667c5a4f8d311 \
|
||||
--hash=sha256:9584cf26b174b35620e53c2558b09d7eb180a655a3470474f513ff9acb494f8c
|
||||
django==4.2.26 \
|
||||
--hash=sha256:9398e487bcb55e3f142cb56d19fbd9a83e15bb03a97edc31f408361ee76d9d7a \
|
||||
--hash=sha256:c96e64fc3c359d051a6306871bd26243db1bd02317472a62ffdbe6c3cae14280
|
||||
# via
|
||||
# -c src/backend/requirements.txt
|
||||
# django-slowtests
|
||||
|
||||
@@ -486,9 +486,9 @@ defusedxml==0.7.1 \
|
||||
--hash=sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69 \
|
||||
--hash=sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61
|
||||
# via python3-openid
|
||||
django==4.2.25 \
|
||||
--hash=sha256:2391ab3d78191caaae2c963c19fd70b99e9751008da22a0adcc667c5a4f8d311 \
|
||||
--hash=sha256:9584cf26b174b35620e53c2558b09d7eb180a655a3470474f513ff9acb494f8c
|
||||
django==4.2.26 \
|
||||
--hash=sha256:9398e487bcb55e3f142cb56d19fbd9a83e15bb03a97edc31f408361ee76d9d7a \
|
||||
--hash=sha256:c96e64fc3c359d051a6306871bd26243db1bd02317472a62ffdbe6c3cae14280
|
||||
# via
|
||||
# -r src/backend/requirements.in
|
||||
# django-allauth
|
||||
|
||||
@@ -7,6 +7,28 @@ import { cancelEvent } from './Events';
|
||||
export const getBaseUrl = (): string =>
|
||||
(window as any).INVENTREE_SETTINGS?.base_url || 'web';
|
||||
|
||||
/**
|
||||
* Returns the overview URL for a given model type.
|
||||
* This is the UI URL, not the API URL.
|
||||
*/
|
||||
export function getOverviewUrl(model: ModelType, absolute?: boolean): string {
|
||||
const modelInfo = ModelInformationDict[model];
|
||||
|
||||
if (modelInfo?.url_overview) {
|
||||
const url = modelInfo.url_overview;
|
||||
const base = getBaseUrl();
|
||||
|
||||
if (absolute && base) {
|
||||
return `/${base}${url}`;
|
||||
} else {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
console.error(`No overview URL found for model ${model}`);
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the detail view URL for a given model type.
|
||||
* This is the UI URL, not the API URL.
|
||||
|
||||
@@ -24,11 +24,7 @@ import { type NavigateFunction, useNavigate } from 'react-router-dom';
|
||||
|
||||
import { isTrue } from '@lib/functions/Conversion';
|
||||
import { getDetailUrl } from '@lib/functions/Navigation';
|
||||
import type {
|
||||
ApiFormFieldSet,
|
||||
ApiFormFieldType,
|
||||
ApiFormProps
|
||||
} from '@lib/types/Forms';
|
||||
import type { ApiFormFieldSet, ApiFormProps } from '@lib/types/Forms';
|
||||
import { useApi } from '../../contexts/ApiContext';
|
||||
import {
|
||||
type NestedDict,
|
||||
@@ -46,9 +42,11 @@ import { ApiFormField } from './fields/ApiFormField';
|
||||
|
||||
export function OptionsApiForm({
|
||||
props: _props,
|
||||
opened,
|
||||
id: pId
|
||||
}: Readonly<{
|
||||
props: ApiFormProps;
|
||||
opened?: boolean;
|
||||
id?: string;
|
||||
}>) {
|
||||
const api = useApi();
|
||||
@@ -75,24 +73,26 @@ export function OptionsApiForm({
|
||||
);
|
||||
|
||||
const optionsQuery = useQuery({
|
||||
enabled: true,
|
||||
enabled: opened !== false && props.ignorePermissionCheck !== true,
|
||||
refetchOnMount: false,
|
||||
queryKey: [
|
||||
'form-options-data',
|
||||
id,
|
||||
opened,
|
||||
props.ignorePermissionCheck,
|
||||
props.method,
|
||||
props.url,
|
||||
props.pk,
|
||||
props.pathParams
|
||||
],
|
||||
queryFn: async () => {
|
||||
const response = await api.options(url);
|
||||
let fields: Record<string, ApiFormFieldType> | null = {};
|
||||
if (!props.ignorePermissionCheck) {
|
||||
fields = extractAvailableFields(response, props.method);
|
||||
if (props.ignorePermissionCheck === true || opened === false) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return fields;
|
||||
return api.options(url).then((response: any) => {
|
||||
return extractAvailableFields(response, props.method);
|
||||
});
|
||||
},
|
||||
throwOnError: (error: any) => {
|
||||
if (error.response) {
|
||||
@@ -110,6 +110,13 @@ export function OptionsApiForm({
|
||||
}
|
||||
});
|
||||
|
||||
// Refetch form options whenever the modal is opened
|
||||
useEffect(() => {
|
||||
if (opened !== false) {
|
||||
optionsQuery.refetch();
|
||||
}
|
||||
}, [opened]);
|
||||
|
||||
const formProps: ApiFormProps = useMemo(() => {
|
||||
const _props = { ...props };
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { TextInput, Tooltip } from '@mantine/core';
|
||||
import { useDebouncedValue } from '@mantine/hooks';
|
||||
import { IconCopyCheck, IconX } from '@tabler/icons-react';
|
||||
import {
|
||||
type ReactNode,
|
||||
@@ -40,30 +39,26 @@ export default function TextField({
|
||||
|
||||
const { value } = useMemo(() => field, [field]);
|
||||
|
||||
const [rawText, setRawText] = useState<string>(value || '');
|
||||
const [textValue, setTextValue] = useState<string>(value || '');
|
||||
|
||||
const [debouncedText] = useDebouncedValue(rawText, 100);
|
||||
const onTextChange = useCallback(
|
||||
(value: any) => {
|
||||
setTextValue(value);
|
||||
onChange(value);
|
||||
},
|
||||
[onChange]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setRawText(value || '');
|
||||
setTextValue(value || '');
|
||||
}, [value]);
|
||||
|
||||
const onTextChange = useCallback((value: any) => {
|
||||
setRawText(value);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (debouncedText !== value) {
|
||||
onChange(debouncedText);
|
||||
}
|
||||
}, [debouncedText]);
|
||||
|
||||
// Construct a "right section" for the text field
|
||||
const textFieldRightSection: ReactNode = useMemo(() => {
|
||||
if (definition.rightSection) {
|
||||
// Use the specified override value
|
||||
return definition.rightSection;
|
||||
} else if (value) {
|
||||
} else if (textValue) {
|
||||
if (!definition.required && !definition.disabled) {
|
||||
// Render a button to clear the text field
|
||||
return (
|
||||
@@ -78,7 +73,7 @@ export default function TextField({
|
||||
);
|
||||
}
|
||||
} else if (
|
||||
!value &&
|
||||
!textValue &&
|
||||
definition.placeholder &&
|
||||
placeholderAutofill &&
|
||||
!definition.disabled
|
||||
@@ -94,7 +89,7 @@ export default function TextField({
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
}, [placeholderAutofill, definition, value]);
|
||||
}, [placeholderAutofill, definition, textValue]);
|
||||
|
||||
return (
|
||||
<TextInput
|
||||
@@ -103,19 +98,19 @@ export default function TextField({
|
||||
id={fieldId}
|
||||
aria-label={`text-field-${field.name}`}
|
||||
type={definition.field_type}
|
||||
value={rawText || ''}
|
||||
value={textValue || ''}
|
||||
error={definition.error ?? error?.message}
|
||||
radius='sm'
|
||||
onChange={(event) => onTextChange(event.currentTarget.value)}
|
||||
onBlur={(event) => {
|
||||
if (event.currentTarget.value != value) {
|
||||
onChange(event.currentTarget.value);
|
||||
if (event.currentTarget.value != textValue) {
|
||||
onTextChange(event.currentTarget.value);
|
||||
}
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.code === 'Enter') {
|
||||
// Bypass debounce on enter key
|
||||
onChange(event.currentTarget.value);
|
||||
onTextChange(event.currentTarget.value);
|
||||
}
|
||||
onKeyDown(event.code);
|
||||
}}
|
||||
|
||||
@@ -16,6 +16,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
|
||||
import { apiUrl } from '@lib/functions/Api';
|
||||
import type { ApiFormFieldType } from '@lib/types/Forms';
|
||||
import { useDebouncedValue } from '@mantine/hooks';
|
||||
import { useApi } from '../../contexts/ApiContext';
|
||||
import type { ImportSessionState } from '../../hooks/UseImportSession';
|
||||
import { StandaloneField } from '../forms/StandaloneField';
|
||||
@@ -83,6 +84,14 @@ function ImporterDefaultField({
|
||||
}) {
|
||||
const api = useApi();
|
||||
|
||||
const [rawValue, setRawValue] = useState<any>('');
|
||||
|
||||
const fieldType: string = useMemo(() => {
|
||||
return session.availableFields[fieldName]?.type;
|
||||
}, [fieldName, session.availableFields]);
|
||||
|
||||
const [value] = useDebouncedValue(rawValue, fieldType == 'string' ? 500 : 10);
|
||||
|
||||
const onChange = useCallback(
|
||||
(value: any) => {
|
||||
// Update the default value for the field
|
||||
@@ -105,6 +114,11 @@ function ImporterDefaultField({
|
||||
[fieldName, session, session.fieldDefaults]
|
||||
);
|
||||
|
||||
// Update the default value after the debounced value changes
|
||||
useEffect(() => {
|
||||
onChange(value);
|
||||
}, [value]);
|
||||
|
||||
const fieldDef: ApiFormFieldType = useMemo(() => {
|
||||
let def: any = session.availableFields[fieldName];
|
||||
|
||||
@@ -114,7 +128,10 @@ function ImporterDefaultField({
|
||||
value: session.fieldDefaults[fieldName],
|
||||
field_type: def.type,
|
||||
description: def.help_text,
|
||||
onValueChange: onChange
|
||||
required: false,
|
||||
onValueChange: (value: string) => {
|
||||
setRawValue(value);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -20,6 +20,9 @@ export function usePartFields({
|
||||
}): ApiFormFieldSet {
|
||||
const settings = useGlobalSettingsState();
|
||||
|
||||
const [virtual, setVirtual] = useState<boolean>(false);
|
||||
const [purchaseable, setPurchaseable] = useState<boolean>(false);
|
||||
|
||||
return useMemo(() => {
|
||||
const fields: ApiFormFieldSet = {
|
||||
category: {
|
||||
@@ -62,9 +65,19 @@ export function usePartFields({
|
||||
is_template: {},
|
||||
testable: {},
|
||||
trackable: {},
|
||||
purchaseable: {},
|
||||
purchaseable: {
|
||||
value: purchaseable,
|
||||
onValueChange: (value: boolean) => {
|
||||
setPurchaseable(value);
|
||||
}
|
||||
},
|
||||
salable: {},
|
||||
virtual: {},
|
||||
virtual: {
|
||||
value: virtual,
|
||||
onValueChange: (value: boolean) => {
|
||||
setVirtual(value);
|
||||
}
|
||||
},
|
||||
locked: {},
|
||||
active: {},
|
||||
starred: {
|
||||
@@ -80,33 +93,37 @@ export function usePartFields({
|
||||
if (create) {
|
||||
fields.copy_category_parameters = {};
|
||||
|
||||
fields.initial_stock = {
|
||||
icon: <IconPackages />,
|
||||
children: {
|
||||
quantity: {
|
||||
value: 0
|
||||
},
|
||||
location: {}
|
||||
}
|
||||
};
|
||||
if (!virtual) {
|
||||
fields.initial_stock = {
|
||||
icon: <IconPackages />,
|
||||
children: {
|
||||
quantity: {
|
||||
value: 0
|
||||
},
|
||||
location: {}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
fields.initial_supplier = {
|
||||
icon: <IconBuildingStore />,
|
||||
children: {
|
||||
supplier: {
|
||||
filters: {
|
||||
is_supplier: true
|
||||
}
|
||||
},
|
||||
sku: {},
|
||||
manufacturer: {
|
||||
filters: {
|
||||
is_manufacturer: true
|
||||
}
|
||||
},
|
||||
mpn: {}
|
||||
}
|
||||
};
|
||||
if (purchaseable) {
|
||||
fields.initial_supplier = {
|
||||
icon: <IconBuildingStore />,
|
||||
children: {
|
||||
supplier: {
|
||||
filters: {
|
||||
is_supplier: true
|
||||
}
|
||||
},
|
||||
sku: {},
|
||||
manufacturer: {
|
||||
filters: {
|
||||
is_manufacturer: true
|
||||
}
|
||||
},
|
||||
mpn: {}
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Additional fields for part duplication
|
||||
@@ -159,7 +176,7 @@ export function usePartFields({
|
||||
}
|
||||
|
||||
return fields;
|
||||
}, [create, duplicatePartInstance, settings]);
|
||||
}, [virtual, purchaseable, create, duplicatePartInstance, settings]);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -125,8 +125,11 @@ export function useSalesOrderLineItemFields({
|
||||
)
|
||||
.sort((a: any, b: any) => a.quantity - b.quantity);
|
||||
|
||||
if (applicablePriceBreaks.length)
|
||||
if (applicablePriceBreaks.length) {
|
||||
setSalePrice(applicablePriceBreaks[0].price);
|
||||
} else {
|
||||
setSalePrice('');
|
||||
}
|
||||
}, [part, quantity, partCurrency, create]);
|
||||
|
||||
return useMemo(() => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { Alert, Divider, Stack } from '@mantine/core';
|
||||
import { useId } from '@mantine/hooks';
|
||||
import { useEffect, useMemo, useRef } from 'react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import type {
|
||||
ApiFormModalProps,
|
||||
@@ -50,14 +50,18 @@ export function useApiFormModal(props: ApiFormModalProps) {
|
||||
[props]
|
||||
);
|
||||
|
||||
const [isOpen, setIsOpen] = useState<boolean>(false);
|
||||
|
||||
const modal = useModal({
|
||||
id: modalId,
|
||||
title: formProps.title,
|
||||
onOpen: () => {
|
||||
setIsOpen(true);
|
||||
modalState.setModalOpen(modalId, true);
|
||||
formProps.onOpen?.();
|
||||
},
|
||||
onClose: () => {
|
||||
setIsOpen(false);
|
||||
modalState.setModalOpen(modalId, false);
|
||||
formProps.onClose?.();
|
||||
},
|
||||
@@ -66,7 +70,7 @@ export function useApiFormModal(props: ApiFormModalProps) {
|
||||
children: (
|
||||
<Stack gap={'xs'}>
|
||||
<Divider />
|
||||
<OptionsApiForm props={formProps} id={modalId} />
|
||||
<OptionsApiForm props={formProps} id={modalId} opened={isOpen} />
|
||||
</Stack>
|
||||
)
|
||||
});
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { BarChart, type ChartTooltipProps, DonutChart } from '@mantine/charts';
|
||||
import { BarChart, DonutChart } from '@mantine/charts';
|
||||
import {
|
||||
Center,
|
||||
Group,
|
||||
Paper,
|
||||
SegmentedControl,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
@@ -17,40 +16,12 @@ import { apiUrl } from '@lib/functions/Api';
|
||||
import type { TableColumn } from '@lib/types/Tables';
|
||||
import { CHART_COLORS } from '../../../components/charts/colors';
|
||||
import { tooltipFormatter } from '../../../components/charts/tooltipFormatter';
|
||||
import {
|
||||
formatCurrency,
|
||||
formatDecimal,
|
||||
formatPriceRange
|
||||
} from '../../../defaults/formatters';
|
||||
import { formatDecimal, formatPriceRange } from '../../../defaults/formatters';
|
||||
import { useTable } from '../../../hooks/UseTable';
|
||||
import { DateColumn, PartColumn } from '../../../tables/ColumnRenderers';
|
||||
import { InvenTreeTable } from '../../../tables/InvenTreeTable';
|
||||
import { LoadingPricingData, NoPricingData } from './PricingPanel';
|
||||
|
||||
/*
|
||||
* Render a tooltip for the chart, with correct date information
|
||||
*/
|
||||
function ChartTooltip({ label, payload }: ChartTooltipProps) {
|
||||
if (!payload) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = payload[0] ?? {};
|
||||
|
||||
return (
|
||||
<Paper px='md' py='sm' withBorder shadow='md' radius='md'>
|
||||
<Group justify='space-between' wrap='nowrap'>
|
||||
<Text key='title' c={data.payload?.color}>
|
||||
{data.name}
|
||||
</Text>
|
||||
<Text key='price' fz='sm'>
|
||||
{formatCurrency(data.payload?.value)}
|
||||
</Text>
|
||||
</Group>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
// Display BOM data as a pie chart
|
||||
function BomPieChart({
|
||||
data,
|
||||
@@ -86,11 +57,6 @@ function BomPieChart({
|
||||
tooltipDataSource='segment'
|
||||
chartLabel={t`Total Price`}
|
||||
valueFormatter={(value) => tooltipFormatter(value, currency)}
|
||||
tooltipProps={{
|
||||
content: ({ label, payload }) => (
|
||||
<ChartTooltip label={label} payload={payload} />
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</Center>
|
||||
);
|
||||
@@ -116,11 +82,6 @@ function BomBarChart({
|
||||
{ name: 'total_price_max', label: t`Maximum Price`, color: 'teal.6' }
|
||||
]}
|
||||
valueFormatter={(value) => tooltipFormatter(value, currency)}
|
||||
tooltipProps={{
|
||||
content: ({ label, payload }) => (
|
||||
<ChartTooltip label={label} payload={payload} />
|
||||
)
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
|
||||
import { ModelType } from '@lib/enums/ModelType';
|
||||
import { UserRoles } from '@lib/enums/Roles';
|
||||
import { apiUrl } from '@lib/functions/Api';
|
||||
import { getDetailUrl } from '@lib/functions/Navigation';
|
||||
import { getDetailUrl, getOverviewUrl } from '@lib/functions/Navigation';
|
||||
import type { StockOperationProps } from '@lib/types/Forms';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import { useBarcodeScanDialog } from '../../components/barcodes/BarcodeScanDialog';
|
||||
@@ -730,7 +730,20 @@ export default function StockDetail() {
|
||||
return {
|
||||
items: [stockitem],
|
||||
model: ModelType.stockitem,
|
||||
refresh: refreshInstance,
|
||||
refresh: () => {
|
||||
const location = stockitem?.location;
|
||||
refreshInstancePromise().then((response) => {
|
||||
if (response.status == 'error') {
|
||||
// If an error occurs refreshing the instance,
|
||||
// the stock likely has likely been depleted
|
||||
if (location) {
|
||||
navigate(getDetailUrl(ModelType.stocklocation, location));
|
||||
} else {
|
||||
navigate(getOverviewUrl(ModelType.stockitem));
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
filters: {
|
||||
in_stock: true
|
||||
}
|
||||
|
||||
@@ -23,11 +23,7 @@ import { useNavigate } from 'react-router-dom';
|
||||
import { ActionButton } from '@lib/components/ActionButton';
|
||||
import { AddItemButton } from '@lib/components/AddItemButton';
|
||||
import { ProgressBar } from '@lib/components/ProgressBar';
|
||||
import {
|
||||
type RowAction,
|
||||
RowEditAction,
|
||||
RowViewAction
|
||||
} from '@lib/components/RowActions';
|
||||
import { type RowAction, RowEditAction } from '@lib/components/RowActions';
|
||||
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
|
||||
import { ModelType } from '@lib/enums/ModelType';
|
||||
import { UserRoles } from '@lib/enums/Roles';
|
||||
|
||||
@@ -13,7 +13,6 @@ import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
|
||||
import { ModelType } from '@lib/enums/ModelType';
|
||||
import { UserRoles } from '@lib/enums/Roles';
|
||||
import { apiUrl } from '@lib/functions/Api';
|
||||
import { formatDecimal } from '@lib/functions/Formatting';
|
||||
import type { TableFilter } from '@lib/types/Filters';
|
||||
import type { TableColumn } from '@lib/types/Tables';
|
||||
import { IconPackageImport } from '@tabler/icons-react';
|
||||
@@ -112,7 +111,6 @@ export function SupplierPartTable({
|
||||
{
|
||||
accessor: 'pack_quantity',
|
||||
sortable: true,
|
||||
|
||||
render: (record: any) => {
|
||||
const part = record?.part_detail ?? {};
|
||||
|
||||
@@ -120,7 +118,7 @@ export function SupplierPartTable({
|
||||
|
||||
if (part.units) {
|
||||
extra.push(
|
||||
<Text key='base'>
|
||||
<Text key='base' size='sm'>
|
||||
{t`Base units`} : {part.units}
|
||||
</Text>
|
||||
);
|
||||
@@ -128,7 +126,7 @@ export function SupplierPartTable({
|
||||
|
||||
return (
|
||||
<TableHoverCard
|
||||
value={formatDecimal(record.pack_quantity)}
|
||||
value={record.pack_quantity}
|
||||
extra={extra}
|
||||
title={t`Pack Quantity`}
|
||||
/>
|
||||
|
||||
@@ -320,7 +320,9 @@ export default function SalesOrderLineItemTable({
|
||||
|
||||
const allocateStock = useAllocateToSalesOrderForm({
|
||||
orderId: orderId,
|
||||
lineItems: selectedItems,
|
||||
lineItems: selectedItems.filter(
|
||||
(item) => item.part_detail?.virtual !== true
|
||||
),
|
||||
onFormSuccess: () => {
|
||||
table.refreshTable();
|
||||
table.clearSelectedRecords();
|
||||
|
||||
@@ -99,6 +99,50 @@ test('Build Order - Basic Tests', async ({ browser }) => {
|
||||
.waitFor();
|
||||
});
|
||||
|
||||
// Test that the build order reference field increments correctly
|
||||
test('Build Order - Reference', async ({ browser }) => {
|
||||
const page = await doCachedLogin(browser, {
|
||||
url: 'manufacturing/index/buildorders'
|
||||
});
|
||||
|
||||
await page
|
||||
.getByRole('button', { name: 'action-button-add-build-order' })
|
||||
.click();
|
||||
await page.getByRole('button', { name: 'Submit' }).waitFor();
|
||||
|
||||
// Grab the next BuildOrder reference
|
||||
const reference: string = await page
|
||||
.getByRole('textbox', { name: 'text-field-reference' })
|
||||
.inputValue();
|
||||
expect(reference).toMatch(/BO\d+/);
|
||||
|
||||
// Select a part
|
||||
await page.getByLabel('related-field-part').fill('MAST');
|
||||
await page.getByText('MAST | Master Assembly').click();
|
||||
|
||||
// Submit the form
|
||||
await page.getByRole('button', { name: 'Submit' }).click();
|
||||
await page.getByText('Item Created').waitFor();
|
||||
|
||||
// Back to the "build order" page - to create a new order
|
||||
await navigate(page, 'manufacturing/index/buildorders');
|
||||
|
||||
await page
|
||||
.getByRole('button', { name: 'action-button-add-build-order' })
|
||||
.click();
|
||||
await page.getByRole('button', { name: 'Submit' }).waitFor();
|
||||
|
||||
const nextReference: string = await page
|
||||
.getByRole('textbox', { name: 'text-field-reference' })
|
||||
.inputValue();
|
||||
expect(nextReference).toMatch(/BO\d+/);
|
||||
|
||||
// Ensure that the reference has incremented
|
||||
const refNumber = Number(reference.replace('BO', ''));
|
||||
const nextRefNumber = Number(nextReference.replace('BO', ''));
|
||||
expect(nextRefNumber).toBe(refNumber + 1);
|
||||
});
|
||||
|
||||
test('Build Order - Calendar', async ({ browser }) => {
|
||||
const page = await doCachedLogin(browser);
|
||||
|
||||
|
||||
@@ -208,7 +208,7 @@ test('Parts - Details', async ({ browser }) => {
|
||||
|
||||
// Depending on the state of other tests, the "In Production" value may vary
|
||||
// This could be either 4 / 49, or 5 / 49
|
||||
await page.getByText(/[4|5] \/ 49/).waitFor();
|
||||
await page.getByText(/[4|5] \/ \d+/).waitFor();
|
||||
|
||||
// Badges
|
||||
await page.getByText('Required: 10').waitFor();
|
||||
@@ -228,14 +228,14 @@ test('Parts - Requirements', async ({ browser }) => {
|
||||
// Check top-level badges
|
||||
await page.getByText('In Stock: 209').waitFor();
|
||||
await page.getByText('Available: 204').waitFor();
|
||||
await page.getByText('Required: 275').waitFor();
|
||||
await page.getByText(/Required: 2\d+/).waitFor();
|
||||
await page.getByText('In Production: 24').waitFor();
|
||||
|
||||
// Check requirements details
|
||||
await page.getByText('204 / 209').waitFor(); // Available stock
|
||||
await page.getByText('0 / 100').waitFor(); // Allocated to build orders
|
||||
await page.getByText(/0 \/ 1\d+/).waitFor(); // Allocated to build orders
|
||||
await page.getByText('5 / 175').waitFor(); // Allocated to sales orders
|
||||
await page.getByText('24 / 214').waitFor(); // In production
|
||||
await page.getByText(/24 \/ 2\d+/).waitFor(); // In production
|
||||
|
||||
// Let's check out the "variants" for this part, too
|
||||
await navigate(page, 'part/81/details'); // WID-REV-A
|
||||
@@ -404,12 +404,10 @@ test('Parts - Pricing (Variant)', async ({ browser }) => {
|
||||
await loadTab(page, 'Part Pricing');
|
||||
await page.getByLabel('Part Pricing').getByText('Part Pricing').waitFor();
|
||||
await page.getByRole('button', { name: 'Pricing Overview' }).waitFor();
|
||||
await page.getByText('Last Updated').waitFor();
|
||||
await page.getByRole('button', { name: 'Internal Pricing' }).isDisabled();
|
||||
await page.getByText('Last Updated').first().waitFor();
|
||||
await page.getByRole('button', { name: 'Internal Pricing' }).isEnabled();
|
||||
await page.getByRole('button', { name: 'BOM Pricing' }).isEnabled();
|
||||
await page.getByRole('button', { name: 'Variant Pricing' }).isEnabled();
|
||||
await page.getByRole('button', { name: 'Sale Pricing' }).isDisabled();
|
||||
await page.getByRole('button', { name: 'Sale History' }).isDisabled();
|
||||
|
||||
// Variant Pricing
|
||||
await page.getByRole('button', { name: 'Variant Pricing' }).click();
|
||||
@@ -552,7 +550,7 @@ test('Parts - Parameter Filtering', async ({ browser }) => {
|
||||
await clearTableFilters(page);
|
||||
|
||||
// All parts should be available (no filters applied)
|
||||
await page.getByText('/ 425').waitFor();
|
||||
await page.getByText(/\/ 42\d/).waitFor();
|
||||
|
||||
const clickOnParamFilter = async (name: string) => {
|
||||
const button = await page
|
||||
@@ -580,7 +578,7 @@ test('Parts - Parameter Filtering', async ({ browser }) => {
|
||||
// Reset the filter
|
||||
await clearParamFilter('Color');
|
||||
|
||||
await page.getByText('/ 425').waitFor();
|
||||
await page.getByText(/\/ 42\d/).waitFor();
|
||||
});
|
||||
|
||||
test('Parts - Notes', async ({ browser }) => {
|
||||
@@ -620,7 +618,7 @@ test('Parts - Revision', async ({ browser }) => {
|
||||
.getByText('Green Round Table (revision B) | B', { exact: true })
|
||||
.click();
|
||||
await page
|
||||
.getByRole('option', { name: 'Thumbnail Green Round Table Virtual' })
|
||||
.getByRole('option', { name: 'Thumbnail Green Round Table No stock' })
|
||||
.click();
|
||||
|
||||
await page.waitForURL('**/web/part/101/**');
|
||||
|
||||
@@ -225,7 +225,7 @@ test('Sales Orders - Shipments', async ({ browser }) => {
|
||||
|
||||
test('Sales Orders - Duplicate', async ({ browser }) => {
|
||||
const page = await doCachedLogin(browser, {
|
||||
url: 'sales/sales-order/11/detail'
|
||||
url: 'sales/sales-order/14/detail'
|
||||
});
|
||||
|
||||
await page.getByLabel('action-menu-order-actions').click();
|
||||
@@ -243,4 +243,39 @@ test('Sales Orders - Duplicate', async ({ browser }) => {
|
||||
await page.getByRole('tab', { name: 'Order Details' }).click();
|
||||
|
||||
await page.getByText('Pending').first().waitFor();
|
||||
|
||||
// Issue the order
|
||||
await page.getByRole('button', { name: 'Issue Order' }).click();
|
||||
await page.getByRole('button', { name: 'Submit' }).click();
|
||||
await page.getByText('In Progress').first().waitFor();
|
||||
|
||||
// Cancel the outstanding shipment
|
||||
await loadTab(page, 'Shipments');
|
||||
await clearTableFilters(page);
|
||||
const cell = await page.getByRole('cell', { name: '1', exact: true });
|
||||
await clickOnRowMenu(cell);
|
||||
await page.getByRole('menuitem', { name: 'Cancel' }).click();
|
||||
await page.getByRole('button', { name: 'Delete' }).click();
|
||||
|
||||
// Check for expected line items
|
||||
await loadTab(page, 'Line Items');
|
||||
await page.getByRole('cell', { name: 'SW-001' }).waitFor();
|
||||
await page.getByRole('cell', { name: 'SW-002' }).waitFor();
|
||||
await page.getByText('1 - 2 / 2').waitFor();
|
||||
|
||||
// Ship the order
|
||||
await page.getByRole('button', { name: 'Ship Order' }).click();
|
||||
await page.getByRole('button', { name: 'Submit' }).click();
|
||||
|
||||
// Complete the order
|
||||
await page.getByRole('button', { name: 'Complete Order' }).click();
|
||||
await page.getByRole('button', { name: 'Submit' }).click();
|
||||
|
||||
// Go to the "details" tab
|
||||
await loadTab(page, 'Order Details');
|
||||
|
||||
// Check for expected results
|
||||
// 2 line items completed, as they are both virtual (no stock)
|
||||
await page.getByText('Complete').first().waitFor();
|
||||
await page.getByText('2 / 2').waitFor();
|
||||
});
|
||||
|
||||
@@ -98,7 +98,7 @@ test('Forms - Supplier Validation', async ({ browser }) => {
|
||||
// Check for validation errors
|
||||
await page.getByText('Form Error').waitFor();
|
||||
await page.getByText('Errors exist for one or more').waitFor();
|
||||
await page.getByText('This field may not be blank.').waitFor();
|
||||
await page.getByText('This field is required').waitFor();
|
||||
await page.getByText('Enter a valid URL.').waitFor();
|
||||
|
||||
// Fill out another field, expect that the errors persist
|
||||
@@ -106,7 +106,7 @@ test('Forms - Supplier Validation', async ({ browser }) => {
|
||||
.getByLabel('text-field-description', { exact: true })
|
||||
.fill('A description');
|
||||
await page.waitForTimeout(250);
|
||||
await page.getByText('This field may not be blank.').waitFor();
|
||||
await page.getByText('This field is required').waitFor();
|
||||
await page.getByText('Enter a valid URL.').waitFor();
|
||||
|
||||
// Generate a unique supplier name
|
||||
|
||||
@@ -111,9 +111,15 @@ test('Importing - BOM', async ({ browser }) => {
|
||||
|
||||
// Delete selected rows
|
||||
await page
|
||||
.getByRole('dialog', { name: 'Importing Data Upload File 2' })
|
||||
.getByRole('dialog', { name: 'Importing Data Upload File' })
|
||||
.getByLabel('action-button-delete-selected')
|
||||
.waitFor();
|
||||
await page.waitForTimeout(200);
|
||||
await page
|
||||
.getByRole('dialog', { name: 'Importing Data Upload File' })
|
||||
.getByLabel('action-button-delete-selected')
|
||||
.click();
|
||||
|
||||
await page.getByRole('button', { name: 'Delete', exact: true }).click();
|
||||
|
||||
await page.getByText('Success', { exact: true }).waitFor();
|
||||
|
||||
Reference in New Issue
Block a user