Compare commits

...

21 Commits

Author SHA1 Message Date
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
35 changed files with 608 additions and 260 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.1'
logger = logging.getLogger('inventree')

View File

@@ -693,9 +693,20 @@ class DataImportRow(models.Model):
try:
instance = self.session.model_class.objects.get(pk=instance_id)
except self.session.model_class.DoesNotExist:
raise DjangoValidationError(_('No record found with the provided ID.'))
self.errors = {
'non_field_errors': _('No record found with the provided ID')
+ f': {instance_id}'
}
return False
except ValueError:
raise DjangoValidationError(_('Invalid ID format provided.'))
self.errors = {
'non_field_errors': _('Invalid ID format provided')
+ f': {instance_id}'
}
return False
except Exception as e:
self.errors = {'non_field_errors': str(e)}
return False
serializer = self.construct_serializer(instance=instance, request=request)

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

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

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

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

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