Merge commit '5c95dfe484b0e01b8a3f95e7413f0f6f71cdc3cd' into block-notes

This commit is contained in:
Oliver Walters 2026-06-16 00:01:49 +00:00
commit 20970d9604
23 changed files with 896 additions and 784 deletions

View File

@ -31,7 +31,7 @@ jobs:
steps:
- name: Checkout Code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # pin@v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3
with:
persist-credentials: false

View File

@ -39,7 +39,7 @@ jobs:
docker: ${{ steps.filter.outputs.docker }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # pin@v6.0.2
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3
with:
persist-credentials: false
- uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # pin@v4.0.1
@ -67,7 +67,7 @@ jobs:
steps:
- name: Check out repo
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # pin@v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3
with:
persist-credentials: false
- name: Test Docker Image
@ -129,7 +129,7 @@ jobs:
steps:
- name: Check out repo
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # pin@v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3
with:
persist-credentials: false
- name: Run Migration Tests
@ -153,7 +153,7 @@ jobs:
steps:
- name: Check out repo
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # pin@v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3
with:
persist-credentials: false
- name: Set Up Python ${{ env.python_version }}

View File

@ -45,7 +45,7 @@ jobs:
force: ${{ steps.force.outputs.force }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # pin@v6.0.2
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3
with:
persist-credentials: false
- uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # pin@v4.0.1
@ -68,7 +68,7 @@ jobs:
timeout-minutes: 60
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # pin@v6.0.2
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3
with:
persist-credentials: false
- name: Environment Setup
@ -122,7 +122,7 @@ jobs:
VITE_COVERAGE: false
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # pin@v6.0.2
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3
with:
persist-credentials: false
- name: Environment Setup
@ -201,7 +201,7 @@ jobs:
VITE_COVERAGE: true
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # pin@v6.0.2
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3
with:
persist-credentials: false
- name: Environment Setup
@ -269,7 +269,7 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # pin@v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3
with:
persist-credentials: false
@ -300,7 +300,7 @@ jobs:
- name: Upload coverage reports to Codecov
if: ${{ !cancelled() && github.ref == 'refs/heads/master' }}
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # pin@v6.0.1
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # pin@v7.0.0
with:
token: ${{ secrets.CODECOV_TOKEN }}
slug: inventree/InvenTree

View File

@ -48,7 +48,7 @@ jobs:
outputs:
server: ${{ steps.filter.outputs.server }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # pin@v6.0.2
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3
with:
persist-credentials: false
- uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # pin@v4.0.1
@ -76,7 +76,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # pin@v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3
with:
fetch-depth: 0
persist-credentials: false

View File

@ -44,7 +44,7 @@ jobs:
submit-performance: ${{ steps.runner-perf.outputs.submit-performance }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # pin@v6.0.2
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3
with:
persist-credentials: false
- uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # pin@v4.0.1
@ -108,7 +108,7 @@ jobs:
if: needs.paths-filter.outputs.cicd == 'true' || needs.paths-filter.outputs.server == 'true' || needs.paths-filter.outputs.frontend == 'true' || needs.paths-filter.outputs.requirements == 'true' || needs.paths-filter.outputs.force == 'true'
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # pin@v6.0.2
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3
with:
persist-credentials: false
- name: Set up Python ${{ env.python_version }}
@ -130,7 +130,7 @@ jobs:
if: needs.paths-filter.outputs.server == 'true' || needs.paths-filter.outputs.requirements == 'true' || needs.paths-filter.outputs.force == 'true'
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # pin@v6.0.2
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3
with:
persist-credentials: false
- name: Environment Setup
@ -152,7 +152,7 @@ jobs:
steps:
- name: Checkout Code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # pin@v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3
with:
persist-credentials: false
- name: Set up Python ${{ env.python_version }}
@ -190,7 +190,7 @@ jobs:
version: ${{ steps.version.outputs.version }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # pin@v6.0.2
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3
with:
persist-credentials: false
- name: Environment Setup
@ -222,7 +222,7 @@ jobs:
echo "Downloaded api.yaml"
- name: Running OpenAPI Spec diff action
id: breaking_changes
uses: oasdiff/oasdiff-action/diff@f30668f65075c93440bd59ce2de73ce9e78751f4 # pin@main
uses: oasdiff/oasdiff-action/diff@3530478ec30f84adedbfeb28f0d9527a290f50a9 # pin@main
with:
base: "api.yaml"
revision: "src/backend/InvenTree/schema.yml"
@ -275,7 +275,7 @@ jobs:
version: ${{ needs.schema.outputs.version }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # pin@v6.0.2
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3
name: Checkout Code
with:
repository: inventree/schema
@ -332,7 +332,7 @@ jobs:
node_version: '>=24'
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # pin@v6.0.2
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3
with:
persist-credentials: false
- name: Environment Setup
@ -390,7 +390,7 @@ jobs:
python_version: ${{ matrix.python_version }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # pin@v6.0.2
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3
with:
persist-credentials: false
- name: Environment Setup
@ -415,7 +415,7 @@ jobs:
path: .coverage
retention-days: 14
- name: Upload coverage reports to Codecov
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # pin@v6.0.1
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # pin@v7.0.0
if: always()
with:
token: ${{ secrets.CODECOV_TOKEN }}
@ -441,7 +441,7 @@ jobs:
INVENTREE_AUTO_UPDATE: true
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # pin@v6.0.2
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3
with:
persist-credentials: false
- name: Environment Setup
@ -492,7 +492,7 @@ jobs:
- 6379:6379
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # pin@v6.0.2
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3
with:
persist-credentials: false
- name: Environment Setup
@ -541,7 +541,7 @@ jobs:
- 3306:3306
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # pin@v6.0.2
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3
with:
persist-credentials: false
- name: Environment Setup
@ -584,7 +584,7 @@ jobs:
- 5432:5432
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # pin@v6.0.2
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3
with:
persist-credentials: false
- name: Environment Setup
@ -597,7 +597,7 @@ jobs:
- name: Run Tests
run: invoke dev.test --check --migrations --report --coverage --translations
- name: Upload coverage reports to Codecov
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # pin@v6.0.1
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # pin@v7.0.0
if: always()
with:
token: ${{ secrets.CODECOV_TOKEN }}
@ -618,7 +618,7 @@ jobs:
INVENTREE_PLUGINS_ENABLED: false
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # pin@v6.0.2
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3
with:
persist-credentials: false
name: Checkout Code
@ -674,7 +674,7 @@ jobs:
security-events: write
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # pin@v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3
with:
persist-credentials: false
- name: Run zizmor 🌈

View File

@ -20,7 +20,7 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
steps:
- name: Checkout Code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # pin@v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3
with:
persist-credentials: false
- name: Version Check
@ -43,7 +43,7 @@ jobs:
contents: write
attestations: write
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # pin@v6.0.2
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3
with:
persist-credentials: false
- name: Environment Setup
@ -109,7 +109,7 @@ jobs:
INVENTREE_DEBUG: true
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # pin@v6.0.2
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3
with:
persist-credentials: false
- name: Environment Setup
@ -149,7 +149,7 @@ jobs:
- ubuntu:24.04
- debian:12
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # pin@v6.0.2
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3
with:
fetch-depth: 0
persist-credentials: false

View File

@ -32,7 +32,7 @@ jobs:
steps:
- name: "Checkout code"
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
@ -67,6 +67,6 @@ jobs:
# Upload the results to GitHub's code scanning dashboard.
- name: "Upload to code-scanning"
uses: github/codeql-action/upload-sarif@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
uses: github/codeql-action/upload-sarif@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
with:
sarif_file: results.sarif

View File

@ -32,7 +32,7 @@ jobs:
steps:
- name: Checkout Code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # pin@v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3
with:
persist-credentials: false
- name: Environment Setup

View File

@ -15,7 +15,7 @@ from django.views.generic.base import RedirectView
import structlog
from django_q.models import OrmQ
from drf_spectacular.utils import OpenApiParameter, OpenApiResponse, extend_schema
from rest_framework import serializers, viewsets
from rest_framework import permissions, serializers, viewsets
from rest_framework.generics import GenericAPIView
from rest_framework.request import clone_request
from rest_framework.response import Response
@ -356,7 +356,10 @@ class InfoView(APIView):
class NotFoundView(APIView):
"""Simple JSON view when accessing an invalid API view."""
permission_classes = [InvenTree.permissions.AllowAnyOrReadScope]
permission_classes = [
permissions.IsAuthenticated,
InvenTree.permissions.AllowAnyOrReadScope,
]
def not_found(self, request):
"""Return a 404 error."""

View File

@ -1,16 +1,19 @@
"""InvenTree API version information."""
# InvenTree API version
INVENTREE_API_VERSION = 506
INVENTREE_API_VERSION = 507
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
INVENTREE_API_TEXT = """
v506 -> 2026-06-16 : https://github.com/inventree/InvenTree/pull/11971
v507 -> 2026-06-16 : https://github.com/inventree/InvenTree/pull/11971
- Removes direct "notes" field from any models which previously supported markdown notes
- Adds a generic "Note" model which can be attached to any model type via a generic foreign key relationship
- Allow multiple notes to be attached to a single object, and for notes to be created / edited / deleted via the API
v506 -> 2026-06-15 : https://github.com/inventree/InvenTree/pull/12168
- Reduce permissions scope for a number of API endpoints, to improve security and ensure that users only have access to the data they need
v505 -> 2026-06-15 : https://github.com/inventree/InvenTree/pull/12165
- Allow parameters to be specified against the PartCategory model

View File

@ -415,7 +415,6 @@ class GlobalSettingsPermissions(OASTokenMixin, permissions.BasePermission):
"""Check that the requesting user is 'admin'."""
try:
user = request.user
if request.method in permissions.SAFE_METHODS:
return True
# Any other methods require staff access permissions

View File

@ -612,6 +612,7 @@ class GeneralApiTests(InvenTreeAPITestCase):
response = self.get(
url, headers={'Authorization': f'Token {token}'}, max_query_count=20
)
self.assertIsNotNone(data.get('active_plugins'))
self.assertGreater(len(response.json()['database']), 4)
data = response.json()

View File

@ -1481,7 +1481,7 @@ class ObservabilityEndSerializer(serializers.Serializer):
class ObservabilityEnd(CreateAPI):
"""Endpoint for observability tools."""
permission_classes = [AllowAnyOrReadScope]
permission_classes = [IsAuthenticated, AllowAnyOrReadScope]
serializer_class = ObservabilityEndSerializer
def create(self, request, *args, **kwargs):

View File

@ -115,6 +115,10 @@ class DataImportSessionAcceptFields(APIView):
"""Accept the field mapping for a DataImportSession."""
session = get_object_or_404(importer.models.DataImportSession, pk=pk)
# Check session ownership
if not request.user.is_staff and session.user != request.user:
raise PermissionDenied()
# Check that the user has permission to accept the field mapping
if model_class := session.model_class:
if not check_user_permission(request.user, model_class, 'change'):
@ -137,17 +141,45 @@ class DataImportSessionAcceptRows(DataImporterPermissionMixin, CreateAPI):
ctx = super().get_serializer_context()
try:
ctx['session'] = importer.models.DataImportSession.objects.get(
session = importer.models.DataImportSession.objects.get(
pk=self.kwargs.get('pk', None)
)
except Exception:
pass
except importer.models.DataImportSession.DoesNotExist:
session = None
if session:
user = self.request.user
if not user.is_staff and session.user != user:
raise PermissionDenied()
ctx['session'] = session
ctx['request'] = self.request
return ctx
class DataImportColumnMappingList(DataImporterPermissionMixin, ListAPI):
class DataImportSessionChildMixin(DataImporterPermissionMixin):
"""Mixin for DataImportRow and DataImportColumnMap views.
Ensures users can only access objects that belong to an import session they own.
Staff users retain access to all objects.
"""
def get_queryset(self):
"""Return only objects whose session belongs to the requesting user."""
queryset = super().get_queryset()
try:
user = self.request.user
except AttributeError:
raise PermissionDenied('User information is not available')
if user.is_staff:
return queryset
return queryset.filter(session__user=user)
class DataImportColumnMappingList(DataImportSessionChildMixin, ListAPI):
"""API endpoint for accessing a list of DataImportColumnMap objects."""
queryset = importer.models.DataImportColumnMap.objects.all()
@ -158,14 +190,14 @@ class DataImportColumnMappingList(DataImporterPermissionMixin, ListAPI):
filterset_fields = ['session']
class DataImportColumnMappingDetail(DataImporterPermissionMixin, RetrieveUpdateAPI):
class DataImportColumnMappingDetail(DataImportSessionChildMixin, RetrieveUpdateAPI):
"""Detail endpoint for a single DataImportColumnMap object."""
queryset = importer.models.DataImportColumnMap.objects.all()
serializer_class = importer.serializers.DataImportColumnMapSerializer
class DataImportRowList(DataImporterPermissionMixin, BulkDeleteMixin, ListAPI):
class DataImportRowList(DataImportSessionChildMixin, BulkDeleteMixin, ListAPI):
"""API endpoint for accessing a list of DataImportRow objects."""
queryset = importer.models.DataImportRow.objects.all()
@ -180,7 +212,7 @@ class DataImportRowList(DataImporterPermissionMixin, BulkDeleteMixin, ListAPI):
ordering = 'row_index'
class DataImportRowDetail(DataImporterPermissionMixin, RetrieveUpdateDestroyAPI):
class DataImportRowDetail(DataImportSessionChildMixin, RetrieveUpdateDestroyAPI):
"""Detail endpoint for a single DataImportRow object."""
queryset = importer.models.DataImportRow.objects.all()

View File

@ -344,14 +344,21 @@ class DataImportSession(models.Model):
self.save()
def check_complete(self) -> bool:
"""Check if the import session is complete."""
"""Check if the import session is complete.
When all rows have been accepted, the rows and column mappings are
deleted as they are no longer needed. The session itself is retained
as an audit record.
"""
if self.completed_row_count < self.row_count:
return False
# Update the status of this session
if self.status != DataImportStatusCode.COMPLETE.value:
self.status = DataImportStatusCode.COMPLETE.value
self.save()
# Clear staging data now that all rows have been imported
self.rows.all().delete()
self.column_mappings.all().delete()
return True

View File

@ -2,10 +2,11 @@
import os
from django.contrib.auth.models import User
from django.core.files.base import ContentFile
from django.urls import reverse
from importer.models import DataImportRow, DataImportSession
from importer.models import DataImportColumnMap, DataImportRow, DataImportSession
from InvenTree.unit_test import AdminTestCase, InvenTreeAPITestCase, InvenTreeTestCase
@ -58,14 +59,20 @@ class ImporterTest(ImporterMixin, InvenTreeTestCase):
self.assertEqual(session.rows.count(), 12)
# Check that some data has been imported
for row in session.rows.all():
rows = list(session.rows.all())
self.assertEqual(len(rows), 12)
for row in rows:
self.assertIsNotNone(row.data.get('name', None))
self.assertTrue(row.valid)
row.validate(commit=True)
self.assertTrue(row.complete)
self.assertEqual(session.completed_row_count, 12)
# All rows accepted: rows and mappings are cleared, session is retained
session.refresh_from_db()
self.assertEqual(session.rows.count(), 0)
self.assertEqual(session.column_mappings.count(), 0)
# Check that the new companies have been created
self.assertEqual(n + 12, Company.objects.count())
@ -204,6 +211,191 @@ class ImportAPITest(ImporterMixin, InvenTreeAPITestCase):
for session in response.data:
self.assertEqual(session['user'], self.user.pk)
def test_accept_fields_ownership(self):
"""Test that accept_fields rejects requests for sessions owned by another user."""
other_user = User.objects.create_user(
username='other_accept', password='password'
)
f = self.helper_file('companies.csv')
session = DataImportSession.objects.create(
data_file=f, model_type='company', user=other_user
)
url = reverse('api-import-session-accept-fields', kwargs={'pk': session.pk})
# Non-owner, non-staff should be denied
self.user.is_staff = False
self.user.save()
self.post(url, expected_code=403)
# Staff should be allowed (subject to model permission)
# Company is part of the purchase_order ruleset
self.user.is_staff = True
self.user.save()
self.assignRole('purchase_order.change')
self.post(url, expected_code=200)
def test_accept_rows_ownership(self):
"""Test that accept_rows rejects requests for sessions owned by another user."""
other_user = User.objects.create_user(
username='other_accept_rows', password='password'
)
f = self.helper_file('companies.csv')
session = DataImportSession.objects.create(
data_file=f, model_type='company', user=other_user
)
session.extract_columns()
url = reverse('api-import-session-accept-rows', kwargs={'pk': session.pk})
self.user.is_staff = False
self.user.save()
self.post(url, {'rows': []}, expected_code=403)
# Staff can reach the endpoint (rows list is empty so validation rejects with 400, not 403)
self.user.is_staff = True
self.user.save()
self.post(url, {'rows': []}, expected_code=400)
def test_session_cleanup_on_complete(self):
"""Test that a completed import session deletes itself and all associated data."""
url = reverse('api-importer-session-list')
data_file = self.helper_file('part_categories.csv')
data = self.post(
url,
{'model_type': 'partcategory', 'data_file': data_file},
format='multipart',
).data
session_id = data['pk']
session_pk = session_id
self.assignRole('part_category.add')
self.post(
reverse('api-import-session-accept-fields', kwargs={'pk': session_id}),
expected_code=200,
)
rows = self.get(
reverse('api-importer-row-list'), data={'session': session_id}
).data
row_ids = [r['pk'] for r in rows]
self.assertGreater(len(row_ids), 0)
# Confirm rows and mappings exist before acceptance
self.assertTrue(DataImportRow.objects.filter(session_id=session_pk).exists())
self.assertTrue(
DataImportColumnMap.objects.filter(session_id=session_pk).exists()
)
# Accept all rows — this should trigger cleanup of rows and mappings
self.post(
reverse('api-import-session-accept-rows', kwargs={'pk': session_id}),
{'rows': row_ids},
)
# Rows and column mappings must be cleared
self.assertFalse(DataImportRow.objects.filter(session_id=session_pk).exists())
self.assertFalse(
DataImportColumnMap.objects.filter(session_id=session_pk).exists()
)
# Session itself is retained as an audit record with COMPLETE status
from importer.models import DataImportSession
from importer.status_codes import DataImportStatusCode
session_obj = DataImportSession.objects.get(pk=session_pk)
self.assertEqual(session_obj.status, DataImportStatusCode.COMPLETE.value)
detail = self.get(
reverse('api-import-session-detail', kwargs={'pk': session_id}),
expected_code=200,
).data
self.assertEqual(detail['row_count'], 0)
self.assertEqual(detail['completed_row_count'], 0)
def test_row_and_mapping_ownership(self):
"""Test that DataImportRow and DataImportColumnMap endpoints filter by session ownership."""
f = self.helper_file('companies.csv')
other_user = User.objects.create_user(
username='other_importer', password='password'
)
# Session owned by self.user
session_mine = DataImportSession.objects.create(
data_file=f, model_type='company', user=self.user
)
session_mine.extract_columns()
# Session owned by another user
f2 = self.helper_file('companies.csv')
session_other = DataImportSession.objects.create(
data_file=f2, model_type='company', user=other_user
)
session_other.extract_columns()
row_list_url = reverse('api-importer-row-list')
mapping_list_url = reverse('api-importer-mapping-list')
# Non-staff: should only see rows/mappings from own session
self.user.is_staff = False
self.user.save()
rows = self.get(row_list_url).data
for row in rows:
self.assertEqual(row['session'], session_mine.pk)
mappings = self.get(mapping_list_url).data
for mapping in mappings:
self.assertEqual(mapping['session'], session_mine.pk)
# Detail endpoint: own session's row/mapping should be accessible
own_row = DataImportRow.objects.filter(session=session_mine).first()
other_row = DataImportRow.objects.filter(session=session_other).first()
if own_row:
self.get(
reverse('api-importer-row-detail', kwargs={'pk': own_row.pk}),
expected_code=200,
)
if other_row:
self.get(
reverse('api-importer-row-detail', kwargs={'pk': other_row.pk}),
expected_code=404,
)
own_mapping = DataImportColumnMap.objects.filter(session=session_mine).first()
other_mapping = DataImportColumnMap.objects.filter(
session=session_other
).first()
if own_mapping:
self.get(
reverse('api-importer-mapping-detail', kwargs={'pk': own_mapping.pk}),
expected_code=200,
)
if other_mapping:
self.get(
reverse('api-importer-mapping-detail', kwargs={'pk': other_mapping.pk}),
expected_code=404,
)
# Staff user: should see rows/mappings from all sessions
self.user.is_staff = True
self.user.save()
all_row_pks = set(DataImportRow.objects.values_list('pk', flat=True))
response_rows = self.get(row_list_url).data
self.assertEqual({r['pk'] for r in response_rows}, all_row_pks)
all_mapping_pks = set(DataImportColumnMap.objects.values_list('pk', flat=True))
response_mappings = self.get(mapping_list_url).data
self.assertEqual({m['pk'] for m in response_mappings}, all_mapping_pks)
class AdminTest(ImporterMixin, AdminTestCase):
"""Tests for the admin interface integration."""

View File

@ -10,7 +10,7 @@ import django_filters.rest_framework.filters as rest_filters
from django_filters.rest_framework import DjangoFilterBackend
from django_filters.rest_framework.filterset import FilterSet
from drf_spectacular.utils import extend_schema
from rest_framework import status
from rest_framework import permissions, status
from rest_framework.exceptions import NotFound
from rest_framework.response import Response
from rest_framework.views import APIView
@ -174,7 +174,10 @@ class PluginDetail(RetrieveDestroyAPI):
queryset = PluginConfig.objects.all()
serializer_class = PluginSerializers.PluginConfigSerializer
permission_classes = [InvenTree.permissions.IsSuperuserOrReadOnlyOrScope]
permission_classes = [
permissions.IsAuthenticated,
InvenTree.permissions.IsSuperuserOrReadOnlyOrScope,
]
lookup_field = 'key'
lookup_url_kwarg = 'plugin'
@ -201,6 +204,7 @@ class PluginAdminDetail(RetrieveAPI):
queryset = PluginConfig.objects.all()
serializer_class = PluginSerializers.PluginAdminDetailSerializer
permission_classes = [InvenTree.permissions.IsAdminOrAdminScope]
lookup_field = 'key'
lookup_url_kwarg = 'plugin'
@ -292,7 +296,10 @@ class PluginSettingList(ListAPI):
queryset = PluginSetting.objects.all()
serializer_class = PluginSerializers.PluginSettingSerializer
permission_classes = [InvenTree.permissions.GlobalSettingsPermissions]
permission_classes = [
permissions.IsAuthenticated,
InvenTree.permissions.GlobalSettingsPermissions,
]
filter_backends = [DjangoFilterBackend]
@ -365,7 +372,10 @@ class PluginAllSettingList(APIView):
- GET: return all settings for a plugin config
"""
permission_classes = [InvenTree.permissions.GlobalSettingsPermissions]
permission_classes = [
permissions.IsAuthenticated,
InvenTree.permissions.GlobalSettingsPermissions,
]
@extend_schema(
responses={200: PluginSerializers.PluginSettingSerializer(many=True)}
@ -393,6 +403,11 @@ class PluginSettingDetail(RetrieveUpdateAPI):
queryset = PluginSetting.objects.all()
serializer_class = PluginSerializers.PluginSettingSerializer
permission_classes = [
permissions.IsAuthenticated,
InvenTree.permissions.GlobalSettingsPermissions,
]
def get_object(self):
"""Lookup the plugin setting object, based on the URL.
@ -415,9 +430,6 @@ class PluginSettingDetail(RetrieveUpdateAPI):
setting_key, plugin=plugin.plugin_config()
)
# Staff permission required
permission_classes = [InvenTree.permissions.GlobalSettingsPermissions]
class PluginUserSettingList(APIView):
"""List endpoint for all user settings for a specific plugin.

View File

@ -816,3 +816,48 @@ class PluginLockedSettingsTest(PluginMixin, InvenTreeAPITestCase):
self.assertFalse(
response.data['read_only'], msg=f'{key} should not be read_only'
)
class PluginUnauthenticatedAccessTest(PluginMixin, InvenTreeAPITestCase):
"""Ensure plugin API endpoints reject unauthenticated requests.
Tests the four endpoints hardened on the permissions-fix branch:
- PluginDetail (api-plugin-detail)
- PluginSettingList (api-plugin-setting-list)
- PluginAllSettingList (api-plugin-settings)
- PluginSettingDetail (api-plugin-setting-detail)
"""
superuser = True
PLUGIN_SLUG = 'sample'
SETTING_KEY = 'API_KEY'
def setUp(self):
"""Activate sample plugin, then log out to simulate an anonymous client."""
super().setUp()
from plugin.registry import registry
registry.set_plugin_state(self.PLUGIN_SLUG, True)
self.client.logout()
def test_plugin_detail_unauthenticated(self):
"""GET /api/plugins/<slug>/ must return 401 for unauthenticated users."""
url = reverse('api-plugin-detail', kwargs={'plugin': self.PLUGIN_SLUG})
self.get(url, expected_code=401)
def test_plugin_setting_list_unauthenticated(self):
"""GET /api/plugins/settings/ must return 401 for unauthenticated users."""
self.get(reverse('api-plugin-setting-list'), expected_code=401)
def test_plugin_all_settings_unauthenticated(self):
"""GET /api/plugins/<slug>/settings/ must return 401 for unauthenticated users."""
url = reverse('api-plugin-settings', kwargs={'plugin': self.PLUGIN_SLUG})
self.get(url, expected_code=401)
def test_plugin_setting_detail_unauthenticated(self):
"""GET /api/plugins/<slug>/settings/<key>/ must return 401 for unauthenticated users."""
url = reverse(
'api-plugin-setting-detail',
kwargs={'plugin': self.PLUGIN_SLUG, 'key': self.SETTING_KEY},
)
self.get(url, expected_code=401)

View File

@ -49,16 +49,15 @@
"@codemirror/state": "^6.6.0",
"@codemirror/theme-one-dark": "^6.1.3",
"@codemirror/view": "^6.40.0",
"@emotion/react": "^11.13.3",
"@floating-ui/dom": "^1.0.0",
"@fortawesome/fontawesome-svg-core": "^7.0.0",
"@fortawesome/free-regular-svg-icons": "^7.0.0",
"@fortawesome/free-solid-svg-icons": "^7.0.0",
"@fortawesome/react-fontawesome": "^3.0.1",
"@fullcalendar/core": "^6.1.15",
"@fullcalendar/daygrid": "^6.1.15",
"@fullcalendar/interaction": "^6.1.15",
"@fullcalendar/react": "^6.1.15",
"@emotion/react": "^11.14.0",
"@fortawesome/fontawesome-svg-core": "^7.2.0",
"@fortawesome/free-regular-svg-icons": "^7.2.0",
"@fortawesome/free-solid-svg-icons": "^7.2.0",
"@fortawesome/react-fontawesome": "^3.3.1",
"@fullcalendar/core": "^6.1.20",
"@fullcalendar/daygrid": "^6.1.20",
"@fullcalendar/interaction": "^6.1.20",
"@fullcalendar/react": "^6.1.20",
"@github/webauthn-json": "^2.1.1",
"@lingui/core": "^5.9.2",
"@lingui/react": "^5.9.2",
@ -76,9 +75,9 @@
"@mantine/utils": "^6.0.22",
"@mantine/vanilla-extract": "^9.2.1",
"@messageformat/date-skeleton": "^1.1.0",
"@sentry/react": "^10.43.0",
"@tabler/icons-react": "^3.17.0",
"@tanstack/react-query": "^5.56.2",
"@sentry/react": "^10.57.0",
"@tabler/icons-react": "^3.44.0",
"@tanstack/react-query": "^5.101.0",
"@tiptap/core": "^3.23.6",
"@tiptap/extension-image": "^3.23.6",
"@tiptap/extension-link": "^3.23.6",
@ -89,65 +88,67 @@
"@uiw/codemirror-theme-vscode": "^4.25.8",
"@uiw/react-codemirror": "^4.25.8",
"@uiw/react-split": "^5.9.4",
"@vanilla-extract/css": "^1.18.0",
"axios": "^1.13.6",
"@vanilla-extract/css": "^1.20.1",
"axios": "^1.17.0",
"clsx": "^2.1.1",
"codemirror": "^6.0.2",
"dayjs": "^1.11.13",
"dompurify": "^3.2.4",
"embla-carousel": "^8.5.2",
"embla-carousel-react": "^8.5.2",
"fuse.js": "^7.0.0",
"dayjs": "^1.11.21",
"dompurify": "^3.4.8",
"easymde": "^2.21.0",
"embla-carousel": "^8.6.0",
"embla-carousel-react": "^8.6.0",
"fuse.js": "^7.4.2",
"html5-qrcode": "^2.3.8",
"mantine-contextmenu": "^9.2.1",
"mantine-datatable": "^9.2.0",
"mantine-datatable": "^9.2.2",
"qrcode": "^1.5.4",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"react-grid-layout": "1.4.4",
"react-hook-form": "^7.62.0",
"react-is": "^19.2.4",
"react-router-dom": "^6.26.2",
"react-select": "^5.9.0",
"react-hook-form": "^7.78.0",
"react-is": "^19.2.7",
"react-router-dom": "^6.30.4",
"react-select": "^5.10.2",
"react-simplemde-editor": "^5.2.0",
"react-window": "1.8.11",
"recharts": "^3.1.2",
"styled-components": "^6.1.14",
"recharts": "^3.8.1",
"styled-components": "^6.4.2",
"tiptap-extension-resizable-image": "^2.1.0",
"undici": "^6.24.0",
"zustand": "^5.0.8"
"undici": "^8.4.1",
"zustand": "^5.0.14"
},
"devDependencies": {
"@babel/core": "^7.29.0",
"@babel/preset-react": "^7.28.5",
"@babel/preset-typescript": "^7.28.5",
"@babel/runtime": "^7.28.6",
"@codecov/vite-plugin": "^1.9.1",
"@babel/core": "^7.29.7",
"@babel/preset-react": "^7.29.7",
"@babel/preset-typescript": "^7.29.7",
"@babel/runtime": "^7.29.7",
"@codecov/vite-plugin": "^2.0.1",
"@flakiness/playwright": "^1.13.0",
"@lingui/babel-plugin-lingui-macro": "^5.9.2",
"@lingui/cli": "^5.9.2",
"@lingui/macro": "^5.9.2",
"@playwright/test": "^1.16.0",
"@types/node": "^25.5.0",
"@types/qrcode": "^1.5.5",
"@types/react": "^19.2.14",
"@lingui/babel-plugin-lingui-macro": "^5.9.5",
"@lingui/cli": "^5.9.5",
"@lingui/macro": "^5.9.5",
"@playwright/test": "^1.60.0",
"@types/node": "^25.9.2",
"@types/qrcode": "^1.5.6",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@types/react-grid-layout": "^1.3.5",
"@types/react-router-dom": "^5.3.3",
"@types/react-window": "^1.8.8",
"@vanilla-extract/vite-plugin": "^5.1.4",
"@vanilla-extract/vite-plugin": "^5.2.2",
"@vitejs/plugin-react": "^5.2.0",
"babel-plugin-macros": "^3.1.0",
"nyc": "^18.0.0",
"otpauth": "^9.4.1",
"otpauth": "^9.5.1",
"path": "^0.12.7",
"rollup": "^4.59.0",
"rollup-plugin-license": "^3.7.0",
"rollup": "^4.61.1",
"rollup-plugin-license": "^3.7.1",
"typescript": "^5.9.3",
"vite": "^6.4.2",
"vite-plugin-babel-macros": "^1.0.6",
"vite-plugin-dts": "^4.5.4",
"vite-plugin-dts": "^5.0.2",
"vite-plugin-externals": "^0.6.2",
"vite-plugin-istanbul": "^8.0.0"
"vite-plugin-istanbul": "^9.0.1"
},
"overrides": {
},

View File

@ -68,7 +68,7 @@ export function ApiFormField({
: definition.value
);
}
}, [definition.value]);
}, [definition.value, definition.field_type]);
const fieldDefinition: ApiFormFieldType = useMemo(() => {
return {

View File

@ -4,7 +4,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
import { ModelType } from '@lib/enums/ModelType';
import { apiUrl } from '@lib/functions/Api';
import type { ApiFormFieldSet } from '@lib/types/Forms';
import type { ApiFormFieldSet, ApiFormFieldType } from '@lib/types/Forms';
import { t } from '@lingui/core/macro';
import type {
StatusCodeInterface,
@ -117,32 +117,44 @@ export function useParameterTemplateFields(): ApiFormFieldSet {
}, []);
}
export function useParameterFields({
modelType,
modelId
}: {
modelType: ModelType;
modelId: number;
}): ApiFormFieldSet {
/**
* Shared hook for the dynamic "value" field on parameter forms.
*
* When the user selects a parameter template, the field type for the
* corresponding value input (data / default_value) must change to match the
* template's data type (boolean, choice, related-field selection list, or
* plain string). This hook encapsulates that state so it can be reused
* across the "Add Parameter" and "Add Category Parameter" forms.
*
* @param resetDep - When this value changes all internal state is reset to
* defaults. Pass a stringified key derived from the form's context (e.g.
* `${modelType}-${modelId}`) so the field resets when the context switches.
*/
export function useDynamicParameterValueField(resetDep?: any): {
onTemplateValueChange: (value: any, record: any) => void;
valueFieldConfig: ApiFormFieldType;
reset: () => void;
} {
const api = useApi();
const user = useUserState.getState();
const templateCreateFields = useParameterTemplateFields();
const [selectionListId, setSelectionListId] = useState<number | null>(null);
// Valid field choices
const [choices, setChoices] = useState<any[]>([]);
// Field type for "data" input
const [fieldType, setFieldType] = useState<
'string' | 'boolean' | 'choice' | 'related field'
>('string');
// Memoized value for the "data" field
const [data, setData] = useState<string>('');
const reset = useCallback(() => {
setSelectionListId(null);
setFieldType('string');
setChoices([]);
setData('');
}, []);
useEffect(() => {
reset();
}, [resetDep, reset]);
const fetchSelectionEntry = useCallback(
(value: any) => {
if (!value || !selectionListId) {
@ -151,9 +163,7 @@ export function useParameterFields({
return api
.get(apiUrl(ApiEndpoints.selectionentry_list, selectionListId), {
params: {
value: value
}
params: { value: value }
})
.then((response) => {
if (response.data && response.data.length == 1) {
@ -166,13 +176,102 @@ export function useParameterFields({
[selectionListId]
);
// Reset the field type and choices when the model changes
useEffect(() => {
setSelectionListId(null);
setFieldType('string');
setChoices([]);
setData('');
}, [modelType, modelId]);
const onTemplateValueChange = useCallback(
(value: any, record: any) => {
setSelectionListId(record?.selectionlist || null);
setData('');
if (record?.checkbox) {
setChoices([]);
setFieldType('boolean');
setData('false');
} else if (record?.choices) {
const _choices: string[] = record.choices.split(',');
if (_choices.length > 0) {
setChoices(
_choices.map((choice) => ({
display_name: choice.trim(),
value: choice.trim()
}))
);
setFieldType('choice');
} else {
setChoices([]);
setFieldType('string');
setData('');
}
} else if (record?.selectionlist) {
setFieldType('related field');
setData('');
} else {
setFieldType('string');
setData('');
}
},
[setFieldType, setData, setChoices]
);
const valueFieldConfig: ApiFormFieldType = useMemo(
() => ({
value: data,
onValueChange: (value: any, record: any) => {
if (fieldType === 'related field' && selectionListId) {
// For related fields, store the primary key value (not the string representation)
setData(record?.value ?? value);
} else {
setData(value);
}
},
field_type: fieldType,
choices: fieldType === 'choice' ? choices : undefined,
default: fieldType === 'boolean' ? false : undefined,
pk_field:
fieldType === 'related field' && selectionListId ? 'value' : undefined,
model:
fieldType === 'related field' && selectionListId
? ModelType.selectionentry
: undefined,
api_url:
fieldType === 'related field' && selectionListId
? apiUrl(ApiEndpoints.selectionentry_list, selectionListId)
: undefined,
filters: fieldType === 'related field' ? { active: true } : undefined,
adjustValue: (value: any) => {
let v: string = value.toString().trim();
if (fieldType === 'boolean') {
if (v.toLowerCase() !== 'true') {
v = 'false';
}
}
return v;
},
singleFetchFunction: fetchSelectionEntry
}),
[data, fieldType, choices, selectionListId, fetchSelectionEntry]
);
return { onTemplateValueChange, valueFieldConfig, reset };
}
export function useParameterFields({
modelType,
modelId
}: {
modelType: ModelType;
modelId: number;
}): ApiFormFieldSet {
const user = useUserState.getState();
const templateCreateFields = useParameterTemplateFields();
const resetKey = useMemo(
() => `${modelType}-${modelId}`,
[modelType, modelId]
);
const { onTemplateValueChange, valueFieldConfig } =
useDynamicParameterValueField(resetKey);
return useMemo(() => {
return {
@ -189,97 +288,17 @@ export function useParameterFields({
for_model: modelType,
enabled: true
},
onValueChange: (value: any, record: any) => {
setSelectionListId(record?.selectionlist || null);
// Adjust the type of the "data" field based on the selected template
if (record?.checkbox) {
// This is a "checkbox" field
setChoices([]);
setFieldType('boolean');
setData('false');
} else if (record?.choices) {
const _choices: string[] = record.choices.split(',');
if (_choices.length > 0) {
setChoices(
_choices.map((choice) => {
return {
display_name: choice.trim(),
value: choice.trim()
};
})
);
setFieldType('choice');
} else {
setChoices([]);
setFieldType('string');
}
} else if (record?.selectionlist) {
setFieldType('related field');
} else {
// Default to a simple string field
setFieldType('string');
}
},
onValueChange: onTemplateValueChange,
addCreateFields: user.isStaff() ? templateCreateFields : undefined
},
data: {
value: data,
onValueChange: (value: any, record: any) => {
if (fieldType === 'related field' && selectionListId) {
// For related fields, we need to store the selected primary key value (not the string representation)
setData(record?.value ?? value);
} else {
setData(value);
}
},
type: fieldType,
field_type: fieldType,
choices: fieldType === 'choice' ? choices : undefined,
default: fieldType === 'boolean' ? false : undefined,
pk_field:
fieldType === 'related field' && selectionListId
? 'value'
: undefined,
model:
fieldType === 'related field' && selectionListId
? ModelType.selectionentry
: undefined,
api_url:
fieldType === 'related field' && selectionListId
? apiUrl(ApiEndpoints.selectionentry_list, selectionListId)
: undefined,
filters:
fieldType === 'related field'
? {
active: true
}
: undefined,
adjustValue: (value: any) => {
// Coerce boolean value into a string (required by backend)
let v: string = value.toString().trim();
if (fieldType === 'boolean') {
if (v.toLowerCase() !== 'true') {
v = 'false';
}
}
return v;
},
singleFetchFunction: fetchSelectionEntry
},
data: valueFieldConfig,
note: {}
};
}, [
data,
modelType,
fieldType,
choices,
modelId,
selectionListId,
onTemplateValueChange,
valueFieldConfig,
templateCreateFields,
user
]);

View File

@ -16,6 +16,7 @@ import type { TableFilter } from '@lib/types/Filters';
import type { ApiFormFieldSet } from '@lib/types/Forms';
import type { TableColumn } from '@lib/types/Tables';
import { IconInfoCircle } from '@tabler/icons-react';
import { useDynamicParameterValueField } from '../../forms/CommonForms';
import {
useCreateApiFormModal,
useDeleteApiFormModal,
@ -32,16 +33,21 @@ export default function PartCategoryTemplateTable({
const table = useTable('part-category-parameter-templates');
const user = useUserState();
const { onTemplateValueChange, valueFieldConfig, reset } =
useDynamicParameterValueField(categoryId);
const formFields: ApiFormFieldSet = useMemo(() => {
return {
category: {
value: categoryId,
disabled: categoryId !== undefined
},
template: {},
default_value: {}
template: {
onValueChange: onTemplateValueChange
},
default_value: valueFieldConfig
};
}, [categoryId]);
}, [categoryId, onTemplateValueChange, valueFieldConfig]);
const [selectedTemplate, setSelectedTemplate] = useState<number>(0);
@ -49,6 +55,7 @@ export default function PartCategoryTemplateTable({
url: ApiEndpoints.category_parameter_list,
title: t`Add Category Parameter`,
fields: useMemo(() => ({ ...formFields }), [formFields]),
onOpen: reset,
table: table
});
@ -57,6 +64,7 @@ export default function PartCategoryTemplateTable({
pk: selectedTemplate,
title: t`Edit Category Parameter`,
fields: useMemo(() => ({ ...formFields }), [formFields]),
onOpen: reset,
table: table
});

File diff suppressed because it is too large Load Diff