Merge branch 'master' into feat/continuous-barcode-scan
This commit is contained in:
commit
7c875dd474
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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}')
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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})
|
||||
|
|
|
|||
|
|
@ -184,6 +184,7 @@ class ManufacturerPartMixin(SerializerContextMixin):
|
|||
|
||||
|
||||
class ManufacturerPartList(
|
||||
DataExportViewMixin,
|
||||
ManufacturerPartMixin,
|
||||
SerializerContextMixin,
|
||||
OutputOptionsMixin,
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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'],
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
),
|
||||
),
|
||||
]
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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'))
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
),
|
||||
),
|
||||
]
|
||||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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']
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 \
|
||||
|
|
|
|||
|
|
@ -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 \
|
||||
|
|
|
|||
|
|
@ -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 \
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -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[] => {
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -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'>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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} />;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -83,6 +83,7 @@ export function extraLineItemFields(): ApiFormFieldSet {
|
|||
order: {
|
||||
hidden: true
|
||||
},
|
||||
line: {},
|
||||
reference: {},
|
||||
description: {},
|
||||
quantity: {},
|
||||
|
|
|
|||
|
|
@ -144,6 +144,7 @@ export function usePurchaseOrderLineItemFields({
|
|||
};
|
||||
}
|
||||
},
|
||||
line: {},
|
||||
reference: {},
|
||||
quantity: {
|
||||
onValueChange: (value) => {
|
||||
|
|
|
|||
|
|
@ -128,8 +128,9 @@ export function useReturnOrderLineItemFields({
|
|||
part_detail: true
|
||||
}
|
||||
},
|
||||
quantity: {},
|
||||
line: {},
|
||||
reference: {},
|
||||
quantity: {},
|
||||
outcome: {
|
||||
hidden: create == true
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Reference in New Issue