mirror of
https://github.com/inventree/InvenTree.git
synced 2025-12-19 05:11:41 -06:00
Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 itertools
|
||||||
import json
|
import json
|
||||||
import os
|
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')
|
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."""
|
"""Request information on existing releases via the GitHub API."""
|
||||||
# Check for github token
|
# Check for github token
|
||||||
token = os.getenv('GITHUB_TOKEN', None)
|
token = os.getenv('GITHUB_TOKEN', None)
|
||||||
@@ -46,16 +133,16 @@ def get_existing_release_tags(include_prerelease=True):
|
|||||||
|
|
||||||
for release in data:
|
for release in data:
|
||||||
tag = release['tag_name'].strip()
|
tag = release['tag_name'].strip()
|
||||||
match = re.match(r'^.*(\d+)\.(\d+)\.(\d+).*$', tag)
|
|
||||||
|
|
||||||
if len(match.groups()) != 3:
|
version_tuple = version_number_to_tuple(tag)
|
||||||
print(f"Version '{tag}' did not match expected pattern")
|
|
||||||
continue
|
|
||||||
|
|
||||||
if not include_prerelease and release['prerelease']:
|
if len(version_tuple) >= 4 and version_tuple[3]:
|
||||||
continue
|
# 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
|
return tags
|
||||||
|
|
||||||
@@ -67,15 +154,7 @@ def check_version_number(version_string, allow_duplicate=False):
|
|||||||
"""
|
"""
|
||||||
print(f"Checking version '{version_string}'")
|
print(f"Checking version '{version_string}'")
|
||||||
|
|
||||||
# Check that the version string matches the required format
|
version_tuple = version_number_to_tuple(version_string)
|
||||||
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()]
|
|
||||||
|
|
||||||
# Look through the existing releases
|
# Look through the existing releases
|
||||||
existing = get_existing_release_tags(include_prerelease=False)
|
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
|
# Assume that this is the highest release, unless told otherwise
|
||||||
highest_release = True
|
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:
|
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!")
|
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
|
highest_release = False
|
||||||
print(f'Found newer release: {release!s}')
|
print(f'Found newer release: {release!s}')
|
||||||
|
|
||||||
|
if highest_release:
|
||||||
|
print(f"-- Version '{version_string}' is the highest release")
|
||||||
|
|
||||||
return highest_release
|
return highest_release
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
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
|
# Ensure that we are running in GH Actions
|
||||||
if os.environ.get('GITHUB_ACTIONS', '') != 'true':
|
if os.environ.get('GITHUB_ACTIONS', '') != 'true':
|
||||||
print('This script is intended to be run within a GitHub Action!')
|
print('This script is intended to be run within a GitHub Action!')
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
if 'only_version' in sys.argv:
|
print('Running InvenTree version check...')
|
||||||
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)
|
|
||||||
|
|
||||||
# GITHUB_REF_TYPE may be either 'branch' or 'tag'
|
# GITHUB_REF_TYPE may be either 'branch' or 'tag'
|
||||||
GITHUB_REF_TYPE = os.environ['GITHUB_REF_TYPE']
|
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_REF_TYPE: {GITHUB_REF_TYPE}')
|
||||||
print(f'GITHUB_BASE_REF: {GITHUB_BASE_REF}')
|
print(f'GITHUB_BASE_REF: {GITHUB_BASE_REF}')
|
||||||
|
|
||||||
here = Path(__file__).parent.absolute()
|
print(
|
||||||
version_file = here.joinpath(
|
f"InvenTree Version: '{inventree_version}' - {version_number_to_tuple(inventree_version)}"
|
||||||
'..', '..', 'src', 'backend', 'InvenTree', 'InvenTree', 'version.py'
|
|
||||||
)
|
)
|
||||||
|
print(f"InvenTree API Version: '{inventree_api_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}'")
|
|
||||||
|
|
||||||
# Check version number and look for existing versions
|
# Check version number and look for existing versions
|
||||||
# If a release is found which matches the current tag, throw an error
|
# 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':
|
if GITHUB_BASE_REF == 'stable':
|
||||||
allow_duplicate = True
|
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
|
# Determine which docker tag we are going to use
|
||||||
docker_tags = None
|
docker_tags = None
|
||||||
@@ -171,8 +268,10 @@ if __name__ == '__main__':
|
|||||||
version_tag = GITHUB_REF.split('/')[-1]
|
version_tag = GITHUB_REF.split('/')[-1]
|
||||||
print(f"Checking requirements for tagged release - '{version_tag}':")
|
print(f"Checking requirements for tagged release - '{version_tag}':")
|
||||||
|
|
||||||
if version_tag != version:
|
if version_tag != inventree_version:
|
||||||
print(f"Version number '{version}' does not match tag '{version_tag}'")
|
print(
|
||||||
|
f"Version number '{inventree_version}' does not match tag '{version_tag}'"
|
||||||
|
)
|
||||||
sys.exit
|
sys.exit
|
||||||
|
|
||||||
docker_tags = [version_tag, 'stable'] if highest_release else [version_tag]
|
docker_tags = [version_tag, 'stable'] if highest_release else [version_tag]
|
||||||
@@ -180,10 +279,11 @@ if __name__ == '__main__':
|
|||||||
elif GITHUB_REF_TYPE == 'branch':
|
elif GITHUB_REF_TYPE == 'branch':
|
||||||
# Otherwise we know we are targeting the 'master' branch
|
# Otherwise we know we are targeting the 'master' branch
|
||||||
docker_tags = ['latest']
|
docker_tags = ['latest']
|
||||||
|
highest_release = False
|
||||||
|
|
||||||
else:
|
else:
|
||||||
print('Unsupported branch / version combination:')
|
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_REF_TYPE:', GITHUB_REF_TYPE)
|
||||||
print('GITHUB_BASE_REF:', GITHUB_BASE_REF)
|
print('GITHUB_BASE_REF:', GITHUB_BASE_REF)
|
||||||
print('GITHUB_REF:', GITHUB_REF)
|
print('GITHUB_REF:', GITHUB_REF)
|
||||||
@@ -193,7 +293,7 @@ if __name__ == '__main__':
|
|||||||
print('Docker tags could not be determined')
|
print('Docker tags could not be determined')
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
print(f"Version check passed for '{version}'!")
|
print(f"Version check passed for '{inventree_version}'!")
|
||||||
print(f"Docker tags: '{docker_tags}'")
|
print(f"Docker tags: '{docker_tags}'")
|
||||||
|
|
||||||
target_repos = [REPO.lower(), f'ghcr.io/{REPO.lower()}']
|
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 }}
|
API: ${{ needs.paths-filter.outputs.api }}
|
||||||
run: |
|
run: |
|
||||||
pip install --require-hashes -r contrib/dev_reqs/requirements.txt >/dev/null 2>&1
|
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)"
|
version="$(python3 .github/scripts/version_check.py --show-api-version --decrement-api=${API} 2>&1)"
|
||||||
echo "Version: $version"
|
echo "API Version: $version"
|
||||||
url="https://raw.githubusercontent.com/inventree/schema/main/export/${version}/api.yaml"
|
url="https://raw.githubusercontent.com/inventree/schema/main/export/${version}/api.yaml"
|
||||||
echo "URL: $url"
|
echo "URL: $url"
|
||||||
code=$(curl -s -o api.yaml $url --write-out '%{http_code}' --silent)
|
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'
|
if: github.ref == 'refs/heads/master' && needs.paths-filter.outputs.api == 'true'
|
||||||
run: |
|
run: |
|
||||||
pip install --require-hashes -r contrib/dev_reqs/requirements.txt >/dev/null 2>&1
|
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)"
|
version="$(python3 .github/scripts/version_check.py --show-api-version 2>&1)"
|
||||||
echo "Version: $version"
|
echo "API Version: $version"
|
||||||
echo "version=$version" >> "$GITHUB_OUTPUT"
|
echo "version=$version" >> "$GITHUB_OUTPUT"
|
||||||
- name: Extract settings / tags
|
- name: Extract settings / tags
|
||||||
run: invoke int.export-definitions --basedir docs
|
run: invoke int.export-definitions --basedir docs
|
||||||
|
|||||||
@@ -305,8 +305,8 @@ class InfoView(APIView):
|
|||||||
'login_message': helpers.getCustomOption('login_message'),
|
'login_message': helpers.getCustomOption('login_message'),
|
||||||
'navbar_message': helpers.getCustomOption('navbar_message'),
|
'navbar_message': helpers.getCustomOption('navbar_message'),
|
||||||
},
|
},
|
||||||
|
'active_plugins': plugins_info(),
|
||||||
# Following fields are only available to staff users
|
# 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,
|
'system_health': check_system_health() if is_staff else None,
|
||||||
'database': InvenTree.version.inventreeDatabase() if is_staff else None,
|
'database': InvenTree.version.inventreeDatabase() if is_staff else None,
|
||||||
'platform': InvenTree.version.inventreePlatform() 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 information."""
|
||||||
|
|
||||||
# InvenTree API version
|
# 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."""
|
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
|
||||||
|
|
||||||
INVENTREE_API_TEXT = """
|
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
|
v390 -> 2025-09-03 : https://github.com/inventree/InvenTree/pull/10257
|
||||||
- Fixes limitation on adding virtual parts to a SalesOrder
|
- Fixes limitation on adding virtual parts to a SalesOrder
|
||||||
- Additional query filter options for BomItem API endpoint
|
- Additional query filter options for BomItem API endpoint
|
||||||
|
|||||||
@@ -605,9 +605,7 @@ class GeneralApiTests(InvenTreeAPITestCase):
|
|||||||
response = self.get(url, max_query_count=20)
|
response = self.get(url, max_query_count=20)
|
||||||
data = response.json()
|
data = response.json()
|
||||||
self.assertEqual(data['database'], None)
|
self.assertEqual(data['database'], None)
|
||||||
|
self.assertIsNotNone(data.get('active_plugins'))
|
||||||
# No active plugin info for anon user
|
|
||||||
self.assertIsNone(data.get('active_plugins'))
|
|
||||||
|
|
||||||
# Staff
|
# Staff
|
||||||
response = self.get(
|
response = self.get(
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ from django.conf import settings
|
|||||||
from .api_version import INVENTREE_API_TEXT, INVENTREE_API_VERSION
|
from .api_version import INVENTREE_API_TEXT, INVENTREE_API_VERSION
|
||||||
|
|
||||||
# InvenTree software version
|
# InvenTree software version
|
||||||
INVENTREE_SW_VERSION = '0.18.0 dev'
|
INVENTREE_SW_VERSION = '1.0.0'
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger('inventree')
|
logger = logging.getLogger('inventree')
|
||||||
|
|||||||
@@ -693,9 +693,20 @@ class DataImportRow(models.Model):
|
|||||||
try:
|
try:
|
||||||
instance = self.session.model_class.objects.get(pk=instance_id)
|
instance = self.session.model_class.objects.get(pk=instance_id)
|
||||||
except self.session.model_class.DoesNotExist:
|
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:
|
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)
|
serializer = self.construct_serializer(instance=instance, request=request)
|
||||||
|
|
||||||
|
|||||||
@@ -174,8 +174,8 @@ class StaleStockNotificationTests(InvenTreeTestCase):
|
|||||||
def test_check_stale_stock_no_stale_items(self):
|
def test_check_stale_stock_no_stale_items(self):
|
||||||
"""Test check_stale_stock when no stale items exist."""
|
"""Test check_stale_stock when no stale items exist."""
|
||||||
# Clear all existing stock items
|
# Clear all existing stock items
|
||||||
|
stock.models.StockItem.objects.update(parent=None)
|
||||||
stock.models.StockItem.objects.all().delete()
|
stock.models.StockItem.objects.all().delete()
|
||||||
|
|
||||||
# Create only future expiry items
|
# Create only future expiry items
|
||||||
today = helpers.current_date()
|
today = helpers.current_date()
|
||||||
stock.models.StockItem.objects.create(
|
stock.models.StockItem.objects.create(
|
||||||
@@ -194,6 +194,7 @@ class StaleStockNotificationTests(InvenTreeTestCase):
|
|||||||
def test_check_stale_stock_with_stale_items(self, mock_offload):
|
def test_check_stale_stock_with_stale_items(self, mock_offload):
|
||||||
"""Test check_stale_stock when stale items exist."""
|
"""Test check_stale_stock when stale items exist."""
|
||||||
# Clear existing stock items
|
# Clear existing stock items
|
||||||
|
stock.models.StockItem.objects.update(parent=None)
|
||||||
stock.models.StockItem.objects.all().delete()
|
stock.models.StockItem.objects.all().delete()
|
||||||
|
|
||||||
self.create_stock_items_with_expiry()
|
self.create_stock_items_with_expiry()
|
||||||
@@ -229,6 +230,7 @@ class StaleStockNotificationTests(InvenTreeTestCase):
|
|||||||
def test_check_stale_stock_filtering(self):
|
def test_check_stale_stock_filtering(self):
|
||||||
"""Test that check_stale_stock properly filters stock items."""
|
"""Test that check_stale_stock properly filters stock items."""
|
||||||
# Clear all existing stock items first
|
# Clear all existing stock items first
|
||||||
|
stock.models.StockItem.objects.update(parent=None)
|
||||||
stock.models.StockItem.objects.all().delete()
|
stock.models.StockItem.objects.all().delete()
|
||||||
|
|
||||||
today = helpers.current_date()
|
today = helpers.current_date()
|
||||||
|
|||||||
@@ -39,8 +39,9 @@ from InvenTree.filters import (
|
|||||||
SEARCH_ORDER_FILTER,
|
SEARCH_ORDER_FILTER,
|
||||||
SEARCH_ORDER_FILTER_ALIAS,
|
SEARCH_ORDER_FILTER_ALIAS,
|
||||||
InvenTreeDateFilter,
|
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 (
|
from InvenTree.mixins import (
|
||||||
CreateAPI,
|
CreateAPI,
|
||||||
CustomRetrieveUpdateDestroyAPI,
|
CustomRetrieveUpdateDestroyAPI,
|
||||||
@@ -933,6 +934,66 @@ class StockFilter(rest_filters.FilterSet):
|
|||||||
else:
|
else:
|
||||||
return queryset.exclude(stale_filter)
|
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:
|
class StockApiMixin:
|
||||||
"""Mixin class for StockItem API endpoints."""
|
"""Mixin class for StockItem API endpoints."""
|
||||||
@@ -1191,52 +1252,6 @@ class StockList(DataExportViewMixin, StockApiMixin, ListCreateDestroyAPIView):
|
|||||||
headers=self.get_success_headers(serializer.data),
|
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
|
filter_backends = SEARCH_ORDER_FILTER_ALIAS
|
||||||
|
|
||||||
ordering_field_aliases = {
|
ordering_field_aliases = {
|
||||||
|
|||||||
@@ -274,18 +274,19 @@
|
|||||||
tree_id: 9
|
tree_id: 9
|
||||||
level: 0
|
level: 0
|
||||||
lft: 1
|
lft: 1
|
||||||
rght: 2
|
rght: 4
|
||||||
|
|
||||||
- model: stock.stockitem
|
- model: stock.stockitem
|
||||||
pk: 1001
|
pk: 1001
|
||||||
fields:
|
fields:
|
||||||
part: 100
|
part: 100
|
||||||
|
parent: 1000
|
||||||
location: 1
|
location: 1
|
||||||
quantity: 11
|
quantity: 11
|
||||||
tree_id: 14
|
tree_id: 9
|
||||||
level: 0
|
level: 1
|
||||||
lft: 1
|
lft: 2
|
||||||
rght: 2
|
rght: 3
|
||||||
|
|
||||||
- model: stock.stockitem
|
- model: stock.stockitem
|
||||||
pk: 1002
|
pk: 1002
|
||||||
|
|||||||
@@ -621,6 +621,13 @@ class StockItemListTest(StockAPITestCase):
|
|||||||
response = self.get_stock(location=7)
|
response = self.get_stock(location=7)
|
||||||
self.assertEqual(len(response), 18)
|
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):
|
def test_filter_by_depleted(self):
|
||||||
"""Filter StockItem by depleted status."""
|
"""Filter StockItem by depleted status."""
|
||||||
response = self.get_stock(depleted=1)
|
response = self.get_stock(depleted=1)
|
||||||
@@ -786,10 +793,10 @@ class StockItemListTest(StockAPITestCase):
|
|||||||
def test_filter_has_child_items(self):
|
def test_filter_has_child_items(self):
|
||||||
"""Filter StockItem by has_child_items."""
|
"""Filter StockItem by has_child_items."""
|
||||||
response = self.get_stock(has_child_items=True)
|
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)
|
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):
|
def test_filter_sent_to_customer(self):
|
||||||
"""Filter StockItem by sent_to_customer."""
|
"""Filter StockItem by sent_to_customer."""
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
"""Unit tests for the 'users' app."""
|
"""Unit tests for the 'users' app."""
|
||||||
|
|
||||||
|
from time import sleep
|
||||||
|
|
||||||
from django.apps import apps
|
from django.apps import apps
|
||||||
from django.contrib.auth.models import Group
|
from django.contrib.auth.models import Group
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
@@ -349,12 +351,21 @@ class MFALoginTest(InvenTreeAPITestCase):
|
|||||||
response = self.post(login_url, auth_data, expected_code=200)
|
response = self.post(login_url, auth_data, expected_code=200)
|
||||||
self._helper_meta_val(response)
|
self._helper_meta_val(response)
|
||||||
|
|
||||||
# Add MFA
|
# Add MFA - trying in a limited loop in case of timing issues
|
||||||
response = self.post(
|
success: bool = False
|
||||||
reverse('browser:mfa:manage_totp'),
|
for _ in range(10):
|
||||||
{'code': self.get_topt()},
|
try:
|
||||||
expected_code=200,
|
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
|
# There must be a TOTP device now - success
|
||||||
self.get(reverse('browser:mfa:manage_totp'), expected_code=200)
|
self.get(reverse('browser:mfa:manage_totp'), expected_code=200)
|
||||||
self.get(reverse('api-token'), expected_code=200)
|
self.get(reverse('api-token'), expected_code=200)
|
||||||
|
|||||||
@@ -320,9 +320,9 @@ distlib==0.4.0 \
|
|||||||
--hash=sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16 \
|
--hash=sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16 \
|
||||||
--hash=sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d
|
--hash=sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d
|
||||||
# via virtualenv
|
# via virtualenv
|
||||||
django==4.2.23 \
|
django==4.2.24 \
|
||||||
--hash=sha256:42fdeaba6e6449d88d4f66de47871015097dc6f1b87910db00a91946295cfae4 \
|
--hash=sha256:40cd7d3f53bc6cd1902eadce23c337e97200888df41e4a73b42d682f23e71d80 \
|
||||||
--hash=sha256:dafbfaf52c2f289bd65f4ab935791cb4fb9a198f2a5ba9faf35d7338a77e9803
|
--hash=sha256:a6527112c58821a0dfc5ab73013f0bdd906539790a17196658e36e66af43c350
|
||||||
# via
|
# via
|
||||||
# -c src/backend/requirements.txt
|
# -c src/backend/requirements.txt
|
||||||
# django-slowtests
|
# django-slowtests
|
||||||
|
|||||||
@@ -391,9 +391,9 @@ defusedxml==0.7.1 \
|
|||||||
--hash=sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69 \
|
--hash=sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69 \
|
||||||
--hash=sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61
|
--hash=sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61
|
||||||
# via python3-openid
|
# via python3-openid
|
||||||
django==4.2.23 \
|
django==4.2.24 \
|
||||||
--hash=sha256:42fdeaba6e6449d88d4f66de47871015097dc6f1b87910db00a91946295cfae4 \
|
--hash=sha256:40cd7d3f53bc6cd1902eadce23c337e97200888df41e4a73b42d682f23e71d80 \
|
||||||
--hash=sha256:dafbfaf52c2f289bd65f4ab935791cb4fb9a198f2a5ba9faf35d7338a77e9803
|
--hash=sha256:a6527112c58821a0dfc5ab73013f0bdd906539790a17196658e36e66af43c350
|
||||||
# via
|
# via
|
||||||
# -r src/backend/requirements.in
|
# -r src/backend/requirements.in
|
||||||
# django-allauth
|
# django-allauth
|
||||||
|
|||||||
@@ -1,5 +1,13 @@
|
|||||||
import { t } from '@lingui/core/macro';
|
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 { useDisclosure, useHotkeys } from '@mantine/hooks';
|
||||||
import { IconExclamationCircle, IconInfoCircle } from '@tabler/icons-react';
|
import { IconExclamationCircle, IconInfoCircle } from '@tabler/icons-react';
|
||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
@@ -21,15 +29,23 @@ export default function DashboardLayout() {
|
|||||||
const [widgets, setWidgets] = useState<DashboardWidgetProps[]>([]);
|
const [widgets, setWidgets] = useState<DashboardWidgetProps[]>([]);
|
||||||
|
|
||||||
// local/remote storage values for widget / layout
|
// local/remote storage values for widget / layout
|
||||||
const [remoteWidgets, setRemoteWidgets, remoteLayouts, setRemoteLayouts] =
|
const [
|
||||||
useLocalState(
|
remoteWidgets,
|
||||||
useShallow((state) => [
|
setRemoteWidgets,
|
||||||
state.widgets,
|
remoteLayouts,
|
||||||
state.setWidgets,
|
setRemoteLayouts,
|
||||||
state.layouts,
|
showSampleDashboard,
|
||||||
state.setLayouts
|
setShowSampleDashboard
|
||||||
])
|
] = useLocalState(
|
||||||
);
|
useShallow((state) => [
|
||||||
|
state.widgets,
|
||||||
|
state.setWidgets,
|
||||||
|
state.layouts,
|
||||||
|
state.setLayouts,
|
||||||
|
state.showSampleDashboard,
|
||||||
|
state.setShowSampleDashboard
|
||||||
|
])
|
||||||
|
);
|
||||||
|
|
||||||
const [editing, setEditing] = useDisclosure(false);
|
const [editing, setEditing] = useDisclosure(false);
|
||||||
const [removing, setRemoving] = useDisclosure(false);
|
const [removing, setRemoving] = useDisclosure(false);
|
||||||
@@ -75,6 +91,9 @@ export default function DashboardLayout() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (newWidget) {
|
if (newWidget) {
|
||||||
|
if (showSampleDashboard) {
|
||||||
|
setShowSampleDashboard(false);
|
||||||
|
}
|
||||||
setWidgets([...widgets, newWidget]);
|
setWidgets([...widgets, newWidget]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -195,10 +214,48 @@ export default function DashboardLayout() {
|
|||||||
|
|
||||||
// Clear all widgets from the dashboard
|
// Clear all widgets from the dashboard
|
||||||
const clearWidgets = useCallback(() => {
|
const clearWidgets = useCallback(() => {
|
||||||
|
if (showSampleDashboard) {
|
||||||
|
setShowSampleDashboard(false);
|
||||||
|
}
|
||||||
setWidgets([]);
|
setWidgets([]);
|
||||||
setLayouts({});
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<DashboardWidgetDrawer
|
<DashboardWidgetDrawer
|
||||||
@@ -228,43 +285,41 @@ export default function DashboardLayout() {
|
|||||||
{layouts && loaded && availableWidgets.loaded ? (
|
{layouts && loaded && availableWidgets.loaded ? (
|
||||||
<>
|
<>
|
||||||
{widgetLabels.length == 0 ? (
|
{widgetLabels.length == 0 ? (
|
||||||
<Center>
|
<>
|
||||||
<Card shadow='xs' padding='xl' style={{ width: '100%' }}>
|
<Center>
|
||||||
<Alert
|
<Card shadow='xs' padding='xl' style={{ width: '100%' }}>
|
||||||
color='blue'
|
<Alert
|
||||||
title={t`No Widgets Selected`}
|
color='blue'
|
||||||
icon={<IconInfoCircle />}
|
title={t`No Widgets Selected`}
|
||||||
>
|
icon={<IconInfoCircle />}
|
||||||
<Text>{t`Use the menu to add widgets to the dashboard`}</Text>
|
>
|
||||||
</Alert>
|
<Text>{t`Use the menu to add widgets to the dashboard`}</Text>
|
||||||
</Card>
|
</Alert>
|
||||||
</Center>
|
</Card>
|
||||||
|
</Center>
|
||||||
|
{showSampleDashboard && (
|
||||||
|
<>
|
||||||
|
<Space h='lg' />
|
||||||
|
{WidgetGrid(
|
||||||
|
defaultLayouts,
|
||||||
|
() => {},
|
||||||
|
editing,
|
||||||
|
defaultWidgets,
|
||||||
|
removing,
|
||||||
|
() => {}
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
<ReactGridLayout
|
WidgetGrid(
|
||||||
className='dashboard-layout'
|
layouts,
|
||||||
breakpoints={{ lg: 1200, md: 996, sm: 768, xs: 480, xxs: 0 }}
|
onLayoutChange,
|
||||||
cols={{ lg: 12, md: 10, sm: 6, xs: 4, xxs: 2 }}
|
editing,
|
||||||
rowHeight={64}
|
widgets,
|
||||||
layouts={layouts}
|
removing,
|
||||||
onLayoutChange={onLayoutChange}
|
removeWidget
|
||||||
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>
|
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
@@ -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} />
|
<IconCircleDashedCheck color='blue' size={16} />
|
||||||
)}
|
)}
|
||||||
{!row.complete && !row.valid && (
|
{!row.complete && !row.valid && (
|
||||||
<HoverCard openDelay={50} closeDelay={100}>
|
<HoverCard openDelay={50} closeDelay={100} position='top-start'>
|
||||||
<HoverCard.Target>
|
<HoverCard.Target>
|
||||||
<IconExclamationCircle color='red' size={16} />
|
<IconExclamationCircle color='red' size={16} />
|
||||||
</HoverCard.Target>
|
</HoverCard.Target>
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import { type ReactNode, useCallback } from 'react';
|
|||||||
import { ModelInformationDict } from '@lib/enums/ModelInformation';
|
import { ModelInformationDict } from '@lib/enums/ModelInformation';
|
||||||
import { ModelType } from '@lib/enums/ModelType';
|
import { ModelType } from '@lib/enums/ModelType';
|
||||||
import { apiUrl } from '@lib/functions/Api';
|
import { apiUrl } from '@lib/functions/Api';
|
||||||
import { navigateToLink } from '@lib/functions/Navigation';
|
import { getBaseUrl, navigateToLink } from '@lib/functions/Navigation';
|
||||||
import type {
|
import type {
|
||||||
ModelRendererDict,
|
ModelRendererDict,
|
||||||
RenderInstanceProps
|
RenderInstanceProps
|
||||||
@@ -219,7 +219,10 @@ export function RenderInlineModel({
|
|||||||
{prefix}
|
{prefix}
|
||||||
{image && <Thumbnail src={image} size={18} />}
|
{image && <Thumbnail src={image} size={18} />}
|
||||||
{url ? (
|
{url ? (
|
||||||
<Anchor href='' onClick={(event: any) => onClick(event)}>
|
<Anchor
|
||||||
|
href={`/${getBaseUrl()}${url}`}
|
||||||
|
onClick={(event: any) => onClick(event)}
|
||||||
|
>
|
||||||
{primary}
|
{primary}
|
||||||
</Anchor>
|
</Anchor>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ import { InvenTreeTable } from '../../tables/InvenTreeTable';
|
|||||||
function ChartTooltip({ label, payload }: Readonly<ChartTooltipProps>) {
|
function ChartTooltip({ label, payload }: Readonly<ChartTooltipProps>) {
|
||||||
const formattedLabel: string = useMemo(() => {
|
const formattedLabel: string = useMemo(() => {
|
||||||
if (label && typeof label === 'number') {
|
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) {
|
} else if (!!label) {
|
||||||
return label.toString();
|
return label.toString();
|
||||||
} else {
|
} else {
|
||||||
@@ -190,7 +190,7 @@ export default function PartStockHistoryDetail({
|
|||||||
enableBulkDelete: true,
|
enableBulkDelete: true,
|
||||||
params: {
|
params: {
|
||||||
part: partId,
|
part: partId,
|
||||||
ordering: 'date'
|
ordering: '-date'
|
||||||
},
|
},
|
||||||
rowActions: rowActions
|
rowActions: rowActions
|
||||||
}}
|
}}
|
||||||
@@ -225,7 +225,7 @@ export default function PartStockHistoryDetail({
|
|||||||
type: 'number',
|
type: 'number',
|
||||||
domain: chartLimits,
|
domain: chartLimits,
|
||||||
tickFormatter: (value: number) => {
|
tickFormatter: (value: number) => {
|
||||||
return formatDate(dayjs().format('YYYY-MM-DD'));
|
return formatDate(dayjs(value).format('YYYY-MM-DD'));
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
series={[
|
series={[
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ interface LocalStateProps {
|
|||||||
setWidgets: (widgets: string[], noPatch?: boolean) => void;
|
setWidgets: (widgets: string[], noPatch?: boolean) => void;
|
||||||
layouts: any;
|
layouts: any;
|
||||||
setLayouts: (layouts: any, noPatch?: boolean) => void;
|
setLayouts: (layouts: any, noPatch?: boolean) => void;
|
||||||
|
showSampleDashboard: boolean;
|
||||||
|
setShowSampleDashboard: (value: boolean) => void;
|
||||||
// panels
|
// panels
|
||||||
lastUsedPanels: Record<string, string>;
|
lastUsedPanels: Record<string, string>;
|
||||||
setLastUsedPanel: (panelKey: string) => (value: string) => void;
|
setLastUsedPanel: (panelKey: string) => (value: string) => void;
|
||||||
@@ -118,6 +120,10 @@ export const useLocalState = create<LocalStateProps>()(
|
|||||||
if (!noPatch)
|
if (!noPatch)
|
||||||
patchUser('widgets', { widgets: get().widgets, layouts: newLayouts });
|
patchUser('widgets', { widgets: get().widgets, layouts: newLayouts });
|
||||||
},
|
},
|
||||||
|
showSampleDashboard: true,
|
||||||
|
setShowSampleDashboard: (value) => {
|
||||||
|
set({ showSampleDashboard: value });
|
||||||
|
},
|
||||||
// panels
|
// panels
|
||||||
lastUsedPanels: {},
|
lastUsedPanels: {},
|
||||||
setLastUsedPanel: (panelKey) => (value) => {
|
setLastUsedPanel: (panelKey) => (value) => {
|
||||||
|
|||||||
@@ -325,7 +325,7 @@ export function BomTable({
|
|||||||
if (on_order > 0) {
|
if (on_order > 0) {
|
||||||
extra.push(
|
extra.push(
|
||||||
<Text key='on_order'>
|
<Text key='on_order'>
|
||||||
{t`On order`}: {on_order}
|
{t`On order`}: {formatDecimal(on_order)}
|
||||||
</Text>
|
</Text>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -333,7 +333,7 @@ export function BomTable({
|
|||||||
if (building > 0) {
|
if (building > 0) {
|
||||||
extra.push(
|
extra.push(
|
||||||
<Text key='building'>
|
<Text key='building'>
|
||||||
{t`Building`}: {building}
|
{t`Building`}: {formatDecimal(building)}
|
||||||
</Text>
|
</Text>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ import type { TableFilter } from '@lib/types/Filters';
|
|||||||
import type { TableColumn } from '@lib/types/Tables';
|
import type { TableColumn } from '@lib/types/Tables';
|
||||||
import { RenderPart } from '../../components/render/Part';
|
import { RenderPart } from '../../components/render/Part';
|
||||||
import OrderPartsWizard from '../../components/wizards/OrderPartsWizard';
|
import OrderPartsWizard from '../../components/wizards/OrderPartsWizard';
|
||||||
import { formatCurrency } from '../../defaults/formatters';
|
import { formatCurrency, formatDecimal } from '../../defaults/formatters';
|
||||||
import { useBuildOrderFields } from '../../forms/BuildForms';
|
import { useBuildOrderFields } from '../../forms/BuildForms';
|
||||||
import {
|
import {
|
||||||
useAllocateToSalesOrderForm,
|
useAllocateToSalesOrderForm,
|
||||||
@@ -149,7 +149,7 @@ export default function SalesOrderLineItemTable({
|
|||||||
);
|
);
|
||||||
|
|
||||||
let color: string | undefined = undefined;
|
let color: string | undefined = undefined;
|
||||||
let text = `${available}`;
|
let text = `${formatDecimal(available)}`;
|
||||||
|
|
||||||
const extra: ReactNode[] = [];
|
const extra: ReactNode[] = [];
|
||||||
|
|
||||||
@@ -167,7 +167,7 @@ export default function SalesOrderLineItemTable({
|
|||||||
if (record.building > 0) {
|
if (record.building > 0) {
|
||||||
extra.push(
|
extra.push(
|
||||||
<Text size='sm'>
|
<Text size='sm'>
|
||||||
{t`In production`}: {record.building}
|
{t`In production`}: {formatDecimal(record.building)}
|
||||||
</Text>
|
</Text>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -175,7 +175,7 @@ export default function SalesOrderLineItemTable({
|
|||||||
if (record.on_order > 0) {
|
if (record.on_order > 0) {
|
||||||
extra.push(
|
extra.push(
|
||||||
<Text size='sm'>
|
<Text size='sm'>
|
||||||
{t`On order`}: {record.on_order}
|
{t`On order`}: {formatDecimal(record.on_order)}
|
||||||
</Text>
|
</Text>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user