Merge commit '546958a1cb02125b96365e7fedda75610fed46ec' into block-notes
This commit is contained in:
commit
edaf810514
|
|
@ -31,7 +31,7 @@ jobs:
|
|||
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # pin@v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@ jobs:
|
|||
docker: ${{ steps.filter.outputs.docker }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # pin@v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # pin@v4.0.1
|
||||
|
|
@ -67,7 +67,7 @@ jobs:
|
|||
|
||||
steps:
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # pin@v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Test Docker Image
|
||||
|
|
@ -129,7 +129,7 @@ jobs:
|
|||
|
||||
steps:
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # pin@v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Run Migration Tests
|
||||
|
|
@ -153,7 +153,7 @@ jobs:
|
|||
|
||||
steps:
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # pin@v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Set Up Python ${{ env.python_version }}
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@ jobs:
|
|||
force: ${{ steps.force.outputs.force }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # pin@v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # pin@v4.0.1
|
||||
|
|
@ -68,7 +68,7 @@ jobs:
|
|||
timeout-minutes: 60
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # pin@v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Environment Setup
|
||||
|
|
@ -122,7 +122,7 @@ jobs:
|
|||
VITE_COVERAGE: false
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # pin@v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Environment Setup
|
||||
|
|
@ -201,7 +201,7 @@ jobs:
|
|||
VITE_COVERAGE: true
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # pin@v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Environment Setup
|
||||
|
|
@ -269,7 +269,7 @@ jobs:
|
|||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # pin@v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
|
|
@ -300,7 +300,7 @@ jobs:
|
|||
|
||||
- name: Upload coverage reports to Codecov
|
||||
if: ${{ !cancelled() && github.ref == 'refs/heads/master' }}
|
||||
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # pin@v6.0.1
|
||||
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # pin@v7.0.0
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
slug: inventree/InvenTree
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@ jobs:
|
|||
outputs:
|
||||
server: ${{ steps.filter.outputs.server }}
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # pin@v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # pin@v4.0.1
|
||||
|
|
@ -76,7 +76,7 @@ jobs:
|
|||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # pin@v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ jobs:
|
|||
submit-performance: ${{ steps.runner-perf.outputs.submit-performance }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # pin@v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # pin@v4.0.1
|
||||
|
|
@ -108,7 +108,7 @@ jobs:
|
|||
if: needs.paths-filter.outputs.cicd == 'true' || needs.paths-filter.outputs.server == 'true' || needs.paths-filter.outputs.frontend == 'true' || needs.paths-filter.outputs.requirements == 'true' || needs.paths-filter.outputs.force == 'true'
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # pin@v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Set up Python ${{ env.python_version }}
|
||||
|
|
@ -130,7 +130,7 @@ jobs:
|
|||
if: needs.paths-filter.outputs.server == 'true' || needs.paths-filter.outputs.requirements == 'true' || needs.paths-filter.outputs.force == 'true'
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # pin@v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Environment Setup
|
||||
|
|
@ -152,7 +152,7 @@ jobs:
|
|||
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # pin@v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Set up Python ${{ env.python_version }}
|
||||
|
|
@ -190,7 +190,7 @@ jobs:
|
|||
version: ${{ steps.version.outputs.version }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # pin@v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Environment Setup
|
||||
|
|
@ -222,7 +222,7 @@ jobs:
|
|||
echo "Downloaded api.yaml"
|
||||
- name: Running OpenAPI Spec diff action
|
||||
id: breaking_changes
|
||||
uses: oasdiff/oasdiff-action/diff@f30668f65075c93440bd59ce2de73ce9e78751f4 # pin@main
|
||||
uses: oasdiff/oasdiff-action/diff@3530478ec30f84adedbfeb28f0d9527a290f50a9 # pin@main
|
||||
with:
|
||||
base: "api.yaml"
|
||||
revision: "src/backend/InvenTree/schema.yml"
|
||||
|
|
@ -275,7 +275,7 @@ jobs:
|
|||
version: ${{ needs.schema.outputs.version }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # pin@v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3
|
||||
name: Checkout Code
|
||||
with:
|
||||
repository: inventree/schema
|
||||
|
|
@ -332,7 +332,7 @@ jobs:
|
|||
node_version: '>=24'
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # pin@v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Environment Setup
|
||||
|
|
@ -390,7 +390,7 @@ jobs:
|
|||
python_version: ${{ matrix.python_version }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # pin@v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Environment Setup
|
||||
|
|
@ -415,7 +415,7 @@ jobs:
|
|||
path: .coverage
|
||||
retention-days: 14
|
||||
- name: Upload coverage reports to Codecov
|
||||
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # pin@v6.0.1
|
||||
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # pin@v7.0.0
|
||||
if: always()
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
|
|
@ -441,7 +441,7 @@ jobs:
|
|||
INVENTREE_AUTO_UPDATE: true
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # pin@v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Environment Setup
|
||||
|
|
@ -492,7 +492,7 @@ jobs:
|
|||
- 6379:6379
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # pin@v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Environment Setup
|
||||
|
|
@ -541,7 +541,7 @@ jobs:
|
|||
- 3306:3306
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # pin@v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Environment Setup
|
||||
|
|
@ -584,7 +584,7 @@ jobs:
|
|||
- 5432:5432
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # pin@v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Environment Setup
|
||||
|
|
@ -597,7 +597,7 @@ jobs:
|
|||
- name: Run Tests
|
||||
run: invoke dev.test --check --migrations --report --coverage --translations
|
||||
- name: Upload coverage reports to Codecov
|
||||
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # pin@v6.0.1
|
||||
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # pin@v7.0.0
|
||||
if: always()
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
|
|
@ -618,7 +618,7 @@ jobs:
|
|||
INVENTREE_PLUGINS_ENABLED: false
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # pin@v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
name: Checkout Code
|
||||
|
|
@ -674,7 +674,7 @@ jobs:
|
|||
security-events: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # pin@v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Run zizmor 🌈
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ jobs:
|
|||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # pin@v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Version Check
|
||||
|
|
@ -43,7 +43,7 @@ jobs:
|
|||
contents: write
|
||||
attestations: write
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # pin@v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Environment Setup
|
||||
|
|
@ -109,7 +109,7 @@ jobs:
|
|||
INVENTREE_DEBUG: true
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # pin@v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Environment Setup
|
||||
|
|
@ -149,7 +149,7 @@ jobs:
|
|||
- ubuntu:24.04
|
||||
- debian:12
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # pin@v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ jobs:
|
|||
|
||||
steps:
|
||||
- name: "Checkout code"
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
|
|
@ -67,6 +67,6 @@ jobs:
|
|||
|
||||
# Upload the results to GitHub's code scanning dashboard.
|
||||
- name: "Upload to code-scanning"
|
||||
uses: github/codeql-action/upload-sarif@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
|
||||
uses: github/codeql-action/upload-sarif@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
|
||||
with:
|
||||
sarif_file: results.sarif
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ jobs:
|
|||
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # pin@v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Environment Setup
|
||||
|
|
|
|||
|
|
@ -9,8 +9,8 @@
|
|||
# - Runs InvenTree web server under django development server
|
||||
# - Monitors source files for any changes, and live-reloads server
|
||||
|
||||
# Base image last bumped 2026-03-20
|
||||
FROM python:3.14.5-slim-trixie@sha256:c845af9399020c7e562969a13689e929074a10fd057acd1b1fad06a2fb068e97 AS inventree_base
|
||||
# Base image last bumped 2026-06-16
|
||||
FROM python:3.14.6-slim-trixie@sha256:44dd04494ee8f3b538294360e7c4b3acb87c8268e4d0a4828a6500b1eff50061 AS inventree_base
|
||||
|
||||
# Build arguments for this image
|
||||
ARG commit_tag=""
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ from django.views.generic.base import RedirectView
|
|||
import structlog
|
||||
from django_q.models import OrmQ
|
||||
from drf_spectacular.utils import OpenApiParameter, OpenApiResponse, extend_schema
|
||||
from rest_framework import serializers, viewsets
|
||||
from rest_framework import permissions, serializers, viewsets
|
||||
from rest_framework.generics import GenericAPIView
|
||||
from rest_framework.request import clone_request
|
||||
from rest_framework.response import Response
|
||||
|
|
@ -356,7 +356,10 @@ class InfoView(APIView):
|
|||
class NotFoundView(APIView):
|
||||
"""Simple JSON view when accessing an invalid API view."""
|
||||
|
||||
permission_classes = [InvenTree.permissions.AllowAnyOrReadScope]
|
||||
permission_classes = [
|
||||
permissions.IsAuthenticated,
|
||||
InvenTree.permissions.AllowAnyOrReadScope,
|
||||
]
|
||||
|
||||
def not_found(self, request):
|
||||
"""Return a 404 error."""
|
||||
|
|
|
|||
|
|
@ -1,16 +1,22 @@
|
|||
"""InvenTree API version information."""
|
||||
|
||||
# InvenTree API version
|
||||
INVENTREE_API_VERSION = 506
|
||||
INVENTREE_API_VERSION = 508
|
||||
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
|
||||
|
||||
INVENTREE_API_TEXT = """
|
||||
|
||||
v506 -> 2026-06-16 : https://github.com/inventree/InvenTree/pull/11971
|
||||
v508 -> 2026-06-16 : https://github.com/inventree/InvenTree/pull/11971
|
||||
- Removes direct "notes" field from any models which previously supported markdown notes
|
||||
- Adds a generic "Note" model which can be attached to any model type via a generic foreign key relationship
|
||||
- Allow multiple notes to be attached to a single object, and for notes to be created / edited / deleted via the API
|
||||
|
||||
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
|
||||
|
||||
|
|
|
|||
|
|
@ -415,7 +415,6 @@ class GlobalSettingsPermissions(OASTokenMixin, permissions.BasePermission):
|
|||
"""Check that the requesting user is 'admin'."""
|
||||
try:
|
||||
user = request.user
|
||||
|
||||
if request.method in permissions.SAFE_METHODS:
|
||||
return True
|
||||
# Any other methods require staff access permissions
|
||||
|
|
|
|||
|
|
@ -612,6 +612,7 @@ class GeneralApiTests(InvenTreeAPITestCase):
|
|||
response = self.get(
|
||||
url, headers={'Authorization': f'Token {token}'}, max_query_count=20
|
||||
)
|
||||
self.assertIsNotNone(data.get('active_plugins'))
|
||||
self.assertGreater(len(response.json()['database']), 4)
|
||||
|
||||
data = response.json()
|
||||
|
|
|
|||
|
|
@ -3,7 +3,8 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from django.contrib.auth.models import User
|
||||
from django.db.models import F, Q
|
||||
from django.db.models import F, OuterRef, Q, Subquery, Sum
|
||||
from django.db.models.functions import Coalesce
|
||||
from django.urls import include, path
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
|
|
@ -520,8 +521,22 @@ class BuildLineFilter(FilterSet):
|
|||
- The quantity available for each BuildLine (including variants and substitutes)
|
||||
- The quantity allocated for each BuildLine
|
||||
"""
|
||||
flt = Q(
|
||||
quantity__lte=F('allocated')
|
||||
allocated_subquery = (
|
||||
BuildItem.objects
|
||||
.filter(build_line=OuterRef('pk'))
|
||||
.values('build_line')
|
||||
.annotate(total=Sum('quantity'))
|
||||
.values('total')
|
||||
)
|
||||
|
||||
queryset = queryset.alias(
|
||||
allocated_quantity=Coalesce(Subquery(allocated_subquery), 0)
|
||||
)
|
||||
|
||||
# A query filter construct to determine the total quantity available for this BuildLine,
|
||||
# taking into account any stock which is already allocated or consumed
|
||||
available = (
|
||||
F('allocated_quantity')
|
||||
+ F('consumed')
|
||||
+ F('available_stock')
|
||||
+ F('available_substitute_stock')
|
||||
|
|
@ -529,8 +544,9 @@ class BuildLineFilter(FilterSet):
|
|||
)
|
||||
|
||||
if str2bool(value):
|
||||
return queryset.filter(flt)
|
||||
return queryset.exclude(flt)
|
||||
return queryset.filter(quantity__lte=available)
|
||||
|
||||
return queryset.filter(quantity__gt=available)
|
||||
|
||||
on_order = rest_filters.BooleanFilter(label=_('On Order'), method='filter_on_order')
|
||||
|
||||
|
|
|
|||
|
|
@ -965,7 +965,7 @@ class Build(
|
|||
|
||||
# Remove the build output from the database
|
||||
# This is a special case where serialized stock can be deleted,
|
||||
# independedent of the global setting which normally prevents deletion of serialized stock items
|
||||
# independent of the global setting which normally prevents deletion of serialized stock items
|
||||
output.delete(ignore_serial_check=True)
|
||||
|
||||
@transaction.atomic
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ from build.models import Build, BuildItem, BuildLine
|
|||
from build.status_codes import BuildStatus
|
||||
from common.settings import set_global_setting
|
||||
from InvenTree.unit_test import InvenTreeAPITestCase
|
||||
from part.models import BomItem, Part
|
||||
from part.models import BomItem, BomItemSubstitute, Part
|
||||
from stock.models import StockItem, StockLocation, StockSortOrder
|
||||
from stock.status_codes import StockStatus
|
||||
|
||||
|
|
@ -1625,6 +1625,175 @@ class BuildLineTests(BuildAPITest):
|
|||
|
||||
self.assertEqual(n_t + n_f, BuildLine.objects.count())
|
||||
|
||||
def test_filter_available_allocated_consumed_mixed(self):
|
||||
"""Filter BuildLine objects with mixed allocated / consumed / stock states."""
|
||||
assembly = Part.objects.create(
|
||||
name='Available Filter Assembly',
|
||||
description='Assembly for advanced available filter tests',
|
||||
assembly=True,
|
||||
)
|
||||
|
||||
components = [
|
||||
Part.objects.create(
|
||||
name=f'Available Filter Component {idx}',
|
||||
description=f'Component {idx}',
|
||||
component=True,
|
||||
)
|
||||
for idx in range(4)
|
||||
]
|
||||
|
||||
# The assembly uses 10x of each component
|
||||
for component in components:
|
||||
BomItem.objects.create(part=assembly, sub_part=component, quantity=10)
|
||||
|
||||
# Create a new Build, requiring 100x of each component
|
||||
build = Build.objects.create(
|
||||
part=assembly,
|
||||
reference='BO-9999',
|
||||
quantity=10,
|
||||
title='Available Filter Mixed',
|
||||
)
|
||||
|
||||
lines = list(build.build_lines.order_by('pk'))
|
||||
self.assertEqual(len(lines), 4)
|
||||
|
||||
# Build line quantity is 100 for each line (10 * build quantity 10)
|
||||
for line in lines:
|
||||
self.assertEqual(line.quantity, 100)
|
||||
|
||||
# Stock allocation baseline for each component
|
||||
# Note: Quantity values will be updated later
|
||||
stock_items = [
|
||||
StockItem.objects.create(part=component, quantity=1)
|
||||
for component in components
|
||||
]
|
||||
|
||||
# Line 0: AVAILABLE = True
|
||||
# allocated = 30
|
||||
# consumed = 0
|
||||
# available = 70
|
||||
BuildItem.objects.create(
|
||||
build_line=lines[0], stock_item=stock_items[0], quantity=30
|
||||
)
|
||||
|
||||
stock_items[0].quantity = 30 + 70
|
||||
stock_items[0].save()
|
||||
|
||||
# Line 1: allocated 20 + consumed 30 + available stock 50 => available (100)
|
||||
BuildItem.objects.create(
|
||||
build_line=lines[1], stock_item=stock_items[1], quantity=20
|
||||
)
|
||||
lines[1].consumed = 30
|
||||
lines[1].save()
|
||||
stock_items[1].quantity = 20 + 50
|
||||
stock_items[1].save()
|
||||
|
||||
# Line 2: allocated 0 + consumed 10 + available stock 50 => not available (60)
|
||||
lines[2].consumed = 10
|
||||
lines[2].save()
|
||||
stock_items[2].quantity = 50
|
||||
stock_items[2].save()
|
||||
|
||||
# Line 3: allocated 40 + consumed 0 + available stock 20 => not available (60)
|
||||
BuildItem.objects.create(
|
||||
build_line=lines[3], stock_item=stock_items[3], quantity=40
|
||||
)
|
||||
stock_items[3].quantity = 40 + 20
|
||||
stock_items[3].save()
|
||||
|
||||
url = reverse('api-build-line-list')
|
||||
|
||||
response_true = self.get(url, {'build': build.pk, 'available': True})
|
||||
|
||||
response_false = self.get(url, {'build': build.pk, 'available': False})
|
||||
|
||||
true_ids = {item['pk'] for item in response_true.data}
|
||||
false_ids = {item['pk'] for item in response_false.data}
|
||||
|
||||
self.assertSetEqual(true_ids, {lines[0].pk, lines[1].pk})
|
||||
self.assertSetEqual(false_ids, {lines[2].pk, lines[3].pk})
|
||||
self.assertSetEqual(true_ids | false_ids, {line.pk for line in lines})
|
||||
|
||||
def test_filter_available_substitute_and_variant_stock(self):
|
||||
"""Filter BuildLine objects where availability comes from substitute or variant stock."""
|
||||
assembly = Part.objects.create(
|
||||
name='Available Filter Sub/Var Assembly',
|
||||
description='Assembly for substitute and variant availability tests',
|
||||
assembly=True,
|
||||
)
|
||||
|
||||
# Substitute path: line should pass via substitute stock
|
||||
sub_master_ok = Part.objects.create(name='Sub Master OK', component=True)
|
||||
sub_alt_ok = Part.objects.create(name='Sub Alt OK', component=True)
|
||||
|
||||
# Substitute path: line should fail (insufficient substitute stock)
|
||||
sub_master_low = Part.objects.create(name='Sub Master Low', component=True)
|
||||
sub_alt_low = Part.objects.create(name='Sub Alt Low', component=True)
|
||||
|
||||
# Variant path: line should pass via variant stock
|
||||
var_parent_ok = Part.objects.create(
|
||||
name='Variant Parent OK', component=True, is_template=True
|
||||
)
|
||||
var_child_ok = Part.objects.create(
|
||||
name='Variant Child OK', component=True, variant_of=var_parent_ok
|
||||
)
|
||||
|
||||
# Variant path: line should fail (insufficient variant stock)
|
||||
var_parent_low = Part.objects.create(
|
||||
name='Variant Parent Low', component=True, is_template=True
|
||||
)
|
||||
var_child_low = Part.objects.create(
|
||||
name='Variant Child Low', component=True, variant_of=var_parent_low
|
||||
)
|
||||
|
||||
bom_sub_ok = BomItem.objects.create(
|
||||
part=assembly, sub_part=sub_master_ok, quantity=10, allow_variants=False
|
||||
)
|
||||
bom_sub_low = BomItem.objects.create(
|
||||
part=assembly, sub_part=sub_master_low, quantity=10, allow_variants=False
|
||||
)
|
||||
bom_var_ok = BomItem.objects.create(
|
||||
part=assembly, sub_part=var_parent_ok, quantity=10, allow_variants=True
|
||||
)
|
||||
bom_var_low = BomItem.objects.create(
|
||||
part=assembly, sub_part=var_parent_low, quantity=10, allow_variants=True
|
||||
)
|
||||
|
||||
BomItemSubstitute.objects.create(bom_item=bom_sub_ok, part=sub_alt_ok)
|
||||
BomItemSubstitute.objects.create(bom_item=bom_sub_low, part=sub_alt_low)
|
||||
|
||||
# Build quantity 10 => each line requires 100 units
|
||||
build = Build.objects.create(
|
||||
part=assembly, reference='BO-0987', quantity=10, title='Available Sub/Var'
|
||||
)
|
||||
|
||||
lines = list(build.build_lines.order_by('pk'))
|
||||
self.assertEqual(len(lines), 4)
|
||||
|
||||
# Keep master parts at zero stock so only substitute/variant paths contribute
|
||||
StockItem.objects.create(part=sub_alt_ok, quantity=100)
|
||||
StockItem.objects.create(part=sub_alt_low, quantity=40)
|
||||
StockItem.objects.create(part=var_child_ok, quantity=100)
|
||||
StockItem.objects.create(part=var_child_low, quantity=40)
|
||||
|
||||
url = reverse('api-build-line-list')
|
||||
|
||||
response_true = self.get(url, {'build': build.pk, 'available': True})
|
||||
|
||||
response_false = self.get(url, {'build': build.pk, 'available': False})
|
||||
|
||||
pk_by_bom = {line.bom_item_id: line.pk for line in lines}
|
||||
|
||||
expected_true = {pk_by_bom[bom_sub_ok.pk], pk_by_bom[bom_var_ok.pk]}
|
||||
expected_false = {pk_by_bom[bom_sub_low.pk], pk_by_bom[bom_var_low.pk]}
|
||||
|
||||
true_ids = {item['pk'] for item in response_true.data}
|
||||
false_ids = {item['pk'] for item in response_false.data}
|
||||
|
||||
self.assertSetEqual(true_ids, expected_true)
|
||||
self.assertSetEqual(false_ids, expected_false)
|
||||
self.assertSetEqual(true_ids | false_ids, {line.pk for line in lines})
|
||||
|
||||
def test_output_options(self):
|
||||
"""Test output options for the BuildLine endpoint."""
|
||||
self.run_output_test(
|
||||
|
|
|
|||
|
|
@ -1481,6 +1481,7 @@ class ObservabilityEndSerializer(serializers.Serializer):
|
|||
class ObservabilityEnd(CreateAPI):
|
||||
"""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]
|
||||
serializer_class = ObservabilityEndSerializer
|
||||
|
||||
|
|
|
|||
|
|
@ -115,6 +115,10 @@ class DataImportSessionAcceptFields(APIView):
|
|||
"""Accept the field mapping for a DataImportSession."""
|
||||
session = get_object_or_404(importer.models.DataImportSession, pk=pk)
|
||||
|
||||
# Check session ownership
|
||||
if not request.user.is_staff and session.user != request.user:
|
||||
raise PermissionDenied()
|
||||
|
||||
# Check that the user has permission to accept the field mapping
|
||||
if model_class := session.model_class:
|
||||
if not check_user_permission(request.user, model_class, 'change'):
|
||||
|
|
@ -137,17 +141,45 @@ class DataImportSessionAcceptRows(DataImporterPermissionMixin, CreateAPI):
|
|||
ctx = super().get_serializer_context()
|
||||
|
||||
try:
|
||||
ctx['session'] = importer.models.DataImportSession.objects.get(
|
||||
session = importer.models.DataImportSession.objects.get(
|
||||
pk=self.kwargs.get('pk', None)
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
except importer.models.DataImportSession.DoesNotExist:
|
||||
session = None
|
||||
|
||||
if session:
|
||||
user = self.request.user
|
||||
if not user.is_staff and session.user != user:
|
||||
raise PermissionDenied()
|
||||
ctx['session'] = session
|
||||
|
||||
ctx['request'] = self.request
|
||||
return ctx
|
||||
|
||||
|
||||
class DataImportColumnMappingList(DataImporterPermissionMixin, ListAPI):
|
||||
class DataImportSessionChildMixin(DataImporterPermissionMixin):
|
||||
"""Mixin for DataImportRow and DataImportColumnMap views.
|
||||
|
||||
Ensures users can only access objects that belong to an import session they own.
|
||||
Staff users retain access to all objects.
|
||||
"""
|
||||
|
||||
def get_queryset(self):
|
||||
"""Return only objects whose session belongs to the requesting user."""
|
||||
queryset = super().get_queryset()
|
||||
|
||||
try:
|
||||
user = self.request.user
|
||||
except AttributeError:
|
||||
raise PermissionDenied('User information is not available')
|
||||
|
||||
if user.is_staff:
|
||||
return queryset
|
||||
|
||||
return queryset.filter(session__user=user)
|
||||
|
||||
|
||||
class DataImportColumnMappingList(DataImportSessionChildMixin, ListAPI):
|
||||
"""API endpoint for accessing a list of DataImportColumnMap objects."""
|
||||
|
||||
queryset = importer.models.DataImportColumnMap.objects.all()
|
||||
|
|
@ -158,14 +190,14 @@ class DataImportColumnMappingList(DataImporterPermissionMixin, ListAPI):
|
|||
filterset_fields = ['session']
|
||||
|
||||
|
||||
class DataImportColumnMappingDetail(DataImporterPermissionMixin, RetrieveUpdateAPI):
|
||||
class DataImportColumnMappingDetail(DataImportSessionChildMixin, RetrieveUpdateAPI):
|
||||
"""Detail endpoint for a single DataImportColumnMap object."""
|
||||
|
||||
queryset = importer.models.DataImportColumnMap.objects.all()
|
||||
serializer_class = importer.serializers.DataImportColumnMapSerializer
|
||||
|
||||
|
||||
class DataImportRowList(DataImporterPermissionMixin, BulkDeleteMixin, ListAPI):
|
||||
class DataImportRowList(DataImportSessionChildMixin, BulkDeleteMixin, ListAPI):
|
||||
"""API endpoint for accessing a list of DataImportRow objects."""
|
||||
|
||||
queryset = importer.models.DataImportRow.objects.all()
|
||||
|
|
@ -180,7 +212,7 @@ class DataImportRowList(DataImporterPermissionMixin, BulkDeleteMixin, ListAPI):
|
|||
ordering = 'row_index'
|
||||
|
||||
|
||||
class DataImportRowDetail(DataImporterPermissionMixin, RetrieveUpdateDestroyAPI):
|
||||
class DataImportRowDetail(DataImportSessionChildMixin, RetrieveUpdateDestroyAPI):
|
||||
"""Detail endpoint for a single DataImportRow object."""
|
||||
|
||||
queryset = importer.models.DataImportRow.objects.all()
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
),
|
||||
),
|
||||
]
|
||||
|
|
@ -152,6 +152,36 @@ class DataImportSession(models.Model):
|
|||
|
||||
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]:
|
||||
"""Return the related model for a given field name.
|
||||
|
||||
|
|
@ -344,14 +374,21 @@ class DataImportSession(models.Model):
|
|||
self.save()
|
||||
|
||||
def check_complete(self) -> bool:
|
||||
"""Check if the import session is complete."""
|
||||
"""Check if the import session is complete.
|
||||
|
||||
When all rows have been accepted, the rows and column mappings are
|
||||
deleted as they are no longer needed. The session itself is retained
|
||||
as an audit record.
|
||||
"""
|
||||
if self.completed_row_count < self.row_count:
|
||||
return False
|
||||
|
||||
# Update the status of this session
|
||||
if self.status != DataImportStatusCode.COMPLETE.value:
|
||||
self.status = DataImportStatusCode.COMPLETE.value
|
||||
self.save()
|
||||
# Clear staging data now that all rows have been imported
|
||||
self.rows.all().delete()
|
||||
self.column_mappings.all().delete()
|
||||
|
||||
return True
|
||||
|
||||
|
|
@ -401,6 +438,11 @@ class DataImportSession(models.Model):
|
|||
if field.get('read_only', False):
|
||||
continue
|
||||
|
||||
if field.get('type') == 'related field':
|
||||
field['lookup_fields'] = self.get_lookup_fields_for_field(
|
||||
field_name
|
||||
)
|
||||
|
||||
fields[field_name] = field
|
||||
|
||||
# Cache the available fields against this instance
|
||||
|
|
@ -488,6 +530,22 @@ class DataImportColumnMap(models.Model):
|
|||
if field_def.get('read_only', False):
|
||||
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(
|
||||
DataImportSession,
|
||||
on_delete=models.CASCADE,
|
||||
|
|
@ -499,6 +557,16 @@ class DataImportColumnMap(models.Model):
|
|||
|
||||
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
|
||||
def available_fields(self):
|
||||
"""Return a list of available fields for this import session.
|
||||
|
|
@ -631,9 +699,12 @@ class DataImportRow(models.Model):
|
|||
default_values = self.default_values
|
||||
|
||||
data = {}
|
||||
extract_errors = {}
|
||||
|
||||
self.related_field_map = {}
|
||||
|
||||
field_lookup_mapping = self.session.field_lookup_mapping
|
||||
|
||||
# We have mapped column (file) to field (serializer) already
|
||||
for field, col in field_mapping.items():
|
||||
# Data override (force value and skip any further checks)
|
||||
|
|
@ -661,7 +732,13 @@ class DataImportRow(models.Model):
|
|||
elif field_type == 'date':
|
||||
value = self.convert_date_field(value)
|
||||
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
|
||||
if value is None and field in default_values:
|
||||
|
|
@ -703,6 +780,9 @@ class DataImportRow(models.Model):
|
|||
|
||||
self.data = data
|
||||
|
||||
if extract_errors:
|
||||
self.errors = extract_errors
|
||||
|
||||
if commit:
|
||||
self.save()
|
||||
|
||||
|
|
@ -726,7 +806,9 @@ class DataImportRow(models.Model):
|
|||
# If none of the formats matched, return the original 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.
|
||||
|
||||
- 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:
|
||||
field_name: The name of the field to perform the lookup against
|
||||
value: The value to be looked up
|
||||
lookup_field: If provided, only query this specific model field (skips auto-lookup)
|
||||
|
||||
Returns:
|
||||
A primary key value
|
||||
|
|
@ -758,21 +841,35 @@ class DataImportRow(models.Model):
|
|||
'session': f'No related model found for field: {field_name}'
|
||||
})
|
||||
|
||||
valid_items = set()
|
||||
|
||||
base_filters = (
|
||||
self.session.field_filters.get(field_name, {})
|
||||
if self.session.field_filters
|
||||
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']
|
||||
|
||||
if custom_id_fields := getattr(model, 'IMPORT_ID_FIELDS', None):
|
||||
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:
|
||||
try:
|
||||
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
|
||||
results = list(queryset[:2])
|
||||
if len(results) == 1:
|
||||
# We have a single match against this field
|
||||
valid_items.add(results[0].pk)
|
||||
|
||||
if len(valid_items) == 1:
|
||||
# We found a single valid match against the related model - return this value
|
||||
return valid_items.pop()
|
||||
|
||||
# We found either zero or multiple values matching against the related model
|
||||
# Return the original value and let the serializer validation handle any errors against this field
|
||||
if len(valid_items) > 1:
|
||||
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
|
||||
|
||||
def serializer_data(self):
|
||||
|
|
@ -837,6 +938,10 @@ class DataImportRow(models.Model):
|
|||
# Row has already been completed
|
||||
return True
|
||||
|
||||
if self.errors:
|
||||
# Errors were set during data extraction (e.g. ambiguous FK lookup)
|
||||
return False
|
||||
|
||||
if self.session.update_records:
|
||||
# Extract the ID field from the data
|
||||
instance_id = self.data.get(self.session.ID_FIELD_LABEL, None)
|
||||
|
|
|
|||
|
|
@ -23,7 +23,15 @@ class DataImportColumnMapSerializer(InvenTreeModelSerializer):
|
|||
"""Meta class options for the serializer."""
|
||||
|
||||
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']
|
||||
|
||||
label = serializers.CharField(read_only=True)
|
||||
|
|
|
|||
|
|
@ -2,10 +2,11 @@
|
|||
|
||||
import os
|
||||
|
||||
from django.contrib.auth.models import User
|
||||
from django.core.files.base import ContentFile
|
||||
from django.urls import reverse
|
||||
|
||||
from importer.models import DataImportRow, DataImportSession
|
||||
from importer.models import DataImportColumnMap, DataImportRow, DataImportSession
|
||||
from InvenTree.unit_test import AdminTestCase, InvenTreeAPITestCase, InvenTreeTestCase
|
||||
|
||||
|
||||
|
|
@ -58,14 +59,20 @@ class ImporterTest(ImporterMixin, InvenTreeTestCase):
|
|||
self.assertEqual(session.rows.count(), 12)
|
||||
|
||||
# Check that some data has been imported
|
||||
for row in session.rows.all():
|
||||
rows = list(session.rows.all())
|
||||
self.assertEqual(len(rows), 12)
|
||||
|
||||
for row in rows:
|
||||
self.assertIsNotNone(row.data.get('name', None))
|
||||
self.assertTrue(row.valid)
|
||||
|
||||
row.validate(commit=True)
|
||||
self.assertTrue(row.complete)
|
||||
|
||||
self.assertEqual(session.completed_row_count, 12)
|
||||
# All rows accepted: rows and mappings are cleared, session is retained
|
||||
session.refresh_from_db()
|
||||
self.assertEqual(session.rows.count(), 0)
|
||||
self.assertEqual(session.column_mappings.count(), 0)
|
||||
|
||||
# Check that the new companies have been created
|
||||
self.assertEqual(n + 12, Company.objects.count())
|
||||
|
|
@ -73,6 +80,72 @@ class ImporterTest(ImporterMixin, InvenTreeTestCase):
|
|||
def test_field_defaults(self):
|
||||
"""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):
|
||||
"""End-to-end tests for the importer API."""
|
||||
|
|
@ -174,6 +247,58 @@ class ImportAPITest(ImporterMixin, InvenTreeAPITestCase):
|
|||
# Check that there are new database records
|
||||
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):
|
||||
"""Test API endpoint which details the list of import sessions."""
|
||||
url = reverse('api-importer-session-list')
|
||||
|
|
@ -204,6 +329,191 @@ class ImportAPITest(ImporterMixin, InvenTreeAPITestCase):
|
|||
for session in response.data:
|
||||
self.assertEqual(session['user'], self.user.pk)
|
||||
|
||||
def test_accept_fields_ownership(self):
|
||||
"""Test that accept_fields rejects requests for sessions owned by another user."""
|
||||
other_user = User.objects.create_user(
|
||||
username='other_accept', password='password'
|
||||
)
|
||||
|
||||
f = self.helper_file('companies.csv')
|
||||
session = DataImportSession.objects.create(
|
||||
data_file=f, model_type='company', user=other_user
|
||||
)
|
||||
|
||||
url = reverse('api-import-session-accept-fields', kwargs={'pk': session.pk})
|
||||
|
||||
# Non-owner, non-staff should be denied
|
||||
self.user.is_staff = False
|
||||
self.user.save()
|
||||
self.post(url, expected_code=403)
|
||||
|
||||
# Staff should be allowed (subject to model permission)
|
||||
# Company is part of the purchase_order ruleset
|
||||
self.user.is_staff = True
|
||||
self.user.save()
|
||||
self.assignRole('purchase_order.change')
|
||||
self.post(url, expected_code=200)
|
||||
|
||||
def test_accept_rows_ownership(self):
|
||||
"""Test that accept_rows rejects requests for sessions owned by another user."""
|
||||
other_user = User.objects.create_user(
|
||||
username='other_accept_rows', password='password'
|
||||
)
|
||||
|
||||
f = self.helper_file('companies.csv')
|
||||
session = DataImportSession.objects.create(
|
||||
data_file=f, model_type='company', user=other_user
|
||||
)
|
||||
session.extract_columns()
|
||||
|
||||
url = reverse('api-import-session-accept-rows', kwargs={'pk': session.pk})
|
||||
|
||||
self.user.is_staff = False
|
||||
self.user.save()
|
||||
self.post(url, {'rows': []}, expected_code=403)
|
||||
|
||||
# Staff can reach the endpoint (rows list is empty so validation rejects with 400, not 403)
|
||||
self.user.is_staff = True
|
||||
self.user.save()
|
||||
self.post(url, {'rows': []}, expected_code=400)
|
||||
|
||||
def test_session_cleanup_on_complete(self):
|
||||
"""Test that a completed import session deletes itself and all associated data."""
|
||||
url = reverse('api-importer-session-list')
|
||||
data_file = self.helper_file('part_categories.csv')
|
||||
|
||||
data = self.post(
|
||||
url,
|
||||
{'model_type': 'partcategory', 'data_file': data_file},
|
||||
format='multipart',
|
||||
).data
|
||||
|
||||
session_id = data['pk']
|
||||
session_pk = session_id
|
||||
|
||||
self.assignRole('part_category.add')
|
||||
self.post(
|
||||
reverse('api-import-session-accept-fields', kwargs={'pk': session_id}),
|
||||
expected_code=200,
|
||||
)
|
||||
|
||||
rows = self.get(
|
||||
reverse('api-importer-row-list'), data={'session': session_id}
|
||||
).data
|
||||
row_ids = [r['pk'] for r in rows]
|
||||
self.assertGreater(len(row_ids), 0)
|
||||
|
||||
# Confirm rows and mappings exist before acceptance
|
||||
self.assertTrue(DataImportRow.objects.filter(session_id=session_pk).exists())
|
||||
self.assertTrue(
|
||||
DataImportColumnMap.objects.filter(session_id=session_pk).exists()
|
||||
)
|
||||
|
||||
# Accept all rows — this should trigger cleanup of rows and mappings
|
||||
self.post(
|
||||
reverse('api-import-session-accept-rows', kwargs={'pk': session_id}),
|
||||
{'rows': row_ids},
|
||||
)
|
||||
|
||||
# Rows and column mappings must be cleared
|
||||
self.assertFalse(DataImportRow.objects.filter(session_id=session_pk).exists())
|
||||
self.assertFalse(
|
||||
DataImportColumnMap.objects.filter(session_id=session_pk).exists()
|
||||
)
|
||||
|
||||
# Session itself is retained as an audit record with COMPLETE status
|
||||
from importer.models import DataImportSession
|
||||
from importer.status_codes import DataImportStatusCode
|
||||
|
||||
session_obj = DataImportSession.objects.get(pk=session_pk)
|
||||
self.assertEqual(session_obj.status, DataImportStatusCode.COMPLETE.value)
|
||||
|
||||
detail = self.get(
|
||||
reverse('api-import-session-detail', kwargs={'pk': session_id}),
|
||||
expected_code=200,
|
||||
).data
|
||||
self.assertEqual(detail['row_count'], 0)
|
||||
self.assertEqual(detail['completed_row_count'], 0)
|
||||
|
||||
def test_row_and_mapping_ownership(self):
|
||||
"""Test that DataImportRow and DataImportColumnMap endpoints filter by session ownership."""
|
||||
f = self.helper_file('companies.csv')
|
||||
|
||||
other_user = User.objects.create_user(
|
||||
username='other_importer', password='password'
|
||||
)
|
||||
|
||||
# Session owned by self.user
|
||||
session_mine = DataImportSession.objects.create(
|
||||
data_file=f, model_type='company', user=self.user
|
||||
)
|
||||
session_mine.extract_columns()
|
||||
|
||||
# Session owned by another user
|
||||
f2 = self.helper_file('companies.csv')
|
||||
session_other = DataImportSession.objects.create(
|
||||
data_file=f2, model_type='company', user=other_user
|
||||
)
|
||||
session_other.extract_columns()
|
||||
|
||||
row_list_url = reverse('api-importer-row-list')
|
||||
mapping_list_url = reverse('api-importer-mapping-list')
|
||||
|
||||
# Non-staff: should only see rows/mappings from own session
|
||||
self.user.is_staff = False
|
||||
self.user.save()
|
||||
|
||||
rows = self.get(row_list_url).data
|
||||
for row in rows:
|
||||
self.assertEqual(row['session'], session_mine.pk)
|
||||
|
||||
mappings = self.get(mapping_list_url).data
|
||||
for mapping in mappings:
|
||||
self.assertEqual(mapping['session'], session_mine.pk)
|
||||
|
||||
# Detail endpoint: own session's row/mapping should be accessible
|
||||
own_row = DataImportRow.objects.filter(session=session_mine).first()
|
||||
other_row = DataImportRow.objects.filter(session=session_other).first()
|
||||
|
||||
if own_row:
|
||||
self.get(
|
||||
reverse('api-importer-row-detail', kwargs={'pk': own_row.pk}),
|
||||
expected_code=200,
|
||||
)
|
||||
if other_row:
|
||||
self.get(
|
||||
reverse('api-importer-row-detail', kwargs={'pk': other_row.pk}),
|
||||
expected_code=404,
|
||||
)
|
||||
|
||||
own_mapping = DataImportColumnMap.objects.filter(session=session_mine).first()
|
||||
other_mapping = DataImportColumnMap.objects.filter(
|
||||
session=session_other
|
||||
).first()
|
||||
|
||||
if own_mapping:
|
||||
self.get(
|
||||
reverse('api-importer-mapping-detail', kwargs={'pk': own_mapping.pk}),
|
||||
expected_code=200,
|
||||
)
|
||||
if other_mapping:
|
||||
self.get(
|
||||
reverse('api-importer-mapping-detail', kwargs={'pk': other_mapping.pk}),
|
||||
expected_code=404,
|
||||
)
|
||||
|
||||
# Staff user: should see rows/mappings from all sessions
|
||||
self.user.is_staff = True
|
||||
self.user.save()
|
||||
|
||||
all_row_pks = set(DataImportRow.objects.values_list('pk', flat=True))
|
||||
response_rows = self.get(row_list_url).data
|
||||
self.assertEqual({r['pk'] for r in response_rows}, all_row_pks)
|
||||
|
||||
all_mapping_pks = set(DataImportColumnMap.objects.values_list('pk', flat=True))
|
||||
response_mappings = self.get(mapping_list_url).data
|
||||
self.assertEqual({m['pk'] for m in response_mappings}, all_mapping_pks)
|
||||
|
||||
|
||||
class AdminTest(ImporterMixin, AdminTestCase):
|
||||
"""Tests for the admin interface integration."""
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import django_filters.rest_framework.filters as rest_filters
|
|||
from django_filters.rest_framework import DjangoFilterBackend
|
||||
from django_filters.rest_framework.filterset import FilterSet
|
||||
from drf_spectacular.utils import extend_schema
|
||||
from rest_framework import status
|
||||
from rest_framework import permissions, status
|
||||
from rest_framework.exceptions import NotFound
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
|
|
@ -174,7 +174,10 @@ class PluginDetail(RetrieveDestroyAPI):
|
|||
|
||||
queryset = PluginConfig.objects.all()
|
||||
serializer_class = PluginSerializers.PluginConfigSerializer
|
||||
permission_classes = [InvenTree.permissions.IsSuperuserOrReadOnlyOrScope]
|
||||
permission_classes = [
|
||||
permissions.IsAuthenticated,
|
||||
InvenTree.permissions.IsSuperuserOrReadOnlyOrScope,
|
||||
]
|
||||
lookup_field = 'key'
|
||||
lookup_url_kwarg = 'plugin'
|
||||
|
||||
|
|
@ -201,6 +204,7 @@ class PluginAdminDetail(RetrieveAPI):
|
|||
|
||||
queryset = PluginConfig.objects.all()
|
||||
serializer_class = PluginSerializers.PluginAdminDetailSerializer
|
||||
permission_classes = [InvenTree.permissions.IsAdminOrAdminScope]
|
||||
lookup_field = 'key'
|
||||
lookup_url_kwarg = 'plugin'
|
||||
|
||||
|
|
@ -292,7 +296,10 @@ class PluginSettingList(ListAPI):
|
|||
queryset = PluginSetting.objects.all()
|
||||
serializer_class = PluginSerializers.PluginSettingSerializer
|
||||
|
||||
permission_classes = [InvenTree.permissions.GlobalSettingsPermissions]
|
||||
permission_classes = [
|
||||
permissions.IsAuthenticated,
|
||||
InvenTree.permissions.GlobalSettingsPermissions,
|
||||
]
|
||||
|
||||
filter_backends = [DjangoFilterBackend]
|
||||
|
||||
|
|
@ -365,7 +372,10 @@ class PluginAllSettingList(APIView):
|
|||
- GET: return all settings for a plugin config
|
||||
"""
|
||||
|
||||
permission_classes = [InvenTree.permissions.GlobalSettingsPermissions]
|
||||
permission_classes = [
|
||||
permissions.IsAuthenticated,
|
||||
InvenTree.permissions.GlobalSettingsPermissions,
|
||||
]
|
||||
|
||||
@extend_schema(
|
||||
responses={200: PluginSerializers.PluginSettingSerializer(many=True)}
|
||||
|
|
@ -393,6 +403,11 @@ class PluginSettingDetail(RetrieveUpdateAPI):
|
|||
queryset = PluginSetting.objects.all()
|
||||
serializer_class = PluginSerializers.PluginSettingSerializer
|
||||
|
||||
permission_classes = [
|
||||
permissions.IsAuthenticated,
|
||||
InvenTree.permissions.GlobalSettingsPermissions,
|
||||
]
|
||||
|
||||
def get_object(self):
|
||||
"""Lookup the plugin setting object, based on the URL.
|
||||
|
||||
|
|
@ -415,9 +430,6 @@ class PluginSettingDetail(RetrieveUpdateAPI):
|
|||
setting_key, plugin=plugin.plugin_config()
|
||||
)
|
||||
|
||||
# Staff permission required
|
||||
permission_classes = [InvenTree.permissions.GlobalSettingsPermissions]
|
||||
|
||||
|
||||
class PluginUserSettingList(APIView):
|
||||
"""List endpoint for all user settings for a specific plugin.
|
||||
|
|
|
|||
|
|
@ -29,7 +29,13 @@ class PartStocktakeExportOptionsSerializer(serializers.Serializer):
|
|||
export_include_variant_items = serializers.BooleanField(
|
||||
default=False,
|
||||
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'
|
||||
TITLE = _('Part Stocktake Exporter')
|
||||
DESCRIPTION = _('Exporter for part stocktake data')
|
||||
VERSION = '1.0.0'
|
||||
VERSION = '1.1.0'
|
||||
AUTHOR = _('InvenTree contributors')
|
||||
|
||||
ExportOptionsSerializer = PartStocktakeExportOptionsSerializer
|
||||
|
|
@ -78,6 +84,9 @@ class PartStocktakeExporter(DataExportMixin, InvenTreePlugin):
|
|||
'pk',
|
||||
'name',
|
||||
'IPN',
|
||||
'active',
|
||||
'component',
|
||||
'assembly',
|
||||
'description',
|
||||
'category',
|
||||
'allocated_to_build_orders',
|
||||
|
|
@ -124,26 +133,36 @@ class PartStocktakeExporter(DataExportMixin, InvenTreePlugin):
|
|||
export_pricing_data = context.get('export_pricing_data', True)
|
||||
include_external_items = context.get('export_include_external_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(
|
||||
queryset, serializer_class, headers, context, output, **kwargs
|
||||
)
|
||||
|
||||
if export_pricing_data:
|
||||
for row in data:
|
||||
quantity = Decimal(row.get('total_in_stock', 0))
|
||||
output_data = []
|
||||
|
||||
if not include_external_items:
|
||||
quantity -= Decimal(row.get('external_stock', 0))
|
||||
for row in data:
|
||||
quantity = Decimal(row.get('total_in_stock', 0))
|
||||
|
||||
if not include_variant_items:
|
||||
quantity -= Decimal(row.get('variant_stock', 0))
|
||||
if not include_external_items:
|
||||
quantity -= Decimal(row.get('external_stock', 0))
|
||||
|
||||
if quantity < 0:
|
||||
quantity = Decimal(0)
|
||||
if not include_variant_items:
|
||||
quantity -= Decimal(row.get('variant_stock', 0))
|
||||
|
||||
pricing_min = row.get('pricing_min', None)
|
||||
pricing_max = row.get('pricing_max', None)
|
||||
if quantity < 0:
|
||||
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:
|
||||
pricing_min = Decimal(pricing_min)
|
||||
|
|
@ -157,4 +176,6 @@ class PartStocktakeExporter(DataExportMixin, InvenTreePlugin):
|
|||
pricing_max * quantity, rounding=10
|
||||
)
|
||||
|
||||
return data
|
||||
output_data.append(row)
|
||||
|
||||
return output_data
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ class StocktakeExporterTest(InvenTreeAPITestCase):
|
|||
'Minimum Unit 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
|
||||
|
|
@ -60,7 +60,6 @@ class StocktakeExporterTest(InvenTreeAPITestCase):
|
|||
'External Stock',
|
||||
'Variant Stock',
|
||||
],
|
||||
excluded_cols=['Active'],
|
||||
)
|
||||
|
||||
# Finally, exclude pricing data entirely
|
||||
|
|
|
|||
|
|
@ -816,3 +816,48 @@ class PluginLockedSettingsTest(PluginMixin, InvenTreeAPITestCase):
|
|||
self.assertFalse(
|
||||
response.data['read_only'], msg=f'{key} should not be read_only'
|
||||
)
|
||||
|
||||
|
||||
class PluginUnauthenticatedAccessTest(PluginMixin, InvenTreeAPITestCase):
|
||||
"""Ensure plugin API endpoints reject unauthenticated requests.
|
||||
|
||||
Tests the four endpoints hardened on the permissions-fix branch:
|
||||
- PluginDetail (api-plugin-detail)
|
||||
- PluginSettingList (api-plugin-setting-list)
|
||||
- PluginAllSettingList (api-plugin-settings)
|
||||
- PluginSettingDetail (api-plugin-setting-detail)
|
||||
"""
|
||||
|
||||
superuser = True
|
||||
PLUGIN_SLUG = 'sample'
|
||||
SETTING_KEY = 'API_KEY'
|
||||
|
||||
def setUp(self):
|
||||
"""Activate sample plugin, then log out to simulate an anonymous client."""
|
||||
super().setUp()
|
||||
from plugin.registry import registry
|
||||
|
||||
registry.set_plugin_state(self.PLUGIN_SLUG, True)
|
||||
self.client.logout()
|
||||
|
||||
def test_plugin_detail_unauthenticated(self):
|
||||
"""GET /api/plugins/<slug>/ must return 401 for unauthenticated users."""
|
||||
url = reverse('api-plugin-detail', kwargs={'plugin': self.PLUGIN_SLUG})
|
||||
self.get(url, expected_code=401)
|
||||
|
||||
def test_plugin_setting_list_unauthenticated(self):
|
||||
"""GET /api/plugins/settings/ must return 401 for unauthenticated users."""
|
||||
self.get(reverse('api-plugin-setting-list'), expected_code=401)
|
||||
|
||||
def test_plugin_all_settings_unauthenticated(self):
|
||||
"""GET /api/plugins/<slug>/settings/ must return 401 for unauthenticated users."""
|
||||
url = reverse('api-plugin-settings', kwargs={'plugin': self.PLUGIN_SLUG})
|
||||
self.get(url, expected_code=401)
|
||||
|
||||
def test_plugin_setting_detail_unauthenticated(self):
|
||||
"""GET /api/plugins/<slug>/settings/<key>/ must return 401 for unauthenticated users."""
|
||||
url = reverse(
|
||||
'api-plugin-setting-detail',
|
||||
kwargs={'plugin': self.PLUGIN_SLUG, 'key': self.SETTING_KEY},
|
||||
)
|
||||
self.get(url, expected_code=401)
|
||||
|
|
|
|||
|
|
@ -449,56 +449,56 @@ charset-normalizer==3.4.7 \
|
|||
# via
|
||||
# -c src/backend/requirements.txt
|
||||
# requests
|
||||
cryptography==48.0.0 \
|
||||
--hash=sha256:0890f502ddf7d9c6426129c3f49f5c0a39278ed7cd6322c8755ffca6ee675a13 \
|
||||
--hash=sha256:0c558d2cdffd8f4bbb30fc7134c74d2ca9a476f830bb053074498fbc86f41ed6 \
|
||||
--hash=sha256:16cd65b9330583e4619939b3a3843eec1e6e789744bb01e7c7e2e62e33c239c8 \
|
||||
--hash=sha256:18349bbc56f4743c8b12dc32e2bccb2cf83ee8b69a3bba74ef8ae857e26b3d25 \
|
||||
--hash=sha256:1e2d54c8be6152856a36f0882ab231e70f8ec7f14e93cf87db8a2ed056bf160c \
|
||||
--hash=sha256:22a5cb272895dce158b2cacdfdc3debd299019659f42947dbdac6f32d68fe832 \
|
||||
--hash=sha256:27241b1dc9962e056062a8eef1991d02c3a24569c95975bd2322a8a52c6e5e12 \
|
||||
--hash=sha256:2b4d59804e8408e2fea7d1fbaf218e5ec984325221db76e6a241a9abd6cdd95c \
|
||||
--hash=sha256:2eb992bbd4661238c5a397594c83f5b4dc2bc5b848c365c8f991b6780efcc5c7 \
|
||||
--hash=sha256:369a6348999f94bbd53435c894377b20ab95f25a9065c283570e70150d8abc3c \
|
||||
--hash=sha256:3cb07a3ed6431663cd321ea8a000a1314c74211f823e4177fefa2255e057d1ec \
|
||||
--hash=sha256:40ba1f85eaa6959837b1d51c9767e230e14612eea4ef110ee8854ada22da1bf5 \
|
||||
--hash=sha256:4defde8685ae324a9eb9d818717e93b4638ef67070ac9bc15b8ca85f63048355 \
|
||||
--hash=sha256:55b7718303bf06a5753dcdccf2f3945cf18ad7bffde41b61226e4db31ab89a9c \
|
||||
--hash=sha256:561215ea3879cb1cbbf272867e2efda62476f240fb58c64de6b393ae19246741 \
|
||||
--hash=sha256:58d00498e8933e4a194f3076aee1b4a97dfec1a6da444535755822fe5d8b0b86 \
|
||||
--hash=sha256:59baa2cb386c4f0b9905bd6eb4c2a79a69a128408fd31d32ca4d7102d4156321 \
|
||||
--hash=sha256:5a5ed8fde7a1d09376ca0b40e68cd59c69fe23b1f9768bd5824f54681626032a \
|
||||
--hash=sha256:5b012212e08b8dd5edc78ef54da83dd9892fd9105323b3993eff6bea65dc21d7 \
|
||||
--hash=sha256:5c3932f4436d1cccb036cb0eaef46e6e2db91035166f1ad6505c3c9d5a635920 \
|
||||
--hash=sha256:614d0949f4790582d2cc25553abd09dd723025f0c0e7c67376a1d77196743d6e \
|
||||
--hash=sha256:76341972e1eff8b4bea859f09c0d3e64b96ce931b084f9b9b7db8ef364c30eff \
|
||||
--hash=sha256:77a2ccbbe917f6710e05ba9adaa25fb5075620bf3ea6fb751997875aff4ae4bd \
|
||||
--hash=sha256:7995ef305d7165c3f11ae07f2517e5a4f1d5c18da1376a0a9ed496336b69e5f3 \
|
||||
--hash=sha256:7ce4bfae76319a532a2dc68f82cc32f5676ee792a983187dac07183690e5c66f \
|
||||
--hash=sha256:7e8eac43dfca5c4cccc6dad9a80504436fca53bb9bc3100a2386d730fbe6b602 \
|
||||
--hash=sha256:84cf79f0dc8b36ac5da873481716e87aef31fcfa0444f9e1d8b4b2cece142855 \
|
||||
--hash=sha256:8c7378637d7d88016fa6791c159f698b3d3eed28ebf844ac36b9dc04a14dae18 \
|
||||
--hash=sha256:8cd666227ef7af430aa5914a9910e0ddd703e75f039cef0825cd0da71b6b711a \
|
||||
--hash=sha256:906cbf0670286c6e0044156bc7d4af9cbb0ef6db9f73e52c3ec56ba6bdde5336 \
|
||||
--hash=sha256:9071196d81abc88b3516ac8cdfad32e2b66dd4a5393a8e68a961e9161ddc6239 \
|
||||
--hash=sha256:9249e3cd978541d665967ac2cb2787fd6a62bddf1e75b3e347a594d7dacf4f74 \
|
||||
--hash=sha256:984a20b0f62a26f48a3396c72e4bc34c66e356d356bf370053066b3b6d54634a \
|
||||
--hash=sha256:9be5aafa5736574f8f15f262adc81b2a9869e2cfe9014d52a44633905b40d52c \
|
||||
--hash=sha256:9c459db21422be75e2809370b829a87eb37f74cd785fc4aa9ea1e5f43b47cda4 \
|
||||
--hash=sha256:9ccdac7d40688ecb5a3b4a604b8a88c8002e3442d6c60aead1db2a89a041560c \
|
||||
--hash=sha256:a0e692c683f4df67815a2d258b324e66f4738bd7a96a218c826dce4f4bd05d8f \
|
||||
--hash=sha256:a5da777e32ffed6f85a7b2b3f7c5cbc88c146bfcd0a1d7baf5fcc6c52ee35dd4 \
|
||||
--hash=sha256:a64697c641c7b1b2178e573cbc31c7c6684cd56883a478d75143dbb7118036db \
|
||||
--hash=sha256:ad64688338ed4bc1a6618076ba75fd7194a5f1797ac60b47afe926285adb3166 \
|
||||
--hash=sha256:bd72e68b06bb1e96913f97dd4901119bc17f39d4586a5adf2d3e47bc2b9d58b5 \
|
||||
--hash=sha256:c17dfe85494deaeddc5ce251aebd1d60bbe6afc8b62071bb0b469431a000124f \
|
||||
--hash=sha256:c18684a7f0cc9a3cb60328f496b8e3372def7c5d2df39ac267878b05565aaaae \
|
||||
--hash=sha256:cc90c0b39b2e3c65ef52c804b72e3c58f8a04ab2a1871272798e5f9572c17d20 \
|
||||
--hash=sha256:db63bf618e5dea46c07de12e900fe1cdd2541e6dc9dbae772a70b7d4d4765f6a \
|
||||
--hash=sha256:ea8990436d914540a40ab24b6a77c0969695ed52f4a4874c5137ccf7045a7057 \
|
||||
--hash=sha256:ecde28a596bead48b0cfd2a1b4416c3d43074c2d785e3a398d7ec1fc4d0f7fbb \
|
||||
--hash=sha256:f5333311663ea94f75dd408665686aaf426563556bb5283554a3539177e03b8c \
|
||||
--hash=sha256:fdfef35d751d510fcef5252703621574364fec16418c4a1e5e1055248401054b
|
||||
cryptography==48.0.1 \
|
||||
--hash=sha256:08a597acce1ff37f347400087776599e2348a3a8bc53b44120e463cd274efe4a \
|
||||
--hash=sha256:09f73a725d582cef64b91281a322cd798d14a33b2b6f2b7ad9531dc336d84c02 \
|
||||
--hash=sha256:0df56b056bc17c1b7d6821dfa65216e62bd232d8ab05eb3db44e71d235651471 \
|
||||
--hash=sha256:0ee6ea481db1ab889cba043ec1eda17bb9c1ea79db6722f779c3667f9f70322f \
|
||||
--hash=sha256:15254441469dd6bf027039453288e2072124f8b6603563f5d759e1c9b69273fa \
|
||||
--hash=sha256:266f4ee051abb2f725b74ef8072b521ce1feacf685a3364fa6a6b45548db791a \
|
||||
--hash=sha256:2c37f2461406063b417837f5f3daab668652acd82423efcd7f0a9f04be972de1 \
|
||||
--hash=sha256:32143b24adb918f078134e1e230f1eb8cc04886b92c28b5f0041aaf3e5699225 \
|
||||
--hash=sha256:33842cf0888951cef5bc7ac724ab844a42044c1727b967b7f8997289a0464f92 \
|
||||
--hash=sha256:3752f2dbc8f07a30aad2932c986cea495b03bb554887828225da104f732852b6 \
|
||||
--hash=sha256:39489bfca54c7a1f6b297efcd8bc608ab92d16c4ca631b0cad4da46724588b24 \
|
||||
--hash=sha256:3e4a1a3232eef2e6c732827d5722db29a0cc8b27af2a4d865b094cf954be9ca1 \
|
||||
--hash=sha256:3fd2ca57062b241c856670b073487d2e86c4637937ca5601e48f97bf8e11fc8f \
|
||||
--hash=sha256:42fcd8e26fe555d9b3577a135f5091fefa0aa4e99129c23fb56787a1bd4ada72 \
|
||||
--hash=sha256:43c5835e2cb98c8733d86f57d6fc879b613f5c3478607281c3e36daffc6dd8a6 \
|
||||
--hash=sha256:48fe40804d4caa2288f24e70ca8c64c42dd826da0ad7e4f1b41b2128d679e6c8 \
|
||||
--hash=sha256:4ab0a343c807bbcd90c971cd1ecf072937cd01847a9e002bef88fb47ac6be577 \
|
||||
--hash=sha256:4fdc69f8e4316bcf0c8c8ec1f26f285d12e8142d88d96c876a59a03be3f6ae67 \
|
||||
--hash=sha256:6184ca7b174f28d7c703f1290d4b297217c45355f77a98f67e9b7f14549ac54a \
|
||||
--hash=sha256:66fd0771e7b9c6dcd44cf1120690d2338d16d72795cf40cae2786a39eba65429 \
|
||||
--hash=sha256:6b2c0c3e6ccf3ade7750f836ef3ee36eea250cc467d45c256895573ac08cc6f1 \
|
||||
--hash=sha256:735824ec41b7f74a7c45fb1591349333e4c696cb6c044e5f46356e560143e4cd \
|
||||
--hash=sha256:7e234ac052af99f2700826a5c29ea99d9c1b1f80341cde62d11c8154dc8e0bd9 \
|
||||
--hash=sha256:869c3b8a53bfe27147832df48b32adadf558249d50e76cb3769d40e986b13265 \
|
||||
--hash=sha256:86be3b1b0b6bf09482fb50a979c508d2950ed95f5621ec77f4e385962006b83a \
|
||||
--hash=sha256:86fe77abb1bd87afb251d4d02ada7ecf53a32cee9b67d976abb2e45a13297475 \
|
||||
--hash=sha256:88c852a0ae366e262e5a1744b685e6a433dc8788dd2a277e418bf4904203609d \
|
||||
--hash=sha256:8ace4507d1e6533c125f4fac754f8bb8b6a74c08e92179dabd7e16571a3efbf3 \
|
||||
--hash=sha256:92a46e1d638daa264ba2971c0b0489c9409787943efae4d60ffda3d091ef832c \
|
||||
--hash=sha256:9621de99d2da096006b629979efd8ae7eb2d8b822488d0c89ee4000c306c59b1 \
|
||||
--hash=sha256:9a49ca6c81417f6a5edb50375a60cccdd70fa0a91a5211829dbea74eba94d2ac \
|
||||
--hash=sha256:9bd3f92d76217892b15df84ca256c2c113d386fdda7a7d8691aeeced976507c6 \
|
||||
--hash=sha256:9de21387aa95e2a895823d0745b430bed4f33503ba9ab5e0b5311f33e37d66d2 \
|
||||
--hash=sha256:b024e784ad6c077ee0147b35ea9cbfc1e34e1fd4c1dcca214c2794d73a12df08 \
|
||||
--hash=sha256:b4e391975f038e66432328639620a4aff2d307513b004f1ca06d6225bced815c \
|
||||
--hash=sha256:b74ca3b8e5ecdd833bf6a002ca41b4793bb27fb8f1c06ffaf2643c9e9140e31b \
|
||||
--hash=sha256:b7a2d1a937a738a881737cec135a38bb61470589b17515b9f73f571d0ae10401 \
|
||||
--hash=sha256:b9a32b876490d66c8bcc9963ef220199569748434ab01a9d6aaeabf88e7f5158 \
|
||||
--hash=sha256:bd81490cd5801d755cf97bb68ac191f14b708470b1c7cf4580f669b9c9264cd8 \
|
||||
--hash=sha256:c1400da5e32a43253392277eac7490a60e497d810a63dd5608d71bbd7af507c9 \
|
||||
--hash=sha256:d069066deead00ac7f090be101be875a06855908f7ec004c27b8fefb4acfb411 \
|
||||
--hash=sha256:d5d30989c6917b478b5817902e85fddaea2261efa8648383d965381ccb9e1ac4 \
|
||||
--hash=sha256:df637c05205ea7c1d7fbcbe54bbfea648a52951155f997af13d895d0ecc96991 \
|
||||
--hash=sha256:e361afba8918070d376df76f408a4f67fec0ee9cff81a99e48fe9a233ef59e17 \
|
||||
--hash=sha256:eb86ce1af36fe65041b6db9a8bb064ee621a7e5fded0f80d475ec243477cd242 \
|
||||
--hash=sha256:f0d27a5696721ef7a672b8c810f6aded391058e0b9486e63e6d93baf765da691 \
|
||||
--hash=sha256:f2ceef93cb096aa3c4cc4b5c94ca6131f9196d28c64d6111533402a9b2054d41 \
|
||||
--hash=sha256:f817adc181390bd54f2f700107a7419040fb7c1bdf2fc26f36551a06a68c3345 \
|
||||
--hash=sha256:fe0180af5bf9236518a087e35bf2d9a347d5f5f51e63c579d683ddff424e3d46
|
||||
# via
|
||||
# -c src/backend/requirements.txt
|
||||
# -r src/backend/requirements.in
|
||||
|
|
|
|||
|
|
@ -354,56 +354,56 @@ coverage[toml]==7.14.1 \
|
|||
--hash=sha256:fc459e5d73be2d6332fcfe8dbf3d8994671fe33c700f4565988ecfa511547253 \
|
||||
--hash=sha256:fd86572566fb40189a8260446158235159bc7a82dfbc87a3b39cf4fb57fcec1c
|
||||
# via -r src/backend/requirements-dev.in
|
||||
cryptography==48.0.0 \
|
||||
--hash=sha256:0890f502ddf7d9c6426129c3f49f5c0a39278ed7cd6322c8755ffca6ee675a13 \
|
||||
--hash=sha256:0c558d2cdffd8f4bbb30fc7134c74d2ca9a476f830bb053074498fbc86f41ed6 \
|
||||
--hash=sha256:16cd65b9330583e4619939b3a3843eec1e6e789744bb01e7c7e2e62e33c239c8 \
|
||||
--hash=sha256:18349bbc56f4743c8b12dc32e2bccb2cf83ee8b69a3bba74ef8ae857e26b3d25 \
|
||||
--hash=sha256:1e2d54c8be6152856a36f0882ab231e70f8ec7f14e93cf87db8a2ed056bf160c \
|
||||
--hash=sha256:22a5cb272895dce158b2cacdfdc3debd299019659f42947dbdac6f32d68fe832 \
|
||||
--hash=sha256:27241b1dc9962e056062a8eef1991d02c3a24569c95975bd2322a8a52c6e5e12 \
|
||||
--hash=sha256:2b4d59804e8408e2fea7d1fbaf218e5ec984325221db76e6a241a9abd6cdd95c \
|
||||
--hash=sha256:2eb992bbd4661238c5a397594c83f5b4dc2bc5b848c365c8f991b6780efcc5c7 \
|
||||
--hash=sha256:369a6348999f94bbd53435c894377b20ab95f25a9065c283570e70150d8abc3c \
|
||||
--hash=sha256:3cb07a3ed6431663cd321ea8a000a1314c74211f823e4177fefa2255e057d1ec \
|
||||
--hash=sha256:40ba1f85eaa6959837b1d51c9767e230e14612eea4ef110ee8854ada22da1bf5 \
|
||||
--hash=sha256:4defde8685ae324a9eb9d818717e93b4638ef67070ac9bc15b8ca85f63048355 \
|
||||
--hash=sha256:55b7718303bf06a5753dcdccf2f3945cf18ad7bffde41b61226e4db31ab89a9c \
|
||||
--hash=sha256:561215ea3879cb1cbbf272867e2efda62476f240fb58c64de6b393ae19246741 \
|
||||
--hash=sha256:58d00498e8933e4a194f3076aee1b4a97dfec1a6da444535755822fe5d8b0b86 \
|
||||
--hash=sha256:59baa2cb386c4f0b9905bd6eb4c2a79a69a128408fd31d32ca4d7102d4156321 \
|
||||
--hash=sha256:5a5ed8fde7a1d09376ca0b40e68cd59c69fe23b1f9768bd5824f54681626032a \
|
||||
--hash=sha256:5b012212e08b8dd5edc78ef54da83dd9892fd9105323b3993eff6bea65dc21d7 \
|
||||
--hash=sha256:5c3932f4436d1cccb036cb0eaef46e6e2db91035166f1ad6505c3c9d5a635920 \
|
||||
--hash=sha256:614d0949f4790582d2cc25553abd09dd723025f0c0e7c67376a1d77196743d6e \
|
||||
--hash=sha256:76341972e1eff8b4bea859f09c0d3e64b96ce931b084f9b9b7db8ef364c30eff \
|
||||
--hash=sha256:77a2ccbbe917f6710e05ba9adaa25fb5075620bf3ea6fb751997875aff4ae4bd \
|
||||
--hash=sha256:7995ef305d7165c3f11ae07f2517e5a4f1d5c18da1376a0a9ed496336b69e5f3 \
|
||||
--hash=sha256:7ce4bfae76319a532a2dc68f82cc32f5676ee792a983187dac07183690e5c66f \
|
||||
--hash=sha256:7e8eac43dfca5c4cccc6dad9a80504436fca53bb9bc3100a2386d730fbe6b602 \
|
||||
--hash=sha256:84cf79f0dc8b36ac5da873481716e87aef31fcfa0444f9e1d8b4b2cece142855 \
|
||||
--hash=sha256:8c7378637d7d88016fa6791c159f698b3d3eed28ebf844ac36b9dc04a14dae18 \
|
||||
--hash=sha256:8cd666227ef7af430aa5914a9910e0ddd703e75f039cef0825cd0da71b6b711a \
|
||||
--hash=sha256:906cbf0670286c6e0044156bc7d4af9cbb0ef6db9f73e52c3ec56ba6bdde5336 \
|
||||
--hash=sha256:9071196d81abc88b3516ac8cdfad32e2b66dd4a5393a8e68a961e9161ddc6239 \
|
||||
--hash=sha256:9249e3cd978541d665967ac2cb2787fd6a62bddf1e75b3e347a594d7dacf4f74 \
|
||||
--hash=sha256:984a20b0f62a26f48a3396c72e4bc34c66e356d356bf370053066b3b6d54634a \
|
||||
--hash=sha256:9be5aafa5736574f8f15f262adc81b2a9869e2cfe9014d52a44633905b40d52c \
|
||||
--hash=sha256:9c459db21422be75e2809370b829a87eb37f74cd785fc4aa9ea1e5f43b47cda4 \
|
||||
--hash=sha256:9ccdac7d40688ecb5a3b4a604b8a88c8002e3442d6c60aead1db2a89a041560c \
|
||||
--hash=sha256:a0e692c683f4df67815a2d258b324e66f4738bd7a96a218c826dce4f4bd05d8f \
|
||||
--hash=sha256:a5da777e32ffed6f85a7b2b3f7c5cbc88c146bfcd0a1d7baf5fcc6c52ee35dd4 \
|
||||
--hash=sha256:a64697c641c7b1b2178e573cbc31c7c6684cd56883a478d75143dbb7118036db \
|
||||
--hash=sha256:ad64688338ed4bc1a6618076ba75fd7194a5f1797ac60b47afe926285adb3166 \
|
||||
--hash=sha256:bd72e68b06bb1e96913f97dd4901119bc17f39d4586a5adf2d3e47bc2b9d58b5 \
|
||||
--hash=sha256:c17dfe85494deaeddc5ce251aebd1d60bbe6afc8b62071bb0b469431a000124f \
|
||||
--hash=sha256:c18684a7f0cc9a3cb60328f496b8e3372def7c5d2df39ac267878b05565aaaae \
|
||||
--hash=sha256:cc90c0b39b2e3c65ef52c804b72e3c58f8a04ab2a1871272798e5f9572c17d20 \
|
||||
--hash=sha256:db63bf618e5dea46c07de12e900fe1cdd2541e6dc9dbae772a70b7d4d4765f6a \
|
||||
--hash=sha256:ea8990436d914540a40ab24b6a77c0969695ed52f4a4874c5137ccf7045a7057 \
|
||||
--hash=sha256:ecde28a596bead48b0cfd2a1b4416c3d43074c2d785e3a398d7ec1fc4d0f7fbb \
|
||||
--hash=sha256:f5333311663ea94f75dd408665686aaf426563556bb5283554a3539177e03b8c \
|
||||
--hash=sha256:fdfef35d751d510fcef5252703621574364fec16418c4a1e5e1055248401054b
|
||||
cryptography==48.0.1 \
|
||||
--hash=sha256:08a597acce1ff37f347400087776599e2348a3a8bc53b44120e463cd274efe4a \
|
||||
--hash=sha256:09f73a725d582cef64b91281a322cd798d14a33b2b6f2b7ad9531dc336d84c02 \
|
||||
--hash=sha256:0df56b056bc17c1b7d6821dfa65216e62bd232d8ab05eb3db44e71d235651471 \
|
||||
--hash=sha256:0ee6ea481db1ab889cba043ec1eda17bb9c1ea79db6722f779c3667f9f70322f \
|
||||
--hash=sha256:15254441469dd6bf027039453288e2072124f8b6603563f5d759e1c9b69273fa \
|
||||
--hash=sha256:266f4ee051abb2f725b74ef8072b521ce1feacf685a3364fa6a6b45548db791a \
|
||||
--hash=sha256:2c37f2461406063b417837f5f3daab668652acd82423efcd7f0a9f04be972de1 \
|
||||
--hash=sha256:32143b24adb918f078134e1e230f1eb8cc04886b92c28b5f0041aaf3e5699225 \
|
||||
--hash=sha256:33842cf0888951cef5bc7ac724ab844a42044c1727b967b7f8997289a0464f92 \
|
||||
--hash=sha256:3752f2dbc8f07a30aad2932c986cea495b03bb554887828225da104f732852b6 \
|
||||
--hash=sha256:39489bfca54c7a1f6b297efcd8bc608ab92d16c4ca631b0cad4da46724588b24 \
|
||||
--hash=sha256:3e4a1a3232eef2e6c732827d5722db29a0cc8b27af2a4d865b094cf954be9ca1 \
|
||||
--hash=sha256:3fd2ca57062b241c856670b073487d2e86c4637937ca5601e48f97bf8e11fc8f \
|
||||
--hash=sha256:42fcd8e26fe555d9b3577a135f5091fefa0aa4e99129c23fb56787a1bd4ada72 \
|
||||
--hash=sha256:43c5835e2cb98c8733d86f57d6fc879b613f5c3478607281c3e36daffc6dd8a6 \
|
||||
--hash=sha256:48fe40804d4caa2288f24e70ca8c64c42dd826da0ad7e4f1b41b2128d679e6c8 \
|
||||
--hash=sha256:4ab0a343c807bbcd90c971cd1ecf072937cd01847a9e002bef88fb47ac6be577 \
|
||||
--hash=sha256:4fdc69f8e4316bcf0c8c8ec1f26f285d12e8142d88d96c876a59a03be3f6ae67 \
|
||||
--hash=sha256:6184ca7b174f28d7c703f1290d4b297217c45355f77a98f67e9b7f14549ac54a \
|
||||
--hash=sha256:66fd0771e7b9c6dcd44cf1120690d2338d16d72795cf40cae2786a39eba65429 \
|
||||
--hash=sha256:6b2c0c3e6ccf3ade7750f836ef3ee36eea250cc467d45c256895573ac08cc6f1 \
|
||||
--hash=sha256:735824ec41b7f74a7c45fb1591349333e4c696cb6c044e5f46356e560143e4cd \
|
||||
--hash=sha256:7e234ac052af99f2700826a5c29ea99d9c1b1f80341cde62d11c8154dc8e0bd9 \
|
||||
--hash=sha256:869c3b8a53bfe27147832df48b32adadf558249d50e76cb3769d40e986b13265 \
|
||||
--hash=sha256:86be3b1b0b6bf09482fb50a979c508d2950ed95f5621ec77f4e385962006b83a \
|
||||
--hash=sha256:86fe77abb1bd87afb251d4d02ada7ecf53a32cee9b67d976abb2e45a13297475 \
|
||||
--hash=sha256:88c852a0ae366e262e5a1744b685e6a433dc8788dd2a277e418bf4904203609d \
|
||||
--hash=sha256:8ace4507d1e6533c125f4fac754f8bb8b6a74c08e92179dabd7e16571a3efbf3 \
|
||||
--hash=sha256:92a46e1d638daa264ba2971c0b0489c9409787943efae4d60ffda3d091ef832c \
|
||||
--hash=sha256:9621de99d2da096006b629979efd8ae7eb2d8b822488d0c89ee4000c306c59b1 \
|
||||
--hash=sha256:9a49ca6c81417f6a5edb50375a60cccdd70fa0a91a5211829dbea74eba94d2ac \
|
||||
--hash=sha256:9bd3f92d76217892b15df84ca256c2c113d386fdda7a7d8691aeeced976507c6 \
|
||||
--hash=sha256:9de21387aa95e2a895823d0745b430bed4f33503ba9ab5e0b5311f33e37d66d2 \
|
||||
--hash=sha256:b024e784ad6c077ee0147b35ea9cbfc1e34e1fd4c1dcca214c2794d73a12df08 \
|
||||
--hash=sha256:b4e391975f038e66432328639620a4aff2d307513b004f1ca06d6225bced815c \
|
||||
--hash=sha256:b74ca3b8e5ecdd833bf6a002ca41b4793bb27fb8f1c06ffaf2643c9e9140e31b \
|
||||
--hash=sha256:b7a2d1a937a738a881737cec135a38bb61470589b17515b9f73f571d0ae10401 \
|
||||
--hash=sha256:b9a32b876490d66c8bcc9963ef220199569748434ab01a9d6aaeabf88e7f5158 \
|
||||
--hash=sha256:bd81490cd5801d755cf97bb68ac191f14b708470b1c7cf4580f669b9c9264cd8 \
|
||||
--hash=sha256:c1400da5e32a43253392277eac7490a60e497d810a63dd5608d71bbd7af507c9 \
|
||||
--hash=sha256:d069066deead00ac7f090be101be875a06855908f7ec004c27b8fefb4acfb411 \
|
||||
--hash=sha256:d5d30989c6917b478b5817902e85fddaea2261efa8648383d965381ccb9e1ac4 \
|
||||
--hash=sha256:df637c05205ea7c1d7fbcbe54bbfea648a52951155f997af13d895d0ecc96991 \
|
||||
--hash=sha256:e361afba8918070d376df76f408a4f67fec0ee9cff81a99e48fe9a233ef59e17 \
|
||||
--hash=sha256:eb86ce1af36fe65041b6db9a8bb064ee621a7e5fded0f80d475ec243477cd242 \
|
||||
--hash=sha256:f0d27a5696721ef7a672b8c810f6aded391058e0b9486e63e6d93baf765da691 \
|
||||
--hash=sha256:f2ceef93cb096aa3c4cc4b5c94ca6131f9196d28c64d6111533402a9b2054d41 \
|
||||
--hash=sha256:f817adc181390bd54f2f700107a7419040fb7c1bdf2fc26f36551a06a68c3345 \
|
||||
--hash=sha256:fe0180af5bf9236518a087e35bf2d9a347d5f5f51e63c579d683ddff424e3d46
|
||||
# via
|
||||
# -c src/backend/requirements-3.14.txt
|
||||
# -c src/backend/requirements.txt
|
||||
|
|
|
|||
|
|
@ -350,56 +350,56 @@ coverage[toml]==7.13.5 \
|
|||
--hash=sha256:f70c9ab2595c56f81a89620e22899eea8b212a4041bd728ac6f4a28bf5d3ddd0 \
|
||||
--hash=sha256:fbabfaceaeb587e16f7008f7795cd80d20ec548dc7f94fbb0d4ec2e038ce563f
|
||||
# via -r src/backend/requirements-dev.in
|
||||
cryptography==48.0.0 \
|
||||
--hash=sha256:0890f502ddf7d9c6426129c3f49f5c0a39278ed7cd6322c8755ffca6ee675a13 \
|
||||
--hash=sha256:0c558d2cdffd8f4bbb30fc7134c74d2ca9a476f830bb053074498fbc86f41ed6 \
|
||||
--hash=sha256:16cd65b9330583e4619939b3a3843eec1e6e789744bb01e7c7e2e62e33c239c8 \
|
||||
--hash=sha256:18349bbc56f4743c8b12dc32e2bccb2cf83ee8b69a3bba74ef8ae857e26b3d25 \
|
||||
--hash=sha256:1e2d54c8be6152856a36f0882ab231e70f8ec7f14e93cf87db8a2ed056bf160c \
|
||||
--hash=sha256:22a5cb272895dce158b2cacdfdc3debd299019659f42947dbdac6f32d68fe832 \
|
||||
--hash=sha256:27241b1dc9962e056062a8eef1991d02c3a24569c95975bd2322a8a52c6e5e12 \
|
||||
--hash=sha256:2b4d59804e8408e2fea7d1fbaf218e5ec984325221db76e6a241a9abd6cdd95c \
|
||||
--hash=sha256:2eb992bbd4661238c5a397594c83f5b4dc2bc5b848c365c8f991b6780efcc5c7 \
|
||||
--hash=sha256:369a6348999f94bbd53435c894377b20ab95f25a9065c283570e70150d8abc3c \
|
||||
--hash=sha256:3cb07a3ed6431663cd321ea8a000a1314c74211f823e4177fefa2255e057d1ec \
|
||||
--hash=sha256:40ba1f85eaa6959837b1d51c9767e230e14612eea4ef110ee8854ada22da1bf5 \
|
||||
--hash=sha256:4defde8685ae324a9eb9d818717e93b4638ef67070ac9bc15b8ca85f63048355 \
|
||||
--hash=sha256:55b7718303bf06a5753dcdccf2f3945cf18ad7bffde41b61226e4db31ab89a9c \
|
||||
--hash=sha256:561215ea3879cb1cbbf272867e2efda62476f240fb58c64de6b393ae19246741 \
|
||||
--hash=sha256:58d00498e8933e4a194f3076aee1b4a97dfec1a6da444535755822fe5d8b0b86 \
|
||||
--hash=sha256:59baa2cb386c4f0b9905bd6eb4c2a79a69a128408fd31d32ca4d7102d4156321 \
|
||||
--hash=sha256:5a5ed8fde7a1d09376ca0b40e68cd59c69fe23b1f9768bd5824f54681626032a \
|
||||
--hash=sha256:5b012212e08b8dd5edc78ef54da83dd9892fd9105323b3993eff6bea65dc21d7 \
|
||||
--hash=sha256:5c3932f4436d1cccb036cb0eaef46e6e2db91035166f1ad6505c3c9d5a635920 \
|
||||
--hash=sha256:614d0949f4790582d2cc25553abd09dd723025f0c0e7c67376a1d77196743d6e \
|
||||
--hash=sha256:76341972e1eff8b4bea859f09c0d3e64b96ce931b084f9b9b7db8ef364c30eff \
|
||||
--hash=sha256:77a2ccbbe917f6710e05ba9adaa25fb5075620bf3ea6fb751997875aff4ae4bd \
|
||||
--hash=sha256:7995ef305d7165c3f11ae07f2517e5a4f1d5c18da1376a0a9ed496336b69e5f3 \
|
||||
--hash=sha256:7ce4bfae76319a532a2dc68f82cc32f5676ee792a983187dac07183690e5c66f \
|
||||
--hash=sha256:7e8eac43dfca5c4cccc6dad9a80504436fca53bb9bc3100a2386d730fbe6b602 \
|
||||
--hash=sha256:84cf79f0dc8b36ac5da873481716e87aef31fcfa0444f9e1d8b4b2cece142855 \
|
||||
--hash=sha256:8c7378637d7d88016fa6791c159f698b3d3eed28ebf844ac36b9dc04a14dae18 \
|
||||
--hash=sha256:8cd666227ef7af430aa5914a9910e0ddd703e75f039cef0825cd0da71b6b711a \
|
||||
--hash=sha256:906cbf0670286c6e0044156bc7d4af9cbb0ef6db9f73e52c3ec56ba6bdde5336 \
|
||||
--hash=sha256:9071196d81abc88b3516ac8cdfad32e2b66dd4a5393a8e68a961e9161ddc6239 \
|
||||
--hash=sha256:9249e3cd978541d665967ac2cb2787fd6a62bddf1e75b3e347a594d7dacf4f74 \
|
||||
--hash=sha256:984a20b0f62a26f48a3396c72e4bc34c66e356d356bf370053066b3b6d54634a \
|
||||
--hash=sha256:9be5aafa5736574f8f15f262adc81b2a9869e2cfe9014d52a44633905b40d52c \
|
||||
--hash=sha256:9c459db21422be75e2809370b829a87eb37f74cd785fc4aa9ea1e5f43b47cda4 \
|
||||
--hash=sha256:9ccdac7d40688ecb5a3b4a604b8a88c8002e3442d6c60aead1db2a89a041560c \
|
||||
--hash=sha256:a0e692c683f4df67815a2d258b324e66f4738bd7a96a218c826dce4f4bd05d8f \
|
||||
--hash=sha256:a5da777e32ffed6f85a7b2b3f7c5cbc88c146bfcd0a1d7baf5fcc6c52ee35dd4 \
|
||||
--hash=sha256:a64697c641c7b1b2178e573cbc31c7c6684cd56883a478d75143dbb7118036db \
|
||||
--hash=sha256:ad64688338ed4bc1a6618076ba75fd7194a5f1797ac60b47afe926285adb3166 \
|
||||
--hash=sha256:bd72e68b06bb1e96913f97dd4901119bc17f39d4586a5adf2d3e47bc2b9d58b5 \
|
||||
--hash=sha256:c17dfe85494deaeddc5ce251aebd1d60bbe6afc8b62071bb0b469431a000124f \
|
||||
--hash=sha256:c18684a7f0cc9a3cb60328f496b8e3372def7c5d2df39ac267878b05565aaaae \
|
||||
--hash=sha256:cc90c0b39b2e3c65ef52c804b72e3c58f8a04ab2a1871272798e5f9572c17d20 \
|
||||
--hash=sha256:db63bf618e5dea46c07de12e900fe1cdd2541e6dc9dbae772a70b7d4d4765f6a \
|
||||
--hash=sha256:ea8990436d914540a40ab24b6a77c0969695ed52f4a4874c5137ccf7045a7057 \
|
||||
--hash=sha256:ecde28a596bead48b0cfd2a1b4416c3d43074c2d785e3a398d7ec1fc4d0f7fbb \
|
||||
--hash=sha256:f5333311663ea94f75dd408665686aaf426563556bb5283554a3539177e03b8c \
|
||||
--hash=sha256:fdfef35d751d510fcef5252703621574364fec16418c4a1e5e1055248401054b
|
||||
cryptography==48.0.1 \
|
||||
--hash=sha256:08a597acce1ff37f347400087776599e2348a3a8bc53b44120e463cd274efe4a \
|
||||
--hash=sha256:09f73a725d582cef64b91281a322cd798d14a33b2b6f2b7ad9531dc336d84c02 \
|
||||
--hash=sha256:0df56b056bc17c1b7d6821dfa65216e62bd232d8ab05eb3db44e71d235651471 \
|
||||
--hash=sha256:0ee6ea481db1ab889cba043ec1eda17bb9c1ea79db6722f779c3667f9f70322f \
|
||||
--hash=sha256:15254441469dd6bf027039453288e2072124f8b6603563f5d759e1c9b69273fa \
|
||||
--hash=sha256:266f4ee051abb2f725b74ef8072b521ce1feacf685a3364fa6a6b45548db791a \
|
||||
--hash=sha256:2c37f2461406063b417837f5f3daab668652acd82423efcd7f0a9f04be972de1 \
|
||||
--hash=sha256:32143b24adb918f078134e1e230f1eb8cc04886b92c28b5f0041aaf3e5699225 \
|
||||
--hash=sha256:33842cf0888951cef5bc7ac724ab844a42044c1727b967b7f8997289a0464f92 \
|
||||
--hash=sha256:3752f2dbc8f07a30aad2932c986cea495b03bb554887828225da104f732852b6 \
|
||||
--hash=sha256:39489bfca54c7a1f6b297efcd8bc608ab92d16c4ca631b0cad4da46724588b24 \
|
||||
--hash=sha256:3e4a1a3232eef2e6c732827d5722db29a0cc8b27af2a4d865b094cf954be9ca1 \
|
||||
--hash=sha256:3fd2ca57062b241c856670b073487d2e86c4637937ca5601e48f97bf8e11fc8f \
|
||||
--hash=sha256:42fcd8e26fe555d9b3577a135f5091fefa0aa4e99129c23fb56787a1bd4ada72 \
|
||||
--hash=sha256:43c5835e2cb98c8733d86f57d6fc879b613f5c3478607281c3e36daffc6dd8a6 \
|
||||
--hash=sha256:48fe40804d4caa2288f24e70ca8c64c42dd826da0ad7e4f1b41b2128d679e6c8 \
|
||||
--hash=sha256:4ab0a343c807bbcd90c971cd1ecf072937cd01847a9e002bef88fb47ac6be577 \
|
||||
--hash=sha256:4fdc69f8e4316bcf0c8c8ec1f26f285d12e8142d88d96c876a59a03be3f6ae67 \
|
||||
--hash=sha256:6184ca7b174f28d7c703f1290d4b297217c45355f77a98f67e9b7f14549ac54a \
|
||||
--hash=sha256:66fd0771e7b9c6dcd44cf1120690d2338d16d72795cf40cae2786a39eba65429 \
|
||||
--hash=sha256:6b2c0c3e6ccf3ade7750f836ef3ee36eea250cc467d45c256895573ac08cc6f1 \
|
||||
--hash=sha256:735824ec41b7f74a7c45fb1591349333e4c696cb6c044e5f46356e560143e4cd \
|
||||
--hash=sha256:7e234ac052af99f2700826a5c29ea99d9c1b1f80341cde62d11c8154dc8e0bd9 \
|
||||
--hash=sha256:869c3b8a53bfe27147832df48b32adadf558249d50e76cb3769d40e986b13265 \
|
||||
--hash=sha256:86be3b1b0b6bf09482fb50a979c508d2950ed95f5621ec77f4e385962006b83a \
|
||||
--hash=sha256:86fe77abb1bd87afb251d4d02ada7ecf53a32cee9b67d976abb2e45a13297475 \
|
||||
--hash=sha256:88c852a0ae366e262e5a1744b685e6a433dc8788dd2a277e418bf4904203609d \
|
||||
--hash=sha256:8ace4507d1e6533c125f4fac754f8bb8b6a74c08e92179dabd7e16571a3efbf3 \
|
||||
--hash=sha256:92a46e1d638daa264ba2971c0b0489c9409787943efae4d60ffda3d091ef832c \
|
||||
--hash=sha256:9621de99d2da096006b629979efd8ae7eb2d8b822488d0c89ee4000c306c59b1 \
|
||||
--hash=sha256:9a49ca6c81417f6a5edb50375a60cccdd70fa0a91a5211829dbea74eba94d2ac \
|
||||
--hash=sha256:9bd3f92d76217892b15df84ca256c2c113d386fdda7a7d8691aeeced976507c6 \
|
||||
--hash=sha256:9de21387aa95e2a895823d0745b430bed4f33503ba9ab5e0b5311f33e37d66d2 \
|
||||
--hash=sha256:b024e784ad6c077ee0147b35ea9cbfc1e34e1fd4c1dcca214c2794d73a12df08 \
|
||||
--hash=sha256:b4e391975f038e66432328639620a4aff2d307513b004f1ca06d6225bced815c \
|
||||
--hash=sha256:b74ca3b8e5ecdd833bf6a002ca41b4793bb27fb8f1c06ffaf2643c9e9140e31b \
|
||||
--hash=sha256:b7a2d1a937a738a881737cec135a38bb61470589b17515b9f73f571d0ae10401 \
|
||||
--hash=sha256:b9a32b876490d66c8bcc9963ef220199569748434ab01a9d6aaeabf88e7f5158 \
|
||||
--hash=sha256:bd81490cd5801d755cf97bb68ac191f14b708470b1c7cf4580f669b9c9264cd8 \
|
||||
--hash=sha256:c1400da5e32a43253392277eac7490a60e497d810a63dd5608d71bbd7af507c9 \
|
||||
--hash=sha256:d069066deead00ac7f090be101be875a06855908f7ec004c27b8fefb4acfb411 \
|
||||
--hash=sha256:d5d30989c6917b478b5817902e85fddaea2261efa8648383d965381ccb9e1ac4 \
|
||||
--hash=sha256:df637c05205ea7c1d7fbcbe54bbfea648a52951155f997af13d895d0ecc96991 \
|
||||
--hash=sha256:e361afba8918070d376df76f408a4f67fec0ee9cff81a99e48fe9a233ef59e17 \
|
||||
--hash=sha256:eb86ce1af36fe65041b6db9a8bb064ee621a7e5fded0f80d475ec243477cd242 \
|
||||
--hash=sha256:f0d27a5696721ef7a672b8c810f6aded391058e0b9486e63e6d93baf765da691 \
|
||||
--hash=sha256:f2ceef93cb096aa3c4cc4b5c94ca6131f9196d28c64d6111533402a9b2054d41 \
|
||||
--hash=sha256:f817adc181390bd54f2f700107a7419040fb7c1bdf2fc26f36551a06a68c3345 \
|
||||
--hash=sha256:fe0180af5bf9236518a087e35bf2d9a347d5f5f51e63c579d683ddff424e3d46
|
||||
# via
|
||||
# -c src/backend/requirements.txt
|
||||
# pdfminer-six
|
||||
|
|
@ -476,9 +476,9 @@ pdfminer-six==20260107 \
|
|||
--hash=sha256:366585ba97e80dffa8f00cebe303d2f381884d8637af4ce422f1df3ef38111a9 \
|
||||
--hash=sha256:96bfd431e3577a55a0efd25676968ca4ce8fd5b53f14565f85716ff363889602
|
||||
# via -r src/backend/requirements-dev.in
|
||||
pip==26.1.1 \
|
||||
--hash=sha256:99cb1c2899893b075ff56e4ed0af55669a955b49ad7fb8d8603ecdaf4ed653fb \
|
||||
--hash=sha256:d36762751d156a4ee895de8af39aa0abeeeb577f93a2eca6ab62467bbf0f8a78
|
||||
pip==26.1.2 \
|
||||
--hash=sha256:382ff9f685ee3bc25864f820aa50505825f10f5458ffff07e30a6d96e5715cab \
|
||||
--hash=sha256:f49cd134c61cf2fd75e0ce2676db03e4054504a5a4986d00f8299ae632dc4605
|
||||
# via pip-tools
|
||||
pip-tools==7.5.3 \
|
||||
--hash=sha256:3aac0c473240ae90db7213c033401f345b05197293ccbdd2704e52e7a783785e \
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
# Please keep this list sorted - if you pin a version provide a reason
|
||||
django<6.0 # Django package
|
||||
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-allauth[mfa,socialaccount,saml,openid,headless] # SSO for external providers via OpenID
|
||||
django-cleanup # Automated deletion of old / unused uploaded files
|
||||
|
|
|
|||
|
|
@ -435,56 +435,56 @@ charset-normalizer==3.4.7 \
|
|||
--hash=sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79 \
|
||||
--hash=sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464
|
||||
# via requests
|
||||
cryptography==48.0.0 \
|
||||
--hash=sha256:0890f502ddf7d9c6426129c3f49f5c0a39278ed7cd6322c8755ffca6ee675a13 \
|
||||
--hash=sha256:0c558d2cdffd8f4bbb30fc7134c74d2ca9a476f830bb053074498fbc86f41ed6 \
|
||||
--hash=sha256:16cd65b9330583e4619939b3a3843eec1e6e789744bb01e7c7e2e62e33c239c8 \
|
||||
--hash=sha256:18349bbc56f4743c8b12dc32e2bccb2cf83ee8b69a3bba74ef8ae857e26b3d25 \
|
||||
--hash=sha256:1e2d54c8be6152856a36f0882ab231e70f8ec7f14e93cf87db8a2ed056bf160c \
|
||||
--hash=sha256:22a5cb272895dce158b2cacdfdc3debd299019659f42947dbdac6f32d68fe832 \
|
||||
--hash=sha256:27241b1dc9962e056062a8eef1991d02c3a24569c95975bd2322a8a52c6e5e12 \
|
||||
--hash=sha256:2b4d59804e8408e2fea7d1fbaf218e5ec984325221db76e6a241a9abd6cdd95c \
|
||||
--hash=sha256:2eb992bbd4661238c5a397594c83f5b4dc2bc5b848c365c8f991b6780efcc5c7 \
|
||||
--hash=sha256:369a6348999f94bbd53435c894377b20ab95f25a9065c283570e70150d8abc3c \
|
||||
--hash=sha256:3cb07a3ed6431663cd321ea8a000a1314c74211f823e4177fefa2255e057d1ec \
|
||||
--hash=sha256:40ba1f85eaa6959837b1d51c9767e230e14612eea4ef110ee8854ada22da1bf5 \
|
||||
--hash=sha256:4defde8685ae324a9eb9d818717e93b4638ef67070ac9bc15b8ca85f63048355 \
|
||||
--hash=sha256:55b7718303bf06a5753dcdccf2f3945cf18ad7bffde41b61226e4db31ab89a9c \
|
||||
--hash=sha256:561215ea3879cb1cbbf272867e2efda62476f240fb58c64de6b393ae19246741 \
|
||||
--hash=sha256:58d00498e8933e4a194f3076aee1b4a97dfec1a6da444535755822fe5d8b0b86 \
|
||||
--hash=sha256:59baa2cb386c4f0b9905bd6eb4c2a79a69a128408fd31d32ca4d7102d4156321 \
|
||||
--hash=sha256:5a5ed8fde7a1d09376ca0b40e68cd59c69fe23b1f9768bd5824f54681626032a \
|
||||
--hash=sha256:5b012212e08b8dd5edc78ef54da83dd9892fd9105323b3993eff6bea65dc21d7 \
|
||||
--hash=sha256:5c3932f4436d1cccb036cb0eaef46e6e2db91035166f1ad6505c3c9d5a635920 \
|
||||
--hash=sha256:614d0949f4790582d2cc25553abd09dd723025f0c0e7c67376a1d77196743d6e \
|
||||
--hash=sha256:76341972e1eff8b4bea859f09c0d3e64b96ce931b084f9b9b7db8ef364c30eff \
|
||||
--hash=sha256:77a2ccbbe917f6710e05ba9adaa25fb5075620bf3ea6fb751997875aff4ae4bd \
|
||||
--hash=sha256:7995ef305d7165c3f11ae07f2517e5a4f1d5c18da1376a0a9ed496336b69e5f3 \
|
||||
--hash=sha256:7ce4bfae76319a532a2dc68f82cc32f5676ee792a983187dac07183690e5c66f \
|
||||
--hash=sha256:7e8eac43dfca5c4cccc6dad9a80504436fca53bb9bc3100a2386d730fbe6b602 \
|
||||
--hash=sha256:84cf79f0dc8b36ac5da873481716e87aef31fcfa0444f9e1d8b4b2cece142855 \
|
||||
--hash=sha256:8c7378637d7d88016fa6791c159f698b3d3eed28ebf844ac36b9dc04a14dae18 \
|
||||
--hash=sha256:8cd666227ef7af430aa5914a9910e0ddd703e75f039cef0825cd0da71b6b711a \
|
||||
--hash=sha256:906cbf0670286c6e0044156bc7d4af9cbb0ef6db9f73e52c3ec56ba6bdde5336 \
|
||||
--hash=sha256:9071196d81abc88b3516ac8cdfad32e2b66dd4a5393a8e68a961e9161ddc6239 \
|
||||
--hash=sha256:9249e3cd978541d665967ac2cb2787fd6a62bddf1e75b3e347a594d7dacf4f74 \
|
||||
--hash=sha256:984a20b0f62a26f48a3396c72e4bc34c66e356d356bf370053066b3b6d54634a \
|
||||
--hash=sha256:9be5aafa5736574f8f15f262adc81b2a9869e2cfe9014d52a44633905b40d52c \
|
||||
--hash=sha256:9c459db21422be75e2809370b829a87eb37f74cd785fc4aa9ea1e5f43b47cda4 \
|
||||
--hash=sha256:9ccdac7d40688ecb5a3b4a604b8a88c8002e3442d6c60aead1db2a89a041560c \
|
||||
--hash=sha256:a0e692c683f4df67815a2d258b324e66f4738bd7a96a218c826dce4f4bd05d8f \
|
||||
--hash=sha256:a5da777e32ffed6f85a7b2b3f7c5cbc88c146bfcd0a1d7baf5fcc6c52ee35dd4 \
|
||||
--hash=sha256:a64697c641c7b1b2178e573cbc31c7c6684cd56883a478d75143dbb7118036db \
|
||||
--hash=sha256:ad64688338ed4bc1a6618076ba75fd7194a5f1797ac60b47afe926285adb3166 \
|
||||
--hash=sha256:bd72e68b06bb1e96913f97dd4901119bc17f39d4586a5adf2d3e47bc2b9d58b5 \
|
||||
--hash=sha256:c17dfe85494deaeddc5ce251aebd1d60bbe6afc8b62071bb0b469431a000124f \
|
||||
--hash=sha256:c18684a7f0cc9a3cb60328f496b8e3372def7c5d2df39ac267878b05565aaaae \
|
||||
--hash=sha256:cc90c0b39b2e3c65ef52c804b72e3c58f8a04ab2a1871272798e5f9572c17d20 \
|
||||
--hash=sha256:db63bf618e5dea46c07de12e900fe1cdd2541e6dc9dbae772a70b7d4d4765f6a \
|
||||
--hash=sha256:ea8990436d914540a40ab24b6a77c0969695ed52f4a4874c5137ccf7045a7057 \
|
||||
--hash=sha256:ecde28a596bead48b0cfd2a1b4416c3d43074c2d785e3a398d7ec1fc4d0f7fbb \
|
||||
--hash=sha256:f5333311663ea94f75dd408665686aaf426563556bb5283554a3539177e03b8c \
|
||||
--hash=sha256:fdfef35d751d510fcef5252703621574364fec16418c4a1e5e1055248401054b
|
||||
cryptography==48.0.1 \
|
||||
--hash=sha256:08a597acce1ff37f347400087776599e2348a3a8bc53b44120e463cd274efe4a \
|
||||
--hash=sha256:09f73a725d582cef64b91281a322cd798d14a33b2b6f2b7ad9531dc336d84c02 \
|
||||
--hash=sha256:0df56b056bc17c1b7d6821dfa65216e62bd232d8ab05eb3db44e71d235651471 \
|
||||
--hash=sha256:0ee6ea481db1ab889cba043ec1eda17bb9c1ea79db6722f779c3667f9f70322f \
|
||||
--hash=sha256:15254441469dd6bf027039453288e2072124f8b6603563f5d759e1c9b69273fa \
|
||||
--hash=sha256:266f4ee051abb2f725b74ef8072b521ce1feacf685a3364fa6a6b45548db791a \
|
||||
--hash=sha256:2c37f2461406063b417837f5f3daab668652acd82423efcd7f0a9f04be972de1 \
|
||||
--hash=sha256:32143b24adb918f078134e1e230f1eb8cc04886b92c28b5f0041aaf3e5699225 \
|
||||
--hash=sha256:33842cf0888951cef5bc7ac724ab844a42044c1727b967b7f8997289a0464f92 \
|
||||
--hash=sha256:3752f2dbc8f07a30aad2932c986cea495b03bb554887828225da104f732852b6 \
|
||||
--hash=sha256:39489bfca54c7a1f6b297efcd8bc608ab92d16c4ca631b0cad4da46724588b24 \
|
||||
--hash=sha256:3e4a1a3232eef2e6c732827d5722db29a0cc8b27af2a4d865b094cf954be9ca1 \
|
||||
--hash=sha256:3fd2ca57062b241c856670b073487d2e86c4637937ca5601e48f97bf8e11fc8f \
|
||||
--hash=sha256:42fcd8e26fe555d9b3577a135f5091fefa0aa4e99129c23fb56787a1bd4ada72 \
|
||||
--hash=sha256:43c5835e2cb98c8733d86f57d6fc879b613f5c3478607281c3e36daffc6dd8a6 \
|
||||
--hash=sha256:48fe40804d4caa2288f24e70ca8c64c42dd826da0ad7e4f1b41b2128d679e6c8 \
|
||||
--hash=sha256:4ab0a343c807bbcd90c971cd1ecf072937cd01847a9e002bef88fb47ac6be577 \
|
||||
--hash=sha256:4fdc69f8e4316bcf0c8c8ec1f26f285d12e8142d88d96c876a59a03be3f6ae67 \
|
||||
--hash=sha256:6184ca7b174f28d7c703f1290d4b297217c45355f77a98f67e9b7f14549ac54a \
|
||||
--hash=sha256:66fd0771e7b9c6dcd44cf1120690d2338d16d72795cf40cae2786a39eba65429 \
|
||||
--hash=sha256:6b2c0c3e6ccf3ade7750f836ef3ee36eea250cc467d45c256895573ac08cc6f1 \
|
||||
--hash=sha256:735824ec41b7f74a7c45fb1591349333e4c696cb6c044e5f46356e560143e4cd \
|
||||
--hash=sha256:7e234ac052af99f2700826a5c29ea99d9c1b1f80341cde62d11c8154dc8e0bd9 \
|
||||
--hash=sha256:869c3b8a53bfe27147832df48b32adadf558249d50e76cb3769d40e986b13265 \
|
||||
--hash=sha256:86be3b1b0b6bf09482fb50a979c508d2950ed95f5621ec77f4e385962006b83a \
|
||||
--hash=sha256:86fe77abb1bd87afb251d4d02ada7ecf53a32cee9b67d976abb2e45a13297475 \
|
||||
--hash=sha256:88c852a0ae366e262e5a1744b685e6a433dc8788dd2a277e418bf4904203609d \
|
||||
--hash=sha256:8ace4507d1e6533c125f4fac754f8bb8b6a74c08e92179dabd7e16571a3efbf3 \
|
||||
--hash=sha256:92a46e1d638daa264ba2971c0b0489c9409787943efae4d60ffda3d091ef832c \
|
||||
--hash=sha256:9621de99d2da096006b629979efd8ae7eb2d8b822488d0c89ee4000c306c59b1 \
|
||||
--hash=sha256:9a49ca6c81417f6a5edb50375a60cccdd70fa0a91a5211829dbea74eba94d2ac \
|
||||
--hash=sha256:9bd3f92d76217892b15df84ca256c2c113d386fdda7a7d8691aeeced976507c6 \
|
||||
--hash=sha256:9de21387aa95e2a895823d0745b430bed4f33503ba9ab5e0b5311f33e37d66d2 \
|
||||
--hash=sha256:b024e784ad6c077ee0147b35ea9cbfc1e34e1fd4c1dcca214c2794d73a12df08 \
|
||||
--hash=sha256:b4e391975f038e66432328639620a4aff2d307513b004f1ca06d6225bced815c \
|
||||
--hash=sha256:b74ca3b8e5ecdd833bf6a002ca41b4793bb27fb8f1c06ffaf2643c9e9140e31b \
|
||||
--hash=sha256:b7a2d1a937a738a881737cec135a38bb61470589b17515b9f73f571d0ae10401 \
|
||||
--hash=sha256:b9a32b876490d66c8bcc9963ef220199569748434ab01a9d6aaeabf88e7f5158 \
|
||||
--hash=sha256:bd81490cd5801d755cf97bb68ac191f14b708470b1c7cf4580f669b9c9264cd8 \
|
||||
--hash=sha256:c1400da5e32a43253392277eac7490a60e497d810a63dd5608d71bbd7af507c9 \
|
||||
--hash=sha256:d069066deead00ac7f090be101be875a06855908f7ec004c27b8fefb4acfb411 \
|
||||
--hash=sha256:d5d30989c6917b478b5817902e85fddaea2261efa8648383d965381ccb9e1ac4 \
|
||||
--hash=sha256:df637c05205ea7c1d7fbcbe54bbfea648a52951155f997af13d895d0ecc96991 \
|
||||
--hash=sha256:e361afba8918070d376df76f408a4f67fec0ee9cff81a99e48fe9a233ef59e17 \
|
||||
--hash=sha256:eb86ce1af36fe65041b6db9a8bb064ee621a7e5fded0f80d475ec243477cd242 \
|
||||
--hash=sha256:f0d27a5696721ef7a672b8c810f6aded391058e0b9486e63e6d93baf765da691 \
|
||||
--hash=sha256:f2ceef93cb096aa3c4cc4b5c94ca6131f9196d28c64d6111533402a9b2054d41 \
|
||||
--hash=sha256:f817adc181390bd54f2f700107a7419040fb7c1bdf2fc26f36551a06a68c3345 \
|
||||
--hash=sha256:fe0180af5bf9236518a087e35bf2d9a347d5f5f51e63c579d683ddff424e3d46
|
||||
# via
|
||||
# -r src/backend/requirements.in
|
||||
# django-anymail
|
||||
|
|
|
|||
|
|
@ -49,16 +49,16 @@
|
|||
"@codemirror/state": "^6.6.0",
|
||||
"@codemirror/theme-one-dark": "^6.1.3",
|
||||
"@codemirror/view": "^6.40.0",
|
||||
"@emotion/react": "^11.13.3",
|
||||
"@floating-ui/dom": "^1.0.0",
|
||||
"@fortawesome/fontawesome-svg-core": "^7.0.0",
|
||||
"@fortawesome/free-regular-svg-icons": "^7.0.0",
|
||||
"@fortawesome/free-solid-svg-icons": "^7.0.0",
|
||||
"@fortawesome/react-fontawesome": "^3.0.1",
|
||||
"@fullcalendar/core": "^6.1.15",
|
||||
"@fullcalendar/daygrid": "^6.1.15",
|
||||
"@fullcalendar/interaction": "^6.1.15",
|
||||
"@fullcalendar/react": "^6.1.15",
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@fortawesome/fontawesome-svg-core": "^7.2.0",
|
||||
"@fortawesome/free-regular-svg-icons": "^7.2.0",
|
||||
"@fortawesome/free-solid-svg-icons": "^7.2.0",
|
||||
"@fortawesome/react-fontawesome": "^3.3.1",
|
||||
"@fullcalendar/core": "^6.1.20",
|
||||
"@fullcalendar/daygrid": "^6.1.20",
|
||||
"@fullcalendar/interaction": "^6.1.20",
|
||||
"@fullcalendar/react": "^6.1.20",
|
||||
"@github/webauthn-json": "^2.1.1",
|
||||
"@lingui/core": "^5.9.2",
|
||||
"@lingui/react": "^5.9.2",
|
||||
|
|
@ -76,9 +76,6 @@
|
|||
"@mantine/utils": "^6.0.22",
|
||||
"@mantine/vanilla-extract": "^9.2.1",
|
||||
"@messageformat/date-skeleton": "^1.1.0",
|
||||
"@sentry/react": "^10.43.0",
|
||||
"@tabler/icons-react": "^3.17.0",
|
||||
"@tanstack/react-query": "^5.56.2",
|
||||
"@tiptap/core": "^3.23.6",
|
||||
"@tiptap/extension-image": "^3.23.6",
|
||||
"@tiptap/extension-link": "^3.23.6",
|
||||
|
|
@ -86,68 +83,71 @@
|
|||
"@tiptap/pm": "^3.23.6",
|
||||
"@tiptap/react": "^3.23.6",
|
||||
"@tiptap/starter-kit": "^3.23.6",
|
||||
"@sentry/react": "^10.57.0",
|
||||
"@tabler/icons-react": "^3.44.0",
|
||||
"@tanstack/react-query": "^5.101.0",
|
||||
"@uiw/codemirror-theme-vscode": "^4.25.8",
|
||||
"@uiw/react-codemirror": "^4.25.8",
|
||||
"@uiw/react-split": "^5.9.4",
|
||||
"@vanilla-extract/css": "^1.18.0",
|
||||
"axios": "^1.13.6",
|
||||
"@vanilla-extract/css": "^1.20.1",
|
||||
"axios": "^1.17.0",
|
||||
"clsx": "^2.1.1",
|
||||
"codemirror": "^6.0.2",
|
||||
"dayjs": "^1.11.13",
|
||||
"dompurify": "^3.2.4",
|
||||
"embla-carousel": "^8.5.2",
|
||||
"embla-carousel-react": "^8.5.2",
|
||||
"fuse.js": "^7.0.0",
|
||||
"dayjs": "^1.11.21",
|
||||
"dompurify": "^3.4.8",
|
||||
"embla-carousel": "^8.6.0",
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
"fuse.js": "^7.4.2",
|
||||
"html5-qrcode": "^2.3.8",
|
||||
"mantine-contextmenu": "^9.2.1",
|
||||
"mantine-datatable": "^9.2.0",
|
||||
"mantine-datatable": "^9.2.2",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"react": "^19.2.7",
|
||||
"react-dom": "^19.2.7",
|
||||
"react-grid-layout": "1.4.4",
|
||||
"react-hook-form": "^7.62.0",
|
||||
"react-is": "^19.2.4",
|
||||
"react-router-dom": "^6.26.2",
|
||||
"react-select": "^5.9.0",
|
||||
"react-window": "1.8.11",
|
||||
"recharts": "^3.1.2",
|
||||
"styled-components": "^6.1.14",
|
||||
"tiptap-extension-resizable-image": "^2.1.0",
|
||||
"undici": "^6.24.0",
|
||||
"zustand": "^5.0.8"
|
||||
"react-hook-form": "^7.78.0",
|
||||
"react-is": "^19.2.7",
|
||||
"react-router-dom": "^6.30.4",
|
||||
"react-select": "^5.10.2",
|
||||
"react-window": "1.8.11",
|
||||
"recharts": "^3.8.1",
|
||||
"styled-components": "^6.4.2",
|
||||
"undici": "^8.4.1",
|
||||
"zustand": "^5.0.14"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.29.0",
|
||||
"@babel/preset-react": "^7.28.5",
|
||||
"@babel/preset-typescript": "^7.28.5",
|
||||
"@babel/runtime": "^7.28.6",
|
||||
"@codecov/vite-plugin": "^1.9.1",
|
||||
"@babel/core": "^7.29.7",
|
||||
"@babel/preset-react": "^7.29.7",
|
||||
"@babel/preset-typescript": "^7.29.7",
|
||||
"@babel/runtime": "^7.29.7",
|
||||
"@codecov/vite-plugin": "^2.0.1",
|
||||
"@flakiness/playwright": "^1.13.0",
|
||||
"@lingui/babel-plugin-lingui-macro": "^5.9.2",
|
||||
"@lingui/cli": "^5.9.2",
|
||||
"@lingui/macro": "^5.9.2",
|
||||
"@playwright/test": "^1.16.0",
|
||||
"@types/node": "^25.5.0",
|
||||
"@types/qrcode": "^1.5.5",
|
||||
"@types/react": "^19.2.14",
|
||||
"@lingui/babel-plugin-lingui-macro": "^5.9.5",
|
||||
"@lingui/cli": "^5.9.5",
|
||||
"@lingui/macro": "^5.9.5",
|
||||
"@playwright/test": "^1.60.0",
|
||||
"@types/node": "^25.9.2",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/react-grid-layout": "^1.3.5",
|
||||
"@types/react-router-dom": "^5.3.3",
|
||||
"@types/react-window": "^1.8.8",
|
||||
"@vanilla-extract/vite-plugin": "^5.1.4",
|
||||
"@vanilla-extract/vite-plugin": "^5.2.2",
|
||||
"@vitejs/plugin-react": "^5.2.0",
|
||||
"babel-plugin-macros": "^3.1.0",
|
||||
"nyc": "^18.0.0",
|
||||
"otpauth": "^9.4.1",
|
||||
"otpauth": "^9.5.1",
|
||||
"path": "^0.12.7",
|
||||
"rollup": "^4.59.0",
|
||||
"rollup-plugin-license": "^3.7.0",
|
||||
"rollup": "^4.61.1",
|
||||
"rollup-plugin-license": "^3.7.1",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^6.4.2",
|
||||
"vite-plugin-babel-macros": "^1.0.6",
|
||||
"vite-plugin-dts": "^4.5.4",
|
||||
"vite-plugin-dts": "^5.0.2",
|
||||
"vite-plugin-externals": "^0.6.2",
|
||||
"vite-plugin-istanbul": "^8.0.0"
|
||||
"vite-plugin-istanbul": "^9.0.1"
|
||||
},
|
||||
"overrides": {
|
||||
},
|
||||
|
|
|
|||
|
|
@ -68,7 +68,7 @@ export function ApiFormField({
|
|||
: definition.value
|
||||
);
|
||||
}
|
||||
}, [definition.value]);
|
||||
}, [definition.value, definition.field_type]);
|
||||
|
||||
const fieldDefinition: ApiFormFieldType = useMemo(() => {
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
session,
|
||||
column,
|
||||
|
|
@ -210,6 +259,9 @@ function ImporterColumnTableRow({
|
|||
<Table.Td>
|
||||
<ImporterColumn column={column} options={options} />
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<ImporterLookupFieldSelector column={column} session={session} />
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<ImporterDefaultField
|
||||
fieldName={column.field}
|
||||
|
|
@ -285,6 +337,7 @@ export default function ImporterColumnSelector({
|
|||
<Table.Th>{t`Database Field`}</Table.Th>
|
||||
<Table.Th>{t`Field Description`}</Table.Th>
|
||||
<Table.Th>{t`Imported Column`}</Table.Th>
|
||||
<Table.Th>{t`Lookup Field`}</Table.Th>
|
||||
<Table.Th>{t`Default Value`}</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
|
|||
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
|
||||
import { ModelType } from '@lib/enums/ModelType';
|
||||
import { apiUrl } from '@lib/functions/Api';
|
||||
import type { ApiFormFieldSet } from '@lib/types/Forms';
|
||||
import type { ApiFormFieldSet, ApiFormFieldType } from '@lib/types/Forms';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import type {
|
||||
StatusCodeInterface,
|
||||
|
|
@ -117,32 +117,44 @@ export function useParameterTemplateFields(): ApiFormFieldSet {
|
|||
}, []);
|
||||
}
|
||||
|
||||
export function useParameterFields({
|
||||
modelType,
|
||||
modelId
|
||||
}: {
|
||||
modelType: ModelType;
|
||||
modelId: number;
|
||||
}): ApiFormFieldSet {
|
||||
/**
|
||||
* Shared hook for the dynamic "value" field on parameter forms.
|
||||
*
|
||||
* When the user selects a parameter template, the field type for the
|
||||
* corresponding value input (data / default_value) must change to match the
|
||||
* template's data type (boolean, choice, related-field selection list, or
|
||||
* plain string). This hook encapsulates that state so it can be reused
|
||||
* across the "Add Parameter" and "Add Category Parameter" forms.
|
||||
*
|
||||
* @param resetDep - When this value changes all internal state is reset to
|
||||
* defaults. Pass a stringified key derived from the form's context (e.g.
|
||||
* `${modelType}-${modelId}`) so the field resets when the context switches.
|
||||
*/
|
||||
export function useDynamicParameterValueField(resetDep?: any): {
|
||||
onTemplateValueChange: (value: any, record: any) => void;
|
||||
valueFieldConfig: ApiFormFieldType;
|
||||
reset: () => void;
|
||||
} {
|
||||
const api = useApi();
|
||||
|
||||
const user = useUserState.getState();
|
||||
|
||||
const templateCreateFields = useParameterTemplateFields();
|
||||
|
||||
const [selectionListId, setSelectionListId] = useState<number | null>(null);
|
||||
|
||||
// Valid field choices
|
||||
const [choices, setChoices] = useState<any[]>([]);
|
||||
|
||||
// Field type for "data" input
|
||||
const [fieldType, setFieldType] = useState<
|
||||
'string' | 'boolean' | 'choice' | 'related field'
|
||||
>('string');
|
||||
|
||||
// Memoized value for the "data" field
|
||||
const [data, setData] = useState<string>('');
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setSelectionListId(null);
|
||||
setFieldType('string');
|
||||
setChoices([]);
|
||||
setData('');
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
reset();
|
||||
}, [resetDep, reset]);
|
||||
|
||||
const fetchSelectionEntry = useCallback(
|
||||
(value: any) => {
|
||||
if (!value || !selectionListId) {
|
||||
|
|
@ -151,9 +163,7 @@ export function useParameterFields({
|
|||
|
||||
return api
|
||||
.get(apiUrl(ApiEndpoints.selectionentry_list, selectionListId), {
|
||||
params: {
|
||||
value: value
|
||||
}
|
||||
params: { value: value }
|
||||
})
|
||||
.then((response) => {
|
||||
if (response.data && response.data.length == 1) {
|
||||
|
|
@ -166,13 +176,102 @@ export function useParameterFields({
|
|||
[selectionListId]
|
||||
);
|
||||
|
||||
// Reset the field type and choices when the model changes
|
||||
useEffect(() => {
|
||||
setSelectionListId(null);
|
||||
setFieldType('string');
|
||||
setChoices([]);
|
||||
setData('');
|
||||
}, [modelType, modelId]);
|
||||
const onTemplateValueChange = useCallback(
|
||||
(value: any, record: any) => {
|
||||
setSelectionListId(record?.selectionlist || null);
|
||||
setData('');
|
||||
|
||||
if (record?.checkbox) {
|
||||
setChoices([]);
|
||||
setFieldType('boolean');
|
||||
setData('false');
|
||||
} else if (record?.choices) {
|
||||
const _choices: string[] = record.choices.split(',');
|
||||
|
||||
if (_choices.length > 0) {
|
||||
setChoices(
|
||||
_choices.map((choice) => ({
|
||||
display_name: choice.trim(),
|
||||
value: choice.trim()
|
||||
}))
|
||||
);
|
||||
setFieldType('choice');
|
||||
} else {
|
||||
setChoices([]);
|
||||
setFieldType('string');
|
||||
setData('');
|
||||
}
|
||||
} else if (record?.selectionlist) {
|
||||
setFieldType('related field');
|
||||
setData('');
|
||||
} else {
|
||||
setFieldType('string');
|
||||
setData('');
|
||||
}
|
||||
},
|
||||
[setFieldType, setData, setChoices]
|
||||
);
|
||||
|
||||
const valueFieldConfig: ApiFormFieldType = useMemo(
|
||||
() => ({
|
||||
value: data,
|
||||
onValueChange: (value: any, record: any) => {
|
||||
if (fieldType === 'related field' && selectionListId) {
|
||||
// For related fields, store the primary key value (not the string representation)
|
||||
setData(record?.value ?? value);
|
||||
} else {
|
||||
setData(value);
|
||||
}
|
||||
},
|
||||
field_type: fieldType,
|
||||
choices: fieldType === 'choice' ? choices : undefined,
|
||||
default: fieldType === 'boolean' ? false : undefined,
|
||||
pk_field:
|
||||
fieldType === 'related field' && selectionListId ? 'value' : undefined,
|
||||
model:
|
||||
fieldType === 'related field' && selectionListId
|
||||
? ModelType.selectionentry
|
||||
: undefined,
|
||||
api_url:
|
||||
fieldType === 'related field' && selectionListId
|
||||
? apiUrl(ApiEndpoints.selectionentry_list, selectionListId)
|
||||
: undefined,
|
||||
filters: fieldType === 'related field' ? { active: true } : undefined,
|
||||
adjustValue: (value: any) => {
|
||||
let v: string = value.toString().trim();
|
||||
|
||||
if (fieldType === 'boolean') {
|
||||
if (v.toLowerCase() !== 'true') {
|
||||
v = 'false';
|
||||
}
|
||||
}
|
||||
|
||||
return v;
|
||||
},
|
||||
singleFetchFunction: fetchSelectionEntry
|
||||
}),
|
||||
[data, fieldType, choices, selectionListId, fetchSelectionEntry]
|
||||
);
|
||||
|
||||
return { onTemplateValueChange, valueFieldConfig, reset };
|
||||
}
|
||||
|
||||
export function useParameterFields({
|
||||
modelType,
|
||||
modelId
|
||||
}: {
|
||||
modelType: ModelType;
|
||||
modelId: number;
|
||||
}): ApiFormFieldSet {
|
||||
const user = useUserState.getState();
|
||||
const templateCreateFields = useParameterTemplateFields();
|
||||
|
||||
const resetKey = useMemo(
|
||||
() => `${modelType}-${modelId}`,
|
||||
[modelType, modelId]
|
||||
);
|
||||
const { onTemplateValueChange, valueFieldConfig } =
|
||||
useDynamicParameterValueField(resetKey);
|
||||
|
||||
return useMemo(() => {
|
||||
return {
|
||||
|
|
@ -189,97 +288,17 @@ export function useParameterFields({
|
|||
for_model: modelType,
|
||||
enabled: true
|
||||
},
|
||||
onValueChange: (value: any, record: any) => {
|
||||
setSelectionListId(record?.selectionlist || null);
|
||||
|
||||
// Adjust the type of the "data" field based on the selected template
|
||||
if (record?.checkbox) {
|
||||
// This is a "checkbox" field
|
||||
setChoices([]);
|
||||
setFieldType('boolean');
|
||||
setData('false');
|
||||
} else if (record?.choices) {
|
||||
const _choices: string[] = record.choices.split(',');
|
||||
|
||||
if (_choices.length > 0) {
|
||||
setChoices(
|
||||
_choices.map((choice) => {
|
||||
return {
|
||||
display_name: choice.trim(),
|
||||
value: choice.trim()
|
||||
};
|
||||
})
|
||||
);
|
||||
setFieldType('choice');
|
||||
} else {
|
||||
setChoices([]);
|
||||
setFieldType('string');
|
||||
}
|
||||
} else if (record?.selectionlist) {
|
||||
setFieldType('related field');
|
||||
} else {
|
||||
// Default to a simple string field
|
||||
setFieldType('string');
|
||||
}
|
||||
},
|
||||
onValueChange: onTemplateValueChange,
|
||||
addCreateFields: user.isStaff() ? templateCreateFields : undefined
|
||||
},
|
||||
data: {
|
||||
value: data,
|
||||
onValueChange: (value: any, record: any) => {
|
||||
if (fieldType === 'related field' && selectionListId) {
|
||||
// For related fields, we need to store the selected primary key value (not the string representation)
|
||||
setData(record?.value ?? value);
|
||||
} else {
|
||||
setData(value);
|
||||
}
|
||||
},
|
||||
type: fieldType,
|
||||
field_type: fieldType,
|
||||
choices: fieldType === 'choice' ? choices : undefined,
|
||||
default: fieldType === 'boolean' ? false : undefined,
|
||||
pk_field:
|
||||
fieldType === 'related field' && selectionListId
|
||||
? 'value'
|
||||
: undefined,
|
||||
model:
|
||||
fieldType === 'related field' && selectionListId
|
||||
? ModelType.selectionentry
|
||||
: undefined,
|
||||
api_url:
|
||||
fieldType === 'related field' && selectionListId
|
||||
? apiUrl(ApiEndpoints.selectionentry_list, selectionListId)
|
||||
: undefined,
|
||||
filters:
|
||||
fieldType === 'related field'
|
||||
? {
|
||||
active: true
|
||||
}
|
||||
: undefined,
|
||||
adjustValue: (value: any) => {
|
||||
// Coerce boolean value into a string (required by backend)
|
||||
|
||||
let v: string = value.toString().trim();
|
||||
|
||||
if (fieldType === 'boolean') {
|
||||
if (v.toLowerCase() !== 'true') {
|
||||
v = 'false';
|
||||
}
|
||||
}
|
||||
|
||||
return v;
|
||||
},
|
||||
singleFetchFunction: fetchSelectionEntry
|
||||
},
|
||||
data: valueFieldConfig,
|
||||
note: {}
|
||||
};
|
||||
}, [
|
||||
data,
|
||||
modelType,
|
||||
fieldType,
|
||||
choices,
|
||||
modelId,
|
||||
selectionListId,
|
||||
onTemplateValueChange,
|
||||
valueFieldConfig,
|
||||
templateCreateFields,
|
||||
user
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import type { TableFilter } from '@lib/types/Filters';
|
|||
import type { ApiFormFieldSet } from '@lib/types/Forms';
|
||||
import type { TableColumn } from '@lib/types/Tables';
|
||||
import { IconInfoCircle } from '@tabler/icons-react';
|
||||
import { useDynamicParameterValueField } from '../../forms/CommonForms';
|
||||
import {
|
||||
useCreateApiFormModal,
|
||||
useDeleteApiFormModal,
|
||||
|
|
@ -32,16 +33,21 @@ export default function PartCategoryTemplateTable({
|
|||
const table = useTable('part-category-parameter-templates');
|
||||
const user = useUserState();
|
||||
|
||||
const { onTemplateValueChange, valueFieldConfig, reset } =
|
||||
useDynamicParameterValueField(categoryId);
|
||||
|
||||
const formFields: ApiFormFieldSet = useMemo(() => {
|
||||
return {
|
||||
category: {
|
||||
value: categoryId,
|
||||
disabled: categoryId !== undefined
|
||||
},
|
||||
template: {},
|
||||
default_value: {}
|
||||
template: {
|
||||
onValueChange: onTemplateValueChange
|
||||
},
|
||||
default_value: valueFieldConfig
|
||||
};
|
||||
}, [categoryId]);
|
||||
}, [categoryId, onTemplateValueChange, valueFieldConfig]);
|
||||
|
||||
const [selectedTemplate, setSelectedTemplate] = useState<number>(0);
|
||||
|
||||
|
|
@ -49,6 +55,7 @@ export default function PartCategoryTemplateTable({
|
|||
url: ApiEndpoints.category_parameter_list,
|
||||
title: t`Add Category Parameter`,
|
||||
fields: useMemo(() => ({ ...formFields }), [formFields]),
|
||||
onOpen: reset,
|
||||
table: table
|
||||
});
|
||||
|
||||
|
|
@ -57,6 +64,7 @@ export default function PartCategoryTemplateTable({
|
|||
pk: selectedTemplate,
|
||||
title: t`Edit Category Parameter`,
|
||||
fields: useMemo(() => ({ ...formFields }), [formFields]),
|
||||
onOpen: reset,
|
||||
table: table
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -98,11 +98,13 @@ export default function PluginListTable() {
|
|||
BooleanColumn({
|
||||
accessor: 'is_builtin',
|
||||
sortable: false,
|
||||
filter: 'builtin',
|
||||
title: t`Builtin`
|
||||
}),
|
||||
BooleanColumn({
|
||||
accessor: 'is_mandatory',
|
||||
sortable: false,
|
||||
filter: 'mandatory',
|
||||
title: t`Mandatory`
|
||||
}),
|
||||
{
|
||||
|
|
|
|||
|
|
@ -123,6 +123,7 @@ export const test = baseTest.extend<{}, {}>({
|
|||
msg.text() !=
|
||||
'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('Error removing trace ID:') &&
|
||||
!url.includes('/this/does/not/exist.js') &&
|
||||
!url.includes('/api/user/me/') &&
|
||||
!url.includes('/api/user/me/token/') &&
|
||||
|
|
|
|||
|
|
@ -903,4 +903,16 @@ test('Build Order - BOM Quantity', async ({ browser }) => {
|
|||
.locator('div');
|
||||
const row2 = await getRowFromCell(line);
|
||||
await row2.getByText('1,175').first().waitFor();
|
||||
|
||||
// Test table filtering against the "Required Parts" table
|
||||
await clearTableFilters(page);
|
||||
await page.getByText('1 - 7 / 7').waitFor();
|
||||
|
||||
// Filter by "available" stock
|
||||
await setTableChoiceFilter(page, 'Available', 'Yes');
|
||||
await page.getByText('1 - 3 / 3').waitFor();
|
||||
|
||||
await clearTableFilters(page);
|
||||
await setTableChoiceFilter(page, 'Available', 'No');
|
||||
await page.getByText('1 - 4 / 4').waitFor();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ test('Forms - Hover', async ({ browser }) => {
|
|||
await page.getByLabel('related-field-supplier').fill('mou');
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.waitForTimeout(250);
|
||||
await page.getByRole('option', { name: 'Mouser Electronics' }).hover();
|
||||
await page.getByText('Mouser Electronics').first().hover();
|
||||
|
||||
// Check for hover info
|
||||
await page.getByText('Company[ID: 2]').waitFor();
|
||||
|
|
|
|||
|
|
@ -60,7 +60,7 @@ test('Importing - Admin Center', async ({ browser }) => {
|
|||
await page.getByRole('button', { name: 'Close' }).click();
|
||||
|
||||
// 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
|
||||
await page.getByRole('checkbox', { name: 'Select all records' }).check();
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue