mirror of
https://github.com/inventree/InvenTree.git
synced 2025-12-17 20:35:01 -06:00
Compare commits
32 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1d2700fa6f | ||
|
|
9c170e6ed3 | ||
|
|
542a75ce58 | ||
|
|
06750358d6 | ||
|
|
6ccc4544be | ||
|
|
ac324cff14 | ||
|
|
98b1678402 | ||
|
|
da3ebacf6b | ||
|
|
24b2401f6a | ||
|
|
d12c335032 | ||
|
|
eae580deaf | ||
|
|
6207aa6c32 | ||
|
|
5ff7ce4703 | ||
|
|
0350623866 | ||
|
|
bb576b16d8 | ||
|
|
9705ea90c2 | ||
|
|
39dc2b17fd | ||
|
|
00646b0891 | ||
|
|
2b7627a940 | ||
|
|
048ece4797 | ||
|
|
0feb542899 | ||
|
|
4f9c42cbc2 | ||
|
|
f8310c268c | ||
|
|
91c134f752 | ||
|
|
a57676faef | ||
|
|
0de265d954 | ||
|
|
f4fdad5d48 | ||
|
|
73d6da8c13 | ||
|
|
c108fd016d | ||
|
|
2c95c3db29 | ||
|
|
8571a42981 | ||
|
|
a916e501c3 |
210
.github/scripts/version_check.py
vendored
210
.github/scripts/version_check.py
vendored
@@ -10,6 +10,7 @@ tagged branch:
|
||||
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import itertools
|
||||
import json
|
||||
import os
|
||||
@@ -23,7 +24,93 @@ REPO = os.getenv('GITHUB_REPOSITORY', 'inventree/inventree')
|
||||
GITHUB_API_URL = os.getenv('GITHUB_API_URL', 'https://api.github.com')
|
||||
|
||||
|
||||
def get_existing_release_tags(include_prerelease=True):
|
||||
def get_src_dir() -> Path:
|
||||
"""Return the path to the InvenTree source directory."""
|
||||
here = Path(__file__).parent.absolute()
|
||||
src_dir = here.joinpath('..', '..', 'src', 'backend', 'InvenTree', 'InvenTree')
|
||||
|
||||
if not src_dir.exists():
|
||||
raise FileNotFoundError(
|
||||
f"Could not find InvenTree source directory: '{src_dir}'"
|
||||
)
|
||||
|
||||
return src_dir
|
||||
|
||||
|
||||
def get_inventree_version() -> str:
|
||||
"""Return the InvenTree version string."""
|
||||
src_dir = get_src_dir()
|
||||
version_file = src_dir.joinpath('version.py')
|
||||
|
||||
if not version_file.exists():
|
||||
raise FileNotFoundError(
|
||||
f"Could not find InvenTree version file: '{version_file}'"
|
||||
)
|
||||
|
||||
with open(version_file, encoding='utf-8') as f:
|
||||
text = f.read()
|
||||
|
||||
# Extract the InvenTree software version
|
||||
results = re.findall(r"""INVENTREE_SW_VERSION = '(.*)'""", text)
|
||||
|
||||
if len(results) != 1:
|
||||
raise ValueError(f'Could not find INVENTREE_SW_VERSION in {version_file}')
|
||||
|
||||
return results[0]
|
||||
|
||||
|
||||
def get_api_version() -> str:
|
||||
"""Return the InvenTree API version string."""
|
||||
src_dir = get_src_dir()
|
||||
api_version_file = src_dir.joinpath('api_version.py')
|
||||
|
||||
if not api_version_file.exists():
|
||||
raise FileNotFoundError(
|
||||
f"Could not find InvenTree API version file: '{api_version_file}'"
|
||||
)
|
||||
|
||||
with open(api_version_file, encoding='utf-8') as f:
|
||||
text = f.read()
|
||||
|
||||
# Extract the InvenTree software version
|
||||
results = re.findall(r"""INVENTREE_API_VERSION = (.*)""", text)
|
||||
|
||||
if len(results) != 1:
|
||||
raise ValueError(
|
||||
f'Could not find INVENTREE_API_VERSION in {api_version_file}'
|
||||
)
|
||||
|
||||
return results[0].strip().strip('"').strip("'")
|
||||
|
||||
|
||||
def version_number_to_tuple(version_string: str) -> tuple[int, int, int, str]:
|
||||
"""Validate a version number string, and convert to a tuple of integers.
|
||||
|
||||
e.g. 1.1.0
|
||||
e.g. 1.1.0 dev
|
||||
e.g. 1.2.3-rc2
|
||||
"""
|
||||
pattern = r'^(\d+)\.(\d+)\.(\d+)[\s-]?(.*)?$'
|
||||
|
||||
match = re.match(pattern, version_string)
|
||||
|
||||
if not match or len(match.groups()) < 3:
|
||||
raise ValueError(
|
||||
f"Version string '{version_string}' did not match required pattern"
|
||||
)
|
||||
|
||||
result = tuple(int(x) for x in match.groups()[:3])
|
||||
|
||||
# Add optional prerelease tag
|
||||
if len(match.groups()) > 3:
|
||||
result += (match.groups()[3] or '',)
|
||||
else:
|
||||
result += ('',)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def get_existing_release_tags(include_prerelease: bool = True):
|
||||
"""Request information on existing releases via the GitHub API."""
|
||||
# Check for github token
|
||||
token = os.getenv('GITHUB_TOKEN', None)
|
||||
@@ -46,16 +133,16 @@ def get_existing_release_tags(include_prerelease=True):
|
||||
|
||||
for release in data:
|
||||
tag = release['tag_name'].strip()
|
||||
match = re.match(r'^.*(\d+)\.(\d+)\.(\d+).*$', tag)
|
||||
|
||||
if len(match.groups()) != 3:
|
||||
print(f"Version '{tag}' did not match expected pattern")
|
||||
continue
|
||||
version_tuple = version_number_to_tuple(tag)
|
||||
|
||||
if not include_prerelease and release['prerelease']:
|
||||
continue
|
||||
if len(version_tuple) >= 4 and version_tuple[3]:
|
||||
# Skip prerelease tags
|
||||
if not include_prerelease:
|
||||
print('-- skipping prerelease tag:', tag)
|
||||
continue
|
||||
|
||||
tags.append([int(x) for x in match.groups()])
|
||||
tags.append(tag)
|
||||
|
||||
return tags
|
||||
|
||||
@@ -67,15 +154,7 @@ def check_version_number(version_string, allow_duplicate=False):
|
||||
"""
|
||||
print(f"Checking version '{version_string}'")
|
||||
|
||||
# Check that the version string matches the required format
|
||||
match = re.match(r'^(\d+)\.(\d+)\.(\d+)(?: dev)?$', version_string)
|
||||
|
||||
if not match or len(match.groups()) != 3:
|
||||
raise ValueError(
|
||||
f"Version string '{version_string}' did not match required pattern"
|
||||
)
|
||||
|
||||
version_tuple = [int(x) for x in match.groups()]
|
||||
version_tuple = version_number_to_tuple(version_string)
|
||||
|
||||
# Look through the existing releases
|
||||
existing = get_existing_release_tags(include_prerelease=False)
|
||||
@@ -83,35 +162,67 @@ def check_version_number(version_string, allow_duplicate=False):
|
||||
# Assume that this is the highest release, unless told otherwise
|
||||
highest_release = True
|
||||
|
||||
# A non-standard tag cannot be the 'highest' release
|
||||
if len(version_tuple) >= 4 and version_tuple[3]:
|
||||
highest_release = False
|
||||
print(f"-- Version tag '{version_string}' cannot be the highest release")
|
||||
|
||||
for release in existing:
|
||||
if release == version_tuple and not allow_duplicate:
|
||||
if version_string == release and not allow_duplicate:
|
||||
raise ValueError(f"Duplicate release '{version_string}' exists!")
|
||||
|
||||
if release > version_tuple:
|
||||
release_tuple = version_number_to_tuple(release)
|
||||
|
||||
if release_tuple > version_tuple:
|
||||
highest_release = False
|
||||
print(f'Found newer release: {release!s}')
|
||||
|
||||
if highest_release:
|
||||
print(f"-- Version '{version_string}' is the highest release")
|
||||
|
||||
return highest_release
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
parser = argparse.ArgumentParser(description='InvenTree Version Check')
|
||||
parser.add_argument(
|
||||
'--show-version',
|
||||
action='store_true',
|
||||
help='Print the InvenTree version and exit',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--show-api-version',
|
||||
action='store_true',
|
||||
help='Print the InvenTree API version and exit',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--decrement-api',
|
||||
type=str,
|
||||
default='false',
|
||||
help='Decrement the API version by 1 and print',
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
inventree_version = get_inventree_version()
|
||||
inventree_api_version = int(get_api_version())
|
||||
|
||||
if args.show_version:
|
||||
print(inventree_version)
|
||||
sys.exit(0)
|
||||
|
||||
if args.show_api_version:
|
||||
if str(args.decrement_api).strip().lower() == 'true':
|
||||
inventree_api_version -= 1
|
||||
print(inventree_api_version)
|
||||
sys.exit(0)
|
||||
|
||||
# Ensure that we are running in GH Actions
|
||||
if os.environ.get('GITHUB_ACTIONS', '') != 'true':
|
||||
print('This script is intended to be run within a GitHub Action!')
|
||||
sys.exit(1)
|
||||
|
||||
if 'only_version' in sys.argv:
|
||||
here = Path(__file__).parent.absolute()
|
||||
version_file = here.joinpath(
|
||||
'..', '..', 'src', 'backend', 'InvenTree', 'InvenTree', 'api_version.py'
|
||||
)
|
||||
text = version_file.read_text()
|
||||
results = re.findall(r"""INVENTREE_API_VERSION = (.*)""", text)
|
||||
# If 2. args is true lower the version number by 1
|
||||
if len(sys.argv) > 2 and sys.argv[2] == 'true':
|
||||
results[0] = str(int(results[0]) - 1)
|
||||
print(results[0])
|
||||
exit(0)
|
||||
print('Running InvenTree version check...')
|
||||
|
||||
# GITHUB_REF_TYPE may be either 'branch' or 'tag'
|
||||
GITHUB_REF_TYPE = os.environ['GITHUB_REF_TYPE']
|
||||
@@ -127,26 +238,10 @@ if __name__ == '__main__':
|
||||
print(f'GITHUB_REF_TYPE: {GITHUB_REF_TYPE}')
|
||||
print(f'GITHUB_BASE_REF: {GITHUB_BASE_REF}')
|
||||
|
||||
here = Path(__file__).parent.absolute()
|
||||
version_file = here.joinpath(
|
||||
'..', '..', 'src', 'backend', 'InvenTree', 'InvenTree', 'version.py'
|
||||
print(
|
||||
f"InvenTree Version: '{inventree_version}' - {version_number_to_tuple(inventree_version)}"
|
||||
)
|
||||
|
||||
version = None
|
||||
|
||||
with open(version_file, encoding='utf-8') as f:
|
||||
text = f.read()
|
||||
|
||||
# Extract the InvenTree software version
|
||||
results = re.findall(r"""INVENTREE_SW_VERSION = '(.*)'""", text)
|
||||
|
||||
if len(results) != 1:
|
||||
print(f'Could not find INVENTREE_SW_VERSION in {version_file}')
|
||||
sys.exit(1)
|
||||
|
||||
version = results[0]
|
||||
|
||||
print(f"InvenTree Version: '{version}'")
|
||||
print(f"InvenTree API Version: '{inventree_api_version}'")
|
||||
|
||||
# Check version number and look for existing versions
|
||||
# If a release is found which matches the current tag, throw an error
|
||||
@@ -161,7 +256,9 @@ if __name__ == '__main__':
|
||||
if GITHUB_BASE_REF == 'stable':
|
||||
allow_duplicate = True
|
||||
|
||||
highest_release = check_version_number(version, allow_duplicate=allow_duplicate)
|
||||
highest_release = check_version_number(
|
||||
inventree_version, allow_duplicate=allow_duplicate
|
||||
)
|
||||
|
||||
# Determine which docker tag we are going to use
|
||||
docker_tags = None
|
||||
@@ -171,8 +268,10 @@ if __name__ == '__main__':
|
||||
version_tag = GITHUB_REF.split('/')[-1]
|
||||
print(f"Checking requirements for tagged release - '{version_tag}':")
|
||||
|
||||
if version_tag != version:
|
||||
print(f"Version number '{version}' does not match tag '{version_tag}'")
|
||||
if version_tag != inventree_version:
|
||||
print(
|
||||
f"Version number '{inventree_version}' does not match tag '{version_tag}'"
|
||||
)
|
||||
sys.exit
|
||||
|
||||
docker_tags = [version_tag, 'stable'] if highest_release else [version_tag]
|
||||
@@ -180,10 +279,11 @@ if __name__ == '__main__':
|
||||
elif GITHUB_REF_TYPE == 'branch':
|
||||
# Otherwise we know we are targeting the 'master' branch
|
||||
docker_tags = ['latest']
|
||||
highest_release = False
|
||||
|
||||
else:
|
||||
print('Unsupported branch / version combination:')
|
||||
print(f'InvenTree Version: {version}')
|
||||
print(f'InvenTree Version: {inventree_version}')
|
||||
print('GITHUB_REF_TYPE:', GITHUB_REF_TYPE)
|
||||
print('GITHUB_BASE_REF:', GITHUB_BASE_REF)
|
||||
print('GITHUB_REF:', GITHUB_REF)
|
||||
@@ -193,7 +293,7 @@ if __name__ == '__main__':
|
||||
print('Docker tags could not be determined')
|
||||
sys.exit(1)
|
||||
|
||||
print(f"Version check passed for '{version}'!")
|
||||
print(f"Version check passed for '{inventree_version}'!")
|
||||
print(f"Docker tags: '{docker_tags}'")
|
||||
|
||||
target_repos = [REPO.lower(), f'ghcr.io/{REPO.lower()}']
|
||||
|
||||
8
.github/workflows/qc_checks.yaml
vendored
8
.github/workflows/qc_checks.yaml
vendored
@@ -164,8 +164,8 @@ jobs:
|
||||
API: ${{ needs.paths-filter.outputs.api }}
|
||||
run: |
|
||||
pip install --require-hashes -r contrib/dev_reqs/requirements.txt >/dev/null 2>&1
|
||||
version="$(python3 .github/scripts/version_check.py only_version ${API} 2>&1)"
|
||||
echo "Version: $version"
|
||||
version="$(python3 .github/scripts/version_check.py --show-api-version --decrement-api=${API} 2>&1)"
|
||||
echo "API Version: $version"
|
||||
url="https://raw.githubusercontent.com/inventree/schema/main/export/${version}/api.yaml"
|
||||
echo "URL: $url"
|
||||
code=$(curl -s -o api.yaml $url --write-out '%{http_code}' --silent)
|
||||
@@ -198,8 +198,8 @@ jobs:
|
||||
if: github.ref == 'refs/heads/master' && needs.paths-filter.outputs.api == 'true'
|
||||
run: |
|
||||
pip install --require-hashes -r contrib/dev_reqs/requirements.txt >/dev/null 2>&1
|
||||
version="$(python3 .github/scripts/version_check.py only_version 2>&1)"
|
||||
echo "Version: $version"
|
||||
version="$(python3 .github/scripts/version_check.py --show-api-version 2>&1)"
|
||||
echo "API Version: $version"
|
||||
echo "version=$version" >> "$GITHUB_OUTPUT"
|
||||
- name: Extract settings / tags
|
||||
run: invoke int.export-definitions --basedir docs
|
||||
|
||||
@@ -305,8 +305,8 @@ class InfoView(APIView):
|
||||
'login_message': helpers.getCustomOption('login_message'),
|
||||
'navbar_message': helpers.getCustomOption('navbar_message'),
|
||||
},
|
||||
'active_plugins': plugins_info(),
|
||||
# Following fields are only available to staff users
|
||||
'active_plugins': plugins_info() if is_staff else None,
|
||||
'system_health': check_system_health() if is_staff else None,
|
||||
'database': InvenTree.version.inventreeDatabase() if is_staff else None,
|
||||
'platform': InvenTree.version.inventreePlatform() if is_staff else None,
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
"""InvenTree API version information."""
|
||||
|
||||
# InvenTree API version
|
||||
INVENTREE_API_VERSION = 390
|
||||
INVENTREE_API_VERSION = 391
|
||||
|
||||
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
|
||||
|
||||
INVENTREE_API_TEXT = """
|
||||
|
||||
v391 -> 2025-09-06 : https://github.com/inventree/InvenTree/pull/10279
|
||||
- Refactors 'exclude_tree', 'cascade', and 'location' filters in StockList API endpoint
|
||||
|
||||
v390 -> 2025-09-03 : https://github.com/inventree/InvenTree/pull/10257
|
||||
- Fixes limitation on adding virtual parts to a SalesOrder
|
||||
- Additional query filter options for BomItem API endpoint
|
||||
|
||||
@@ -237,11 +237,18 @@ class InvenTreeHostSettingsMiddleware(MiddlewareMixin):
|
||||
# Ensure that the settings are set correctly with the current request
|
||||
accessed_scheme = request._current_scheme_host
|
||||
if accessed_scheme and not accessed_scheme.startswith(settings.SITE_URL):
|
||||
msg = f'INVE-E7: The used path `{accessed_scheme}` does not match the SITE_URL `{settings.SITE_URL}`'
|
||||
logger.error(msg)
|
||||
return render(
|
||||
request, 'config_error.html', {'error_message': msg}, status=500
|
||||
)
|
||||
if (
|
||||
isinstance(settings.CSRF_TRUSTED_ORIGINS, list)
|
||||
and len(settings.CSRF_TRUSTED_ORIGINS) > 1
|
||||
):
|
||||
# The used url might not be the primary url - next check determines if in a trusted origins
|
||||
pass
|
||||
else:
|
||||
msg = f'INVE-E7: The used path `{accessed_scheme}` does not match the SITE_URL `{settings.SITE_URL}`'
|
||||
logger.error(msg)
|
||||
return render(
|
||||
request, 'config_error.html', {'error_message': msg}, status=500
|
||||
)
|
||||
|
||||
# Check trusted origins
|
||||
referer = urlsplit(accessed_scheme)
|
||||
|
||||
@@ -605,9 +605,7 @@ class GeneralApiTests(InvenTreeAPITestCase):
|
||||
response = self.get(url, max_query_count=20)
|
||||
data = response.json()
|
||||
self.assertEqual(data['database'], None)
|
||||
|
||||
# No active plugin info for anon user
|
||||
self.assertIsNone(data.get('active_plugins'))
|
||||
self.assertIsNotNone(data.get('active_plugins'))
|
||||
|
||||
# Staff
|
||||
response = self.get(
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Tests for middleware functions."""
|
||||
|
||||
from django.conf import settings
|
||||
from django.http import Http404
|
||||
from django.urls import reverse
|
||||
|
||||
@@ -87,36 +88,72 @@ class MiddlewareTests(InvenTreeTestCase):
|
||||
log_error('testpath')
|
||||
check(1)
|
||||
|
||||
def do_positive_test(self, response):
|
||||
"""Helper function to check for positive test results."""
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertNotContains(response, 'INVE-E7')
|
||||
self.assertContains(response, 'window.INVENTREE_SETTINGS')
|
||||
|
||||
def test_site_url_checks(self):
|
||||
"""Test that the site URL check is correctly working."""
|
||||
# correctly set
|
||||
# simple setup
|
||||
with self.settings(
|
||||
SITE_URL='http://testserver', CSRF_TRUSTED_ORIGINS=['http://testserver']
|
||||
):
|
||||
response = self.client.get(reverse('web'))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertNotContains(response, 'INVE-E7')
|
||||
self.assertContains(response, 'window.INVENTREE_SETTINGS')
|
||||
self.do_positive_test(response)
|
||||
|
||||
# wrongly set site URL
|
||||
with self.settings(SITE_URL='https://example.com'):
|
||||
# simple setup with wildcard
|
||||
with self.settings(
|
||||
SITE_URL='http://testserver', CSRF_TRUSTED_ORIGINS=['http://*.testserver']
|
||||
):
|
||||
response = self.client.get(reverse('web'))
|
||||
self.assertEqual(response.status_code, 500)
|
||||
self.assertContains(
|
||||
response,
|
||||
'INVE-E7: The used path `http://testserver` does not match',
|
||||
status_code=500,
|
||||
)
|
||||
self.assertNotContains(
|
||||
response, 'window.INVENTREE_SETTINGS', status_code=500
|
||||
)
|
||||
self.do_positive_test(response)
|
||||
|
||||
def test_site_url_checks_multi(self):
|
||||
"""Test that the site URL check is correctly working in a multi-site setup."""
|
||||
# multi-site setup with trusted origins
|
||||
with self.settings(
|
||||
SITE_URL='https://testserver.example.com',
|
||||
CSRF_TRUSTED_ORIGINS=[
|
||||
'http://testserver',
|
||||
'https://testserver.example.com',
|
||||
],
|
||||
):
|
||||
# this will run with testserver as host by default
|
||||
response = self.client.get(reverse('web'))
|
||||
self.do_positive_test(response)
|
||||
|
||||
# Now test with the "outside" url name
|
||||
response = self.client.get(
|
||||
'https://testserver.example.com/web/',
|
||||
SERVER_NAME='testserver.example.com',
|
||||
)
|
||||
self.do_positive_test(response)
|
||||
|
||||
# A non-trsuted origin must still fail in multi - origin setup
|
||||
response = self.client.get(
|
||||
'https://not-my-testserver.example.com/web/',
|
||||
SERVER_NAME='not-my-testserver.example.com',
|
||||
)
|
||||
self.assertEqual(response.status_code, 500)
|
||||
|
||||
# Even if it is a subdomain
|
||||
response = self.client.get(
|
||||
'https://not-my.testserver.example.com/web/',
|
||||
SERVER_NAME='not-my.testserver.example.com',
|
||||
)
|
||||
self.assertEqual(response.status_code, 500)
|
||||
|
||||
def test_site_url_checks_fails(self):
|
||||
"""Test that the site URL check is correctly failing.
|
||||
|
||||
Important for security.
|
||||
"""
|
||||
# wrongly set but in debug -> is ignored
|
||||
with self.settings(SITE_URL='https://example.com', DEBUG=True):
|
||||
response = self.client.get(reverse('web'))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertNotContains(response, 'INVE-E7')
|
||||
self.assertContains(response, 'window.INVENTREE_SETTINGS')
|
||||
self.do_positive_test(response)
|
||||
|
||||
# wrongly set cors
|
||||
with self.settings(
|
||||
@@ -133,10 +170,32 @@ class MiddlewareTests(InvenTreeTestCase):
|
||||
response, 'window.INVENTREE_SETTINGS', status_code=500
|
||||
)
|
||||
|
||||
# wrongly set site URL
|
||||
with self.settings(
|
||||
SITE_URL='http://testserver', CSRF_TRUSTED_ORIGINS=['http://*.testserver']
|
||||
SITE_URL='https://example.com',
|
||||
CSRF_TRUSTED_ORIGINS=['http://localhost:8000'],
|
||||
):
|
||||
response = self.client.get(reverse('web'))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertNotContains(response, 'INVE-E7')
|
||||
self.assertContains(response, 'window.INVENTREE_SETTINGS')
|
||||
self.assertEqual(response.status_code, 500)
|
||||
self.assertContains(
|
||||
response, 'INVE-E7: The used path `http://testserver` ', status_code=500
|
||||
)
|
||||
self.assertNotContains(
|
||||
response, 'window.INVENTREE_SETTINGS', status_code=500
|
||||
)
|
||||
|
||||
# Log stuff # TODO remove
|
||||
print(
|
||||
'###DBG-TST###',
|
||||
'site',
|
||||
settings.SITE_URL,
|
||||
'trusted',
|
||||
settings.CSRF_TRUSTED_ORIGINS,
|
||||
)
|
||||
|
||||
# Check that the correct step triggers the error message
|
||||
self.assertContains(
|
||||
response,
|
||||
'INVE-E7: The used path `http://testserver` does not match',
|
||||
status_code=500,
|
||||
)
|
||||
|
||||
@@ -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 = '0.18.0 dev'
|
||||
INVENTREE_SW_VERSION = '1.0.2'
|
||||
|
||||
|
||||
logger = logging.getLogger('inventree')
|
||||
|
||||
@@ -480,8 +480,8 @@ class BuildLineFilter(rest_filters.FilterSet):
|
||||
def filter_allocated(self, queryset, name, value):
|
||||
"""Filter by whether each BuildLine is fully allocated."""
|
||||
if str2bool(value):
|
||||
return queryset.filter(allocated__gte=F('quantity'))
|
||||
return queryset.filter(allocated__lt=F('quantity'))
|
||||
return queryset.filter(allocated__gte=F('quantity') - F('consumed'))
|
||||
return queryset.filter(allocated__lt=F('quantity') - F('consumed'))
|
||||
|
||||
consumed = rest_filters.BooleanFilter(label=_('Consumed'), method='filter_consumed')
|
||||
|
||||
|
||||
@@ -1,21 +1,22 @@
|
||||
"""Queryset filtering helper functions for the Build app."""
|
||||
|
||||
from django.db import models
|
||||
from django.db.models import Q, Sum
|
||||
from django.db.models.functions import Coalesce
|
||||
from django.db.models import DecimalField, ExpressionWrapper, F, Max, Sum
|
||||
from django.db.models.functions import Coalesce, Greatest
|
||||
|
||||
|
||||
def annotate_allocated_quantity(queryset: Q) -> Q:
|
||||
"""Annotate the 'allocated' quantity for each build item in the queryset.
|
||||
|
||||
Arguments:
|
||||
queryset: The BuildLine queryset to annotate
|
||||
|
||||
"""
|
||||
queryset = queryset.prefetch_related('allocations')
|
||||
|
||||
return queryset.annotate(
|
||||
allocated=Coalesce(
|
||||
Sum('allocations__quantity'), 0, output_field=models.DecimalField()
|
||||
)
|
||||
def annotate_required_quantity():
|
||||
"""Annotate the 'required' quantity for each build item in the queryset."""
|
||||
# Note: The use of Max() here is intentional, to avoid aggregation issues in MySQL
|
||||
# Ref: https://github.com/inventree/InvenTree/pull/10398
|
||||
return Greatest(
|
||||
ExpressionWrapper(
|
||||
Max(F('quantity')) - Max(F('consumed')), output_field=DecimalField()
|
||||
),
|
||||
0,
|
||||
output_field=DecimalField(),
|
||||
)
|
||||
|
||||
|
||||
def annotate_allocated_quantity():
|
||||
"""Annotate the 'allocated' quantity for each build item in the queryset."""
|
||||
return Coalesce(Sum('allocations__quantity'), 0, output_field=DecimalField())
|
||||
|
||||
@@ -29,7 +29,7 @@ import report.mixins
|
||||
import stock.models
|
||||
import users.models
|
||||
from build.events import BuildEvents
|
||||
from build.filters import annotate_allocated_quantity
|
||||
from build.filters import annotate_allocated_quantity, annotate_required_quantity
|
||||
from build.status_codes import BuildStatus, BuildStatusGroups
|
||||
from build.validators import (
|
||||
generate_next_build_reference,
|
||||
@@ -1062,7 +1062,7 @@ class Build(
|
||||
|
||||
lines = self.untracked_line_items.all()
|
||||
lines = lines.exclude(bom_item__consumable=True)
|
||||
lines = annotate_allocated_quantity(lines)
|
||||
lines = lines.annotate(allocated=annotate_allocated_quantity())
|
||||
|
||||
for build_line in lines:
|
||||
reduce_by = build_line.allocated - build_line.quantity
|
||||
@@ -1381,10 +1381,12 @@ class Build(
|
||||
elif tracked is False:
|
||||
lines = lines.filter(bom_item__sub_part__trackable=False)
|
||||
|
||||
lines = annotate_allocated_quantity(lines)
|
||||
lines = lines.prefetch_related('allocations')
|
||||
|
||||
# Filter out any lines which have been fully allocated
|
||||
lines = lines.filter(allocated__lt=F('quantity'))
|
||||
lines = lines.annotate(
|
||||
allocated=annotate_allocated_quantity(),
|
||||
required=annotate_required_quantity(),
|
||||
).filter(allocated__lt=F('required'))
|
||||
|
||||
return lines
|
||||
|
||||
@@ -1436,10 +1438,14 @@ class Build(
|
||||
True if any BuildLine has been over-allocated.
|
||||
"""
|
||||
lines = self.build_lines.all().exclude(bom_item__consumable=True)
|
||||
lines = annotate_allocated_quantity(lines)
|
||||
|
||||
lines = lines.prefetch_related('allocations')
|
||||
|
||||
# Find any lines which have been over-allocated
|
||||
lines = lines.filter(allocated__gt=F('quantity'))
|
||||
lines = lines.annotate(
|
||||
allocated=annotate_allocated_quantity(),
|
||||
required=annotate_required_quantity(),
|
||||
).filter(allocated__gt=F('required'))
|
||||
|
||||
return lines.count() > 0
|
||||
|
||||
@@ -1644,19 +1650,30 @@ class BuildLine(report.mixins.InvenTreeReportMixin, InvenTree.models.InvenTreeMo
|
||||
return allocated['q']
|
||||
|
||||
def unallocated_quantity(self):
|
||||
"""Return the unallocated quantity for this BuildLine."""
|
||||
return max(self.quantity - self.allocated_quantity(), 0)
|
||||
"""Return the unallocated quantity for this BuildLine.
|
||||
|
||||
- Start with the required quantity
|
||||
- Subtract the consumed quantity
|
||||
- Subtract the allocated quantity
|
||||
|
||||
Return the remaining quantity (or zero if negative)
|
||||
"""
|
||||
return max(self.quantity - self.consumed - self.allocated_quantity(), 0)
|
||||
|
||||
def is_fully_allocated(self):
|
||||
"""Return True if this BuildLine is fully allocated."""
|
||||
if self.bom_item.consumable:
|
||||
return True
|
||||
|
||||
return self.allocated_quantity() >= self.quantity
|
||||
required = max(0, self.quantity - self.consumed)
|
||||
|
||||
return self.allocated_quantity() >= required
|
||||
|
||||
def is_overallocated(self):
|
||||
"""Return True if this BuildLine is over-allocated."""
|
||||
return self.allocated_quantity() > self.quantity
|
||||
required = max(0, self.quantity - self.consumed)
|
||||
|
||||
return self.allocated_quantity() > required
|
||||
|
||||
def is_fully_consumed(self):
|
||||
"""Return True if this BuildLine is fully consumed."""
|
||||
|
||||
@@ -853,6 +853,78 @@ class AutoAllocationTests(BuildTestBase):
|
||||
self.assertEqual(self.line_1.unallocated_quantity(), 0)
|
||||
self.assertEqual(self.line_2.unallocated_quantity(), 0)
|
||||
|
||||
def test_allocate_consumed(self):
|
||||
"""Test for auto-allocation against a build which has been fully consumed.
|
||||
|
||||
Steps:
|
||||
1. Fully allocate the build (using the auto-allocate function)
|
||||
2. Consume allocated stock
|
||||
3. Ensure that all allocations are removed
|
||||
4. Re-run the auto-allocate function
|
||||
5. Check that no new allocations have been made
|
||||
"""
|
||||
self.assertEqual(self.build.allocated_stock.count(), 0)
|
||||
self.assertFalse(self.build.is_fully_allocated(tracked=False))
|
||||
|
||||
# Auto allocate stock against the build order
|
||||
self.build.auto_allocate_stock(
|
||||
interchangeable=True, substitutes=True, optional_items=True
|
||||
)
|
||||
|
||||
self.assertEqual(self.line_1.allocated_quantity(), 50)
|
||||
self.assertEqual(self.line_2.allocated_quantity(), 30)
|
||||
|
||||
self.assertEqual(self.line_1.unallocated_quantity(), 0)
|
||||
self.assertEqual(self.line_2.unallocated_quantity(), 0)
|
||||
|
||||
self.assertTrue(self.line_1.is_fully_allocated())
|
||||
self.assertTrue(self.line_2.is_fully_allocated())
|
||||
|
||||
self.assertFalse(self.line_1.is_overallocated())
|
||||
self.assertFalse(self.line_2.is_overallocated())
|
||||
|
||||
N = self.build.allocated_stock.count()
|
||||
|
||||
self.assertEqual(self.line_1.allocations.count(), 2)
|
||||
self.assertEqual(self.line_2.allocations.count(), 6)
|
||||
|
||||
for item in self.line_1.allocations.all():
|
||||
item.complete_allocation()
|
||||
|
||||
for item in self.line_2.allocations.all():
|
||||
item.complete_allocation()
|
||||
|
||||
self.line_1.refresh_from_db()
|
||||
self.line_2.refresh_from_db()
|
||||
|
||||
self.assertTrue(self.line_1.is_fully_allocated())
|
||||
self.assertTrue(self.line_2.is_fully_allocated())
|
||||
self.assertFalse(self.line_1.is_overallocated())
|
||||
self.assertFalse(self.line_2.is_overallocated())
|
||||
|
||||
self.assertEqual(self.line_1.allocations.count(), 0)
|
||||
self.assertEqual(self.line_2.allocations.count(), 0)
|
||||
|
||||
self.assertEqual(self.line_1.quantity, self.line_1.consumed)
|
||||
self.assertEqual(self.line_2.quantity, self.line_2.consumed)
|
||||
|
||||
# Check that the "allocations" have been removed
|
||||
self.assertEqual(self.build.allocated_stock.count(), N - 8)
|
||||
|
||||
# Now, try to auto-allocate again
|
||||
self.build.auto_allocate_stock(
|
||||
interchangeable=True, substitutes=True, optional_items=True
|
||||
)
|
||||
|
||||
# Ensure that there are no "new" allocations (there should be none!)
|
||||
self.assertEqual(self.line_1.allocated_quantity(), 0)
|
||||
self.assertEqual(self.line_2.allocated_quantity(), 0)
|
||||
|
||||
self.assertEqual(self.line_1.unallocated_quantity(), 0)
|
||||
self.assertEqual(self.line_2.unallocated_quantity(), 0)
|
||||
|
||||
self.assertEqual(self.build.allocated_stock.count(), N - 8)
|
||||
|
||||
|
||||
class ExternalBuildTest(InvenTreeAPITestCase):
|
||||
"""Unit tests for external build order functionality."""
|
||||
|
||||
@@ -630,6 +630,38 @@ class DataImportRow(models.Model):
|
||||
if value is None and field in default_values:
|
||||
value = default_values[field]
|
||||
|
||||
# If the field provides a set of valid 'choices', use that as a lookup
|
||||
if field_type == 'choice' and 'choices' in field_def:
|
||||
choices = field_def.get('choices', None)
|
||||
|
||||
if callable(choices):
|
||||
choices = choices()
|
||||
|
||||
# Try to match the provided value against the available choices
|
||||
choice_value = None
|
||||
|
||||
for choice in choices:
|
||||
primary_value = choice['value']
|
||||
display_value = choice['display_name']
|
||||
|
||||
if primary_value == value:
|
||||
choice_value = primary_value
|
||||
# Break on first match against a primary choice value
|
||||
break
|
||||
|
||||
if display_value == value:
|
||||
choice_value = primary_value
|
||||
|
||||
elif (
|
||||
str(display_value).lower().strip() == str(value).lower().strip()
|
||||
and choice_value is None
|
||||
):
|
||||
# Case-insensitive match against display value
|
||||
choice_value = primary_value
|
||||
|
||||
if choice_value is not None:
|
||||
value = choice_value
|
||||
|
||||
data[field] = value
|
||||
|
||||
self.data = data
|
||||
@@ -693,9 +725,20 @@ class DataImportRow(models.Model):
|
||||
try:
|
||||
instance = self.session.model_class.objects.get(pk=instance_id)
|
||||
except self.session.model_class.DoesNotExist:
|
||||
raise DjangoValidationError(_('No record found with the provided ID.'))
|
||||
self.errors = {
|
||||
'non_field_errors': _('No record found with the provided ID')
|
||||
+ f': {instance_id}'
|
||||
}
|
||||
return False
|
||||
except ValueError:
|
||||
raise DjangoValidationError(_('Invalid ID format provided.'))
|
||||
self.errors = {
|
||||
'non_field_errors': _('Invalid ID format provided')
|
||||
+ f': {instance_id}'
|
||||
}
|
||||
return False
|
||||
except Exception as e:
|
||||
self.errors = {'non_field_errors': str(e)}
|
||||
return False
|
||||
|
||||
serializer = self.construct_serializer(instance=instance, request=request)
|
||||
|
||||
|
||||
@@ -174,8 +174,8 @@ class StaleStockNotificationTests(InvenTreeTestCase):
|
||||
def test_check_stale_stock_no_stale_items(self):
|
||||
"""Test check_stale_stock when no stale items exist."""
|
||||
# Clear all existing stock items
|
||||
stock.models.StockItem.objects.update(parent=None)
|
||||
stock.models.StockItem.objects.all().delete()
|
||||
|
||||
# Create only future expiry items
|
||||
today = helpers.current_date()
|
||||
stock.models.StockItem.objects.create(
|
||||
@@ -194,6 +194,7 @@ class StaleStockNotificationTests(InvenTreeTestCase):
|
||||
def test_check_stale_stock_with_stale_items(self, mock_offload):
|
||||
"""Test check_stale_stock when stale items exist."""
|
||||
# Clear existing stock items
|
||||
stock.models.StockItem.objects.update(parent=None)
|
||||
stock.models.StockItem.objects.all().delete()
|
||||
|
||||
self.create_stock_items_with_expiry()
|
||||
@@ -229,6 +230,7 @@ class StaleStockNotificationTests(InvenTreeTestCase):
|
||||
def test_check_stale_stock_filtering(self):
|
||||
"""Test that check_stale_stock properly filters stock items."""
|
||||
# Clear all existing stock items first
|
||||
stock.models.StockItem.objects.update(parent=None)
|
||||
stock.models.StockItem.objects.all().delete()
|
||||
|
||||
today = helpers.current_date()
|
||||
|
||||
@@ -111,7 +111,7 @@ class UserInterfaceMixinTests(InvenTreeAPITestCase):
|
||||
# Request custom panel information for a part instance
|
||||
response = self.get(url, data=query_data)
|
||||
|
||||
# There should be 4 active panels for the part by default
|
||||
# There should be 3 active panels for the part by default
|
||||
self.assertEqual(3, len(response.data))
|
||||
|
||||
_part.active = False
|
||||
@@ -119,8 +119,8 @@ class UserInterfaceMixinTests(InvenTreeAPITestCase):
|
||||
|
||||
response = self.get(url, data=query_data)
|
||||
|
||||
# As the part is not active, only 3 panels left
|
||||
self.assertEqual(3, len(response.data))
|
||||
# As the part is not active, only 2 panels left
|
||||
self.assertEqual(2, len(response.data))
|
||||
|
||||
# Disable the "ENABLE_PART_PANELS" setting, and try again
|
||||
plugin.set_setting('ENABLE_PART_PANELS', False)
|
||||
|
||||
@@ -93,13 +93,17 @@ class SampleUserInterfacePlugin(SettingsMixin, UserInterfaceMixin, InvenTreePlug
|
||||
except (Part.DoesNotExist, ValueError):
|
||||
part = None
|
||||
|
||||
panels.append({
|
||||
'key': 'part-panel',
|
||||
'title': _('Part Panel'),
|
||||
'source': self.plugin_static_file('sample_panel.js:renderPartPanel'),
|
||||
'icon': 'ti:package_outline',
|
||||
'context': {'part_name': part.name if part else ''},
|
||||
})
|
||||
# Only display this panel for "active" parts
|
||||
if part and part.active:
|
||||
panels.append({
|
||||
'key': 'part-panel',
|
||||
'title': _('Part Panel'),
|
||||
'source': self.plugin_static_file(
|
||||
'sample_panel.js:renderPartPanel'
|
||||
),
|
||||
'icon': 'ti:package:outline',
|
||||
'context': {'part_name': part.name if part else ''},
|
||||
})
|
||||
|
||||
# Next, add a custom panel which will appear on the 'purchaseorder' page
|
||||
if target_model == 'purchaseorder' and self.get_setting(
|
||||
|
||||
@@ -10,4 +10,4 @@ class VersionPlugin(InvenTreePlugin):
|
||||
NAME = 'Sample Version Plugin'
|
||||
DESCRIPTION = 'A simple plugin which shows how to use the version limits'
|
||||
MIN_VERSION = '0.1.0'
|
||||
MAX_VERSION = '1.0.0'
|
||||
MAX_VERSION = '2.0.0'
|
||||
|
||||
@@ -4,7 +4,7 @@ import logging
|
||||
import os
|
||||
|
||||
from django.apps import AppConfig
|
||||
from django.core.exceptions import AppRegistryNotReady
|
||||
from django.core.exceptions import AppRegistryNotReady, ValidationError
|
||||
from django.core.files.base import ContentFile
|
||||
from django.core.files.storage import default_storage
|
||||
from django.db.utils import IntegrityError, OperationalError, ProgrammingError
|
||||
@@ -52,6 +52,8 @@ class ReportConfig(AppConfig):
|
||||
try:
|
||||
self.create_default_labels()
|
||||
self.create_default_reports()
|
||||
except ValidationError:
|
||||
logger.warning('Validation error when creating default templates')
|
||||
except (
|
||||
AppRegistryNotReady,
|
||||
IntegrityError,
|
||||
@@ -162,6 +164,10 @@ class ReportConfig(AppConfig):
|
||||
**template, template=self.file_from_template('label', filename)
|
||||
)
|
||||
logger.info("Creating new label template: '%s'", template['name'])
|
||||
except ValidationError:
|
||||
logger.warning(
|
||||
"Could not create label template: '%s'", template['name']
|
||||
)
|
||||
except Exception:
|
||||
InvenTree.exceptions.log_error('create_default_labels', scope='init')
|
||||
|
||||
@@ -261,5 +267,9 @@ class ReportConfig(AppConfig):
|
||||
**template, template=self.file_from_template('report', filename)
|
||||
)
|
||||
logger.info("Created new report template: '%s'", template['name'])
|
||||
except ValidationError:
|
||||
logger.warning(
|
||||
"Could not create report template: '%s'", template['name']
|
||||
)
|
||||
except Exception:
|
||||
InvenTree.exceptions.log_error('create_default_reports', scope='init')
|
||||
|
||||
@@ -39,8 +39,9 @@ from InvenTree.filters import (
|
||||
SEARCH_ORDER_FILTER,
|
||||
SEARCH_ORDER_FILTER_ALIAS,
|
||||
InvenTreeDateFilter,
|
||||
NumberOrNullFilter,
|
||||
)
|
||||
from InvenTree.helpers import extract_serial_numbers, generateTestKey, isNull, str2bool
|
||||
from InvenTree.helpers import extract_serial_numbers, generateTestKey, str2bool
|
||||
from InvenTree.mixins import (
|
||||
CreateAPI,
|
||||
CustomRetrieveUpdateDestroyAPI,
|
||||
@@ -933,6 +934,66 @@ class StockFilter(rest_filters.FilterSet):
|
||||
else:
|
||||
return queryset.exclude(stale_filter)
|
||||
|
||||
exclude_tree = rest_filters.NumberFilter(
|
||||
method='filter_exclude_tree',
|
||||
label=_('Exclude Tree'),
|
||||
help_text=_(
|
||||
'Provide a StockItem PK to exclude that item and all its descendants'
|
||||
),
|
||||
)
|
||||
|
||||
def filter_exclude_tree(self, queryset, name, value):
|
||||
"""Exclude a StockItem and all of its descendants from the queryset."""
|
||||
try:
|
||||
root = StockItem.objects.get(pk=value)
|
||||
pks_to_exclude = [
|
||||
item.pk for item in root.get_descendants(include_self=True)
|
||||
]
|
||||
return queryset.exclude(pk__in=pks_to_exclude)
|
||||
except (ValueError, StockItem.DoesNotExist):
|
||||
# If the value is invalid or the object doesn't exist, do nothing.
|
||||
return queryset
|
||||
|
||||
cascade = rest_filters.BooleanFilter(
|
||||
method='filter_cascade',
|
||||
label=_('Cascade Locations'),
|
||||
help_text=_('If true, include items in child locations of the given location'),
|
||||
)
|
||||
|
||||
location = NumberOrNullFilter(
|
||||
method='filter_location',
|
||||
label=_('Location'),
|
||||
help_text=_("Filter by numeric Location ID or the literal 'null'"),
|
||||
)
|
||||
|
||||
def filter_cascade(self, queryset, name, value):
|
||||
"""Dummy filter method for 'cascade'.
|
||||
|
||||
- Ensures 'cascade' appears in API documentation
|
||||
- Does NOT actually filter the queryset directly
|
||||
"""
|
||||
return queryset
|
||||
|
||||
def filter_location(self, queryset, name, value):
|
||||
"""Filter for location that also applies cascade logic."""
|
||||
cascade = str2bool(self.data.get('cascade', True))
|
||||
|
||||
if value == 'null':
|
||||
if not cascade:
|
||||
return queryset.filter(location=None)
|
||||
return queryset
|
||||
|
||||
if not cascade:
|
||||
return queryset.filter(location=value)
|
||||
|
||||
try:
|
||||
loc_obj = StockLocation.objects.get(pk=value)
|
||||
except StockLocation.DoesNotExist:
|
||||
return queryset
|
||||
|
||||
children = loc_obj.getUniqueChildren()
|
||||
return queryset.filter(location__in=children)
|
||||
|
||||
|
||||
class StockApiMixin:
|
||||
"""Mixin class for StockItem API endpoints."""
|
||||
@@ -1191,52 +1252,6 @@ class StockList(DataExportViewMixin, StockApiMixin, ListCreateDestroyAPIView):
|
||||
headers=self.get_success_headers(serializer.data),
|
||||
)
|
||||
|
||||
def filter_queryset(self, queryset):
|
||||
"""Custom filtering for the StockItem queryset."""
|
||||
params = self.request.query_params
|
||||
|
||||
queryset = super().filter_queryset(queryset)
|
||||
|
||||
# Exclude stock item tree
|
||||
exclude_tree = params.get('exclude_tree', None)
|
||||
|
||||
if exclude_tree is not None:
|
||||
try:
|
||||
item = StockItem.objects.get(pk=exclude_tree)
|
||||
|
||||
queryset = queryset.exclude(
|
||||
pk__in=[it.pk for it in item.get_descendants(include_self=True)]
|
||||
)
|
||||
|
||||
except (ValueError, StockItem.DoesNotExist): # pragma: no cover
|
||||
pass
|
||||
|
||||
# Does the client wish to filter by stock location?
|
||||
loc_id = params.get('location', None)
|
||||
|
||||
cascade = str2bool(params.get('cascade', True))
|
||||
|
||||
if loc_id is not None:
|
||||
# Filter by 'null' location (i.e. top-level items)
|
||||
if isNull(loc_id):
|
||||
if not cascade:
|
||||
queryset = queryset.filter(location=None)
|
||||
else:
|
||||
try:
|
||||
# If '?cascade=true' then include items which exist in sub-locations
|
||||
if cascade:
|
||||
location = StockLocation.objects.get(pk=loc_id)
|
||||
queryset = queryset.filter(
|
||||
location__in=location.getUniqueChildren()
|
||||
)
|
||||
else:
|
||||
queryset = queryset.filter(location=loc_id)
|
||||
|
||||
except (ValueError, StockLocation.DoesNotExist): # pragma: no cover
|
||||
pass
|
||||
|
||||
return queryset
|
||||
|
||||
filter_backends = SEARCH_ORDER_FILTER_ALIAS
|
||||
|
||||
ordering_field_aliases = {
|
||||
|
||||
@@ -274,18 +274,19 @@
|
||||
tree_id: 9
|
||||
level: 0
|
||||
lft: 1
|
||||
rght: 2
|
||||
rght: 4
|
||||
|
||||
- model: stock.stockitem
|
||||
pk: 1001
|
||||
fields:
|
||||
part: 100
|
||||
parent: 1000
|
||||
location: 1
|
||||
quantity: 11
|
||||
tree_id: 14
|
||||
level: 0
|
||||
lft: 1
|
||||
rght: 2
|
||||
tree_id: 9
|
||||
level: 1
|
||||
lft: 2
|
||||
rght: 3
|
||||
|
||||
- model: stock.stockitem
|
||||
pk: 1002
|
||||
|
||||
@@ -329,7 +329,7 @@ class StockItemSerializer(
|
||||
'supplier_part_detail.MPN',
|
||||
]
|
||||
|
||||
import_exclude_fields = ['use_pack_size', 'location_path']
|
||||
import_exclude_fields = ['location_path', 'serial_numbers', 'use_pack_size']
|
||||
|
||||
class Meta:
|
||||
"""Metaclass options."""
|
||||
@@ -490,7 +490,7 @@ class StockItemSerializer(
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
"""Custom update method to pass the user information through to the instance."""
|
||||
instance._user = self.context['user']
|
||||
instance._user = self.context.get('user', None)
|
||||
|
||||
status_custom_key = validated_data.pop('status_custom_key', None)
|
||||
status = validated_data.pop('status', None)
|
||||
|
||||
@@ -621,6 +621,13 @@ class StockItemListTest(StockAPITestCase):
|
||||
response = self.get_stock(location=7)
|
||||
self.assertEqual(len(response), 18)
|
||||
|
||||
def test_filter_by_exclude_tree(self):
|
||||
"""Filter StockItem by excluding a StockItem tree."""
|
||||
response = self.get_stock(exclude_tree=1000)
|
||||
for item in response:
|
||||
self.assertNotEqual(item['pk'], 1000)
|
||||
self.assertNotEqual(item['parent'], 1000)
|
||||
|
||||
def test_filter_by_depleted(self):
|
||||
"""Filter StockItem by depleted status."""
|
||||
response = self.get_stock(depleted=1)
|
||||
@@ -786,10 +793,10 @@ class StockItemListTest(StockAPITestCase):
|
||||
def test_filter_has_child_items(self):
|
||||
"""Filter StockItem by has_child_items."""
|
||||
response = self.get_stock(has_child_items=True)
|
||||
self.assertEqual(len(response), 0)
|
||||
self.assertEqual(len(response), 1)
|
||||
|
||||
response = self.get_stock(has_child_items=False)
|
||||
self.assertEqual(len(response), 29) # TODO: adjust test dataset (belongs_to)
|
||||
self.assertEqual(len(response), 28) # TODO: adjust test dataset (belongs_to)
|
||||
|
||||
def test_filter_sent_to_customer(self):
|
||||
"""Filter StockItem by sent_to_customer."""
|
||||
|
||||
@@ -144,6 +144,14 @@ class UserDetail(RetrieveUpdateDestroyAPI):
|
||||
serializer_class = ExtendedUserSerializer
|
||||
permission_classes = [InvenTree.permissions.StaffRolePermissionOrReadOnly]
|
||||
|
||||
def perform_destroy(self, instance):
|
||||
"""Override destroy method to ensure sessions are deleted first."""
|
||||
# Remove all sessions for this user
|
||||
if sessions := instance.usersession_set.all():
|
||||
sessions.delete()
|
||||
# Normally delete the user
|
||||
return super().perform_destroy(instance)
|
||||
|
||||
|
||||
class UserDetailSetPassword(UpdateAPI):
|
||||
"""Allows superusers to set the password for a user."""
|
||||
|
||||
@@ -61,7 +61,10 @@ if settings.LDAP_AUTH:
|
||||
user.save()
|
||||
|
||||
# if they got an email address from LDAP, create it now and make it the primary
|
||||
if user.email:
|
||||
if (
|
||||
user.email
|
||||
and not EmailAddress.objects.filter(user=user, email=user.email).exists()
|
||||
):
|
||||
EmailAddress.objects.create(user=user, email=user.email, primary=True)
|
||||
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"""Unit tests for the 'users' app."""
|
||||
|
||||
from time import sleep
|
||||
|
||||
from django.apps import apps
|
||||
from django.contrib.auth.models import Group
|
||||
from django.test import TestCase
|
||||
@@ -349,12 +351,21 @@ class MFALoginTest(InvenTreeAPITestCase):
|
||||
response = self.post(login_url, auth_data, expected_code=200)
|
||||
self._helper_meta_val(response)
|
||||
|
||||
# Add MFA
|
||||
response = self.post(
|
||||
reverse('browser:mfa:manage_totp'),
|
||||
{'code': self.get_topt()},
|
||||
expected_code=200,
|
||||
)
|
||||
# Add MFA - trying in a limited loop in case of timing issues
|
||||
success: bool = False
|
||||
for _ in range(10):
|
||||
try:
|
||||
response = self.post(
|
||||
reverse('browser:mfa:manage_totp'),
|
||||
{'code': self.get_topt()},
|
||||
expected_code=200,
|
||||
)
|
||||
success = True
|
||||
break
|
||||
except AssertionError:
|
||||
sleep(0.8)
|
||||
self.assertTrue(success, 'Failed to add MFA device')
|
||||
|
||||
# There must be a TOTP device now - success
|
||||
self.get(reverse('browser:mfa:manage_totp'), expected_code=200)
|
||||
self.get(reverse('api-token'), expected_code=200)
|
||||
|
||||
@@ -320,9 +320,9 @@ distlib==0.4.0 \
|
||||
--hash=sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16 \
|
||||
--hash=sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d
|
||||
# via virtualenv
|
||||
django==4.2.23 \
|
||||
--hash=sha256:42fdeaba6e6449d88d4f66de47871015097dc6f1b87910db00a91946295cfae4 \
|
||||
--hash=sha256:dafbfaf52c2f289bd65f4ab935791cb4fb9a198f2a5ba9faf35d7338a77e9803
|
||||
django==4.2.24 \
|
||||
--hash=sha256:40cd7d3f53bc6cd1902eadce23c337e97200888df41e4a73b42d682f23e71d80 \
|
||||
--hash=sha256:a6527112c58821a0dfc5ab73013f0bdd906539790a17196658e36e66af43c350
|
||||
# via
|
||||
# -c src/backend/requirements.txt
|
||||
# django-slowtests
|
||||
|
||||
@@ -391,9 +391,9 @@ defusedxml==0.7.1 \
|
||||
--hash=sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69 \
|
||||
--hash=sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61
|
||||
# via python3-openid
|
||||
django==4.2.23 \
|
||||
--hash=sha256:42fdeaba6e6449d88d4f66de47871015097dc6f1b87910db00a91946295cfae4 \
|
||||
--hash=sha256:dafbfaf52c2f289bd65f4ab935791cb4fb9a198f2a5ba9faf35d7338a77e9803
|
||||
django==4.2.24 \
|
||||
--hash=sha256:40cd7d3f53bc6cd1902eadce23c337e97200888df41e4a73b42d682f23e71d80 \
|
||||
--hash=sha256:a6527112c58821a0dfc5ab73013f0bdd906539790a17196658e36e66af43c350
|
||||
# via
|
||||
# -r src/backend/requirements.in
|
||||
# django-allauth
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { MantineSize } from '@mantine/core';
|
||||
import type { MantineRadius, MantineSize } from '@mantine/core';
|
||||
|
||||
export type UiSizeType = MantineSize | string | number;
|
||||
|
||||
@@ -6,7 +6,7 @@ export interface UserTheme {
|
||||
primaryColor: string;
|
||||
whiteColor: string;
|
||||
blackColor: string;
|
||||
radius: UiSizeType;
|
||||
radius: MantineRadius;
|
||||
loader: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ import {
|
||||
IconDownload,
|
||||
IconFilter
|
||||
} from '@tabler/icons-react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import type { CalendarState } from '../../hooks/UseCalendar';
|
||||
import { useLocalState } from '../../states/LocalState';
|
||||
@@ -59,6 +59,9 @@ export default function Calendar({
|
||||
|
||||
const [locale] = useLocalState(useShallow((s) => [s.language]));
|
||||
|
||||
// Ensure underscore is replaced with dash
|
||||
const calendarLocale = useMemo(() => locale.replace('_', '-'), [locale]);
|
||||
|
||||
const selectMonth = useCallback(
|
||||
(date: DateValue) => {
|
||||
state.selectMonth(date);
|
||||
@@ -186,7 +189,7 @@ export default function Calendar({
|
||||
plugins={[dayGridPlugin, interactionPlugin]}
|
||||
initialView='dayGridMonth'
|
||||
locales={allLocales}
|
||||
locale={locale}
|
||||
locale={calendarLocale}
|
||||
headerToolbar={false}
|
||||
footerToolbar={false}
|
||||
{...calendarProps}
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { Alert, Card, Center, Divider, Loader, Text } from '@mantine/core';
|
||||
import {
|
||||
Alert,
|
||||
Card,
|
||||
Center,
|
||||
Divider,
|
||||
Loader,
|
||||
Space,
|
||||
Text
|
||||
} from '@mantine/core';
|
||||
import { useDisclosure, useHotkeys } from '@mantine/hooks';
|
||||
import { IconExclamationCircle, IconInfoCircle } from '@tabler/icons-react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
@@ -21,15 +29,23 @@ export default function DashboardLayout() {
|
||||
const [widgets, setWidgets] = useState<DashboardWidgetProps[]>([]);
|
||||
|
||||
// local/remote storage values for widget / layout
|
||||
const [remoteWidgets, setRemoteWidgets, remoteLayouts, setRemoteLayouts] =
|
||||
useLocalState(
|
||||
useShallow((state) => [
|
||||
state.widgets,
|
||||
state.setWidgets,
|
||||
state.layouts,
|
||||
state.setLayouts
|
||||
])
|
||||
);
|
||||
const [
|
||||
remoteWidgets,
|
||||
setRemoteWidgets,
|
||||
remoteLayouts,
|
||||
setRemoteLayouts,
|
||||
showSampleDashboard,
|
||||
setShowSampleDashboard
|
||||
] = useLocalState(
|
||||
useShallow((state) => [
|
||||
state.widgets,
|
||||
state.setWidgets,
|
||||
state.layouts,
|
||||
state.setLayouts,
|
||||
state.showSampleDashboard,
|
||||
state.setShowSampleDashboard
|
||||
])
|
||||
);
|
||||
|
||||
const [editing, setEditing] = useDisclosure(false);
|
||||
const [removing, setRemoving] = useDisclosure(false);
|
||||
@@ -75,6 +91,9 @@ export default function DashboardLayout() {
|
||||
);
|
||||
|
||||
if (newWidget) {
|
||||
if (showSampleDashboard) {
|
||||
setShowSampleDashboard(false);
|
||||
}
|
||||
setWidgets([...widgets, newWidget]);
|
||||
}
|
||||
|
||||
@@ -195,10 +214,48 @@ export default function DashboardLayout() {
|
||||
|
||||
// Clear all widgets from the dashboard
|
||||
const clearWidgets = useCallback(() => {
|
||||
if (showSampleDashboard) {
|
||||
setShowSampleDashboard(false);
|
||||
}
|
||||
setWidgets([]);
|
||||
setLayouts({});
|
||||
}, []);
|
||||
|
||||
const defaultLayouts = {
|
||||
lg: [
|
||||
{
|
||||
w: 6,
|
||||
h: 4,
|
||||
x: 0,
|
||||
y: 0,
|
||||
i: 'gstart',
|
||||
minW: 5,
|
||||
minH: 4,
|
||||
moved: false,
|
||||
static: false
|
||||
},
|
||||
{
|
||||
w: 6,
|
||||
h: 4,
|
||||
x: 6,
|
||||
y: 0,
|
||||
i: 'news',
|
||||
minW: 5,
|
||||
minH: 4,
|
||||
moved: false,
|
||||
static: false
|
||||
}
|
||||
]
|
||||
};
|
||||
const loadWigs = ['news', 'gstart'];
|
||||
const defaultWidgets = useMemo(() => {
|
||||
return loadWigs
|
||||
.map((lwid: string) =>
|
||||
availableWidgets.items.find((wid) => wid.label === lwid)
|
||||
)
|
||||
.filter((widget): widget is DashboardWidgetProps => widget !== undefined);
|
||||
}, [availableWidgets.items, defaultLayouts]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<DashboardWidgetDrawer
|
||||
@@ -228,43 +285,41 @@ export default function DashboardLayout() {
|
||||
{layouts && loaded && availableWidgets.loaded ? (
|
||||
<>
|
||||
{widgetLabels.length == 0 ? (
|
||||
<Center>
|
||||
<Card shadow='xs' padding='xl' style={{ width: '100%' }}>
|
||||
<Alert
|
||||
color='blue'
|
||||
title={t`No Widgets Selected`}
|
||||
icon={<IconInfoCircle />}
|
||||
>
|
||||
<Text>{t`Use the menu to add widgets to the dashboard`}</Text>
|
||||
</Alert>
|
||||
</Card>
|
||||
</Center>
|
||||
<>
|
||||
<Center>
|
||||
<Card shadow='xs' padding='xl' style={{ width: '100%' }}>
|
||||
<Alert
|
||||
color='blue'
|
||||
title={t`No Widgets Selected`}
|
||||
icon={<IconInfoCircle />}
|
||||
>
|
||||
<Text>{t`Use the menu to add widgets to the dashboard`}</Text>
|
||||
</Alert>
|
||||
</Card>
|
||||
</Center>
|
||||
{showSampleDashboard && (
|
||||
<>
|
||||
<Space h='lg' />
|
||||
{WidgetGrid(
|
||||
defaultLayouts,
|
||||
() => {},
|
||||
editing,
|
||||
defaultWidgets,
|
||||
removing,
|
||||
() => {}
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<ReactGridLayout
|
||||
className='dashboard-layout'
|
||||
breakpoints={{ lg: 1200, md: 996, sm: 768, xs: 480, xxs: 0 }}
|
||||
cols={{ lg: 12, md: 10, sm: 6, xs: 4, xxs: 2 }}
|
||||
rowHeight={64}
|
||||
layouts={layouts}
|
||||
onLayoutChange={onLayoutChange}
|
||||
compactType={'vertical'}
|
||||
isDraggable={editing}
|
||||
isResizable={editing}
|
||||
margin={[10, 10]}
|
||||
containerPadding={[0, 0]}
|
||||
resizeHandles={['ne', 'se', 'sw', 'nw']}
|
||||
>
|
||||
{widgets.map((item: DashboardWidgetProps) => {
|
||||
return DashboardWidget({
|
||||
item: item,
|
||||
editing: editing,
|
||||
removing: removing,
|
||||
onRemove: () => {
|
||||
removeWidget(item.label);
|
||||
}
|
||||
});
|
||||
})}
|
||||
</ReactGridLayout>
|
||||
WidgetGrid(
|
||||
layouts,
|
||||
onLayoutChange,
|
||||
editing,
|
||||
widgets,
|
||||
removing,
|
||||
removeWidget
|
||||
)
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
@@ -275,3 +330,40 @@ export default function DashboardLayout() {
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function WidgetGrid(
|
||||
layouts: {},
|
||||
onLayoutChange: (layout: any, newLayouts: any) => void,
|
||||
editing: boolean,
|
||||
widgets: DashboardWidgetProps[],
|
||||
removing: boolean,
|
||||
removeWidget: (widget: string) => void
|
||||
) {
|
||||
return (
|
||||
<ReactGridLayout
|
||||
className='dashboard-layout'
|
||||
breakpoints={{ lg: 1200, md: 996, sm: 768, xs: 480, xxs: 0 }}
|
||||
cols={{ lg: 12, md: 10, sm: 6, xs: 4, xxs: 2 }}
|
||||
rowHeight={64}
|
||||
layouts={layouts}
|
||||
onLayoutChange={onLayoutChange}
|
||||
compactType={'vertical'}
|
||||
isDraggable={editing}
|
||||
isResizable={editing}
|
||||
margin={[10, 10]}
|
||||
containerPadding={[0, 0]}
|
||||
resizeHandles={['ne', 'se', 'sw', 'nw']}
|
||||
>
|
||||
{widgets.map((item: DashboardWidgetProps) => {
|
||||
return DashboardWidget({
|
||||
item: item,
|
||||
editing: editing,
|
||||
removing: removing,
|
||||
onRemove: () => {
|
||||
removeWidget(item.label);
|
||||
}
|
||||
});
|
||||
})}
|
||||
</ReactGridLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -285,7 +285,7 @@ export default function ImporterDataSelector({
|
||||
<IconCircleDashedCheck color='blue' size={16} />
|
||||
)}
|
||||
{!row.complete && !row.valid && (
|
||||
<HoverCard openDelay={50} closeDelay={100}>
|
||||
<HoverCard openDelay={50} closeDelay={100} position='top-start'>
|
||||
<HoverCard.Target>
|
||||
<IconExclamationCircle color='red' size={16} />
|
||||
</HoverCard.Target>
|
||||
|
||||
@@ -15,7 +15,7 @@ import { type ReactNode, useCallback } from 'react';
|
||||
import { ModelInformationDict } from '@lib/enums/ModelInformation';
|
||||
import { ModelType } from '@lib/enums/ModelType';
|
||||
import { apiUrl } from '@lib/functions/Api';
|
||||
import { navigateToLink } from '@lib/functions/Navigation';
|
||||
import { getBaseUrl, navigateToLink } from '@lib/functions/Navigation';
|
||||
import type {
|
||||
ModelRendererDict,
|
||||
RenderInstanceProps
|
||||
@@ -219,7 +219,10 @@ export function RenderInlineModel({
|
||||
{prefix}
|
||||
{image && <Thumbnail src={image} size={18} />}
|
||||
{url ? (
|
||||
<Anchor href='' onClick={(event: any) => onClick(event)}>
|
||||
<Anchor
|
||||
href={`/${getBaseUrl()}${url}`}
|
||||
onClick={(event: any) => onClick(event)}
|
||||
>
|
||||
{primary}
|
||||
</Anchor>
|
||||
) : (
|
||||
|
||||
@@ -88,7 +88,14 @@ export function RenderStockItem(
|
||||
|
||||
const allocated: number = Math.max(0, instance?.allocated ?? 0);
|
||||
|
||||
if (instance?.serial !== null && instance?.serial !== undefined) {
|
||||
// Determine if this item is serialized
|
||||
const serialized: boolean =
|
||||
instance?.quantity == 1 &&
|
||||
instance?.serial !== null &&
|
||||
instance?.serial !== undefined &&
|
||||
instance?.serial !== '';
|
||||
|
||||
if (serialized) {
|
||||
quantity_string += `${t`Serial Number`}: ${instance.serial}`;
|
||||
} else if (allocated > 0) {
|
||||
const available: number = Math.max(0, instance.quantity - allocated);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { MantineSize } from '@mantine/core';
|
||||
import type { MantineRadius } from '@mantine/core';
|
||||
|
||||
export const emptyServerAPI = {
|
||||
server: null,
|
||||
@@ -26,7 +26,7 @@ export const emptyServerAPI = {
|
||||
|
||||
export interface SiteMarkProps {
|
||||
value: number;
|
||||
label: MantineSize;
|
||||
label: MantineRadius;
|
||||
}
|
||||
|
||||
export const SizeMarks: SiteMarkProps[] = [
|
||||
|
||||
@@ -52,7 +52,7 @@ export function usePluginPanels({
|
||||
// API query to fetch initial information on available plugin panels
|
||||
const pluginQuery = useQuery({
|
||||
enabled: pluginPanelsEnabled && !!model && id !== undefined,
|
||||
queryKey: ['custom-plugin-panels', model, id],
|
||||
queryKey: ['custom-plugin-panels', model, id, instance],
|
||||
throwOnError: (error: any) => {
|
||||
console.error('ERR: Failed to fetch plugin panels');
|
||||
return false;
|
||||
|
||||
@@ -40,19 +40,19 @@ export function UserTheme({ height }: Readonly<{ height: number }>) {
|
||||
);
|
||||
|
||||
// radius
|
||||
function getMark(value: number) {
|
||||
function getRadiusFromValue(value: number) {
|
||||
const obj = SizeMarks.find((mark) => mark.value === value);
|
||||
if (obj) return obj;
|
||||
return SizeMarks[0];
|
||||
if (obj) return obj.label;
|
||||
return 'sm';
|
||||
}
|
||||
function getDefaultRadius() {
|
||||
const value = Number.parseInt(userTheme.radius.toString());
|
||||
return SizeMarks.some((mark) => mark.value === value) ? value : 50;
|
||||
}
|
||||
const [radius, setRadius] = useState(getDefaultRadius());
|
||||
|
||||
const [radius, setRadius] = useState(25);
|
||||
|
||||
function changeRadius(value: number) {
|
||||
const r = getRadiusFromValue(value);
|
||||
setRadius(value);
|
||||
setTheme([{ key: 'radius', value: value.toString() }]);
|
||||
|
||||
setTheme([{ key: 'radius', value: r.toString() }]);
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -163,7 +163,7 @@ export function UserTheme({ height }: Readonly<{ height: number }>) {
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Slider
|
||||
label={(val) => getMark(val).label}
|
||||
label={(val) => getRadiusFromValue(val)}
|
||||
defaultValue={50}
|
||||
step={25}
|
||||
marks={SizeMarks}
|
||||
|
||||
@@ -32,7 +32,7 @@ import { InvenTreeTable } from '../../tables/InvenTreeTable';
|
||||
function ChartTooltip({ label, payload }: Readonly<ChartTooltipProps>) {
|
||||
const formattedLabel: string = useMemo(() => {
|
||||
if (label && typeof label === 'number') {
|
||||
return formatDate(dayjs().format('YYYY-MM-DD')) ?? label;
|
||||
return formatDate(dayjs(label).format('YYYY-MM-DD')) ?? label;
|
||||
} else if (!!label) {
|
||||
return label.toString();
|
||||
} else {
|
||||
@@ -190,7 +190,7 @@ export default function PartStockHistoryDetail({
|
||||
enableBulkDelete: true,
|
||||
params: {
|
||||
part: partId,
|
||||
ordering: 'date'
|
||||
ordering: '-date'
|
||||
},
|
||||
rowActions: rowActions
|
||||
}}
|
||||
@@ -225,7 +225,7 @@ export default function PartStockHistoryDetail({
|
||||
type: 'number',
|
||||
domain: chartLimits,
|
||||
tickFormatter: (value: number) => {
|
||||
return formatDate(dayjs().format('YYYY-MM-DD'));
|
||||
return formatDate(dayjs(value).format('YYYY-MM-DD'));
|
||||
}
|
||||
}}
|
||||
series={[
|
||||
|
||||
@@ -173,17 +173,12 @@ export default function StockDetail() {
|
||||
stockitem.status_custom_key == stockitem.status
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
name: 'updated',
|
||||
icon: 'calendar',
|
||||
label: t`Last Updated`
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
name: 'stocktake',
|
||||
icon: 'calendar',
|
||||
label: t`Last Stocktake`,
|
||||
hidden: !stockitem.stocktake
|
||||
type: 'link',
|
||||
name: 'link',
|
||||
label: t`Link`,
|
||||
external: true,
|
||||
copy: true,
|
||||
hidden: !stockitem.link
|
||||
}
|
||||
];
|
||||
|
||||
@@ -415,6 +410,19 @@ export default function StockDetail() {
|
||||
icon: 'part',
|
||||
label: t`Packaging`,
|
||||
hidden: !stockitem.packaging
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
name: 'updated',
|
||||
icon: 'calendar',
|
||||
label: t`Last Updated`
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
name: 'stocktake',
|
||||
icon: 'calendar',
|
||||
label: t`Last Stocktake`,
|
||||
hidden: !stockitem.stocktake
|
||||
}
|
||||
];
|
||||
|
||||
|
||||
@@ -31,6 +31,8 @@ interface LocalStateProps {
|
||||
setWidgets: (widgets: string[], noPatch?: boolean) => void;
|
||||
layouts: any;
|
||||
setLayouts: (layouts: any, noPatch?: boolean) => void;
|
||||
showSampleDashboard: boolean;
|
||||
setShowSampleDashboard: (value: boolean) => void;
|
||||
// panels
|
||||
lastUsedPanels: Record<string, string>;
|
||||
setLastUsedPanel: (panelKey: string) => (value: string) => void;
|
||||
@@ -118,6 +120,10 @@ export const useLocalState = create<LocalStateProps>()(
|
||||
if (!noPatch)
|
||||
patchUser('widgets', { widgets: get().widgets, layouts: newLayouts });
|
||||
},
|
||||
showSampleDashboard: true,
|
||||
setShowSampleDashboard: (value) => {
|
||||
set({ showSampleDashboard: value });
|
||||
},
|
||||
// panels
|
||||
lastUsedPanels: {},
|
||||
setLastUsedPanel: (panelKey) => (value) => {
|
||||
|
||||
@@ -325,7 +325,7 @@ export function BomTable({
|
||||
if (on_order > 0) {
|
||||
extra.push(
|
||||
<Text key='on_order'>
|
||||
{t`On order`}: {on_order}
|
||||
{t`On order`}: {formatDecimal(on_order)}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
@@ -333,7 +333,7 @@ export function BomTable({
|
||||
if (building > 0) {
|
||||
extra.push(
|
||||
<Text key='building'>
|
||||
{t`Building`}: {building}
|
||||
{t`Building`}: {formatDecimal(building)}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -183,9 +183,13 @@ export default function BuildAllocatedStockTable({
|
||||
|
||||
const [selectedItems, setSelectedItems] = useState<any[]>([]);
|
||||
|
||||
const itemsToConsume = useMemo(() => {
|
||||
return selectedItems.filter((item) => !item.part_detail?.trackable);
|
||||
}, [selectedItems]);
|
||||
|
||||
const consumeStock = useConsumeBuildItemsForm({
|
||||
buildId: buildId ?? 0,
|
||||
allocatedItems: selectedItems,
|
||||
allocatedItems: itemsToConsume,
|
||||
onFormSuccess: () => {
|
||||
table.clearSelectedRecords();
|
||||
table.refreshTable();
|
||||
@@ -225,13 +229,16 @@ export default function BuildAllocatedStockTable({
|
||||
|
||||
const rowActions = useCallback(
|
||||
(record: any): RowAction[] => {
|
||||
const part = record.part_detail ?? {};
|
||||
const trackable: boolean = part?.trackable ?? false;
|
||||
|
||||
return [
|
||||
{
|
||||
color: 'green',
|
||||
icon: <IconCircleDashedCheck />,
|
||||
title: t`Consume`,
|
||||
tooltip: t`Consume Stock`,
|
||||
hidden: !user.hasChangeRole(UserRoles.build),
|
||||
hidden: !buildId || trackable || !user.hasChangeRole(UserRoles.build),
|
||||
onClick: () => {
|
||||
setSelectedItems([record]);
|
||||
consumeStock.open();
|
||||
|
||||
@@ -479,6 +479,7 @@ export default function BuildLineTable({
|
||||
);
|
||||
}
|
||||
|
||||
const allocated = record.allocatedQuantity ?? 0;
|
||||
let required = Math.max(0, record.quantity - record.consumed);
|
||||
|
||||
if (output?.pk) {
|
||||
@@ -486,7 +487,7 @@ export default function BuildLineTable({
|
||||
required = record.bom_item_detail?.quantity;
|
||||
}
|
||||
|
||||
if (required <= 0) {
|
||||
if (allocated <= 0 && required <= 0) {
|
||||
return (
|
||||
<Group gap='xs' wrap='nowrap'>
|
||||
<IconCircleCheck size={16} color='green' />
|
||||
@@ -502,7 +503,7 @@ export default function BuildLineTable({
|
||||
return (
|
||||
<ProgressBar
|
||||
progressLabel={true}
|
||||
value={record.allocatedQuantity}
|
||||
value={allocated}
|
||||
maximum={required}
|
||||
/>
|
||||
);
|
||||
@@ -664,9 +665,10 @@ export default function BuildLineTable({
|
||||
(record: any): RowAction[] => {
|
||||
const part = record.part_detail ?? {};
|
||||
const in_production = build.status == buildStatus.PRODUCTION;
|
||||
const consumable = record.bom_item_detail?.consumable ?? false;
|
||||
const consumable: boolean = record.bom_item_detail?.consumable ?? false;
|
||||
const trackable: boolean = part?.trackable ?? false;
|
||||
|
||||
const hasOutput = !!output?.pk;
|
||||
const hasOutput: boolean = !!output?.pk;
|
||||
|
||||
const required = Math.max(
|
||||
0,
|
||||
@@ -677,6 +679,7 @@ export default function BuildLineTable({
|
||||
const canConsume =
|
||||
in_production &&
|
||||
!consumable &&
|
||||
!trackable &&
|
||||
record.allocated > 0 &&
|
||||
user.hasChangeRole(UserRoles.build);
|
||||
|
||||
@@ -952,6 +955,9 @@ export default function BuildLineTable({
|
||||
dataFormatter: formatRecords,
|
||||
enableDownload: true,
|
||||
enableSelection: true,
|
||||
enableLabels: true,
|
||||
modelType: ModelType.buildline,
|
||||
onCellClick: () => {},
|
||||
rowExpansion: rowExpansion
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -29,7 +29,7 @@ import type { TableFilter } from '@lib/types/Filters';
|
||||
import type { TableColumn } from '@lib/types/Tables';
|
||||
import { RenderPart } from '../../components/render/Part';
|
||||
import OrderPartsWizard from '../../components/wizards/OrderPartsWizard';
|
||||
import { formatCurrency } from '../../defaults/formatters';
|
||||
import { formatCurrency, formatDecimal } from '../../defaults/formatters';
|
||||
import { useBuildOrderFields } from '../../forms/BuildForms';
|
||||
import {
|
||||
useAllocateToSalesOrderForm,
|
||||
@@ -149,7 +149,7 @@ export default function SalesOrderLineItemTable({
|
||||
);
|
||||
|
||||
let color: string | undefined = undefined;
|
||||
let text = `${available}`;
|
||||
let text = `${formatDecimal(available)}`;
|
||||
|
||||
const extra: ReactNode[] = [];
|
||||
|
||||
@@ -167,7 +167,7 @@ export default function SalesOrderLineItemTable({
|
||||
if (record.building > 0) {
|
||||
extra.push(
|
||||
<Text size='sm'>
|
||||
{t`In production`}: {record.building}
|
||||
{t`In production`}: {formatDecimal(record.building)}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
@@ -175,7 +175,7 @@ export default function SalesOrderLineItemTable({
|
||||
if (record.on_order > 0) {
|
||||
extra.push(
|
||||
<Text size='sm'>
|
||||
{t`On order`}: {record.on_order}
|
||||
{t`On order`}: {formatDecimal(record.on_order)}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -358,6 +358,7 @@ export function UserTable({
|
||||
title: t`Delete user`,
|
||||
successMessage: t`User deleted`,
|
||||
table: table,
|
||||
preFormContent: <></>,
|
||||
preFormWarning: t`Are you sure you want to delete this user?`
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user