mirror of
https://github.com/inventree/InvenTree.git
synced 2026-07-07 07:31:24 +00:00
Merge branch 'master' into app-stock-coverage
This commit is contained in:
@@ -1,13 +1,25 @@
|
||||
"""InvenTree API version information."""
|
||||
|
||||
# InvenTree API version
|
||||
INVENTREE_API_VERSION = 253
|
||||
INVENTREE_API_VERSION = 257
|
||||
|
||||
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
|
||||
|
||||
|
||||
INVENTREE_API_TEXT = """
|
||||
|
||||
v257 - 2024-09-22 : https://github.com/inventree/InvenTree/pull/8150
|
||||
- Adds API endpoint for reporting barcode scan history
|
||||
|
||||
v256 - 2024-09-19 : https://github.com/inventree/InvenTree/pull/7704
|
||||
- Adjustments for "stocktake" (stock history) API endpoints
|
||||
|
||||
v255 - 2024-09-19 : https://github.com/inventree/InvenTree/pull/8145
|
||||
- Enables copying line items when duplicating an order
|
||||
|
||||
v254 - 2024-09-14 : https://github.com/inventree/InvenTree/pull/7470
|
||||
- Implements new API endpoints for enabling custom UI functionality via plugins
|
||||
|
||||
v253 - 2024-09-14 : https://github.com/inventree/InvenTree/pull/7944
|
||||
- Adjustments for user API endpoints
|
||||
|
||||
|
||||
@@ -10,6 +10,8 @@ Additionally, update the following files with the new locale code:
|
||||
|
||||
- /src/frontend/.linguirc file
|
||||
- /src/frontend/src/contexts/LanguageContext.tsx
|
||||
|
||||
(and then run "invoke int.frontend-trans")
|
||||
"""
|
||||
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
@@ -34,6 +36,7 @@ LOCALES = [
|
||||
('it', _('Italian')),
|
||||
('ja', _('Japanese')),
|
||||
('ko', _('Korean')),
|
||||
('lt', _('Lithuanian')),
|
||||
('lv', _('Latvian')),
|
||||
('nl', _('Dutch')),
|
||||
('no', _('Norwegian')),
|
||||
|
||||
@@ -8,6 +8,7 @@ class Command(BaseCommand):
|
||||
|
||||
def handle(self, *args, **kwargs):
|
||||
"""Run the management command."""
|
||||
from plugin.staticfiles import collect_plugins_static_files
|
||||
import plugin.staticfiles
|
||||
|
||||
collect_plugins_static_files()
|
||||
plugin.staticfiles.collect_plugins_static_files()
|
||||
plugin.staticfiles.clear_plugins_static_files()
|
||||
|
||||
@@ -180,5 +180,9 @@ class RetrieveUpdateDestroyAPI(CleanMixin, generics.RetrieveUpdateDestroyAPIView
|
||||
"""View for retrieve, update and destroy API."""
|
||||
|
||||
|
||||
class RetrieveDestroyAPI(generics.RetrieveDestroyAPIView):
|
||||
"""View for retrieve and destroy API."""
|
||||
|
||||
|
||||
class UpdateAPI(CleanMixin, generics.UpdateAPIView):
|
||||
"""View for update API."""
|
||||
|
||||
@@ -439,7 +439,7 @@ class ReferenceIndexingMixin(models.Model):
|
||||
)
|
||||
|
||||
# Check that the reference field can be rebuild
|
||||
cls.rebuild_reference_field(value, validate=True)
|
||||
return cls.rebuild_reference_field(value, validate=True)
|
||||
|
||||
@classmethod
|
||||
def rebuild_reference_field(cls, reference, validate=False):
|
||||
|
||||
@@ -133,6 +133,34 @@ STATIC_URL = '/static/'
|
||||
# Web URL endpoint for served media files
|
||||
MEDIA_URL = '/media/'
|
||||
|
||||
# Are plugins enabled?
|
||||
PLUGINS_ENABLED = get_boolean_setting(
|
||||
'INVENTREE_PLUGINS_ENABLED', 'plugins_enabled', False
|
||||
)
|
||||
|
||||
PLUGINS_INSTALL_DISABLED = get_boolean_setting(
|
||||
'INVENTREE_PLUGIN_NOINSTALL', 'plugin_noinstall', False
|
||||
)
|
||||
|
||||
PLUGIN_FILE = config.get_plugin_file()
|
||||
|
||||
# Plugin test settings
|
||||
PLUGIN_TESTING = get_setting(
|
||||
'INVENTREE_PLUGIN_TESTING', 'PLUGIN_TESTING', TESTING
|
||||
) # Are plugins being tested?
|
||||
|
||||
PLUGIN_TESTING_SETUP = get_setting(
|
||||
'INVENTREE_PLUGIN_TESTING_SETUP', 'PLUGIN_TESTING_SETUP', False
|
||||
) # Load plugins from setup hooks in testing?
|
||||
|
||||
PLUGIN_TESTING_EVENTS = False # Flag if events are tested right now
|
||||
|
||||
PLUGIN_RETRY = get_setting(
|
||||
'INVENTREE_PLUGIN_RETRY', 'PLUGIN_RETRY', 3, typecast=int
|
||||
) # How often should plugin loading be tried?
|
||||
|
||||
PLUGIN_FILE_CHECKED = False # Was the plugin file checked?
|
||||
|
||||
STATICFILES_DIRS = []
|
||||
|
||||
# Translated Template settings
|
||||
@@ -153,6 +181,12 @@ if DEBUG and 'collectstatic' not in sys.argv:
|
||||
if web_dir.exists():
|
||||
STATICFILES_DIRS.append(web_dir)
|
||||
|
||||
# Append directory for sample plugin static content (if in debug mode)
|
||||
if PLUGINS_ENABLED:
|
||||
print('Adding plugin sample static content')
|
||||
STATICFILES_DIRS.append(BASE_DIR.joinpath('plugin', 'samples', 'static'))
|
||||
|
||||
print('-', STATICFILES_DIRS[-1])
|
||||
STATFILES_I18_PROCESSORS = ['InvenTree.context.status_codes']
|
||||
|
||||
# Color Themes Directory
|
||||
@@ -169,10 +203,11 @@ DBBACKUP_STORAGE = get_setting(
|
||||
|
||||
# Default backup configuration
|
||||
DBBACKUP_STORAGE_OPTIONS = get_setting(
|
||||
'INVENTREE_BACKUP_OPTIONS', 'backup_options', None
|
||||
'INVENTREE_BACKUP_OPTIONS',
|
||||
'backup_options',
|
||||
default_value={'location': config.get_backup_dir()},
|
||||
typecast=dict,
|
||||
)
|
||||
if DBBACKUP_STORAGE_OPTIONS is None:
|
||||
DBBACKUP_STORAGE_OPTIONS = {'location': config.get_backup_dir()}
|
||||
|
||||
INVENTREE_ADMIN_ENABLED = get_boolean_setting(
|
||||
'INVENTREE_ADMIN_ENABLED', config_key='admin_enabled', default_value=True
|
||||
@@ -555,6 +590,9 @@ for key in db_keys:
|
||||
# Check that required database configuration options are specified
|
||||
required_keys = ['ENGINE', 'NAME']
|
||||
|
||||
# Ensure all database keys are upper case
|
||||
db_config = {key.upper(): value for key, value in db_config.items()}
|
||||
|
||||
for key in required_keys:
|
||||
if key not in db_config: # pragma: no cover
|
||||
error_msg = f'Missing required database configuration value {key}'
|
||||
@@ -1254,29 +1292,6 @@ IGNORED_ERRORS = [Http404, django.core.exceptions.PermissionDenied]
|
||||
MAINTENANCE_MODE_RETRY_AFTER = 10
|
||||
MAINTENANCE_MODE_STATE_BACKEND = 'InvenTree.backends.InvenTreeMaintenanceModeBackend'
|
||||
|
||||
# Are plugins enabled?
|
||||
PLUGINS_ENABLED = get_boolean_setting(
|
||||
'INVENTREE_PLUGINS_ENABLED', 'plugins_enabled', False
|
||||
)
|
||||
PLUGINS_INSTALL_DISABLED = get_boolean_setting(
|
||||
'INVENTREE_PLUGIN_NOINSTALL', 'plugin_noinstall', False
|
||||
)
|
||||
|
||||
PLUGIN_FILE = config.get_plugin_file()
|
||||
|
||||
# Plugin test settings
|
||||
PLUGIN_TESTING = get_setting(
|
||||
'INVENTREE_PLUGIN_TESTING', 'PLUGIN_TESTING', TESTING
|
||||
) # Are plugins being tested?
|
||||
PLUGIN_TESTING_SETUP = get_setting(
|
||||
'INVENTREE_PLUGIN_TESTING_SETUP', 'PLUGIN_TESTING_SETUP', False
|
||||
) # Load plugins from setup hooks in testing?
|
||||
PLUGIN_TESTING_EVENTS = False # Flag if events are tested right now
|
||||
PLUGIN_RETRY = get_setting(
|
||||
'INVENTREE_PLUGIN_RETRY', 'PLUGIN_RETRY', 3, typecast=int
|
||||
) # How often should plugin loading be tried?
|
||||
PLUGIN_FILE_CHECKED = False # Was the plugin file checked?
|
||||
|
||||
# Flag to allow table events during testing
|
||||
TESTING_TABLE_EVENTS = False
|
||||
|
||||
|
||||
@@ -291,6 +291,18 @@ class InvenTreeAPITestCase(ExchangeRateMixin, UserMixin, APITestCase):
|
||||
|
||||
self.assertLess(n, value, msg=msg)
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
"""Setup for API tests.
|
||||
|
||||
- Ensure that all global settings are assigned default values.
|
||||
"""
|
||||
from common.models import InvenTreeSetting
|
||||
|
||||
InvenTreeSetting.build_default_values()
|
||||
|
||||
super().setUpTestData()
|
||||
|
||||
def check_response(self, url, response, expected_code=None):
|
||||
"""Debug output for an unexpected response."""
|
||||
# Check that the response returned the expected status code
|
||||
|
||||
@@ -484,7 +484,7 @@ if settings.ENABLE_PLATFORM_FRONTEND:
|
||||
|
||||
urlpatterns += frontendpatterns
|
||||
|
||||
# Append custom plugin URLs (if plugin support is enabled)
|
||||
# Append custom plugin URLs (if custom plugin support is enabled)
|
||||
if settings.PLUGINS_ENABLED:
|
||||
urlpatterns.append(get_plugin_urls())
|
||||
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
"""Queryset filtering helper functions for the Build app."""
|
||||
|
||||
|
||||
from django.db import models
|
||||
from django.db.models import Sum, Q
|
||||
from django.db.models.functions import Coalesce
|
||||
|
||||
|
||||
def annotate_allocated_quantity(queryset: Q) -> Q:
|
||||
"""
|
||||
Annotate the 'allocated' quantity for each build item in the queryset.
|
||||
|
||||
Arguments:
|
||||
queryset: The BuildLine queryset to annotate
|
||||
|
||||
"""
|
||||
|
||||
queryset = queryset.prefetch_related('allocations')
|
||||
|
||||
return queryset.annotate(
|
||||
allocated=Coalesce(
|
||||
Sum('allocations__quantity'), 0,
|
||||
output_field=models.DecimalField()
|
||||
)
|
||||
)
|
||||
@@ -9,7 +9,7 @@ from django.contrib.auth.models import User
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.validators import MinValueValidator
|
||||
from django.db import models, transaction
|
||||
from django.db.models import Sum, Q
|
||||
from django.db.models import F, Sum, Q
|
||||
from django.db.models.functions import Coalesce
|
||||
from django.db.models.signals import post_save
|
||||
from django.dispatch.dispatcher import receiver
|
||||
@@ -24,6 +24,7 @@ from rest_framework import serializers
|
||||
from build.status_codes import BuildStatus, BuildStatusGroups
|
||||
from stock.status_codes import StockStatus, StockHistoryCode
|
||||
|
||||
from build.filters import annotate_allocated_quantity
|
||||
from build.validators import generate_next_build_reference, validate_build_order_reference
|
||||
from generic.states import StateTransitionMixin
|
||||
|
||||
@@ -124,8 +125,7 @@ class Build(
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
"""Custom save method for the BuildOrder model"""
|
||||
self.validate_reference_field(self.reference)
|
||||
self.reference_int = self.rebuild_reference_field(self.reference)
|
||||
self.reference_int = self.validate_reference_field(self.reference)
|
||||
|
||||
# Check part when initially creating the build order
|
||||
if not self.pk or self.has_field_changed('part'):
|
||||
@@ -987,12 +987,13 @@ class Build(
|
||||
items_to_save = []
|
||||
items_to_delete = []
|
||||
|
||||
lines = self.untracked_line_items
|
||||
lines = lines.prefetch_related('allocations')
|
||||
lines = self.untracked_line_items.all()
|
||||
lines = lines.exclude(bom_item__consumable=True)
|
||||
lines = annotate_allocated_quantity(lines)
|
||||
|
||||
for build_line in lines:
|
||||
|
||||
reduce_by = build_line.allocated_quantity() - build_line.quantity
|
||||
reduce_by = build_line.allocated - build_line.quantity
|
||||
|
||||
if reduce_by <= 0:
|
||||
continue
|
||||
@@ -1290,18 +1291,20 @@ class Build(
|
||||
"""Returns a list of BuildLine objects which have not been fully allocated."""
|
||||
lines = self.build_lines.all()
|
||||
|
||||
# Remove any 'consumable' line items
|
||||
lines = lines.exclude(bom_item__consumable=True)
|
||||
|
||||
if tracked is True:
|
||||
lines = lines.filter(bom_item__sub_part__trackable=True)
|
||||
elif tracked is False:
|
||||
lines = lines.filter(bom_item__sub_part__trackable=False)
|
||||
|
||||
unallocated_lines = []
|
||||
lines = annotate_allocated_quantity(lines)
|
||||
|
||||
for line in lines:
|
||||
if not line.is_fully_allocated():
|
||||
unallocated_lines.append(line)
|
||||
# Filter out any lines which have been fully allocated
|
||||
lines = lines.filter(allocated__lt=F('quantity'))
|
||||
|
||||
return unallocated_lines
|
||||
return lines
|
||||
|
||||
def is_fully_allocated(self, tracked=None):
|
||||
"""Test if the BuildOrder has been fully allocated.
|
||||
@@ -1314,19 +1317,24 @@ class Build(
|
||||
Returns:
|
||||
True if the BuildOrder has been fully allocated, otherwise False
|
||||
"""
|
||||
lines = self.unallocated_lines(tracked=tracked)
|
||||
return len(lines) == 0
|
||||
|
||||
return self.unallocated_lines(tracked=tracked).count() == 0
|
||||
|
||||
def is_output_fully_allocated(self, output):
|
||||
"""Determine if the specified output (StockItem) has been fully allocated for this build
|
||||
|
||||
Args:
|
||||
output: StockItem object
|
||||
output: StockItem object (the "in production" output to test against)
|
||||
|
||||
To determine if the output has been fully allocated,
|
||||
we need to test all "trackable" BuildLine objects
|
||||
"""
|
||||
for line in self.build_lines.filter(bom_item__sub_part__trackable=True):
|
||||
|
||||
lines = self.build_lines.filter(bom_item__sub_part__trackable=True)
|
||||
lines = lines.exclude(bom_item__consumable=True)
|
||||
|
||||
# Find any lines which have not been fully allocated
|
||||
for line in lines:
|
||||
# Grab all BuildItem objects which point to this output
|
||||
allocations = BuildItem.objects.filter(
|
||||
build_line=line,
|
||||
@@ -1350,11 +1358,14 @@ class Build(
|
||||
Returns:
|
||||
True if any BuildLine has been over-allocated.
|
||||
"""
|
||||
for line in self.build_lines.all():
|
||||
if line.is_overallocated():
|
||||
return True
|
||||
|
||||
return False
|
||||
lines = self.build_lines.all().exclude(bom_item__consumable=True)
|
||||
lines = annotate_allocated_quantity(lines)
|
||||
|
||||
# Find any lines which have been over-allocated
|
||||
lines = lines.filter(allocated__gt=F('quantity'))
|
||||
|
||||
return lines.count() > 0
|
||||
|
||||
@property
|
||||
def is_active(self):
|
||||
@@ -1692,6 +1703,9 @@ class BuildItem(InvenTree.models.InvenTreeMetadataModel):
|
||||
|
||||
- If the referenced part is trackable, the stock item will be *installed* into the build output
|
||||
- If the referenced part is *not* trackable, the stock item will be *consumed* by the build order
|
||||
|
||||
TODO: This is quite expensive (in terms of number of database hits) - and requires some thought
|
||||
|
||||
"""
|
||||
item = self.stock_item
|
||||
|
||||
|
||||
@@ -35,6 +35,15 @@ class AttachmentAdmin(admin.ModelAdmin):
|
||||
search_fields = ('content_type', 'comment')
|
||||
|
||||
|
||||
@admin.register(common.models.BarcodeScanResult)
|
||||
class BarcodeScanResultAdmin(admin.ModelAdmin):
|
||||
"""Admin interface for BarcodeScanResult objects."""
|
||||
|
||||
list_display = ('data', 'timestamp', 'user', 'endpoint', 'result')
|
||||
|
||||
list_filter = ('user', 'endpoint', 'result')
|
||||
|
||||
|
||||
@admin.register(common.models.ProjectCode)
|
||||
class ProjectCodeAdmin(ImportExportModelAdmin):
|
||||
"""Admin settings for ProjectCode."""
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
# Generated by Django 4.2.15 on 2024-09-21 06:05
|
||||
|
||||
import InvenTree.models
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
('common', '0029_inventreecustomuserstatemodel'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='BarcodeScanResult',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('data', models.CharField(help_text='Barcode data', max_length=250, verbose_name='Data')),
|
||||
('timestamp', models.DateTimeField(auto_now_add=True, help_text='Date and time of the barcode scan', verbose_name='Timestamp')),
|
||||
('endpoint', models.CharField(blank=True, help_text='URL endpoint which processed the barcode', max_length=250, null=True, verbose_name='Path')),
|
||||
('context', models.JSONField(blank=True, help_text='Context data for the barcode scan', max_length=1000, null=True, verbose_name='Context')),
|
||||
('response', models.JSONField(blank=True, help_text='Response data from the barcode scan', max_length=1000, null=True, verbose_name='Response')),
|
||||
('result', models.BooleanField(default=False, help_text='Was the barcode scan successful?', verbose_name='Result')),
|
||||
('user', models.ForeignKey(blank=True, help_text='User who scanned the barcode', null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, verbose_name='User')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Barcode Scan',
|
||||
},
|
||||
bases=(InvenTree.models.PluginValidationMixin, models.Model),
|
||||
),
|
||||
]
|
||||
@@ -43,6 +43,7 @@ from taggit.managers import TaggableManager
|
||||
import build.validators
|
||||
import common.currency
|
||||
import common.validators
|
||||
import InvenTree.exceptions
|
||||
import InvenTree.fields
|
||||
import InvenTree.helpers
|
||||
import InvenTree.models
|
||||
@@ -1398,6 +1399,18 @@ class InvenTreeSetting(BaseInvenTreeSetting):
|
||||
'default': True,
|
||||
'validator': bool,
|
||||
},
|
||||
'BARCODE_STORE_RESULTS': {
|
||||
'name': _('Store Barcode Results'),
|
||||
'description': _('Store barcode scan results in the database'),
|
||||
'default': False,
|
||||
'validator': bool,
|
||||
},
|
||||
'BARCODE_RESULTS_MAX_NUM': {
|
||||
'name': _('Barcode Scans Maximum Count'),
|
||||
'description': _('Maximum number of barcode scan results to store'),
|
||||
'default': 100,
|
||||
'validator': [int, MinValueValidator(1)],
|
||||
},
|
||||
'BARCODE_INPUT_DELAY': {
|
||||
'name': _('Barcode Input Delay'),
|
||||
'description': _('Barcode input processing delay time'),
|
||||
@@ -2095,6 +2108,13 @@ class InvenTreeSetting(BaseInvenTreeSetting):
|
||||
'validator': bool,
|
||||
'after_save': reload_plugin_registry,
|
||||
},
|
||||
'ENABLE_PLUGINS_INTERFACE': {
|
||||
'name': _('Enable interface integration'),
|
||||
'description': _('Enable plugins to integrate into the user interface'),
|
||||
'default': False,
|
||||
'validator': bool,
|
||||
'after_save': reload_plugin_registry,
|
||||
},
|
||||
'PROJECT_CODES_ENABLED': {
|
||||
'name': _('Enable project codes'),
|
||||
'description': _('Enable project codes for tracking projects'),
|
||||
@@ -3438,3 +3458,67 @@ class InvenTreeCustomUserStateModel(models.Model):
|
||||
})
|
||||
|
||||
return super().clean()
|
||||
|
||||
|
||||
class BarcodeScanResult(InvenTree.models.InvenTreeModel):
|
||||
"""Model for storing barcode scans results."""
|
||||
|
||||
BARCODE_SCAN_MAX_LEN = 250
|
||||
|
||||
class Meta:
|
||||
"""Model meta options."""
|
||||
|
||||
verbose_name = _('Barcode Scan')
|
||||
|
||||
data = models.CharField(
|
||||
max_length=BARCODE_SCAN_MAX_LEN,
|
||||
verbose_name=_('Data'),
|
||||
help_text=_('Barcode data'),
|
||||
blank=False,
|
||||
null=False,
|
||||
)
|
||||
|
||||
user = models.ForeignKey(
|
||||
User,
|
||||
on_delete=models.SET_NULL,
|
||||
blank=True,
|
||||
null=True,
|
||||
verbose_name=_('User'),
|
||||
help_text=_('User who scanned the barcode'),
|
||||
)
|
||||
|
||||
timestamp = models.DateTimeField(
|
||||
auto_now_add=True,
|
||||
verbose_name=_('Timestamp'),
|
||||
help_text=_('Date and time of the barcode scan'),
|
||||
)
|
||||
|
||||
endpoint = models.CharField(
|
||||
max_length=250,
|
||||
verbose_name=_('Path'),
|
||||
help_text=_('URL endpoint which processed the barcode'),
|
||||
blank=True,
|
||||
null=True,
|
||||
)
|
||||
|
||||
context = models.JSONField(
|
||||
max_length=1000,
|
||||
verbose_name=_('Context'),
|
||||
help_text=_('Context data for the barcode scan'),
|
||||
blank=True,
|
||||
null=True,
|
||||
)
|
||||
|
||||
response = models.JSONField(
|
||||
max_length=1000,
|
||||
verbose_name=_('Response'),
|
||||
help_text=_('Response data from the barcode scan'),
|
||||
blank=True,
|
||||
null=True,
|
||||
)
|
||||
|
||||
result = models.BooleanField(
|
||||
verbose_name=_('Result'),
|
||||
help_text=_('Was the barcode scan successful?'),
|
||||
default=False,
|
||||
)
|
||||
|
||||
@@ -436,6 +436,13 @@ class SupplierPriceBreakList(ListCreateAPI):
|
||||
serializer_class = SupplierPriceBreakSerializer
|
||||
filterset_class = SupplierPriceBreakFilter
|
||||
|
||||
def get_queryset(self):
|
||||
"""Return annotated queryset for the SupplierPriceBreak list endpoint."""
|
||||
queryset = super().get_queryset()
|
||||
queryset = SupplierPriceBreakSerializer.annotate_queryset(queryset)
|
||||
|
||||
return queryset
|
||||
|
||||
def get_serializer(self, *args, **kwargs):
|
||||
"""Return serializer instance for this endpoint."""
|
||||
try:
|
||||
@@ -468,6 +475,13 @@ class SupplierPriceBreakDetail(RetrieveUpdateDestroyAPI):
|
||||
queryset = SupplierPriceBreak.objects.all()
|
||||
serializer_class = SupplierPriceBreakSerializer
|
||||
|
||||
def get_queryset(self):
|
||||
"""Return annotated queryset for the SupplierPriceBreak list endpoint."""
|
||||
queryset = super().get_queryset()
|
||||
queryset = SupplierPriceBreakSerializer.annotate_queryset(queryset)
|
||||
|
||||
return queryset
|
||||
|
||||
|
||||
manufacturer_part_api_urls = [
|
||||
path(
|
||||
|
||||
@@ -512,6 +512,13 @@ class SupplierPriceBreakSerializer(
|
||||
if not part_detail:
|
||||
self.fields.pop('part_detail', None)
|
||||
|
||||
@staticmethod
|
||||
def annotate_queryset(queryset):
|
||||
"""Prefetch related fields for the queryset."""
|
||||
queryset = queryset.select_related('part', 'part__supplier', 'part__part')
|
||||
|
||||
return queryset
|
||||
|
||||
quantity = InvenTreeDecimalField()
|
||||
|
||||
price = InvenTreeMoneySerializer(allow_null=True, required=True, label=_('Price'))
|
||||
|
||||
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
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
@@ -5,7 +5,6 @@ from typing import cast
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import authenticate, login
|
||||
from django.db import transaction
|
||||
from django.db.models import F, Q
|
||||
from django.http.response import JsonResponse
|
||||
from django.urls import include, path, re_path
|
||||
@@ -14,7 +13,6 @@ from django.utils.translation import gettext_lazy as _
|
||||
from django_filters import rest_framework as rest_filters
|
||||
from django_ical.views import ICalFeed
|
||||
from rest_framework import status
|
||||
from rest_framework.exceptions import ValidationError
|
||||
from rest_framework.response import Response
|
||||
|
||||
import common.models
|
||||
@@ -214,54 +212,6 @@ class PurchaseOrderList(PurchaseOrderMixin, DataExportViewMixin, ListCreateAPI):
|
||||
|
||||
filterset_class = PurchaseOrderFilter
|
||||
|
||||
def create(self, request, *args, **kwargs):
|
||||
"""Save user information on create."""
|
||||
data = self.clean_data(request.data)
|
||||
|
||||
duplicate_order = data.pop('duplicate_order', None)
|
||||
duplicate_line_items = str2bool(data.pop('duplicate_line_items', False))
|
||||
duplicate_extra_lines = str2bool(data.pop('duplicate_extra_lines', False))
|
||||
|
||||
if duplicate_order is not None:
|
||||
try:
|
||||
duplicate_order = models.PurchaseOrder.objects.get(pk=duplicate_order)
|
||||
except (ValueError, models.PurchaseOrder.DoesNotExist):
|
||||
raise ValidationError({
|
||||
'duplicate_order': [_('No matching purchase order found')]
|
||||
})
|
||||
|
||||
serializer = self.get_serializer(data=data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
with transaction.atomic():
|
||||
order = serializer.save()
|
||||
order.created_by = request.user
|
||||
order.save()
|
||||
|
||||
# Duplicate line items from other order if required
|
||||
if duplicate_order is not None:
|
||||
if duplicate_line_items:
|
||||
for line in duplicate_order.lines.all():
|
||||
# Copy the line across to the new order
|
||||
line.pk = None
|
||||
line.order = order
|
||||
line.received = 0
|
||||
|
||||
line.save()
|
||||
|
||||
if duplicate_extra_lines:
|
||||
for line in duplicate_order.extra_lines.all():
|
||||
# Copy the line across to the new order
|
||||
line.pk = None
|
||||
line.order = order
|
||||
|
||||
line.save()
|
||||
|
||||
headers = self.get_success_headers(serializer.data)
|
||||
return Response(
|
||||
serializer.data, status=status.HTTP_201_CREATED, headers=headers
|
||||
)
|
||||
|
||||
def filter_queryset(self, queryset):
|
||||
"""Custom queryset filtering."""
|
||||
# Perform basic filtering
|
||||
@@ -1041,11 +991,30 @@ class SalesOrderShipmentFilter(rest_filters.FilterSet):
|
||||
return queryset.filter(delivery_date=None)
|
||||
|
||||
|
||||
class SalesOrderShipmentList(ListCreateAPI):
|
||||
"""API list endpoint for SalesOrderShipment model."""
|
||||
class SalesOrderShipmentMixin:
|
||||
"""Mixin class for SalesOrderShipment endpoints."""
|
||||
|
||||
queryset = models.SalesOrderShipment.objects.all()
|
||||
serializer_class = serializers.SalesOrderShipmentSerializer
|
||||
|
||||
def get_queryset(self, *args, **kwargs):
|
||||
"""Return annotated queryset for this endpoint."""
|
||||
queryset = super().get_queryset(*args, **kwargs)
|
||||
|
||||
queryset = queryset.prefetch_related(
|
||||
'order',
|
||||
'order__customer',
|
||||
'allocations',
|
||||
'allocations__item',
|
||||
'allocations__item__part',
|
||||
)
|
||||
|
||||
return queryset
|
||||
|
||||
|
||||
class SalesOrderShipmentList(SalesOrderShipmentMixin, ListCreateAPI):
|
||||
"""API list endpoint for SalesOrderShipment model."""
|
||||
|
||||
filterset_class = SalesOrderShipmentFilter
|
||||
|
||||
filter_backends = SEARCH_ORDER_FILTER_ALIAS
|
||||
@@ -1053,12 +1022,9 @@ class SalesOrderShipmentList(ListCreateAPI):
|
||||
ordering_fields = ['delivery_date', 'shipment_date']
|
||||
|
||||
|
||||
class SalesOrderShipmentDetail(RetrieveUpdateDestroyAPI):
|
||||
class SalesOrderShipmentDetail(SalesOrderShipmentMixin, RetrieveUpdateDestroyAPI):
|
||||
"""API detail endpooint for SalesOrderShipment model."""
|
||||
|
||||
queryset = models.SalesOrderShipment.objects.all()
|
||||
serializer_class = serializers.SalesOrderShipmentSerializer
|
||||
|
||||
|
||||
class SalesOrderShipmentComplete(CreateAPI):
|
||||
"""API endpoint for completing (shipping) a SalesOrderShipment."""
|
||||
|
||||
@@ -247,6 +247,16 @@ class Order(
|
||||
'contact': _('Contact does not match selected company')
|
||||
})
|
||||
|
||||
def clean_line_item(self, line):
|
||||
"""Clean a line item for this order.
|
||||
|
||||
Used when duplicating an existing line item,
|
||||
to ensure it is 'fresh'.
|
||||
"""
|
||||
line.pk = None
|
||||
line.target_date = None
|
||||
line.order = self
|
||||
|
||||
def report_context(self):
|
||||
"""Generate context data for the reporting interface."""
|
||||
return {
|
||||
@@ -379,6 +389,11 @@ class PurchaseOrder(TotalPriceMixin, Order):
|
||||
|
||||
verbose_name = _('Purchase Order')
|
||||
|
||||
def clean_line_item(self, line):
|
||||
"""Clean a line item for this PurchaseOrder."""
|
||||
super().clean_line_item(line)
|
||||
line.received = 0
|
||||
|
||||
def report_context(self):
|
||||
"""Return report context data for this PurchaseOrder."""
|
||||
return {**super().report_context(), 'supplier': self.supplier}
|
||||
@@ -892,6 +907,11 @@ class SalesOrder(TotalPriceMixin, Order):
|
||||
|
||||
verbose_name = _('Sales Order')
|
||||
|
||||
def clean_line_item(self, line):
|
||||
"""Clean a line item for this SalesOrder."""
|
||||
super().clean_line_item(line)
|
||||
line.shipped = 0
|
||||
|
||||
def report_context(self):
|
||||
"""Generate report context data for this SalesOrder."""
|
||||
return {**super().report_context(), 'customer': self.customer}
|
||||
@@ -2083,6 +2103,12 @@ class ReturnOrder(TotalPriceMixin, Order):
|
||||
|
||||
verbose_name = _('Return Order')
|
||||
|
||||
def clean_line_item(self, line):
|
||||
"""Clean a line item for this ReturnOrder."""
|
||||
super().clean_line_item(line)
|
||||
line.received_date = None
|
||||
line.outcome = ReturnOrderLineStatus.PENDING.value
|
||||
|
||||
def report_context(self):
|
||||
"""Generate report context data for this ReturnOrder."""
|
||||
return {**super().report_context(), 'customer': self.customer}
|
||||
|
||||
@@ -74,10 +74,39 @@ class TotalPriceMixin(serializers.Serializer):
|
||||
)
|
||||
|
||||
|
||||
class DuplicateOrderSerializer(serializers.Serializer):
|
||||
"""Serializer for specifying options when duplicating an order."""
|
||||
|
||||
class Meta:
|
||||
"""Metaclass options."""
|
||||
|
||||
fields = ['order_id', 'copy_lines', 'copy_extra_lines']
|
||||
|
||||
order_id = serializers.IntegerField(
|
||||
required=True, label=_('Order ID'), help_text=_('ID of the order to duplicate')
|
||||
)
|
||||
|
||||
copy_lines = serializers.BooleanField(
|
||||
required=False,
|
||||
default=True,
|
||||
label=_('Copy Lines'),
|
||||
help_text=_('Copy line items from the original order'),
|
||||
)
|
||||
|
||||
copy_extra_lines = serializers.BooleanField(
|
||||
required=False,
|
||||
default=True,
|
||||
label=_('Copy Extra Lines'),
|
||||
help_text=_('Copy extra line items from the original order'),
|
||||
)
|
||||
|
||||
|
||||
class AbstractOrderSerializer(DataImportExportSerializerMixin, serializers.Serializer):
|
||||
"""Abstract serializer class which provides fields common to all order types."""
|
||||
|
||||
export_exclude_fields = ['notes']
|
||||
export_exclude_fields = ['notes', 'duplicate']
|
||||
|
||||
import_exclude_fields = ['notes', 'duplicate']
|
||||
|
||||
# Number of line items in this order
|
||||
line_items = serializers.IntegerField(read_only=True, label=_('Line Items'))
|
||||
@@ -127,6 +156,13 @@ class AbstractOrderSerializer(DataImportExportSerializerMixin, serializers.Seria
|
||||
required=False, allow_null=True, label=_('Creation Date')
|
||||
)
|
||||
|
||||
duplicate = DuplicateOrderSerializer(
|
||||
label=_('Duplicate Order'),
|
||||
help_text=_('Specify options for duplicating this order'),
|
||||
required=False,
|
||||
write_only=True,
|
||||
)
|
||||
|
||||
def validate_reference(self, reference):
|
||||
"""Custom validation for the reference field."""
|
||||
self.Meta.model.validate_reference_field(reference)
|
||||
@@ -166,9 +202,49 @@ class AbstractOrderSerializer(DataImportExportSerializerMixin, serializers.Seria
|
||||
'notes',
|
||||
'barcode_hash',
|
||||
'overdue',
|
||||
'duplicate',
|
||||
*extra_fields,
|
||||
]
|
||||
|
||||
def clean_line_item(self, line):
|
||||
"""Clean a line item object (when duplicating)."""
|
||||
line.pk = None
|
||||
line.order = self
|
||||
|
||||
@transaction.atomic
|
||||
def create(self, validated_data):
|
||||
"""Create a new order object.
|
||||
|
||||
Optionally, copy line items from an existing order.
|
||||
"""
|
||||
duplicate = validated_data.pop('duplicate', None)
|
||||
|
||||
instance = super().create(validated_data)
|
||||
|
||||
if duplicate:
|
||||
order_id = duplicate.get('order_id', None)
|
||||
copy_lines = duplicate.get('copy_lines', True)
|
||||
copy_extra_lines = duplicate.get('copy_extra_lines', True)
|
||||
|
||||
try:
|
||||
copy_from = instance.__class__.objects.get(pk=order_id)
|
||||
except Exception:
|
||||
# If the order ID is invalid, raise a validation error
|
||||
raise ValidationError(_('Invalid order ID'))
|
||||
|
||||
if copy_lines:
|
||||
for line in copy_from.lines.all():
|
||||
instance.clean_line_item(line)
|
||||
line.save()
|
||||
|
||||
if copy_extra_lines:
|
||||
for line in copy_from.extra_lines.all():
|
||||
line.pk = None
|
||||
line.order = instance
|
||||
line.save()
|
||||
|
||||
return instance
|
||||
|
||||
|
||||
class AbstractLineItemSerializer:
|
||||
"""Abstract serializer for LineItem object."""
|
||||
@@ -259,6 +335,12 @@ class PurchaseOrderSerializer(
|
||||
if supplier_detail is not True:
|
||||
self.fields.pop('supplier_detail', None)
|
||||
|
||||
def skip_create_fields(self):
|
||||
"""Skip these fields when instantiating a new object."""
|
||||
fields = super().skip_create_fields()
|
||||
|
||||
return [*fields, 'duplicate']
|
||||
|
||||
@staticmethod
|
||||
def annotate_queryset(queryset):
|
||||
"""Add extra information to the queryset.
|
||||
@@ -900,6 +982,12 @@ class SalesOrderSerializer(
|
||||
if customer_detail is not True:
|
||||
self.fields.pop('customer_detail', None)
|
||||
|
||||
def skip_create_fields(self):
|
||||
"""Skip these fields when instantiating a new object."""
|
||||
fields = super().skip_create_fields()
|
||||
|
||||
return [*fields, 'duplicate']
|
||||
|
||||
@staticmethod
|
||||
def annotate_queryset(queryset):
|
||||
"""Add extra information to the queryset.
|
||||
@@ -1692,6 +1780,12 @@ class ReturnOrderSerializer(
|
||||
if customer_detail is not True:
|
||||
self.fields.pop('customer_detail', None)
|
||||
|
||||
def skip_create_fields(self):
|
||||
"""Skip these fields when instantiating a new object."""
|
||||
fields = super().skip_create_fields()
|
||||
|
||||
return [*fields, 'duplicate']
|
||||
|
||||
@staticmethod
|
||||
def annotate_queryset(queryset):
|
||||
"""Custom annotation for the serializer queryset."""
|
||||
|
||||
@@ -439,18 +439,22 @@ class PurchaseOrderTest(OrderTest):
|
||||
del data['reference']
|
||||
|
||||
# Duplicate with non-existent PK to provoke error
|
||||
data['duplicate_order'] = 10000001
|
||||
data['duplicate_line_items'] = True
|
||||
data['duplicate_extra_lines'] = False
|
||||
data['duplicate'] = {
|
||||
'order_id': 10000001,
|
||||
'copy_lines': True,
|
||||
'copy_extra_lines': False,
|
||||
}
|
||||
|
||||
data['reference'] = 'PO-9999'
|
||||
|
||||
# Duplicate via the API
|
||||
response = self.post(reverse('api-po-list'), data, expected_code=400)
|
||||
|
||||
data['duplicate_order'] = 1
|
||||
data['duplicate_line_items'] = True
|
||||
data['duplicate_extra_lines'] = False
|
||||
data['duplicate'] = {
|
||||
'order_id': 1,
|
||||
'copy_lines': True,
|
||||
'copy_extra_lines': False,
|
||||
}
|
||||
|
||||
data['reference'] = 'PO-9999'
|
||||
|
||||
@@ -466,8 +470,12 @@ class PurchaseOrderTest(OrderTest):
|
||||
self.assertEqual(po_dup.lines.count(), po.lines.count())
|
||||
|
||||
data['reference'] = 'PO-9998'
|
||||
data['duplicate_line_items'] = False
|
||||
data['duplicate_extra_lines'] = True
|
||||
|
||||
data['duplicate'] = {
|
||||
'order_id': 1,
|
||||
'copy_lines': False,
|
||||
'copy_extra_lines': True,
|
||||
}
|
||||
|
||||
response = self.post(reverse('api-po-list'), data, expected_code=201)
|
||||
|
||||
|
||||
@@ -1715,6 +1715,13 @@ class PartStocktakeReportList(ListAPI):
|
||||
ordering = '-pk'
|
||||
|
||||
|
||||
class PartStocktakeReportDetail(RetrieveUpdateDestroyAPI):
|
||||
"""API endpoint for detail view of a single PartStocktakeReport object."""
|
||||
|
||||
queryset = PartStocktakeReport.objects.all()
|
||||
serializer_class = part_serializers.PartStocktakeReportSerializer
|
||||
|
||||
|
||||
class PartStocktakeReportGenerate(CreateAPI):
|
||||
"""API endpoint for manually generating a new PartStocktakeReport."""
|
||||
|
||||
@@ -2184,6 +2191,11 @@ part_api_urls = [
|
||||
PartStocktakeReportGenerate.as_view(),
|
||||
name='api-part-stocktake-report-generate',
|
||||
),
|
||||
path(
|
||||
'<int:pk>/',
|
||||
PartStocktakeReportDetail.as_view(),
|
||||
name='api-part-stocktake-report-detail',
|
||||
),
|
||||
path(
|
||||
'',
|
||||
PartStocktakeReportList.as_view(),
|
||||
|
||||
@@ -1193,6 +1193,7 @@ class PartStocktakeReportSerializer(InvenTree.serializers.InvenTreeModelSerializ
|
||||
|
||||
model = PartStocktakeReport
|
||||
fields = ['pk', 'date', 'report', 'part_count', 'user', 'user_detail']
|
||||
read_only_fields = ['date', 'report', 'part_count', 'user']
|
||||
|
||||
user_detail = InvenTree.serializers.UserSerializer(
|
||||
source='user', read_only=True, many=False
|
||||
|
||||
@@ -11,12 +11,15 @@ from django_filters.rest_framework import DjangoFilterBackend
|
||||
from drf_spectacular.utils import extend_schema
|
||||
from rest_framework import permissions, status
|
||||
from rest_framework.exceptions import NotFound
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
|
||||
import plugin.serializers as PluginSerializers
|
||||
from common.api import GlobalSettingsPermissions
|
||||
from common.settings import get_global_setting
|
||||
from InvenTree.api import MetadataView
|
||||
from InvenTree.exceptions import log_error
|
||||
from InvenTree.filters import SEARCH_ORDER_FILTER
|
||||
from InvenTree.mixins import (
|
||||
CreateAPI,
|
||||
@@ -414,6 +417,43 @@ class RegistryStatusView(APIView):
|
||||
return Response(result)
|
||||
|
||||
|
||||
class PluginPanelList(APIView):
|
||||
"""API endpoint for listing all available plugin panels."""
|
||||
|
||||
permission_classes = [IsAuthenticated]
|
||||
serializer_class = PluginSerializers.PluginPanelSerializer
|
||||
|
||||
@extend_schema(responses={200: PluginSerializers.PluginPanelSerializer(many=True)})
|
||||
def get(self, request):
|
||||
"""Show available plugin panels."""
|
||||
target_model = request.query_params.get('target_model', None)
|
||||
target_id = request.query_params.get('target_id', None)
|
||||
|
||||
panels = []
|
||||
|
||||
if get_global_setting('ENABLE_PLUGINS_INTERFACE'):
|
||||
# Extract all plugins from the registry which provide custom panels
|
||||
for _plugin in registry.with_mixin('ui', active=True):
|
||||
try:
|
||||
# Allow plugins to fill this data out
|
||||
plugin_panels = _plugin.get_ui_panels(
|
||||
target_model, target_id, request
|
||||
)
|
||||
|
||||
if plugin_panels and type(plugin_panels) is list:
|
||||
for panel in plugin_panels:
|
||||
panel['plugin'] = _plugin.slug
|
||||
|
||||
# TODO: Validate each panel before inserting
|
||||
panels.append(panel)
|
||||
except Exception:
|
||||
# Custom panels could not load
|
||||
# Log the error and continue
|
||||
log_error(f'{_plugin.slug}.get_ui_panels')
|
||||
|
||||
return Response(PluginSerializers.PluginPanelSerializer(panels, many=True).data)
|
||||
|
||||
|
||||
class PluginMetadataView(MetadataView):
|
||||
"""Metadata API endpoint for the PluginConfig model."""
|
||||
|
||||
@@ -428,6 +468,21 @@ plugin_api_urls = [
|
||||
path(
|
||||
'plugins/',
|
||||
include([
|
||||
path(
|
||||
'ui/',
|
||||
include([
|
||||
path(
|
||||
'panels/',
|
||||
include([
|
||||
path(
|
||||
'',
|
||||
PluginPanelList.as_view(),
|
||||
name='api-plugin-panel-list',
|
||||
)
|
||||
]),
|
||||
)
|
||||
]),
|
||||
),
|
||||
# Plugin management
|
||||
path('reload/', PluginReload.as_view(), name='api-plugin-reload'),
|
||||
path('install/', PluginInstall.as_view(), name='api-plugin-install'),
|
||||
|
||||
@@ -3,19 +3,27 @@
|
||||
import logging
|
||||
|
||||
from django.db.models import F
|
||||
from django.urls import path
|
||||
from django.urls import include, path
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from django_filters import rest_framework as rest_filters
|
||||
from drf_spectacular.utils import extend_schema, extend_schema_view
|
||||
from rest_framework import permissions, status
|
||||
from rest_framework.exceptions import PermissionDenied, ValidationError
|
||||
from rest_framework.generics import CreateAPIView
|
||||
from rest_framework.response import Response
|
||||
|
||||
import common.models
|
||||
import order.models
|
||||
import plugin.base.barcodes.helper
|
||||
import stock.models
|
||||
from common.settings import get_global_setting
|
||||
from InvenTree.api import BulkDeleteMixin
|
||||
from InvenTree.exceptions import log_error
|
||||
from InvenTree.filters import SEARCH_ORDER_FILTER
|
||||
from InvenTree.helpers import hash_barcode
|
||||
from InvenTree.mixins import ListAPI, RetrieveDestroyAPI
|
||||
from InvenTree.permissions import IsStaffOrReadOnly
|
||||
from plugin import registry
|
||||
from users.models import RuleSet
|
||||
|
||||
@@ -30,6 +38,70 @@ class BarcodeView(CreateAPIView):
|
||||
# Default serializer class (can be overridden)
|
||||
serializer_class = barcode_serializers.BarcodeSerializer
|
||||
|
||||
def log_scan(self, request, response=None, result=False):
|
||||
"""Log a barcode scan to the database.
|
||||
|
||||
Arguments:
|
||||
request: HTTP request object
|
||||
response: Optional response data
|
||||
"""
|
||||
from common.models import BarcodeScanResult
|
||||
|
||||
# Extract context data from the request
|
||||
context = {**request.GET.dict(), **request.POST.dict(), **request.data}
|
||||
|
||||
barcode = context.pop('barcode', '')
|
||||
|
||||
# Exit if storing barcode scans is disabled
|
||||
if not get_global_setting('BARCODE_STORE_RESULTS', backup=False, create=False):
|
||||
return
|
||||
|
||||
# Ensure that the response data is stringified first, otherwise cannot be JSON encoded
|
||||
if type(response) is dict:
|
||||
response = {key: str(value) for key, value in response.items()}
|
||||
elif response is None:
|
||||
pass
|
||||
else:
|
||||
response = str(response)
|
||||
|
||||
# Ensure that the context data is stringified first, otherwise cannot be JSON encoded
|
||||
if type(context) is dict:
|
||||
context = {key: str(value) for key, value in context.items()}
|
||||
elif context is None:
|
||||
pass
|
||||
else:
|
||||
context = str(context)
|
||||
|
||||
# Ensure data is not too long
|
||||
if len(barcode) > BarcodeScanResult.BARCODE_SCAN_MAX_LEN:
|
||||
barcode = barcode[: BarcodeScanResult.BARCODE_SCAN_MAX_LEN]
|
||||
|
||||
try:
|
||||
BarcodeScanResult.objects.create(
|
||||
data=barcode,
|
||||
user=request.user,
|
||||
endpoint=request.path,
|
||||
response=response,
|
||||
result=result,
|
||||
context=context,
|
||||
)
|
||||
|
||||
# Ensure that we do not store too many scans
|
||||
max_scans = int(get_global_setting('BARCODE_RESULTS_MAX_NUM', create=False))
|
||||
num_scans = BarcodeScanResult.objects.count()
|
||||
|
||||
if num_scans > max_scans:
|
||||
n = num_scans - max_scans
|
||||
old_scan_ids = (
|
||||
BarcodeScanResult.objects.all()
|
||||
.order_by('timestamp')
|
||||
.values_list('pk', flat=True)[:n]
|
||||
)
|
||||
BarcodeScanResult.objects.filter(pk__in=old_scan_ids).delete()
|
||||
except Exception:
|
||||
# Gracefully log error to database
|
||||
log_error(f'{self.__class__.__name__}.log_scan')
|
||||
|
||||
def queryset(self):
|
||||
"""This API view does not have a queryset."""
|
||||
return None
|
||||
@@ -40,7 +112,13 @@ class BarcodeView(CreateAPIView):
|
||||
def create(self, request, *args, **kwargs):
|
||||
"""Handle create method - override default create."""
|
||||
serializer = self.get_serializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
try:
|
||||
serializer.is_valid(raise_exception=True)
|
||||
except Exception as exc:
|
||||
self.log_scan(request, response={'error': str(exc)}, result=False)
|
||||
raise exc
|
||||
|
||||
data = serializer.validated_data
|
||||
|
||||
barcode = str(data.pop('barcode')).strip()
|
||||
@@ -119,15 +197,19 @@ class BarcodeScan(BarcodeView):
|
||||
kwargs:
|
||||
Any custom fields passed by the specific serializer
|
||||
"""
|
||||
result = self.scan_barcode(barcode, request, **kwargs)
|
||||
response = self.scan_barcode(barcode, request, **kwargs)
|
||||
|
||||
if result['plugin'] is None:
|
||||
result['error'] = _('No match found for barcode data')
|
||||
if response['plugin'] is None:
|
||||
response['error'] = _('No match found for barcode data')
|
||||
self.log_scan(request, response, False)
|
||||
raise ValidationError(response)
|
||||
|
||||
raise ValidationError(result)
|
||||
response['success'] = _('Match found for barcode data')
|
||||
|
||||
result['success'] = _('Match found for barcode data')
|
||||
return Response(result)
|
||||
# Log the scan result
|
||||
self.log_scan(request, response, True)
|
||||
|
||||
return Response(response)
|
||||
|
||||
|
||||
@extend_schema_view(
|
||||
@@ -333,7 +415,7 @@ class BarcodePOAllocate(BarcodeView):
|
||||
supplier_parts = company.models.SupplierPart.objects.filter(supplier=supplier)
|
||||
|
||||
if not part and not supplier_part and not manufacturer_part:
|
||||
raise ValidationError({'error': _('No matching part data found')})
|
||||
raise ValidationError(_('No matching part data found'))
|
||||
|
||||
if part and (part_id := part.get('pk', None)):
|
||||
supplier_parts = supplier_parts.filter(part__pk=part_id)
|
||||
@@ -349,12 +431,10 @@ class BarcodePOAllocate(BarcodeView):
|
||||
)
|
||||
|
||||
if supplier_parts.count() == 0:
|
||||
raise ValidationError({'error': _('No matching supplier parts found')})
|
||||
raise ValidationError(_('No matching supplier parts found'))
|
||||
|
||||
if supplier_parts.count() > 1:
|
||||
raise ValidationError({
|
||||
'error': _('Multiple matching supplier parts found')
|
||||
})
|
||||
raise ValidationError(_('Multiple matching supplier parts found'))
|
||||
|
||||
# At this stage, we have a single matching supplier part
|
||||
return supplier_parts.first()
|
||||
@@ -364,25 +444,32 @@ class BarcodePOAllocate(BarcodeView):
|
||||
# The purchase order is provided as part of the request
|
||||
purchase_order = kwargs.get('purchase_order')
|
||||
|
||||
result = self.scan_barcode(barcode, request, **kwargs)
|
||||
response = self.scan_barcode(barcode, request, **kwargs)
|
||||
|
||||
if result['plugin'] is None:
|
||||
result['error'] = _('No match found for barcode data')
|
||||
raise ValidationError(result)
|
||||
if response['plugin'] is None:
|
||||
response['error'] = _('No matching plugin found for barcode data')
|
||||
|
||||
supplier_part = self.get_supplier_part(
|
||||
purchase_order,
|
||||
part=result.get('part', None),
|
||||
supplier_part=result.get('supplierpart', None),
|
||||
manufacturer_part=result.get('manufacturerpart', None),
|
||||
)
|
||||
|
||||
result['success'] = _('Matched supplier part')
|
||||
result['supplierpart'] = supplier_part.format_matched_response()
|
||||
else:
|
||||
try:
|
||||
supplier_part = self.get_supplier_part(
|
||||
purchase_order,
|
||||
part=response.get('part', None),
|
||||
supplier_part=response.get('supplierpart', None),
|
||||
manufacturer_part=response.get('manufacturerpart', None),
|
||||
)
|
||||
response['success'] = _('Matched supplier part')
|
||||
response['supplierpart'] = supplier_part.format_matched_response()
|
||||
except ValidationError as e:
|
||||
response['error'] = str(e)
|
||||
|
||||
# TODO: Determine the 'quantity to order' for the supplier part
|
||||
|
||||
return Response(result)
|
||||
self.log_scan(request, response, 'success' in response)
|
||||
|
||||
if 'error' in response:
|
||||
raise ValidationError
|
||||
|
||||
return Response(response)
|
||||
|
||||
|
||||
class BarcodePOReceive(BarcodeView):
|
||||
@@ -427,6 +514,7 @@ class BarcodePOReceive(BarcodeView):
|
||||
if result := internal_barcode_plugin.scan(barcode):
|
||||
if 'stockitem' in result:
|
||||
response['error'] = _('Item has already been received')
|
||||
self.log_scan(request, response, False)
|
||||
raise ValidationError(response)
|
||||
|
||||
# Now, look just for "supplier-barcode" plugins
|
||||
@@ -464,11 +552,13 @@ class BarcodePOReceive(BarcodeView):
|
||||
# A plugin has not been found!
|
||||
if plugin is None:
|
||||
response['error'] = _('No match for supplier barcode')
|
||||
|
||||
self.log_scan(request, response, 'success' in response)
|
||||
|
||||
if 'error' in response:
|
||||
raise ValidationError(response)
|
||||
elif 'error' in response:
|
||||
raise ValidationError(response)
|
||||
else:
|
||||
return Response(response)
|
||||
|
||||
return Response(response)
|
||||
|
||||
|
||||
class BarcodeSOAllocate(BarcodeView):
|
||||
@@ -489,7 +579,11 @@ class BarcodeSOAllocate(BarcodeView):
|
||||
serializer_class = barcode_serializers.BarcodeSOAllocateSerializer
|
||||
|
||||
def get_line_item(self, stock_item, **kwargs):
|
||||
"""Return the matching line item for the provided stock item."""
|
||||
"""Return the matching line item for the provided stock item.
|
||||
|
||||
Raises:
|
||||
ValidationError: If no single matching line item is found
|
||||
"""
|
||||
# Extract sales order object (required field)
|
||||
sales_order = kwargs['sales_order']
|
||||
|
||||
@@ -506,22 +600,24 @@ class BarcodeSOAllocate(BarcodeView):
|
||||
)
|
||||
|
||||
if lines.count() > 1:
|
||||
raise ValidationError({'error': _('Multiple matching line items found')})
|
||||
raise ValidationError(_('Multiple matching line items found'))
|
||||
|
||||
if lines.count() == 0:
|
||||
raise ValidationError({'error': _('No matching line item found')})
|
||||
raise ValidationError(_('No matching line item found'))
|
||||
|
||||
return lines.first()
|
||||
|
||||
def get_shipment(self, **kwargs):
|
||||
"""Extract the shipment from the provided kwargs, or guess."""
|
||||
"""Extract the shipment from the provided kwargs, or guess.
|
||||
|
||||
Raises:
|
||||
ValidationError: If the shipment does not match the sales order
|
||||
"""
|
||||
sales_order = kwargs['sales_order']
|
||||
|
||||
if shipment := kwargs.get('shipment', None):
|
||||
if shipment.order != sales_order:
|
||||
raise ValidationError({
|
||||
'error': _('Shipment does not match sales order')
|
||||
})
|
||||
raise ValidationError(_('Shipment does not match sales order'))
|
||||
|
||||
return shipment
|
||||
|
||||
@@ -536,37 +632,55 @@ class BarcodeSOAllocate(BarcodeView):
|
||||
return None
|
||||
|
||||
def handle_barcode(self, barcode: str, request, **kwargs):
|
||||
"""Handle barcode scan for sales order allocation."""
|
||||
"""Handle barcode scan for sales order allocation.
|
||||
|
||||
Arguments:
|
||||
barcode: Raw barcode data
|
||||
request: HTTP request object
|
||||
|
||||
kwargs:
|
||||
sales_order: SalesOrder ID value (required)
|
||||
line: SalesOrderLineItem ID value (optional)
|
||||
shipment: SalesOrderShipment ID value (optional)
|
||||
"""
|
||||
logger.debug("BarcodeSOAllocate: scanned barcode - '%s'", barcode)
|
||||
|
||||
result = self.scan_barcode(barcode, request, **kwargs)
|
||||
response = self.scan_barcode(barcode, request, **kwargs)
|
||||
|
||||
if result['plugin'] is None:
|
||||
result['error'] = _('No match found for barcode data')
|
||||
raise ValidationError(result)
|
||||
if 'sales_order' not in kwargs:
|
||||
# SalesOrder ID *must* be provided
|
||||
response['error'] = _('No sales order provided')
|
||||
elif response['plugin'] is None:
|
||||
# Check that the barcode at least matches a plugin
|
||||
response['error'] = _('No matching plugin found for barcode data')
|
||||
else:
|
||||
try:
|
||||
stock_item_id = response['stockitem'].get('pk', None)
|
||||
stock_item = stock.models.StockItem.objects.get(pk=stock_item_id)
|
||||
except Exception:
|
||||
response['error'] = _('Barcode does not match an existing stock item')
|
||||
|
||||
# Check that the scanned barcode was a StockItem
|
||||
if 'stockitem' not in result:
|
||||
result['error'] = _('Barcode does not match an existing stock item')
|
||||
raise ValidationError(result)
|
||||
|
||||
try:
|
||||
stock_item_id = result['stockitem'].get('pk', None)
|
||||
stock_item = stock.models.StockItem.objects.get(pk=stock_item_id)
|
||||
except (ValueError, stock.models.StockItem.DoesNotExist):
|
||||
result['error'] = _('Barcode does not match an existing stock item')
|
||||
raise ValidationError(result)
|
||||
if 'error' in response:
|
||||
self.log_scan(request, response, False)
|
||||
raise ValidationError(response)
|
||||
|
||||
# At this stage, we have a valid StockItem object
|
||||
# Extract any other data from the kwargs
|
||||
line_item = self.get_line_item(stock_item, **kwargs)
|
||||
sales_order = kwargs['sales_order']
|
||||
shipment = self.get_shipment(**kwargs)
|
||||
|
||||
if stock_item is not None and line_item is not None:
|
||||
if stock_item.part != line_item.part:
|
||||
result['error'] = _('Stock item does not match line item')
|
||||
raise ValidationError(result)
|
||||
try:
|
||||
# Extract any other data from the kwargs
|
||||
# Note: This may raise a ValidationError at some point - we break on the first error
|
||||
sales_order = kwargs['sales_order']
|
||||
line_item = self.get_line_item(stock_item, **kwargs)
|
||||
shipment = self.get_shipment(**kwargs)
|
||||
if stock_item is not None and line_item is not None:
|
||||
if stock_item.part != line_item.part:
|
||||
response['error'] = _('Stock item does not match line item')
|
||||
except ValidationError as e:
|
||||
response['error'] = str(e)
|
||||
|
||||
if 'error' in response:
|
||||
self.log_scan(request, response, False)
|
||||
raise ValidationError(response)
|
||||
|
||||
quantity = kwargs.get('quantity', None)
|
||||
|
||||
@@ -574,11 +688,12 @@ class BarcodeSOAllocate(BarcodeView):
|
||||
if stock_item.serialized:
|
||||
quantity = 1
|
||||
|
||||
if quantity is None:
|
||||
elif quantity is None:
|
||||
quantity = line_item.quantity - line_item.shipped
|
||||
quantity = min(quantity, stock_item.unallocated_quantity())
|
||||
|
||||
response = {
|
||||
**response,
|
||||
'stock_item': stock_item.pk if stock_item else None,
|
||||
'part': stock_item.part.pk if stock_item else None,
|
||||
'sales_order': sales_order.pk if sales_order else None,
|
||||
@@ -590,25 +705,91 @@ class BarcodeSOAllocate(BarcodeView):
|
||||
if stock_item is not None and quantity is not None:
|
||||
if stock_item.unallocated_quantity() < quantity:
|
||||
response['error'] = _('Insufficient stock available')
|
||||
raise ValidationError(response)
|
||||
|
||||
# If we have sufficient information, we can allocate the stock item
|
||||
if all(x is not None for x in [line_item, sales_order, shipment, quantity]):
|
||||
order.models.SalesOrderAllocation.objects.create(
|
||||
line=line_item, shipment=shipment, item=stock_item, quantity=quantity
|
||||
)
|
||||
# If we have sufficient information, we can allocate the stock item
|
||||
elif all(
|
||||
x is not None for x in [line_item, sales_order, shipment, quantity]
|
||||
):
|
||||
order.models.SalesOrderAllocation.objects.create(
|
||||
line=line_item,
|
||||
shipment=shipment,
|
||||
item=stock_item,
|
||||
quantity=quantity,
|
||||
)
|
||||
|
||||
response['success'] = _('Stock item allocated to sales order')
|
||||
response['success'] = _('Stock item allocated to sales order')
|
||||
|
||||
else:
|
||||
response['error'] = _('Not enough information')
|
||||
response['action_required'] = True
|
||||
|
||||
self.log_scan(request, response, 'success' in response)
|
||||
|
||||
if 'error' in response:
|
||||
raise ValidationError(response)
|
||||
else:
|
||||
return Response(response)
|
||||
|
||||
response['error'] = _('Not enough information')
|
||||
response['action_required'] = True
|
||||
|
||||
raise ValidationError(response)
|
||||
class BarcodeScanResultMixin:
|
||||
"""Mixin class for BarcodeScan API endpoints."""
|
||||
|
||||
queryset = common.models.BarcodeScanResult.objects.all()
|
||||
serializer_class = barcode_serializers.BarcodeScanResultSerializer
|
||||
permission_classes = [permissions.IsAuthenticated, IsStaffOrReadOnly]
|
||||
|
||||
def get_queryset(self):
|
||||
"""Return the queryset for the BarcodeScan API."""
|
||||
queryset = super().get_queryset()
|
||||
|
||||
# Pre-fetch user data
|
||||
queryset = queryset.prefetch_related('user')
|
||||
|
||||
return queryset
|
||||
|
||||
|
||||
class BarcodeScanResultFilter(rest_filters.FilterSet):
|
||||
"""Custom filterset for the BarcodeScanResult API."""
|
||||
|
||||
class Meta:
|
||||
"""Meta class for the BarcodeScanResultFilter."""
|
||||
|
||||
model = common.models.BarcodeScanResult
|
||||
fields = ['user', 'result']
|
||||
|
||||
|
||||
class BarcodeScanResultList(BarcodeScanResultMixin, BulkDeleteMixin, ListAPI):
|
||||
"""List API endpoint for BarcodeScan objects."""
|
||||
|
||||
filterset_class = BarcodeScanResultFilter
|
||||
filter_backends = SEARCH_ORDER_FILTER
|
||||
|
||||
ordering_fields = ['user', 'plugin', 'timestamp', 'endpoint', 'result']
|
||||
|
||||
ordering = '-timestamp'
|
||||
|
||||
search_fields = ['plugin']
|
||||
|
||||
|
||||
class BarcodeScanResultDetail(BarcodeScanResultMixin, RetrieveDestroyAPI):
|
||||
"""Detail endpoint for a BarcodeScan object."""
|
||||
|
||||
|
||||
barcode_api_urls = [
|
||||
# Barcode scan history
|
||||
path(
|
||||
'history/',
|
||||
include([
|
||||
path(
|
||||
'<int:pk>/',
|
||||
BarcodeScanResultDetail.as_view(),
|
||||
name='api-barcode-scan-result-detail',
|
||||
),
|
||||
path(
|
||||
'', BarcodeScanResultList.as_view(), name='api-barcode-scan-result-list'
|
||||
),
|
||||
]),
|
||||
),
|
||||
# Generate a barcode for a database object
|
||||
path('generate/', BarcodeGenerate.as_view(), name='api-barcode-generate'),
|
||||
# Link a third-party barcode to an item (e.g. Part / StockItem / etc)
|
||||
|
||||
@@ -5,12 +5,39 @@ from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from rest_framework import serializers
|
||||
|
||||
import common.models
|
||||
import order.models
|
||||
import plugin.base.barcodes.helper
|
||||
import stock.models
|
||||
from InvenTree.serializers import UserSerializer
|
||||
from order.status_codes import PurchaseOrderStatus, SalesOrderStatus
|
||||
|
||||
|
||||
class BarcodeScanResultSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for barcode scan results."""
|
||||
|
||||
class Meta:
|
||||
"""Meta class for BarcodeScanResultSerializer."""
|
||||
|
||||
model = common.models.BarcodeScanResult
|
||||
|
||||
fields = [
|
||||
'pk',
|
||||
'data',
|
||||
'timestamp',
|
||||
'endpoint',
|
||||
'context',
|
||||
'response',
|
||||
'result',
|
||||
'user',
|
||||
'user_detail',
|
||||
]
|
||||
|
||||
read_only_fields = fields
|
||||
|
||||
user_detail = UserSerializer(source='user', read_only=True)
|
||||
|
||||
|
||||
class BarcodeSerializer(serializers.Serializer):
|
||||
"""Generic serializer for receiving barcode data."""
|
||||
|
||||
|
||||
@@ -4,6 +4,8 @@ from django.urls import reverse
|
||||
|
||||
import company.models
|
||||
import order.models
|
||||
from common.models import BarcodeScanResult
|
||||
from common.settings import set_global_setting
|
||||
from InvenTree.unit_test import InvenTreeAPITestCase
|
||||
from part.models import Part
|
||||
from stock.models import StockItem
|
||||
@@ -89,6 +91,11 @@ class BarcodeAPITest(InvenTreeAPITestCase):
|
||||
"""Test that we can lookup a stock item based on ID."""
|
||||
item = StockItem.objects.first()
|
||||
|
||||
# Save barcode scan results to database
|
||||
set_global_setting('BARCODE_STORE_RESULTS', True)
|
||||
|
||||
n = BarcodeScanResult.objects.count()
|
||||
|
||||
response = self.post(
|
||||
self.scan_url, {'barcode': item.format_barcode()}, expected_code=200
|
||||
)
|
||||
@@ -97,6 +104,20 @@ class BarcodeAPITest(InvenTreeAPITestCase):
|
||||
self.assertIn('barcode_data', response.data)
|
||||
self.assertEqual(response.data['stockitem']['pk'], item.pk)
|
||||
|
||||
self.assertEqual(BarcodeScanResult.objects.count(), n + 1)
|
||||
|
||||
result = BarcodeScanResult.objects.last()
|
||||
|
||||
self.assertTrue(result.result)
|
||||
self.assertEqual(result.data, item.format_barcode())
|
||||
|
||||
response = result.response
|
||||
|
||||
self.assertEqual(response['plugin'], 'InvenTreeBarcode')
|
||||
|
||||
for k in ['barcode_data', 'stockitem', 'success']:
|
||||
self.assertIn(k, response)
|
||||
|
||||
def test_invalid_item(self):
|
||||
"""Test response for invalid stock item."""
|
||||
response = self.post(
|
||||
@@ -309,7 +330,7 @@ class SOAllocateTest(InvenTreeAPITestCase):
|
||||
'123456789', sales_order=self.sales_order.pk, expected_code=400
|
||||
)
|
||||
|
||||
self.assertIn('No match found for barcode', str(result['error']))
|
||||
self.assertIn('No matching plugin found for barcode data', str(result['error']))
|
||||
|
||||
# Test with a barcode that matches a *different* stock item
|
||||
item = StockItem.objects.exclude(pk=self.stock_item.pk).first()
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
"""UserInterfaceMixin class definition.
|
||||
|
||||
Allows integration of custom UI elements into the React user interface.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import TypedDict
|
||||
|
||||
from rest_framework.request import Request
|
||||
|
||||
logger = logging.getLogger('inventree')
|
||||
|
||||
|
||||
class CustomPanel(TypedDict):
|
||||
"""Type definition for a custom panel.
|
||||
|
||||
Attributes:
|
||||
name: The name of the panel (required, used as a DOM identifier).
|
||||
label: The label of the panel (required, human readable).
|
||||
icon: The icon of the panel (optional, must be a valid icon identifier).
|
||||
content: The content of the panel (optional, raw HTML).
|
||||
source: The source of the panel (optional, path to a JavaScript file).
|
||||
"""
|
||||
|
||||
name: str
|
||||
label: str
|
||||
icon: str
|
||||
content: str
|
||||
source: str
|
||||
|
||||
|
||||
class UserInterfaceMixin:
|
||||
"""Plugin mixin class which handles injection of custom elements into the front-end interface.
|
||||
|
||||
- All content is accessed via the API, as requested by the user interface.
|
||||
- This means that content can be dynamically generated, based on the current state of the system.
|
||||
|
||||
The following custom UI methods are available:
|
||||
- get_ui_panels: Return a list of custom panels to be injected into the UI
|
||||
|
||||
"""
|
||||
|
||||
class MixinMeta:
|
||||
"""Metaclass for this plugin mixin."""
|
||||
|
||||
MIXIN_NAME = 'ui'
|
||||
|
||||
def __init__(self):
|
||||
"""Register mixin."""
|
||||
super().__init__()
|
||||
self.add_mixin('ui', True, __class__)
|
||||
|
||||
def get_ui_panels(
|
||||
self, instance_type: str, instance_id: int, request: Request, **kwargs
|
||||
) -> list[CustomPanel]:
|
||||
"""Return a list of custom panels to be injected into the UI.
|
||||
|
||||
Args:
|
||||
instance_type: The type of object being viewed (e.g. 'part')
|
||||
instance_id: The ID of the object being viewed (e.g. 123)
|
||||
request: HTTPRequest object (including user information)
|
||||
|
||||
Returns:
|
||||
list: A list of custom panels to be injected into the UI
|
||||
|
||||
- The returned list should contain a dict for each custom panel to be injected into the UI:
|
||||
- The following keys can be specified:
|
||||
{
|
||||
'name': 'panel_name', # The name of the panel (required, must be unique)
|
||||
'label': 'Panel Title', # The title of the panel (required, human readable)
|
||||
'icon': 'icon-name', # Icon name (optional, must be a valid icon identifier)
|
||||
'content': '<p>Panel content</p>', # HTML content to be rendered in the panel (optional)
|
||||
'source': 'static/plugin/panel.js', # Path to a JavaScript file to be loaded (optional)
|
||||
}
|
||||
|
||||
- Either 'source' or 'content' must be provided
|
||||
|
||||
"""
|
||||
# Default implementation returns an empty list
|
||||
return []
|
||||
@@ -8,7 +8,8 @@ from django.urls import include, path, re_path, reverse
|
||||
|
||||
from error_report.models import Error
|
||||
|
||||
from InvenTree.unit_test import InvenTreeTestCase
|
||||
from common.models import InvenTreeSetting
|
||||
from InvenTree.unit_test import InvenTreeAPITestCase, InvenTreeTestCase
|
||||
from plugin import InvenTreePlugin
|
||||
from plugin.base.integration.PanelMixin import PanelMixin
|
||||
from plugin.helpers import MixinNotImplementedError
|
||||
@@ -341,7 +342,10 @@ class APICallMixinTest(BaseMixinDefinition, TestCase):
|
||||
|
||||
|
||||
class PanelMixinTests(InvenTreeTestCase):
|
||||
"""Test that the PanelMixin plugin operates correctly."""
|
||||
"""Test that the PanelMixin plugin operates correctly.
|
||||
|
||||
TODO: This class will be removed in the future, as the PanelMixin is deprecated.
|
||||
"""
|
||||
|
||||
fixtures = ['category', 'part', 'location', 'stock']
|
||||
|
||||
@@ -475,3 +479,100 @@ class PanelMixinTests(InvenTreeTestCase):
|
||||
|
||||
plugin = Wrong()
|
||||
plugin.get_custom_panels('abc', 'abc')
|
||||
|
||||
|
||||
class UserInterfaceMixinTests(InvenTreeAPITestCase):
|
||||
"""Test the UserInterfaceMixin plugin mixin class."""
|
||||
|
||||
roles = 'all'
|
||||
|
||||
fixtures = ['part', 'category', 'location', 'stock']
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
"""Set up the test case."""
|
||||
super().setUpTestData()
|
||||
|
||||
# Ensure that the 'sampleui' plugin is installed and active
|
||||
registry.set_plugin_state('sampleui', True)
|
||||
|
||||
# Ensure that UI plugins are enabled
|
||||
InvenTreeSetting.set_setting('ENABLE_PLUGINS_INTERFACE', True, change_user=None)
|
||||
|
||||
def test_installed(self):
|
||||
"""Test that the sample UI plugin is installed and active."""
|
||||
plugin = registry.get_plugin('sampleui')
|
||||
self.assertTrue(plugin.is_active())
|
||||
|
||||
plugins = registry.with_mixin('ui')
|
||||
self.assertGreater(len(plugins), 0)
|
||||
|
||||
def test_panels(self):
|
||||
"""Test that the sample UI plugin provides custom panels."""
|
||||
from part.models import Part
|
||||
|
||||
plugin = registry.get_plugin('sampleui')
|
||||
|
||||
_part = Part.objects.first()
|
||||
|
||||
# Ensure that the part is active
|
||||
_part.active = True
|
||||
_part.save()
|
||||
|
||||
url = reverse('api-plugin-panel-list')
|
||||
|
||||
query_data = {'target_model': 'part', 'target_id': _part.pk}
|
||||
|
||||
# Enable *all* plugin settings
|
||||
plugin.set_setting('ENABLE_PART_PANELS', True)
|
||||
plugin.set_setting('ENABLE_PURCHASE_ORDER_PANELS', True)
|
||||
plugin.set_setting('ENABLE_BROKEN_PANELS', True)
|
||||
plugin.set_setting('ENABLE_DYNAMIC_PANEL', True)
|
||||
|
||||
# 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
|
||||
self.assertEqual(4, len(response.data))
|
||||
|
||||
_part.active = False
|
||||
_part.save()
|
||||
|
||||
response = self.get(url, data=query_data)
|
||||
|
||||
# As the part is not active, only 3 panels left
|
||||
self.assertEqual(3, len(response.data))
|
||||
|
||||
# Disable the "ENABLE_PART_PANELS" setting, and try again
|
||||
plugin.set_setting('ENABLE_PART_PANELS', False)
|
||||
|
||||
response = self.get(url, data=query_data)
|
||||
|
||||
# There should still be 3 panels
|
||||
self.assertEqual(3, len(response.data))
|
||||
|
||||
# Check for the correct panel names
|
||||
self.assertEqual(response.data[0]['name'], 'sample_panel')
|
||||
self.assertIn('content', response.data[0])
|
||||
self.assertNotIn('source', response.data[0])
|
||||
|
||||
self.assertEqual(response.data[1]['name'], 'broken_panel')
|
||||
self.assertEqual(response.data[1]['source'], '/this/does/not/exist.js')
|
||||
self.assertNotIn('content', response.data[1])
|
||||
|
||||
self.assertEqual(response.data[2]['name'], 'dynamic_panel')
|
||||
self.assertEqual(response.data[2]['source'], '/static/plugin/sample_panel.js')
|
||||
self.assertNotIn('content', response.data[2])
|
||||
|
||||
# Next, disable the global setting for UI integration
|
||||
InvenTreeSetting.set_setting(
|
||||
'ENABLE_PLUGINS_INTERFACE', False, change_user=None
|
||||
)
|
||||
|
||||
response = self.get(url, data=query_data)
|
||||
|
||||
# There should be no panels available
|
||||
self.assertEqual(0, len(response.data))
|
||||
|
||||
# Set the setting back to True for subsequent tests
|
||||
InvenTreeSetting.set_setting('ENABLE_PLUGINS_INTERFACE', True, change_user=None)
|
||||
|
||||
@@ -10,6 +10,7 @@ from django.core.exceptions import ValidationError
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
import plugin.models
|
||||
import plugin.staticfiles
|
||||
from InvenTree.exceptions import log_error
|
||||
|
||||
logger = logging.getLogger('inventree')
|
||||
@@ -119,6 +120,10 @@ def install_plugins_file():
|
||||
log_error('pip')
|
||||
return False
|
||||
|
||||
# Update static files
|
||||
plugin.staticfiles.collect_plugins_static_files()
|
||||
plugin.staticfiles.clear_plugins_static_files()
|
||||
|
||||
# At this point, the plugins file has been installed
|
||||
return True
|
||||
|
||||
@@ -256,6 +261,9 @@ def install_plugin(url=None, packagename=None, user=None, version=None):
|
||||
|
||||
registry.reload_plugins(full_reload=True, force_reload=True, collect=True)
|
||||
|
||||
# Update static files
|
||||
plugin.staticfiles.collect_plugins_static_files()
|
||||
|
||||
return ret
|
||||
|
||||
|
||||
@@ -320,6 +328,9 @@ def uninstall_plugin(cfg: plugin.models.PluginConfig, user=None, delete_config=T
|
||||
# Remove the plugin configuration from the database
|
||||
cfg.delete()
|
||||
|
||||
# Remove static files associated with this plugin
|
||||
plugin.staticfiles.clear_plugin_static_files(cfg.key)
|
||||
|
||||
# Reload the plugin registry
|
||||
registry.reload_plugins(full_reload=True, force_reload=True, collect=True)
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ from plugin.base.integration.ReportMixin import ReportMixin
|
||||
from plugin.base.integration.ScheduleMixin import ScheduleMixin
|
||||
from plugin.base.integration.SettingsMixin import SettingsMixin
|
||||
from plugin.base.integration.UrlsMixin import UrlsMixin
|
||||
from plugin.base.integration.UserInterfaceMixin import UserInterfaceMixin
|
||||
from plugin.base.integration.ValidationMixin import ValidationMixin
|
||||
from plugin.base.label.mixins import LabelPrintingMixin
|
||||
from plugin.base.locate.mixins import LocateMixin
|
||||
@@ -38,5 +39,7 @@ __all__ = [
|
||||
'SingleNotificationMethod',
|
||||
'SupplierBarcodeMixin',
|
||||
'UrlsMixin',
|
||||
'UrlsMixin',
|
||||
'UserInterfaceMixin',
|
||||
'ValidationMixin',
|
||||
]
|
||||
|
||||
@@ -742,11 +742,12 @@ class PluginsRegistry:
|
||||
def plugin_settings_keys(self):
|
||||
"""A list of keys which are used to store plugin settings."""
|
||||
return [
|
||||
'ENABLE_PLUGINS_URL',
|
||||
'ENABLE_PLUGINS_NAVIGATION',
|
||||
'ENABLE_PLUGINS_APP',
|
||||
'ENABLE_PLUGINS_SCHEDULE',
|
||||
'ENABLE_PLUGINS_EVENTS',
|
||||
'ENABLE_PLUGINS_INTERFACE',
|
||||
'ENABLE_PLUGINS_NAVIGATION',
|
||||
'ENABLE_PLUGINS_SCHEDULE',
|
||||
'ENABLE_PLUGINS_URL',
|
||||
]
|
||||
|
||||
def calculate_plugin_hash(self):
|
||||
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
{% load i18n %}
|
||||
|
||||
<h4>Custom Plugin Panel</h4>
|
||||
|
||||
<p>
|
||||
This content has been rendered by a custom plugin, and will be displayed for any "part" instance
|
||||
(as long as the plugin is enabled).
|
||||
This content has been rendered on the server, using the django templating system.
|
||||
</p>
|
||||
|
||||
<h5>Part Details</h5>
|
||||
|
||||
<table class='table table-striped table-condensed'>
|
||||
<tr>
|
||||
<th>Part Name</th>
|
||||
<td>{{ part.name }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Part Description</th>
|
||||
<td>{{ part.description }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Part Category</th>
|
||||
<td>{{ part.category.pathstring }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Part IPN</th>
|
||||
<td>{% if part.IPN %}{{ part.IPN }}{% else %}<i>No IPN specified</i>{% endif %}</td>
|
||||
</tr>
|
||||
</table>
|
||||
@@ -0,0 +1,124 @@
|
||||
"""Sample plugin which demonstrates user interface integrations."""
|
||||
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from part.models import Part
|
||||
from plugin import InvenTreePlugin
|
||||
from plugin.helpers import render_template, render_text
|
||||
from plugin.mixins import SettingsMixin, UserInterfaceMixin
|
||||
|
||||
|
||||
class SampleUserInterfacePlugin(SettingsMixin, UserInterfaceMixin, InvenTreePlugin):
|
||||
"""A sample plugin which demonstrates user interface integrations."""
|
||||
|
||||
NAME = 'SampleUI'
|
||||
SLUG = 'sampleui'
|
||||
TITLE = 'Sample User Interface Plugin'
|
||||
DESCRIPTION = 'A sample plugin which demonstrates user interface integrations'
|
||||
VERSION = '1.0'
|
||||
|
||||
SETTINGS = {
|
||||
'ENABLE_PART_PANELS': {
|
||||
'name': _('Enable Part Panels'),
|
||||
'description': _('Enable custom panels for Part views'),
|
||||
'default': True,
|
||||
'validator': bool,
|
||||
},
|
||||
'ENABLE_PURCHASE_ORDER_PANELS': {
|
||||
'name': _('Enable Purchase Order Panels'),
|
||||
'description': _('Enable custom panels for Purchase Order views'),
|
||||
'default': False,
|
||||
'validator': bool,
|
||||
},
|
||||
'ENABLE_BROKEN_PANELS': {
|
||||
'name': _('Enable Broken Panels'),
|
||||
'description': _('Enable broken panels for testing'),
|
||||
'default': True,
|
||||
'validator': bool,
|
||||
},
|
||||
'ENABLE_DYNAMIC_PANEL': {
|
||||
'name': _('Enable Dynamic Panel'),
|
||||
'description': _('Enable dynamic panels for testing'),
|
||||
'default': True,
|
||||
'validator': bool,
|
||||
},
|
||||
}
|
||||
|
||||
def get_ui_panels(self, instance_type: str, instance_id: int, request, **kwargs):
|
||||
"""Return a list of custom panels to be injected into the UI."""
|
||||
panels = []
|
||||
|
||||
# First, add a custom panel which will appear on every type of page
|
||||
# This panel will contain a simple message
|
||||
|
||||
content = render_text(
|
||||
"""
|
||||
This is a <i>sample panel</i> which appears on every page.
|
||||
It renders a simple string of <b>HTML</b> content.
|
||||
|
||||
<br>
|
||||
<h5>Instance Details:</h5>
|
||||
<ul>
|
||||
<li>Instance Type: {{ instance_type }}</li>
|
||||
<li>Instance ID: {{ instance_id }}</li>
|
||||
</ul>
|
||||
""",
|
||||
context={'instance_type': instance_type, 'instance_id': instance_id},
|
||||
)
|
||||
|
||||
panels.append({
|
||||
'name': 'sample_panel',
|
||||
'label': 'Sample Panel',
|
||||
'content': content,
|
||||
})
|
||||
|
||||
# A broken panel which tries to load a non-existent JS file
|
||||
if self.get_setting('ENABLE_BROKEN_PANElS'):
|
||||
panels.append({
|
||||
'name': 'broken_panel',
|
||||
'label': 'Broken Panel',
|
||||
'source': '/this/does/not/exist.js',
|
||||
})
|
||||
|
||||
# A dynamic panel which will be injected into the UI (loaded from external file)
|
||||
if self.get_setting('ENABLE_DYNAMIC_PANEL'):
|
||||
panels.append({
|
||||
'name': 'dynamic_panel',
|
||||
'label': 'Dynamic Part Panel',
|
||||
'source': '/static/plugin/sample_panel.js',
|
||||
'icon': 'part',
|
||||
})
|
||||
|
||||
# Next, add a custom panel which will appear on the 'part' page
|
||||
# Note that this content is rendered from a template file,
|
||||
# using the django templating system
|
||||
if self.get_setting('ENABLE_PART_PANELS') and instance_type == 'part':
|
||||
try:
|
||||
part = Part.objects.get(pk=instance_id)
|
||||
except (Part.DoesNotExist, ValueError):
|
||||
part = None
|
||||
|
||||
# Note: This panel will *only* be available if the part is active
|
||||
if part and part.active:
|
||||
content = render_template(
|
||||
self, 'uidemo/custom_part_panel.html', context={'part': part}
|
||||
)
|
||||
|
||||
panels.append({
|
||||
'name': 'part_panel',
|
||||
'label': 'Part Panel',
|
||||
'content': content,
|
||||
})
|
||||
|
||||
# Next, add a custom panel which will appear on the 'purchaseorder' page
|
||||
if (
|
||||
self.get_setting('ENABLE_PURCHASE_ORDER_PANELS')
|
||||
and instance_type == 'purchaseorder'
|
||||
):
|
||||
panels.append({
|
||||
'name': 'purchase_order_panel',
|
||||
'label': 'Purchase Order Panel',
|
||||
'content': 'This is a custom panel which appears on the <b>Purchase Order</b> view page.',
|
||||
})
|
||||
|
||||
return panels
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* A sample panel plugin for InvenTree.
|
||||
*
|
||||
* This plugin file is dynamically loaded,
|
||||
* as specified in the plugin/samples/integration/user_interface_sample.py
|
||||
*
|
||||
* It provides a simple example of how panels can be dynamically rendered,
|
||||
* as well as dynamically hidden, based on the provided context.
|
||||
*/
|
||||
|
||||
export function renderPanel(target, context) {
|
||||
|
||||
if (!target) {
|
||||
console.error("No target provided to renderPanel");
|
||||
return;
|
||||
}
|
||||
|
||||
target.innerHTML = `
|
||||
<h4>Dynamic Panel Content</h4>
|
||||
|
||||
<p>This panel has been dynamically rendered by the plugin system.</p>
|
||||
<p>It can be hidden or displayed based on the provided context.</p>
|
||||
|
||||
<hr>
|
||||
<h5>Context:</h5>
|
||||
|
||||
<ul>
|
||||
<li>Username: ${context.user.username()}</li>
|
||||
<li>Is Staff: ${context.user.isStaff() ? "YES": "NO"}</li>
|
||||
<li>Model Type: ${context.model}</li>
|
||||
<li>Instance ID: ${context.id}</li>
|
||||
`;
|
||||
|
||||
}
|
||||
|
||||
|
||||
// Dynamically hide the panel based on the provided context
|
||||
export function isPanelHidden(context) {
|
||||
|
||||
// Hide the panel if the user is not staff
|
||||
if (!context?.user?.isStaff()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Only display for active parts
|
||||
return context.model != 'part' || !context.instance || !context.instance.active;
|
||||
}
|
||||
@@ -301,3 +301,46 @@ class PluginRelationSerializer(serializers.PrimaryKeyRelatedField):
|
||||
def to_representation(self, value):
|
||||
"""Return the 'key' of the PluginConfig object."""
|
||||
return value.key
|
||||
|
||||
|
||||
class PluginPanelSerializer(serializers.Serializer):
|
||||
"""Serializer for a plugin panel."""
|
||||
|
||||
class Meta:
|
||||
"""Meta for serializer."""
|
||||
|
||||
fields = [
|
||||
'plugin',
|
||||
'name',
|
||||
'label',
|
||||
# Following fields are optional
|
||||
'icon',
|
||||
'content',
|
||||
'source',
|
||||
]
|
||||
|
||||
# Required fields
|
||||
plugin = serializers.CharField(
|
||||
label=_('Plugin Key'), required=True, allow_blank=False
|
||||
)
|
||||
|
||||
name = serializers.CharField(
|
||||
label=_('Panel Name'), required=True, allow_blank=False
|
||||
)
|
||||
|
||||
label = serializers.CharField(
|
||||
label=_('Panel Title'), required=True, allow_blank=False
|
||||
)
|
||||
|
||||
# Optional fields
|
||||
icon = serializers.CharField(
|
||||
label=_('Panel Icon'), required=False, allow_blank=True
|
||||
)
|
||||
|
||||
content = serializers.CharField(
|
||||
label=_('Panel Content (HTML)'), required=False, allow_blank=True
|
||||
)
|
||||
|
||||
source = serializers.CharField(
|
||||
label=_('Panel Source (javascript)'), required=False, allow_blank=True
|
||||
)
|
||||
|
||||
@@ -32,6 +32,8 @@ def clear_static_dir(path, recursive=True):
|
||||
# Finally, delete the directory itself to remove orphan folders when uninstalling a plugin
|
||||
staticfiles_storage.delete(path)
|
||||
|
||||
logger.info('Cleared static directory: %s', path)
|
||||
|
||||
|
||||
def collect_plugins_static_files():
|
||||
"""Copy static files from all installed plugins into the static directory."""
|
||||
@@ -43,6 +45,26 @@ def collect_plugins_static_files():
|
||||
copy_plugin_static_files(slug, check_reload=False)
|
||||
|
||||
|
||||
def clear_plugins_static_files():
|
||||
"""Clear out static files for plugins which are no longer active."""
|
||||
installed_plugins = set(registry.plugins.keys())
|
||||
|
||||
path = 'plugins/'
|
||||
|
||||
# Check that the directory actually exists
|
||||
if not staticfiles_storage.exists(path):
|
||||
return
|
||||
|
||||
# Get all static files in the 'plugins' static directory
|
||||
dirs, _files = staticfiles_storage.listdir('plugins/')
|
||||
|
||||
for d in dirs:
|
||||
# Check if the directory is a plugin directory
|
||||
if d not in installed_plugins:
|
||||
# Clear out the static files for this plugin
|
||||
clear_static_dir(f'plugins/{d}/', recursive=True)
|
||||
|
||||
|
||||
def copy_plugin_static_files(slug, check_reload=True):
|
||||
"""Copy static files for the specified plugin."""
|
||||
if check_reload:
|
||||
@@ -93,3 +115,8 @@ def copy_plugin_static_files(slug, check_reload=True):
|
||||
copied += 1
|
||||
|
||||
logger.info("Copied %s static files for plugin '%s'.", copied, slug)
|
||||
|
||||
|
||||
def clear_plugin_static_files(slug: str, recursive: bool = True):
|
||||
"""Clear static files for the specified plugin."""
|
||||
clear_static_dir(f'plugins/{slug}/', recursive=recursive)
|
||||
|
||||
@@ -17,6 +17,8 @@
|
||||
{% include "InvenTree/settings/setting.html" with key="BARCODE_WEBCAM_SUPPORT" icon="fa-video" %}
|
||||
{% include "InvenTree/settings/setting.html" with key="BARCODE_SHOW_TEXT" icon="fa-closed-captioning" %}
|
||||
{% include "InvenTree/settings/setting.html" with key="BARCODE_GENERATION_PLUGIN" icon="fa-qrcode" %}
|
||||
{% include "InvenTree/settings/setting.html" with key="BARCODE_STORE_RESULTS" icon="fa-qrcode" %}
|
||||
{% include "InvenTree/settings/setting.html" with key="BARCODE_RESULTS_MAX_NUM" icon="fa-qrcode" %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
|
||||
@@ -98,7 +98,8 @@ function purchaseOrderFields(options={}) {
|
||||
|
||||
return fields;
|
||||
}
|
||||
}
|
||||
},
|
||||
disabled: !!options.duplicate_order,
|
||||
},
|
||||
supplier_reference: {},
|
||||
project_code: {
|
||||
@@ -155,35 +156,13 @@ function purchaseOrderFields(options={}) {
|
||||
|
||||
// Add fields for order duplication (only if required)
|
||||
if (options.duplicate_order) {
|
||||
fields.duplicate_order = {
|
||||
fields.duplicate__order_id = {
|
||||
value: options.duplicate_order,
|
||||
group: 'duplicate',
|
||||
required: 'true',
|
||||
type: 'related field',
|
||||
model: 'purchaseorder',
|
||||
filters: {
|
||||
supplier_detail: true,
|
||||
},
|
||||
api_url: '{% url "api-po-list" %}',
|
||||
label: '{% trans "Purchase Order" %}',
|
||||
help_text: '{% trans "Select purchase order to duplicate" %}',
|
||||
hidden: true,
|
||||
};
|
||||
|
||||
fields.duplicate_line_items = {
|
||||
value: true,
|
||||
group: 'duplicate',
|
||||
type: 'boolean',
|
||||
label: '{% trans "Duplicate Line Items" %}',
|
||||
help_text: '{% trans "Duplicate all line items from the selected order" %}',
|
||||
};
|
||||
|
||||
fields.duplicate_extra_lines = {
|
||||
value: true,
|
||||
group: 'duplicate',
|
||||
type: 'boolean',
|
||||
label: '{% trans "Duplicate Extra Lines" %}',
|
||||
help_text: '{% trans "Duplicate extra line items from the selected order" %}',
|
||||
};
|
||||
fields.duplicate__copy_lines = {};
|
||||
fields.duplicate__copy_extra_lines = {};
|
||||
}
|
||||
|
||||
if (!global_settings.PROJECT_CODES_ENABLED) {
|
||||
|
||||
@@ -241,6 +241,7 @@ class RuleSet(models.Model):
|
||||
'plugin_pluginconfig',
|
||||
'plugin_pluginsetting',
|
||||
'plugin_notificationusersetting',
|
||||
'common_barcodescanresult',
|
||||
'common_newsfeedentry',
|
||||
'taggit_tag',
|
||||
'taggit_taggeditem',
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
"it",
|
||||
"ja",
|
||||
"ko",
|
||||
"lt",
|
||||
"lv",
|
||||
"nl",
|
||||
"no",
|
||||
|
||||
+13
-13
@@ -11,7 +11,7 @@
|
||||
"compile": "lingui compile --typescript"
|
||||
},
|
||||
"dependencies": {
|
||||
"@codemirror/autocomplete": "^6.18.0",
|
||||
"@codemirror/autocomplete": "^6.18.1",
|
||||
"@codemirror/lang-liquid": "^6.2.1",
|
||||
"@codemirror/language": "^6.10.2",
|
||||
"@codemirror/lint": "^6.8.1",
|
||||
@@ -37,11 +37,11 @@
|
||||
"@mantine/notifications": "^7.12.2",
|
||||
"@mantine/spotlight": "^7.12.2",
|
||||
"@mantine/vanilla-extract": "^7.12.2",
|
||||
"@sentry/react": "^8.29.0",
|
||||
"@tabler/icons-react": "^3.15.0",
|
||||
"@tanstack/react-query": "^5.55.4",
|
||||
"@uiw/codemirror-theme-vscode": "^4.23.1",
|
||||
"@uiw/react-codemirror": "^4.23.1",
|
||||
"@sentry/react": "^8.30.0",
|
||||
"@tabler/icons-react": "^3.17.0",
|
||||
"@tanstack/react-query": "^5.56.2",
|
||||
"@uiw/codemirror-theme-vscode": "^4.23.2",
|
||||
"@uiw/react-codemirror": "^4.23.2",
|
||||
"@uiw/react-split": "^5.9.3",
|
||||
"@vanilla-extract/css": "^1.15.5",
|
||||
"axios": "^1.7.7",
|
||||
@@ -49,7 +49,7 @@
|
||||
"codemirror": "^6.0.1",
|
||||
"dayjs": "^1.11.13",
|
||||
"easymde": "^2.18.0",
|
||||
"embla-carousel-react": "^8.2.1",
|
||||
"embla-carousel-react": "^8.3.0",
|
||||
"fuse.js": "^7.0.0",
|
||||
"html5-qrcode": "^2.3.8",
|
||||
"mantine-datatable": "^7.12.4",
|
||||
@@ -71,13 +71,13 @@
|
||||
"@babel/core": "^7.25.2",
|
||||
"@babel/preset-react": "^7.24.7",
|
||||
"@babel/preset-typescript": "^7.24.7",
|
||||
"@codecov/vite-plugin": "^1.0.0",
|
||||
"@codecov/vite-plugin": "^1.1.0",
|
||||
"@lingui/cli": "^4.11.4",
|
||||
"@lingui/macro": "^4.11.4",
|
||||
"@playwright/test": "^1.47.0",
|
||||
"@types/node": "^22.5.4",
|
||||
"@playwright/test": "^1.47.1",
|
||||
"@types/node": "^22.5.5",
|
||||
"@types/qrcode": "^1.5.5",
|
||||
"@types/react": "^18.3.5",
|
||||
"@types/react": "^18.3.6",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@types/react-grid-layout": "^1.3.5",
|
||||
"@types/react-router-dom": "^5.3.3",
|
||||
@@ -87,8 +87,8 @@
|
||||
"babel-plugin-macros": "^3.1.0",
|
||||
"nyc": "^17.0.0",
|
||||
"rollup-plugin-license": "^3.5.2",
|
||||
"typescript": "^5.5.4",
|
||||
"vite": "^5.4.3",
|
||||
"typescript": "^5.6.2",
|
||||
"vite": "^5.4.6",
|
||||
"vite-plugin-babel-macros": "^1.0.6",
|
||||
"vite-plugin-istanbul": "^6.0.2"
|
||||
}
|
||||
|
||||
@@ -38,7 +38,8 @@ export default defineConfig({
|
||||
{
|
||||
command: 'invoke dev.server -a 127.0.0.1:8000',
|
||||
env: {
|
||||
INVENTREE_DEBUG: 'True'
|
||||
INVENTREE_DEBUG: 'True',
|
||||
INVENTREE_PLUGINS_ENABLED: 'True'
|
||||
},
|
||||
url: 'http://127.0.0.1:8000/api/',
|
||||
reuseExistingServer: !process.env.CI,
|
||||
|
||||
@@ -4,7 +4,7 @@ import { ErrorBoundary, FallbackRender } from '@sentry/react';
|
||||
import { IconExclamationCircle } from '@tabler/icons-react';
|
||||
import { ReactNode, useCallback } from 'react';
|
||||
|
||||
function DefaultFallback({ title }: { title: String }): ReactNode {
|
||||
function DefaultFallback({ title }: Readonly<{ title: string }>): ReactNode {
|
||||
return (
|
||||
<Alert
|
||||
color="red"
|
||||
@@ -20,11 +20,11 @@ export function Boundary({
|
||||
children,
|
||||
label,
|
||||
fallback
|
||||
}: {
|
||||
}: Readonly<{
|
||||
children: ReactNode;
|
||||
label: string;
|
||||
fallback?: React.ReactElement | FallbackRender | undefined;
|
||||
}): ReactNode {
|
||||
fallback?: React.ReactElement | FallbackRender;
|
||||
}>): ReactNode {
|
||||
const onError = useCallback(
|
||||
(error: unknown, componentStack: string | undefined, eventId: string) => {
|
||||
console.error(`Error rendering component: ${label}`);
|
||||
|
||||
@@ -14,13 +14,13 @@ export function DashboardItemProxy({
|
||||
url,
|
||||
params,
|
||||
autoupdate = true
|
||||
}: {
|
||||
}: Readonly<{
|
||||
id: string;
|
||||
text: string;
|
||||
url: ApiEndpoints;
|
||||
params: any;
|
||||
autoupdate: boolean;
|
||||
}) {
|
||||
}>) {
|
||||
function fetchData() {
|
||||
return api
|
||||
.get(`${apiUrl(url)}?search=&offset=0&limit=25`, { params: params })
|
||||
|
||||
@@ -22,7 +22,7 @@ export type AdminButtonProps = {
|
||||
* - The user has "superuser" role
|
||||
* - The user has at least read rights for the selected item
|
||||
*/
|
||||
export default function AdminButton(props: AdminButtonProps) {
|
||||
export default function AdminButton(props: Readonly<AdminButtonProps>) {
|
||||
const user = useUserState();
|
||||
|
||||
const enabled: boolean = useMemo(() => {
|
||||
|
||||
@@ -9,12 +9,12 @@ export function ButtonMenu({
|
||||
actions,
|
||||
tooltip = '',
|
||||
label = ''
|
||||
}: {
|
||||
}: Readonly<{
|
||||
icon: any;
|
||||
actions: React.ReactNode[];
|
||||
label?: string;
|
||||
tooltip?: string;
|
||||
}) {
|
||||
}>) {
|
||||
return (
|
||||
<Menu shadow="xs">
|
||||
<Menu.Target>
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
ActionIcon,
|
||||
Button,
|
||||
CopyButton as MantineCopyButton,
|
||||
MantineSize,
|
||||
Text,
|
||||
Tooltip
|
||||
} from '@mantine/core';
|
||||
@@ -11,11 +12,15 @@ import { InvenTreeIcon } from '../../functions/icons';
|
||||
|
||||
export function CopyButton({
|
||||
value,
|
||||
label
|
||||
}: {
|
||||
label,
|
||||
content,
|
||||
size
|
||||
}: Readonly<{
|
||||
value: any;
|
||||
label?: JSX.Element;
|
||||
}) {
|
||||
label?: string;
|
||||
content?: JSX.Element;
|
||||
size?: MantineSize;
|
||||
}>) {
|
||||
const ButtonComponent = label ? Button : ActionIcon;
|
||||
|
||||
return (
|
||||
@@ -26,15 +31,19 @@ export function CopyButton({
|
||||
color={copied ? 'teal' : 'gray'}
|
||||
onClick={copy}
|
||||
variant="transparent"
|
||||
size="sm"
|
||||
size={size ?? 'sm'}
|
||||
>
|
||||
{copied ? (
|
||||
<InvenTreeIcon icon="check" />
|
||||
) : (
|
||||
<InvenTreeIcon icon="copy" />
|
||||
)}
|
||||
|
||||
{label && <Text ml={10}>{label}</Text>}
|
||||
{content}
|
||||
{label && (
|
||||
<Text p={size ?? 'sm'} size={size ?? 'sm'}>
|
||||
{label}
|
||||
</Text>
|
||||
)}
|
||||
</ButtonComponent>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
@@ -6,12 +6,12 @@ export function EditButton({
|
||||
editing,
|
||||
disabled,
|
||||
saveIcon
|
||||
}: {
|
||||
}: Readonly<{
|
||||
setEditing: (value?: React.SetStateAction<boolean> | undefined) => void;
|
||||
editing: boolean;
|
||||
disabled?: boolean;
|
||||
saveIcon?: JSX.Element;
|
||||
}) {
|
||||
}>) {
|
||||
saveIcon = saveIcon || <IconDeviceFloppy />;
|
||||
return (
|
||||
<ActionIcon
|
||||
|
||||
@@ -12,14 +12,14 @@ export default function PrimaryActionButton({
|
||||
color,
|
||||
hidden,
|
||||
onClick
|
||||
}: {
|
||||
}: Readonly<{
|
||||
title: string;
|
||||
tooltip?: string;
|
||||
icon?: InvenTreeIconType;
|
||||
color?: string;
|
||||
hidden?: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
}>) {
|
||||
if (hidden) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -6,10 +6,10 @@ import { ActionButton } from './ActionButton';
|
||||
export default function RemoveRowButton({
|
||||
onClick,
|
||||
tooltip = t`Remove this row`
|
||||
}: {
|
||||
}: Readonly<{
|
||||
onClick: () => void;
|
||||
tooltip?: string;
|
||||
}) {
|
||||
}>) {
|
||||
return (
|
||||
<ActionButton
|
||||
onClick={onClick}
|
||||
|
||||
@@ -33,7 +33,7 @@ const brandIcons: { [key: string]: JSX.Element } = {
|
||||
microsoft: <IconBrandAzure />
|
||||
};
|
||||
|
||||
export function SsoButton({ provider }: { provider: Provider }) {
|
||||
export function SsoButton({ provider }: Readonly<{ provider: Provider }>) {
|
||||
function login() {
|
||||
// set preferred provider
|
||||
api
|
||||
|
||||
@@ -7,14 +7,14 @@ export function PassFailButton({
|
||||
value,
|
||||
passText,
|
||||
failText
|
||||
}: {
|
||||
}: Readonly<{
|
||||
value: any;
|
||||
passText?: string;
|
||||
failText?: string;
|
||||
}) {
|
||||
}>) {
|
||||
const v = isTrue(value);
|
||||
const pass = passText || t`Pass`;
|
||||
const fail = failText || t`Fail`;
|
||||
const pass = passText ?? t`Pass`;
|
||||
const fail = failText ?? t`Fail`;
|
||||
|
||||
return (
|
||||
<Badge
|
||||
@@ -29,11 +29,11 @@ export function PassFailButton({
|
||||
);
|
||||
}
|
||||
|
||||
export function YesNoButton({ value }: { value: any }) {
|
||||
export function YesNoButton({ value }: Readonly<{ value: any }>) {
|
||||
return <PassFailButton value={value} passText={t`Yes`} failText={t`No`} />;
|
||||
}
|
||||
|
||||
export function YesNoUndefinedButton({ value }: { value?: boolean }) {
|
||||
export function YesNoUndefinedButton({ value }: Readonly<{ value?: boolean }>) {
|
||||
if (value === undefined) {
|
||||
return <Skeleton height={15} width={32} />;
|
||||
} else {
|
||||
|
||||
@@ -96,7 +96,10 @@ type FieldProps = {
|
||||
* Badge shows username, full name, or group name depending on server settings.
|
||||
* Badge appends icon to describe type of Owner
|
||||
*/
|
||||
function NameBadge({ pk, type }: { pk: string | number; type: BadgeType }) {
|
||||
function NameBadge({
|
||||
pk,
|
||||
type
|
||||
}: Readonly<{ pk: string | number; type: BadgeType }>) {
|
||||
const { data } = useQuery({
|
||||
queryKey: ['badge', type, pk],
|
||||
queryFn: async () => {
|
||||
@@ -331,17 +334,17 @@ function StatusValue(props: Readonly<FieldProps>) {
|
||||
);
|
||||
}
|
||||
|
||||
function CopyField({ value }: { value: string }) {
|
||||
function CopyField({ value }: Readonly<{ value: string }>) {
|
||||
return <CopyButton value={value} />;
|
||||
}
|
||||
|
||||
export function DetailsTableField({
|
||||
item,
|
||||
field
|
||||
}: {
|
||||
}: Readonly<{
|
||||
item: any;
|
||||
field: DetailsField;
|
||||
}) {
|
||||
}>) {
|
||||
function getFieldType(type: string) {
|
||||
switch (type) {
|
||||
case 'text':
|
||||
@@ -394,11 +397,11 @@ export function DetailsTable({
|
||||
item,
|
||||
fields,
|
||||
title
|
||||
}: {
|
||||
}: Readonly<{
|
||||
item: any;
|
||||
fields: DetailsField[];
|
||||
title?: string;
|
||||
}) {
|
||||
}>) {
|
||||
return (
|
||||
<Paper p="xs" withBorder radius="xs">
|
||||
<Stack gap="xs">
|
||||
|
||||
@@ -81,10 +81,10 @@ const removeModal = (apiPath: string, setImage: (image: string) => void) =>
|
||||
function UploadModal({
|
||||
apiPath,
|
||||
setImage
|
||||
}: {
|
||||
}: Readonly<{
|
||||
apiPath: string;
|
||||
setImage: (image: string) => void;
|
||||
}) {
|
||||
}>) {
|
||||
const [currentFile, setCurrentFile] = useState<FileWithPath | null>(null);
|
||||
let uploading = false;
|
||||
|
||||
@@ -96,7 +96,7 @@ function UploadModal({
|
||||
<Text size="xl" inline>
|
||||
<Trans>Drag and drop to upload</Trans>
|
||||
</Text>
|
||||
<Text size="sm" color="dimmed" inline mt={7}>
|
||||
<Text size="sm" c="dimmed" inline mt={7}>
|
||||
<Trans>Click to select file(s)</Trans>
|
||||
</Text>
|
||||
</div>
|
||||
@@ -131,7 +131,7 @@ function UploadModal({
|
||||
<Text size="xl" inline style={{ wordBreak: 'break-all' }}>
|
||||
{file.name}
|
||||
</Text>
|
||||
<Text size="sm" color="dimmed" inline mt={7}>
|
||||
<Text size="sm" c="dimmed" inline mt={7}>
|
||||
{size.toFixed(2)} MB
|
||||
</Text>
|
||||
</div>
|
||||
@@ -246,14 +246,14 @@ function ImageActionButtons({
|
||||
hasImage,
|
||||
pk,
|
||||
setImage
|
||||
}: {
|
||||
}: Readonly<{
|
||||
actions?: DetailImageButtonProps;
|
||||
visible: boolean;
|
||||
apiPath: string;
|
||||
hasImage: boolean;
|
||||
pk: string;
|
||||
setImage: (image: string) => void;
|
||||
}) {
|
||||
}>) {
|
||||
return (
|
||||
<>
|
||||
{visible && (
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
// import SimpleMDE from "react-simplemde-editor";
|
||||
import { t } from '@lingui/macro';
|
||||
import { useMantineColorScheme } from '@mantine/core';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import EasyMDE, { default as SimpleMde } from 'easymde';
|
||||
@@ -60,13 +58,11 @@ export default function NotesEditor({
|
||||
modelType,
|
||||
modelId,
|
||||
editable
|
||||
}: {
|
||||
}: Readonly<{
|
||||
modelType: ModelType;
|
||||
modelId: number;
|
||||
editable?: boolean;
|
||||
}) {
|
||||
const { colorScheme } = useMantineColorScheme();
|
||||
|
||||
}>) {
|
||||
// In addition to the editable prop, we also need to check if the user has "enabled" editing
|
||||
const [editing, setEditing] = useState<boolean>(false);
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import NotAuthenticated from './NotAuthenticated';
|
||||
import NotFound from './NotFound';
|
||||
import PermissionDenied from './PermissionDenied';
|
||||
|
||||
export default function ClientError({ status }: { status?: number }) {
|
||||
export default function ClientError({ status }: Readonly<{ status?: number }>) {
|
||||
switch (status) {
|
||||
case 401:
|
||||
return <NotAuthenticated />;
|
||||
|
||||
@@ -19,13 +19,13 @@ export default function ErrorPage({
|
||||
title,
|
||||
message,
|
||||
status
|
||||
}: {
|
||||
}: Readonly<{
|
||||
title: string;
|
||||
message: string;
|
||||
status?: number;
|
||||
redirectMessage?: string;
|
||||
redirectTarget?: string;
|
||||
}) {
|
||||
}>) {
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
|
||||
@@ -2,7 +2,7 @@ import { t } from '@lingui/macro';
|
||||
|
||||
import GenericErrorPage from './GenericErrorPage';
|
||||
|
||||
export default function ServerError({ status }: { status?: number }) {
|
||||
export default function ServerError({ status }: Readonly<{ status?: number }>) {
|
||||
return (
|
||||
<GenericErrorPage
|
||||
title={t`Server Error`}
|
||||
|
||||
@@ -74,7 +74,7 @@ export interface ApiFormAction {
|
||||
*/
|
||||
export interface ApiFormProps {
|
||||
url: ApiEndpoints | string;
|
||||
pk?: number | string | undefined;
|
||||
pk?: number | string;
|
||||
pk_field?: string;
|
||||
pathParams?: PathParams;
|
||||
method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
|
||||
@@ -103,10 +103,10 @@ export interface ApiFormProps {
|
||||
export function OptionsApiForm({
|
||||
props: _props,
|
||||
id: pId
|
||||
}: {
|
||||
}: Readonly<{
|
||||
props: ApiFormProps;
|
||||
id?: string;
|
||||
}) {
|
||||
}>) {
|
||||
const props = useMemo(
|
||||
() => ({
|
||||
..._props,
|
||||
@@ -197,11 +197,11 @@ export function ApiForm({
|
||||
id,
|
||||
props,
|
||||
optionsLoading
|
||||
}: {
|
||||
}: Readonly<{
|
||||
id: string;
|
||||
props: ApiFormProps;
|
||||
optionsLoading: boolean;
|
||||
}) {
|
||||
}>) {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [fields, setFields] = useState<ApiFormFieldSet>(
|
||||
@@ -649,10 +649,10 @@ export function ApiForm({
|
||||
export function CreateApiForm({
|
||||
id,
|
||||
props
|
||||
}: {
|
||||
}: Readonly<{
|
||||
id?: string;
|
||||
props: ApiFormProps;
|
||||
}) {
|
||||
}>) {
|
||||
const createProps = useMemo<ApiFormProps>(
|
||||
() => ({
|
||||
...props,
|
||||
@@ -667,10 +667,10 @@ export function CreateApiForm({
|
||||
export function EditApiForm({
|
||||
id,
|
||||
props
|
||||
}: {
|
||||
}: Readonly<{
|
||||
id?: string;
|
||||
props: ApiFormProps;
|
||||
}) {
|
||||
}>) {
|
||||
const editProps = useMemo<ApiFormProps>(
|
||||
() => ({
|
||||
...props,
|
||||
@@ -687,10 +687,10 @@ export function EditApiForm({
|
||||
export function DeleteApiForm({
|
||||
id,
|
||||
props
|
||||
}: {
|
||||
}: Readonly<{
|
||||
id?: string;
|
||||
props: ApiFormProps;
|
||||
}) {
|
||||
}>) {
|
||||
const deleteProps = useMemo<ApiFormProps>(
|
||||
() => ({
|
||||
...props,
|
||||
|
||||
@@ -118,7 +118,7 @@ export function AuthenticationForm() {
|
||||
<Anchor
|
||||
component="button"
|
||||
type="button"
|
||||
color="dimmed"
|
||||
c="dimmed"
|
||||
size="xs"
|
||||
onClick={() => navigate('/reset-password')}
|
||||
>
|
||||
@@ -143,7 +143,7 @@ export function AuthenticationForm() {
|
||||
<Anchor
|
||||
component="button"
|
||||
type="button"
|
||||
color="dimmed"
|
||||
c="dimmed"
|
||||
size="xs"
|
||||
onClick={() => setMode.toggle()}
|
||||
>
|
||||
@@ -278,10 +278,10 @@ export function RegistrationForm() {
|
||||
export function ModeSelector({
|
||||
loginMode,
|
||||
setMode
|
||||
}: {
|
||||
}: Readonly<{
|
||||
loginMode: boolean;
|
||||
setMode: any;
|
||||
}) {
|
||||
}>) {
|
||||
const [auth_settings] = useServerApiState((state) => [state.auth_settings]);
|
||||
const registration_enabled =
|
||||
auth_settings?.registration_enabled ||
|
||||
@@ -297,7 +297,7 @@ export function ModeSelector({
|
||||
<Anchor
|
||||
component="button"
|
||||
type="button"
|
||||
color="dimmed"
|
||||
c="dimmed"
|
||||
size="xs"
|
||||
onClick={() => setMode.close()}
|
||||
>
|
||||
@@ -308,7 +308,7 @@ export function ModeSelector({
|
||||
<Anchor
|
||||
component="button"
|
||||
type="button"
|
||||
color="dimmed"
|
||||
c="dimmed"
|
||||
size="xs"
|
||||
onClick={() => setMode.open()}
|
||||
>
|
||||
|
||||
@@ -17,10 +17,10 @@ import { HostList } from '../../states/states';
|
||||
export function HostOptionsForm({
|
||||
data,
|
||||
saveOptions
|
||||
}: {
|
||||
}: Readonly<{
|
||||
data: HostList;
|
||||
saveOptions: (newData: HostList) => void;
|
||||
}) {
|
||||
}>) {
|
||||
const form = useForm({ initialValues: data });
|
||||
function deleteItem(key: string) {
|
||||
const newData = form.values;
|
||||
|
||||
@@ -13,11 +13,11 @@ export function InstanceOptions({
|
||||
hostKey,
|
||||
ChangeHost,
|
||||
setHostEdit
|
||||
}: {
|
||||
}: Readonly<{
|
||||
hostKey: string;
|
||||
ChangeHost: (newHost: string | null) => void;
|
||||
setHostEdit: () => void;
|
||||
}) {
|
||||
}>) {
|
||||
const [HostListEdit, setHostListEdit] = useToggle([false, true] as const);
|
||||
const [setHost, setHostList, hostList] = useLocalState((state) => [
|
||||
state.setHost,
|
||||
@@ -85,10 +85,10 @@ export function InstanceOptions({
|
||||
function ServerInfo({
|
||||
hostList,
|
||||
hostKey
|
||||
}: {
|
||||
}: Readonly<{
|
||||
hostList: HostList;
|
||||
hostKey: string;
|
||||
}) {
|
||||
}>) {
|
||||
const [server] = useServerApiState((state) => [state.server]);
|
||||
|
||||
return (
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user