Merege upstream/master into branch

This commit is contained in:
Matthias Mair 2024-06-12 07:59:07 +02:00
parent 952d6919b6
commit 717001d8f1
No known key found for this signature in database
GPG Key ID: A593429DDA23B66A
121 changed files with 70730 additions and 52911 deletions

View File

@ -140,14 +140,14 @@ jobs:
fi fi
- name: Login to Dockerhub - name: Login to Dockerhub
if: github.event_name != 'pull_request' && steps.docker_login.outputs.skip_dockerhub_login != 'true' if: github.event_name != 'pull_request' && steps.docker_login.outputs.skip_dockerhub_login != 'true'
uses: docker/login-action@e92390c5fb421da1463c202d546fed0ec5c39f20 # pin@v3.1.0 uses: docker/login-action@0d4c9c5ea7693da7b068278f7b52bda2a190a446 # pin@v3.2.0
with: with:
username: ${{ secrets.DOCKER_USERNAME }} username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }} password: ${{ secrets.DOCKER_PASSWORD }}
- name: Log into registry ghcr.io - name: Log into registry ghcr.io
if: github.event_name != 'pull_request' if: github.event_name != 'pull_request'
uses: docker/login-action@e92390c5fb421da1463c202d546fed0ec5c39f20 # pin@v3.1.0 uses: docker/login-action@0d4c9c5ea7693da7b068278f7b52bda2a190a446 # pin@v3.2.0
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.actor }} username: ${{ github.actor }}
@ -165,7 +165,7 @@ jobs:
- name: Push Docker Images - name: Push Docker Images
id: push-docker id: push-docker
if: github.event_name != 'pull_request' if: github.event_name != 'pull_request'
uses: docker/build-push-action@2cdde995de11925a030ce8070c3d77a52ffcf1c0 # pin@v5.3.0 uses: docker/build-push-action@ca052bb54ab0790a636c9b5f226502c73d547a25 # pin@v5.4.0
with: with:
context: . context: .
file: ./contrib/container/Dockerfile file: ./contrib/container/Dockerfile

View File

@ -67,6 +67,6 @@ jobs:
# Upload the results to GitHub's code scanning dashboard. # Upload the results to GitHub's code scanning dashboard.
- name: "Upload to code-scanning" - name: "Upload to code-scanning"
uses: github/codeql-action/upload-sarif@9fdb3e49720b44c48891d036bb502feb25684276 # v3.25.6 uses: github/codeql-action/upload-sarif@2e230e8fe0ad3a14a340ad0815ddb96d599d2aff # v3.25.8
with: with:
sarif_file: results.sarif sarif_file: results.sarif

View File

