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