mirror of
https://github.com/inventree/InvenTree.git
synced 2025-12-18 04:45:12 -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 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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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.0'
|
||||
|
||||
|
||||
logger = logging.getLogger('inventree')
|
||||
|
||||
@@ -693,9 +693,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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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,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>
|
||||
) : (
|
||||
|
||||
@@ -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={[
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user