@ -54,13 +54,23 @@ invoke setup-dev
InvenTree roughly follow the [GitLab flow](https://docs.gitlab.com/ee/topics/gitlab_flow.html) branching style, to allow simple management of multiple tagged releases, short-lived branches, and development on the main branch. InvenTree roughly follow the [GitLab flow](https://docs.gitlab.com/ee/topics/gitlab_flow.html) branching style, to allow simple management of multiple tagged releases, short-lived branches, and development on the main branch.
There are nominally 5 active branches:
- `master` - The main development branch
- `stable` - The latest stable release
- `l10n` - Translation branch: Source to Crowdin
- `l10_crowdin` - Translation branch: Source from Crowdin
- `y.y.x` - Release branch for the currently supported version (e.g. `0.5.x`)
All other branches are removed periodically by maintainers or core team members. This includes old release branches.
Do not use them as base for feature development or forks as patches from them might not be accepted without rebasing.
### Version Numbering ### Version Numbering
InvenTree version numbering follows the [semantic versioning](https://semver.org/) specification. InvenTree version numbering follows the [semantic versioning](https://semver.org/) specification.
### Master Branch ### Main Development Branch
The HEAD of the "main" or "master" branch of InvenTree represents the current "latest" state of code development. The HEAD of the "master" branch of InvenTree represents the current "latest" state of code development.
- All feature branches are merged into master - All feature branches are merged into master
- All bug fixes are merged into master - All bug fixes are merged into master
@ -73,7 +83,6 @@ Feature branches should be branched *from* the *master* branch.
- One major feature per branch / pull request - One major feature per branch / pull request
- Feature pull requests are merged back *into* the master branch - Feature pull requests are merged back *into* the master branch
- Features *may* also be merged into a release candidate branch
### Stable Branch ### Stable Branch
@ -82,17 +91,24 @@ The HEAD of the "stable" branch represents the latest stable release code.
- Versioned releases are merged into the "stable" branch - Versioned releases are merged into the "stable" branch
- Bug fix branches are made *from* the "stable" branch - Bug fix branches are made *from* the "stable" branch
#### Release Candidate Branches
- Release candidate branches are made from master, and merged into stable. ### Bugfix Branches
- RC branches are targeted at a major/minor version e.g. "0.5"
- When a release candidate branch is merged into *stable*, the release is tagged
#### Bugfix Branches
- If a bug is discovered in a tagged release version of InvenTree, a "bugfix" or "hotfix" branch should be made *from* that tagged release - If a bug is discovered in a tagged release version of InvenTree, a "bugfix" or "hotfix" branch should be made *from* that tagged release
- When approved, the branch is merged back *into* stable, with an incremented PATCH number (e.g. 0.4.1 -> 0.4.2) - When approved, the branch is merged back *into* stable, with an incremented PATCH number (e.g. 0.4.1 -> 0.4.2)
- The bugfix *must* also be cherry picked into the *master* branch. - The bugfix *must* also be cherry picked into the *master* branch.
- A bugfix *might* also be backported from *master* to the *stable* branch automatically if marked with the `backport` label.
### Translation Branches
Crowdin is used for web-based translation management. The handling of files is fully automated, the `l10n` and `l10_crowdin` branches are used to manage the translation process and are not meant to be touched manually by anyone.
The translation process is as follows:
1. Commits to `master` trigger CI by GitHub Actions
2. Translation source files are created and automatically pushed to the `l10n` branch - this is the source branch for Crowdin
3. Crowdin picks up on the new source files and makes them available for translation
4. Translations made in Crowdin are automatically pushed back to the `l10_crowdin` branch by Crowdin once they are approved
5. The `l10_crowdin` branch is merged back into `master` by a maintainer periodically
## API versioning ## API versioning

View File

@ -1,14 +1,17 @@
"""InvenTree API version information.""" """InvenTree API version information."""
# InvenTree API version # InvenTree API version
INVENTREE_API_VERSION = 206 INVENTREE_API_VERSION = 207
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about.""" """Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
INVENTREE_API_TEXT = """ INVENTREE_API_TEXT = """
v206 - 2024-06-04 : ### v207 - 2024-06-04 : ###
- Added 2fa urls to the SSO provider API endpoint - Added 2fa urls to the SSO provider API endpoint
v206 - 2024-06-08 : https://github.com/inventree/InvenTree/pull/7417
- Adds "choices" field to the PartTestTemplate model
v205 - 2024-06-03 : https://github.com/inventree/InvenTree/pull/7284 v205 - 2024-06-03 : https://github.com/inventree/InvenTree/pull/7284
- Added model_type and model_id fields to the "NotesImage" serializer - Added model_type and model_id fields to the "NotesImage" serializer

View File

@ -38,6 +38,7 @@ LOCALES = [
('pl', _('Polish')), ('pl', _('Polish')),
('pt', _('Portuguese')), ('pt', _('Portuguese')),
('pt-br', _('Portuguese (Brazilian)')), ('pt-br', _('Portuguese (Brazilian)')),
('ro', _('Romanian')),
('ru', _('Russian')), ('ru', _('Russian')),
('sk', _('Slovak')), ('sk', _('Slovak')),
('sl', _('Slovenian')), ('sl', _('Slovenian')),

View File

@ -0,0 +1,13 @@
"""Management command to collect plugin static files."""
from django.core.management import BaseCommand
class Command(BaseCommand):
"""Collect static files for all installed plugins."""
def handle(self, *args, **kwargs):
"""Run the management command."""
from plugin.staticfiles import collect_plugins_static_files
collect_plugins_static_files()

View File

@ -125,7 +125,7 @@ def canAppAccessDatabase(
excluded_commands.append('test') excluded_commands.append('test')
if not allow_plugins: if not allow_plugins:
excluded_commands.extend(['collectstatic']) excluded_commands.extend(['collectstatic', 'collectplugins'])
for cmd in excluded_commands: for cmd in excluded_commands:
if cmd in sys.argv: if cmd in sys.argv:

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,18 @@
# Generated by Django 4.2.12 on 2024-06-05 01:14
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('part', '0122_parttesttemplate_enabled'),
]
operations = [
migrations.AddField(
model_name='parttesttemplate',
name='choices',
field=models.CharField(blank=True, help_text='Valid choices for this test (comma-separated)', max_length=5000, verbose_name='Choices'),
),
]

View File

@ -3470,6 +3470,27 @@ class PartTestTemplate(InvenTree.models.InvenTreeMetadataModel):
) )
}) })
# Check that 'choices' are in fact valid
if self.choices is None:
self.choices = ''
else:
self.choices = str(self.choices).strip()
if self.choices:
choice_set = set()
for choice in self.choices.split(','):
choice = choice.strip()
# Ignore empty choices
if not choice:
continue
if choice in choice_set:
raise ValidationError({'choices': _('Choices must be unique')})
choice_set.add(choice)
self.validate_unique() self.validate_unique()
super().clean() super().clean()
@ -3548,6 +3569,20 @@ class PartTestTemplate(InvenTree.models.InvenTreeMetadataModel):
), ),
) )
choices = models.CharField(
max_length=5000,
verbose_name=_('Choices'),
help_text=_('Valid choices for this test (comma-separated)'),
blank=True,
)
def get_choices(self):
"""Return a list of valid choices for this test template."""
if not self.choices:
return []
return [x.strip() for x in self.choices.split(',') if x.strip()]
def validate_template_name(name): def validate_template_name(name):
"""Placeholder for legacy function used in migrations.""" """Placeholder for legacy function used in migrations."""

