Merge branch 'master' into preview-panel

This commit is contained in:
Oliver 2026-06-17 11:04:04 +10:00 committed by GitHub
commit 7266da595e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
41 changed files with 2467 additions and 2213 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -32,7 +32,7 @@ jobs:
steps: steps:
- name: "Checkout code" - name: "Checkout code"
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with: with:
persist-credentials: false persist-credentials: false
@ -67,6 +67,6 @@ jobs:
# Upload the results to GitHub's code scanning dashboard. # Upload the results to GitHub's code scanning dashboard.
- name: "Upload to code-scanning" - 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: with:
sarif_file: results.sarif sarif_file: results.sarif

View File

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

View File

@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added ### Added
- [#12165](https://github.com/inventree/InvenTree/pull/12165) adds support for parameters against the PartCategory model
- [#12103](https://github.com/inventree/InvenTree/pull/12103) adds column-based filtering to table views in the user interface. This extends the existing table filtering functionality by allowing users to apply filters directly to individual columns. - [#12103](https://github.com/inventree/InvenTree/pull/12103) adds column-based filtering to table views in the user interface. This extends the existing table filtering functionality by allowing users to apply filters directly to individual columns.
- [#12093](https://github.com/inventree/InvenTree/pull/12093) adds "read_only" attribute to PluginSetting API endpoint, which indicates whether a particular plugin setting is read-only (i.e. cannot be modified via the API) - [#12093](https://github.com/inventree/InvenTree/pull/12093) adds "read_only" attribute to PluginSetting API endpoint, which indicates whether a particular plugin setting is read-only (i.e. cannot be modified via the API)
- [#12079](https://github.com/inventree/InvenTree/pull/12079) adds the ability to save filter groups for table and calendar views in the user interface. This allows users to save and reuse commonly used filter configurations, improving the usability and efficiency of the interface. - [#12079](https://github.com/inventree/InvenTree/pull/12079) adds the ability to save filter groups for table and calendar views in the user interface. This allows users to save and reuse commonly used filter configurations, improving the usability and efficiency of the interface.

View File

@ -9,8 +9,8 @@
# - Runs InvenTree web server under django development server # - Runs InvenTree web server under django development server
# - Monitors source files for any changes, and live-reloads server # - Monitors source files for any changes, and live-reloads server
# Base image last bumped 2026-03-20 # Base image last bumped 2026-06-16
FROM python:3.14.5-slim-trixie@sha256:c845af9399020c7e562969a13689e929074a10fd057acd1b1fad06a2fb068e97 AS inventree_base FROM python:3.14.6-slim-trixie@sha256:44dd04494ee8f3b538294360e7c4b3acb87c8268e4d0a4828a6500b1eff50061 AS inventree_base
# Build arguments for this image # Build arguments for this image
ARG commit_tag="" ARG commit_tag=""

View File

@ -15,7 +15,7 @@ from django.views.generic.base import RedirectView
import structlog import structlog
from django_q.models import OrmQ from django_q.models import OrmQ
from drf_spectacular.utils import OpenApiParameter, OpenApiResponse, extend_schema 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.generics import GenericAPIView
from rest_framework.request import clone_request from rest_framework.request import clone_request
from rest_framework.response import Response from rest_framework.response import Response
@ -356,7 +356,10 @@ class InfoView(APIView):
class NotFoundView(APIView): class NotFoundView(APIView):
"""Simple JSON view when accessing an invalid API view.""" """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): def not_found(self, request):
"""Return a 404 error.""" """Return a 404 error."""

View File

@ -1,11 +1,20 @@
"""InvenTree API version information.""" """InvenTree API version information."""
# InvenTree API version # InvenTree API version
INVENTREE_API_VERSION = 504 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.""" """Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
INVENTREE_API_TEXT = """ INVENTREE_API_TEXT = """
v507 -> 2026-06-16 : https://github.com/inventree/InvenTree/pull/12180
- Adds "lookup_field" parameter to the DataImportSessionSerializer, which allows for more flexible lookup of related objects during data import operations
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
v504 -> 2026-06-13 : https://github.com/inventree/InvenTree/pull/12139 v504 -> 2026-06-13 : https://github.com/inventree/InvenTree/pull/12139
- Adjustments to the SelectionList and SelectionListEntry API endpoints to support more efficient queries and data retrieval - Adjustments to the SelectionList and SelectionListEntry API endpoints to support more efficient queries and data retrieval

View File

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

View File

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

View File

@ -1405,6 +1405,7 @@ class ObservabilityEndSerializer(serializers.Serializer):
class ObservabilityEnd(CreateAPI): class ObservabilityEnd(CreateAPI):
"""Endpoint for observability tools.""" """Endpoint for observability tools."""
# Note: This endpoint can be called anonymously, as it needs to function before the user is authenticated (e.g. during login)
permission_classes = [AllowAnyOrReadScope] permission_classes = [AllowAnyOrReadScope]
serializer_class = ObservabilityEndSerializer serializer_class = ObservabilityEndSerializer

View File

@ -115,6 +115,10 @@ class DataImportSessionAcceptFields(APIView):
"""Accept the field mapping for a DataImportSession.""" """Accept the field mapping for a DataImportSession."""
session = get_object_or_404(importer.models.DataImportSession, pk=pk) 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 # Check that the user has permission to accept the field mapping
if model_class := session.model_class: if model_class := session.model_class:
if not check_user_permission(request.user, model_class, 'change'): if not check_user_permission(request.user, model_class, 'change'):
@ -137,17 +141,45 @@ class DataImportSessionAcceptRows(DataImporterPermissionMixin, CreateAPI):
ctx = super().get_serializer_context() ctx = super().get_serializer_context()
try: try:
ctx['session'] = importer.models.DataImportSession.objects.get( session = importer.models.DataImportSession.objects.get(
pk=self.kwargs.get('pk', None) pk=self.kwargs.get('pk', None)
) )
except Exception: except importer.models.DataImportSession.DoesNotExist:
pass 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 ctx['request'] = self.request
return ctx 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.""" """API endpoint for accessing a list of DataImportColumnMap objects."""
queryset = importer.models.DataImportColumnMap.objects.all() queryset = importer.models.DataImportColumnMap.objects.all()
@ -158,14 +190,14 @@ class DataImportColumnMappingList(DataImporterPermissionMixin, ListAPI):
filterset_fields = ['session'] filterset_fields = ['session']
class DataImportColumnMappingDetail(DataImporterPermissionMixin, RetrieveUpdateAPI): class DataImportColumnMappingDetail(DataImportSessionChildMixin, RetrieveUpdateAPI):
"""Detail endpoint for a single DataImportColumnMap object.""" """Detail endpoint for a single DataImportColumnMap object."""
queryset = importer.models.DataImportColumnMap.objects.all() queryset = importer.models.DataImportColumnMap.objects.all()
serializer_class = importer.serializers.DataImportColumnMapSerializer serializer_class = importer.serializers.DataImportColumnMapSerializer
class DataImportRowList(DataImporterPermissionMixin, BulkDeleteMixin, ListAPI): class DataImportRowList(DataImportSessionChildMixin, BulkDeleteMixin, ListAPI):
"""API endpoint for accessing a list of DataImportRow objects.""" """API endpoint for accessing a list of DataImportRow objects."""
queryset = importer.models.DataImportRow.objects.all() queryset = importer.models.DataImportRow.objects.all()
@ -180,7 +212,7 @@ class DataImportRowList(DataImporterPermissionMixin, BulkDeleteMixin, ListAPI):
ordering = 'row_index' ordering = 'row_index'
class DataImportRowDetail(DataImporterPermissionMixin, RetrieveUpdateDestroyAPI): class DataImportRowDetail(DataImportSessionChildMixin, RetrieveUpdateDestroyAPI):
"""Detail endpoint for a single DataImportRow object.""" """Detail endpoint for a single DataImportRow object."""
queryset = importer.models.DataImportRow.objects.all() queryset = importer.models.DataImportRow.objects.all()

View File

@ -0,0 +1,24 @@
# Generated migration
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("importer", "0005_dataimportsession_update_records"),
]
operations = [
migrations.AddField(
model_name="dataimportcolumnmap",
name="lookup_field",
field=models.CharField(
blank=True,
null=True,
max_length=100,
verbose_name="Lookup Field",
help_text="Database field to use for foreign-key lookup. Leave blank for automatic lookup.",
),
),
]

View File

@ -152,6 +152,36 @@ class DataImportSession(models.Model):
return supported_models().get(self.model_type, None) return supported_models().get(self.model_type, None)
def get_lookup_fields_for_field(self, field_name: str) -> list:
"""Return the valid lookup fields for a given related (FK) field.
Returns a list of field names that can be used as a lookup key,
consisting of 'pk' plus any fields defined in IMPORT_ID_FIELDS on the related model.
"""
model = self.get_related_model(field_name)
if not model:
return ['pk']
id_fields = ['pk']
if custom_fields := getattr(model, 'IMPORT_ID_FIELDS', None):
id_fields += custom_fields
return id_fields
@property
def field_lookup_mapping(self) -> dict:
"""Return a dict of field -> lookup_field mappings for this import session.
Only entries where lookup_field is explicitly set are included.
"""
return {
mapping.field: mapping.lookup_field
for mapping in self.column_mappings.all()
if mapping.lookup_field
}
def get_related_model(self, field_name: str) -> Optional[models.Model]: def get_related_model(self, field_name: str) -> Optional[models.Model]:
"""Return the related model for a given field name. """Return the related model for a given field name.
@ -344,14 +374,21 @@ class DataImportSession(models.Model):
self.save() self.save()
def check_complete(self) -> bool: 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: if self.completed_row_count < self.row_count:
return False return False
# Update the status of this session
if self.status != DataImportStatusCode.COMPLETE.value: if self.status != DataImportStatusCode.COMPLETE.value:
self.status = DataImportStatusCode.COMPLETE.value self.status = DataImportStatusCode.COMPLETE.value
self.save() self.save()
# Clear staging data now that all rows have been imported
self.rows.all().delete()
self.column_mappings.all().delete()
return True return True
@ -401,6 +438,11 @@ class DataImportSession(models.Model):
if field.get('read_only', False): if field.get('read_only', False):
continue continue
if field.get('type') == 'related field':
field['lookup_fields'] = self.get_lookup_fields_for_field(
field_name
)
fields[field_name] = field fields[field_name] = field
# Cache the available fields against this instance # Cache the available fields against this instance
@ -488,6 +530,22 @@ class DataImportColumnMap(models.Model):
if field_def.get('read_only', False): if field_def.get('read_only', False):
raise DjangoValidationError({'field': _('Selected field is read-only')}) raise DjangoValidationError({'field': _('Selected field is read-only')})
if self.lookup_field:
if field_def.get('type') != 'related field':
raise DjangoValidationError({
'lookup_field': _(
'Lookup field can only be set for related (foreign-key) fields'
)
})
valid_lookup_fields = self.session.get_lookup_fields_for_field(self.field)
if self.lookup_field not in valid_lookup_fields:
raise DjangoValidationError({
'lookup_field': _(
'Invalid lookup field. Valid options are: {options}'
).format(options=', '.join(valid_lookup_fields))
})
session = models.ForeignKey( session = models.ForeignKey(
DataImportSession, DataImportSession,
on_delete=models.CASCADE, on_delete=models.CASCADE,
@ -499,6 +557,16 @@ class DataImportColumnMap(models.Model):
column = models.CharField(blank=True, max_length=100, verbose_name=_('Column')) column = models.CharField(blank=True, max_length=100, verbose_name=_('Column'))
lookup_field = models.CharField(
blank=True,
null=True,
max_length=100,
verbose_name=_('Lookup Field'),
help_text=_(
'Database field to use for foreign-key lookup. Leave blank for automatic lookup.'
),
)
@property @property
def available_fields(self): def available_fields(self):
"""Return a list of available fields for this import session. """Return a list of available fields for this import session.
@ -631,9 +699,12 @@ class DataImportRow(models.Model):
default_values = self.default_values default_values = self.default_values
data = {} data = {}
extract_errors = {}
self.related_field_map = {} self.related_field_map = {}
field_lookup_mapping = self.session.field_lookup_mapping
# We have mapped column (file) to field (serializer) already # We have mapped column (file) to field (serializer) already
for field, col in field_mapping.items(): for field, col in field_mapping.items():
# Data override (force value and skip any further checks) # Data override (force value and skip any further checks)
@ -661,7 +732,13 @@ class DataImportRow(models.Model):
elif field_type == 'date': elif field_type == 'date':
value = self.convert_date_field(value) value = self.convert_date_field(value)
elif field_type == 'related field': elif field_type == 'related field':
value = self.lookup_related_field(field, value) try:
value = self.lookup_related_field(
field, value, lookup_field=field_lookup_mapping.get(field)
)
except DjangoValidationError as exc:
extract_errors[field] = exc.message
continue
# Use the default value, if provided # Use the default value, if provided
if value is None and field in default_values: if value is None and field in default_values:
@ -703,6 +780,9 @@ class DataImportRow(models.Model):
self.data = data self.data = data
if extract_errors:
self.errors = extract_errors
if commit: if commit:
self.save() self.save()
@ -726,7 +806,9 @@ class DataImportRow(models.Model):
# If none of the formats matched, return the original value # If none of the formats matched, return the original value
return value return value
def lookup_related_field(self, field_name: str, value: str) -> Optional[int]: def lookup_related_field(
self, field_name: str, value: str, lookup_field: Optional[str] = None
) -> Optional[int]:
"""Try to perform lookup against a related field. """Try to perform lookup against a related field.
- This is used to convert a human-readable value (e.g. a supplier name) into a database reference (e.g. supplier ID). - This is used to convert a human-readable value (e.g. a supplier name) into a database reference (e.g. supplier ID).
@ -735,6 +817,7 @@ class DataImportRow(models.Model):
Arguments: Arguments:
field_name: The name of the field to perform the lookup against field_name: The name of the field to perform the lookup against
value: The value to be looked up value: The value to be looked up
lookup_field: If provided, only query this specific model field (skips auto-lookup)
Returns: Returns:
A primary key value A primary key value
@ -758,21 +841,35 @@ class DataImportRow(models.Model):
'session': f'No related model found for field: {field_name}' 'session': f'No related model found for field: {field_name}'
}) })
valid_items = set()
base_filters = ( base_filters = (
self.session.field_filters.get(field_name, {}) self.session.field_filters.get(field_name, {})
if self.session.field_filters if self.session.field_filters
else {} else {}
) )
# First priority is the PK (primary key) field if lookup_field and type(lookup_field) is str:
# A specific lookup field has been chosen by the user — query only that field
try:
queryset = model.objects.filter(**{lookup_field: value}, **base_filters)
except ValueError:
return value
results = list(queryset[:2])
if len(results) == 1:
return results[0].pk
# Zero or multiple results — return raw value and let serializer report the error
return value
# Auto-lookup: try pk first, then any model-defined IMPORT_ID_FIELDS
id_fields = ['pk'] id_fields = ['pk']
if custom_id_fields := getattr(model, 'IMPORT_ID_FIELDS', None): if custom_id_fields := getattr(model, 'IMPORT_ID_FIELDS', None):
id_fields += custom_id_fields id_fields += custom_id_fields
# Iterate through the provided list - if any of the values match, we can perform the lookup valid_items = set()
for id_field in id_fields: for id_field in id_fields:
try: try:
queryset = model.objects.filter(**{id_field: value}, **base_filters) queryset = model.objects.filter(**{id_field: value}, **base_filters)
@ -782,15 +879,19 @@ class DataImportRow(models.Model):
# Evaluate at most two results to determine if there is exactly one match # Evaluate at most two results to determine if there is exactly one match
results = list(queryset[:2]) results = list(queryset[:2])
if len(results) == 1: if len(results) == 1:
# We have a single match against this field
valid_items.add(results[0].pk) valid_items.add(results[0].pk)
if len(valid_items) == 1: if len(valid_items) == 1:
# We found a single valid match against the related model - return this value
return valid_items.pop() return valid_items.pop()
# We found either zero or multiple values matching against the related model if len(valid_items) > 1:
# Return the original value and let the serializer validation handle any errors against this field raise DjangoValidationError(
_(
'Multiple matches found for value - please ensure the value is unique, or select a specific lookup field'
)
)
# No match found - return the original value and let the serializer validation handle it
return value return value
def serializer_data(self): def serializer_data(self):
@ -837,6 +938,10 @@ class DataImportRow(models.Model):
# Row has already been completed # Row has already been completed
return True return True
if self.errors:
# Errors were set during data extraction (e.g. ambiguous FK lookup)
return False
if self.session.update_records: if self.session.update_records:
# Extract the ID field from the data # Extract the ID field from the data
instance_id = self.data.get(self.session.ID_FIELD_LABEL, None) instance_id = self.data.get(self.session.ID_FIELD_LABEL, None)

