mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +00:00
Merege upstream/master into branch
This commit is contained in:
parent
952d6919b6
commit
717001d8f1
6
.github/workflows/docker.yaml
vendored
6
.github/workflows/docker.yaml
vendored
@ -140,14 +140,14 @@ jobs:
|
||||
fi
|
||||
- name: Login to Dockerhub
|
||||
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:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
- name: Log into registry ghcr.io
|
||||
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:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
@ -165,7 +165,7 @@ jobs:
|
||||
- name: Push Docker Images
|
||||
id: push-docker
|
||||
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:
|
||||
context: .
|
||||
file: ./contrib/container/Dockerfile
|
||||
|
2
.github/workflows/scorecard.yaml
vendored
2
.github/workflows/scorecard.yaml
vendored
@ -67,6 +67,6 @@ jobs:
|
||||
|
||||
# Upload the results to GitHub's code scanning dashboard.
|
||||
- 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:
|
||||
sarif_file: results.sarif
|
||||
|
@ -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.
|
||||
|
||||
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
|
||||
|
||||
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 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
|
||||
- Feature pull requests are merged back *into* the master branch
|
||||
- Features *may* also be merged into a release candidate 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
|
||||
- Bug fix branches are made *from* the "stable" branch
|
||||
|
||||
#### Release Candidate Branches
|
||||
|
||||
- Release candidate branches are made from master, and merged into stable.
|
||||
- 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
|
||||
### 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
|
||||
- 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.
|
||||
- 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
|
||||
|
||||
|
@ -1,14 +1,17 @@
|
||||
"""InvenTree API version information."""
|
||||
|
||||
# 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."""
|
||||
|
||||
INVENTREE_API_TEXT = """
|
||||
v206 - 2024-06-04 : ###
|
||||
v207 - 2024-06-04 : ###
|
||||
- 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
|
||||
- Added model_type and model_id fields to the "NotesImage" serializer
|
||||
|
||||
|
@ -38,6 +38,7 @@ LOCALES = [
|
||||
('pl', _('Polish')),
|
||||
('pt', _('Portuguese')),
|
||||
('pt-br', _('Portuguese (Brazilian)')),
|
||||
('ro', _('Romanian')),
|
||||
('ru', _('Russian')),
|
||||
('sk', _('Slovak')),
|
||||
('sl', _('Slovenian')),
|
||||
|
@ -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()
|
@ -125,7 +125,7 @@ def canAppAccessDatabase(
|
||||
excluded_commands.append('test')
|
||||
|
||||
if not allow_plugins:
|
||||
excluded_commands.extend(['collectstatic'])
|
||||
excluded_commands.extend(['collectstatic', 'collectplugins'])
|
||||
|
||||
for cmd in excluded_commands:
|
||||
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
14393
src/backend/InvenTree/locale/ro/LC_MESSAGES/django.po
Normal file
14393
src/backend/InvenTree/locale/ro/LC_MESSAGES/django.po
Normal file
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
@ -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'),
|
||||
),
|
||||
]
|
@ -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()
|
||||
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):
|
||||
"""Placeholder for legacy function used in migrations."""
|
||||
|
@ -179,6 +179,7 @@ class PartTestTemplateSerializer(InvenTree.serializers.InvenTreeModelSerializer)
|
||||
'requires_value',
|
||||
'requires_attachment',
|
||||
'results',
|
||||
'choices',
|
||||
]
|
||||
|
||||
key = serializers.CharField(read_only=True)
|
||||
|
@ -893,51 +893,6 @@ class PartAPITest(PartAPITestBase):
|
||||
# Now there should be 5 total parts
|
||||
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):
|
||||
"""Return list of part thumbnails."""
|
||||
url = reverse('api-part-thumbs')
|
||||
@ -2904,3 +2859,96 @@ class PartSchedulingTest(PartAPITestBase):
|
||||
for entry in data:
|
||||
for k in ['date', 'quantity', 'label']:
|
||||
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']))
|
||||
|
@ -12,6 +12,7 @@ from django.utils.translation import gettext_lazy as _
|
||||
|
||||
import common.models
|
||||
import InvenTree.models
|
||||
import plugin.staticfiles
|
||||
from plugin import InvenTreePlugin, registry
|
||||
|
||||
|
||||
@ -186,6 +187,20 @@ class PluginConfig(InvenTree.models.MetadataMixin, models.Model):
|
||||
|
||||
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):
|
||||
"""This model represents settings for individual plugins."""
|
||||
|
@ -208,15 +208,7 @@ class PluginActivateSerializer(serializers.Serializer):
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
"""Apply the new 'active' value to the plugin instance."""
|
||||
from InvenTree.tasks import check_for_migrations, offload_task
|
||||
|
||||
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)
|
||||
|
||||
instance.activate(validated_data.get('active', True))
|
||||
return instance
|
||||
|
||||
|
||||
|
92
src/backend/InvenTree/plugin/staticfiles.py
Normal file
92
src/backend/InvenTree/plugin/staticfiles.py
Normal 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)
|
@ -180,6 +180,11 @@ class StockItemResource(InvenTreeResource):
|
||||
column_name=_('Supplier Part ID'),
|
||||
widget=widgets.ForeignKeyWidget(SupplierPart),
|
||||
)
|
||||
supplier_part_sku = Field(
|
||||
attribute='supplier_part__SKU',
|
||||
column_name=_('Supplier Part SKU'),
|
||||
readonly=True,
|
||||
)
|
||||
supplier = Field(
|
||||
attribute='supplier_part__supplier__id',
|
||||
column_name=_('Supplier ID'),
|
||||
|
@ -2380,23 +2380,28 @@ class StockItemTestResult(InvenTree.models.InvenTreeMetadataModel):
|
||||
super().clean()
|
||||
|
||||
# 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 key == template.key:
|
||||
if template.requires_value and not self.value:
|
||||
raise ValidationError({
|
||||
'value': _('Value must be provided for this test')
|
||||
})
|
||||
if template:
|
||||
if template.requires_value and not self.value:
|
||||
raise ValidationError({
|
||||
'value': _('Value must be provided for this test')
|
||||
})
|
||||
|
||||
if template.requires_attachment and not self.attachment:
|
||||
raise ValidationError({
|
||||
'attachment': _('Attachment must be uploaded for this test')
|
||||
})
|
||||
if template.requires_attachment and not self.attachment:
|
||||
raise ValidationError({
|
||||
'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
|
||||
def key(self):
|
||||
|
@ -1715,6 +1715,60 @@ class StockTestResultTest(StockAPITestCase):
|
||||
|
||||
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):
|
||||
"""Unit tests for the stock assignment API endpoint, where stock items are manually assigned to a customer."""
|
||||
|
@ -17,6 +17,7 @@
|
||||
<div class="bg-qr-code rounded">
|
||||
<img src="{{ qr_code_url }}" alt="{% trans 'QR Code' %}" class="mx-auto d-block"/>
|
||||
</div>
|
||||
<p class="text-center mt-2">{% trans 'Secret: ' %}{{ secret }}</p>
|
||||
<br>
|
||||
|
||||
<h4>
|
||||
|
@ -305,6 +305,7 @@ function partFields(options={}) {
|
||||
function categoryFields(options={}) {
|
||||
let fields = {
|
||||
parent: {
|
||||
label: '{% trans "Parent" %}',
|
||||
help_text: '{% trans "Parent part category" %}',
|
||||
required: false,
|
||||
tree_picker: {
|
||||
@ -2827,6 +2828,7 @@ function partTestTemplateFields(options={}) {
|
||||
requires_value: {},
|
||||
requires_attachment: {},
|
||||
enabled: {},
|
||||
choices: {},
|
||||
part: {
|
||||
hidden: true,
|
||||
}
|
||||
|
51
src/backend/package-lock.json
generated
51
src/backend/package-lock.json
generated
@ -5,7 +5,7 @@
|
||||
"packages": {
|
||||
"": {
|
||||
"dependencies": {
|
||||
"eslint": "^9.3.0",
|
||||
"eslint": "^9.4.0",
|
||||
"eslint-config-google": "^0.14.0"
|
||||
}
|
||||
},
|
||||
@ -50,6 +50,19 @@
|
||||
"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": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.1.0.tgz",
|
||||
@ -73,24 +86,19 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint/js": {
|
||||
"version": "9.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.3.0.tgz",
|
||||
"integrity": "sha512-niBqk8iwv96+yuTwjM6bWg8ovzAPF9qkICsGtcoa5/dmqcEMfdwNAX7+/OHcJHc7wj7XqPxH98oAHytFYlw6Sw==",
|
||||
"version": "9.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.4.0.tgz",
|
||||
"integrity": "sha512-fdI7VJjP3Rvc70lC4xkFXHB0fiPeojiL1PxVG6t1ZvXQrarj893PweuBTujxDUFk0Fxj4R7PIIAZ/aiiyZPZcg==",
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@humanwhocodes/config-array": {
|
||||
"version": "0.13.0",
|
||||
"resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz",
|
||||
"integrity": "sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==",
|
||||
"dependencies": {
|
||||
"@humanwhocodes/object-schema": "^2.0.3",
|
||||
"debug": "^4.3.1",
|
||||
"minimatch": "^3.0.5"
|
||||
},
|
||||
"node_modules/@eslint/object-schema": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.3.tgz",
|
||||
"integrity": "sha512-HAbhAYKfsAC2EkTqve00ibWIZlaU74Z1EHwAjYr4PXF0YU2VEA1zSIKSSpKszRLRWwHzzRZXvK632u+uXzvsvw==",
|
||||
"engines": {
|
||||
"node": ">=10.10.0"
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@humanwhocodes/module-importer": {
|
||||
@ -105,11 +113,6 @@
|
||||
"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": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.3.0.tgz",
|
||||
@ -319,15 +322,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/eslint": {
|
||||
"version": "9.3.0",
|
||||
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.3.0.tgz",
|
||||
"integrity": "sha512-5Iv4CsZW030lpUqHBapdPo3MJetAPtejVW8B84GIcIIv8+ohFaddXsrn1Gn8uD9ijDb+kcYKFUVmC8qG8B2ORQ==",
|
||||
"version": "9.4.0",
|
||||
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.4.0.tgz",
|
||||
"integrity": "sha512-sjc7Y8cUD1IlwYcTS9qPSvGjAC8Ne9LctpxKKu3x/1IC9bnOg98Zy6GxEJUfr1NojMgVPlyANXYns8oE2c1TAA==",
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.2.0",
|
||||
"@eslint-community/regexpp": "^4.6.1",
|
||||
"@eslint/config-array": "^0.15.1",
|
||||
"@eslint/eslintrc": "^3.1.0",
|
||||
"@eslint/js": "9.3.0",
|
||||
"@humanwhocodes/config-array": "^0.13.0",
|
||||
"@eslint/js": "9.4.0",
|
||||
"@humanwhocodes/module-importer": "^1.0.1",
|
||||
"@humanwhocodes/retry": "^0.3.0",
|
||||
"@nodelib/fs.walk": "^1.2.8",
|
||||
|
@ -1,6 +1,6 @@
|
||||
{
|
||||
"dependencies": {
|
||||
"eslint": "^9.3.0",
|
||||
"eslint": "^9.4.0",
|
||||
"eslint-config-google": "^0.14.0"
|
||||
},
|
||||
"type": "module"
|
||||
|
@ -23,6 +23,7 @@
|
||||
"pl",
|
||||
"pt",
|
||||
"pt-br",
|
||||
"ro",
|
||||
"ru",
|
||||
"sk",
|
||||
"sl",
|
||||
|
@ -27,6 +27,7 @@
|
||||
"@lingui/core": "^4.10.0",
|
||||
"@lingui/react": "^4.10.0",
|
||||
"@mantine/carousel": "^7.8.0",
|
||||
"@mantine/charts": "^7.10.1",
|
||||
"@mantine/core": "^7.10.0",
|
||||
"@mantine/dates": "^7.8.0",
|
||||
"@mantine/dropzone": "^7.8.0",
|
||||
|
@ -1,12 +1,12 @@
|
||||
export const CHART_COLORS: string[] = [
|
||||
'#ffa8a8',
|
||||
'#8ce99a',
|
||||
'#74c0fc',
|
||||
'#ffe066',
|
||||
'#63e6be',
|
||||
'#ffc078',
|
||||
'#d8f5a2',
|
||||
'#66d9e8',
|
||||
'#e599f7',
|
||||
'#dee2e6'
|
||||
'blue',
|
||||
'teal',
|
||||
'lime',
|
||||
'yellow',
|
||||
'grape',
|
||||
'red',
|
||||
'orange',
|
||||
'green',
|
||||
'indigo',
|
||||
'pink'
|
||||
];
|
||||
|
@ -1,9 +1,12 @@
|
||||
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 (
|
||||
formatCurrency(label, {
|
||||
formatCurrency(value, {
|
||||
currency: currency
|
||||
})?.toString() ?? ''
|
||||
})?.toString() ?? value.toString()
|
||||
);
|
||||
}
|
||||
|
@ -59,12 +59,17 @@ export function RelatedModelField({
|
||||
if (field.value === pk) return;
|
||||
|
||||
if (
|
||||
field.value !== null &&
|
||||
field.value !== undefined &&
|
||||
field.value !== ''
|
||||
field?.value !== null &&
|
||||
field?.value !== undefined &&
|
||||
field?.value !== ''
|
||||
) {
|
||||
const url = `${definition.api_url}${field.value}/`;
|
||||
|
||||
if (!url) {
|
||||
setPk(null);
|
||||
return;
|
||||
}
|
||||
|
||||
api.get(url).then((response) => {
|
||||
let pk_field = definition.pk_field ?? 'pk';
|
||||
if (response.data && response.data[pk_field]) {
|
||||
|
@ -24,7 +24,6 @@ import { identifierString } from '../../functions/conversion';
|
||||
import { navigateToLink } from '../../functions/navigation';
|
||||
import { useLocalState } from '../../states/LocalState';
|
||||
import { Boundary } from '../Boundary';
|
||||
import { PlaceholderPanel } from '../items/Placeholder';
|
||||
import { StylishText } from '../items/StylishText';
|
||||
|
||||
/**
|
||||
@ -34,7 +33,7 @@ export type PanelType = {
|
||||
name: string;
|
||||
label: string;
|
||||
icon?: ReactNode;
|
||||
content?: ReactNode;
|
||||
content: ReactNode;
|
||||
hidden?: boolean;
|
||||
disabled?: boolean;
|
||||
showHeadline?: boolean;
|
||||
@ -190,7 +189,7 @@ function BasePanelGroup({
|
||||
</>
|
||||
)}
|
||||
<Boundary label={`PanelContent-${panel.name}`}>
|
||||
{panel.content ?? <PlaceholderPanel />}
|
||||
{panel.content}
|
||||
</Boundary>
|
||||
</Stack>
|
||||
</Tabs.Panel>
|
||||
|
@ -40,6 +40,7 @@ export const getSupportedLanguages = (): Record<string, string> => {
|
||||
pl: t`Polish`,
|
||||
pt: t`Portuguese`,
|
||||
'pt-br': t`Portuguese (Brazilian)`,
|
||||
ro: t`Romanian`,
|
||||
ru: t`Russian`,
|
||||
sk: t`Slovak`,
|
||||
sl: t`Slovenian`,
|
||||
|
@ -866,3 +866,63 @@ export function stockLocationFields({}: {}): ApiFormFieldSet {
|
||||
|
||||
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
Loading…
Reference in New Issue
Block a user