View File

@ -179,6 +179,7 @@ class PartTestTemplateSerializer(InvenTree.serializers.InvenTreeModelSerializer)
'requires_value', 'requires_value',
'requires_attachment', 'requires_attachment',
'results', 'results',
'choices',
] ]
key = serializers.CharField(read_only=True) key = serializers.CharField(read_only=True)

View File

@ -893,51 +893,6 @@ class PartAPITest(PartAPITestBase):
# Now there should be 5 total parts # Now there should be 5 total parts
self.assertEqual(len(response.data), 3) self.assertEqual(len(response.data), 3)
def test_test_templates(self):
"""Test the PartTestTemplate API."""
url = reverse('api-part-test-template-list')
# List ALL items
response = self.get(url)
self.assertEqual(len(response.data), 9)
# Request for a particular part
response = self.get(url, data={'part': 10000})
self.assertEqual(len(response.data), 5)
response = self.get(url, data={'part': 10004})
self.assertEqual(len(response.data), 6)
# Try to post a new object (missing description)
response = self.post(
url,
data={'part': 10000, 'test_name': 'My very first test', 'required': False},
expected_code=400,
)
# Try to post a new object (should succeed)
response = self.post(
url,
data={
'part': 10000,
'test_name': 'New Test',
'required': True,
'description': 'a test description',
},
)
# Try to post a new test with the same name (should fail)
response = self.post(
url,
data={'part': 10004, 'test_name': ' newtest', 'description': 'dafsdf'},
expected_code=400,
)
# Try to post a new test against a non-trackable part (should fail)
response = self.post(
url, data={'part': 1, 'test_name': 'A simple test'}, expected_code=400
)
def test_get_thumbs(self): def test_get_thumbs(self):
"""Return list of part thumbnails.""" """Return list of part thumbnails."""
url = reverse('api-part-thumbs') url = reverse('api-part-thumbs')
@ -2904,3 +2859,96 @@ class PartSchedulingTest(PartAPITestBase):
for entry in data: for entry in data:
for k in ['date', 'quantity', 'label']: for k in ['date', 'quantity', 'label']:
self.assertIn(k, entry) self.assertIn(k, entry)
class PartTestTemplateTest(PartAPITestBase):
"""API unit tests for the PartTestTemplate model."""
def test_test_templates(self):
"""Test the PartTestTemplate API."""
url = reverse('api-part-test-template-list')
# List ALL items
response = self.get(url)
self.assertEqual(len(response.data), 9)
# Request for a particular part
response = self.get(url, data={'part': 10000})
self.assertEqual(len(response.data), 5)
response = self.get(url, data={'part': 10004})
self.assertEqual(len(response.data), 6)
# Try to post a new object (missing description)
response = self.post(
url,
data={'part': 10000, 'test_name': 'My very first test', 'required': False},
expected_code=400,
)
# Try to post a new object (should succeed)
response = self.post(
url,
data={
'part': 10000,
'test_name': 'New Test',
'required': True,
'description': 'a test description',
},
)
# Try to post a new test with the same name (should fail)
response = self.post(
url,
data={'part': 10004, 'test_name': ' newtest', 'description': 'dafsdf'},
expected_code=400,
)
# Try to post a new test against a non-trackable part (should fail)
response = self.post(
url, data={'part': 1, 'test_name': 'A simple test'}, expected_code=400
)
def test_choices(self):
"""Test the 'choices' field for the PartTestTemplate model."""
template = PartTestTemplate.objects.first()
url = reverse('api-part-test-template-detail', kwargs={'pk': template.pk})
# Check OPTIONS response
response = self.options(url)
options = response.data['actions']['PUT']
self.assertTrue(options['pk']['read_only'])
self.assertTrue(options['pk']['required'])
self.assertEqual(options['part']['api_url'], '/api/part/')
self.assertTrue(options['test_name']['required'])
self.assertFalse(options['test_name']['read_only'])
self.assertFalse(options['choices']['required'])
self.assertFalse(options['choices']['read_only'])
self.assertEqual(
options['choices']['help_text'],
'Valid choices for this test (comma-separated)',
)
# Check data endpoint
response = self.get(url)
data = response.data
for key in [
'pk',
'key',
'part',
'test_name',
'description',
'enabled',
'required',
'results',
'choices',
]:
self.assertIn(key, data)
# Patch with invalid choices
response = self.patch(url, {'choices': 'a,b,c,d,e,f,f'}, expected_code=400)
self.assertIn('Choices must be unique', str(response.data['choices']))