View File

@ -23,7 +23,15 @@ class DataImportColumnMapSerializer(InvenTreeModelSerializer):
"""Meta class options for the serializer.""" """Meta class options for the serializer."""
model = importer.models.DataImportColumnMap model = importer.models.DataImportColumnMap
fields = ['pk', 'session', 'column', 'field', 'label', 'description'] fields = [
'pk',
'session',
'column',
'field',
'label',
'description',
'lookup_field',
]
read_only_fields = ['field', 'session'] read_only_fields = ['field', 'session']
label = serializers.CharField(read_only=True) label = serializers.CharField(read_only=True)

View File

@ -2,10 +2,11 @@
import os import os
from django.contrib.auth.models import User
from django.core.files.base import ContentFile from django.core.files.base import ContentFile
from django.urls import reverse 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 from InvenTree.unit_test import AdminTestCase, InvenTreeAPITestCase, InvenTreeTestCase
@ -58,14 +59,20 @@ class ImporterTest(ImporterMixin, InvenTreeTestCase):
self.assertEqual(session.rows.count(), 12) self.assertEqual(session.rows.count(), 12)
# Check that some data has been imported # 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.assertIsNotNone(row.data.get('name', None))
self.assertTrue(row.valid) self.assertTrue(row.valid)
row.validate(commit=True) row.validate(commit=True)
self.assertTrue(row.complete) 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 # Check that the new companies have been created
self.assertEqual(n + 12, Company.objects.count()) self.assertEqual(n + 12, Company.objects.count())
@ -73,6 +80,72 @@ class ImporterTest(ImporterMixin, InvenTreeTestCase):
def test_field_defaults(self): def test_field_defaults(self):
"""Test default field values.""" """Test default field values."""
def test_lookup_field_ambiguous_match(self):
"""Test the behavior of lookup_related_field for ambiguous and pinned matches."""
from django.core.exceptions import ValidationError as DjangoValidationError
from part.models import Part, PartCategory
category = PartCategory.objects.create(
name='Test Category', description='Test category'
)
# Two parts which collide under different IMPORT_ID_FIELDS ('IPN' and 'name')
part_a = Part.objects.create(
category=category, name='Widget', description='desc', IPN='AMBIG-001'
)
Part.objects.create(
category=category, name='AMBIG-001', description='desc', IPN='WIDGET-002'
)
data_file = self.helper_file('companies.csv')
session = DataImportSession.objects.create(
data_file=data_file, model_type='stockitem'
)
row = DataImportRow(session=session)
row.related_field_map = {}
# Auto-lookup (no pinned lookup field) raises, as the value matches two different parts
with self.assertRaises(DjangoValidationError):
row.lookup_related_field('part', 'AMBIG-001')
# Pinning the lookup field to 'IPN' resolves the match unambiguously
result = row.lookup_related_field('part', 'AMBIG-001', lookup_field='IPN')
self.assertEqual(result, part_a.pk)
# Pinning the lookup field to 'name' resolves to the *other* part
result = row.lookup_related_field('part', 'AMBIG-001', lookup_field='name')
self.assertNotEqual(result, part_a.pk)
def test_lookup_field_validation(self):
"""Test that DataImportColumnMap.clean() validates the lookup_field value."""
from django.core.exceptions import ValidationError as DjangoValidationError
data_file = self.helper_file('companies.csv')
session = DataImportSession.objects.create(
data_file=data_file, model_type='stockitem'
)
part_mapping = session.column_mappings.get(field='part')
quantity_mapping = session.column_mappings.get(field='quantity')
# Valid lookup field for a related (FK) field
part_mapping.lookup_field = 'IPN'
part_mapping.save()
part_mapping.refresh_from_db()
self.assertEqual(part_mapping.lookup_field, 'IPN')
# Invalid lookup field (not a valid IMPORT_ID_FIELDS option)
part_mapping.lookup_field = 'not_a_real_field'
with self.assertRaises(DjangoValidationError):
part_mapping.save()
# lookup_field cannot be set against a non-related field
quantity_mapping.lookup_field = 'IPN'
with self.assertRaises(DjangoValidationError):
quantity_mapping.save()
class ImportAPITest(ImporterMixin, InvenTreeAPITestCase): class ImportAPITest(ImporterMixin, InvenTreeAPITestCase):
"""End-to-end tests for the importer API.""" """End-to-end tests for the importer API."""
@ -174,6 +247,58 @@ class ImportAPITest(ImporterMixin, InvenTreeAPITestCase):
# Check that there are new database records # Check that there are new database records
self.assertEqual(PartCategory.objects.count(), N + 4) self.assertEqual(PartCategory.objects.count(), N + 4)
def test_column_mapping_lookup_field(self):
"""Test that the 'lookup_field' option can be specified via the column-mapping API."""
f = self.helper_file('companies.csv')
session = DataImportSession.objects.create(
data_file=f, model_type='stockitem', user=self.user
)
self.assignRole('stock.change')
# available_fields should expose the valid lookup field options for the 'part' FK field
session_detail = self.get(
reverse('api-import-session-detail', kwargs={'pk': session.pk})
).data
part_field_info = session_detail['available_fields']['part']
self.assertIn('lookup_fields', part_field_info)
self.assertIn('IPN', part_field_info['lookup_fields'])
self.assertIn('name', part_field_info['lookup_fields'])
self.assertIn('pk', part_field_info['lookup_fields'])
part_mapping = session.column_mappings.get(field='part')
quantity_mapping = session.column_mappings.get(field='quantity')
mapping_url = reverse(
'api-importer-mapping-detail', kwargs={'pk': part_mapping.pk}
)
# Initially, no lookup field is set (defaults to 'auto')
data = self.get(mapping_url).data
self.assertIn(data['lookup_field'], [None, ''])
# Set a valid lookup field
data = self.patch(mapping_url, {'lookup_field': 'IPN'}, expected_code=200).data
self.assertEqual(data['lookup_field'], 'IPN')
# Confirm it persists
data = self.get(mapping_url).data
self.assertEqual(data['lookup_field'], 'IPN')
# An invalid lookup field is rejected
self.patch(mapping_url, {'lookup_field': 'not_a_real_field'}, expected_code=400)
# Clear the lookup field (revert to 'auto')
data = self.patch(mapping_url, {'lookup_field': None}, expected_code=200).data
self.assertIn(data['lookup_field'], [None, ''])
# lookup_field cannot be set against a non-related field
quantity_url = reverse(
'api-importer-mapping-detail', kwargs={'pk': quantity_mapping.pk}
)
self.patch(quantity_url, {'lookup_field': 'IPN'}, expected_code=400)
def test_session_list(self): def test_session_list(self):
"""Test API endpoint which details the list of import sessions.""" """Test API endpoint which details the list of import sessions."""
url = reverse('api-importer-session-list') url = reverse('api-importer-session-list')
@ -204,6 +329,191 @@ class ImportAPITest(ImporterMixin, InvenTreeAPITestCase):
for session in response.data: for session in response.data:
self.assertEqual(session['user'], self.user.pk) 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): class AdminTest(ImporterMixin, AdminTestCase):
"""Tests for the admin interface integration.""" """Tests for the admin interface integration."""

View File

