mirror of
https://github.com/inventree/InvenTree.git
synced 2026-09-01 17:51:23 +00:00
Merge commit '5c95dfe484b0e01b8a3f95e7413f0f6f71cdc3cd' 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
|
||||||
|
|||||||
@@ -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,19 @@
|
|||||||
"""InvenTree API version information."""
|
"""InvenTree API version information."""
|
||||||
|
|
||||||
# InvenTree API version
|
# InvenTree API version
|
||||||
INVENTREE_API_VERSION = 506
|
INVENTREE_API_VERSION = 507
|
||||||
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
|
"""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
|
v507 -> 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
|
||||||
|
|
||||||
|
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()
|
||||||
|
|||||||
@@ -1481,7 +1481,7 @@ class ObservabilityEndSerializer(serializers.Serializer):
|
|||||||
class ObservabilityEnd(CreateAPI):
|
class ObservabilityEnd(CreateAPI):
|
||||||
"""Endpoint for observability tools."""
|
"""Endpoint for observability tools."""
|
||||||
|
|
||||||
permission_classes = [AllowAnyOrReadScope]
|
permission_classes = [IsAuthenticated, AllowAnyOrReadScope]
|
||||||
serializer_class = ObservabilityEndSerializer
|
serializer_class = ObservabilityEndSerializer
|
||||||
|
|
||||||
def create(self, request, *args, **kwargs):
|
def create(self, request, *args, **kwargs):
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -344,14 +344,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
|
||||||
|
|
||||||
|
|||||||
@@ -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())
|
||||||
@@ -204,6 +211,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.
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
+50
-49
@@ -49,16 +49,15 @@
|
|||||||
"@codemirror/state": "^6.6.0",
|
"@codemirror/state": "^6.6.0",
|
||||||
"@codemirror/theme-one-dark": "^6.1.3",
|
"@codemirror/theme-one-dark": "^6.1.3",
|
||||||
"@codemirror/view": "^6.40.0",
|
"@codemirror/view": "^6.40.0",
|
||||||
"@emotion/react": "^11.13.3",
|
"@emotion/react": "^11.14.0",
|
||||||
"@floating-ui/dom": "^1.0.0",
|
"@fortawesome/fontawesome-svg-core": "^7.2.0",
|
||||||
"@fortawesome/fontawesome-svg-core": "^7.0.0",
|
"@fortawesome/free-regular-svg-icons": "^7.2.0",
|
||||||
"@fortawesome/free-regular-svg-icons": "^7.0.0",
|
"@fortawesome/free-solid-svg-icons": "^7.2.0",
|
||||||
"@fortawesome/free-solid-svg-icons": "^7.0.0",
|
"@fortawesome/react-fontawesome": "^3.3.1",
|
||||||
"@fortawesome/react-fontawesome": "^3.0.1",
|
"@fullcalendar/core": "^6.1.20",
|
||||||
"@fullcalendar/core": "^6.1.15",
|
"@fullcalendar/daygrid": "^6.1.20",
|
||||||
"@fullcalendar/daygrid": "^6.1.15",
|
"@fullcalendar/interaction": "^6.1.20",
|
||||||
"@fullcalendar/interaction": "^6.1.15",
|
"@fullcalendar/react": "^6.1.20",
|
||||||
"@fullcalendar/react": "^6.1.15",
|
|
||||||
"@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 +75,9 @@
|
|||||||
"@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",
|
"@sentry/react": "^10.57.0",
|
||||||
"@tabler/icons-react": "^3.17.0",
|
"@tabler/icons-react": "^3.44.0",
|
||||||
"@tanstack/react-query": "^5.56.2",
|
"@tanstack/react-query": "^5.101.0",
|
||||||
"@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",
|
||||||
@@ -89,65 +88,67 @@
|
|||||||
"@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",
|
"easymde": "^2.21.0",
|
||||||
"embla-carousel-react": "^8.5.2",
|
"embla-carousel": "^8.6.0",
|
||||||
"fuse.js": "^7.0.0",
|
"embla-carousel-react": "^8.6.0",
|
||||||
|
"fuse.js": "^7.4.2",
|
||||||
"html5-qrcode": "^2.3.8",
|
"html5-qrcode": "^2.3.8",
|
||||||
"mantine-contextmenu": "^9.2.1",
|
"mantine-contextmenu": "^9.2.1",
|
||||||
"mantine-datatable": "^9.2.0",
|
"mantine-datatable": "^9.2.2",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.7",
|
||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.7",
|
||||||
"react-grid-layout": "1.4.4",
|
"react-grid-layout": "1.4.4",
|
||||||
"react-hook-form": "^7.62.0",
|
"react-hook-form": "^7.78.0",
|
||||||
"react-is": "^19.2.4",
|
"react-is": "^19.2.7",
|
||||||
"react-router-dom": "^6.26.2",
|
"react-router-dom": "^6.30.4",
|
||||||
"react-select": "^5.9.0",
|
"react-select": "^5.10.2",
|
||||||
|
"react-simplemde-editor": "^5.2.0",
|
||||||
"react-window": "1.8.11",
|
"react-window": "1.8.11",
|
||||||
"recharts": "^3.1.2",
|
"recharts": "^3.8.1",
|
||||||
"styled-components": "^6.1.14",
|
"styled-components": "^6.4.2",
|
||||||
"tiptap-extension-resizable-image": "^2.1.0",
|
"tiptap-extension-resizable-image": "^2.1.0",
|
||||||
"undici": "^6.24.0",
|
"undici": "^8.4.1",
|
||||||
"zustand": "^5.0.8"
|
"zustand": "^5.0.14"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/core": "^7.29.0",
|
"@babel/core": "^7.29.7",
|
||||||
"@babel/preset-react": "^7.28.5",
|
"@babel/preset-react": "^7.29.7",
|
||||||
"@babel/preset-typescript": "^7.28.5",
|
"@babel/preset-typescript": "^7.29.7",
|
||||||
"@babel/runtime": "^7.28.6",
|
"@babel/runtime": "^7.29.7",
|
||||||
"@codecov/vite-plugin": "^1.9.1",
|
"@codecov/vite-plugin": "^2.0.1",
|
||||||
"@flakiness/playwright": "^1.13.0",
|
"@flakiness/playwright": "^1.13.0",
|
||||||
"@lingui/babel-plugin-lingui-macro": "^5.9.2",
|
"@lingui/babel-plugin-lingui-macro": "^5.9.5",
|
||||||
"@lingui/cli": "^5.9.2",
|
"@lingui/cli": "^5.9.5",
|
||||||
"@lingui/macro": "^5.9.2",
|
"@lingui/macro": "^5.9.5",
|
||||||
"@playwright/test": "^1.16.0",
|
"@playwright/test": "^1.60.0",
|
||||||
"@types/node": "^25.5.0",
|
"@types/node": "^25.9.2",
|
||||||
"@types/qrcode": "^1.5.5",
|
"@types/qrcode": "^1.5.6",
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.17",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@types/react-grid-layout": "^1.3.5",
|
"@types/react-grid-layout": "^1.3.5",
|
||||||
"@types/react-router-dom": "^5.3.3",
|
"@types/react-router-dom": "^5.3.3",
|
||||||
"@types/react-window": "^1.8.8",
|
"@types/react-window": "^1.8.8",
|
||||||
"@vanilla-extract/vite-plugin": "^5.1.4",
|
"@vanilla-extract/vite-plugin": "^5.2.2",
|
||||||
"@vitejs/plugin-react": "^5.2.0",
|
"@vitejs/plugin-react": "^5.2.0",
|
||||||
"babel-plugin-macros": "^3.1.0",
|
"babel-plugin-macros": "^3.1.0",
|
||||||
"nyc": "^18.0.0",
|
"nyc": "^18.0.0",
|
||||||
"otpauth": "^9.4.1",
|
"otpauth": "^9.5.1",
|
||||||
"path": "^0.12.7",
|
"path": "^0.12.7",
|
||||||
"rollup": "^4.59.0",
|
"rollup": "^4.61.1",
|
||||||
"rollup-plugin-license": "^3.7.0",
|
"rollup-plugin-license": "^3.7.1",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
"vite": "^6.4.2",
|
"vite": "^6.4.2",
|
||||||
"vite-plugin-babel-macros": "^1.0.6",
|
"vite-plugin-babel-macros": "^1.0.6",
|
||||||
"vite-plugin-dts": "^4.5.4",
|
"vite-plugin-dts": "^5.0.2",
|
||||||
"vite-plugin-externals": "^0.6.2",
|
"vite-plugin-externals": "^0.6.2",
|
||||||
"vite-plugin-istanbul": "^8.0.0"
|
"vite-plugin-istanbul": "^9.0.1"
|
||||||
},
|
},
|
||||||
"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 {
|
||||||
|
|||||||
@@ -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
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+347
-557
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user