View File

@ -12,6 +12,7 @@ from django.utils.translation import gettext_lazy as _
import common.models import common.models
import InvenTree.models import InvenTree.models
import plugin.staticfiles
from plugin import InvenTreePlugin, registry from plugin import InvenTreePlugin, registry
@ -186,6 +187,20 @@ class PluginConfig(InvenTree.models.MetadataMixin, models.Model):
return getattr(self.plugin, 'is_package', False) return getattr(self.plugin, 'is_package', False)
def activate(self, active: bool) -> None:
"""Set the 'active' status of this plugin instance."""
from InvenTree.tasks import check_for_migrations, offload_task
if self.active == active:
return
self.active = active
self.save()
if active:
offload_task(check_for_migrations)
offload_task(plugin.staticfiles.copy_plugin_static_files, self.key)
class PluginSetting(common.models.BaseInvenTreeSetting): class PluginSetting(common.models.BaseInvenTreeSetting):
"""This model represents settings for individual plugins.""" """This model represents settings for individual plugins."""

View File

@ -208,15 +208,7 @@ class PluginActivateSerializer(serializers.Serializer):
def update(self, instance, validated_data): def update(self, instance, validated_data):
"""Apply the new 'active' value to the plugin instance.""" """Apply the new 'active' value to the plugin instance."""
from InvenTree.tasks import check_for_migrations, offload_task instance.activate(validated_data.get('active', True))
instance.active = validated_data.get('active', True)
instance.save()
if instance.active:
# A plugin has just been activated - check for database migrations
offload_task(check_for_migrations)
return instance return instance

View File