@ -68,6 +68,7 @@ logger = structlog.get_logger('inventree')
class PartCategory( class PartCategory(
InvenTree.models.PluginValidationMixin, InvenTree.models.PluginValidationMixin,
InvenTree.models.InvenTreeParameterMixin,
InvenTree.models.MetadataMixin, InvenTree.models.MetadataMixin,
InvenTree.models.PathStringMixin, InvenTree.models.PathStringMixin,
InvenTree.models.InvenTreeTree, InvenTree.models.InvenTreeTree,

View File

@ -103,6 +103,8 @@ class CategorySerializer(
'structural', 'structural',
'icon', 'icon',
'parent_default_location', 'parent_default_location',
# Optional fields
'parameters',
] ]
read_only_fields = ['level', 'pathstring'] read_only_fields = ['level', 'pathstring']
@ -176,6 +178,8 @@ class CategorySerializer(
parent_default_location = serializers.IntegerField(read_only=True, allow_null=True) parent_default_location = serializers.IntegerField(read_only=True, allow_null=True)
parameters = common.filters.enable_parameters_filter()
class CategoryTree(InvenTree.serializers.InvenTreeModelSerializer): class CategoryTree(InvenTree.serializers.InvenTreeModelSerializer):
"""Serializer for PartCategory tree.""" """Serializer for PartCategory tree."""

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

View File

@ -29,7 +29,13 @@ class PartStocktakeExportOptionsSerializer(serializers.Serializer):
export_include_variant_items = serializers.BooleanField( export_include_variant_items = serializers.BooleanField(
default=False, default=False,
label=_('Include Variant Items'), label=_('Include Variant Items'),
help_text=_('Include part variant stock in pricing calculations'), help_text=_('Include part variant stock in stocktake data'),
)
export_exclude_zero_stock_entries = serializers.BooleanField(
default=False,
label=_('Exclude Zero Stock Entries'),
help_text=_('Exclude parts with zero stock from the exported dataset'),
) )
@ -43,7 +49,7 @@ class PartStocktakeExporter(DataExportMixin, InvenTreePlugin):
SLUG = 'inventree-stocktake-exporter' SLUG = 'inventree-stocktake-exporter'
TITLE = _('Part Stocktake Exporter') TITLE = _('Part Stocktake Exporter')
DESCRIPTION = _('Exporter for part stocktake data') DESCRIPTION = _('Exporter for part stocktake data')
VERSION = '1.0.0' VERSION = '1.1.0'
AUTHOR = _('InvenTree contributors') AUTHOR = _('InvenTree contributors')
ExportOptionsSerializer = PartStocktakeExportOptionsSerializer ExportOptionsSerializer = PartStocktakeExportOptionsSerializer
@ -78,6 +84,9 @@ class PartStocktakeExporter(DataExportMixin, InvenTreePlugin):
'pk', 'pk',
'name', 'name',
'IPN', 'IPN',
'active',
'component',
'assembly',
'description', 'description',
'category', 'category',
'allocated_to_build_orders', 'allocated_to_build_orders',
@ -124,26 +133,36 @@ class PartStocktakeExporter(DataExportMixin, InvenTreePlugin):
export_pricing_data = context.get('export_pricing_data', True) export_pricing_data = context.get('export_pricing_data', True)
include_external_items = context.get('export_include_external_items', False) include_external_items = context.get('export_include_external_items', False)
include_variant_items = context.get('export_include_variant_items', False) include_variant_items = context.get('export_include_variant_items', False)
exclude_zero_stock = context.get('export_exclude_zero_stock_entries', False)
data = super().export_data( data = super().export_data(
queryset, serializer_class, headers, context, output, **kwargs queryset, serializer_class, headers, context, output, **kwargs
) )
if export_pricing_data: output_data = []
for row in data:
quantity = Decimal(row.get('total_in_stock', 0))
if not include_external_items: for row in data:
quantity -= Decimal(row.get('external_stock', 0)) quantity = Decimal(row.get('total_in_stock', 0))
if not include_variant_items: if not include_external_items:
quantity -= Decimal(row.get('variant_stock', 0)) quantity -= Decimal(row.get('external_stock', 0))
if quantity < 0: if not include_variant_items:
quantity = Decimal(0) quantity -= Decimal(row.get('variant_stock', 0))
pricing_min = row.get('pricing_min', None) if quantity < 0:
pricing_max = row.get('pricing_max', None) quantity = Decimal(0)
if exclude_zero_stock:
continue
if export_pricing_data:
pricing_min = row.get('pricing_min', None) or row.get(
'pricing_max', None
)
pricing_max = row.get('pricing_max', None) or row.get(
'pricing_min', None
)
if pricing_min is not None: if pricing_min is not None:
pricing_min = Decimal(pricing_min) pricing_min = Decimal(pricing_min)
@ -157,4 +176,6 @@ class PartStocktakeExporter(DataExportMixin, InvenTreePlugin):
pricing_max * quantity, rounding=10 pricing_max * quantity, rounding=10
) )
return data output_data.append(row)
return output_data

View File

@ -37,7 +37,7 @@ class StocktakeExporterTest(InvenTreeAPITestCase):
'Minimum Unit Cost', 'Minimum Unit Cost',
'Maximum Total Cost', 'Maximum Total Cost',
], ],
excluded_cols=['Active', 'External Stock', 'Variant Stock'], excluded_cols=['External Stock', 'Variant Stock'],
) )
# Now, with additional parameters specific to the plugin # Now, with additional parameters specific to the plugin
@ -60,7 +60,6 @@ class StocktakeExporterTest(InvenTreeAPITestCase):
'External Stock', 'External Stock',
'Variant Stock', 'Variant Stock',
], ],
excluded_cols=['Active'],
) )
# Finally, exclude pricing data entirely # Finally, exclude pricing data entirely

View File

