Compare commits

...

16 Commits
1.1.2 ... 1.1.4

Author SHA1 Message Date
github-actions[bot]
295c4f3e5d [bug] Serialize location (#10882) (#10883)
* Properly set location id when serializing stock

* Add correct tracking entries

* Add unit test

(cherry picked from commit a7ff1250ba)

Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
2025-11-22 13:44:48 +11:00
github-actions[bot]
c6ecd019dc [UI] Delete stock fix (#10868) (#10869)
* Add helper func getOverviewUrl

* Redirect to parent page when stock item is counted to zero

(cherry picked from commit 468efbacfc)

Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
2025-11-20 06:48:45 +11:00
github-actions[bot]
005d9850b8 Fix for shipping virtual parts (#10853) (#10864)
* Additional checks for virtual parts in sales order process

* Prevent allocation against virtual parts

* Fix order of operations

* Adjust part form fields based on selections

* Prevent order locking

* Updated playwright tests

* Add unit test

(cherry picked from commit 7b38fa30bb)

Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
2025-11-19 15:49:38 +11:00
github-actions[bot]
a585f5407a Bug fix for sales order pricing (#10858) (#10863)
* Bug fix for sales order pricing

- Clear sale price field if no pricing

* Adjust playwright tests

(cherry picked from commit d06d80fb99)

Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
2025-11-19 15:12:16 +11:00
github-actions[bot]
974a7d5510 [UI] Remove duplicate action (#10844) (#10845)
- "Build Output" is same as "stock item" in this case

(cherry picked from commit 57a2de6ffc)

Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
2025-11-17 19:58:54 +11:00
github-actions[bot]
39623ddf98 [UI] Fix for form OPTIONS query (#10840) (#10843)
* [UI] Fix for form OPTIONS query

- Fetch OPTIONs each time form is opened
- Ensure default values are filled correctly
- Prevent issues with latching form state

* Add comment

* Add playwright test

- Check that the reference field increments properly

* Fix other Playwright tests

(cherry picked from commit 770f7a292e)

Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
2025-11-16 22:07:59 +11:00
github-actions[bot]
1890589a43 [bug] State change fixes (#10832) (#10839)
* Fix for setting custom status

* Fix for setting custom status when receiving stock items

* Allow caching for set_status

* Updated code and unit tests

(cherry picked from commit aa9958bf11)

Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
2025-11-15 08:01:21 +11:00
github-actions[bot]
8cbce3f335 Char fix (#10827) (#10830)
* Remove debouncing from text field

* Add debounce to data import field

* Only apply for strings values

* Fix unit test

* More unit test tweaks

(cherry picked from commit ba9b5438b4)

Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
2025-11-14 17:53:27 +11:00
github-actions[bot]
56f09e1aa6 Bug fix for loading boolean settings (#10826) (#10828)
- Do not just cast to bool
- The string "False" casts to True in this case
- Use the function that supports strings

(cherry picked from commit 8cb808f613)

Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
2025-11-14 08:27:42 +11:00
Oliver
a1a2a47bba Bug fix for pack quantity display (#10810) 2025-11-12 00:45:44 +11:00
github-actions[bot]
e2eeaa991d PO receive fix (#10807) (#10808)
* Extract note field when receiving stock items against PO

* Fix tracking entry when receiving item

(cherry picked from commit f3c1cc12af)

Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
2025-11-11 21:15:13 +11:00
github-actions[bot]
4bb1354b68 Fix for pricing display (#10804) (#10805)
(cherry picked from commit 77f80385c9)

Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
2025-11-11 16:27:24 +11:00
Matthias Mair
68e3216b7b [1.1.x] fix(backend): auth check middleware for specific media access (#10784) (#10795)
* fix(backend): auth check middleware for specific media access (#10784)

* simplify

* fix return type

* handle token (app access)

* reduce lookup amount

* add positive test again

* add poisitive test

* move out settings

* add tests for Check2FAMiddleware

* add test for auth_request

* add a reverse name for auth_request

* auth tests refactors

* move test

* disable check for things that do not trigger

* fix typing for python 3.9

* make names clearer and add comments

* finish tests

* fix call

* re-enable mfa test without the timing component

* cleanup helper

* ignore easy out

* ignore scenario that can not happen

(cherry picked from commit f3e8482469)

* fix merge
2025-11-10 09:52:30 +11:00
Matthias Mair
1573d5ff40 Bump software version to 1.1.4 (#10792) 2025-11-09 17:08:15 +11:00
github-actions[bot]
89287d56ff chore(deps): bump django from 4.2.25 to 4.2.26 in /src/backend (#10781) (#10790)
* chore(deps): bump django from 4.2.25 to 4.2.26 in /src/backend

Bumps [django](https://github.com/django/django) from 4.2.25 to 4.2.26.
- [Commits](https://github.com/django/django/compare/4.2.25...4.2.26)

---
updated-dependencies:
- dependency-name: django
  dependency-version: 4.2.26
  dependency-type: direct:production
...



* fix style

* also bump docker version

---------




(cherry picked from commit 726e852b7b)

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Matthias Mair <code@mjmair.com>
2025-11-08 22:10:19 +01:00
Oliver
1885caa744 Bump InvenTree software version to 1.1.3 (#10762) 2025-11-04 11:00:09 +11:00
40 changed files with 699 additions and 276 deletions

View File

@@ -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

View File

@@ -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:

View 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

View File

@@ -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

View File

@@ -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.

View File

@@ -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)

View File

@@ -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."""

View File

@@ -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

View File

@@ -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)),

View File

@@ -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')

View File

@@ -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)

View File

@@ -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'

View File

@@ -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,

View File

@@ -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()

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -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()

View File

@@ -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."""

View File

@@ -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

View File

@@ -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

View File

@@ -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.

View File

@@ -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 };

View File

@@ -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);
}}

View File

@@ -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);
}
};
}

View File

@@ -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]);
}
/**

View File

@@ -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(() => {

View File

@@ -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>
)
});

View File

@@ -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} />
)
}}
/>
);
}

View File

@@ -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
}

View File

@@ -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';

View File

@@ -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`}
/>

View File

@@ -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();

View File

@@ -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);

View File

@@ -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/**');

View File

@@ -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();
});

View File

@@ -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

View File

@@ -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();