Merge branch 'master' into feat/continuous-barcode-scan

This commit is contained in:
Matthias Mair 2026-04-09 16:22:40 +02:00 committed by GitHub
commit 7c875dd474
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
143 changed files with 39846 additions and 35223 deletions

View File

@ -14,6 +14,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- [#11702](https://github.com/inventree/InvenTree/pull/11702) adds "last updated" and "updated by" fields for label and report templates, allowing users to track when a template was last modified and by whom.
- [#11685](https://github.com/inventree/InvenTree/pull/11685) exposes the data importer wizard to the plugin interface, allowing plugins to trigger the data importer wizard and perform custom data imports from the UI.
- [#11692](https://github.com/inventree/InvenTree/pull/11692) adds line item numbering for external orders (purchase, sales and return orders). This allows users to specify a line number for each line item on the order, which can be used for reference purposes. The line number is optional, and can be left blank if not required. The line number is stored as a string, to allow for more flexible formatting (e.g. "1", "1.1", "A", etc).
- [#11641](https://github.com/inventree/InvenTree/pull/11641) adds support for custom parameters against the SalesOrderShipment model.
- [#11527](https://github.com/inventree/InvenTree/pull/11527) adds a new API endpoint for monitoring the status of a particular background task. This endpoint allows clients to check the status of a background task and receive updates when the task is complete. This is useful for long-running tasks that may take some time to complete, allowing clients to provide feedback to users about the progress of the task.
- [#11405](https://github.com/inventree/InvenTree/pull/11405) adds default table filters, which hide inactive items by default. The default table filters are overridden by user filter selection, and only apply to the table view initially presented to the user. This means that users can still view inactive items if they choose to, but they will not be shown by default.

View File

@ -6,9 +6,9 @@ asgiref==3.11.1 \
# via
# -c src/backend/requirements.txt
# django
django==5.2.12 \
--hash=sha256:4853482f395c3a151937f6991272540fcbf531464f254a347bf7c89f53c8cff7 \
--hash=sha256:6b809af7165c73eff5ce1c87fdae75d4da6520d6667f86401ecf55b681eb1eeb
django==5.2.13 \
--hash=sha256:5788fce61da23788a8ce6f02583765ab060d396720924789f97fa42119d37f7a \
--hash=sha256:a31589db5188d074c63f0945c3888fad104627dfcc236fb2b97f71f89da33bc4
# via
# -c src/backend/requirements.txt
# -r contrib/container/requirements.in
@ -17,9 +17,9 @@ django-auth-ldap==5.3.0 \
--hash=sha256:743d8107b146240b46f7e97207dc06cb11facc0cd70dce490b7ca09dd5643d19 \
--hash=sha256:aa880415983149b072f876d976ef8ec755a438090e176817998263a6ed9e1038
# via -r contrib/container/requirements.in
gunicorn==25.2.0 \
--hash=sha256:10bd7adb36d44945d97d0a1fdf9a0fb086ae9c7b39e56b4dece8555a6bf4a09c \
--hash=sha256:88f5b444d0055bf298435384af7294f325e2273fd37ba9f9ff7b98e0a1e5dfdc
gunicorn==25.3.0 \
--hash=sha256:cacea387dab08cd6776501621c295a904fe8e3b7aae9a1a3cbb26f4e7ed54660 \
--hash=sha256:f74e1b2f9f76f6cd1ca01198968bd2dd65830edc24b6e8e4d78de8320e2fe889
# via
# -c src/backend/requirements.txt
# -r contrib/container/requirements.in

View File

@ -74,6 +74,9 @@ Enter the package name into the form as shown below. You can add a path and a ve
{{ image("plugin/plugin_install_txt.png", "Plugin.txt file") }}
!!! info "Superuser Required"
Only users with superuser privileges can manage plugins via the web interface.
#### Local Directory
Custom plugins can be placed in the `data/plugins/` directory, where they will be automatically discovered. This can be useful for developing and testing plugins, but can prove more difficult in production (e.g. when using Docker).

View File

@ -547,14 +547,18 @@ You can add asset images to the reports and labels by using the `{% raw %}{% ass
## Parameters
If you need to load a parameter value for a particular model instance, within the context of your template, you can use the `parameter` template tag:
If you need to reference a parameter for a particular model instance, within the context of your template, you can use the `parameter` template tag:
### parameter
This returns a [Parameter](../concepts/parameters.md) object which contains the value of the parameter, as well as any associated metadata (e.g. units, description, etc).
::: report.templatetags.report.parameter
options:
show_docstring_description: false
show_source: False
### Example
#### Example
The following example assumes that you have a report or label which contains a valid [Part](../part/index.md) instance:
@ -580,6 +584,27 @@ A [Parameter](../concepts/parameters.md) has the following available attributes:
| Units | The *units* of the parameter (e.g. "km") |
| Template | A reference to a [ParameterTemplate](../concepts/parameters.md#parameter-templates) |
### parameter_value
To access just the value of a parameter, use the `parameter_value` template tag:
::: report.templatetags.report.parameter_value
options:
show_docstring_description: false
show_source: False
#### Example
```
{% raw %}
{% load report %}
{% parameter_value part "length" backup_value="3"as length_value %}
Part: {{ part.name }}<br>
Length: {{ length_value }}
{% endraw %}
```
## Rendering Markdown
Some data fields (such as the *Notes* field available on many internal database models) support [markdown formatting](https://en.wikipedia.org/wiki/Markdown). To render markdown content in a custom report, there are template filters made available through the [django-markdownify](https://github.com/erwinmatijsen/django-markdownify) library. This library provides functionality for converting markdown content to HTML representation, allowing it to be then rendered to PDF by the InvenTree report generation pipeline.

View File

@ -1,11 +1,28 @@
"""InvenTree API version information."""
# InvenTree API version
INVENTREE_API_VERSION = 470
INVENTREE_API_VERSION = 475
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
INVENTREE_API_TEXT = """
v475 -> 2026-04-09 : https://github.com/inventree/InvenTree/pull/11702
- Adds "updated" and "updated_by" fields to the LabelTemplate and ReportTemplate API endpoints
v474 -> 2026-04-08 : https://github.com/inventree/InvenTree/pull/11693
- Adds DataImportMixin to the ManufacturerPartList API endpoint
v473 -> 2026-04-08 : https://github.com/inventree/InvenTree/pull/11692
- Adds "line" field to PurchaseOrderLineItem and PurchaseOrderExtraLineItem API endpoints
- Adds "line" field to SalesOrderLineItem and SalesOrderExtraLineItem API endpoints
- Adds "line" field to ReturnOrderLineItem and ReturnOrderExtraLineItem API endpoints
v472 -> 2026-04-01 : https://github.com/inventree/InvenTree/pull/xxxx
- Fixes writable fields on the user detail endpoint
v471 -> 2026-04-07 : https://github.com/inventree/InvenTree/pull/11685
- Adds data importer support for the "SalesOrderShipment" model
v470 -> 2026-04-01 : https://github.com/inventree/InvenTree/pull/11659
- Renames "is_staff" field to "is_admin" and updates help texts accordingly to highlight current security boundaries
@ -13,7 +30,7 @@ v469 -> 2026-03-31 : https://github.com/inventree/InvenTree/pull/11641
- Adds parameter support to the SalesOrderShipment model and API endpoints
v468 -> 2026-03-31 : https://github.com/inventree/InvenTree/pull/11649
- Add ordering to contetype related fields - no functional changes
- Add ordering to contentype related fields - no functional changes
v467 -> 2026-03-20 : https://github.com/inventree/InvenTree/pull/11573
- Fix definition for the "parent" field on the StockItemSerializer

View File

@ -8,6 +8,7 @@ import json
import os.path
import re
from decimal import Decimal, InvalidOperation
from pathlib import Path
from typing import Optional, TypeVar
from wsgiref.util import FileWrapper
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
@ -35,9 +36,6 @@ from InvenTree.sanitizer import (
DEFAULT_TAGS,
)
from .setting.storages import StorageBackends
from .settings import MEDIA_URL, STATIC_URL
logger = structlog.get_logger('inventree')
INT_CLIP_MAX = 0x7FFFFFFF
@ -189,9 +187,8 @@ def getMediaUrl(
)
if name is not None:
file = regenerate_imagefile(file, name)
if settings.STORAGE_TARGET == StorageBackends.S3:
return str(file.url)
return os.path.join(MEDIA_URL, str(file.url))
return default_storage.url(file.name)
def regenerate_imagefile(_file, _name: str):
@ -229,7 +226,7 @@ def image2name(img_obj: StdImageField, do_preview: bool, do_thumbnail: bool):
def getStaticUrl(filename):
"""Return the qualified access path for the given file, under the static media directory."""
return os.path.join(STATIC_URL, str(filename))
return StaticFilesStorage().url(filename)
def TestIfImage(img) -> bool:
@ -261,8 +258,8 @@ def getBlankThumbnail():
def checkStaticFile(*args) -> bool:
"""Check if a file exists in the static storage."""
static_storage = StaticFilesStorage()
fn = os.path.join(*args)
return static_storage.exists(fn)
fn = Path(*args)
return static_storage.exists(str(fn))
def getLogoImage(as_file=False, custom=True):

View File

@ -1,9 +1,11 @@
"""Provides helper functions used throughout the InvenTree project that access the database."""
import io
import ipaddress
import socket
from decimal import Decimal
from typing import Optional, cast
from urllib.parse import urljoin
from urllib.parse import urljoin, urlparse
from django.conf import settings
from django.core.exceptions import ValidationError
@ -88,6 +90,36 @@ def construct_absolute_url(*arg, base_url=None, request=None):
return urljoin(base_url, relative_url)
def validate_url_no_ssrf(url):
"""Validate that a URL does not point to a private/internal network address.
Resolves the hostname to an IP address and checks it against private,
loopback, link-local, and reserved IP ranges to prevent SSRF attacks.
Arguments:
url: The URL to validate
Raises:
ValueError: If the URL resolves to a private or reserved IP address
"""
parsed = urlparse(url)
hostname = parsed.hostname
if not hostname:
raise ValueError(_('Invalid URL: no hostname'))
try:
addrinfo = socket.getaddrinfo(hostname, None)
except socket.gaierror:
raise ValueError(_('Invalid URL: hostname could not be resolved'))
for _family, _type, _proto, _canonname, sockaddr in addrinfo:
ip = ipaddress.ip_address(sockaddr[0])
if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved:
raise ValueError(_('URL points to a private or reserved IP address'))
def download_image_from_url(remote_url, timeout=2.5):
"""Download an image file from a remote URL.
@ -115,6 +147,9 @@ def download_image_from_url(remote_url, timeout=2.5):
validator = URLValidator()
validator(remote_url)
# SSRF protection: validate the resolved IP is not private/internal
validate_url_no_ssrf(remote_url)
# Calculate maximum allowable image size (in bytes)
max_size = (
int(get_global_setting('INVENTREE_DOWNLOAD_IMAGE_MAX_SIZE')) * 1024 * 1024
@ -129,10 +164,36 @@ def download_image_from_url(remote_url, timeout=2.5):
response = requests.get(
remote_url,
timeout=timeout,
allow_redirects=True,
allow_redirects=False,
stream=True,
headers=headers,
)
# Handle redirects manually to validate each destination
max_redirects = 5
redirect_count = 0
while response.is_redirect and redirect_count < max_redirects:
redirect_url = response.headers.get('Location')
if not redirect_url:
break
# Validate the redirect destination against SSRF
validator(redirect_url)
validate_url_no_ssrf(redirect_url)
redirect_count += 1
response = requests.get(
redirect_url,
timeout=timeout,
allow_redirects=False,
stream=True,
headers=headers,
)
if redirect_count >= max_redirects:
raise ValueError(_('Too many redirects'))
# Throw an error if anything goes wrong
response.raise_for_status()
except requests.exceptions.ConnectionError as exc:
@ -143,6 +204,8 @@ def download_image_from_url(remote_url, timeout=2.5):
raise requests.exceptions.HTTPError(
_('Server responded with invalid status code') + f': {response.status_code}'
)
except ValueError:
raise
except Exception as exc:
raise Exception(_('Exception occurred') + f': {exc!s}')

View File

@ -3,8 +3,7 @@
- May be required after importing a new dataset, for example
"""
import os
from django.core.files.storage import default_storage
from django.core.management.base import BaseCommand
from django.db.utils import OperationalError, ProgrammingError
@ -35,7 +34,7 @@ class Command(BaseCommand):
img_paths.append(x.path)
if len(img_paths) > 0:
if all(os.path.exists(path) for path in img_paths):
if all(default_storage.exists(p) for p in img_paths):
# All images exist - skip further work
return

View File

@ -201,6 +201,11 @@ PLUGINS_INSTALL_DISABLED = get_boolean_setting(
'INVENTREE_PLUGIN_NOINSTALL', 'plugin_noinstall', False
)
if not PLUGINS_ENABLED:
PLUGINS_INSTALL_DISABLED = (
True # If plugins are disabled, also disable installation
)
PLUGIN_FILE = config.get_plugin_file()
# Plugin test settings

View File

@ -692,13 +692,16 @@ class TestHelpers(TestCase):
self.assertFalse(helpers.isNull(s))
def testStaticUrl(self):
"""Test static url helpers."""
"""Test static URL helpers."""
self.assertEqual(helpers.getStaticUrl('test.jpg'), '/static/test.jpg')
self.assertEqual(helpers.getBlankImage(), '/static/img/blank_image.png')
self.assertEqual(
helpers.getBlankThumbnail(), '/static/img/blank_image.thumbnail.png'
)
self.assertFalse(helpers.checkStaticFile('dummy', 'dir', 'test.jpg'))
self.assertTrue(helpers.checkStaticFile('img', 'blank_image.png'))
def testMediaUrl(self):
"""Test getMediaUrl."""
# Str should not work

View File

@ -49,7 +49,8 @@ def validate_part_name_format(value):
})
# Attempt to render the template with a dummy Part instance
p = Part(name='test part', description='some test part')
# Use pk=1 to ensure conditional checks like {% if part.pk %} are evaluated
p = Part(pk=1, name='test part', description='some test part')
try:
SandboxedEnvironment().from_string(value).render({'part': p})

View File

@ -184,6 +184,7 @@ class ManufacturerPartMixin(SerializerContextMixin):
class ManufacturerPartList(
DataExportViewMixin,
ManufacturerPartMixin,
SerializerContextMixin,
OutputOptionsMixin,

View File

@ -19,14 +19,14 @@ class DataImportSerializerRegister:
def register(self, serializer) -> None:
"""Register a new serializer with the importer registry."""
if not issubclass(serializer, DataImportSerializerMixin):
logger.debug('Invalid serializer class: %s', type(serializer))
logger.debug('Invalid serializer class: %s', serializer.__name__)
return
if not issubclass(serializer, Serializer):
logger.debug('Invalid serializer class: %s', type(serializer))
logger.debug('Invalid serializer class: %s', serializer.__name__)
return
logger.debug('Registering serializer class for import: %s', type(serializer))
logger.debug('Registering serializer class for import: %s', serializer.__name__)
if serializer not in self.supported_serializers:
self.supported_serializers.append(serializer)

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

View File

@ -82,7 +82,7 @@ class GeneralExtraLineList(SerializerContextMixin, DataExportViewMixin):
filter_backends = SEARCH_ORDER_FILTER
ordering_fields = ['quantity', 'notes', 'reference']
ordering_fields = ['quantity', 'notes', 'reference', 'line']
search_fields = ['quantity', 'notes', 'reference', 'description']
@ -717,6 +717,7 @@ class PurchaseOrderLineItemList(
'order': 'order__reference',
'status': 'order__status',
'complete_date': 'order__complete_date',
'line': ['line', 'part__SKU'],
}
ordering_fields = [
@ -733,6 +734,7 @@ class PurchaseOrderLineItemList(
'order',
'status',
'complete_date',
'line',
]
search_fields = [
@ -1067,6 +1069,7 @@ class SalesOrderLineItemList(
'reference',
'sale_price',
'target_date',
'line',
]
ordering_field_aliases = {
@ -1074,6 +1077,7 @@ class SalesOrderLineItemList(
'part': 'part__name',
'IPN': 'part__IPN',
'order': 'order__reference',
'line': ['line', 'part__name'],
}
search_fields = ['part__name', 'quantity', 'reference']
@ -1720,9 +1724,11 @@ class ReturnOrderLineItemList(
'reference',
'target_date',
'received_date',
'line',
]
ordering_field_aliases = {
'line': ['line', 'item__part__name'],
'part': 'item__part__name',
'IPN': 'item__part__IPN',
'stock': ['item__quantity', 'item__serial_int', 'item__serial'],

View File

@ -0,0 +1,79 @@
# Generated by Django 5.2.12 on 2026-04-08 03:24
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("order", "0115_purchaseorder_updated_at_returnorder_updated_at_and_more"),
]
operations = [
migrations.AddField(
model_name="purchaseorderextraline",
name="line",
field=models.CharField(
blank=True,
default="",
help_text="Line number for this item (optional)",
max_length=20,
verbose_name="Line Number",
),
),
migrations.AddField(
model_name="purchaseorderlineitem",
name="line",
field=models.CharField(
blank=True,
default="",
help_text="Line number for this item (optional)",
max_length=20,
verbose_name="Line Number",
),
),
migrations.AddField(
model_name="returnorderextraline",
name="line",
field=models.CharField(
blank=True,
default="",
help_text="Line number for this item (optional)",
max_length=20,
verbose_name="Line Number",
),
),
migrations.AddField(
model_name="returnorderlineitem",
name="line",
field=models.CharField(
blank=True,
default="",
help_text="Line number for this item (optional)",
max_length=20,
verbose_name="Line Number",
),
),
migrations.AddField(
model_name="salesorderextraline",
name="line",
field=models.CharField(
blank=True,
default="",
help_text="Line number for this item (optional)",
max_length=20,
verbose_name="Line Number",
),
),
migrations.AddField(
model_name="salesorderlineitem",
name="line",
field=models.CharField(
blank=True,
default="",
help_text="Line number for this item (optional)",
max_length=20,
verbose_name="Line Number",
),
),
]

View File

@ -1793,6 +1793,15 @@ class OrderLineItem(InvenTree.models.InvenTreeMetadataModel):
if self.price:
return self.quantity * self.price
line = models.CharField(
max_length=20,
blank=True,
default='',
null=False,
verbose_name=_('Line Number'),
help_text=_('Line number for this item (optional)'),
)
reference = models.CharField(
max_length=100,
blank=True,

View File

@ -276,6 +276,7 @@ class AbstractLineItemSerializer(FilterableSerializerMixin, serializers.Serializ
"""Construct a set of fields for this serializer."""
return [
'pk',
'line',
'link',
'notes',
'order',
@ -309,6 +310,7 @@ class AbstractExtraLineSerializer(
"""Construct a set of fields for this serializer."""
return [
'pk',
'line',
'description',
'link',
'notes',
@ -1297,7 +1299,10 @@ class SalesOrderLineItemSerializer(
@register_importer()
class SalesOrderShipmentSerializer(
FilterableSerializerMixin, NotesFieldMixin, InvenTreeModelSerializer
DataImportExportSerializerMixin,
FilterableSerializerMixin,
NotesFieldMixin,
InvenTreeModelSerializer,
):
"""Serializer for the SalesOrderShipment class."""

View File

@ -5,7 +5,7 @@ import os
from django.conf import settings
import structlog
from jinja2 import Environment, select_autoescape
from jinja2.sandbox import SandboxedEnvironment
from common.settings import get_global_setting
@ -37,11 +37,7 @@ def compile_full_name_template(*args, **kwargs):
# Cache the template string
_part_full_name_template_string = template_string
env = Environment(
autoescape=select_autoescape(default_for_string=False, default=False),
variable_start_string='{{',
variable_end_string='}}',
)
env = SandboxedEnvironment(variable_start_string='{{', variable_end_string='}}')
# Compile the template
try:

View File

@ -210,6 +210,7 @@ class PluginInstall(CreateAPI):
queryset = PluginConfig.objects.none()
serializer_class = PluginSerializers.PluginConfigInstallSerializer
permission_classes = [InvenTree.permissions.IsSuperuserOrSuperScope]
def create(self, request, *args, **kwargs):
"""Install a plugin via the API."""

View File

@ -236,8 +236,8 @@ def install_plugin(url=None, packagename=None, user=None, version=None):
user: Optional user performing the installation
version: Optional version specifier
"""
if user and not user.is_staff:
raise ValidationError(_('Only staff users can administer plugins'))
if user and not user.is_superuser:
raise ValidationError(_('Only superuser accounts can administer plugins'))
if settings.PLUGINS_INSTALL_DISABLED:
raise ValidationError(_('Plugin installation is disabled'))
@ -269,6 +269,13 @@ def install_plugin(url=None, packagename=None, user=None, version=None):
if version:
full_pkg = f'{full_pkg}=={version}'
if not full_pkg:
raise ValidationError(_('No package name or URL provided for installation'))
# Sanitize the package name for installation
if any(c in full_pkg for c in ';&|`$()'):
raise ValidationError(_('Invalid characters in package name or URL'))
install_name.append(full_pkg)
ret = {}
@ -333,6 +340,9 @@ def uninstall_plugin(cfg: plugin.models.PluginConfig, user=None, delete_config=T
"""
from plugin.registry import registry
if user and not user.is_superuser:
raise ValidationError(_('Only superuser accounts can administer plugins'))
if settings.PLUGINS_INSTALL_DISABLED:
raise ValidationError(_('Plugin uninstalling is disabled'))

View File

@ -165,6 +165,9 @@ class PluginConfigInstallSerializer(serializers.Serializer):
version = data.get('version', None)
user = self.context['request'].user
if not user or not user.is_superuser:
raise ValidationError(_('Only superuser accounts can administer plugins'))
return install_plugin(
url=url, packagename=packagename, version=version, user=user
)
@ -266,10 +269,13 @@ class PluginUninstallSerializer(serializers.Serializer):
"""Uninstall the specified plugin."""
from plugin.installer import uninstall_plugin
user = self.context['request'].user
if not user or not user.is_superuser:
raise ValidationError(_('Only superuser accounts can administer plugins'))
return uninstall_plugin(
instance,
user=self.context['request'].user,
delete_config=validated_data.get('delete_config', True),
instance, user=user, delete_config=validated_data.get('delete_config', True)
)

View File

@ -63,6 +63,21 @@ class PluginDetailAPITest(PluginMixin, InvenTreeAPITestCase):
"""Test the plugin install command."""
url = reverse('api-plugin-install')
# Requires superuser permissions
self.user.is_superuser = False
self.user.save()
self.post(
url,
{'confirm': True, 'packagename': self.PKG_NAME},
expected_code=403,
max_query_time=30,
)
# Provide superuser permissions
self.user.is_superuser = True
self.user.save()
# invalid package name
data = self.post(
url,
@ -209,7 +224,7 @@ class PluginDetailAPITest(PluginMixin, InvenTreeAPITestCase):
test_plg.refresh_from_db()
self.assertTrue(test_plg.is_active())
def test_pluginCfg_delete(self):
def test_plugin_config_delete(self):
"""Test deleting a config."""
test_plg = self.plugin_confs.first()
assert test_plg is not None

View File

@ -11,11 +11,14 @@ from typing import Optional
from unittest import mock
from unittest.mock import patch
from django.contrib.auth.models import User
from django.core.exceptions import ValidationError
from django.test import TestCase, override_settings
import plugin.templatetags.plugin_extras as plugin_tags
from InvenTree.unit_test import PluginRegistryMixin, TestQueryMixin
from plugin import InvenTreePlugin, PluginMixinEnum
from plugin.installer import install_plugin
from plugin.registry import registry
from plugin.samples.integration.another_sample import (
NoIntegrationPlugin,
@ -326,6 +329,9 @@ class RegistryTests(TestQueryMixin, PluginRegistryMixin, TestCase):
def test_broken_samples(self):
"""Test that the broken samples trigger reloads."""
# Reset the registry to a known state
registry.errors = {}
# In the base setup there are no errors
self.assertEqual(len(registry.errors), 0)
@ -686,3 +692,40 @@ class RegistryTests(TestQueryMixin, PluginRegistryMixin, TestCase):
self.assertTrue(cfg.is_builtin())
self.assertFalse(cfg.is_package())
self.assertFalse(cfg.is_sample())
class InstallerTests(TestCase):
"""Tests for the plugin installer code."""
def test_plugin_install_errors(self):
"""Test error handling for plugin installation."""
# No data provided
with self.assertRaises(ValidationError) as e:
install_plugin()
self.assertIn(
'No package name or URL provided for installation', str(e.exception)
)
# Invalid package name
for pkg in [
'invalid;name',
'invalid&name',
'invalid|name',
'invalid`name',
'invalid$(name)',
]:
with self.assertRaises(ValidationError) as e:
install_plugin(packagename=pkg)
self.assertIn('Invalid characters in package name or URL', str(e.exception))
# Non superuser account
user = User.objects.create(username='my-user', is_superuser=False)
with self.assertRaises(ValidationError) as e:
install_plugin(user=user, packagename='some-package')
self.assertIn(
'Only superuser accounts can administer plugins', str(e.exception)
)

View File

@ -224,23 +224,27 @@ class LabelPrint(GenericAPIView):
return Response(DataOutputSerializer(output).data, status=201)
class LabelTemplateList(TemplatePermissionMixin, ListCreateAPI):
class LabelTemplateMixin:
"""Mixin class for label template API views."""
queryset = report.models.LabelTemplate.objects.all().prefetch_related('updated_by')
serializer_class = report.serializers.LabelTemplateSerializer
class LabelTemplateList(TemplatePermissionMixin, LabelTemplateMixin, ListCreateAPI):
"""API endpoint for viewing list of LabelTemplate objects."""
queryset = report.models.LabelTemplate.objects.all()
serializer_class = report.serializers.LabelTemplateSerializer
filterset_class = LabelFilter
filter_backends = [DjangoFilterBackend, InvenTreeSearchFilter]
search_fields = ['name', 'description']
ordering_fields = ['name', 'enabled']
class LabelTemplateDetail(TemplatePermissionMixin, RetrieveUpdateDestroyAPI):
class LabelTemplateDetail(
TemplatePermissionMixin, LabelTemplateMixin, RetrieveUpdateDestroyAPI
):
"""Detail API endpoint for label template model."""
queryset = report.models.LabelTemplate.objects.all()
serializer_class = report.serializers.LabelTemplateSerializer
class ReportPrint(GenericAPIView):
"""API endpoint for printing reports."""
@ -300,23 +304,27 @@ class ReportPrint(GenericAPIView):
return Response(DataOutputSerializer(output).data, status=201)
class ReportTemplateList(TemplatePermissionMixin, ListCreateAPI):
class ReportTemplateMixin:
"""Mixin class for report template API views."""
queryset = report.models.ReportTemplate.objects.all().prefetch_related('updated_by')
serializer_class = report.serializers.ReportTemplateSerializer
class ReportTemplateList(TemplatePermissionMixin, ReportTemplateMixin, ListCreateAPI):
"""API endpoint for viewing list of ReportTemplate objects."""
queryset = report.models.ReportTemplate.objects.all()
serializer_class = report.serializers.ReportTemplateSerializer
filterset_class = ReportFilter
filter_backends = [DjangoFilterBackend, InvenTreeSearchFilter]
search_fields = ['name', 'description']
ordering_fields = ['name', 'enabled']
class ReportTemplateDetail(TemplatePermissionMixin, RetrieveUpdateDestroyAPI):
class ReportTemplateDetail(
TemplatePermissionMixin, ReportTemplateMixin, RetrieveUpdateDestroyAPI
):
"""Detail API endpoint for report template model."""
queryset = report.models.ReportTemplate.objects.all()
serializer_class = report.serializers.ReportTemplateSerializer
class ReportSnippetList(TemplatePermissionMixin, ListCreateAPI):
"""API endpoint for listing ReportSnippet objects."""

View File

@ -0,0 +1,64 @@
# Generated by Django 5.2.12 on 2026-04-09 01:13
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("report", "0031_reporttemplate_merge"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddField(
model_name="labeltemplate",
name="updated",
field=models.DateTimeField(
blank=True,
default=None,
help_text="Timestamp of last update",
null=True,
verbose_name="Updated",
),
),
migrations.AddField(
model_name="labeltemplate",
name="updated_by",
field=models.ForeignKey(
blank=True,
help_text="User who last updated this object",
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="%(class)s_updated",
to=settings.AUTH_USER_MODEL,
verbose_name="Update By",
),
),
migrations.AddField(
model_name="reporttemplate",
name="updated",
field=models.DateTimeField(
blank=True,
default=None,
help_text="Timestamp of last update",
null=True,
verbose_name="Updated",
),
),
migrations.AddField(
model_name="reporttemplate",
name="updated_by",
field=models.ForeignKey(
blank=True,
help_text="User who last updated this object",
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="%(class)s_updated",
to=settings.AUTH_USER_MODEL,
verbose_name="Update By",
),
),
]

View File

@ -27,7 +27,7 @@ import InvenTree.helpers
import InvenTree.models
import report.helpers
import report.validators
from common.models import DataOutput, RenderChoices
from common.models import DataOutput, RenderChoices, UpdatedUserMixin
from common.settings import get_global_setting
from InvenTree.helpers_model import get_base_url
from InvenTree.models import MetadataMixin
@ -189,7 +189,9 @@ class ReportContextExtension(TypedDict):
merge: bool
class ReportTemplateBase(MetadataMixin, InvenTree.models.InvenTreeModel):
class ReportTemplateBase(
MetadataMixin, UpdatedUserMixin, InvenTree.models.InvenTreeModel
):
"""Base class for reports, labels."""
class ModelChoices(RenderChoices):
@ -205,8 +207,9 @@ class ReportTemplateBase(MetadataMixin, InvenTree.models.InvenTreeModel):
def save(self, *args, **kwargs):
"""Perform additional actions when the report is saved."""
# Increment revision number
self.revision += 1
if kwargs.pop('increment_revision', True):
# Increment revision number
self.revision += 1
super().save()

View File

@ -11,6 +11,7 @@ from InvenTree.serializers import (
InvenTreeAttachmentSerializerField,
InvenTreeModelSerializer,
)
from users.serializers import UserSerializer
class ReportSerializerBase(InvenTreeModelSerializer):
@ -27,6 +28,21 @@ class ReportSerializerBase(InvenTreeModelSerializer):
if len(self.fields['model_type'].choices) == 0:
self.fields['model_type'].choices = report.helpers.report_model_options()
def save(self, **kwargs):
"""Override the save method to capture the user information."""
user = self.context.get('request').user
if not user or not user.is_authenticated:
raise PermissionError(
_('User must be authenticated to save report templates')
)
instance = super().save(**kwargs)
instance.updated_by = user
instance.save(increment_revision=False)
return instance
@staticmethod
def base_fields():
"""Base serializer field set."""
@ -41,6 +57,9 @@ class ReportSerializerBase(InvenTreeModelSerializer):
'enabled',
'revision',
'attach_to_model',
'updated',
'updated_by',
'updated_by_detail',
]
template = InvenTreeAttachmentSerializerField(required=True)
@ -56,6 +75,10 @@ class ReportSerializerBase(InvenTreeModelSerializer):
allow_null=False,
)
updated_by_detail = UserSerializer(
source='updated_by', read_only=True, allow_null=True, many=False
)
class ReportTemplateSerializer(ReportSerializerBase):
"""Serializer class for report template model."""

View File

@ -473,10 +473,10 @@ def parameter(
Arguments:
instance: A Model object
parameter_name: The name of the parameter to retrieve
parameter_name: The name of the parameter to retrieve (case insensitive)
Returns:
A Parameter object, or None if not found
A Parameter object, or the provided default value if not found
"""
if instance is None:
raise ValueError('parameter tag requires a valid Model instance')
@ -484,12 +484,46 @@ def parameter(
if not isinstance(instance, Model) or not hasattr(instance, 'parameters'):
raise TypeError("parameter tag requires a Model with 'parameters' attribute")
return (
instance.parameters
# First try with exact match
if (
parameter := instance.parameters
.prefetch_related('template')
.filter(template__name=parameter_name)
.first()
)
):
return parameter
# Next, try with case-insensitive match
if (
parameter := instance.parameters
.prefetch_related('template')
.filter(template__name__iexact=parameter_name)
.first()
):
return parameter
return None
@register.simple_tag()
def parameter_value(
instance: Model, parameter_name: str, backup_value: Optional[Any] = None
) -> str:
"""Return the value of a Parameter for the given part and parameter name.
Arguments:
instance: A Model object
parameter_name: The name of the parameter to retrieve (case insensitive)
backup_value: A backup value to return if the parameter is not found
Returns:
The value of the Parameter, or the backup_value if not found
"""
if param := parameter(instance, parameter_name):
return param.data
# If the matching parameter is not found, return the backup value
return backup_value
@register.simple_tag()

View File

@ -238,8 +238,21 @@ class ApiTokenSerializer(InvenTreeModelSerializer):
def validate(self, data):
"""Validate the data for the serializer."""
request_user = self.context['request'].user
if not request_user:
raise serializers.ValidationError(
_('User must be authenticated')
) # pragma: no cover
if 'user' not in data:
data['user'] = self.context['request'].user
data['user'] = request_user
# Only superusers can create tokens for other users
if data['user'] != request_user and not request_user.is_superuser:
raise serializers.ValidationError(
_('Only a superuser can create a token for another user')
)
return super().validate(data)
user_detail = UserSerializer(source='user', read_only=True)
@ -380,6 +393,9 @@ class MeUserSerializer(ExtendedUserSerializer):
but ensures that certain fields are read-only.
"""
# Remove the 'group_ids' field, as this is not relevant for the 'me' endpoint
fields = [f for f in ExtendedUserSerializer.Meta.fields if f != 'group_ids']
read_only_fields = [
*ExtendedUserSerializer.Meta.read_only_fields,
'is_active',
@ -389,6 +405,28 @@ class MeUserSerializer(ExtendedUserSerializer):
profile = UserProfileSerializer(many=False, read_only=True)
# Redefine the fields from ExtendedUserSerializer, to ensure they are marked as read-only
is_staff = serializers.BooleanField(
label=_('Staff'),
help_text=_('Does this user have staff permissions'),
required=False,
read_only=True,
)
is_superuser = serializers.BooleanField(
label=_('Superuser'),
help_text=_('Is this user a superuser'),
required=False,
read_only=True,
)
is_active = serializers.BooleanField(
label=_('Active'),
help_text=_('Is this user account active'),
required=False,
read_only=True,
)
def make_random_password(length=14):
"""Generate a random password of given length."""

View File

@ -44,7 +44,7 @@ class UserAPITests(InvenTreeAPITestCase):
)
def test_api_url(self):
"""Test the 'api_url attribute in related API endpoints.
"""Test the 'api_url' attribute in related API endpoints.
Ref: https://github.com/inventree/InvenTree/pull/10182
"""
@ -129,6 +129,19 @@ class UserAPITests(InvenTreeAPITestCase):
self.assertIn('Only a superuser can adjust this field', str(response.data))
# Try again, but with superuser access
self.user.is_superuser = True
self.user.save()
response = self.post(
url,
data={**data, 'username': 'Superuser', 'is_superuser': True},
expected_code=201,
)
self.assertEqual(response.data['username'], 'Superuser')
self.assertEqual(response.data['is_superuser'], True)
def test_user_detail(self):
"""Test the UserDetail API endpoint."""
user = User.objects.first()
@ -143,7 +156,7 @@ class UserAPITests(InvenTreeAPITestCase):
self.get(url, expected_code=200)
# Let's try to update the user
data = {'is_active': False, 'is_staff': False}
data = {'is_active': True, 'is_staff': False}
self.patch(url, data=data, expected_code=403)
@ -158,6 +171,26 @@ class UserAPITests(InvenTreeAPITestCase):
self.patch(url, data=data, expected_code=200)
# Try to change the "is_superuser" field - only a superuser can do this
data['is_superuser'] = True
response = self.patch(url, data=data, expected_code=403)
self.assertIn(
'You do not have permission to perform this action', str(response.data)
)
self.user.is_staff = True
self.user.is_superuser = True
self.user.save()
for val in [True, False]:
data['is_staff'] = True
data['is_superuser'] = val
response = self.patch(url, data=data, expected_code=200)
self.assertEqual(response.data['is_superuser'], val)
self.assertEqual(response.data['is_staff'], True)
# Try again, but logged out - expect no access to the endpoint
self.logout()
self.get(url, expected_code=401)
@ -229,6 +262,71 @@ class UserAPITests(InvenTreeAPITestCase):
self.assertEqual(len(data['permissions']), len(perms) + len(build_perms))
def test_me_endpoint(self):
"""Test against the users /me/ endpoint."""
url = reverse('api-user-me')
# Test endpoint options
response = self.options(url, expected_code=200)
# Check that particular fields are present, and have the correct attributes
fields = response.data['actions']['PUT']
for name in [
'pk',
'username',
'email',
'groups',
'is_active',
'is_staff',
'is_superuser',
]:
self.assertIn(name, fields)
for name in ['is_active', 'is_staff', 'is_superuser']:
self.assertTrue(fields[name]['read_only'])
# Perform a GET request against the endpoint
response = self.get(url, expected_code=200)
for field in [
'pk',
'username',
'email',
'is_active',
'is_staff',
'is_superuser',
]:
self.assertIn(field, response.data)
# Change their own username
for name in ['Henry', 'Sally']:
response = self.patch(url, data={'username': name}, expected_code=200)
self.assertEqual(response.data['username'], name)
# Defined starting point for the user
for v in [True, False]:
self.user.is_staff = v
self.user.is_superuser = v
self.user.save()
for key in ['is_staff', 'is_superuser']:
for val in [True, False]:
response = self.patch(url, data={key: val}, expected_code=200)
# Check that the field was *NOT CHANGED*
self.assertEqual(response.data[key], v)
# Ensure we cannot change the "is_active" field either
response = self.patch(url, data={'is_active': False}, expected_code=200)
self.assertEqual(response.data['is_active'], True)
self.user.is_active = False
self.user.save()
# User cannot fetch their own details if they are not active
response = self.get(url, expected_code=401)
class SuperuserAPITests(InvenTreeAPITestCase):
"""Tests for user API endpoints that require superuser rights."""
@ -245,7 +343,7 @@ class SuperuserAPITests(InvenTreeAPITestCase):
resp = self.put(url, {'password': 1}, expected_code=400)
self.assertContains(resp, 'This password is too short', status_code=400)
# now with overwerite
# now with overwrite
resp = self.put(
url, {'password': 1, 'override_warning': True}, expected_code=200
)
@ -259,6 +357,8 @@ class SuperuserAPITests(InvenTreeAPITestCase):
class UserTokenTests(InvenTreeAPITestCase):
"""Tests for user token functionality."""
fixtures = ['users']
def test_token_generation(self):
"""Test user token generation."""
url = reverse('api-token')
@ -397,8 +497,30 @@ class UserTokenTests(InvenTreeAPITestCase):
self.client.logout()
self.get(reverse('api-token'), expected_code=401)
def test_token_security(self):
"""Test that token generation is only available to users with the correct permissions."""
url = reverse('api-token-list')
class GroupDetialTests(InvenTreeAPITestCase):
# Try to generate a token for a different user (should fail)
response = self.post(url, data={'name': 'test', 'user': 1}, expected_code=400)
self.assertIn(
'Only a superuser can create a token for another user', str(response.data)
)
# there should be no tokens created
self.assertEqual(ApiToken.objects.count(), 0)
# now with superuser permissions
self.user.is_superuser = True
self.user.save()
response = self.post(url, data={'name': 'test', 'user': 1}, expected_code=201)
self.assertIn('token', response.data)
self.assertEqual(ApiToken.objects.count(), 1)
class GroupDetailTests(InvenTreeAPITestCase):
"""Tests for the GroupDetail API endpoint."""
fixtures = ['users']

View File

@ -101,16 +101,16 @@ blessed==1.34.0 \
# via
# -c src/backend/requirements.txt
# -r src/backend/requirements.in
boto3==1.42.76 \
--hash=sha256:63c6779c814847016b89ae1b72ed968f8a63d80e589ba337511aa6fc1b59585e \
--hash=sha256:aa2b1973eee8973a9475d24bb579b1dee7176595338d4e4f7880b5c6189b8814
boto3==1.42.77 \
--hash=sha256:95eb3ef693068586f70ca3f29c43701c34a9a73d0df413ea7eaff138efa4a6b9 \
--hash=sha256:c6d9b05e5b86767d4c6ef762f155c891366e5951162f71d030e109fe531f4fd9
# via
# -c src/backend/requirements.txt
# django-anymail
# django-storages
botocore==1.42.76 \
--hash=sha256:151e714ae3c32f68ea0b4dc60751401e03f84a87c6cf864ea0ee64aa10eb4607 \
--hash=sha256:c553fa0ae29e36a5c407f74da78b78404b81b74b15fb62bf640a3cd9385f0874
botocore==1.42.77 \
--hash=sha256:807bc2c3825bec6f025506ceeba5f7f111a00de8d58f35c679ee16c8ff6e7b10 \
--hash=sha256:cbb0ac410fab4aa0839a521329f970b271ec298d67465ed7fa7d095c0dad9f48
# via
# -c src/backend/requirements.txt
# boto3
@ -520,9 +520,9 @@ defusedxml==0.7.1 \
# via
# -c src/backend/requirements.txt
# python3-openid
django==5.2.12 \
--hash=sha256:4853482f395c3a151937f6991272540fcbf531464f254a347bf7c89f53c8cff7 \
--hash=sha256:6b809af7165c73eff5ce1c87fdae75d4da6520d6667f86401ecf55b681eb1eeb
django==5.2.13 \
--hash=sha256:5788fce61da23788a8ce6f02583765ab060d396720924789f97fa42119d37f7a \
--hash=sha256:a31589db5188d074c63f0945c3888fad104627dfcc236fb2b97f71f89da33bc4
# via
# -c src/backend/requirements.txt
# -r src/backend/requirements.in
@ -886,9 +886,9 @@ fonttools[woff]==4.62.1 \
# via
# -c src/backend/requirements.txt
# weasyprint
googleapis-common-protos==1.73.0 \
--hash=sha256:778d07cd4fbeff84c6f7c72102f0daf98fa2bfd3fa8bea426edc545588da0b5a \
--hash=sha256:dfdaaa2e860f242046be561e6d6cb5c5f1541ae02cfbcb034371aadb2942b4e8
googleapis-common-protos==1.73.1 \
--hash=sha256:13114f0e9d2391756a0194c3a8131974ed7bffb06086569ba193364af59163b6 \
--hash=sha256:e51f09eb0a43a8602f5a915870972e6b4a394088415c79d79605a46d8e826ee8
# via
# -c src/backend/requirements.txt
# opentelemetry-exporter-otlp-proto-grpc
@ -959,9 +959,9 @@ grpcio==1.78.0 \
# -c src/backend/requirements.txt
# -r src/backend/requirements.in
# opentelemetry-exporter-otlp-proto-grpc
gunicorn==25.2.0 \
--hash=sha256:10bd7adb36d44945d97d0a1fdf9a0fb086ae9c7b39e56b4dece8555a6bf4a09c \
--hash=sha256:88f5b444d0055bf298435384af7294f325e2273fd37ba9f9ff7b98e0a1e5dfdc
gunicorn==25.3.0 \
--hash=sha256:cacea387dab08cd6776501621c295a904fe8e3b7aae9a1a3cbb26f4e7ed54660 \
--hash=sha256:f74e1b2f9f76f6cd1ca01198968bd2dd65830edc24b6e8e4d78de8320e2fe889
# via
# -c src/backend/requirements.txt
# -r src/backend/requirements.in
@ -1027,9 +1027,9 @@ jsonschema-specifications==2025.9.1 \
# via
# -c src/backend/requirements.txt
# jsonschema
jwcrypto==1.5.6 \
--hash=sha256:150d2b0ebbdb8f40b77f543fb44ffd2baeff48788be71f67f03566692fd55789 \
--hash=sha256:771a87762a0c081ae6166958a954f80848820b2ab066937dc8b8379d65b1b039
jwcrypto==1.5.7 \
--hash=sha256:70204d7cca406eda8c82352e3c41ba2d946610dafd19e54403f0a1f4f18633c6 \
--hash=sha256:729463fefe28b6de5cf1ebfda3e94f1a1b41d2799148ef98a01cb9678ebe2bb0
# via
# -c src/backend/requirements.txt
# django-oauth-toolkit

View File

@ -417,9 +417,9 @@ distlib==0.4.0 \
--hash=sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16 \
--hash=sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d
# via virtualenv
django==5.2.12 \
--hash=sha256:4853482f395c3a151937f6991272540fcbf531464f254a347bf7c89f53c8cff7 \
--hash=sha256:6b809af7165c73eff5ce1c87fdae75d4da6520d6667f86401ecf55b681eb1eeb
django==5.2.13 \
--hash=sha256:5788fce61da23788a8ce6f02583765ab060d396720924789f97fa42119d37f7a \
--hash=sha256:a31589db5188d074c63f0945c3888fad104627dfcc236fb2b97f71f89da33bc4
# via
# -c src/backend/requirements-3.14.txt
# -c src/backend/requirements.txt
@ -578,9 +578,9 @@ pytest-django==4.12.0 \
--hash=sha256:3ff300c49f8350ba2953b90297d23bf5f589db69545f56f1ec5f8cff5da83e85 \
--hash=sha256:df94ec819a83c8979c8f6de13d9cdfbe76e8c21d39473cfe2b40c9fc9be3c758
# via -r src/backend/requirements-dev.in
python-discovery==1.2.0 \
--hash=sha256:1e108f1bbe2ed0ef089823d28805d5ad32be8e734b86a5f212bf89b71c266e4a \
--hash=sha256:7d33e350704818b09e3da2bd419d37e21e7c30db6e0977bb438916e06b41b5b1
python-discovery==1.2.1 \
--hash=sha256:180c4d114bff1c32462537eac5d6a332b768242b76b69c0259c7d14b1b680c9e \
--hash=sha256:b6a957b24c1cd79252484d3566d1b49527581d46e789aaf43181005e56201502
# via virtualenv
pyyaml==6.0.3 \
--hash=sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c \

View File

@ -412,9 +412,9 @@ distlib==0.4.0 \
--hash=sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16 \
--hash=sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d
# via virtualenv
django==5.2.12 \
--hash=sha256:4853482f395c3a151937f6991272540fcbf531464f254a347bf7c89f53c8cff7 \
--hash=sha256:6b809af7165c73eff5ce1c87fdae75d4da6520d6667f86401ecf55b681eb1eeb
django==5.2.13 \
--hash=sha256:5788fce61da23788a8ce6f02583765ab060d396720924789f97fa42119d37f7a \
--hash=sha256:a31589db5188d074c63f0945c3888fad104627dfcc236fb2b97f71f89da33bc4
# via
# -c src/backend/requirements.txt
# django-silk
@ -568,9 +568,9 @@ pytest-django==4.12.0 \
--hash=sha256:3ff300c49f8350ba2953b90297d23bf5f589db69545f56f1ec5f8cff5da83e85 \
--hash=sha256:df94ec819a83c8979c8f6de13d9cdfbe76e8c21d39473cfe2b40c9fc9be3c758
# via -r src/backend/requirements-dev.in
python-discovery==1.2.0 \
--hash=sha256:1e108f1bbe2ed0ef089823d28805d5ad32be8e734b86a5f212bf89b71c266e4a \
--hash=sha256:7d33e350704818b09e3da2bd419d37e21e7c30db6e0977bb438916e06b41b5b1
python-discovery==1.2.1 \
--hash=sha256:180c4d114bff1c32462537eac5d6a332b768242b76b69c0259c7d14b1b680c9e \
--hash=sha256:b6a957b24c1cd79252484d3566d1b49527581d46e789aaf43181005e56201502
# via virtualenv
pyyaml==6.0.3 \
--hash=sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c \

View File

@ -95,15 +95,15 @@ blessed==1.34.0 \
--hash=sha256:3d17468c3d47e11ed8d6ca3da1270b8aba8ac8bd0a55a1f8189e4d04f223a1f0 \
--hash=sha256:eb403f9ce2efab633bd1e7a05fbb35b979a7b9f213ddc41478dd55dd6999e8bf
# via -r src/backend/requirements.in
boto3==1.42.76 \
--hash=sha256:63c6779c814847016b89ae1b72ed968f8a63d80e589ba337511aa6fc1b59585e \
--hash=sha256:aa2b1973eee8973a9475d24bb579b1dee7176595338d4e4f7880b5c6189b8814
boto3==1.42.77 \
--hash=sha256:95eb3ef693068586f70ca3f29c43701c34a9a73d0df413ea7eaff138efa4a6b9 \
--hash=sha256:c6d9b05e5b86767d4c6ef762f155c891366e5951162f71d030e109fe531f4fd9
# via
# django-anymail
# django-storages
botocore==1.42.76 \
--hash=sha256:151e714ae3c32f68ea0b4dc60751401e03f84a87c6cf864ea0ee64aa10eb4607 \
--hash=sha256:c553fa0ae29e36a5c407f74da78b78404b81b74b15fb62bf640a3cd9385f0874
botocore==1.42.77 \
--hash=sha256:807bc2c3825bec6f025506ceeba5f7f111a00de8d58f35c679ee16c8ff6e7b10 \
--hash=sha256:cbb0ac410fab4aa0839a521329f970b271ec298d67465ed7fa7d095c0dad9f48
# via
# boto3
# s3transfer
@ -501,9 +501,9 @@ defusedxml==0.7.1 \
--hash=sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69 \
--hash=sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61
# via python3-openid
django==5.2.12 \
--hash=sha256:4853482f395c3a151937f6991272540fcbf531464f254a347bf7c89f53c8cff7 \
--hash=sha256:6b809af7165c73eff5ce1c87fdae75d4da6520d6667f86401ecf55b681eb1eeb
django==5.2.13 \
--hash=sha256:5788fce61da23788a8ce6f02583765ab060d396720924789f97fa42119d37f7a \
--hash=sha256:a31589db5188d074c63f0945c3888fad104627dfcc236fb2b97f71f89da33bc4
# via
# -r src/backend/requirements.in
# django-allauth
@ -783,9 +783,9 @@ fonttools[woff]==4.62.1 \
--hash=sha256:ef46db46c9447103b8f3ff91e8ba009d5fe181b1920a83757a5762551e32bb68 \
--hash=sha256:fa1d16210b6b10a826d71bed68dd9ec24a9e218d5a5e2797f37c573e7ec215ca
# via weasyprint
googleapis-common-protos==1.73.0 \
--hash=sha256:778d07cd4fbeff84c6f7c72102f0daf98fa2bfd3fa8bea426edc545588da0b5a \
--hash=sha256:dfdaaa2e860f242046be561e6d6cb5c5f1541ae02cfbcb034371aadb2942b4e8
googleapis-common-protos==1.73.1 \
--hash=sha256:13114f0e9d2391756a0194c3a8131974ed7bffb06086569ba193364af59163b6 \
--hash=sha256:e51f09eb0a43a8602f5a915870972e6b4a394088415c79d79605a46d8e826ee8
# via
# opentelemetry-exporter-otlp-proto-grpc
# opentelemetry-exporter-otlp-proto-http
@ -854,9 +854,9 @@ grpcio==1.78.0 \
# via
# -r src/backend/requirements.in
# opentelemetry-exporter-otlp-proto-grpc
gunicorn==25.2.0 \
--hash=sha256:10bd7adb36d44945d97d0a1fdf9a0fb086ae9c7b39e56b4dece8555a6bf4a09c \
--hash=sha256:88f5b444d0055bf298435384af7294f325e2273fd37ba9f9ff7b98e0a1e5dfdc
gunicorn==25.3.0 \
--hash=sha256:cacea387dab08cd6776501621c295a904fe8e3b7aae9a1a3cbb26f4e7ed54660 \
--hash=sha256:f74e1b2f9f76f6cd1ca01198968bd2dd65830edc24b6e8e4d78de8320e2fe889
# via -r src/backend/requirements.in
icalendar==7.0.3 \
--hash=sha256:8c9fea6d3a89671bba8b6938d8565b4d0ec465c6a2796ef0f92790dcb9e627cd \
@ -902,9 +902,9 @@ jsonschema-specifications==2025.9.1 \
--hash=sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe \
--hash=sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d
# via jsonschema
jwcrypto==1.5.6 \
--hash=sha256:150d2b0ebbdb8f40b77f543fb44ffd2baeff48788be71f67f03566692fd55789 \
--hash=sha256:771a87762a0c081ae6166958a954f80848820b2ab066937dc8b8379d65b1b039
jwcrypto==1.5.7 \
--hash=sha256:70204d7cca406eda8c82352e3c41ba2d946610dafd19e54403f0a1f4f18633c6 \
--hash=sha256:729463fefe28b6de5cf1ebfda3e94f1a1b41d2799148ef98a01cb9678ebe2bb0
# via django-oauth-toolkit
lxml==6.0.2 \
--hash=sha256:058027e261afed589eddcfe530fcc6f3402d7fd7e89bfd0532df82ebc1563dba \

View File

@ -2,6 +2,14 @@
This file contains historical changelog information for the InvenTree UI components library.
### 0.10.1 - April 2026
Allows plugins to specify custom model rendering functions within the data import wizard, allowing import of data models not defined in the core InvenTree codebase.
### 0.10.0 - April 2026
Exposes the `importer` object to the plugin context, allow plugins to initialize a data import session using the data importer wizard.
### 0.9.0 - March 2026
Exposes the `useMonitorBackgroundTask` hook, which allows plugins to monitor the status of a background task and display notifications when the task is complete. This is useful for plugins that offload long-running tasks to the background and want to provide feedback to the user when the task is complete.

View File

@ -48,6 +48,13 @@ export type InvenTreeFormsContext = {
stockActions: StockAdjustmentFormsContext;
};
export type ImporterDrawerContext = {
open: (sessionId: number, options?: { onClose?: () => void }) => void;
close: () => void;
isOpen: () => boolean;
sessionId: () => number | null;
};
/**
* A set of properties which are passed to a plugin,
* for rendering an element in the user interface.
@ -59,6 +66,8 @@ export type InvenTreeFormsContext = {
* @param globalSettings - The global settings (see ../states/SettingsState.tsx)
* @param navigate - The navigation function (see react-router-dom)
* @param theme - The current Mantine theme
* @param forms - A set of functions for opening various API forms (see ../components/Forms.tsx)
* @param importer - A set of functions for controlling the global importer drawer (see ../components/importer/GlobalImporterDrawer.tsx)
* @param colorScheme - The current Mantine color scheme (e.g. 'light' / 'dark')
* @param host - The current host URL
* @param i18n - The i18n instance for translations (from @lingui/core)
@ -87,6 +96,7 @@ export type InvenTreePluginContext = {
navigate: NavigateFunction;
theme: MantineTheme;
forms: InvenTreeFormsContext;
importer: ImporterDrawerContext;
colorScheme: MantineColorScheme;
model?: ModelType | string;
id?: string | number | null;

View File

@ -1,7 +1,7 @@
{
"name": "@inventreedb/ui",
"description": "UI components for the InvenTree project",
"version": "0.9.0",
"version": "0.10.1",
"private": false,
"type": "module",
"license": "MIT",
@ -130,7 +130,7 @@
"rollup": "^4.59.0",
"rollup-plugin-license": "^3.7.0",
"typescript": "^5.9.3",
"vite": "7.1.11",
"vite": "7.3.2",
"vite-plugin-babel-macros": "^1.0.6",
"vite-plugin-dts": "^4.5.4",
"vite-plugin-externals": "^0.6.2",

View File

@ -0,0 +1,22 @@
import { useImporterState } from '../../states/ImporterState';
import ImporterDrawer from './ImporterDrawer';
export default function GlobalImporterDrawer() {
const isOpen = useImporterState((state) => state.isOpen);
const sessionId = useImporterState((state) => state.sessionId);
const customFields = useImporterState((state) => state.customFields);
const closeImporter = useImporterState((state) => state.closeImporter);
if (!isOpen || sessionId === null) {
return null;
}
return (
<ImporterDrawer
sessionId={sessionId}
opened={isOpen}
onClose={closeImporter}
customFields={customFields}
/>
);
}

View File

@ -36,10 +36,12 @@ import { RenderRemoteInstance } from '../render/Instance';
function ImporterDataCell({
session,
column,
fieldDef,
row,
onEdit
}: Readonly<{
session: ImportSessionState;
fieldDef: any;
column: any;
row: any;
onEdit?: () => void;
@ -63,22 +65,24 @@ function ImporterDataCell({
}, [row.errors, column.field]);
const cellValue: ReactNode = useMemo(() => {
const field_def = session.availableFields[column.field];
// const field_def = session.availableFields[column.field];
if (!row?.data) {
return '-';
}
switch (field_def?.type) {
switch (fieldDef?.type) {
case 'boolean':
return (
<YesNoButton value={row.data ? row.data[column.field] : false} />
);
case 'related field':
if (field_def.model && row.data[column.field]) {
if (fieldDef.model && row.data[column.field]) {
return (
<RenderRemoteInstance
model={field_def.model}
model={fieldDef.model}
modelRenderer={fieldDef.modelRenderer}
modelUrl={fieldDef.api_url}
pk={row.data[column.field]}
/>
);
@ -95,7 +99,7 @@ function ImporterDataCell({
}
return value;
}, [row.data, column.field, session.availableFields]);
}, [fieldDef, row.data, column.field, session.availableFields]);
const cellValid: boolean = useMemo(
() => cellErrors.length == 0,
@ -127,9 +131,11 @@ function ImporterDataCell({
}
export default function ImporterDataSelector({
session
session,
customFields
}: Readonly<{
session: ImportSessionState;
customFields?: ApiFormFieldSet | null;
}>) {
const api = useApi();
const table = useTable('dataimporter');
@ -142,9 +148,11 @@ export default function ImporterDataSelector({
for (const field of selectedFieldNames) {
// Find the field definition in session.availableFields
const fieldDef = session.availableFields[field];
if (fieldDef) {
const customField = customFields?.[field] ?? null;
if (fieldDef || customField) {
// Construct field filters based on session field filters
let filters = fieldDef.filters ?? {};
let filters = fieldDef?.filters ?? {};
if (session.fieldFilters[field]) {
filters = {
@ -159,15 +167,30 @@ export default function ImporterDataSelector({
fields[field] = {
...fieldDef,
...customField,
field_type: fieldDef.type,
description: fieldDef.help_text,
filters: filters
};
console.log('Defined Field:', field);
console.log({
...fieldDef,
...customField,
field_type: fieldDef.type,
description: fieldDef.help_text,
filters: filters
});
}
}
return fields;
}, [selectedFieldNames, session.availableFields, session.fieldFilters]);
}, [
customFields,
selectedFieldNames,
session.availableFields,
session.fieldFilters
]);
const importData = useCallback(
(rows: number[]) => {
@ -322,6 +345,10 @@ export default function ImporterDataSelector({
column={column}
row={row}
onEdit={() => editCell(row, column)}
fieldDef={{
...session.availableFields[column.field],
...customFields?.[column.field]
}}
/>
);
}
@ -330,7 +357,7 @@ export default function ImporterDataSelector({
];
return columns;
}, [session]);
}, [session, customFields]);
const rowActions = useCallback(
(record: any): RowAction[] => {

View File

@ -15,7 +15,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
import { apiUrl } from '@lib/functions/Api';
import type { ApiFormFieldType } from '@lib/types/Forms';
import type { ApiFormFieldSet, ApiFormFieldType } from '@lib/types/Forms';
import { useDebouncedValue } from '@mantine/hooks';
import { useApi } from '../../contexts/ApiContext';
import type { ImportSessionState } from '../../hooks/UseImportSession';
@ -77,9 +77,11 @@ function ImporterColumn({
function ImporterDefaultField({
fieldName,
customField,
session
}: {
fieldName: string;
customField?: ApiFormFieldType | null;
session: ImportSessionState;
}) {
const api = useApi();
@ -162,8 +164,15 @@ function ImporterDefaultField({
};
}
if (customField) {
def = {
...def,
...customField
};
}
return def;
}, [fieldName, session.availableFields, session.fieldDefaults]);
}, [fieldName, session.availableFields, session.fieldDefaults, customField]);
return (
fieldDef && <StandaloneField fieldDefinition={fieldDef} hideLabels={true} />
@ -173,10 +182,12 @@ function ImporterDefaultField({
function ImporterColumnTableRow({
session,
column,
customField,
options
}: Readonly<{
session: ImportSessionState;
column: any;
customField?: ApiFormFieldType | null;
options: any;
}>) {
return (
@ -200,16 +211,22 @@ function ImporterColumnTableRow({
<ImporterColumn column={column} options={options} />
</Table.Td>
<Table.Td>
<ImporterDefaultField fieldName={column.field} session={session} />
<ImporterDefaultField
fieldName={column.field}
session={session}
customField={customField}
/>
</Table.Td>
</Table.Tr>
);
}
export default function ImporterColumnSelector({
session
session,
customFields
}: Readonly<{
session: ImportSessionState;
customFields?: ApiFormFieldSet | null;
}>) {
const api = useApi();
@ -279,6 +296,7 @@ export default function ImporterColumnSelector({
session={session}
column={column}
options={columnOptions}
customField={customFields?.[column.field] || null}
/>
);
})}

View File

@ -14,6 +14,7 @@ import { IconCheck, IconExclamationCircle } from '@tabler/icons-react';
import { type ReactNode, useMemo } from 'react';
import { ModelType } from '@lib/enums/ModelType';
import type { ApiFormFieldSet } from '@lib/index';
import { useImportSession } from '../../hooks/UseImportSession';
import useStatusCodes from '../../hooks/UseStatusCodes';
import { StylishText } from '../items/StylishText';
@ -51,10 +52,12 @@ function ImportDrawerStepper({
export default function ImporterDrawer({
sessionId,
customFields,
opened,
onClose
}: Readonly<{
sessionId: number;
customFields?: ApiFormFieldSet | null;
opened: boolean;
onClose: () => void;
}>) {
@ -93,9 +96,16 @@ export default function ImporterDrawer({
switch (session.status) {
case importSessionStatus.MAPPING:
return <ImporterColumnSelector session={session} />;
return (
<ImporterColumnSelector
session={session}
customFields={customFields}
/>
);
case importSessionStatus.PROCESSING:
return <ImporterDataSelector session={session} />;
return (
<ImporterDataSelector session={session} customFields={customFields} />
);
case importSessionStatus.COMPLETE:
return (
<Stack gap='xs'>

View File

@ -21,6 +21,7 @@ import {
} from '../../states/SettingsStates';
import { useUserState } from '../../states/UserState';
import { Boundary } from '../Boundary';
import GlobalImporterDrawer from '../importer/GlobalImporterDrawer';
import { ApiIcon } from '../items/ApiIcon';
import { useInvenTreeContext } from '../plugins/PluginContext';
import { callExternalPluginFunction } from '../plugins/PluginSource';
@ -118,31 +119,34 @@ export default function LayoutComponent() {
return (
<ProtectedRoute>
<Flex direction='column' mih='100vh'>
<Header />
<Container className={classes.layoutContent} size='100%'>
<Boundary label={'layout'}>
<Outlet />
</Boundary>
{/* </ErrorBoundary> */}
</Container>
<Space h='xl' />
<Footer />
{userSettings.isSet('SHOW_SPOTLIGHT') && (
<Spotlight
actions={actions}
store={firstStore}
highlightQuery
scrollable
searchProps={{
leftSection: <IconSearch size='1.2rem' />,
placeholder: t`Search...`
}}
shortcut={['mod + K']}
nothingFound={t`Nothing found...`}
/>
)}
</Flex>
<>
<Flex direction='column' mih='100vh'>
<Header />
<Container className={classes.layoutContent} size='100%'>
<Boundary label={'layout'}>
<Outlet />
</Boundary>
{/* </ErrorBoundary> */}
</Container>
<Space h='xl' />
<Footer />
{userSettings.isSet('SHOW_SPOTLIGHT') && (
<Spotlight
actions={actions}
store={firstStore}
highlightQuery
scrollable
searchProps={{
leftSection: <IconSearch size='1.2rem' />,
placeholder: t`Search...`
}}
shortcut={['mod + K']}
nothingFound={t`Nothing found...`}
/>
)}
</Flex>
<GlobalImporterDrawer />
</>
</ProtectedRoute>
);
}

View File

@ -36,6 +36,12 @@ import {
useDeleteApiFormModal,
useEditApiFormModal
} from '../../hooks/UseForm';
import {
type ImporterOpenOptions,
closeGlobalImporter,
getGlobalImporterState,
openGlobalImporter
} from '../../states/ImporterState';
import { useServerApiState } from '../../states/ServerApiState';
import { RenderInstance } from '../render/Instance';
@ -70,6 +76,13 @@ export const useInvenTreeContext = () => {
renderInstance: RenderInstance,
theme: theme,
colorScheme: colorScheme,
importer: {
open: (sessionId: number, options?: ImporterOpenOptions) =>
openGlobalImporter(sessionId, options),
close: () => closeGlobalImporter(),
isOpen: () => getGlobalImporterState().isOpen,
sessionId: () => getGlobalImporterState().sessionId
},
forms: {
bulkEdit: useBulkEditApiFormModal,
create: useCreateApiFormModal,

View File

@ -119,9 +119,13 @@ export function RenderInstance(props: RenderInstanceProps): ReactNode {
export function RenderRemoteInstance({
model,
modelUrl,
modelRenderer,
pk
}: Readonly<{
model: ModelType;
modelUrl?: string;
modelRenderer?: (instance: any) => ReactNode;
pk: number;
}>): ReactNode {
const api = useApi();
@ -129,7 +133,9 @@ export function RenderRemoteInstance({
const { data, isLoading, isFetching } = useQuery({
queryKey: ['model', model, pk],
queryFn: async () => {
const url = apiUrl(ModelInformationDict[model].api_endpoint, pk);
const url = modelUrl
? apiUrl(modelUrl, pk)
: apiUrl(ModelInformationDict[model].api_endpoint, pk);
return api.get(url).then((response) => response.data);
}
@ -147,6 +153,10 @@ export function RenderRemoteInstance({
);
}
if (!!modelRenderer) {
return modelRenderer({ instance: data });
}
return <RenderInstance model={model} instance={data} />;
}

View File

@ -83,6 +83,7 @@ export function extraLineItemFields(): ApiFormFieldSet {
order: {
hidden: true
},
line: {},
reference: {},
description: {},
quantity: {},

View File

@ -144,6 +144,7 @@ export function usePurchaseOrderLineItemFields({
};
}
},
line: {},
reference: {},
quantity: {
onValueChange: (value) => {

View File

@ -128,8 +128,9 @@ export function useReturnOrderLineItemFields({
part_detail: true
}
},
quantity: {},
line: {},
reference: {},
quantity: {},
outcome: {
hidden: create == true
},

View File

@ -169,6 +169,7 @@ export function useSalesOrderLineItemFields({
},
onValueChange: (_: any, record?: any) => setPart(record)
},
line: {},
reference: {},
quantity: {
onValueChange: (value) => {

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

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