@ -816,3 +816,48 @@ class PluginLockedSettingsTest(PluginMixin, InvenTreeAPITestCase):
self.assertFalse( self.assertFalse(
response.data['read_only'], msg=f'{key} should not be read_only' 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

@ -449,56 +449,56 @@ charset-normalizer==3.4.7 \
# via # via
# -c src/backend/requirements.txt # -c src/backend/requirements.txt
# requests # requests
cryptography==48.0.0 \ cryptography==48.0.1 \
--hash=sha256:0890f502ddf7d9c6426129c3f49f5c0a39278ed7cd6322c8755ffca6ee675a13 \ --hash=sha256:08a597acce1ff37f347400087776599e2348a3a8bc53b44120e463cd274efe4a \
--hash=sha256:0c558d2cdffd8f4bbb30fc7134c74d2ca9a476f830bb053074498fbc86f41ed6 \ --hash=sha256:09f73a725d582cef64b91281a322cd798d14a33b2b6f2b7ad9531dc336d84c02 \
--hash=sha256:16cd65b9330583e4619939b3a3843eec1e6e789744bb01e7c7e2e62e33c239c8 \ --hash=sha256:0df56b056bc17c1b7d6821dfa65216e62bd232d8ab05eb3db44e71d235651471 \
--hash=sha256:18349bbc56f4743c8b12dc32e2bccb2cf83ee8b69a3bba74ef8ae857e26b3d25 \ --hash=sha256:0ee6ea481db1ab889cba043ec1eda17bb9c1ea79db6722f779c3667f9f70322f \
--hash=sha256:1e2d54c8be6152856a36f0882ab231e70f8ec7f14e93cf87db8a2ed056bf160c \ --hash=sha256:15254441469dd6bf027039453288e2072124f8b6603563f5d759e1c9b69273fa \
--hash=sha256:22a5cb272895dce158b2cacdfdc3debd299019659f42947dbdac6f32d68fe832 \ --hash=sha256:266f4ee051abb2f725b74ef8072b521ce1feacf685a3364fa6a6b45548db791a \
--hash=sha256:27241b1dc9962e056062a8eef1991d02c3a24569c95975bd2322a8a52c6e5e12 \ --hash=sha256:2c37f2461406063b417837f5f3daab668652acd82423efcd7f0a9f04be972de1 \
--hash=sha256:2b4d59804e8408e2fea7d1fbaf218e5ec984325221db76e6a241a9abd6cdd95c \ --hash=sha256:32143b24adb918f078134e1e230f1eb8cc04886b92c28b5f0041aaf3e5699225 \
--hash=sha256:2eb992bbd4661238c5a397594c83f5b4dc2bc5b848c365c8f991b6780efcc5c7 \ --hash=sha256:33842cf0888951cef5bc7ac724ab844a42044c1727b967b7f8997289a0464f92 \
--hash=sha256:369a6348999f94bbd53435c894377b20ab95f25a9065c283570e70150d8abc3c \ --hash=sha256:3752f2dbc8f07a30aad2932c986cea495b03bb554887828225da104f732852b6 \
--hash=sha256:3cb07a3ed6431663cd321ea8a000a1314c74211f823e4177fefa2255e057d1ec \ --hash=sha256:39489bfca54c7a1f6b297efcd8bc608ab92d16c4ca631b0cad4da46724588b24 \
--hash=sha256:40ba1f85eaa6959837b1d51c9767e230e14612eea4ef110ee8854ada22da1bf5 \ --hash=sha256:3e4a1a3232eef2e6c732827d5722db29a0cc8b27af2a4d865b094cf954be9ca1 \
--hash=sha256:4defde8685ae324a9eb9d818717e93b4638ef67070ac9bc15b8ca85f63048355 \ --hash=sha256:3fd2ca57062b241c856670b073487d2e86c4637937ca5601e48f97bf8e11fc8f \
--hash=sha256:55b7718303bf06a5753dcdccf2f3945cf18ad7bffde41b61226e4db31ab89a9c \ --hash=sha256:42fcd8e26fe555d9b3577a135f5091fefa0aa4e99129c23fb56787a1bd4ada72 \
--hash=sha256:561215ea3879cb1cbbf272867e2efda62476f240fb58c64de6b393ae19246741 \ --hash=sha256:43c5835e2cb98c8733d86f57d6fc879b613f5c3478607281c3e36daffc6dd8a6 \
--hash=sha256:58d00498e8933e4a194f3076aee1b4a97dfec1a6da444535755822fe5d8b0b86 \ --hash=sha256:48fe40804d4caa2288f24e70ca8c64c42dd826da0ad7e4f1b41b2128d679e6c8 \
--hash=sha256:59baa2cb386c4f0b9905bd6eb4c2a79a69a128408fd31d32ca4d7102d4156321 \ --hash=sha256:4ab0a343c807bbcd90c971cd1ecf072937cd01847a9e002bef88fb47ac6be577 \
--hash=sha256:5a5ed8fde7a1d09376ca0b40e68cd59c69fe23b1f9768bd5824f54681626032a \ --hash=sha256:4fdc69f8e4316bcf0c8c8ec1f26f285d12e8142d88d96c876a59a03be3f6ae67 \
--hash=sha256:5b012212e08b8dd5edc78ef54da83dd9892fd9105323b3993eff6bea65dc21d7 \ --hash=sha256:6184ca7b174f28d7c703f1290d4b297217c45355f77a98f67e9b7f14549ac54a \
--hash=sha256:5c3932f4436d1cccb036cb0eaef46e6e2db91035166f1ad6505c3c9d5a635920 \ --hash=sha256:66fd0771e7b9c6dcd44cf1120690d2338d16d72795cf40cae2786a39eba65429 \
--hash=sha256:614d0949f4790582d2cc25553abd09dd723025f0c0e7c67376a1d77196743d6e \ --hash=sha256:6b2c0c3e6ccf3ade7750f836ef3ee36eea250cc467d45c256895573ac08cc6f1 \
--hash=sha256:76341972e1eff8b4bea859f09c0d3e64b96ce931b084f9b9b7db8ef364c30eff \ --hash=sha256:735824ec41b7f74a7c45fb1591349333e4c696cb6c044e5f46356e560143e4cd \
--hash=sha256:77a2ccbbe917f6710e05ba9adaa25fb5075620bf3ea6fb751997875aff4ae4bd \ --hash=sha256:7e234ac052af99f2700826a5c29ea99d9c1b1f80341cde62d11c8154dc8e0bd9 \
--hash=sha256:7995ef305d7165c3f11ae07f2517e5a4f1d5c18da1376a0a9ed496336b69e5f3 \ --hash=sha256:869c3b8a53bfe27147832df48b32adadf558249d50e76cb3769d40e986b13265 \
--hash=sha256:7ce4bfae76319a532a2dc68f82cc32f5676ee792a983187dac07183690e5c66f \ --hash=sha256:86be3b1b0b6bf09482fb50a979c508d2950ed95f5621ec77f4e385962006b83a \
--hash=sha256:7e8eac43dfca5c4cccc6dad9a80504436fca53bb9bc3100a2386d730fbe6b602 \ --hash=sha256:86fe77abb1bd87afb251d4d02ada7ecf53a32cee9b67d976abb2e45a13297475 \
--hash=sha256:84cf79f0dc8b36ac5da873481716e87aef31fcfa0444f9e1d8b4b2cece142855 \ --hash=sha256:88c852a0ae366e262e5a1744b685e6a433dc8788dd2a277e418bf4904203609d \
--hash=sha256:8c7378637d7d88016fa6791c159f698b3d3eed28ebf844ac36b9dc04a14dae18 \ --hash=sha256:8ace4507d1e6533c125f4fac754f8bb8b6a74c08e92179dabd7e16571a3efbf3 \
--hash=sha256:8cd666227ef7af430aa5914a9910e0ddd703e75f039cef0825cd0da71b6b711a \ --hash=sha256:92a46e1d638daa264ba2971c0b0489c9409787943efae4d60ffda3d091ef832c \
--hash=sha256:906cbf0670286c6e0044156bc7d4af9cbb0ef6db9f73e52c3ec56ba6bdde5336 \ --hash=sha256:9621de99d2da096006b629979efd8ae7eb2d8b822488d0c89ee4000c306c59b1 \
--hash=sha256:9071196d81abc88b3516ac8cdfad32e2b66dd4a5393a8e68a961e9161ddc6239 \ --hash=sha256:9a49ca6c81417f6a5edb50375a60cccdd70fa0a91a5211829dbea74eba94d2ac \
--hash=sha256:9249e3cd978541d665967ac2cb2787fd6a62bddf1e75b3e347a594d7dacf4f74 \ --hash=sha256:9bd3f92d76217892b15df84ca256c2c113d386fdda7a7d8691aeeced976507c6 \
--hash=sha256:984a20b0f62a26f48a3396c72e4bc34c66e356d356bf370053066b3b6d54634a \ --hash=sha256:9de21387aa95e2a895823d0745b430bed4f33503ba9ab5e0b5311f33e37d66d2 \
--hash=sha256:9be5aafa5736574f8f15f262adc81b2a9869e2cfe9014d52a44633905b40d52c \ --hash=sha256:b024e784ad6c077ee0147b35ea9cbfc1e34e1fd4c1dcca214c2794d73a12df08 \
--hash=sha256:9c459db21422be75e2809370b829a87eb37f74cd785fc4aa9ea1e5f43b47cda4 \ --hash=sha256:b4e391975f038e66432328639620a4aff2d307513b004f1ca06d6225bced815c \
--hash=sha256:9ccdac7d40688ecb5a3b4a604b8a88c8002e3442d6c60aead1db2a89a041560c \ --hash=sha256:b74ca3b8e5ecdd833bf6a002ca41b4793bb27fb8f1c06ffaf2643c9e9140e31b \
--hash=sha256:a0e692c683f4df67815a2d258b324e66f4738bd7a96a218c826dce4f4bd05d8f \ --hash=sha256:b7a2d1a937a738a881737cec135a38bb61470589b17515b9f73f571d0ae10401 \
--hash=sha256:a5da777e32ffed6f85a7b2b3f7c5cbc88c146bfcd0a1d7baf5fcc6c52ee35dd4 \ --hash=sha256:b9a32b876490d66c8bcc9963ef220199569748434ab01a9d6aaeabf88e7f5158 \
--hash=sha256:a64697c641c7b1b2178e573cbc31c7c6684cd56883a478d75143dbb7118036db \ --hash=sha256:bd81490cd5801d755cf97bb68ac191f14b708470b1c7cf4580f669b9c9264cd8 \
--hash=sha256:ad64688338ed4bc1a6618076ba75fd7194a5f1797ac60b47afe926285adb3166 \ --hash=sha256:c1400da5e32a43253392277eac7490a60e497d810a63dd5608d71bbd7af507c9 \
--hash=sha256:bd72e68b06bb1e96913f97dd4901119bc17f39d4586a5adf2d3e47bc2b9d58b5 \ --hash=sha256:d069066deead00ac7f090be101be875a06855908f7ec004c27b8fefb4acfb411 \
--hash=sha256:c17dfe85494deaeddc5ce251aebd1d60bbe6afc8b62071bb0b469431a000124f \ --hash=sha256:d5d30989c6917b478b5817902e85fddaea2261efa8648383d965381ccb9e1ac4 \
--hash=sha256:c18684a7f0cc9a3cb60328f496b8e3372def7c5d2df39ac267878b05565aaaae \ --hash=sha256:df637c05205ea7c1d7fbcbe54bbfea648a52951155f997af13d895d0ecc96991 \
--hash=sha256:cc90c0b39b2e3c65ef52c804b72e3c58f8a04ab2a1871272798e5f9572c17d20 \ --hash=sha256:e361afba8918070d376df76f408a4f67fec0ee9cff81a99e48fe9a233ef59e17 \
--hash=sha256:db63bf618e5dea46c07de12e900fe1cdd2541e6dc9dbae772a70b7d4d4765f6a \ --hash=sha256:eb86ce1af36fe65041b6db9a8bb064ee621a7e5fded0f80d475ec243477cd242 \
--hash=sha256:ea8990436d914540a40ab24b6a77c0969695ed52f4a4874c5137ccf7045a7057 \ --hash=sha256:f0d27a5696721ef7a672b8c810f6aded391058e0b9486e63e6d93baf765da691 \
--hash=sha256:ecde28a596bead48b0cfd2a1b4416c3d43074c2d785e3a398d7ec1fc4d0f7fbb \ --hash=sha256:f2ceef93cb096aa3c4cc4b5c94ca6131f9196d28c64d6111533402a9b2054d41 \
--hash=sha256:f5333311663ea94f75dd408665686aaf426563556bb5283554a3539177e03b8c \ --hash=sha256:f817adc181390bd54f2f700107a7419040fb7c1bdf2fc26f36551a06a68c3345 \
--hash=sha256:fdfef35d751d510fcef5252703621574364fec16418c4a1e5e1055248401054b --hash=sha256:fe0180af5bf9236518a087e35bf2d9a347d5f5f51e63c579d683ddff424e3d46
# via # via
# -c src/backend/requirements.txt # -c src/backend/requirements.txt
# -r src/backend/requirements.in # -r src/backend/requirements.in

View File

@ -354,56 +354,56 @@ coverage[toml]==7.14.1 \
--hash=sha256:fc459e5d73be2d6332fcfe8dbf3d8994671fe33c700f4565988ecfa511547253 \ --hash=sha256:fc459e5d73be2d6332fcfe8dbf3d8994671fe33c700f4565988ecfa511547253 \
--hash=sha256:fd86572566fb40189a8260446158235159bc7a82dfbc87a3b39cf4fb57fcec1c --hash=sha256:fd86572566fb40189a8260446158235159bc7a82dfbc87a3b39cf4fb57fcec1c
# via -r src/backend/requirements-dev.in # via -r src/backend/requirements-dev.in
cryptography==48.0.0 \ cryptography==48.0.1 \
--hash=sha256:0890f502ddf7d9c6426129c3f49f5c0a39278ed7cd6322c8755ffca6ee675a13 \ --hash=sha256:08a597acce1ff37f347400087776599e2348a3a8bc53b44120e463cd274efe4a \
--hash=sha256:0c558d2cdffd8f4bbb30fc7134c74d2ca9a476f830bb053074498fbc86f41ed6 \ --hash=sha256:09f73a725d582cef64b91281a322cd798d14a33b2b6f2b7ad9531dc336d84c02 \
--hash=sha256:16cd65b9330583e4619939b3a3843eec1e6e789744bb01e7c7e2e62e33c239c8 \ --hash=sha256:0df56b056bc17c1b7d6821dfa65216e62bd232d8ab05eb3db44e71d235651471 \
--hash=sha256:18349bbc56f4743c8b12dc32e2bccb2cf83ee8b69a3bba74ef8ae857e26b3d25 \ --hash=sha256:0ee6ea481db1ab889cba043ec1eda17bb9c1ea79db6722f779c3667f9f70322f \
--hash=sha256:1e2d54c8be6152856a36f0882ab231e70f8ec7f14e93cf87db8a2ed056bf160c \ --hash=sha256:15254441469dd6bf027039453288e2072124f8b6603563f5d759e1c9b69273fa \
--hash=sha256:22a5cb272895dce158b2cacdfdc3debd299019659f42947dbdac6f32d68fe832 \ --hash=sha256:266f4ee051abb2f725b74ef8072b521ce1feacf685a3364fa6a6b45548db791a \
--hash=sha256:27241b1dc9962e056062a8eef1991d02c3a24569c95975bd2322a8a52c6e5e12 \ --hash=sha256:2c37f2461406063b417837f5f3daab668652acd82423efcd7f0a9f04be972de1 \
--hash=sha256:2b4d59804e8408e2fea7d1fbaf218e5ec984325221db76e6a241a9abd6cdd95c \ --hash=sha256:32143b24adb918f078134e1e230f1eb8cc04886b92c28b5f0041aaf3e5699225 \
--hash=sha256:2eb992bbd4661238c5a397594c83f5b4dc2bc5b848c365c8f991b6780efcc5c7 \ --hash=sha256:33842cf0888951cef5bc7ac724ab844a42044c1727b967b7f8997289a0464f92 \
--hash=sha256:369a6348999f94bbd53435c894377b20ab95f25a9065c283570e70150d8abc3c \ --hash=sha256:3752f2dbc8f07a30aad2932c986cea495b03bb554887828225da104f732852b6 \
--hash=sha256:3cb07a3ed6431663cd321ea8a000a1314c74211f823e4177fefa2255e057d1ec \ --hash=sha256:39489bfca54c7a1f6b297efcd8bc608ab92d16c4ca631b0cad4da46724588b24 \
--hash=sha256:40ba1f85eaa6959837b1d51c9767e230e14612eea4ef110ee8854ada22da1bf5 \ --hash=sha256:3e4a1a3232eef2e6c732827d5722db29a0cc8b27af2a4d865b094cf954be9ca1 \
--hash=sha256:4defde8685ae324a9eb9d818717e93b4638ef67070ac9bc15b8ca85f63048355 \ --hash=sha256:3fd2ca57062b241c856670b073487d2e86c4637937ca5601e48f97bf8e11fc8f \
--hash=sha256:55b7718303bf06a5753dcdccf2f3945cf18ad7bffde41b61226e4db31ab89a9c \ --hash=sha256:42fcd8e26fe555d9b3577a135f5091fefa0aa4e99129c23fb56787a1bd4ada72 \
--hash=sha256:561215ea3879cb1cbbf272867e2efda62476f240fb58c64de6b393ae19246741 \ --hash=sha256:43c5835e2cb98c8733d86f57d6fc879b613f5c3478607281c3e36daffc6dd8a6 \
--hash=sha256:58d00498e8933e4a194f3076aee1b4a97dfec1a6da444535755822fe5d8b0b86 \ --hash=sha256:48fe40804d4caa2288f24e70ca8c64c42dd826da0ad7e4f1b41b2128d679e6c8 \
--hash=sha256:59baa2cb386c4f0b9905bd6eb4c2a79a69a128408fd31d32ca4d7102d4156321 \ --hash=sha256:4ab0a343c807bbcd90c971cd1ecf072937cd01847a9e002bef88fb47ac6be577 \
--hash=sha256:5a5ed8fde7a1d09376ca0b40e68cd59c69fe23b1f9768bd5824f54681626032a \ --hash=sha256:4fdc69f8e4316bcf0c8c8ec1f26f285d12e8142d88d96c876a59a03be3f6ae67 \
--hash=sha256:5b012212e08b8dd5edc78ef54da83dd9892fd9105323b3993eff6bea65dc21d7 \ --hash=sha256:6184ca7b174f28d7c703f1290d4b297217c45355f77a98f67e9b7f14549ac54a \
--hash=sha256:5c3932f4436d1cccb036cb0eaef46e6e2db91035166f1ad6505c3c9d5a635920 \ --hash=sha256:66fd0771e7b9c6dcd44cf1120690d2338d16d72795cf40cae2786a39eba65429 \
--hash=sha256:614d0949f4790582d2cc25553abd09dd723025f0c0e7c67376a1d77196743d6e \ --hash=sha256:6b2c0c3e6ccf3ade7750f836ef3ee36eea250cc467d45c256895573ac08cc6f1 \
--hash=sha256:76341972e1eff8b4bea859f09c0d3e64b96ce931b084f9b9b7db8ef364c30eff \ --hash=sha256:735824ec41b7f74a7c45fb1591349333e4c696cb6c044e5f46356e560143e4cd \
--hash=sha256:77a2ccbbe917f6710e05ba9adaa25fb5075620bf3ea6fb751997875aff4ae4bd \ --hash=sha256:7e234ac052af99f2700826a5c29ea99d9c1b1f80341cde62d11c8154dc8e0bd9 \
--hash=sha256:7995ef305d7165c3f11ae07f2517e5a4f1d5c18da1376a0a9ed496336b69e5f3 \ --hash=sha256:869c3b8a53bfe27147832df48b32adadf558249d50e76cb3769d40e986b13265 \
--hash=sha256:7ce4bfae76319a532a2dc68f82cc32f5676ee792a983187dac07183690e5c66f \ --hash=sha256:86be3b1b0b6bf09482fb50a979c508d2950ed95f5621ec77f4e385962006b83a \
--hash=sha256:7e8eac43dfca5c4cccc6dad9a80504436fca53bb9bc3100a2386d730fbe6b602 \ --hash=sha256:86fe77abb1bd87afb251d4d02ada7ecf53a32cee9b67d976abb2e45a13297475 \
--hash=sha256:84cf79f0dc8b36ac5da873481716e87aef31fcfa0444f9e1d8b4b2cece142855 \ --hash=sha256:88c852a0ae366e262e5a1744b685e6a433dc8788dd2a277e418bf4904203609d \
--hash=sha256:8c7378637d7d88016fa6791c159f698b3d3eed28ebf844ac36b9dc04a14dae18 \ --hash=sha256:8ace4507d1e6533c125f4fac754f8bb8b6a74c08e92179dabd7e16571a3efbf3 \
--hash=sha256:8cd666227ef7af430aa5914a9910e0ddd703e75f039cef0825cd0da71b6b711a \ --hash=sha256:92a46e1d638daa264ba2971c0b0489c9409787943efae4d60ffda3d091ef832c \
--hash=sha256:906cbf0670286c6e0044156bc7d4af9cbb0ef6db9f73e52c3ec56ba6bdde5336 \ --hash=sha256:9621de99d2da096006b629979efd8ae7eb2d8b822488d0c89ee4000c306c59b1 \
--hash=sha256:9071196d81abc88b3516ac8cdfad32e2b66dd4a5393a8e68a961e9161ddc6239 \ --hash=sha256:9a49ca6c81417f6a5edb50375a60cccdd70fa0a91a5211829dbea74eba94d2ac \
--hash=sha256:9249e3cd978541d665967ac2cb2787fd6a62bddf1e75b3e347a594d7dacf4f74 \ --hash=sha256:9bd3f92d76217892b15df84ca256c2c113d386fdda7a7d8691aeeced976507c6 \
--hash=sha256:984a20b0f62a26f48a3396c72e4bc34c66e356d356bf370053066b3b6d54634a \ --hash=sha256:9de21387aa95e2a895823d0745b430bed4f33503ba9ab5e0b5311f33e37d66d2 \
--hash=sha256:9be5aafa5736574f8f15f262adc81b2a9869e2cfe9014d52a44633905b40d52c \ --hash=sha256:b024e784ad6c077ee0147b35ea9cbfc1e34e1fd4c1dcca214c2794d73a12df08 \
--hash=sha256:9c459db21422be75e2809370b829a87eb37f74cd785fc4aa9ea1e5f43b47cda4 \ --hash=sha256:b4e391975f038e66432328639620a4aff2d307513b004f1ca06d6225bced815c \
--hash=sha256:9ccdac7d40688ecb5a3b4a604b8a88c8002e3442d6c60aead1db2a89a041560c \ --hash=sha256:b74ca3b8e5ecdd833bf6a002ca41b4793bb27fb8f1c06ffaf2643c9e9140e31b \
--hash=sha256:a0e692c683f4df67815a2d258b324e66f4738bd7a96a218c826dce4f4bd05d8f \ --hash=sha256:b7a2d1a937a738a881737cec135a38bb61470589b17515b9f73f571d0ae10401 \
--hash=sha256:a5da777e32ffed6f85a7b2b3f7c5cbc88c146bfcd0a1d7baf5fcc6c52ee35dd4 \ --hash=sha256:b9a32b876490d66c8bcc9963ef220199569748434ab01a9d6aaeabf88e7f5158 \
--hash=sha256:a64697c641c7b1b2178e573cbc31c7c6684cd56883a478d75143dbb7118036db \ --hash=sha256:bd81490cd5801d755cf97bb68ac191f14b708470b1c7cf4580f669b9c9264cd8 \
--hash=sha256:ad64688338ed4bc1a6618076ba75fd7194a5f1797ac60b47afe926285adb3166 \ --hash=sha256:c1400da5e32a43253392277eac7490a60e497d810a63dd5608d71bbd7af507c9 \
--hash=sha256:bd72e68b06bb1e96913f97dd4901119bc17f39d4586a5adf2d3e47bc2b9d58b5 \ --hash=sha256:d069066deead00ac7f090be101be875a06855908f7ec004c27b8fefb4acfb411 \
--hash=sha256:c17dfe85494deaeddc5ce251aebd1d60bbe6afc8b62071bb0b469431a000124f \ --hash=sha256:d5d30989c6917b478b5817902e85fddaea2261efa8648383d965381ccb9e1ac4 \
--hash=sha256:c18684a7f0cc9a3cb60328f496b8e3372def7c5d2df39ac267878b05565aaaae \ --hash=sha256:df637c05205ea7c1d7fbcbe54bbfea648a52951155f997af13d895d0ecc96991 \
--hash=sha256:cc90c0b39b2e3c65ef52c804b72e3c58f8a04ab2a1871272798e5f9572c17d20 \ --hash=sha256:e361afba8918070d376df76f408a4f67fec0ee9cff81a99e48fe9a233ef59e17 \
--hash=sha256:db63bf618e5dea46c07de12e900fe1cdd2541e6dc9dbae772a70b7d4d4765f6a \ --hash=sha256:eb86ce1af36fe65041b6db9a8bb064ee621a7e5fded0f80d475ec243477cd242 \
--hash=sha256:ea8990436d914540a40ab24b6a77c0969695ed52f4a4874c5137ccf7045a7057 \ --hash=sha256:f0d27a5696721ef7a672b8c810f6aded391058e0b9486e63e6d93baf765da691 \
--hash=sha256:ecde28a596bead48b0cfd2a1b4416c3d43074c2d785e3a398d7ec1fc4d0f7fbb \ --hash=sha256:f2ceef93cb096aa3c4cc4b5c94ca6131f9196d28c64d6111533402a9b2054d41 \
--hash=sha256:f5333311663ea94f75dd408665686aaf426563556bb5283554a3539177e03b8c \ --hash=sha256:f817adc181390bd54f2f700107a7419040fb7c1bdf2fc26f36551a06a68c3345 \
--hash=sha256:fdfef35d751d510fcef5252703621574364fec16418c4a1e5e1055248401054b --hash=sha256:fe0180af5bf9236518a087e35bf2d9a347d5f5f51e63c579d683ddff424e3d46
# via # via
# -c src/backend/requirements-3.14.txt # -c src/backend/requirements-3.14.txt
# -c src/backend/requirements.txt # -c src/backend/requirements.txt

View File

@ -350,56 +350,56 @@ coverage[toml]==7.13.5 \
--hash=sha256:f70c9ab2595c56f81a89620e22899eea8b212a4041bd728ac6f4a28bf5d3ddd0 \ --hash=sha256:f70c9ab2595c56f81a89620e22899eea8b212a4041bd728ac6f4a28bf5d3ddd0 \
--hash=sha256:fbabfaceaeb587e16f7008f7795cd80d20ec548dc7f94fbb0d4ec2e038ce563f --hash=sha256:fbabfaceaeb587e16f7008f7795cd80d20ec548dc7f94fbb0d4ec2e038ce563f
# via -r src/backend/requirements-dev.in # via -r src/backend/requirements-dev.in
cryptography==48.0.0 \ cryptography==48.0.1 \
--hash=sha256:0890f502ddf7d9c6426129c3f49f5c0a39278ed7cd6322c8755ffca6ee675a13 \ --hash=sha256:08a597acce1ff37f347400087776599e2348a3a8bc53b44120e463cd274efe4a \
--hash=sha256:0c558d2cdffd8f4bbb30fc7134c74d2ca9a476f830bb053074498fbc86f41ed6 \ --hash=sha256:09f73a725d582cef64b91281a322cd798d14a33b2b6f2b7ad9531dc336d84c02 \
--hash=sha256:16cd65b9330583e4619939b3a3843eec1e6e789744bb01e7c7e2e62e33c239c8 \ --hash=sha256:0df56b056bc17c1b7d6821dfa65216e62bd232d8ab05eb3db44e71d235651471 \
--hash=sha256:18349bbc56f4743c8b12dc32e2bccb2cf83ee8b69a3bba74ef8ae857e26b3d25 \ --hash=sha256:0ee6ea481db1ab889cba043ec1eda17bb9c1ea79db6722f779c3667f9f70322f \
--hash=sha256:1e2d54c8be6152856a36f0882ab231e70f8ec7f14e93cf87db8a2ed056bf160c \ --hash=sha256:15254441469dd6bf027039453288e2072124f8b6603563f5d759e1c9b69273fa \
--hash=sha256:22a5cb272895dce158b2cacdfdc3debd299019659f42947dbdac6f32d68fe832 \ --hash=sha256:266f4ee051abb2f725b74ef8072b521ce1feacf685a3364fa6a6b45548db791a \
--hash=sha256:27241b1dc9962e056062a8eef1991d02c3a24569c95975bd2322a8a52c6e5e12 \ --hash=sha256:2c37f2461406063b417837f5f3daab668652acd82423efcd7f0a9f04be972de1 \
--hash=sha256:2b4d59804e8408e2fea7d1fbaf218e5ec984325221db76e6a241a9abd6cdd95c \ --hash=sha256:32143b24adb918f078134e1e230f1eb8cc04886b92c28b5f0041aaf3e5699225 \
--hash=sha256:2eb992bbd4661238c5a397594c83f5b4dc2bc5b848c365c8f991b6780efcc5c7 \ --hash=sha256:33842cf0888951cef5bc7ac724ab844a42044c1727b967b7f8997289a0464f92 \
--hash=sha256:369a6348999f94bbd53435c894377b20ab95f25a9065c283570e70150d8abc3c \ --hash=sha256:3752f2dbc8f07a30aad2932c986cea495b03bb554887828225da104f732852b6 \
--hash=sha256:3cb07a3ed6431663cd321ea8a000a1314c74211f823e4177fefa2255e057d1ec \ --hash=sha256:39489bfca54c7a1f6b297efcd8bc608ab92d16c4ca631b0cad4da46724588b24 \
--hash=sha256:40ba1f85eaa6959837b1d51c9767e230e14612eea4ef110ee8854ada22da1bf5 \ --hash=sha256:3e4a1a3232eef2e6c732827d5722db29a0cc8b27af2a4d865b094cf954be9ca1 \
--hash=sha256:4defde8685ae324a9eb9d818717e93b4638ef67070ac9bc15b8ca85f63048355 \ --hash=sha256:3fd2ca57062b241c856670b073487d2e86c4637937ca5601e48f97bf8e11fc8f \
--hash=sha256:55b7718303bf06a5753dcdccf2f3945cf18ad7bffde41b61226e4db31ab89a9c \ --hash=sha256:42fcd8e26fe555d9b3577a135f5091fefa0aa4e99129c23fb56787a1bd4ada72 \
--hash=sha256:561215ea3879cb1cbbf272867e2efda62476f240fb58c64de6b393ae19246741 \ --hash=sha256:43c5835e2cb98c8733d86f57d6fc879b613f5c3478607281c3e36daffc6dd8a6 \
--hash=sha256:58d00498e8933e4a194f3076aee1b4a97dfec1a6da444535755822fe5d8b0b86 \ --hash=sha256:48fe40804d4caa2288f24e70ca8c64c42dd826da0ad7e4f1b41b2128d679e6c8 \
--hash=sha256:59baa2cb386c4f0b9905bd6eb4c2a79a69a128408fd31d32ca4d7102d4156321 \ --hash=sha256:4ab0a343c807bbcd90c971cd1ecf072937cd01847a9e002bef88fb47ac6be577 \
--hash=sha256:5a5ed8fde7a1d09376ca0b40e68cd59c69fe23b1f9768bd5824f54681626032a \ --hash=sha256:4fdc69f8e4316bcf0c8c8ec1f26f285d12e8142d88d96c876a59a03be3f6ae67 \
--hash=sha256:5b012212e08b8dd5edc78ef54da83dd9892fd9105323b3993eff6bea65dc21d7 \ --hash=sha256:6184ca7b174f28d7c703f1290d4b297217c45355f77a98f67e9b7f14549ac54a \
--hash=sha256:5c3932f4436d1cccb036cb0eaef46e6e2db91035166f1ad6505c3c9d5a635920 \ --hash=sha256:66fd0771e7b9c6dcd44cf1120690d2338d16d72795cf40cae2786a39eba65429 \
--hash=sha256:614d0949f4790582d2cc25553abd09dd723025f0c0e7c67376a1d77196743d6e \ --hash=sha256:6b2c0c3e6ccf3ade7750f836ef3ee36eea250cc467d45c256895573ac08cc6f1 \
--hash=sha256:76341972e1eff8b4bea859f09c0d3e64b96ce931b084f9b9b7db8ef364c30eff \ --hash=sha256:735824ec41b7f74a7c45fb1591349333e4c696cb6c044e5f46356e560143e4cd \
--hash=sha256:77a2ccbbe917f6710e05ba9adaa25fb5075620bf3ea6fb751997875aff4ae4bd \ --hash=sha256:7e234ac052af99f2700826a5c29ea99d9c1b1f80341cde62d11c8154dc8e0bd9 \
--hash=sha256:7995ef305d7165c3f11ae07f2517e5a4f1d5c18da1376a0a9ed496336b69e5f3 \ --hash=sha256:869c3b8a53bfe27147832df48b32adadf558249d50e76cb3769d40e986b13265 \
--hash=sha256:7ce4bfae76319a532a2dc68f82cc32f5676ee792a983187dac07183690e5c66f \ --hash=sha256:86be3b1b0b6bf09482fb50a979c508d2950ed95f5621ec77f4e385962006b83a \
--hash=sha256:7e8eac43dfca5c4cccc6dad9a80504436fca53bb9bc3100a2386d730fbe6b602 \ --hash=sha256:86fe77abb1bd87afb251d4d02ada7ecf53a32cee9b67d976abb2e45a13297475 \
--hash=sha256:84cf79f0dc8b36ac5da873481716e87aef31fcfa0444f9e1d8b4b2cece142855 \ --hash=sha256:88c852a0ae366e262e5a1744b685e6a433dc8788dd2a277e418bf4904203609d \
--hash=sha256:8c7378637d7d88016fa6791c159f698b3d3eed28ebf844ac36b9dc04a14dae18 \ --hash=sha256:8ace4507d1e6533c125f4fac754f8bb8b6a74c08e92179dabd7e16571a3efbf3 \
--hash=sha256:8cd666227ef7af430aa5914a9910e0ddd703e75f039cef0825cd0da71b6b711a \ --hash=sha256:92a46e1d638daa264ba2971c0b0489c9409787943efae4d60ffda3d091ef832c \
--hash=sha256:906cbf0670286c6e0044156bc7d4af9cbb0ef6db9f73e52c3ec56ba6bdde5336 \ --hash=sha256:9621de99d2da096006b629979efd8ae7eb2d8b822488d0c89ee4000c306c59b1 \
--hash=sha256:9071196d81abc88b3516ac8cdfad32e2b66dd4a5393a8e68a961e9161ddc6239 \ --hash=sha256:9a49ca6c81417f6a5edb50375a60cccdd70fa0a91a5211829dbea74eba94d2ac \
--hash=sha256:9249e3cd978541d665967ac2cb2787fd6a62bddf1e75b3e347a594d7dacf4f74 \ --hash=sha256:9bd3f92d76217892b15df84ca256c2c113d386fdda7a7d8691aeeced976507c6 \
--hash=sha256:984a20b0f62a26f48a3396c72e4bc34c66e356d356bf370053066b3b6d54634a \ --hash=sha256:9de21387aa95e2a895823d0745b430bed4f33503ba9ab5e0b5311f33e37d66d2 \
--hash=sha256:9be5aafa5736574f8f15f262adc81b2a9869e2cfe9014d52a44633905b40d52c \ --hash=sha256:b024e784ad6c077ee0147b35ea9cbfc1e34e1fd4c1dcca214c2794d73a12df08 \
--hash=sha256:9c459db21422be75e2809370b829a87eb37f74cd785fc4aa9ea1e5f43b47cda4 \ --hash=sha256:b4e391975f038e66432328639620a4aff2d307513b004f1ca06d6225bced815c \
--hash=sha256:9ccdac7d40688ecb5a3b4a604b8a88c8002e3442d6c60aead1db2a89a041560c \ --hash=sha256:b74ca3b8e5ecdd833bf6a002ca41b4793bb27fb8f1c06ffaf2643c9e9140e31b \
--hash=sha256:a0e692c683f4df67815a2d258b324e66f4738bd7a96a218c826dce4f4bd05d8f \ --hash=sha256:b7a2d1a937a738a881737cec135a38bb61470589b17515b9f73f571d0ae10401 \
--hash=sha256:a5da777e32ffed6f85a7b2b3f7c5cbc88c146bfcd0a1d7baf5fcc6c52ee35dd4 \ --hash=sha256:b9a32b876490d66c8bcc9963ef220199569748434ab01a9d6aaeabf88e7f5158 \
--hash=sha256:a64697c641c7b1b2178e573cbc31c7c6684cd56883a478d75143dbb7118036db \ --hash=sha256:bd81490cd5801d755cf97bb68ac191f14b708470b1c7cf4580f669b9c9264cd8 \
--hash=sha256:ad64688338ed4bc1a6618076ba75fd7194a5f1797ac60b47afe926285adb3166 \ --hash=sha256:c1400da5e32a43253392277eac7490a60e497d810a63dd5608d71bbd7af507c9 \
--hash=sha256:bd72e68b06bb1e96913f97dd4901119bc17f39d4586a5adf2d3e47bc2b9d58b5 \ --hash=sha256:d069066deead00ac7f090be101be875a06855908f7ec004c27b8fefb4acfb411 \
--hash=sha256:c17dfe85494deaeddc5ce251aebd1d60bbe6afc8b62071bb0b469431a000124f \ --hash=sha256:d5d30989c6917b478b5817902e85fddaea2261efa8648383d965381ccb9e1ac4 \
--hash=sha256:c18684a7f0cc9a3cb60328f496b8e3372def7c5d2df39ac267878b05565aaaae \ --hash=sha256:df637c05205ea7c1d7fbcbe54bbfea648a52951155f997af13d895d0ecc96991 \
--hash=sha256:cc90c0b39b2e3c65ef52c804b72e3c58f8a04ab2a1871272798e5f9572c17d20 \ --hash=sha256:e361afba8918070d376df76f408a4f67fec0ee9cff81a99e48fe9a233ef59e17 \
--hash=sha256:db63bf618e5dea46c07de12e900fe1cdd2541e6dc9dbae772a70b7d4d4765f6a \ --hash=sha256:eb86ce1af36fe65041b6db9a8bb064ee621a7e5fded0f80d475ec243477cd242 \
--hash=sha256:ea8990436d914540a40ab24b6a77c0969695ed52f4a4874c5137ccf7045a7057 \ --hash=sha256:f0d27a5696721ef7a672b8c810f6aded391058e0b9486e63e6d93baf765da691 \
--hash=sha256:ecde28a596bead48b0cfd2a1b4416c3d43074c2d785e3a398d7ec1fc4d0f7fbb \ --hash=sha256:f2ceef93cb096aa3c4cc4b5c94ca6131f9196d28c64d6111533402a9b2054d41 \
--hash=sha256:f5333311663ea94f75dd408665686aaf426563556bb5283554a3539177e03b8c \ --hash=sha256:f817adc181390bd54f2f700107a7419040fb7c1bdf2fc26f36551a06a68c3345 \
--hash=sha256:fdfef35d751d510fcef5252703621574364fec16418c4a1e5e1055248401054b --hash=sha256:fe0180af5bf9236518a087e35bf2d9a347d5f5f51e63c579d683ddff424e3d46
# via # via
# -c src/backend/requirements.txt # -c src/backend/requirements.txt
# pdfminer-six # pdfminer-six
@ -476,9 +476,9 @@ pdfminer-six==20260107 \
--hash=sha256:366585ba97e80dffa8f00cebe303d2f381884d8637af4ce422f1df3ef38111a9 \ --hash=sha256:366585ba97e80dffa8f00cebe303d2f381884d8637af4ce422f1df3ef38111a9 \
--hash=sha256:96bfd431e3577a55a0efd25676968ca4ce8fd5b53f14565f85716ff363889602 --hash=sha256:96bfd431e3577a55a0efd25676968ca4ce8fd5b53f14565f85716ff363889602
# via -r src/backend/requirements-dev.in # via -r src/backend/requirements-dev.in
pip==26.1.1 \ pip==26.1.2 \
--hash=sha256:99cb1c2899893b075ff56e4ed0af55669a955b49ad7fb8d8603ecdaf4ed653fb \ --hash=sha256:382ff9f685ee3bc25864f820aa50505825f10f5458ffff07e30a6d96e5715cab \
--hash=sha256:d36762751d156a4ee895de8af39aa0abeeeb577f93a2eca6ab62467bbf0f8a78 --hash=sha256:f49cd134c61cf2fd75e0ce2676db03e4054504a5a4986d00f8299ae632dc4605
# via pip-tools # via pip-tools
pip-tools==7.5.3 \ pip-tools==7.5.3 \
--hash=sha256:3aac0c473240ae90db7213c033401f345b05197293ccbdd2704e52e7a783785e \ --hash=sha256:3aac0c473240ae90db7213c033401f345b05197293ccbdd2704e52e7a783785e \

View File

@ -1,7 +1,7 @@
# Please keep this list sorted - if you pin a version provide a reason # Please keep this list sorted - if you pin a version provide a reason
django<6.0 # Django package django<6.0 # Django package
blessed # CLI for Q Monitor blessed # CLI for Q Monitor
cryptography>=48.0.0 # Core cryptographic functionality cryptography>=48.0.1 # Core cryptographic functionality
django-anymail[amazon_ses,postal] # Email backend for various providers django-anymail[amazon_ses,postal] # Email backend for various providers
django-allauth[mfa,socialaccount,saml,openid,headless] # SSO for external providers via OpenID django-allauth[mfa,socialaccount,saml,openid,headless] # SSO for external providers via OpenID
django-cleanup # Automated deletion of old / unused uploaded files django-cleanup # Automated deletion of old / unused uploaded files

View File

@ -435,56 +435,56 @@ charset-normalizer==3.4.7 \
--hash=sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79 \ --hash=sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79 \
--hash=sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464 --hash=sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464
# via requests # via requests
cryptography==48.0.0 \ cryptography==48.0.1 \
--hash=sha256:0890f502ddf7d9c6426129c3f49f5c0a39278ed7cd6322c8755ffca6ee675a13 \ --hash=sha256:08a597acce1ff37f347400087776599e2348a3a8bc53b44120e463cd274efe4a \
--hash=sha256:0c558d2cdffd8f4bbb30fc7134c74d2ca9a476f830bb053074498fbc86f41ed6 \ --hash=sha256:09f73a725d582cef64b91281a322cd798d14a33b2b6f2b7ad9531dc336d84c02 \
--hash=sha256:16cd65b9330583e4619939b3a3843eec1e6e789744bb01e7c7e2e62e33c239c8 \ --hash=sha256:0df56b056bc17c1b7d6821dfa65216e62bd232d8ab05eb3db44e71d235651471 \
--hash=sha256:18349bbc56f4743c8b12dc32e2bccb2cf83ee8b69a3bba74ef8ae857e26b3d25 \ --hash=sha256:0ee6ea481db1ab889cba043ec1eda17bb9c1ea79db6722f779c3667f9f70322f \
--hash=sha256:1e2d54c8be6152856a36f0882ab231e70f8ec7f14e93cf87db8a2ed056bf160c \ --hash=sha256:15254441469dd6bf027039453288e2072124f8b6603563f5d759e1c9b69273fa \
--hash=sha256:22a5cb272895dce158b2cacdfdc3debd299019659f42947dbdac6f32d68fe832 \ --hash=sha256:266f4ee051abb2f725b74ef8072b521ce1feacf685a3364fa6a6b45548db791a \
--hash=sha256:27241b1dc9962e056062a8eef1991d02c3a24569c95975bd2322a8a52c6e5e12 \ --hash=sha256:2c37f2461406063b417837f5f3daab668652acd82423efcd7f0a9f04be972de1 \
--hash=sha256:2b4d59804e8408e2fea7d1fbaf218e5ec984325221db76e6a241a9abd6cdd95c \ --hash=sha256:32143b24adb918f078134e1e230f1eb8cc04886b92c28b5f0041aaf3e5699225 \
--hash=sha256:2eb992bbd4661238c5a397594c83f5b4dc2bc5b848c365c8f991b6780efcc5c7 \ --hash=sha256:33842cf0888951cef5bc7ac724ab844a42044c1727b967b7f8997289a0464f92 \
--hash=sha256:369a6348999f94bbd53435c894377b20ab95f25a9065c283570e70150d8abc3c \ --hash=sha256:3752f2dbc8f07a30aad2932c986cea495b03bb554887828225da104f732852b6 \
--hash=sha256:3cb07a3ed6431663cd321ea8a000a1314c74211f823e4177fefa2255e057d1ec \ --hash=sha256:39489bfca54c7a1f6b297efcd8bc608ab92d16c4ca631b0cad4da46724588b24 \
--hash=sha256:40ba1f85eaa6959837b1d51c9767e230e14612eea4ef110ee8854ada22da1bf5 \ --hash=sha256:3e4a1a3232eef2e6c732827d5722db29a0cc8b27af2a4d865b094cf954be9ca1 \
--hash=sha256:4defde8685ae324a9eb9d818717e93b4638ef67070ac9bc15b8ca85f63048355 \ --hash=sha256:3fd2ca57062b241c856670b073487d2e86c4637937ca5601e48f97bf8e11fc8f \
--hash=sha256:55b7718303bf06a5753dcdccf2f3945cf18ad7bffde41b61226e4db31ab89a9c \ --hash=sha256:42fcd8e26fe555d9b3577a135f5091fefa0aa4e99129c23fb56787a1bd4ada72 \
--hash=sha256:561215ea3879cb1cbbf272867e2efda62476f240fb58c64de6b393ae19246741 \ --hash=sha256:43c5835e2cb98c8733d86f57d6fc879b613f5c3478607281c3e36daffc6dd8a6 \
--hash=sha256:58d00498e8933e4a194f3076aee1b4a97dfec1a6da444535755822fe5d8b0b86 \ --hash=sha256:48fe40804d4caa2288f24e70ca8c64c42dd826da0ad7e4f1b41b2128d679e6c8 \
--hash=sha256:59baa2cb386c4f0b9905bd6eb4c2a79a69a128408fd31d32ca4d7102d4156321 \ --hash=sha256:4ab0a343c807bbcd90c971cd1ecf072937cd01847a9e002bef88fb47ac6be577 \
--hash=sha256:5a5ed8fde7a1d09376ca0b40e68cd59c69fe23b1f9768bd5824f54681626032a \ --hash=sha256:4fdc69f8e4316bcf0c8c8ec1f26f285d12e8142d88d96c876a59a03be3f6ae67 \
--hash=sha256:5b012212e08b8dd5edc78ef54da83dd9892fd9105323b3993eff6bea65dc21d7 \ --hash=sha256:6184ca7b174f28d7c703f1290d4b297217c45355f77a98f67e9b7f14549ac54a \
--hash=sha256:5c3932f4436d1cccb036cb0eaef46e6e2db91035166f1ad6505c3c9d5a635920 \ --hash=sha256:66fd0771e7b9c6dcd44cf1120690d2338d16d72795cf40cae2786a39eba65429 \
--hash=sha256:614d0949f4790582d2cc25553abd09dd723025f0c0e7c67376a1d77196743d6e \ --hash=sha256:6b2c0c3e6ccf3ade7750f836ef3ee36eea250cc467d45c256895573ac08cc6f1 \
--hash=sha256:76341972e1eff8b4bea859f09c0d3e64b96ce931b084f9b9b7db8ef364c30eff \ --hash=sha256:735824ec41b7f74a7c45fb1591349333e4c696cb6c044e5f46356e560143e4cd \
--hash=sha256:77a2ccbbe917f6710e05ba9adaa25fb5075620bf3ea6fb751997875aff4ae4bd \ --hash=sha256:7e234ac052af99f2700826a5c29ea99d9c1b1f80341cde62d11c8154dc8e0bd9 \
--hash=sha256:7995ef305d7165c3f11ae07f2517e5a4f1d5c18da1376a0a9ed496336b69e5f3 \ --hash=sha256:869c3b8a53bfe27147832df48b32adadf558249d50e76cb3769d40e986b13265 \
--hash=sha256:7ce4bfae76319a532a2dc68f82cc32f5676ee792a983187dac07183690e5c66f \ --hash=sha256:86be3b1b0b6bf09482fb50a979c508d2950ed95f5621ec77f4e385962006b83a \
--hash=sha256:7e8eac43dfca5c4cccc6dad9a80504436fca53bb9bc3100a2386d730fbe6b602 \ --hash=sha256:86fe77abb1bd87afb251d4d02ada7ecf53a32cee9b67d976abb2e45a13297475 \
--hash=sha256:84cf79f0dc8b36ac5da873481716e87aef31fcfa0444f9e1d8b4b2cece142855 \ --hash=sha256:88c852a0ae366e262e5a1744b685e6a433dc8788dd2a277e418bf4904203609d \
--hash=sha256:8c7378637d7d88016fa6791c159f698b3d3eed28ebf844ac36b9dc04a14dae18 \ --hash=sha256:8ace4507d1e6533c125f4fac754f8bb8b6a74c08e92179dabd7e16571a3efbf3 \
--hash=sha256:8cd666227ef7af430aa5914a9910e0ddd703e75f039cef0825cd0da71b6b711a \ --hash=sha256:92a46e1d638daa264ba2971c0b0489c9409787943efae4d60ffda3d091ef832c \
--hash=sha256:906cbf0670286c6e0044156bc7d4af9cbb0ef6db9f73e52c3ec56ba6bdde5336 \ --hash=sha256:9621de99d2da096006b629979efd8ae7eb2d8b822488d0c89ee4000c306c59b1 \
--hash=sha256:9071196d81abc88b3516ac8cdfad32e2b66dd4a5393a8e68a961e9161ddc6239 \ --hash=sha256:9a49ca6c81417f6a5edb50375a60cccdd70fa0a91a5211829dbea74eba94d2ac \
--hash=sha256:9249e3cd978541d665967ac2cb2787fd6a62bddf1e75b3e347a594d7dacf4f74 \ --hash=sha256:9bd3f92d76217892b15df84ca256c2c113d386fdda7a7d8691aeeced976507c6 \
--hash=sha256:984a20b0f62a26f48a3396c72e4bc34c66e356d356bf370053066b3b6d54634a \ --hash=sha256:9de21387aa95e2a895823d0745b430bed4f33503ba9ab5e0b5311f33e37d66d2 \
--hash=sha256:9be5aafa5736574f8f15f262adc81b2a9869e2cfe9014d52a44633905b40d52c \ --hash=sha256:b024e784ad6c077ee0147b35ea9cbfc1e34e1fd4c1dcca214c2794d73a12df08 \
--hash=sha256:9c459db21422be75e2809370b829a87eb37f74cd785fc4aa9ea1e5f43b47cda4 \ --hash=sha256:b4e391975f038e66432328639620a4aff2d307513b004f1ca06d6225bced815c \
--hash=sha256:9ccdac7d40688ecb5a3b4a604b8a88c8002e3442d6c60aead1db2a89a041560c \ --hash=sha256:b74ca3b8e5ecdd833bf6a002ca41b4793bb27fb8f1c06ffaf2643c9e9140e31b \
--hash=sha256:a0e692c683f4df67815a2d258b324e66f4738bd7a96a218c826dce4f4bd05d8f \ --hash=sha256:b7a2d1a937a738a881737cec135a38bb61470589b17515b9f73f571d0ae10401 \
--hash=sha256:a5da777e32ffed6f85a7b2b3f7c5cbc88c146bfcd0a1d7baf5fcc6c52ee35dd4 \ --hash=sha256:b9a32b876490d66c8bcc9963ef220199569748434ab01a9d6aaeabf88e7f5158 \
--hash=sha256:a64697c641c7b1b2178e573cbc31c7c6684cd56883a478d75143dbb7118036db \ --hash=sha256:bd81490cd5801d755cf97bb68ac191f14b708470b1c7cf4580f669b9c9264cd8 \
--hash=sha256:ad64688338ed4bc1a6618076ba75fd7194a5f1797ac60b47afe926285adb3166 \ --hash=sha256:c1400da5e32a43253392277eac7490a60e497d810a63dd5608d71bbd7af507c9 \
--hash=sha256:bd72e68b06bb1e96913f97dd4901119bc17f39d4586a5adf2d3e47bc2b9d58b5 \ --hash=sha256:d069066deead00ac7f090be101be875a06855908f7ec004c27b8fefb4acfb411 \
--hash=sha256:c17dfe85494deaeddc5ce251aebd1d60bbe6afc8b62071bb0b469431a000124f \ --hash=sha256:d5d30989c6917b478b5817902e85fddaea2261efa8648383d965381ccb9e1ac4 \
--hash=sha256:c18684a7f0cc9a3cb60328f496b8e3372def7c5d2df39ac267878b05565aaaae \ --hash=sha256:df637c05205ea7c1d7fbcbe54bbfea648a52951155f997af13d895d0ecc96991 \
--hash=sha256:cc90c0b39b2e3c65ef52c804b72e3c58f8a04ab2a1871272798e5f9572c17d20 \ --hash=sha256:e361afba8918070d376df76f408a4f67fec0ee9cff81a99e48fe9a233ef59e17 \
--hash=sha256:db63bf618e5dea46c07de12e900fe1cdd2541e6dc9dbae772a70b7d4d4765f6a \ --hash=sha256:eb86ce1af36fe65041b6db9a8bb064ee621a7e5fded0f80d475ec243477cd242 \
--hash=sha256:ea8990436d914540a40ab24b6a77c0969695ed52f4a4874c5137ccf7045a7057 \ --hash=sha256:f0d27a5696721ef7a672b8c810f6aded391058e0b9486e63e6d93baf765da691 \
--hash=sha256:ecde28a596bead48b0cfd2a1b4416c3d43074c2d785e3a398d7ec1fc4d0f7fbb \ --hash=sha256:f2ceef93cb096aa3c4cc4b5c94ca6131f9196d28c64d6111533402a9b2054d41 \
--hash=sha256:f5333311663ea94f75dd408665686aaf426563556bb5283554a3539177e03b8c \ --hash=sha256:f817adc181390bd54f2f700107a7419040fb7c1bdf2fc26f36551a06a68c3345 \
--hash=sha256:fdfef35d751d510fcef5252703621574364fec16418c4a1e5e1055248401054b --hash=sha256:fe0180af5bf9236518a087e35bf2d9a347d5f5f51e63c579d683ddff424e3d46
# via # via
# -r src/backend/requirements.in # -r src/backend/requirements.in
# django-anymail # django-anymail

View File

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

View File

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

View File

@ -179,6 +179,55 @@ function ImporterDefaultField({
); );
} }
function ImporterLookupFieldSelector({
column,
session
}: Readonly<{ column: any; session: ImportSessionState }>) {
const api = useApi();
const fieldDef = session.availableFields[column.field];
const lookupFields: string[] = fieldDef?.lookup_fields ?? [];
const [selected, setSelected] = useState<string>(column.lookup_field ?? '');
useEffect(() => {
setSelected(column.lookup_field ?? '');
}, [column.lookup_field]);
if (lookupFields.length === 0) {
return null;
}
const options = [
{ value: '', label: t`Auto` },
...lookupFields.map((f: string) => ({ value: f, label: f }))
];
const onChange = useCallback(
(value: string | null) => {
const next = value ?? '';
api
.patch(
apiUrl(ApiEndpoints.import_session_column_mapping_list, column.pk),
{ lookup_field: next || null }
)
.then(() => setSelected(next))
.catch(() => {});
},
[column.pk]
);
return (
<Select
aria-label={`import-lookup-field-${column.field}`}
data={options}
value={selected}
onChange={onChange}
size='sm'
/>
);
}
function ImporterColumnTableRow({ function ImporterColumnTableRow({
session, session,
column, column,
@ -210,6 +259,9 @@ function ImporterColumnTableRow({
<Table.Td> <Table.Td>
<ImporterColumn column={column} options={options} /> <ImporterColumn column={column} options={options} />
</Table.Td> </Table.Td>
<Table.Td>
<ImporterLookupFieldSelector column={column} session={session} />
</Table.Td>
<Table.Td> <Table.Td>
<ImporterDefaultField <ImporterDefaultField
fieldName={column.field} fieldName={column.field}
@ -285,6 +337,7 @@ export default function ImporterColumnSelector({
<Table.Th>{t`Database Field`}</Table.Th> <Table.Th>{t`Database Field`}</Table.Th>
<Table.Th>{t`Field Description`}</Table.Th> <Table.Th>{t`Field Description`}</Table.Th>
<Table.Th>{t`Imported Column`}</Table.Th> <Table.Th>{t`Imported Column`}</Table.Th>
<Table.Th>{t`Lookup Field`}</Table.Th>
<Table.Th>{t`Default Value`}</Table.Th> <Table.Th>{t`Default Value`}</Table.Th>
</Table.Tr> </Table.Tr>
</Table.Thead> </Table.Thead>

View File

@ -4,7 +4,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
import { ApiEndpoints } from '@lib/enums/ApiEndpoints'; import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
import { ModelType } from '@lib/enums/ModelType'; import { ModelType } from '@lib/enums/ModelType';
import { apiUrl } from '@lib/functions/Api'; 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 { t } from '@lingui/core/macro';
import type { import type {
StatusCodeInterface, StatusCodeInterface,
@ -117,32 +117,44 @@ export function useParameterTemplateFields(): ApiFormFieldSet {
}, []); }, []);
} }
export function useParameterFields({ /**
modelType, * Shared hook for the dynamic "value" field on parameter forms.
modelId *
}: { * When the user selects a parameter template, the field type for the
modelType: ModelType; * corresponding value input (data / default_value) must change to match the
modelId: number; * template's data type (boolean, choice, related-field selection list, or
}): ApiFormFieldSet { * 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 api = useApi();
const user = useUserState.getState();
const templateCreateFields = useParameterTemplateFields();
const [selectionListId, setSelectionListId] = useState<number | null>(null); const [selectionListId, setSelectionListId] = useState<number | null>(null);
// Valid field choices
const [choices, setChoices] = useState<any[]>([]); const [choices, setChoices] = useState<any[]>([]);
// Field type for "data" input
const [fieldType, setFieldType] = useState< const [fieldType, setFieldType] = useState<
'string' | 'boolean' | 'choice' | 'related field' 'string' | 'boolean' | 'choice' | 'related field'
>('string'); >('string');
// Memoized value for the "data" field
const [data, setData] = useState<string>(''); const [data, setData] = useState<string>('');
const reset = useCallback(() => {
setSelectionListId(null);
setFieldType('string');
setChoices([]);
setData('');
}, []);
useEffect(() => {
reset();
}, [resetDep, reset]);
const fetchSelectionEntry = useCallback( const fetchSelectionEntry = useCallback(
(value: any) => { (value: any) => {
if (!value || !selectionListId) { if (!value || !selectionListId) {
@ -151,9 +163,7 @@ export function useParameterFields({
return api return api
.get(apiUrl(ApiEndpoints.selectionentry_list, selectionListId), { .get(apiUrl(ApiEndpoints.selectionentry_list, selectionListId), {
params: { params: { value: value }
value: value
}
}) })
.then((response) => { .then((response) => {
if (response.data && response.data.length == 1) { if (response.data && response.data.length == 1) {
@ -166,13 +176,102 @@ export function useParameterFields({
[selectionListId] [selectionListId]
); );
// Reset the field type and choices when the model changes const onTemplateValueChange = useCallback(
useEffect(() => { (value: any, record: any) => {
setSelectionListId(null); setSelectionListId(record?.selectionlist || null);
setFieldType('string'); setData('');
setChoices([]);
setData(''); if (record?.checkbox) {
}, [modelType, modelId]); 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 useMemo(() => {
return { return {
@ -189,97 +288,17 @@ export function useParameterFields({
for_model: modelType, for_model: modelType,
enabled: true enabled: true
}, },
onValueChange: (value: any, record: any) => { onValueChange: onTemplateValueChange,
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');
}
},
addCreateFields: user.isStaff() ? templateCreateFields : undefined addCreateFields: user.isStaff() ? templateCreateFields : undefined
}, },
data: { data: valueFieldConfig,
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
},
note: {} note: {}
}; };
}, [ }, [
data,
modelType, modelType,
fieldType,
choices,
modelId, modelId,
selectionListId, onTemplateValueChange,
valueFieldConfig,
templateCreateFields, templateCreateFields,
user user
]); ]);

View File

@ -35,6 +35,7 @@ import InstanceDetail from '../../components/nav/InstanceDetail';
import NavigationTree from '../../components/nav/NavigationTree'; import NavigationTree from '../../components/nav/NavigationTree';
import { PageDetail } from '../../components/nav/PageDetail'; import { PageDetail } from '../../components/nav/PageDetail';
import { PanelGroup } from '../../components/panels/PanelGroup'; import { PanelGroup } from '../../components/panels/PanelGroup';
import ParametersPanel from '../../components/panels/ParametersPanel';
import SegmentedControlPanel from '../../components/panels/SegmentedControlPanel'; import SegmentedControlPanel from '../../components/panels/SegmentedControlPanel';
import { partCategoryFields } from '../../forms/PartForms'; import { partCategoryFields } from '../../forms/PartForms';
import { import {
@ -323,9 +324,14 @@ export default function CategoryDetail() {
/> />
) )
}, },
ParametersPanel({
model_type: ModelType.partcategory,
model_id: category?.pk,
hidden: !id || !category.pk
}),
{ {
name: 'category_parameters', name: 'category_parameters',
label: t`Category Parameters`, label: t`Parameter Templates`,
icon: <IconListCheck />, icon: <IconListCheck />,
hidden: !id || !category.pk, hidden: !id || !category.pk,
content: <PartCategoryTemplateTable categoryId={category?.pk} /> content: <PartCategoryTemplateTable categoryId={category?.pk} />

View File

@ -1,5 +1,5 @@
import { t } from '@lingui/core/macro'; import { t } from '@lingui/core/macro';
import { Group, Text } from '@mantine/core'; import { Alert, Group, Stack, Text } from '@mantine/core';
import { useCallback, useMemo, useState } from 'react'; import { useCallback, useMemo, useState } from 'react';
import { AddItemButton } from '@lib/components/AddItemButton'; import { AddItemButton } from '@lib/components/AddItemButton';
@ -15,6 +15,8 @@ import useTable from '@lib/hooks/UseTable';
import type { TableFilter } from '@lib/types/Filters'; import type { TableFilter } from '@lib/types/Filters';
import type { ApiFormFieldSet } from '@lib/types/Forms'; import type { ApiFormFieldSet } from '@lib/types/Forms';
import type { TableColumn } from '@lib/types/Tables'; import type { TableColumn } from '@lib/types/Tables';
import { IconInfoCircle } from '@tabler/icons-react';
import { useDynamicParameterValueField } from '../../forms/CommonForms';
import { import {
useCreateApiFormModal, useCreateApiFormModal,
useDeleteApiFormModal, useDeleteApiFormModal,
@ -31,16 +33,21 @@ export default function PartCategoryTemplateTable({
const table = useTable('part-category-parameter-templates'); const table = useTable('part-category-parameter-templates');
const user = useUserState(); const user = useUserState();
const { onTemplateValueChange, valueFieldConfig, reset } =
useDynamicParameterValueField(categoryId);
const formFields: ApiFormFieldSet = useMemo(() => { const formFields: ApiFormFieldSet = useMemo(() => {
return { return {
category: { category: {
value: categoryId, value: categoryId,
disabled: categoryId !== undefined disabled: categoryId !== undefined
}, },
template: {}, template: {
default_value: {} onValueChange: onTemplateValueChange
},
default_value: valueFieldConfig
}; };
}, [categoryId]); }, [categoryId, onTemplateValueChange, valueFieldConfig]);
const [selectedTemplate, setSelectedTemplate] = useState<number>(0); const [selectedTemplate, setSelectedTemplate] = useState<number>(0);
@ -48,6 +55,7 @@ export default function PartCategoryTemplateTable({
url: ApiEndpoints.category_parameter_list, url: ApiEndpoints.category_parameter_list,
title: t`Add Category Parameter`, title: t`Add Category Parameter`,
fields: useMemo(() => ({ ...formFields }), [formFields]), fields: useMemo(() => ({ ...formFields }), [formFields]),
onOpen: reset,
table: table table: table
}); });
@ -56,6 +64,7 @@ export default function PartCategoryTemplateTable({
pk: selectedTemplate, pk: selectedTemplate,
title: t`Edit Category Parameter`, title: t`Edit Category Parameter`,
fields: useMemo(() => ({ ...formFields }), [formFields]), fields: useMemo(() => ({ ...formFields }), [formFields]),
onOpen: reset,
table: table table: table
}); });
@ -152,6 +161,15 @@ export default function PartCategoryTemplateTable({
{newTemplate.modal} {newTemplate.modal}
{editTemplate.modal} {editTemplate.modal}
{deleteTemplate.modal} {deleteTemplate.modal}
<Alert
color='blue'
icon={<IconInfoCircle />}
title={t`Part Category Parameters Templates`}
>
<Stack gap='xs'>
<Text>{t`Parts which are created within this category will inherit the default values specified here.`}</Text>
</Stack>
</Alert>
<InvenTreeTable <InvenTreeTable
url={apiUrl(ApiEndpoints.category_parameter_list)} url={apiUrl(ApiEndpoints.category_parameter_list)}
tableState={table} tableState={table}

View File

@ -123,6 +123,7 @@ export const test = baseTest.extend<{}, {}>({
msg.text() != msg.text() !=
'Failed to load resource: the server responded with a status of 400 (Bad Request)' && 'Failed to load resource: the server responded with a status of 400 (Bad Request)' &&
!msg.text().includes('/this/does/not/exist.js') && !msg.text().includes('/this/does/not/exist.js') &&
!msg.text().includes('Error removing trace ID:') &&
!url.includes('/this/does/not/exist.js') && !url.includes('/this/does/not/exist.js') &&
!url.includes('/api/user/me/') && !url.includes('/api/user/me/') &&
!url.includes('/api/user/me/token/') && !url.includes('/api/user/me/token/') &&

View File

@ -32,7 +32,7 @@ test('Forms - Hover', async ({ browser }) => {
await page.getByLabel('related-field-supplier').fill('mou'); await page.getByLabel('related-field-supplier').fill('mou');
await page.waitForLoadState('networkidle'); await page.waitForLoadState('networkidle');
await page.waitForTimeout(250); await page.waitForTimeout(250);
await page.getByRole('option', { name: 'Mouser Electronics' }).hover(); await page.getByText('Mouser Electronics').first().hover();
// Check for hover info // Check for hover info
await page.getByText('Company[ID: 2]').waitFor(); await page.getByText('Company[ID: 2]').waitFor();

View File

@ -60,7 +60,7 @@ test('Importing - Admin Center', async ({ browser }) => {
await page.getByRole('button', { name: 'Close' }).click(); await page.getByRole('button', { name: 'Close' }).click();
// Confirmation of full import success // Confirmation of full import success
await page.getByRole('cell', { name: '3 / 3' }).first().waitFor(); // await page.getByRole('cell', { name: '3 / 3' }).first().waitFor();
// Manually delete records // Manually delete records
await page.getByRole('checkbox', { name: 'Select all records' }).check(); await page.getByRole('checkbox', { name: 'Select all records' }).check();

File diff suppressed because it is too large Load Diff