Compare commits

...

12 Commits
1.1.3 ... 1.0.0

Author SHA1 Message Date
Oliver
0feb542899 Update InvenTree software version to 1.0.0 (#10319)
Bump to 1.0.0 in preparation for release
2025-09-14 21:22:38 +10:00
github-actions[bot]
4f9c42cbc2 feat(frontend): Add samples for dashboard (#10306) (#10317)
* feat(forntend): Add sampels to dashboard
Closes #9990

* add sessions storage to disable sample dash once cleared/removed

(cherry picked from commit 9679e58212)

Co-authored-by: Matthias Mair <code@mjmair.com>
2025-09-14 20:54:11 +10:00
Oliver
f8310c268c 1.0.0 rc0 (#10312)
- Release candidate for testing release process
2025-09-12 09:56:00 +10:00
github-actions[bot]
91c134f752 Fix link rendering for RenderInlineModel (#10311) (#10313)
(cherry picked from commit f3ec708a28)

Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
2025-09-12 09:47:00 +10:00
Oliver
a57676faef Release version checker (#10304) (#10310)
* Enhance version check regex

* Refactor version_check.py

- Account for non-standard release tags (rc / dev / etc)
- Refactor code for extracting version info
- Add argparse support
- Update qc_checks.yaml

* Enhanced debug output

* Stringify and strip

* Display version tuple in log

* Tweak CI logs
2025-09-12 09:18:31 +10:00
github-actions[bot]
0de265d954 chore(deps): bump django from 4.2.23 to 4.2.24 in /src/backend (#10300) (#10309)
* chore(deps): bump django from 4.2.23 to 4.2.24 in /src/backend

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

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



* fix style

---------




(cherry picked from commit ccfd1c4bf8)

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Matthias Mair <code@mjmair.com>
2025-09-12 09:06:28 +10:00
github-actions[bot]
f4fdad5d48 Refactor (backend): Improve BuildItemList API filters (#10279) (#10303)
* refactor(stock): improve StockList api filters

* update PR numver in api_version

* Update src/backend/InvenTree/InvenTree/api_version.py



* Fix MySQL test failure caused by self-referential FK constraint in StockItem

* Data import fix (#10298)

* Data import fix

- Improved error handling

* Tweak frontend display of errors

* chore(deps-dev): bump vite from 6.3.5 to 6.3.6 in /src/frontend (#10297)

Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 6.3.5 to 6.3.6.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/v6.3.6/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v6.3.6/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 6.3.6
  dependency-type: direct:development
...




---------





(cherry picked from commit 8adfa234bb)

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: Reza <50555450+Reza98Sh@users.noreply.github.com>
Co-authored-by: Matthias Mair <code@mjmair.com>
Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-09-11 09:38:33 +10:00
github-actions[bot]
73d6da8c13 Data import fix (#10298) (#10299)
* Data import fix

- Improved error handling

* Tweak frontend display of errors

(cherry picked from commit 9df896cf7a)

Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
2025-09-10 11:05:26 +10:00
github-actions[bot]
c108fd016d fix(backend): re-add active plugins to anon status (#10282) (#10295)
* this was disabled due to wrong feedback by me, common debug tools do not work because of this

* patch tests

* make mfa test more robust

(cherry picked from commit 2c22686520)

Co-authored-by: Matthias Mair <code@mjmair.com>
2025-09-09 08:25:37 +10:00
github-actions[bot]
2c95c3db29 [UI] Fixes for part stock history (#10293) (#10294)
- Correct default data ordering
- Fix rendering for date labels

(cherry picked from commit b65a3f985d)

Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
2025-09-09 08:06:35 +10:00
github-actions[bot]
8571a42981 Improved formatting (#10284) (#10292)
- Use decimal formatting functions for more places in the UI

(cherry picked from commit 2ac381b4dc)

Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
2025-09-09 06:41:56 +10:00
Oliver
a916e501c3 Bump version number to 1.0.0 (#10283)
- Prep for upcoming release
2025-09-09 06:32:48 +10:00
21 changed files with 442 additions and 192 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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={[

View File

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

View File

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

View File

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