@ -0,0 +1,92 @@
"""Static files management for InvenTree plugins."""
import logging
from pathlib import Path
from django.contrib.staticfiles.storage import staticfiles_storage
from plugin.registry import registry
logger = logging.getLogger('inventree')
def clear_static_dir(path, recursive=True):
"""Clear the specified directory from the 'static' output directory.
Arguments:
path: The path to the directory to clear
recursive: If True, clear the directory recursively
"""
if not staticfiles_storage.exists(path):
return
dirs, files = staticfiles_storage.listdir(path)
for f in files:
staticfiles_storage.delete(f'{path}/{f}')
if recursive:
for d in dirs:
clear_static_dir(f'{path}/{d}', recursive=True)
staticfiles_storage.delete(d)
def collect_plugins_static_files():
"""Copy static files from all installed plugins into the static directory."""
registry.check_reload()
logger.info('Collecting static files for all installed plugins.')
for slug in registry.plugins.keys():
copy_plugin_static_files(slug)
def copy_plugin_static_files(slug):
"""Copy static files for the specified plugin."""
registry.check_reload()
plugin = registry.get_plugin(slug)
if not plugin:
return
logger.info("Copying static files for plugin '%s'")
# Get the source path for the plugin
source_path = plugin.path().joinpath('static')
if not source_path.is_dir():
return
# Create prefix for the destination path
destination_prefix = f'plugins/{slug}/'
# Clear the destination path
clear_static_dir(destination_prefix)
items = list(source_path.glob('*'))
idx = 0
copied = 0
while idx < len(items):
item = items[idx]
idx += 1
if item.is_dir():
items.extend(item.glob('*'))
continue
if item.is_file():
relative_path = item.relative_to(source_path)
destination_path = f'{destination_prefix}{relative_path}'
with item.open('rb') as src:
staticfiles_storage.save(destination_path, src)
logger.debug(f'- copied {item} to {destination_path}')
copied += 1
logger.info(f"Copied %s static files for plugin '%s'.", copied, slug)

View File

