Compare commits

...

32 Commits
1.1.x ... 1.0.2

Author SHA1 Message Date
github-actions[bot]
1d2700fa6f [UI] Consume tracked (#10422) (#10424)
* Prevent manual consumption of tracked stock

* Prevent manual consuming of trackable items

(cherry picked from commit b0a60ed963)

Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
2025-09-29 14:01:43 +10:00
github-actions[bot]
9c170e6ed3 Fix locale formatting for calendar display (#10418) (#10420)
- Cannot accept underscore

(cherry picked from commit bcc386aecf)

Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
2025-09-29 11:31:49 +10:00
github-actions[bot]
542a75ce58 [UI] Enable printing of build lines (#10403) (#10410)
* [UI] Enable printing of build lines

- Closes https://github.com/inventree/InvenTree/issues/10402

* Prevent cell click action

(cherry picked from commit e897222e07)

Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
2025-09-27 14:30:43 +10:00
github-actions[bot]
06750358d6 [bug] Auto allocate bugfix (#10398) (#10407)
* Fix "unallocated_quantity" calculation

- Take "consumed" quantity into account also

* Account for consumed quantity in:

- build.is_fully_allocated
- build.is_overallocated

* Additional unit tests

- Ensure the new calculations work properly

* Adjust API filter

* Try splitting query

* Another fix

* Try ExpressionWrapper

* Change order of operations?

* Refactor

* Adjust filtering strategy

* Change ordering

* Use Max wrapper

* Add comments

(cherry picked from commit 6fdc6b3a8c)

Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
2025-09-27 10:23:06 +10:00
github-actions[bot]
6ccc4544be Fix typo (#10400) (#10401)
(cherry picked from commit 52be30eef5)

Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
2025-09-26 13:47:10 +10:00
github-actions[bot]
ac324cff14 Tweak build line table (#10397) (#10399)
- Show allocated quantity even if fully consumed
- Handles edge case where fully consumed but more stock allocated

(cherry picked from commit 1670523dab)

Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
2025-09-26 11:37:09 +10:00
github-actions[bot]
98b1678402 fix: correct user deletion (#10385) (#10386)
* Ensure all user sessions are cleared

* remove double warning text on user delete

(cherry picked from commit 4794d69687)

Co-authored-by: Matthias Mair <code@mjmair.com>
2025-09-24 10:30:29 +10:00
github-actions[bot]
da3ebacf6b Hide "consume" action when not viewed from build page (#10378) (#10379)
(cherry picked from commit a7b1b9d523)

Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
2025-09-23 21:08:54 +10:00
github-actions[bot]
24b2401f6a Handle null user case (#10362) (#10365)
(cherry picked from commit 2f357587bc)

Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
2025-09-20 13:32:45 +10:00
github-actions[bot]
d12c335032 Support import of "choice" fields (#10361) (#10364)
- Perform reverse lookup of display value

(cherry picked from commit bbfdcdce73)

Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
2025-09-20 12:56:53 +10:00
Oliver
eae580deaf Bump software version to 1.0.2 (#10360) 2025-09-20 10:15:49 +10:00
github-actions[bot]
6207aa6c32 Improved error handling (#10352) (#10354)
- Closes https://github.com/inventree/InvenTree/issues/10338

(cherry picked from commit f4333bd83f)

Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
2025-09-19 18:43:00 +10:00
github-actions[bot]
5ff7ce4703 fix(backend): better siteurl testing in middleware (#10335) (#10353)
* fix(backend): simplify siteurl testing

* add multi-site test

* pass off site_url check if more than one trusted origin is set

* split up testing

* add temporary debug info

* fix test enviorment

(cherry picked from commit 4b0acad518)

Co-authored-by: Matthias Mair <code@mjmair.com>
2025-09-19 18:42:33 +10:00
github-actions[bot]
0350623866 [UI] Display Stock link (#10350) (#10351)
- Display "link" for stock item

(cherry picked from commit 843dd92901)

Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
2025-09-19 10:50:31 +10:00
github-actions[bot]
bb576b16d8 fix bug I introduced with automatic EmailAddress creation for LDAP users (#10347) (#10349)
(cherry picked from commit fd57b5354b)

Co-authored-by: Jacob Felknor <jacobfelknor073@gmail.com>
2025-09-19 08:14:03 +10:00
github-actions[bot]
9705ea90c2 UI panels fix (#10341) (#10342)
* Tweak sample plugin

* Re-fetch panels when instance changes

* Unit test fix

(cherry picked from commit df0e27bed2)

Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
2025-09-18 13:31:43 +10:00
github-actions[bot]
39dc2b17fd Fix for RenderStockItem (#10336) (#10337)
- Handle case where serial number is empty string

(cherry picked from commit f057247fc1)

Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
2025-09-17 13:47:45 +10:00
github-actions[bot]
00646b0891 Exclude field from stock-item import (#10333) (#10334)
(cherry picked from commit a6e555708f)

Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
2025-09-17 10:05:27 +10:00
Oliver
2b7627a940 Bump dummy plugin version (#10330)
* Bump dummy plugin version

- Required, else newer versions fail CI

* Bump version number to 1.0.1
2025-09-16 11:45:33 +10:00
github-actions[bot]
048ece4797 Fix user defined radius (#10327) (#10328)
- Observe correct radius values
- Closes https://github.com/inventree/InvenTree/issues/10322

(cherry picked from commit 5727999d4d)

Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
2025-09-16 10:25:28 +10:00
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
44 changed files with 793 additions and 298 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -237,11 +237,18 @@ class InvenTreeHostSettingsMiddleware(MiddlewareMixin):
# Ensure that the settings are set correctly with the current request
accessed_scheme = request._current_scheme_host
if accessed_scheme and not accessed_scheme.startswith(settings.SITE_URL):
msg = f'INVE-E7: The used path `{accessed_scheme}` does not match the SITE_URL `{settings.SITE_URL}`'
logger.error(msg)
return render(
request, 'config_error.html', {'error_message': msg}, status=500
)
if (
isinstance(settings.CSRF_TRUSTED_ORIGINS, list)
and len(settings.CSRF_TRUSTED_ORIGINS) > 1
):
# The used url might not be the primary url - next check determines if in a trusted origins
pass
else:
msg = f'INVE-E7: The used path `{accessed_scheme}` does not match the SITE_URL `{settings.SITE_URL}`'
logger.error(msg)
return render(
request, 'config_error.html', {'error_message': msg}, status=500
)
# Check trusted origins
referer = urlsplit(accessed_scheme)

View File

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

View File

@@ -1,5 +1,6 @@
"""Tests for middleware functions."""
from django.conf import settings
from django.http import Http404
from django.urls import reverse
@@ -87,36 +88,72 @@ class MiddlewareTests(InvenTreeTestCase):
log_error('testpath')
check(1)
def do_positive_test(self, response):
"""Helper function to check for positive test results."""
self.assertEqual(response.status_code, 200)
self.assertNotContains(response, 'INVE-E7')
self.assertContains(response, 'window.INVENTREE_SETTINGS')
def test_site_url_checks(self):
"""Test that the site URL check is correctly working."""
# correctly set
# simple setup
with self.settings(
SITE_URL='http://testserver', CSRF_TRUSTED_ORIGINS=['http://testserver']
):
response = self.client.get(reverse('web'))
self.assertEqual(response.status_code, 200)
self.assertNotContains(response, 'INVE-E7')
self.assertContains(response, 'window.INVENTREE_SETTINGS')
self.do_positive_test(response)
# wrongly set site URL
with self.settings(SITE_URL='https://example.com'):
# simple setup with wildcard
with self.settings(
SITE_URL='http://testserver', CSRF_TRUSTED_ORIGINS=['http://*.testserver']
):
response = self.client.get(reverse('web'))
self.assertEqual(response.status_code, 500)
self.assertContains(
response,
'INVE-E7: The used path `http://testserver` does not match',
status_code=500,
)
self.assertNotContains(
response, 'window.INVENTREE_SETTINGS', status_code=500
)
self.do_positive_test(response)
def test_site_url_checks_multi(self):
"""Test that the site URL check is correctly working in a multi-site setup."""
# multi-site setup with trusted origins
with self.settings(
SITE_URL='https://testserver.example.com',
CSRF_TRUSTED_ORIGINS=[
'http://testserver',
'https://testserver.example.com',
],
):
# this will run with testserver as host by default
response = self.client.get(reverse('web'))
self.do_positive_test(response)
# Now test with the "outside" url name
response = self.client.get(
'https://testserver.example.com/web/',
SERVER_NAME='testserver.example.com',
)
self.do_positive_test(response)
# A non-trsuted origin must still fail in multi - origin setup
response = self.client.get(
'https://not-my-testserver.example.com/web/',
SERVER_NAME='not-my-testserver.example.com',
)
self.assertEqual(response.status_code, 500)
# Even if it is a subdomain
response = self.client.get(
'https://not-my.testserver.example.com/web/',
SERVER_NAME='not-my.testserver.example.com',
)
self.assertEqual(response.status_code, 500)
def test_site_url_checks_fails(self):
"""Test that the site URL check is correctly failing.
Important for security.
"""
# wrongly set but in debug -> is ignored
with self.settings(SITE_URL='https://example.com', DEBUG=True):
response = self.client.get(reverse('web'))
self.assertEqual(response.status_code, 200)
self.assertNotContains(response, 'INVE-E7')
self.assertContains(response, 'window.INVENTREE_SETTINGS')
self.do_positive_test(response)
# wrongly set cors
with self.settings(
@@ -133,10 +170,32 @@ class MiddlewareTests(InvenTreeTestCase):
response, 'window.INVENTREE_SETTINGS', status_code=500
)
# wrongly set site URL
with self.settings(
SITE_URL='http://testserver', CSRF_TRUSTED_ORIGINS=['http://*.testserver']
SITE_URL='https://example.com',
CSRF_TRUSTED_ORIGINS=['http://localhost:8000'],
):
response = self.client.get(reverse('web'))
self.assertEqual(response.status_code, 200)
self.assertNotContains(response, 'INVE-E7')
self.assertContains(response, 'window.INVENTREE_SETTINGS')
self.assertEqual(response.status_code, 500)
self.assertContains(
response, 'INVE-E7: The used path `http://testserver` ', status_code=500
)
self.assertNotContains(
response, 'window.INVENTREE_SETTINGS', status_code=500
)
# Log stuff # TODO remove
print(
'###DBG-TST###',
'site',
settings.SITE_URL,
'trusted',
settings.CSRF_TRUSTED_ORIGINS,
)
# Check that the correct step triggers the error message
self.assertContains(
response,
'INVE-E7: The used path `http://testserver` does not match',
status_code=500,
)

View File

@@ -18,7 +18,7 @@ from django.conf import settings
from .api_version import INVENTREE_API_TEXT, INVENTREE_API_VERSION
# InvenTree software version
INVENTREE_SW_VERSION = '0.18.0 dev'
INVENTREE_SW_VERSION = '1.0.2'
logger = logging.getLogger('inventree')

View File

@@ -480,8 +480,8 @@ class BuildLineFilter(rest_filters.FilterSet):
def filter_allocated(self, queryset, name, value):
"""Filter by whether each BuildLine is fully allocated."""
if str2bool(value):
return queryset.filter(allocated__gte=F('quantity'))
return queryset.filter(allocated__lt=F('quantity'))
return queryset.filter(allocated__gte=F('quantity') - F('consumed'))
return queryset.filter(allocated__lt=F('quantity') - F('consumed'))
consumed = rest_filters.BooleanFilter(label=_('Consumed'), method='filter_consumed')

View File

@@ -1,21 +1,22 @@
"""Queryset filtering helper functions for the Build app."""
from django.db import models
from django.db.models import Q, Sum
from django.db.models.functions import Coalesce
from django.db.models import DecimalField, ExpressionWrapper, F, Max, Sum
from django.db.models.functions import Coalesce, Greatest
def annotate_allocated_quantity(queryset: Q) -> Q:
"""Annotate the 'allocated' quantity for each build item in the queryset.
Arguments:
queryset: The BuildLine queryset to annotate
"""
queryset = queryset.prefetch_related('allocations')
return queryset.annotate(
allocated=Coalesce(
Sum('allocations__quantity'), 0, output_field=models.DecimalField()
)
def annotate_required_quantity():
"""Annotate the 'required' quantity for each build item in the queryset."""
# Note: The use of Max() here is intentional, to avoid aggregation issues in MySQL
# Ref: https://github.com/inventree/InvenTree/pull/10398
return Greatest(
ExpressionWrapper(
Max(F('quantity')) - Max(F('consumed')), output_field=DecimalField()
),
0,
output_field=DecimalField(),
)
def annotate_allocated_quantity():
"""Annotate the 'allocated' quantity for each build item in the queryset."""
return Coalesce(Sum('allocations__quantity'), 0, output_field=DecimalField())

View File

@@ -29,7 +29,7 @@ import report.mixins
import stock.models
import users.models
from build.events import BuildEvents
from build.filters import annotate_allocated_quantity
from build.filters import annotate_allocated_quantity, annotate_required_quantity
from build.status_codes import BuildStatus, BuildStatusGroups
from build.validators import (
generate_next_build_reference,
@@ -1062,7 +1062,7 @@ class Build(
lines = self.untracked_line_items.all()
lines = lines.exclude(bom_item__consumable=True)
lines = annotate_allocated_quantity(lines)
lines = lines.annotate(allocated=annotate_allocated_quantity())
for build_line in lines:
reduce_by = build_line.allocated - build_line.quantity
@@ -1381,10 +1381,12 @@ class Build(
elif tracked is False:
lines = lines.filter(bom_item__sub_part__trackable=False)
lines = annotate_allocated_quantity(lines)
lines = lines.prefetch_related('allocations')
# Filter out any lines which have been fully allocated
lines = lines.filter(allocated__lt=F('quantity'))
lines = lines.annotate(
allocated=annotate_allocated_quantity(),
required=annotate_required_quantity(),
).filter(allocated__lt=F('required'))
return lines
@@ -1436,10 +1438,14 @@ class Build(
True if any BuildLine has been over-allocated.
"""
lines = self.build_lines.all().exclude(bom_item__consumable=True)
lines = annotate_allocated_quantity(lines)
lines = lines.prefetch_related('allocations')
# Find any lines which have been over-allocated
lines = lines.filter(allocated__gt=F('quantity'))
lines = lines.annotate(
allocated=annotate_allocated_quantity(),
required=annotate_required_quantity(),
).filter(allocated__gt=F('required'))
return lines.count() > 0
@@ -1644,19 +1650,30 @@ class BuildLine(report.mixins.InvenTreeReportMixin, InvenTree.models.InvenTreeMo
return allocated['q']
def unallocated_quantity(self):
"""Return the unallocated quantity for this BuildLine."""
return max(self.quantity - self.allocated_quantity(), 0)
"""Return the unallocated quantity for this BuildLine.
- Start with the required quantity
- Subtract the consumed quantity
- Subtract the allocated quantity
Return the remaining quantity (or zero if negative)
"""
return max(self.quantity - self.consumed - self.allocated_quantity(), 0)
def is_fully_allocated(self):
"""Return True if this BuildLine is fully allocated."""
if self.bom_item.consumable:
return True
return self.allocated_quantity() >= self.quantity
required = max(0, self.quantity - self.consumed)
return self.allocated_quantity() >= required
def is_overallocated(self):
"""Return True if this BuildLine is over-allocated."""
return self.allocated_quantity() > self.quantity
required = max(0, self.quantity - self.consumed)
return self.allocated_quantity() > required
def is_fully_consumed(self):
"""Return True if this BuildLine is fully consumed."""

View File

@@ -853,6 +853,78 @@ class AutoAllocationTests(BuildTestBase):
self.assertEqual(self.line_1.unallocated_quantity(), 0)
self.assertEqual(self.line_2.unallocated_quantity(), 0)
def test_allocate_consumed(self):
"""Test for auto-allocation against a build which has been fully consumed.
Steps:
1. Fully allocate the build (using the auto-allocate function)
2. Consume allocated stock
3. Ensure that all allocations are removed
4. Re-run the auto-allocate function
5. Check that no new allocations have been made
"""
self.assertEqual(self.build.allocated_stock.count(), 0)
self.assertFalse(self.build.is_fully_allocated(tracked=False))
# Auto allocate stock against the build order
self.build.auto_allocate_stock(
interchangeable=True, substitutes=True, optional_items=True
)
self.assertEqual(self.line_1.allocated_quantity(), 50)
self.assertEqual(self.line_2.allocated_quantity(), 30)
self.assertEqual(self.line_1.unallocated_quantity(), 0)
self.assertEqual(self.line_2.unallocated_quantity(), 0)
self.assertTrue(self.line_1.is_fully_allocated())
self.assertTrue(self.line_2.is_fully_allocated())
self.assertFalse(self.line_1.is_overallocated())
self.assertFalse(self.line_2.is_overallocated())
N = self.build.allocated_stock.count()
self.assertEqual(self.line_1.allocations.count(), 2)
self.assertEqual(self.line_2.allocations.count(), 6)
for item in self.line_1.allocations.all():
item.complete_allocation()
for item in self.line_2.allocations.all():
item.complete_allocation()
self.line_1.refresh_from_db()
self.line_2.refresh_from_db()
self.assertTrue(self.line_1.is_fully_allocated())
self.assertTrue(self.line_2.is_fully_allocated())
self.assertFalse(self.line_1.is_overallocated())
self.assertFalse(self.line_2.is_overallocated())
self.assertEqual(self.line_1.allocations.count(), 0)
self.assertEqual(self.line_2.allocations.count(), 0)
self.assertEqual(self.line_1.quantity, self.line_1.consumed)
self.assertEqual(self.line_2.quantity, self.line_2.consumed)
# Check that the "allocations" have been removed
self.assertEqual(self.build.allocated_stock.count(), N - 8)
# Now, try to auto-allocate again
self.build.auto_allocate_stock(
interchangeable=True, substitutes=True, optional_items=True
)
# Ensure that there are no "new" allocations (there should be none!)
self.assertEqual(self.line_1.allocated_quantity(), 0)
self.assertEqual(self.line_2.allocated_quantity(), 0)
self.assertEqual(self.line_1.unallocated_quantity(), 0)
self.assertEqual(self.line_2.unallocated_quantity(), 0)
self.assertEqual(self.build.allocated_stock.count(), N - 8)
class ExternalBuildTest(InvenTreeAPITestCase):
"""Unit tests for external build order functionality."""

View File

@@ -630,6 +630,38 @@ class DataImportRow(models.Model):
if value is None and field in default_values:
value = default_values[field]
# If the field provides a set of valid 'choices', use that as a lookup
if field_type == 'choice' and 'choices' in field_def:
choices = field_def.get('choices', None)
if callable(choices):
choices = choices()
# Try to match the provided value against the available choices
choice_value = None
for choice in choices:
primary_value = choice['value']
display_value = choice['display_name']
if primary_value == value:
choice_value = primary_value
# Break on first match against a primary choice value
break
if display_value == value:
choice_value = primary_value
elif (
str(display_value).lower().strip() == str(value).lower().strip()
and choice_value is None
):
# Case-insensitive match against display value
choice_value = primary_value
if choice_value is not None:
value = choice_value
data[field] = value
self.data = data
@@ -693,9 +725,20 @@ class DataImportRow(models.Model):
try:
instance = self.session.model_class.objects.get(pk=instance_id)
except self.session.model_class.DoesNotExist:
raise DjangoValidationError(_('No record found with the provided ID.'))
self.errors = {
'non_field_errors': _('No record found with the provided ID')
+ f': {instance_id}'
}
return False
except ValueError:
raise DjangoValidationError(_('Invalid ID format provided.'))
self.errors = {
'non_field_errors': _('Invalid ID format provided')
+ f': {instance_id}'
}
return False
except Exception as e:
self.errors = {'non_field_errors': str(e)}
return False
serializer = self.construct_serializer(instance=instance, request=request)

View File

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

View File

@@ -111,7 +111,7 @@ class UserInterfaceMixinTests(InvenTreeAPITestCase):
# Request custom panel information for a part instance
response = self.get(url, data=query_data)
# There should be 4 active panels for the part by default
# There should be 3 active panels for the part by default
self.assertEqual(3, len(response.data))
_part.active = False
@@ -119,8 +119,8 @@ class UserInterfaceMixinTests(InvenTreeAPITestCase):
response = self.get(url, data=query_data)
# As the part is not active, only 3 panels left
self.assertEqual(3, len(response.data))
# As the part is not active, only 2 panels left
self.assertEqual(2, len(response.data))
# Disable the "ENABLE_PART_PANELS" setting, and try again
plugin.set_setting('ENABLE_PART_PANELS', False)

View File

@@ -93,13 +93,17 @@ class SampleUserInterfacePlugin(SettingsMixin, UserInterfaceMixin, InvenTreePlug
except (Part.DoesNotExist, ValueError):
part = None
panels.append({
'key': 'part-panel',
'title': _('Part Panel'),
'source': self.plugin_static_file('sample_panel.js:renderPartPanel'),
'icon': 'ti:package_outline',
'context': {'part_name': part.name if part else ''},
})
# Only display this panel for "active" parts
if part and part.active:
panels.append({
'key': 'part-panel',
'title': _('Part Panel'),
'source': self.plugin_static_file(
'sample_panel.js:renderPartPanel'
),
'icon': 'ti:package:outline',
'context': {'part_name': part.name if part else ''},
})
# Next, add a custom panel which will appear on the 'purchaseorder' page
if target_model == 'purchaseorder' and self.get_setting(

View File

@@ -10,4 +10,4 @@ class VersionPlugin(InvenTreePlugin):
NAME = 'Sample Version Plugin'
DESCRIPTION = 'A simple plugin which shows how to use the version limits'
MIN_VERSION = '0.1.0'
MAX_VERSION = '1.0.0'
MAX_VERSION = '2.0.0'

View File

@@ -4,7 +4,7 @@ import logging
import os
from django.apps import AppConfig
from django.core.exceptions import AppRegistryNotReady
from django.core.exceptions import AppRegistryNotReady, ValidationError
from django.core.files.base import ContentFile
from django.core.files.storage import default_storage
from django.db.utils import IntegrityError, OperationalError, ProgrammingError
@@ -52,6 +52,8 @@ class ReportConfig(AppConfig):
try:
self.create_default_labels()
self.create_default_reports()
except ValidationError:
logger.warning('Validation error when creating default templates')
except (
AppRegistryNotReady,
IntegrityError,
@@ -162,6 +164,10 @@ class ReportConfig(AppConfig):
**template, template=self.file_from_template('label', filename)
)
logger.info("Creating new label template: '%s'", template['name'])
except ValidationError:
logger.warning(
"Could not create label template: '%s'", template['name']
)
except Exception:
InvenTree.exceptions.log_error('create_default_labels', scope='init')
@@ -261,5 +267,9 @@ class ReportConfig(AppConfig):
**template, template=self.file_from_template('report', filename)
)
logger.info("Created new report template: '%s'", template['name'])
except ValidationError:
logger.warning(
"Could not create report template: '%s'", template['name']
)
except Exception:
InvenTree.exceptions.log_error('create_default_reports', scope='init')

View File

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

View File

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

View File

@@ -329,7 +329,7 @@ class StockItemSerializer(
'supplier_part_detail.MPN',
]
import_exclude_fields = ['use_pack_size', 'location_path']
import_exclude_fields = ['location_path', 'serial_numbers', 'use_pack_size']
class Meta:
"""Metaclass options."""
@@ -490,7 +490,7 @@ class StockItemSerializer(
def update(self, instance, validated_data):
"""Custom update method to pass the user information through to the instance."""
instance._user = self.context['user']
instance._user = self.context.get('user', None)
status_custom_key = validated_data.pop('status_custom_key', None)
status = validated_data.pop('status', None)

View File

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

View File

@@ -144,6 +144,14 @@ class UserDetail(RetrieveUpdateDestroyAPI):
serializer_class = ExtendedUserSerializer
permission_classes = [InvenTree.permissions.StaffRolePermissionOrReadOnly]
def perform_destroy(self, instance):
"""Override destroy method to ensure sessions are deleted first."""
# Remove all sessions for this user
if sessions := instance.usersession_set.all():
sessions.delete()
# Normally delete the user
return super().perform_destroy(instance)
class UserDetailSetPassword(UpdateAPI):
"""Allows superusers to set the password for a user."""

View File

@@ -61,7 +61,10 @@ if settings.LDAP_AUTH:
user.save()
# if they got an email address from LDAP, create it now and make it the primary
if user.email:
if (
user.email
and not EmailAddress.objects.filter(user=user, email=user.email).exists()
):
EmailAddress.objects.create(user=user, email=user.email, primary=True)

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
import type { MantineSize } from '@mantine/core';
import type { MantineRadius, MantineSize } from '@mantine/core';
export type UiSizeType = MantineSize | string | number;
@@ -6,7 +6,7 @@ export interface UserTheme {
primaryColor: string;
whiteColor: string;
blackColor: string;
radius: UiSizeType;
radius: MantineRadius;
loader: string;
}

View File

@@ -27,7 +27,7 @@ import {
IconDownload,
IconFilter
} from '@tabler/icons-react';
import { useCallback, useEffect, useState } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useShallow } from 'zustand/react/shallow';
import type { CalendarState } from '../../hooks/UseCalendar';
import { useLocalState } from '../../states/LocalState';
@@ -59,6 +59,9 @@ export default function Calendar({
const [locale] = useLocalState(useShallow((s) => [s.language]));
// Ensure underscore is replaced with dash
const calendarLocale = useMemo(() => locale.replace('_', '-'), [locale]);
const selectMonth = useCallback(
(date: DateValue) => {
state.selectMonth(date);
@@ -186,7 +189,7 @@ export default function Calendar({
plugins={[dayGridPlugin, interactionPlugin]}
initialView='dayGridMonth'
locales={allLocales}
locale={locale}
locale={calendarLocale}
headerToolbar={false}
footerToolbar={false}
{...calendarProps}

View File

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

View File

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

View File

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

View File

@@ -88,7 +88,14 @@ export function RenderStockItem(
const allocated: number = Math.max(0, instance?.allocated ?? 0);
if (instance?.serial !== null && instance?.serial !== undefined) {
// Determine if this item is serialized
const serialized: boolean =
instance?.quantity == 1 &&
instance?.serial !== null &&
instance?.serial !== undefined &&
instance?.serial !== '';
if (serialized) {
quantity_string += `${t`Serial Number`}: ${instance.serial}`;
} else if (allocated > 0) {
const available: number = Math.max(0, instance.quantity - allocated);

View File

@@ -1,4 +1,4 @@
import type { MantineSize } from '@mantine/core';
import type { MantineRadius } from '@mantine/core';
export const emptyServerAPI = {
server: null,
@@ -26,7 +26,7 @@ export const emptyServerAPI = {
export interface SiteMarkProps {
value: number;
label: MantineSize;
label: MantineRadius;
}
export const SizeMarks: SiteMarkProps[] = [

View File

@@ -52,7 +52,7 @@ export function usePluginPanels({
// API query to fetch initial information on available plugin panels
const pluginQuery = useQuery({
enabled: pluginPanelsEnabled && !!model && id !== undefined,
queryKey: ['custom-plugin-panels', model, id],
queryKey: ['custom-plugin-panels', model, id, instance],
throwOnError: (error: any) => {
console.error('ERR: Failed to fetch plugin panels');
return false;

View File

@@ -40,19 +40,19 @@ export function UserTheme({ height }: Readonly<{ height: number }>) {
);
// radius
function getMark(value: number) {
function getRadiusFromValue(value: number) {
const obj = SizeMarks.find((mark) => mark.value === value);
if (obj) return obj;
return SizeMarks[0];
if (obj) return obj.label;
return 'sm';
}
function getDefaultRadius() {
const value = Number.parseInt(userTheme.radius.toString());
return SizeMarks.some((mark) => mark.value === value) ? value : 50;
}
const [radius, setRadius] = useState(getDefaultRadius());
const [radius, setRadius] = useState(25);
function changeRadius(value: number) {
const r = getRadiusFromValue(value);
setRadius(value);
setTheme([{ key: 'radius', value: value.toString() }]);
setTheme([{ key: 'radius', value: r.toString() }]);
}
return (
@@ -163,7 +163,7 @@ export function UserTheme({ height }: Readonly<{ height: number }>) {
</Table.Td>
<Table.Td>
<Slider
label={(val) => getMark(val).label}
label={(val) => getRadiusFromValue(val)}
defaultValue={50}
step={25}
marks={SizeMarks}

View File

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

View File

@@ -173,17 +173,12 @@ export default function StockDetail() {
stockitem.status_custom_key == stockitem.status
},
{
type: 'text',
name: 'updated',
icon: 'calendar',
label: t`Last Updated`
},
{
type: 'text',
name: 'stocktake',
icon: 'calendar',
label: t`Last Stocktake`,
hidden: !stockitem.stocktake
type: 'link',
name: 'link',
label: t`Link`,
external: true,
copy: true,
hidden: !stockitem.link
}
];
@@ -415,6 +410,19 @@ export default function StockDetail() {
icon: 'part',
label: t`Packaging`,
hidden: !stockitem.packaging
},
{
type: 'text',
name: 'updated',
icon: 'calendar',
label: t`Last Updated`
},
{
type: 'text',
name: 'stocktake',
icon: 'calendar',
label: t`Last Stocktake`,
hidden: !stockitem.stocktake
}
];

View File

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

View File

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

View File

@@ -183,9 +183,13 @@ export default function BuildAllocatedStockTable({
const [selectedItems, setSelectedItems] = useState<any[]>([]);
const itemsToConsume = useMemo(() => {
return selectedItems.filter((item) => !item.part_detail?.trackable);
}, [selectedItems]);
const consumeStock = useConsumeBuildItemsForm({
buildId: buildId ?? 0,
allocatedItems: selectedItems,
allocatedItems: itemsToConsume,
onFormSuccess: () => {
table.clearSelectedRecords();
table.refreshTable();
@@ -225,13 +229,16 @@ export default function BuildAllocatedStockTable({
const rowActions = useCallback(
(record: any): RowAction[] => {
const part = record.part_detail ?? {};
const trackable: boolean = part?.trackable ?? false;
return [
{
color: 'green',
icon: <IconCircleDashedCheck />,
title: t`Consume`,
tooltip: t`Consume Stock`,
hidden: !user.hasChangeRole(UserRoles.build),
hidden: !buildId || trackable || !user.hasChangeRole(UserRoles.build),
onClick: () => {
setSelectedItems([record]);
consumeStock.open();

View File

@@ -479,6 +479,7 @@ export default function BuildLineTable({
);
}
const allocated = record.allocatedQuantity ?? 0;
let required = Math.max(0, record.quantity - record.consumed);
if (output?.pk) {
@@ -486,7 +487,7 @@ export default function BuildLineTable({
required = record.bom_item_detail?.quantity;
}
if (required <= 0) {
if (allocated <= 0 && required <= 0) {
return (
<Group gap='xs' wrap='nowrap'>
<IconCircleCheck size={16} color='green' />
@@ -502,7 +503,7 @@ export default function BuildLineTable({
return (
<ProgressBar
progressLabel={true}
value={record.allocatedQuantity}
value={allocated}
maximum={required}
/>
);
@@ -664,9 +665,10 @@ export default function BuildLineTable({
(record: any): RowAction[] => {
const part = record.part_detail ?? {};
const in_production = build.status == buildStatus.PRODUCTION;
const consumable = record.bom_item_detail?.consumable ?? false;
const consumable: boolean = record.bom_item_detail?.consumable ?? false;
const trackable: boolean = part?.trackable ?? false;
const hasOutput = !!output?.pk;
const hasOutput: boolean = !!output?.pk;
const required = Math.max(
0,
@@ -677,6 +679,7 @@ export default function BuildLineTable({
const canConsume =
in_production &&
!consumable &&
!trackable &&
record.allocated > 0 &&
user.hasChangeRole(UserRoles.build);
@@ -952,6 +955,9 @@ export default function BuildLineTable({
dataFormatter: formatRecords,
enableDownload: true,
enableSelection: true,
enableLabels: true,
modelType: ModelType.buildline,
onCellClick: () => {},
rowExpansion: rowExpansion
}}
/>

View File

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

View File

@@ -358,6 +358,7 @@ export function UserTable({
title: t`Delete user`,
successMessage: t`User deleted`,
table: table,
preFormContent: <></>,
preFormWarning: t`Are you sure you want to delete this user?`
});