@ -180,6 +180,11 @@ class StockItemResource(InvenTreeResource):
column_name=_('Supplier Part ID'), column_name=_('Supplier Part ID'),
widget=widgets.ForeignKeyWidget(SupplierPart), widget=widgets.ForeignKeyWidget(SupplierPart),
) )
supplier_part_sku = Field(
attribute='supplier_part__SKU',
column_name=_('Supplier Part SKU'),
readonly=True,
)
supplier = Field( supplier = Field(
attribute='supplier_part__supplier__id', attribute='supplier_part__supplier__id',
column_name=_('Supplier ID'), column_name=_('Supplier ID'),

View File

@ -2380,12 +2380,15 @@ class StockItemTestResult(InvenTree.models.InvenTreeMetadataModel):
super().clean() super().clean()
# If this test result corresponds to a template, check the requirements of the template # If this test result corresponds to a template, check the requirements of the template
key = self.key template = self.template
templates = self.stock_item.part.getTestTemplates() if template is None:
# Fallback if there is no matching template
for template in self.stock_item.part.getTestTemplates():
if self.key == template.key:
break
for template in templates: if template:
if key == template.key:
if template.requires_value and not self.value: if template.requires_value and not self.value:
raise ValidationError({ raise ValidationError({
'value': _('Value must be provided for this test') 'value': _('Value must be provided for this test')
@ -2396,7 +2399,9 @@ class StockItemTestResult(InvenTree.models.InvenTreeMetadataModel):
'attachment': _('Attachment must be uploaded for this test') 'attachment': _('Attachment must be uploaded for this test')
}) })
break if choices := template.get_choices():
if self.value not in choices:
raise ValidationError({'value': _('Invalid value for this test')})
@property @property
def key(self): def key(self):

View File

@ -1715,6 +1715,60 @@ class StockTestResultTest(StockAPITestCase):
self.assertEqual(StockItemTestResult.objects.count(), n) self.assertEqual(StockItemTestResult.objects.count(), n)
def test_value_choices(self):
"""Test that the 'value' field is correctly validated."""
url = reverse('api-stock-test-result-list')
test_template = PartTestTemplate.objects.first()
test_template.choices = 'AA, BB, CC'
test_template.save()
stock_item = StockItem.objects.create(
part=test_template.part, quantity=1, location=StockLocation.objects.first()
)
# Create result with invalid choice
response = self.post(
url,
{
'template': test_template.pk,
'stock_item': stock_item.pk,
'result': True,
'value': 'DD',
},
expected_code=400,
)
self.assertIn('Invalid value for this test', str(response.data['value']))
# Create result with valid choice
response = self.post(
url,
{
'template': test_template.pk,
'stock_item': stock_item.pk,
'result': True,
'value': 'BB',
},
expected_code=201,
)
# Create result with unrestricted choice
test_template.choices = ''
test_template.save()
response = self.post(
url,
{
'template': test_template.pk,
'stock_item': stock_item.pk,
'result': False,
'value': '12345',
},
expected_code=201,
)
class StockAssignTest(StockAPITestCase): class StockAssignTest(StockAPITestCase):
"""Unit tests for the stock assignment API endpoint, where stock items are manually assigned to a customer.""" """Unit tests for the stock assignment API endpoint, where stock items are manually assigned to a customer."""

View File

@ -17,6 +17,7 @@
<div class="bg-qr-code rounded"> <div class="bg-qr-code rounded">
<img src="{{ qr_code_url }}" alt="{% trans 'QR Code' %}" class="mx-auto d-block"/> <img src="{{ qr_code_url }}" alt="{% trans 'QR Code' %}" class="mx-auto d-block"/>
</div> </div>
<p class="text-center mt-2">{% trans 'Secret: ' %}{{ secret }}</p>
<br> <br>
<h4> <h4>

View File

@ -305,6 +305,7 @@ function partFields(options={}) {
function categoryFields(options={}) { function categoryFields(options={}) {
let fields = { let fields = {
parent: { parent: {
label: '{% trans "Parent" %}',
help_text: '{% trans "Parent part category" %}', help_text: '{% trans "Parent part category" %}',
required: false, required: false,
tree_picker: { tree_picker: {
@ -2827,6 +2828,7 @@ function partTestTemplateFields(options={}) {
requires_value: {}, requires_value: {},
requires_attachment: {}, requires_attachment: {},
enabled: {}, enabled: {},
choices: {},
part: { part: {
hidden: true, hidden: true,
} }

View File

@ -5,7 +5,7 @@
"packages": { "packages": {
"": { "": {
"dependencies": { "dependencies": {
"eslint": "^9.3.0", "eslint": "^9.4.0",
"eslint-config-google": "^0.14.0" "eslint-config-google": "^0.14.0"
} }
}, },
@ -50,6 +50,19 @@
"node": "^12.0.0 || ^14.0.0 || >=16.0.0" "node": "^12.0.0 || ^14.0.0 || >=16.0.0"
} }
}, },
"node_modules/@eslint/config-array": {
"version": "0.15.1",
"resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.15.1.tgz",
"integrity": "sha512-K4gzNq+yymn/EVsXYmf+SBcBro8MTf+aXJZUphM96CdzUEr+ClGDvAbpmaEK+cGVigVXIgs9gNmvHAlrzzY5JQ==",
"dependencies": {
"@eslint/object-schema": "^2.1.3",
"debug": "^4.3.1",
"minimatch": "^3.0.5"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
}
},
"node_modules/@eslint/eslintrc": { "node_modules/@eslint/eslintrc": {
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.1.0.tgz", "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.1.0.tgz",
@ -73,24 +86,19 @@
} }
}, },
"node_modules/@eslint/js": { "node_modules/@eslint/js": {
"version": "9.3.0", "version": "9.4.0",
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.3.0.tgz", "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.4.0.tgz",
"integrity": "sha512-niBqk8iwv96+yuTwjM6bWg8ovzAPF9qkICsGtcoa5/dmqcEMfdwNAX7+/OHcJHc7wj7XqPxH98oAHytFYlw6Sw==", "integrity": "sha512-fdI7VJjP3Rvc70lC4xkFXHB0fiPeojiL1PxVG6t1ZvXQrarj893PweuBTujxDUFk0Fxj4R7PIIAZ/aiiyZPZcg==",
"engines": { "engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0" "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
} }
}, },
"node_modules/@humanwhocodes/config-array": { "node_modules/@eslint/object-schema": {
"version": "0.13.0", "version": "2.1.3",
"resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.3.tgz",
"integrity": "sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==", "integrity": "sha512-HAbhAYKfsAC2EkTqve00ibWIZlaU74Z1EHwAjYr4PXF0YU2VEA1zSIKSSpKszRLRWwHzzRZXvK632u+uXzvsvw==",
"dependencies": {
"@humanwhocodes/object-schema": "^2.0.3",
"debug": "^4.3.1",
"minimatch": "^3.0.5"
},
"engines": { "engines": {
"node": ">=10.10.0" "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
} }
}, },
"node_modules/@humanwhocodes/module-importer": { "node_modules/@humanwhocodes/module-importer": {
@ -105,11 +113,6 @@
"url": "https://github.com/sponsors/nzakas" "url": "https://github.com/sponsors/nzakas"
} }
}, },
"node_modules/@humanwhocodes/object-schema": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz",
"integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA=="
},
"node_modules/@humanwhocodes/retry": { "node_modules/@humanwhocodes/retry": {
"version": "0.3.0", "version": "0.3.0",
"resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.3.0.tgz", "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.3.0.tgz",
@ -319,15 +322,15 @@
} }
}, },
"node_modules/eslint": { "node_modules/eslint": {
"version": "9.3.0", "version": "9.4.0",
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.3.0.tgz", "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.4.0.tgz",
"integrity": "sha512-5Iv4CsZW030lpUqHBapdPo3MJetAPtejVW8B84GIcIIv8+ohFaddXsrn1Gn8uD9ijDb+kcYKFUVmC8qG8B2ORQ==", "integrity": "sha512-sjc7Y8cUD1IlwYcTS9qPSvGjAC8Ne9LctpxKKu3x/1IC9bnOg98Zy6GxEJUfr1NojMgVPlyANXYns8oE2c1TAA==",
"dependencies": { "dependencies": {
"@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.6.1", "@eslint-community/regexpp": "^4.6.1",
"@eslint/config-array": "^0.15.1",
"@eslint/eslintrc": "^3.1.0", "@eslint/eslintrc": "^3.1.0",
"@eslint/js": "9.3.0", "@eslint/js": "9.4.0",
"@humanwhocodes/config-array": "^0.13.0",
"@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/module-importer": "^1.0.1",
"@humanwhocodes/retry": "^0.3.0", "@humanwhocodes/retry": "^0.3.0",
"@nodelib/fs.walk": "^1.2.8", "@nodelib/fs.walk": "^1.2.8",

View File

@ -1,6 +1,6 @@
{ {
"dependencies": { "dependencies": {
"eslint": "^9.3.0", "eslint": "^9.4.0",
"eslint-config-google": "^0.14.0" "eslint-config-google": "^0.14.0"
}, },
"type": "module" "type": "module"

View File

@ -23,6 +23,7 @@
"pl", "pl",
"pt", "pt",
"pt-br", "pt-br",
"ro",
"ru", "ru",
"sk", "sk",
"sl", "sl",

View File

@ -27,6 +27,7 @@
"@lingui/core": "^4.10.0", "@lingui/core": "^4.10.0",
"@lingui/react": "^4.10.0", "@lingui/react": "^4.10.0",
"@mantine/carousel": "^7.8.0", "@mantine/carousel": "^7.8.0",
"@mantine/charts": "^7.10.1",
"@mantine/core": "^7.10.0", "@mantine/core": "^7.10.0",
"@mantine/dates": "^7.8.0", "@mantine/dates": "^7.8.0",
"@mantine/dropzone": "^7.8.0", "@mantine/dropzone": "^7.8.0",

View File

@ -1,12 +1,12 @@
export const CHART_COLORS: string[] = [ export const CHART_COLORS: string[] = [
'#ffa8a8', 'blue',
'#8ce99a', 'teal',
'#74c0fc', 'lime',
'#ffe066', 'yellow',
'#63e6be', 'grape',
'#ffc078', 'red',
'#d8f5a2', 'orange',
'#66d9e8', 'green',
'#e599f7', 'indigo',
'#dee2e6' 'pink'
]; ];

View File

@ -1,9 +1,12 @@
import { formatCurrency } from '../../defaults/formatters'; import { formatCurrency } from '../../defaults/formatters';
export function tooltipFormatter(label: any, currency: string) { /*
* Render a chart label for a currency graph
*/
export function tooltipFormatter(value: any, currency?: string) {
return ( return (
formatCurrency(label, { formatCurrency(value, {
currency: currency currency: currency
})?.toString() ?? '' })?.toString() ?? value.toString()
); );
} }

View File

@ -59,12 +59,17 @@ export function RelatedModelField({
if (field.value === pk) return; if (field.value === pk) return;
if ( if (
field.value !== null && field?.value !== null &&
field.value !== undefined && field?.value !== undefined &&
field.value !== '' field?.value !== ''
) { ) {
const url = `${definition.api_url}${field.value}/`; const url = `${definition.api_url}${field.value}/`;
if (!url) {
setPk(null);
return;
}
api.get(url).then((response) => { api.get(url).then((response) => {
let pk_field = definition.pk_field ?? 'pk'; let pk_field = definition.pk_field ?? 'pk';
if (response.data && response.data[pk_field]) { if (response.data && response.data[pk_field]) {

View File

@ -24,7 +24,6 @@ import { identifierString } from '../../functions/conversion';
import { navigateToLink } from '../../functions/navigation'; import { navigateToLink } from '../../functions/navigation';
import { useLocalState } from '../../states/LocalState'; import { useLocalState } from '../../states/LocalState';
import { Boundary } from '../Boundary'; import { Boundary } from '../Boundary';
import { PlaceholderPanel } from '../items/Placeholder';
import { StylishText } from '../items/StylishText'; import { StylishText } from '../items/StylishText';
/** /**
@ -34,7 +33,7 @@ export type PanelType = {
name: string; name: string;
label: string; label: string;
icon?: ReactNode; icon?: ReactNode;
content?: ReactNode; content: ReactNode;
hidden?: boolean; hidden?: boolean;
disabled?: boolean; disabled?: boolean;
showHeadline?: boolean; showHeadline?: boolean;
@ -190,7 +189,7 @@ function BasePanelGroup({
</> </>
)} )}
<Boundary label={`PanelContent-${panel.name}`}> <Boundary label={`PanelContent-${panel.name}`}>
{panel.content ?? <PlaceholderPanel />} {panel.content}
</Boundary> </Boundary>
</Stack> </Stack>
</Tabs.Panel> </Tabs.Panel>

View File

@ -40,6 +40,7 @@ export const getSupportedLanguages = (): Record<string, string> => {
pl: t`Polish`, pl: t`Polish`,
pt: t`Portuguese`, pt: t`Portuguese`,
'pt-br': t`Portuguese (Brazilian)`, 'pt-br': t`Portuguese (Brazilian)`,
ro: t`Romanian`,
ru: t`Russian`, ru: t`Russian`,
sk: t`Slovak`, sk: t`Slovak`,
sl: t`Slovenian`, sl: t`Slovenian`,

View File

@ -866,3 +866,63 @@ export function stockLocationFields({}: {}): ApiFormFieldSet {
return fields; return fields;
} }
// Construct a set of fields for
export function useTestResultFields({
partId,
itemId
}: {
partId: number;
itemId: number;
}): ApiFormFieldSet {
// Valid field choices
const [choices, setChoices] = useState<any[]>([]);
// Field type for the "value" input
const [fieldType, setFieldType] = useState<'string' | 'choice'>('string');
return useMemo(() => {
return {
stock_item: {
value: itemId,
hidden: true
},
template: {
filters: {
include_inherited: true,
part: partId
},
onValueChange: (value: any, record: any) => {
// Adjust the type of the "value" field based on the selected template
if (record?.choices) {
let _choices: string[] = record.choices.split(',');
if (_choices.length > 0) {
setChoices(
_choices.map((choice) => {
return {
label: choice.trim(),
value: choice.trim()
};
})
);
setFieldType('choice');
} else {
setChoices([]);
setFieldType('string');
}
}
}
},
result: {},
value: {
field_type: fieldType,
choices: fieldType === 'choice' ? choices : undefined
},
attachment: {},
notes: {},
started_datetime: {},
finished_datetime: {}
};
}, [choices, fieldType, partId, itemId]);
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More