Merge branch 'master' into matmair/issue11460
This commit is contained in:
commit
f13e20f784
|
|
@ -31,7 +31,7 @@ jobs:
|
|||
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # pin@v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@ jobs:
|
|||
docker: ${{ steps.filter.outputs.docker }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # pin@v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # pin@v4.0.1
|
||||
|
|
@ -67,7 +67,7 @@ jobs:
|
|||
|
||||
steps:
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # pin@v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Test Docker Image
|
||||
|
|
@ -129,7 +129,7 @@ jobs:
|
|||
|
||||
steps:
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # pin@v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Run Migration Tests
|
||||
|
|
@ -153,7 +153,7 @@ jobs:
|
|||
|
||||
steps:
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # pin@v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Set Up Python ${{ env.python_version }}
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@ jobs:
|
|||
force: ${{ steps.force.outputs.force }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # pin@v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # pin@v4.0.1
|
||||
|
|
@ -68,7 +68,7 @@ jobs:
|
|||
timeout-minutes: 60
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # pin@v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Environment Setup
|
||||
|
|
@ -115,12 +115,14 @@ jobs:
|
|||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
|
||||
permissions:
|
||||
contents: read # Required for actions/checkout
|
||||
id-token: write # Required for GitHub OIDC
|
||||
env:
|
||||
VITE_COVERAGE: false
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # pin@v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Environment Setup
|
||||
|
|
@ -199,7 +201,7 @@ jobs:
|
|||
VITE_COVERAGE: true
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # pin@v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Environment Setup
|
||||
|
|
@ -267,7 +269,7 @@ jobs:
|
|||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # pin@v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
|
|
@ -298,7 +300,7 @@ jobs:
|
|||
|
||||
- name: Upload coverage reports to Codecov
|
||||
if: ${{ !cancelled() && github.ref == 'refs/heads/master' }}
|
||||
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # pin@v6.0.1
|
||||
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # pin@v7.0.0
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
slug: inventree/InvenTree
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@ jobs:
|
|||
outputs:
|
||||
server: ${{ steps.filter.outputs.server }}
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # pin@v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # pin@v4.0.1
|
||||
|
|
@ -76,7 +76,7 @@ jobs:
|
|||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # pin@v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ jobs:
|
|||
submit-performance: ${{ steps.runner-perf.outputs.submit-performance }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # pin@v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # pin@v4.0.1
|
||||
|
|
@ -108,7 +108,7 @@ jobs:
|
|||
if: needs.paths-filter.outputs.cicd == 'true' || needs.paths-filter.outputs.server == 'true' || needs.paths-filter.outputs.frontend == 'true' || needs.paths-filter.outputs.requirements == 'true' || needs.paths-filter.outputs.force == 'true'
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # pin@v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Set up Python ${{ env.python_version }}
|
||||
|
|
@ -130,7 +130,7 @@ jobs:
|
|||
if: needs.paths-filter.outputs.server == 'true' || needs.paths-filter.outputs.requirements == 'true' || needs.paths-filter.outputs.force == 'true'
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # pin@v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Environment Setup
|
||||
|
|
@ -152,7 +152,7 @@ jobs:
|
|||
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # pin@v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Set up Python ${{ env.python_version }}
|
||||
|
|
@ -190,7 +190,7 @@ jobs:
|
|||
version: ${{ steps.version.outputs.version }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # pin@v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Environment Setup
|
||||
|
|
@ -222,7 +222,7 @@ jobs:
|
|||
echo "Downloaded api.yaml"
|
||||
- name: Running OpenAPI Spec diff action
|
||||
id: breaking_changes
|
||||
uses: oasdiff/oasdiff-action/diff@f30668f65075c93440bd59ce2de73ce9e78751f4 # pin@main
|
||||
uses: oasdiff/oasdiff-action/diff@3530478ec30f84adedbfeb28f0d9527a290f50a9 # pin@main
|
||||
with:
|
||||
base: "api.yaml"
|
||||
revision: "src/backend/InvenTree/schema.yml"
|
||||
|
|
@ -275,7 +275,7 @@ jobs:
|
|||
version: ${{ needs.schema.outputs.version }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # pin@v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3
|
||||
name: Checkout Code
|
||||
with:
|
||||
repository: inventree/schema
|
||||
|
|
@ -332,7 +332,7 @@ jobs:
|
|||
node_version: '>=24'
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # pin@v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Environment Setup
|
||||
|
|
@ -390,7 +390,7 @@ jobs:
|
|||
python_version: ${{ matrix.python_version }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # pin@v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Environment Setup
|
||||
|
|
@ -415,7 +415,7 @@ jobs:
|
|||
path: .coverage
|
||||
retention-days: 14
|
||||
- name: Upload coverage reports to Codecov
|
||||
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # pin@v6.0.1
|
||||
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # pin@v7.0.0
|
||||
if: always()
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
|
|
@ -441,7 +441,7 @@ jobs:
|
|||
INVENTREE_AUTO_UPDATE: true
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # pin@v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Environment Setup
|
||||
|
|
@ -492,7 +492,7 @@ jobs:
|
|||
- 6379:6379
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # pin@v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Environment Setup
|
||||
|
|
@ -541,7 +541,7 @@ jobs:
|
|||
- 3306:3306
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # pin@v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Environment Setup
|
||||
|
|
@ -584,7 +584,7 @@ jobs:
|
|||
- 5432:5432
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # pin@v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Environment Setup
|
||||
|
|
@ -597,7 +597,7 @@ jobs:
|
|||
- name: Run Tests
|
||||
run: invoke dev.test --check --migrations --report --coverage --translations
|
||||
- name: Upload coverage reports to Codecov
|
||||
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # pin@v6.0.1
|
||||
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # pin@v7.0.0
|
||||
if: always()
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
|
|
@ -618,7 +618,7 @@ jobs:
|
|||
INVENTREE_PLUGINS_ENABLED: false
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # pin@v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
name: Checkout Code
|
||||
|
|
@ -674,7 +674,7 @@ jobs:
|
|||
security-events: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # pin@v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Run zizmor 🌈
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ jobs:
|
|||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # pin@v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Version Check
|
||||
|
|
@ -43,7 +43,7 @@ jobs:
|
|||
contents: write
|
||||
attestations: write
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # pin@v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Environment Setup
|
||||
|
|
@ -109,7 +109,7 @@ jobs:
|
|||
INVENTREE_DEBUG: true
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # pin@v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Environment Setup
|
||||
|
|
@ -149,7 +149,7 @@ jobs:
|
|||
- ubuntu:24.04
|
||||
- debian:12
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # pin@v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ jobs:
|
|||
|
||||
steps:
|
||||
- name: "Checkout code"
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
|
|
@ -67,6 +67,6 @@ jobs:
|
|||
|
||||
# Upload the results to GitHub's code scanning dashboard.
|
||||
- name: "Upload to code-scanning"
|
||||
uses: github/codeql-action/upload-sarif@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
|
||||
uses: github/codeql-action/upload-sarif@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
|
||||
with:
|
||||
sarif_file: results.sarif
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ jobs:
|
|||
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # pin@v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Environment Setup
|
||||
|
|
|
|||
|
|
@ -9,12 +9,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||
|
||||
### Breaking Changes
|
||||
|
||||
- [#12160](https://github.com/inventree/InvenTree/pull/12160) changes the way that remote URIs are loaded into the PDF report generator. Remote URIs (e.g. `http://` and `https://`) are now blocked by default, and can be enabled via the `REPORT_FETCH_URLS` system setting. This change was made to improve the security of the report generation process, as allowing remote URL fetching can potentially expose internal network services to SSRF attacks. Additionally, file URIs (e.g. `file://`) are now always blocked, and assets must be embedded as `data:` URIs before reaching the PDF generator.
|
||||
- [#12107](https://github.com/inventree/InvenTree/pull/12107) makes a breaking change to the `SalesOrderStatusGroups` enum, fixing a bug where the "shipped" status was not included in the "active" group. This change may affect any external client applications which make use of the `SalesOrderStatusGroups` enum, as the "shipped" status will now be included in the "active" group instead of the "complete" group. If you are using this enum in an external client application, you will need to update your application to account for this change.
|
||||
- [#9604](https://github.com/inventree/InvenTree/pull/9604) refactors user API endpoint to be less ambiguous
|
||||
- [#11893](https://github.com/inventree/InvenTree/pull/11893) bumps Node environment to version 24 LTS - this is only relevant if you build the frontend assets yourself
|
||||
|
||||
### Added
|
||||
|
||||
- [#12165](https://github.com/inventree/InvenTree/pull/12165) adds support for parameters against the PartCategory model
|
||||
- [#12103](https://github.com/inventree/InvenTree/pull/12103) adds column-based filtering to table views in the user interface. This extends the existing table filtering functionality by allowing users to apply filters directly to individual columns.
|
||||
- [#12093](https://github.com/inventree/InvenTree/pull/12093) adds "read_only" attribute to PluginSetting API endpoint, which indicates whether a particular plugin setting is read-only (i.e. cannot be modified via the API)
|
||||
- [#12079](https://github.com/inventree/InvenTree/pull/12079) adds the ability to save filter groups for table and calendar views in the user interface. This allows users to save and reuse commonly used filter configurations, improving the usability and efficiency of the interface.
|
||||
|
|
@ -36,6 +38,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||
|
||||
### Changed
|
||||
|
||||
- [#12142](https://github.com/inventree/InvenTree/pull/12142) prevents users from printing reports or labels against models for which they do not have adequate permissions. This change improves the security of the system by ensuring that users cannot access or print reports or labels for models they do not have permission to view.
|
||||
- [#11990](https://github.com/inventree/InvenTree/pull/11990) build output operations performed via the API now offload the work to a background task, and now return a task ID which can be used to monitor the progress of the task. This allows for better performance and responsiveness when performing build output operations, as the work is performed asynchronously in the background.
|
||||
- [#11825](https://github.com/inventree/InvenTree/pull/11825) adds a new "bom" ruleset and associated permissions for BOM management, separate from the "part" ruleset which remains focused on part management. This allows for more granular control over user permissions, allowing users to have different levels of access to part management and BOM management functionality.
|
||||
- [#11816](https://github.com/inventree/InvenTree/pull/11816) makes the `issued_by` field on the `Build` API read only, and instead sets the `issued_by` field to the current user when a build is created. This change was made to ensure that the `issued_by` field accurately reflects the user who created the build, and to prevent users from setting this field to an arbitrary value when creating or updating a build.
|
||||
|
|
|
|||
|
|
@ -9,8 +9,8 @@
|
|||
# - Runs InvenTree web server under django development server
|
||||
# - Monitors source files for any changes, and live-reloads server
|
||||
|
||||
# Base image last bumped 2026-03-20
|
||||
FROM python:3.14.5-slim-trixie@sha256:c845af9399020c7e562969a13689e929074a10fd057acd1b1fad06a2fb068e97 AS inventree_base
|
||||
# Base image last bumped 2026-06-16
|
||||
FROM python:3.14.6-slim-trixie@sha256:44dd04494ee8f3b538294360e7c4b3acb87c8268e4d0a4828a6500b1eff50061 AS inventree_base
|
||||
|
||||
# Build arguments for this image
|
||||
ARG commit_tag=""
|
||||
|
|
@ -18,6 +18,7 @@ ARG commit_hash=""
|
|||
ARG commit_date=""
|
||||
|
||||
ARG data_dir="data"
|
||||
ARG NODE_VERSION=24
|
||||
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
ENV PIP_DISABLE_PIP_VERSION_CHECK=1
|
||||
|
|
@ -118,15 +119,10 @@ RUN pip install --user --require-hashes -r base_requirements.txt --no-cache-dir
|
|||
rm -rf /root/.cache/pip
|
||||
|
||||
# Install frontend build dependencies
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
nodejs npm \
|
||||
&& apt-get clean \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN npm install -g n yarn --ignore-scripts && \
|
||||
yarn config set network-timeout 600000 -g
|
||||
RUN bash -c "n lts"
|
||||
RUN cd "${INVENTREE_HOME}" && invoke int.frontend-compile --extract
|
||||
RUN curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.5/install.sh | bash && \
|
||||
bash -c "export NVM_DIR="$HOME/.nvm" && source $HOME/.nvm/nvm.sh && \
|
||||
nvm install $NODE_VERSION && corepack enable yarn && yarn config set network-timeout 600000 -g"
|
||||
RUN bash -c "source $HOME/.nvm/nvm.sh && cd '${INVENTREE_HOME}' && invoke int.frontend-compile --extract"
|
||||
|
||||
# InvenTree production image:
|
||||
# - Copies required files from local directory
|
||||
|
|
|
|||
|
|
@ -61,70 +61,70 @@ packaging==26.2 \
|
|||
# gunicorn
|
||||
# mariadb
|
||||
# wheel
|
||||
psycopg[binary, pool]==3.3.3 \
|
||||
--hash=sha256:5e9a47458b3c1583326513b2556a2a9473a1001a56c9efe9e587245b43148dd9 \
|
||||
--hash=sha256:f96525a72bcfade6584ab17e89de415ff360748c766f0106959144dcbb38c698
|
||||
psycopg[binary, pool]==3.3.4 \
|
||||
--hash=sha256:b6bbc25ccf05c8fad3b061d9db2ef0909a555171b84b07f29458a447253d679a \
|
||||
--hash=sha256:e21207764952cff81b6b8bdacad9a3939f2793367fdac2987b3aac36a651b5bc
|
||||
# via -r contrib/container/requirements.in
|
||||
psycopg-binary==3.3.3 \
|
||||
--hash=sha256:05f32239aec25c5fb15f7948cffdc2dc0dac098e48b80a140e4ba32b572a2e7d \
|
||||
--hash=sha256:07c7211f9327d522c9c47560cae00a4ecf6687f4e02d779d035dd3177b41cb12 \
|
||||
--hash=sha256:0dde92cfde09293fb63b3f547919ba7d73bd2654573c03502b3263dd0218e44e \
|
||||
--hash=sha256:111c59897a452196116db12e7f608da472fbff000693a21040e35fc978b23430 \
|
||||
--hash=sha256:162e5675efb4704192411eaf8e00d07f7960b679cd3306e7efb120bb8d9456cc \
|
||||
--hash=sha256:165f22ab5a9513a3d7425ffb7fcc7955ed8ccaeef6d37e369d6cc1dff1582383 \
|
||||
--hash=sha256:17bb6600e2455993946385249a3c3d0af52cd70c1c1cdbf712e9d696d0b0bf1b \
|
||||
--hash=sha256:19f93235ece6dbfc4036b5e4f6d8b13f0b8f2b3eeb8b0bd2936d406991bcdd40 \
|
||||
--hash=sha256:1bef235a50a80f6aba05147002bc354559657cb6386dbd04d8e1c97d1d7cbe84 \
|
||||
--hash=sha256:258d1ea53464d29768bf25930f43291949f4c7becc706f6e220c515a63a24edd \
|
||||
--hash=sha256:263a24f39f26e19ed7fc982d7859a36f17841b05bebad3eb47bb9cd2dd785351 \
|
||||
--hash=sha256:329ff393441e75f10b673ae99ab45276887993d49e65f141da20d915c05aafd8 \
|
||||
--hash=sha256:42961609ac07c232a427da7c87a468d3c82fee6762c220f38e37cfdacb2b178d \
|
||||
--hash=sha256:47f06fcbe8542b4d96d7392c476a74ada521c5aebdb41c3c0155f6595fc14c8d \
|
||||
--hash=sha256:48e500cf1c0984dacf1f28ea482c3cdbb4c2288d51c336c04bc64198ab21fc51 \
|
||||
--hash=sha256:497852c5eaf1f0c2d88ab74a64a8097c099deac0c71de1cbcf18659a8a04a4b2 \
|
||||
--hash=sha256:4d4606c84d04b80f9138d72f1e28c6c02dc5ae0c7b8f3f8aaf89c681ce1cd1b1 \
|
||||
--hash=sha256:5152d50798c2fa5bd9b68ec68eb68a1b71b95126c1d70adaa1a08cd5eefdc23d \
|
||||
--hash=sha256:533efe6dc3a7cba5e2a84e38970786bb966306863e45f3db152007e9f48638a6 \
|
||||
--hash=sha256:56c767007ca959ca32f796b42379fc7e1ae2ed085d29f20b05b3fc394f3715cc \
|
||||
--hash=sha256:5958dbf28b77ce2033482f6cb9ef04d43f5d8f4b7636e6963d5626f000efb23e \
|
||||
--hash=sha256:59aa31fe11a0e1d1bcc2ce37ed35fe2ac84cd65bb9036d049b1a1c39064d0f14 \
|
||||
--hash=sha256:642050398583d61c9856210568eb09a8e4f2fe8224bf3be21b67a370e677eead \
|
||||
--hash=sha256:6698dbab5bcef8fdb570fc9d35fd9ac52041771bfcfe6fd0fc5f5c4e36f1e99d \
|
||||
--hash=sha256:73eaaf4bb04709f545606c1db2f65f4000e8a04cdbf3e00d165a23004692093e \
|
||||
--hash=sha256:74eae563166ebf74e8d950ff359be037b85723d99ca83f57d9b244a871d6c13b \
|
||||
--hash=sha256:78c9ce98caaf82ac8484d269791c1b403d7598633e0e4e2fa1097baae244e2f1 \
|
||||
--hash=sha256:7c84f9d214f2d1de2fafebc17fa68ac3f6561a59e291553dfc45ad299f4898c1 \
|
||||
--hash=sha256:883d68d48ca9ff3cb3d10c5fdebea02c79b48eecacdddbf7cce6e7cdbdc216b8 \
|
||||
--hash=sha256:8e7e9eca9b363dbedeceeadd8be97149d2499081f3c52d141d7cd1f395a91f83 \
|
||||
--hash=sha256:90eecd93073922f085967f3ed3a98ba8c325cbbc8c1a204e300282abd2369e13 \
|
||||
--hash=sha256:97c839717bf8c8df3f6d983a20949c4fb22e2a34ee172e3e427ede363feda27b \
|
||||
--hash=sha256:9d6a1e56dd267848edb824dbeb08cf5bac649e02ee0b03ba883ba3f4f0bd54f2 \
|
||||
--hash=sha256:9f7d0cf072c6fbac3795b08c98ef9ea013f11db609659dcfc6b1f6cc31f9e181 \
|
||||
--hash=sha256:a39f34c9b18e8f6794cca17bfbcd64572ca2482318db644268049f8c738f35a6 \
|
||||
--hash=sha256:a4aab31bd6d1057f287c96c0effca3a25584eb9cc702f282ecb96ded7814e830 \
|
||||
--hash=sha256:a6af77b6626ce92b5817bf294b4d45ec1a6161dba80fc2d82cdffdd6814fd023 \
|
||||
--hash=sha256:a89bb9ee11177b2995d87186b1d9fa892d8ea725e85eab28c6525e4cc14ee048 \
|
||||
--hash=sha256:ae07a3114313dd91fce686cab2f4c44af094398519af0e0f854bc707e1aeedf1 \
|
||||
--hash=sha256:b27d3a23c79fa59557d2cc63a7e8bb4c7e022c018558eda36f9d7c4e6b99a6e0 \
|
||||
--hash=sha256:b3385b58b2fe408a13d084c14b8dcf468cd36cbbe774408250facc128f9fa75c \
|
||||
--hash=sha256:b62cf8784eb6d35beaee1056d54caf94ec6ecf2b7552395e305518ab61eb8fd2 \
|
||||
--hash=sha256:cab7bc3d288d37a80aa8c0820033250c95e40b1c2b5c57cf59827b19c2a8b69d \
|
||||
--hash=sha256:cb85b1d5702877c16f28d7b92ba030c1f49ebcc9b87d03d8c10bf45a2f1c7508 \
|
||||
--hash=sha256:d257c58d7b36a621dcce1d01476ad8b60f12d80eb1406aee4cf796f88b2ae482 \
|
||||
--hash=sha256:d593612758d0041cb13cb0003f7f8d3fabb7ad9319e651e78afae49b1cf5860e \
|
||||
--hash=sha256:da2f331a01af232259a21573a01338530c6016dcfad74626c01330535bcd8628 \
|
||||
--hash=sha256:dac7ee2f88b4d7bb12837989ca354c38d400eeb21bce3b73dac02622f0a3c8d6 \
|
||||
--hash=sha256:e77957d2ba17cada11be09a5066d93026cdb61ada7c8893101d7fe1c6e1f3925 \
|
||||
--hash=sha256:e7800e6c6b5dc4b0ca7cc7370f770f53ac83886b76afda0848065a674231e856 \
|
||||
--hash=sha256:e7b607f0e14f2a4cf7e78a05ebd13df6144acfba87cb90842e70d3f125d9f53f \
|
||||
--hash=sha256:eb072949b8ebf4082ae24289a2b0fd724da9adc8f22743409d6fd718ddb379df \
|
||||
--hash=sha256:eb36a08859b9432d94ea6b26ec41a2f98f83f14868c91321d0c1e11f672eeae7 \
|
||||
--hash=sha256:f24e8e17035200a465c178e9ea945527ad0738118694184c450f1192a452ff25 \
|
||||
--hash=sha256:fab6b5e37715885c69f5d091f6ff229be71e235f272ebaa35158d5a46fd548a0
|
||||
psycopg-binary==3.3.4 \
|
||||
--hash=sha256:018fbed325936da502feb546642c982dcc4b9ffdea32dfef78dbf3b7f7ad4070 \
|
||||
--hash=sha256:0579252a1202cd73e4da137a1426e2dae993ae44e757605344282af3a082848c \
|
||||
--hash=sha256:136f199a407b5348b9b857c504aff60c77622a28482e7195839ce1b51238c4cc \
|
||||
--hash=sha256:13a7f380824c35896dcac7fe0f61440f7ca49d6dc73f3c13a9a4471e6a3b302e \
|
||||
--hash=sha256:17a21953a9e5ff3a16dab692625a3676e2f101db5e40072f39dbee2250194d68 \
|
||||
--hash=sha256:1dc1f79fd16bb1f3f4421417a514607539f17804d95c7ed617265369d1981cae \
|
||||
--hash=sha256:1fbaa292a3c8bb61b45df1ad3da1908ccee7cb889db9425e3557d9e34e2a4829 \
|
||||
--hash=sha256:22cdbf5f91ef7bb91fe0c5757e1962d3127a8010256eefd9c61fcaf441802097 \
|
||||
--hash=sha256:26df2717e59c0473e4465a97dfb1b7afebaa479277870fd5784d1436470db47c \
|
||||
--hash=sha256:276904e3452d6a23d474ef9a21eee19f20eed3d53ddd2576af033827e0ba0992 \
|
||||
--hash=sha256:28b7398fdd19db3232c884fb24550bdfe951221f510e195e233299e4c9b78f97 \
|
||||
--hash=sha256:2c09aad7051326e7603c14e50636db9c01f78272dc54b3accff03d46370461e6 \
|
||||
--hash=sha256:32a6fbf8481e3a370d0d72b860d35948a693cb01281da217f7b2f307636e591a \
|
||||
--hash=sha256:41f2ec0fea529832982bcb6c9415de3c86264ebe562b77a467c0fbcd7efbba8d \
|
||||
--hash=sha256:46893c26858be12cc49ca4226ed6a60b4bfccadd946b3bebb783a60b38788228 \
|
||||
--hash=sha256:47c656a8a7ba6eb0cff1801a4caaa9c8bdc12d03080e273aff1c8ac39971a77e \
|
||||
--hash=sha256:494ca54901be8cf9eb7e02c25b731f2317c378efa44f43e8f9bd0e1184ae7be4 \
|
||||
--hash=sha256:514404ed543efd620c85602b747df2a23cf1241b4067199e1a66f2d2757aaa41 \
|
||||
--hash=sha256:574ea21a9651958f1535c5a1c649c7409e9168bcbffa29a3f2f961f58b322949 \
|
||||
--hash=sha256:580ae30a5f95ccd90008ec697d3ed6a4a2047a516407ad904283fa42086936e9 \
|
||||
--hash=sha256:5ab28a2a7649df3b72e6b674b4c190e448e8e77cf496a65bd846472048de2089 \
|
||||
--hash=sha256:5c4ab71be17bdca30cb34c34c4e1496e2f5d6f20c199c12bad226070b22ef9bf \
|
||||
--hash=sha256:612a627d733f695b1de1f9b4bd511c15f999a5d8b915d444bbd7dd71cf3370da \
|
||||
--hash=sha256:6402a9d8146cf4b3974ded3fd28a971e83dc6a0333eb7822524a3aa20b546578 \
|
||||
--hash=sha256:6b9016b1714da4dd5ecaaa75b82098aa5a0b87854ce9b092e21c27c4ae23e014 \
|
||||
--hash=sha256:71e55ccbdfae79a2ed9c6369c3008a3025817ff9d7e27b32a2d84e2a4267e66e \
|
||||
--hash=sha256:7465bfe6087d2d5b42d4c53b9b11ca9f218e477317a4a162a10e3c19e984ba8e \
|
||||
--hash=sha256:75a9067e236f9b9ae3535b66fe99bddb33d39c0de10112e49b9ab11eee53dc31 \
|
||||
--hash=sha256:773d573e11f437ce0bdb95b7c18dc58390494f96d43f8b45b9760436114f7652 \
|
||||
--hash=sha256:77df19583501ea288eaf15ac0fe7ad01e6d8091a91d5c41df5c718f307d8e31b \
|
||||
--hash=sha256:7f7668f30b9dd5163197e5cbf4e0efd54e00f0a859cc566ce56cfc31f4054839 \
|
||||
--hash=sha256:8c0056529e68dbe9184cd4019a1f3d8f3a4ead2f6fc7a5afcf27d3314edd1277 \
|
||||
--hash=sha256:94596f9e7633ee3f6440711d43bb70aa31cc0a46a900ab8b4201a366ace5c9e7 \
|
||||
--hash=sha256:ab8cca8ef8fb1ccf5b048ae5bd78ba55b9e4b5d472e3ce5ca39ff4d2a9c249e4 \
|
||||
--hash=sha256:ad3bc94054876155549fdaedf4a46d1ec69d39a5bcee377148afe498e84c4b8e \
|
||||
--hash=sha256:b56b603ebcea8aa10b46228b8410ba7f13e7c2ee54389d4d9be0927fd8ce2a70 \
|
||||
--hash=sha256:b6f5a29e9c775b9f12a1a717aa7a2c80f9e1db6f27ba44a5b59c80ac61d2ffcf \
|
||||
--hash=sha256:b7bfff1ca23732b488cbca3076fc11bc98d520ee122514fdb17a8e20d3338f5a \
|
||||
--hash=sha256:bdef84570ebbce1d42b4e7ea952d21c414c5f118ad02fee00c5625f35e134429 \
|
||||
--hash=sha256:c37e024c07308cd06cf3ec51bfd0e7f6157585a4d84d1bce4a7f5f7913719bf8 \
|
||||
--hash=sha256:c677c4ad433cb7150c8cd304a0769ae3bcfbe5ea0676eb53faa7b1443b16d0d3 \
|
||||
--hash=sha256:cf7f73a4a792bc5db58a4b385d8a1467e8d468f7548702fb0ed1e9b7501b1c13 \
|
||||
--hash=sha256:cffc3408d77a27973f33e5d909b624cce683db5fc25964b02fe0aae7886c1007 \
|
||||
--hash=sha256:d7b4d40c153fa352ab3cca530f3a0baedf7621b2ebcbd7f084009522c21788fc \
|
||||
--hash=sha256:dbfdb9b6cc79f31104a7b162a2b921b765fcc62af6c00540a167a8de47e4ed38 \
|
||||
--hash=sha256:df1d567fc430f6df15c9fcf67d87685fc49bdb325adc0db5af1adfb2f44eb5c9 \
|
||||
--hash=sha256:e2631da29253a98bd496e6c4813b24e09a4fe3fb2a9e88513305d6f8747cce95 \
|
||||
--hash=sha256:e7510c37550f91a187e3660a8cc50d4b760f8c3b8b2f89ebc5698cd2c7f2c85d \
|
||||
--hash=sha256:eb05ee1c2b817d27c537333224c9e83c7afb86fe7296ba970990068baf819b16 \
|
||||
--hash=sha256:eb4eed2079c01a4850bf467deacfab56d356d4225040170af03dc9958321242d \
|
||||
--hash=sha256:ee17a2cf4943cde261adfad1bbc5bf38d6b3776d7afff74c7cabcbeaeb08c260 \
|
||||
--hash=sha256:f80e3f2b5331dbbf0901bcb658056c03eeb2c1ef31d774afb0d61598b242e744 \
|
||||
--hash=sha256:f9b1c2533af01cd7648378599f82b0b8ae32f293296e6eec5753a625bc97ef28 \
|
||||
--hash=sha256:fa1cbc10768a796c96d3243656016bf4e337c81c71097270bb7b0ad6210d9765 \
|
||||
--hash=sha256:fbd1d4ed566895ad2d3bf4ddfd8bae90026930ddf29df3b9d91d32c8c47866a7
|
||||
# via psycopg
|
||||
psycopg-pool==3.3.0 \
|
||||
--hash=sha256:2e44329155c410b5e8666372db44276a8b1ebd8c90f1c3026ebba40d4bc81063 \
|
||||
--hash=sha256:fa115eb2860bd88fce1717d75611f41490dec6135efb619611142b24da3f6db5
|
||||
psycopg-pool==3.3.1 \
|
||||
--hash=sha256:2af5b432941c4c9ad5c87b3fa410aec910ec8f7c122855897983a06c45f2e4b5 \
|
||||
--hash=sha256:b10b10b7a175d5cc1592147dc5b7eec8a9e0834eb3ed2c4a92c858e2f51eb63c
|
||||
# via psycopg
|
||||
pyasn1==0.6.3 \
|
||||
--hash=sha256:697a8ecd6d98891189184ca1fa05d1bb00e2f84b5977c481452050549c8a72cf \
|
||||
|
|
@ -136,8 +136,8 @@ pyasn1-modules==0.4.2 \
|
|||
--hash=sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a \
|
||||
--hash=sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6
|
||||
# via python-ldap
|
||||
python-ldap==3.4.5 \
|
||||
--hash=sha256:b2f6ef1c37fe2c6a5a85212efe71311ee21847766a7d45fcb711f3b270a5f79a
|
||||
python-ldap==3.4.7 \
|
||||
--hash=sha256:bacd9fb680d20263d8570ade1cf234d90d281149a8beb4f079dd8f33f7613dc8
|
||||
# via
|
||||
# -r contrib/container/requirements.in
|
||||
# django-auth-ldap
|
||||
|
|
@ -236,26 +236,26 @@ typing-extensions==4.15.0 \
|
|||
# via
|
||||
# -c src/backend/requirements.txt
|
||||
# psycopg-pool
|
||||
uv==0.11.7 \
|
||||
--hash=sha256:0df59ab0c6a4b14a763e8445e1c303af9abeb53cdfa4428daf9ff9642c0a3cce \
|
||||
--hash=sha256:162fa961a9a081dcea6e889c79f738a5ae56507047e4672964972e33c301bea9 \
|
||||
--hash=sha256:23d457d6731ebdb83f1bffebe4894edab2ef43c1ec5488433c74300db4958924 \
|
||||
--hash=sha256:46d971489b00bdb27e0aa715e4a5cd4ef2c28ea5b6ef78f2b67bf861eb44b405 \
|
||||
--hash=sha256:4e4d5e31bea86e1b6e0f5a0f95e14e80018e6f6c0129256d2915a4b3d793644d \
|
||||
--hash=sha256:553e67cc766d013ce24353fecd4ea5533d2aedcfd35f9fac430e07b1d1f23ed4 \
|
||||
--hash=sha256:5674dfb5944513f4b3735b05c2deba6b1b01151f46729d533d413a9a905f8c5d \
|
||||
--hash=sha256:5985a15a92bd9a170fc1947abb1fbc3e9828c5a430ad85b5bed8356c20b67a71 \
|
||||
--hash=sha256:6158b7e39464f1aa1e040daa0186cae4749a78b5cd80ac769f32ca711b8976b1 \
|
||||
--hash=sha256:750ee5b96959b807cf442b73dd8b55111862d63f258f896787ea5f06b68aaca9 \
|
||||
--hash=sha256:7d6a17507b8139b8803f445a03fd097f732ce8356b1b7b13cdb4dd8ef7f4b2e0 \
|
||||
--hash=sha256:8b2fe1ec6775dad10183e3fdce430a5b37b7857d49763c884f3a67eaa8ca6f8a \
|
||||
--hash=sha256:ceae53b202ea92bc954759bc7c7570cdcd5c3512fce15701198c19fd2dfb8605 \
|
||||
--hash=sha256:dd48823ca4b505124389f49ae50626ba9f57212b9047738efc95126ed5f3844d \
|
||||
--hash=sha256:eb91f52ee67e10d5290f2c2897e2171357f1a10966de38d83eefa93d96843b0c \
|
||||
--hash=sha256:f394331f0507e80ee732cb3df737589de53bed999dd02a6d24682f08c2f8ac4f \
|
||||
--hash=sha256:f422d39530516b1dfb28bb6e90c32bb7dacd50f6a383cd6e40c1a859419fbc8c \
|
||||
--hash=sha256:f97e9f4e4d44fb5c4dfaa05e858ef3414a96416a2e4af270ecd88a3e5fb049a9 \
|
||||
--hash=sha256:fab0bb43fbbc0ee5b5fee212078d2300c371b725faff7cf72eeaafa0bff0606b
|
||||
uv==0.11.19 \
|
||||
--hash=sha256:28b0d612a766eb25756dbaa315433b726e93affa467d29a2682cc317547952ba \
|
||||
--hash=sha256:2d663bacb97e2e8412d1c26eace28c7ebbde9d6f5d7d78760fafd114d693817f \
|
||||
--hash=sha256:2e0e0b8ad59ec56f1440d6e4313b64a1d8119275dcec73d19eef33c43f99428c \
|
||||
--hash=sha256:301fd78309fc545c2cec2bfcc61a6bbdde876856c6d2041502737cf44085c178 \
|
||||
--hash=sha256:32d7988c0dfb6f90941f201c871a4478e96e4f2a32bdb2256d62a78ee20593fc \
|
||||
--hash=sha256:480fc34a8d0967af6a90b3f99a6e5687cd5c6e29528de96bec04d6e305a59363 \
|
||||
--hash=sha256:50e4d4796ca1a6da359a4f723a0fea86640c381d3ff4fa759a41badd7cb52dee \
|
||||
--hash=sha256:574f5dd4f31666661ea6386d3b91c5f0e8b84a8cae98ebba447c4674f2e6a4c7 \
|
||||
--hash=sha256:62b0b35a51d3034ff30ecd0f381e9bbc20d5b335754f54b098da29424d551ceb \
|
||||
--hash=sha256:65e932720daed1af1f720a0ff5f9b33ee5f7ad97488dcceceb85154fc1323b82 \
|
||||
--hash=sha256:7222f45b5541551057bfc2e3021f113800704f665c119fdf3ea700c6c4859b21 \
|
||||
--hash=sha256:731d9fab8db5d41590af64236d03f8069c8da665fd0f9493b85985f19c86cd90 \
|
||||
--hash=sha256:7fdd881cd6d80782afcf8c1d446dd15a42985167fd812b763d38ba1e4a8d944d \
|
||||
--hash=sha256:8f90b6687a480d154595aa619fb836a9a20d00ce37293db8099aad924f2b18f9 \
|
||||
--hash=sha256:a98495b9dd67287d8c1a0786f98cb037a50f0ee6c3d648572edaa7137aabc277 \
|
||||
--hash=sha256:aa6a7e8d07b33ad22f4732848ebb1d9486503973c248d6e632c06ce4339fe347 \
|
||||
--hash=sha256:c729f56ffef9b945053412c839695e8a0b13758aa15b7763e95a7dd539a6f522 \
|
||||
--hash=sha256:f4aa17ffd719daf37b7a6265efd3ee4922a8ddaabaf0406d2b28c7e5ce2f20ff \
|
||||
--hash=sha256:f56f5bf853626a30423052d7ee00bf5cc940a08347d6ee7ede96862d084054a5
|
||||
# via -r contrib/container/requirements.in
|
||||
wheel==0.47.0 \
|
||||
--hash=sha256:212281cab4dff978f6cedd499cd893e1f620791ca6ff7107cf270781e587eced \
|
||||
|
|
|
|||
|
|
@ -139,9 +139,9 @@ charset-normalizer==3.4.7 \
|
|||
# via
|
||||
# -c src/backend/requirements.txt
|
||||
# requests
|
||||
idna==3.17 \
|
||||
--hash=sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c \
|
||||
--hash=sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f
|
||||
idna==3.18 \
|
||||
--hash=sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2 \
|
||||
--hash=sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848
|
||||
# via
|
||||
# -c src/backend/requirements.txt
|
||||
# requests
|
||||
|
|
|
|||
|
|
@ -230,3 +230,35 @@ And the snippet file `stock_row.html` may be written as follows:
|
|||
</tr>
|
||||
{% endraw %}
|
||||
```
|
||||
|
||||
## Security
|
||||
|
||||
Report templates are powerful by design — they have access to the full Django template language and to model data across the InvenTree database. For this reason, **template upload is restricted to staff users only**.
|
||||
|
||||
### URL Fetching
|
||||
|
||||
When WeasyPrint renders a template to PDF it can make outbound requests to load images, stylesheets, and fonts referenced in the HTML. InvenTree restricts this through a custom URL fetcher with the following rules:
|
||||
|
||||
| URL Type | Behavior |
|
||||
|---|---|
|
||||
| `data:` URIs | Always permitted — self-contained, no network access |
|
||||
| `file://` | Always blocked — assets and images must be inlined as `data:` URIs before reaching WeasyPrint |
|
||||
| `http` / `https` | Disabled by default, but can be blocked - see *Remote URL Fetching* below |
|
||||
| Any other scheme | Always blocked |
|
||||
|
||||
HTTP redirects are also disabled: a URL that passes validation cannot redirect to an internal address.
|
||||
|
||||
### Remote URL Fetching
|
||||
|
||||
The **Report URL Fetching** system setting (`REPORT_FETCH_URLS`) controls whether `http://` and `https://` URLs in templates are permitted. It defaults to **disabled**.
|
||||
|
||||
When enabled, URLs are still validated against private, loopback, link-local, and reserved IP ranges before the request is made, preventing templates from being used as a vector for [Server-Side Request Forgery (SSRF)](https://owasp.org/www-community/attacks/Server_Side_Request_Forgery) attacks against internal network services.
|
||||
|
||||
!!! warning "Enable with care"
|
||||
Enabling remote URL fetching allows report templates to trigger outbound HTTP requests from the InvenTree server. Only enable this if your templates genuinely require it, and ensure that templates are reviewed before deployment.
|
||||
|
||||
### Asset Files
|
||||
|
||||
Asset files uploaded through the admin interface are embedded directly into the rendered PDF as base64 `data:` URIs — they are read via the Django storage API and never loaded through WeasyPrint's URL fetcher. This means assets work correctly regardless of whether remote URL fetching is enabled, and also work with remote storage backends such as S3.
|
||||
|
||||
There are various [helper functions](./helpers.md#report-assets) available to assist with embedding assets into templates.
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
---
|
||||
title: Report and LabelGeneration
|
||||
title: Report and Label Generation
|
||||
---
|
||||
|
||||
## Custom Reports
|
||||
|
|
|
|||
|
|
@ -144,6 +144,7 @@ Configuration of report generation:
|
|||
{{ globalsetting("REPORT_ENABLE") }}
|
||||
{{ globalsetting("REPORT_DEFAULT_PAGE_SIZE") }}
|
||||
{{ globalsetting("REPORT_DEBUG_MODE") }}
|
||||
{{ globalsetting("REPORT_FETCH_URLS") }}
|
||||
{{ globalsetting("REPORT_LOG_ERRORS") }}
|
||||
|
||||
### Label Printing
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
mkdocs==1.6.1
|
||||
mkdocs-macros-plugin>=0.7,<2.0
|
||||
mkdocs-material>=9.0,<10.0
|
||||
mkdocs-git-revision-date-localized-plugin>=1.5.2,<2.0
|
||||
mkdocs-git-revision-date-localized-plugin>=1.5.3,<2.0
|
||||
mkdocs-redirects
|
||||
mkdocs-simple-hooks>=0.1,<1.0
|
||||
mkdocs-include-markdown-plugin
|
||||
|
|
|
|||
|
|
@ -227,9 +227,9 @@ httpx==0.28.1 \
|
|||
--hash=sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc \
|
||||
--hash=sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad
|
||||
# via neoteroi-mkdocs
|
||||
idna==3.17 \
|
||||
--hash=sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c \
|
||||
--hash=sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f
|
||||
idna==3.18 \
|
||||
--hash=sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2 \
|
||||
--hash=sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848
|
||||
# via
|
||||
# -c src/backend/requirements.txt
|
||||
# anyio
|
||||
|
|
@ -398,9 +398,9 @@ mkdocs-get-deps==0.2.2 \
|
|||
--hash=sha256:8ee8d5f316cdbbb2834bc1df6e69c08fe769a83e040060de26d3c19fad3599a1 \
|
||||
--hash=sha256:e7878cbeac04860b8b5e0ca31d3abad3df9411a75a32cde82f8e44b6c16ff650
|
||||
# via mkdocs
|
||||
mkdocs-git-revision-date-localized-plugin==1.5.2 \
|
||||
--hash=sha256:0b3d65abdb8b82591d724c1d5ece6af7bb3cd305bdd47e2fadd430886a9a2513 \
|
||||
--hash=sha256:4f3175e039bc4fe0055cac97d295ce8d0d233a13d65986d42fd5324e4985acc2
|
||||
mkdocs-git-revision-date-localized-plugin==1.5.3 \
|
||||
--hash=sha256:873444b54cab4d47c69bd6e85da05ef5fbe81fee27e64508114c46a0e4f81e37 \
|
||||
--hash=sha256:cd96e432de6a7e59b31c7041574b22f84179c8636835419ff458877ecfaaaf05
|
||||
# via -r docs/requirements.in
|
||||
mkdocs-include-markdown-plugin==7.3.0 \
|
||||
--hash=sha256:2800126746452e31c2e321bbd43c8190b356e0de353e20cbc16a34a3c3d6796c \
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ from django.views.generic.base import RedirectView
|
|||
import structlog
|
||||
from django_q.models import OrmQ
|
||||
from drf_spectacular.utils import OpenApiParameter, OpenApiResponse, extend_schema
|
||||
from rest_framework import serializers, viewsets
|
||||
from rest_framework import permissions, serializers, viewsets
|
||||
from rest_framework.generics import GenericAPIView
|
||||
from rest_framework.request import clone_request
|
||||
from rest_framework.response import Response
|
||||
|
|
@ -356,7 +356,10 @@ class InfoView(APIView):
|
|||
class NotFoundView(APIView):
|
||||
"""Simple JSON view when accessing an invalid API view."""
|
||||
|
||||
permission_classes = [InvenTree.permissions.AllowAnyOrReadScope]
|
||||
permission_classes = [
|
||||
permissions.IsAuthenticated,
|
||||
InvenTree.permissions.AllowAnyOrReadScope,
|
||||
]
|
||||
|
||||
def not_found(self, request):
|
||||
"""Return a 404 error."""
|
||||
|
|
|
|||
|
|
@ -1,11 +1,33 @@
|
|||
"""InvenTree API version information."""
|
||||
|
||||
# InvenTree API version
|
||||
INVENTREE_API_VERSION = 501
|
||||
INVENTREE_API_VERSION = 508
|
||||
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
|
||||
|
||||
INVENTREE_API_TEXT = """
|
||||
|
||||
v508 -> 2026-06-17 : https://github.com/inventree/InvenTree/pull/11982
|
||||
- An order's "status_custom_key" can be updated via PATCH API endpoint
|
||||
|
||||
v507 -> 2026-06-16 : https://github.com/inventree/InvenTree/pull/12180
|
||||
- Adds "lookup_field" parameter to the DataImportSessionSerializer, which allows for more flexible lookup of related objects during data import operations
|
||||
|
||||
v506 -> 2026-06-15 : https://github.com/inventree/InvenTree/pull/12168
|
||||
- Reduce permissions scope for a number of API endpoints, to improve security and ensure that users only have access to the data they need
|
||||
|
||||
v505 -> 2026-06-15 : https://github.com/inventree/InvenTree/pull/12165
|
||||
- Allow parameters to be specified against the PartCategory model
|
||||
|
||||
v504 -> 2026-06-13 : https://github.com/inventree/InvenTree/pull/12139
|
||||
- Adjustments to the SelectionList and SelectionListEntry API endpoints to support more efficient queries and data retrieval
|
||||
|
||||
v503 -> 2026-06-11 : https://github.com/inventree/InvenTree/pull/12155
|
||||
- Adds additional filtering and sorting options to the LabelTemplate API endpoint
|
||||
- Adds additional filtering and sorting options to the ReportTemplate API endpoint
|
||||
|
||||
v502 -> 2026-06-10 : https://github.com/inventree/InvenTree/pull/12142
|
||||
- Prevents users from printing reports or labels against models for which they do not have adequate permissions. This change improves the security of the system by ensuring that users cannot access or print reports or labels for models they do not have permission to view.
|
||||
|
||||
v501 -> 2026-06-05 : https://github.com/inventree/InvenTree/pull/12093
|
||||
- Adds "read_only" attribute to PluginSetting API endpoint, which indicates whether a particular plugin setting is read-only (i.e. cannot be modified via the API)
|
||||
|
||||
|
|
|
|||
|
|
@ -415,7 +415,6 @@ class GlobalSettingsPermissions(OASTokenMixin, permissions.BasePermission):
|
|||
"""Check that the requesting user is 'admin'."""
|
||||
try:
|
||||
user = request.user
|
||||
|
||||
if request.method in permissions.SAFE_METHODS:
|
||||
return True
|
||||
# Any other methods require staff access permissions
|
||||
|
|
|
|||
|
|
@ -612,6 +612,7 @@ class GeneralApiTests(InvenTreeAPITestCase):
|
|||
response = self.get(
|
||||
url, headers={'Authorization': f'Token {token}'}, max_query_count=20
|
||||
)
|
||||
self.assertIsNotNone(data.get('active_plugins'))
|
||||
self.assertGreater(len(response.json()['database']), 4)
|
||||
|
||||
data = response.json()
|
||||
|
|
|
|||
|
|
@ -55,6 +55,10 @@ try:
|
|||
except (KeyError, IndexError):
|
||||
logger.warning('INVE-W1: Current branch could not be detected.')
|
||||
main_branch = None
|
||||
|
||||
if main_repo is not None:
|
||||
main_repo.close()
|
||||
main_repo = None
|
||||
except ImportError:
|
||||
logger.warning(
|
||||
'INVE-W2: Dulwich module not found, git information will not be available.'
|
||||
|
|
|
|||
|
|
@ -3,7 +3,8 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from django.contrib.auth.models import User
|
||||
from django.db.models import F, Q
|
||||
from django.db.models import F, OuterRef, Q, Subquery, Sum
|
||||
from django.db.models.functions import Coalesce
|
||||
from django.urls import include, path
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
|
|
@ -520,8 +521,22 @@ class BuildLineFilter(FilterSet):
|
|||
- The quantity available for each BuildLine (including variants and substitutes)
|
||||
- The quantity allocated for each BuildLine
|
||||
"""
|
||||
flt = Q(
|
||||
quantity__lte=F('allocated')
|
||||
allocated_subquery = (
|
||||
BuildItem.objects
|
||||
.filter(build_line=OuterRef('pk'))
|
||||
.values('build_line')
|
||||
.annotate(total=Sum('quantity'))
|
||||
.values('total')
|
||||
)
|
||||
|
||||
queryset = queryset.alias(
|
||||
allocated_quantity=Coalesce(Subquery(allocated_subquery), 0)
|
||||
)
|
||||
|
||||
# A query filter construct to determine the total quantity available for this BuildLine,
|
||||
# taking into account any stock which is already allocated or consumed
|
||||
available = (
|
||||
F('allocated_quantity')
|
||||
+ F('consumed')
|
||||
+ F('available_stock')
|
||||
+ F('available_substitute_stock')
|
||||
|
|
@ -529,8 +544,9 @@ class BuildLineFilter(FilterSet):
|
|||
)
|
||||
|
||||
if str2bool(value):
|
||||
return queryset.filter(flt)
|
||||
return queryset.exclude(flt)
|
||||
return queryset.filter(quantity__lte=available)
|
||||
|
||||
return queryset.filter(quantity__gt=available)
|
||||
|
||||
on_order = rest_filters.BooleanFilter(label=_('On Order'), method='filter_on_order')
|
||||
|
||||
|
|
|
|||
|
|
@ -965,7 +965,7 @@ class Build(
|
|||
|
||||
# Remove the build output from the database
|
||||
# This is a special case where serialized stock can be deleted,
|
||||
# independedent of the global setting which normally prevents deletion of serialized stock items
|
||||
# independent of the global setting which normally prevents deletion of serialized stock items
|
||||
output.delete(ignore_serial_check=True)
|
||||
|
||||
@transaction.atomic
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ from build.models import Build, BuildItem, BuildLine
|
|||
from build.status_codes import BuildStatus
|
||||
from common.settings import set_global_setting
|
||||
from InvenTree.unit_test import InvenTreeAPITestCase
|
||||
from part.models import BomItem, Part
|
||||
from part.models import BomItem, BomItemSubstitute, Part
|
||||
from stock.models import StockItem, StockLocation, StockSortOrder
|
||||
from stock.status_codes import StockStatus
|
||||
|
||||
|
|
@ -1625,6 +1625,175 @@ class BuildLineTests(BuildAPITest):
|
|||
|
||||
self.assertEqual(n_t + n_f, BuildLine.objects.count())
|
||||
|
||||
def test_filter_available_allocated_consumed_mixed(self):
|
||||
"""Filter BuildLine objects with mixed allocated / consumed / stock states."""
|
||||
assembly = Part.objects.create(
|
||||
name='Available Filter Assembly',
|
||||
description='Assembly for advanced available filter tests',
|
||||
assembly=True,
|
||||
)
|
||||
|
||||
components = [
|
||||
Part.objects.create(
|
||||
name=f'Available Filter Component {idx}',
|
||||
description=f'Component {idx}',
|
||||
component=True,
|
||||
)
|
||||
for idx in range(4)
|
||||
]
|
||||
|
||||
# The assembly uses 10x of each component
|
||||
for component in components:
|
||||
BomItem.objects.create(part=assembly, sub_part=component, quantity=10)
|
||||
|
||||
# Create a new Build, requiring 100x of each component
|
||||
build = Build.objects.create(
|
||||
part=assembly,
|
||||
reference='BO-9999',
|
||||
quantity=10,
|
||||
title='Available Filter Mixed',
|
||||
)
|
||||
|
||||
lines = list(build.build_lines.order_by('pk'))
|
||||
self.assertEqual(len(lines), 4)
|
||||
|
||||
# Build line quantity is 100 for each line (10 * build quantity 10)
|
||||
for line in lines:
|
||||
self.assertEqual(line.quantity, 100)
|
||||
|
||||
# Stock allocation baseline for each component
|
||||
# Note: Quantity values will be updated later
|
||||
stock_items = [
|
||||
StockItem.objects.create(part=component, quantity=1)
|
||||
for component in components
|
||||
]
|
||||
|
||||
# Line 0: AVAILABLE = True
|
||||
# allocated = 30
|
||||
# consumed = 0
|
||||
# available = 70
|
||||
BuildItem.objects.create(
|
||||
build_line=lines[0], stock_item=stock_items[0], quantity=30
|
||||
)
|
||||
|
||||
stock_items[0].quantity = 30 + 70
|
||||
stock_items[0].save()
|
||||
|
||||
# Line 1: allocated 20 + consumed 30 + available stock 50 => available (100)
|
||||
BuildItem.objects.create(
|
||||
build_line=lines[1], stock_item=stock_items[1], quantity=20
|
||||
)
|
||||
lines[1].consumed = 30
|
||||
lines[1].save()
|
||||
stock_items[1].quantity = 20 + 50
|
||||
stock_items[1].save()
|
||||
|
||||
# Line 2: allocated 0 + consumed 10 + available stock 50 => not available (60)
|
||||
lines[2].consumed = 10
|
||||
lines[2].save()
|
||||
stock_items[2].quantity = 50
|
||||
stock_items[2].save()
|
||||
|
||||
# Line 3: allocated 40 + consumed 0 + available stock 20 => not available (60)
|
||||
BuildItem.objects.create(
|
||||
build_line=lines[3], stock_item=stock_items[3], quantity=40
|
||||
)
|
||||
stock_items[3].quantity = 40 + 20
|
||||
stock_items[3].save()
|
||||
|
||||
url = reverse('api-build-line-list')
|
||||
|
||||
response_true = self.get(url, {'build': build.pk, 'available': True})
|
||||
|
||||
response_false = self.get(url, {'build': build.pk, 'available': False})
|
||||
|
||||
true_ids = {item['pk'] for item in response_true.data}
|
||||
false_ids = {item['pk'] for item in response_false.data}
|
||||
|
||||
self.assertSetEqual(true_ids, {lines[0].pk, lines[1].pk})
|
||||
self.assertSetEqual(false_ids, {lines[2].pk, lines[3].pk})
|
||||
self.assertSetEqual(true_ids | false_ids, {line.pk for line in lines})
|
||||
|
||||
def test_filter_available_substitute_and_variant_stock(self):
|
||||
"""Filter BuildLine objects where availability comes from substitute or variant stock."""
|
||||
assembly = Part.objects.create(
|
||||
name='Available Filter Sub/Var Assembly',
|
||||
description='Assembly for substitute and variant availability tests',
|
||||
assembly=True,
|
||||
)
|
||||
|
||||
# Substitute path: line should pass via substitute stock
|
||||
sub_master_ok = Part.objects.create(name='Sub Master OK', component=True)
|
||||
sub_alt_ok = Part.objects.create(name='Sub Alt OK', component=True)
|
||||
|
||||
# Substitute path: line should fail (insufficient substitute stock)
|
||||
sub_master_low = Part.objects.create(name='Sub Master Low', component=True)
|
||||
sub_alt_low = Part.objects.create(name='Sub Alt Low', component=True)
|
||||
|
||||
# Variant path: line should pass via variant stock
|
||||
var_parent_ok = Part.objects.create(
|
||||
name='Variant Parent OK', component=True, is_template=True
|
||||
)
|
||||
var_child_ok = Part.objects.create(
|
||||
name='Variant Child OK', component=True, variant_of=var_parent_ok
|
||||
)
|
||||
|
||||
# Variant path: line should fail (insufficient variant stock)
|
||||
var_parent_low = Part.objects.create(
|
||||
name='Variant Parent Low', component=True, is_template=True
|
||||
)
|
||||
var_child_low = Part.objects.create(
|
||||
name='Variant Child Low', component=True, variant_of=var_parent_low
|
||||
)
|
||||
|
||||
bom_sub_ok = BomItem.objects.create(
|
||||
part=assembly, sub_part=sub_master_ok, quantity=10, allow_variants=False
|
||||
)
|
||||
bom_sub_low = BomItem.objects.create(
|
||||
part=assembly, sub_part=sub_master_low, quantity=10, allow_variants=False
|
||||
)
|
||||
bom_var_ok = BomItem.objects.create(
|
||||
part=assembly, sub_part=var_parent_ok, quantity=10, allow_variants=True
|
||||
)
|
||||
bom_var_low = BomItem.objects.create(
|
||||
part=assembly, sub_part=var_parent_low, quantity=10, allow_variants=True
|
||||
)
|
||||
|
||||
BomItemSubstitute.objects.create(bom_item=bom_sub_ok, part=sub_alt_ok)
|
||||
BomItemSubstitute.objects.create(bom_item=bom_sub_low, part=sub_alt_low)
|
||||
|
||||
# Build quantity 10 => each line requires 100 units
|
||||
build = Build.objects.create(
|
||||
part=assembly, reference='BO-0987', quantity=10, title='Available Sub/Var'
|
||||
)
|
||||
|
||||
lines = list(build.build_lines.order_by('pk'))
|
||||
self.assertEqual(len(lines), 4)
|
||||
|
||||
# Keep master parts at zero stock so only substitute/variant paths contribute
|
||||
StockItem.objects.create(part=sub_alt_ok, quantity=100)
|
||||
StockItem.objects.create(part=sub_alt_low, quantity=40)
|
||||
StockItem.objects.create(part=var_child_ok, quantity=100)
|
||||
StockItem.objects.create(part=var_child_low, quantity=40)
|
||||
|
||||
url = reverse('api-build-line-list')
|
||||
|
||||
response_true = self.get(url, {'build': build.pk, 'available': True})
|
||||
|
||||
response_false = self.get(url, {'build': build.pk, 'available': False})
|
||||
|
||||
pk_by_bom = {line.bom_item_id: line.pk for line in lines}
|
||||
|
||||
expected_true = {pk_by_bom[bom_sub_ok.pk], pk_by_bom[bom_var_ok.pk]}
|
||||
expected_false = {pk_by_bom[bom_sub_low.pk], pk_by_bom[bom_var_low.pk]}
|
||||
|
||||
true_ids = {item['pk'] for item in response_true.data}
|
||||
false_ids = {item['pk'] for item in response_false.data}
|
||||
|
||||
self.assertSetEqual(true_ids, expected_true)
|
||||
self.assertSetEqual(false_ids, expected_false)
|
||||
self.assertSetEqual(true_ids | false_ids, {line.pk for line in lines})
|
||||
|
||||
def test_output_options(self):
|
||||
"""Test output options for the BuildLine endpoint."""
|
||||
self.run_output_test(
|
||||
|
|
|
|||
|
|
@ -1207,29 +1207,25 @@ class IconList(ListAPI):
|
|||
return list(get_icon_packs().values())
|
||||
|
||||
|
||||
class SelectionListList(ListCreateAPI):
|
||||
class SelectionListMixin(OutputOptionsMixin):
|
||||
"""Mixin for SelectionList views."""
|
||||
|
||||
queryset = common.models.SelectionList.objects.all()
|
||||
serializer_class = common.serializers.SelectionListSerializer
|
||||
permission_classes = [IsAuthenticatedOrReadScope]
|
||||
|
||||
def get_queryset(self):
|
||||
"""Override the queryset method to include entry count."""
|
||||
return self.serializer_class.annotate_queryset(super().get_queryset())
|
||||
|
||||
|
||||
class SelectionListList(SelectionListMixin, ListCreateAPI):
|
||||
"""List view for SelectionList objects."""
|
||||
|
||||
queryset = common.models.SelectionList.objects.all()
|
||||
serializer_class = common.serializers.SelectionListSerializer
|
||||
permission_classes = [IsAuthenticatedOrReadScope]
|
||||
|
||||
def get_queryset(self):
|
||||
"""Override the queryset method to include entry count."""
|
||||
return self.serializer_class.annotate_queryset(super().get_queryset())
|
||||
|
||||
|
||||
class SelectionListDetail(RetrieveUpdateDestroyAPI):
|
||||
class SelectionListDetail(SelectionListMixin, RetrieveUpdateDestroyAPI):
|
||||
"""Detail view for a SelectionList object."""
|
||||
|
||||
queryset = common.models.SelectionList.objects.all()
|
||||
serializer_class = common.serializers.SelectionListSerializer
|
||||
permission_classes = [IsAuthenticatedOrReadScope]
|
||||
|
||||
def get_queryset(self):
|
||||
"""Override the queryset method to include entry count."""
|
||||
return self.serializer_class.annotate_queryset(super().get_queryset())
|
||||
|
||||
|
||||
class EntryMixin:
|
||||
"""Mixin for SelectionEntry views."""
|
||||
|
|
@ -1246,6 +1242,12 @@ class EntryMixin:
|
|||
queryset = queryset.prefetch_related('list')
|
||||
return queryset
|
||||
|
||||
def perform_destroy(self, instance):
|
||||
"""Prevent deletion of entries belonging to a locked selection list."""
|
||||
if instance.list.locked:
|
||||
raise PermissionDenied(_('Selection list is locked'))
|
||||
super().perform_destroy(instance)
|
||||
|
||||
|
||||
class SelectionEntryList(EntryMixin, ListCreateAPI):
|
||||
"""List view for SelectionEntry objects."""
|
||||
|
|
@ -1403,6 +1405,7 @@ class ObservabilityEndSerializer(serializers.Serializer):
|
|||
class ObservabilityEnd(CreateAPI):
|
||||
"""Endpoint for observability tools."""
|
||||
|
||||
# Note: This endpoint can be called anonymously, as it needs to function before the user is authenticated (e.g. during login)
|
||||
permission_classes = [AllowAnyOrReadScope]
|
||||
serializer_class = ObservabilityEndSerializer
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,29 @@
|
|||
"""Migration to change NotificationMessage object_id fields from PositiveIntegerField to CharField.
|
||||
|
||||
This allows notifications to reference models that use non-integer primary keys,
|
||||
such as UUIDField (e.g. MachineConfig), without a database overflow error.
|
||||
|
||||
See: https://github.com/inventree/InvenTree/issues/12131
|
||||
"""
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('common', '0043_auto_20260518_1206'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='notificationmessage',
|
||||
name='target_object_id',
|
||||
field=models.CharField(max_length=255),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='notificationmessage',
|
||||
name='source_object_id',
|
||||
field=models.CharField(max_length=255, null=True, blank=True),
|
||||
),
|
||||
]
|
||||
|
|
@ -1678,7 +1678,7 @@ class NotificationMessage(models.Model):
|
|||
ContentType, on_delete=models.CASCADE, related_name='notification_target'
|
||||
)
|
||||
|
||||
target_object_id = models.PositiveIntegerField()
|
||||
target_object_id = models.CharField(max_length=255)
|
||||
|
||||
target_object = GenericForeignKey('target_content_type', 'target_object_id')
|
||||
|
||||
|
|
@ -1691,7 +1691,7 @@ class NotificationMessage(models.Model):
|
|||
blank=True,
|
||||
)
|
||||
|
||||
source_object_id = models.PositiveIntegerField(null=True, blank=True)
|
||||
source_object_id = models.CharField(max_length=255, null=True, blank=True)
|
||||
|
||||
source_object = GenericForeignKey('source_content_type', 'source_object_id')
|
||||
|
||||
|
|
|
|||
|
|
@ -1004,15 +1004,17 @@ class SelectionEntrySerializer(InvenTreeModelSerializer):
|
|||
def validate(self, attrs):
|
||||
"""Ensure that the selection list is not locked."""
|
||||
ret = super().validate(attrs)
|
||||
if self.instance and self.instance.list.locked:
|
||||
list_obj = attrs.get('list') or (self.instance and self.instance.list)
|
||||
if list_obj and list_obj.locked:
|
||||
raise serializers.ValidationError({'list': _('Selection list is locked')})
|
||||
return ret
|
||||
|
||||
|
||||
class SelectionListSerializer(InvenTreeModelSerializer):
|
||||
class SelectionListSerializer(FilterableSerializerMixin, InvenTreeModelSerializer):
|
||||
"""Serializer for a selection list."""
|
||||
|
||||
_choices_validated: dict = {}
|
||||
_choices_provided: bool = False
|
||||
|
||||
class Meta:
|
||||
"""Meta options for SelectionListSerializer."""
|
||||
|
|
@ -1029,81 +1031,25 @@ class SelectionListSerializer(InvenTreeModelSerializer):
|
|||
'default',
|
||||
'created',
|
||||
'last_updated',
|
||||
'choices',
|
||||
'entry_count',
|
||||
'choices',
|
||||
]
|
||||
|
||||
default = SelectionEntrySerializer(read_only=True, allow_null=True, many=False)
|
||||
choices = SelectionEntrySerializer(source='entries', many=True, required=False)
|
||||
entry_count = serializers.IntegerField(read_only=True)
|
||||
|
||||
choices = OptionalField(
|
||||
serializer_class=SelectionEntrySerializer,
|
||||
serializer_kwargs={'source': 'entries', 'many': True, 'read_only': True},
|
||||
prefetch_fields=['entries'],
|
||||
default_include=True,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def annotate_queryset(queryset):
|
||||
"""Add count of entries for each selection list."""
|
||||
return queryset.annotate(entry_count=Count('entries'))
|
||||
|
||||
def is_valid(self, *, raise_exception=False):
|
||||
"""Validate the selection list. Choices are validated separately."""
|
||||
choices = (
|
||||
self.initial_data.pop('choices')
|
||||
if self.initial_data.get('choices') is not None
|
||||
else []
|
||||
)
|
||||
|
||||
# Validate the choices
|
||||
_choices_validated = []
|
||||
db_entries = (
|
||||
{a.id: a for a in self.instance.entries.all()} if self.instance else {}
|
||||
)
|
||||
|
||||
for choice in choices:
|
||||
current_inst = db_entries.get(choice.get('id'))
|
||||
serializer = SelectionEntrySerializer(
|
||||
instance=current_inst,
|
||||
data={'list': current_inst.list.pk if current_inst else None, **choice},
|
||||
)
|
||||
serializer.is_valid(raise_exception=raise_exception)
|
||||
_choices_validated.append({
|
||||
**serializer.validated_data,
|
||||
'id': choice.get('id'),
|
||||
})
|
||||
self._choices_validated = _choices_validated
|
||||
|
||||
return super().is_valid(raise_exception=raise_exception)
|
||||
|
||||
def create(self, validated_data):
|
||||
"""Create a new selection list. Save the choices separately."""
|
||||
list_entry = common_models.SelectionList.objects.create(**validated_data)
|
||||
for choice_data in self._choices_validated:
|
||||
common_models.SelectionListEntry.objects.create(**{
|
||||
**choice_data,
|
||||
'list': list_entry,
|
||||
})
|
||||
return list_entry
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
"""Update an existing selection list. Save the choices separately."""
|
||||
inst_mapping = {inst.id: inst for inst in instance.entries.all()}
|
||||
existing_ids = {a.get('id') for a in self._choices_validated}
|
||||
|
||||
# Perform creations and updates.
|
||||
ret = []
|
||||
for data in self._choices_validated:
|
||||
list_inst = data.get('list', None)
|
||||
inst = inst_mapping.get(data.get('id'))
|
||||
if inst is None:
|
||||
if list_inst is None:
|
||||
data['list'] = instance
|
||||
ret.append(SelectionEntrySerializer().create(data))
|
||||
else:
|
||||
ret.append(SelectionEntrySerializer().update(inst, data))
|
||||
|
||||
# Perform deletions.
|
||||
for entry_id in inst_mapping.keys() - existing_ids:
|
||||
inst_mapping[entry_id].delete()
|
||||
|
||||
return super().update(instance, validated_data)
|
||||
|
||||
def validate(self, attrs):
|
||||
"""Ensure that the selection list is not locked."""
|
||||
ret = super().validate(attrs)
|
||||
|
|
|
|||
|
|
@ -677,6 +677,12 @@ SYSTEM_SETTINGS: dict[str, InvenTreeSettingsKeyType] = {
|
|||
'default': False,
|
||||
'validator': bool,
|
||||
},
|
||||
'REPORT_FETCH_URLS': {
|
||||
'name': _('Report URL Fetching'),
|
||||
'description': _('Allow fetching of remote URLs when generating reports'),
|
||||
'default': False,
|
||||
'validator': bool,
|
||||
},
|
||||
'REPORT_LOG_ERRORS': {
|
||||
'name': _('Log Report Errors'),
|
||||
'description': _('Log errors which occur when generating reports'),
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ from PIL import Image
|
|||
from taggit.models import Tag
|
||||
|
||||
import common.models
|
||||
from common.models import SelectionList, SelectionListEntry
|
||||
from InvenTree.unit_test import InvenTreeAPITestCase
|
||||
|
||||
|
||||
|
|
@ -1167,3 +1168,69 @@ class TagAPITests(InvenTreeAPITestCase):
|
|||
|
||||
self.assertIn(self.part_a.pk, pks)
|
||||
self.assertNotIn(self.part_b.pk, pks)
|
||||
|
||||
|
||||
class SelectionListLockedTest(InvenTreeAPITestCase):
|
||||
"""Tests that a locked SelectionList rejects all entry mutations."""
|
||||
|
||||
def setUp(self):
|
||||
"""Create a locked SelectionList with one entry."""
|
||||
super().setUp()
|
||||
|
||||
self.sel_list = SelectionList.objects.create(name='Locked List', locked=True)
|
||||
self.entry = SelectionListEntry.objects.create(
|
||||
list=self.sel_list, value='v1', label='Entry 1'
|
||||
)
|
||||
|
||||
self.list_url = reverse(
|
||||
'api-selectionlist-detail', kwargs={'pk': self.sel_list.pk}
|
||||
)
|
||||
self.entry_list_url = reverse(
|
||||
'api-selectionlistentry-list', kwargs={'pk': self.sel_list.pk}
|
||||
)
|
||||
self.entry_detail_url = reverse(
|
||||
'api-selectionlistentry-detail',
|
||||
kwargs={'pk': self.sel_list.pk, 'entrypk': self.entry.pk},
|
||||
)
|
||||
|
||||
def test_create_entry_locked(self):
|
||||
"""POST a new entry to a locked list should be rejected."""
|
||||
response = self.post(
|
||||
self.entry_list_url,
|
||||
{'list': self.sel_list.pk, 'value': 'v2', 'label': 'Entry 2'},
|
||||
expected_code=400,
|
||||
)
|
||||
self.assertIn('list', response.data)
|
||||
self.assertIn('locked', str(response.data['list']).lower())
|
||||
|
||||
def test_update_entry_locked(self):
|
||||
"""PATCH an entry on a locked list should be rejected."""
|
||||
response = self.patch(
|
||||
self.entry_detail_url, {'label': 'Changed'}, expected_code=400
|
||||
)
|
||||
self.assertIn('list', response.data)
|
||||
self.assertIn('locked', str(response.data['list']).lower())
|
||||
|
||||
def test_delete_entry_locked(self):
|
||||
"""DELETE an entry from a locked list should be rejected."""
|
||||
self.delete(self.entry_detail_url, expected_code=403)
|
||||
self.assertTrue(SelectionListEntry.objects.filter(pk=self.entry.pk).exists())
|
||||
|
||||
def test_patch_list_with_choices_locked(self):
|
||||
"""PATCH the list with a choices payload should be rejected when locked."""
|
||||
response = self.patch(
|
||||
self.list_url,
|
||||
{'choices': [{'value': 'v2', 'label': 'New'}]},
|
||||
expected_code=400,
|
||||
)
|
||||
self.assertIn('locked', response.data)
|
||||
|
||||
def test_patch_list_without_choices_preserves_entries(self):
|
||||
"""PATCH the list without choices should not touch entries (even when unlocked)."""
|
||||
self.sel_list.locked = False
|
||||
self.sel_list.save()
|
||||
|
||||
self.patch(self.list_url, {'name': 'Renamed List'}, expected_code=200)
|
||||
|
||||
# Entry must still exist — omitting choices must not delete entries
|
||||
self.assertTrue(SelectionListEntry.objects.filter(pk=self.entry.pk).exists())
|
||||
|
|
|
|||
|
|
@ -2103,43 +2103,58 @@ class SelectionListTest(InvenTreeAPITestCase):
|
|||
# Test adding a new list via the API
|
||||
response = self.post(
|
||||
reverse('api-selectionlist-list'),
|
||||
{
|
||||
'name': 'New List',
|
||||
'active': True,
|
||||
'choices': [{'value': '1', 'label': 'Test Entry'}],
|
||||
},
|
||||
{'name': 'New List', 'active': True},
|
||||
expected_code=201,
|
||||
)
|
||||
list_pk = response.data['pk']
|
||||
self.assertEqual(response.data['name'], 'New List')
|
||||
self.assertTrue(response.data['active'])
|
||||
|
||||
entry_list_url = reverse('api-selectionlistentry-list', kwargs={'pk': list_pk})
|
||||
|
||||
# Add an entry via the entry API
|
||||
response = self.post(
|
||||
entry_list_url,
|
||||
{'list': list_pk, 'value': '1', 'label': 'Test Entry'},
|
||||
expected_code=201,
|
||||
)
|
||||
entry_pk = response.data['id']
|
||||
self.assertEqual(response.data['value'], '1')
|
||||
self.assertEqual(response.data['label'], 'Test Entry')
|
||||
|
||||
# Verify the entry appears in the list's choices
|
||||
response = self.get(
|
||||
reverse('api-selectionlist-detail', kwargs={'pk': list_pk}),
|
||||
data={'choices': True},
|
||||
expected_code=200,
|
||||
)
|
||||
self.assertEqual(len(response.data['choices']), 1)
|
||||
self.assertEqual(response.data['choices'][0]['value'], '1')
|
||||
|
||||
# Test editing the list choices via the API (remove and add in same call)
|
||||
response = self.patch(
|
||||
reverse('api-selectionlist-detail', kwargs={'pk': list_pk}),
|
||||
{'choices': [{'value': '2', 'label': 'New Label'}]},
|
||||
expected_code=200,
|
||||
# Edit the entry via the entry detail API
|
||||
entry_url = reverse(
|
||||
'api-selectionlistentry-detail', kwargs={'pk': list_pk, 'entrypk': entry_pk}
|
||||
)
|
||||
self.assertEqual(response.data['name'], 'New List')
|
||||
self.assertTrue(response.data['active'])
|
||||
self.assertEqual(len(response.data['choices']), 1)
|
||||
self.assertEqual(response.data['choices'][0]['value'], '2')
|
||||
self.assertEqual(response.data['choices'][0]['label'], 'New Label')
|
||||
entry_id = response.data['choices'][0]['id']
|
||||
response = self.patch(entry_url, {'label': 'Updated Label'}, expected_code=200)
|
||||
self.assertEqual(response.data['value'], '1')
|
||||
self.assertEqual(response.data['label'], 'Updated Label')
|
||||
|
||||
# Test changing an entry via list API
|
||||
response = self.patch(
|
||||
# Add a second entry, then delete the first via the entry detail API
|
||||
self.post(
|
||||
entry_list_url,
|
||||
{'list': list_pk, 'value': '2', 'label': 'Second Entry'},
|
||||
expected_code=201,
|
||||
)
|
||||
self.delete(entry_url, expected_code=204)
|
||||
|
||||
# Verify only the second entry remains
|
||||
response = self.get(
|
||||
reverse('api-selectionlist-detail', kwargs={'pk': list_pk}),
|
||||
{'choices': [{'id': entry_id, 'value': '2', 'label': 'New Label Text'}]},
|
||||
data={'choices': True},
|
||||
expected_code=200,
|
||||
)
|
||||
self.assertEqual(response.data['name'], 'New List')
|
||||
self.assertTrue(response.data['active'])
|
||||
self.assertEqual(len(response.data['choices']), 1)
|
||||
self.assertEqual(response.data['choices'][0]['value'], '2')
|
||||
self.assertEqual(response.data['choices'][0]['label'], 'New Label Text')
|
||||
|
||||
def test_api_locked(self):
|
||||
"""Test editing with locked/unlocked list."""
|
||||
|
|
|
|||
|
|
@ -115,6 +115,10 @@ class DataImportSessionAcceptFields(APIView):
|
|||
"""Accept the field mapping for a DataImportSession."""
|
||||
session = get_object_or_404(importer.models.DataImportSession, pk=pk)
|
||||
|
||||
# Check session ownership
|
||||
if not request.user.is_staff and session.user != request.user:
|
||||
raise PermissionDenied()
|
||||
|
||||
# Check that the user has permission to accept the field mapping
|
||||
if model_class := session.model_class:
|
||||
if not check_user_permission(request.user, model_class, 'change'):
|
||||
|
|
@ -137,17 +141,45 @@ class DataImportSessionAcceptRows(DataImporterPermissionMixin, CreateAPI):
|
|||
ctx = super().get_serializer_context()
|
||||
|
||||
try:
|
||||
ctx['session'] = importer.models.DataImportSession.objects.get(
|
||||
session = importer.models.DataImportSession.objects.get(
|
||||
pk=self.kwargs.get('pk', None)
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
except importer.models.DataImportSession.DoesNotExist:
|
||||
session = None
|
||||
|
||||
if session:
|
||||
user = self.request.user
|
||||
if not user.is_staff and session.user != user:
|
||||
raise PermissionDenied()
|
||||
ctx['session'] = session
|
||||
|
||||
ctx['request'] = self.request
|
||||
return ctx
|
||||
|
||||
|
||||
class DataImportColumnMappingList(DataImporterPermissionMixin, ListAPI):
|
||||
class DataImportSessionChildMixin(DataImporterPermissionMixin):
|
||||
"""Mixin for DataImportRow and DataImportColumnMap views.
|
||||
|
||||
Ensures users can only access objects that belong to an import session they own.
|
||||
Staff users retain access to all objects.
|
||||
"""
|
||||
|
||||
def get_queryset(self):
|
||||
"""Return only objects whose session belongs to the requesting user."""
|
||||
queryset = super().get_queryset()
|
||||
|
||||
try:
|
||||
user = self.request.user
|
||||
except AttributeError:
|
||||
raise PermissionDenied('User information is not available')
|
||||
|
||||
if user.is_staff:
|
||||
return queryset
|
||||
|
||||
return queryset.filter(session__user=user)
|
||||
|
||||
|
||||
class DataImportColumnMappingList(DataImportSessionChildMixin, ListAPI):
|
||||
"""API endpoint for accessing a list of DataImportColumnMap objects."""
|
||||
|
||||
queryset = importer.models.DataImportColumnMap.objects.all()
|
||||
|
|
@ -158,14 +190,14 @@ class DataImportColumnMappingList(DataImporterPermissionMixin, ListAPI):
|
|||
filterset_fields = ['session']
|
||||
|
||||
|
||||
class DataImportColumnMappingDetail(DataImporterPermissionMixin, RetrieveUpdateAPI):
|
||||
class DataImportColumnMappingDetail(DataImportSessionChildMixin, RetrieveUpdateAPI):
|
||||
"""Detail endpoint for a single DataImportColumnMap object."""
|
||||
|
||||
queryset = importer.models.DataImportColumnMap.objects.all()
|
||||
serializer_class = importer.serializers.DataImportColumnMapSerializer
|
||||
|
||||
|
||||
class DataImportRowList(DataImporterPermissionMixin, BulkDeleteMixin, ListAPI):
|
||||
class DataImportRowList(DataImportSessionChildMixin, BulkDeleteMixin, ListAPI):
|
||||
"""API endpoint for accessing a list of DataImportRow objects."""
|
||||
|
||||
queryset = importer.models.DataImportRow.objects.all()
|
||||
|
|
@ -180,7 +212,7 @@ class DataImportRowList(DataImporterPermissionMixin, BulkDeleteMixin, ListAPI):
|
|||
ordering = 'row_index'
|
||||
|
||||
|
||||
class DataImportRowDetail(DataImporterPermissionMixin, RetrieveUpdateDestroyAPI):
|
||||
class DataImportRowDetail(DataImportSessionChildMixin, RetrieveUpdateDestroyAPI):
|
||||
"""Detail endpoint for a single DataImportRow object."""
|
||||
|
||||
queryset = importer.models.DataImportRow.objects.all()
|
||||
|
|
|
|||
|
|
@ -0,0 +1,24 @@
|
|||
# Generated migration
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("importer", "0005_dataimportsession_update_records"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="dataimportcolumnmap",
|
||||
name="lookup_field",
|
||||
field=models.CharField(
|
||||
blank=True,
|
||||
null=True,
|
||||
max_length=100,
|
||||
verbose_name="Lookup Field",
|
||||
help_text="Database field to use for foreign-key lookup. Leave blank for automatic lookup.",
|
||||
),
|
||||
),
|
||||
]
|
||||
|
|
@ -152,6 +152,36 @@ class DataImportSession(models.Model):
|
|||
|
||||
return supported_models().get(self.model_type, None)
|
||||
|
||||
def get_lookup_fields_for_field(self, field_name: str) -> list:
|
||||
"""Return the valid lookup fields for a given related (FK) field.
|
||||
|
||||
Returns a list of field names that can be used as a lookup key,
|
||||
consisting of 'pk' plus any fields defined in IMPORT_ID_FIELDS on the related model.
|
||||
"""
|
||||
model = self.get_related_model(field_name)
|
||||
|
||||
if not model:
|
||||
return ['pk']
|
||||
|
||||
id_fields = ['pk']
|
||||
|
||||
if custom_fields := getattr(model, 'IMPORT_ID_FIELDS', None):
|
||||
id_fields += custom_fields
|
||||
|
||||
return id_fields
|
||||
|
||||
@property
|
||||
def field_lookup_mapping(self) -> dict:
|
||||
"""Return a dict of field -> lookup_field mappings for this import session.
|
||||
|
||||
Only entries where lookup_field is explicitly set are included.
|
||||
"""
|
||||
return {
|
||||
mapping.field: mapping.lookup_field
|
||||
for mapping in self.column_mappings.all()
|
||||
if mapping.lookup_field
|
||||
}
|
||||
|
||||
def get_related_model(self, field_name: str) -> Optional[models.Model]:
|
||||
"""Return the related model for a given field name.
|
||||
|
||||
|
|
@ -344,14 +374,21 @@ class DataImportSession(models.Model):
|
|||
self.save()
|
||||
|
||||
def check_complete(self) -> bool:
|
||||
"""Check if the import session is complete."""
|
||||
"""Check if the import session is complete.
|
||||
|
||||
When all rows have been accepted, the rows and column mappings are
|
||||
deleted as they are no longer needed. The session itself is retained
|
||||
as an audit record.
|
||||
"""
|
||||
if self.completed_row_count < self.row_count:
|
||||
return False
|
||||
|
||||
# Update the status of this session
|
||||
if self.status != DataImportStatusCode.COMPLETE.value:
|
||||
self.status = DataImportStatusCode.COMPLETE.value
|
||||
self.save()
|
||||
# Clear staging data now that all rows have been imported
|
||||
self.rows.all().delete()
|
||||
self.column_mappings.all().delete()
|
||||
|
||||
return True
|
||||
|
||||
|
|
@ -401,6 +438,11 @@ class DataImportSession(models.Model):
|
|||
if field.get('read_only', False):
|
||||
continue
|
||||
|
||||
if field.get('type') == 'related field':
|
||||
field['lookup_fields'] = self.get_lookup_fields_for_field(
|
||||
field_name
|
||||
)
|
||||
|
||||
fields[field_name] = field
|
||||
|
||||
# Cache the available fields against this instance
|
||||
|
|
@ -488,6 +530,22 @@ class DataImportColumnMap(models.Model):
|
|||
if field_def.get('read_only', False):
|
||||
raise DjangoValidationError({'field': _('Selected field is read-only')})
|
||||
|
||||
if self.lookup_field:
|
||||
if field_def.get('type') != 'related field':
|
||||
raise DjangoValidationError({
|
||||
'lookup_field': _(
|
||||
'Lookup field can only be set for related (foreign-key) fields'
|
||||
)
|
||||
})
|
||||
|
||||
valid_lookup_fields = self.session.get_lookup_fields_for_field(self.field)
|
||||
if self.lookup_field not in valid_lookup_fields:
|
||||
raise DjangoValidationError({
|
||||
'lookup_field': _(
|
||||
'Invalid lookup field. Valid options are: {options}'
|
||||
).format(options=', '.join(valid_lookup_fields))
|
||||
})
|
||||
|
||||
session = models.ForeignKey(
|
||||
DataImportSession,
|
||||
on_delete=models.CASCADE,
|
||||
|
|
@ -499,6 +557,16 @@ class DataImportColumnMap(models.Model):
|
|||
|
||||
column = models.CharField(blank=True, max_length=100, verbose_name=_('Column'))
|
||||
|
||||
lookup_field = models.CharField(
|
||||
blank=True,
|
||||
null=True,
|
||||
max_length=100,
|
||||
verbose_name=_('Lookup Field'),
|
||||
help_text=_(
|
||||
'Database field to use for foreign-key lookup. Leave blank for automatic lookup.'
|
||||
),
|
||||
)
|
||||
|
||||
@property
|
||||
def available_fields(self):
|
||||
"""Return a list of available fields for this import session.
|
||||
|
|
@ -631,9 +699,12 @@ class DataImportRow(models.Model):
|
|||
default_values = self.default_values
|
||||
|
||||
data = {}
|
||||
extract_errors = {}
|
||||
|
||||
self.related_field_map = {}
|
||||
|
||||
field_lookup_mapping = self.session.field_lookup_mapping
|
||||
|
||||
# We have mapped column (file) to field (serializer) already
|
||||
for field, col in field_mapping.items():
|
||||
# Data override (force value and skip any further checks)
|
||||
|
|
@ -661,7 +732,13 @@ class DataImportRow(models.Model):
|
|||
elif field_type == 'date':
|
||||
value = self.convert_date_field(value)
|
||||
elif field_type == 'related field':
|
||||
value = self.lookup_related_field(field, value)
|
||||
try:
|
||||
value = self.lookup_related_field(
|
||||
field, value, lookup_field=field_lookup_mapping.get(field)
|
||||
)
|
||||
except DjangoValidationError as exc:
|
||||
extract_errors[field] = exc.message
|
||||
continue
|
||||
|
||||
# Use the default value, if provided
|
||||
if value is None and field in default_values:
|
||||
|
|
@ -703,6 +780,9 @@ class DataImportRow(models.Model):
|
|||
|
||||
self.data = data
|
||||
|
||||
if extract_errors:
|
||||
self.errors = extract_errors
|
||||
|
||||
if commit:
|
||||
self.save()
|
||||
|
||||
|
|
@ -726,7 +806,9 @@ class DataImportRow(models.Model):
|
|||
# If none of the formats matched, return the original value
|
||||
return value
|
||||
|
||||
def lookup_related_field(self, field_name: str, value: str) -> Optional[int]:
|
||||
def lookup_related_field(
|
||||
self, field_name: str, value: str, lookup_field: Optional[str] = None
|
||||
) -> Optional[int]:
|
||||
"""Try to perform lookup against a related field.
|
||||
|
||||
- This is used to convert a human-readable value (e.g. a supplier name) into a database reference (e.g. supplier ID).
|
||||
|
|
@ -735,6 +817,7 @@ class DataImportRow(models.Model):
|
|||
Arguments:
|
||||
field_name: The name of the field to perform the lookup against
|
||||
value: The value to be looked up
|
||||
lookup_field: If provided, only query this specific model field (skips auto-lookup)
|
||||
|
||||
Returns:
|
||||
A primary key value
|
||||
|
|
@ -758,21 +841,35 @@ class DataImportRow(models.Model):
|
|||
'session': f'No related model found for field: {field_name}'
|
||||
})
|
||||
|
||||
valid_items = set()
|
||||
|
||||
base_filters = (
|
||||
self.session.field_filters.get(field_name, {})
|
||||
if self.session.field_filters
|
||||
else {}
|
||||
)
|
||||
|
||||
# First priority is the PK (primary key) field
|
||||
if lookup_field and type(lookup_field) is str:
|
||||
# A specific lookup field has been chosen by the user — query only that field
|
||||
try:
|
||||
queryset = model.objects.filter(**{lookup_field: value}, **base_filters)
|
||||
except ValueError:
|
||||
return value
|
||||
|
||||
results = list(queryset[:2])
|
||||
|
||||
if len(results) == 1:
|
||||
return results[0].pk
|
||||
|
||||
# Zero or multiple results — return raw value and let serializer report the error
|
||||
return value
|
||||
|
||||
# Auto-lookup: try pk first, then any model-defined IMPORT_ID_FIELDS
|
||||
id_fields = ['pk']
|
||||
|
||||
if custom_id_fields := getattr(model, 'IMPORT_ID_FIELDS', None):
|
||||
id_fields += custom_id_fields
|
||||
|
||||
# Iterate through the provided list - if any of the values match, we can perform the lookup
|
||||
valid_items = set()
|
||||
|
||||
for id_field in id_fields:
|
||||
try:
|
||||
queryset = model.objects.filter(**{id_field: value}, **base_filters)
|
||||
|
|
@ -782,15 +879,19 @@ class DataImportRow(models.Model):
|
|||
# Evaluate at most two results to determine if there is exactly one match
|
||||
results = list(queryset[:2])
|
||||
if len(results) == 1:
|
||||
# We have a single match against this field
|
||||
valid_items.add(results[0].pk)
|
||||
|
||||
if len(valid_items) == 1:
|
||||
# We found a single valid match against the related model - return this value
|
||||
return valid_items.pop()
|
||||
|
||||
# We found either zero or multiple values matching against the related model
|
||||
# Return the original value and let the serializer validation handle any errors against this field
|
||||
if len(valid_items) > 1:
|
||||
raise DjangoValidationError(
|
||||
_(
|
||||
'Multiple matches found for value - please ensure the value is unique, or select a specific lookup field'
|
||||
)
|
||||
)
|
||||
|
||||
# No match found - return the original value and let the serializer validation handle it
|
||||
return value
|
||||
|
||||
def serializer_data(self):
|
||||
|
|
@ -837,6 +938,10 @@ class DataImportRow(models.Model):
|
|||
# Row has already been completed
|
||||
return True
|
||||
|
||||
if self.errors:
|
||||
# Errors were set during data extraction (e.g. ambiguous FK lookup)
|
||||
return False
|
||||
|
||||
if self.session.update_records:
|
||||
# Extract the ID field from the data
|
||||
instance_id = self.data.get(self.session.ID_FIELD_LABEL, None)
|
||||
|
|
|
|||
|
|
@ -23,7 +23,15 @@ class DataImportColumnMapSerializer(InvenTreeModelSerializer):
|
|||
"""Meta class options for the serializer."""
|
||||
|
||||
model = importer.models.DataImportColumnMap
|
||||
fields = ['pk', 'session', 'column', 'field', 'label', 'description']
|
||||
fields = [
|
||||
'pk',
|
||||
'session',
|
||||
'column',
|
||||
'field',
|
||||
'label',
|
||||
'description',
|
||||
'lookup_field',
|
||||
]
|
||||
read_only_fields = ['field', 'session']
|
||||
|
||||
label = serializers.CharField(read_only=True)
|
||||
|
|
|
|||
|
|
@ -2,10 +2,11 @@
|
|||
|
||||
import os
|
||||
|
||||
from django.contrib.auth.models import User
|
||||
from django.core.files.base import ContentFile
|
||||
from django.urls import reverse
|
||||
|
||||
from importer.models import DataImportRow, DataImportSession
|
||||
from importer.models import DataImportColumnMap, DataImportRow, DataImportSession
|
||||
from InvenTree.unit_test import AdminTestCase, InvenTreeAPITestCase, InvenTreeTestCase
|
||||
|
||||
|
||||
|
|
@ -58,14 +59,20 @@ class ImporterTest(ImporterMixin, InvenTreeTestCase):
|
|||
self.assertEqual(session.rows.count(), 12)
|
||||
|
||||
# Check that some data has been imported
|
||||
for row in session.rows.all():
|
||||
rows = list(session.rows.all())
|
||||
self.assertEqual(len(rows), 12)
|
||||
|
||||
for row in rows:
|
||||
self.assertIsNotNone(row.data.get('name', None))
|
||||
self.assertTrue(row.valid)
|
||||
|
||||
row.validate(commit=True)
|
||||
self.assertTrue(row.complete)
|
||||
|
||||
self.assertEqual(session.completed_row_count, 12)
|
||||
# All rows accepted: rows and mappings are cleared, session is retained
|
||||
session.refresh_from_db()
|
||||
self.assertEqual(session.rows.count(), 0)
|
||||
self.assertEqual(session.column_mappings.count(), 0)
|
||||
|
||||
# Check that the new companies have been created
|
||||
self.assertEqual(n + 12, Company.objects.count())
|
||||
|
|
@ -73,6 +80,72 @@ class ImporterTest(ImporterMixin, InvenTreeTestCase):
|
|||
def test_field_defaults(self):
|
||||
"""Test default field values."""
|
||||
|
||||
def test_lookup_field_ambiguous_match(self):
|
||||
"""Test the behavior of lookup_related_field for ambiguous and pinned matches."""
|
||||
from django.core.exceptions import ValidationError as DjangoValidationError
|
||||
|
||||
from part.models import Part, PartCategory
|
||||
|
||||
category = PartCategory.objects.create(
|
||||
name='Test Category', description='Test category'
|
||||
)
|
||||
|
||||
# Two parts which collide under different IMPORT_ID_FIELDS ('IPN' and 'name')
|
||||
part_a = Part.objects.create(
|
||||
category=category, name='Widget', description='desc', IPN='AMBIG-001'
|
||||
)
|
||||
Part.objects.create(
|
||||
category=category, name='AMBIG-001', description='desc', IPN='WIDGET-002'
|
||||
)
|
||||
|
||||
data_file = self.helper_file('companies.csv')
|
||||
session = DataImportSession.objects.create(
|
||||
data_file=data_file, model_type='stockitem'
|
||||
)
|
||||
|
||||
row = DataImportRow(session=session)
|
||||
row.related_field_map = {}
|
||||
|
||||
# Auto-lookup (no pinned lookup field) raises, as the value matches two different parts
|
||||
with self.assertRaises(DjangoValidationError):
|
||||
row.lookup_related_field('part', 'AMBIG-001')
|
||||
|
||||
# Pinning the lookup field to 'IPN' resolves the match unambiguously
|
||||
result = row.lookup_related_field('part', 'AMBIG-001', lookup_field='IPN')
|
||||
self.assertEqual(result, part_a.pk)
|
||||
|
||||
# Pinning the lookup field to 'name' resolves to the *other* part
|
||||
result = row.lookup_related_field('part', 'AMBIG-001', lookup_field='name')
|
||||
self.assertNotEqual(result, part_a.pk)
|
||||
|
||||
def test_lookup_field_validation(self):
|
||||
"""Test that DataImportColumnMap.clean() validates the lookup_field value."""
|
||||
from django.core.exceptions import ValidationError as DjangoValidationError
|
||||
|
||||
data_file = self.helper_file('companies.csv')
|
||||
session = DataImportSession.objects.create(
|
||||
data_file=data_file, model_type='stockitem'
|
||||
)
|
||||
|
||||
part_mapping = session.column_mappings.get(field='part')
|
||||
quantity_mapping = session.column_mappings.get(field='quantity')
|
||||
|
||||
# Valid lookup field for a related (FK) field
|
||||
part_mapping.lookup_field = 'IPN'
|
||||
part_mapping.save()
|
||||
part_mapping.refresh_from_db()
|
||||
self.assertEqual(part_mapping.lookup_field, 'IPN')
|
||||
|
||||
# Invalid lookup field (not a valid IMPORT_ID_FIELDS option)
|
||||
part_mapping.lookup_field = 'not_a_real_field'
|
||||
with self.assertRaises(DjangoValidationError):
|
||||
part_mapping.save()
|
||||
|
||||
# lookup_field cannot be set against a non-related field
|
||||
quantity_mapping.lookup_field = 'IPN'
|
||||
with self.assertRaises(DjangoValidationError):
|
||||
quantity_mapping.save()
|
||||
|
||||
|
||||
class ImportAPITest(ImporterMixin, InvenTreeAPITestCase):
|
||||
"""End-to-end tests for the importer API."""
|
||||
|
|
@ -174,6 +247,58 @@ class ImportAPITest(ImporterMixin, InvenTreeAPITestCase):
|
|||
# Check that there are new database records
|
||||
self.assertEqual(PartCategory.objects.count(), N + 4)
|
||||
|
||||
def test_column_mapping_lookup_field(self):
|
||||
"""Test that the 'lookup_field' option can be specified via the column-mapping API."""
|
||||
f = self.helper_file('companies.csv')
|
||||
|
||||
session = DataImportSession.objects.create(
|
||||
data_file=f, model_type='stockitem', user=self.user
|
||||
)
|
||||
|
||||
self.assignRole('stock.change')
|
||||
|
||||
# available_fields should expose the valid lookup field options for the 'part' FK field
|
||||
session_detail = self.get(
|
||||
reverse('api-import-session-detail', kwargs={'pk': session.pk})
|
||||
).data
|
||||
part_field_info = session_detail['available_fields']['part']
|
||||
self.assertIn('lookup_fields', part_field_info)
|
||||
self.assertIn('IPN', part_field_info['lookup_fields'])
|
||||
self.assertIn('name', part_field_info['lookup_fields'])
|
||||
self.assertIn('pk', part_field_info['lookup_fields'])
|
||||
|
||||
part_mapping = session.column_mappings.get(field='part')
|
||||
quantity_mapping = session.column_mappings.get(field='quantity')
|
||||
|
||||
mapping_url = reverse(
|
||||
'api-importer-mapping-detail', kwargs={'pk': part_mapping.pk}
|
||||
)
|
||||
|
||||
# Initially, no lookup field is set (defaults to 'auto')
|
||||
data = self.get(mapping_url).data
|
||||
self.assertIn(data['lookup_field'], [None, ''])
|
||||
|
||||
# Set a valid lookup field
|
||||
data = self.patch(mapping_url, {'lookup_field': 'IPN'}, expected_code=200).data
|
||||
self.assertEqual(data['lookup_field'], 'IPN')
|
||||
|
||||
# Confirm it persists
|
||||
data = self.get(mapping_url).data
|
||||
self.assertEqual(data['lookup_field'], 'IPN')
|
||||
|
||||
# An invalid lookup field is rejected
|
||||
self.patch(mapping_url, {'lookup_field': 'not_a_real_field'}, expected_code=400)
|
||||
|
||||
# Clear the lookup field (revert to 'auto')
|
||||
data = self.patch(mapping_url, {'lookup_field': None}, expected_code=200).data
|
||||
self.assertIn(data['lookup_field'], [None, ''])
|
||||
|
||||
# lookup_field cannot be set against a non-related field
|
||||
quantity_url = reverse(
|
||||
'api-importer-mapping-detail', kwargs={'pk': quantity_mapping.pk}
|
||||
)
|
||||
self.patch(quantity_url, {'lookup_field': 'IPN'}, expected_code=400)
|
||||
|
||||
def test_session_list(self):
|
||||
"""Test API endpoint which details the list of import sessions."""
|
||||
url = reverse('api-importer-session-list')
|
||||
|
|
@ -204,6 +329,191 @@ class ImportAPITest(ImporterMixin, InvenTreeAPITestCase):
|
|||
for session in response.data:
|
||||
self.assertEqual(session['user'], self.user.pk)
|
||||
|
||||
def test_accept_fields_ownership(self):
|
||||
"""Test that accept_fields rejects requests for sessions owned by another user."""
|
||||
other_user = User.objects.create_user(
|
||||
username='other_accept', password='password'
|
||||
)
|
||||
|
||||
f = self.helper_file('companies.csv')
|
||||
session = DataImportSession.objects.create(
|
||||
data_file=f, model_type='company', user=other_user
|
||||
)
|
||||
|
||||
url = reverse('api-import-session-accept-fields', kwargs={'pk': session.pk})
|
||||
|
||||
# Non-owner, non-staff should be denied
|
||||
self.user.is_staff = False
|
||||
self.user.save()
|
||||
self.post(url, expected_code=403)
|
||||
|
||||
# Staff should be allowed (subject to model permission)
|
||||
# Company is part of the purchase_order ruleset
|
||||
self.user.is_staff = True
|
||||
self.user.save()
|
||||
self.assignRole('purchase_order.change')
|
||||
self.post(url, expected_code=200)
|
||||
|
||||
def test_accept_rows_ownership(self):
|
||||
"""Test that accept_rows rejects requests for sessions owned by another user."""
|
||||
other_user = User.objects.create_user(
|
||||
username='other_accept_rows', password='password'
|
||||
)
|
||||
|
||||
f = self.helper_file('companies.csv')
|
||||
session = DataImportSession.objects.create(
|
||||
data_file=f, model_type='company', user=other_user
|
||||
)
|
||||
session.extract_columns()
|
||||
|
||||
url = reverse('api-import-session-accept-rows', kwargs={'pk': session.pk})
|
||||
|
||||
self.user.is_staff = False
|
||||
self.user.save()
|
||||
self.post(url, {'rows': []}, expected_code=403)
|
||||
|
||||
# Staff can reach the endpoint (rows list is empty so validation rejects with 400, not 403)
|
||||
self.user.is_staff = True
|
||||
self.user.save()
|
||||
self.post(url, {'rows': []}, expected_code=400)
|
||||
|
||||
def test_session_cleanup_on_complete(self):
|
||||
"""Test that a completed import session deletes itself and all associated data."""
|
||||
url = reverse('api-importer-session-list')
|
||||
data_file = self.helper_file('part_categories.csv')
|
||||
|
||||
data = self.post(
|
||||
url,
|
||||
{'model_type': 'partcategory', 'data_file': data_file},
|
||||
format='multipart',
|
||||
).data
|
||||
|
||||
session_id = data['pk']
|
||||
session_pk = session_id
|
||||
|
||||
self.assignRole('part_category.add')
|
||||
self.post(
|
||||
reverse('api-import-session-accept-fields', kwargs={'pk': session_id}),
|
||||
expected_code=200,
|
||||
)
|
||||
|
||||
rows = self.get(
|
||||
reverse('api-importer-row-list'), data={'session': session_id}
|
||||
).data
|
||||
row_ids = [r['pk'] for r in rows]
|
||||
self.assertGreater(len(row_ids), 0)
|
||||
|
||||
# Confirm rows and mappings exist before acceptance
|
||||
self.assertTrue(DataImportRow.objects.filter(session_id=session_pk).exists())
|
||||
self.assertTrue(
|
||||
DataImportColumnMap.objects.filter(session_id=session_pk).exists()
|
||||
)
|
||||
|
||||
# Accept all rows — this should trigger cleanup of rows and mappings
|
||||
self.post(
|
||||
reverse('api-import-session-accept-rows', kwargs={'pk': session_id}),
|
||||
{'rows': row_ids},
|
||||
)
|
||||
|
||||
# Rows and column mappings must be cleared
|
||||
self.assertFalse(DataImportRow.objects.filter(session_id=session_pk).exists())
|
||||
self.assertFalse(
|
||||
DataImportColumnMap.objects.filter(session_id=session_pk).exists()
|
||||
)
|
||||
|
||||
# Session itself is retained as an audit record with COMPLETE status
|
||||
from importer.models import DataImportSession
|
||||
from importer.status_codes import DataImportStatusCode
|
||||
|
||||
session_obj = DataImportSession.objects.get(pk=session_pk)
|
||||
self.assertEqual(session_obj.status, DataImportStatusCode.COMPLETE.value)
|
||||
|
||||
detail = self.get(
|
||||
reverse('api-import-session-detail', kwargs={'pk': session_id}),
|
||||
expected_code=200,
|
||||
).data
|
||||
self.assertEqual(detail['row_count'], 0)
|
||||
self.assertEqual(detail['completed_row_count'], 0)
|
||||
|
||||
def test_row_and_mapping_ownership(self):
|
||||
"""Test that DataImportRow and DataImportColumnMap endpoints filter by session ownership."""
|
||||
f = self.helper_file('companies.csv')
|
||||
|
||||
other_user = User.objects.create_user(
|
||||
username='other_importer', password='password'
|
||||
)
|
||||
|
||||
# Session owned by self.user
|
||||
session_mine = DataImportSession.objects.create(
|
||||
data_file=f, model_type='company', user=self.user
|
||||
)
|
||||
session_mine.extract_columns()
|
||||
|
||||
# Session owned by another user
|
||||
f2 = self.helper_file('companies.csv')
|
||||
session_other = DataImportSession.objects.create(
|
||||
data_file=f2, model_type='company', user=other_user
|
||||
)
|
||||
session_other.extract_columns()
|
||||
|
||||
row_list_url = reverse('api-importer-row-list')
|
||||
mapping_list_url = reverse('api-importer-mapping-list')
|
||||
|
||||
# Non-staff: should only see rows/mappings from own session
|
||||
self.user.is_staff = False
|
||||
self.user.save()
|
||||
|
||||
rows = self.get(row_list_url).data
|
||||
for row in rows:
|
||||
self.assertEqual(row['session'], session_mine.pk)
|
||||
|
||||
mappings = self.get(mapping_list_url).data
|
||||
for mapping in mappings:
|
||||
self.assertEqual(mapping['session'], session_mine.pk)
|
||||
|
||||
# Detail endpoint: own session's row/mapping should be accessible
|
||||
own_row = DataImportRow.objects.filter(session=session_mine).first()
|
||||
other_row = DataImportRow.objects.filter(session=session_other).first()
|
||||
|
||||
if own_row:
|
||||
self.get(
|
||||
reverse('api-importer-row-detail', kwargs={'pk': own_row.pk}),
|
||||
expected_code=200,
|
||||
)
|
||||
if other_row:
|
||||
self.get(
|
||||
reverse('api-importer-row-detail', kwargs={'pk': other_row.pk}),
|
||||
expected_code=404,
|
||||
)
|
||||
|
||||
own_mapping = DataImportColumnMap.objects.filter(session=session_mine).first()
|
||||
other_mapping = DataImportColumnMap.objects.filter(
|
||||
session=session_other
|
||||
).first()
|
||||
|
||||
if own_mapping:
|
||||
self.get(
|
||||
reverse('api-importer-mapping-detail', kwargs={'pk': own_mapping.pk}),
|
||||
expected_code=200,
|
||||
)
|
||||
if other_mapping:
|
||||
self.get(
|
||||
reverse('api-importer-mapping-detail', kwargs={'pk': other_mapping.pk}),
|
||||
expected_code=404,
|
||||
)
|
||||
|
||||
# Staff user: should see rows/mappings from all sessions
|
||||
self.user.is_staff = True
|
||||
self.user.save()
|
||||
|
||||
all_row_pks = set(DataImportRow.objects.values_list('pk', flat=True))
|
||||
response_rows = self.get(row_list_url).data
|
||||
self.assertEqual({r['pk'] for r in response_rows}, all_row_pks)
|
||||
|
||||
all_mapping_pks = set(DataImportColumnMap.objects.values_list('pk', flat=True))
|
||||
response_mappings = self.get(mapping_list_url).data
|
||||
self.assertEqual({m['pk'] for m in response_mappings}, all_mapping_pks)
|
||||
|
||||
|
||||
class AdminTest(ImporterMixin, AdminTestCase):
|
||||
"""Tests for the admin interface integration."""
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
|
@ -267,4 +267,4 @@ class LabelPrinterMachine(BaseMachineType):
|
|||
if not location_pk:
|
||||
return None
|
||||
|
||||
return StockLocation.objects.get(pk=location_pk)
|
||||
return StockLocation.objects.filter(pk=location_pk).first()
|
||||
|
|
|
|||
|
|
@ -171,6 +171,8 @@ class TestLabelPrinterMachineType(InvenTreeAPITestCase):
|
|||
|
||||
fixtures = ['category', 'part', 'location', 'stock']
|
||||
|
||||
roles = ['part.view']
|
||||
|
||||
def test_registration(self):
|
||||
"""Test that the machine is correctly registered from the plugin."""
|
||||
PLG_KEY = 'label-printer-test-plugin'
|
||||
|
|
@ -295,6 +297,14 @@ class TestLabelPrinterMachineType(InvenTreeAPITestCase):
|
|||
|
||||
self.assertIn('is not a valid choice', str(response.data['machine']))
|
||||
|
||||
def test_location_invalid_pk(self):
|
||||
"""Test that location property returns None for an invalid PK without raising."""
|
||||
machine = self.create_machine()
|
||||
|
||||
machine.set_setting('LOCATION', 'M', 999999)
|
||||
|
||||
self.assertIsNone(machine.location)
|
||||
|
||||
|
||||
class AdminTest(AdminTestCase):
|
||||
"""Tests for the admin interface integration."""
|
||||
|
|
|
|||
|
|
@ -590,6 +590,21 @@ class Order(
|
|||
"""Return the Address associated with this order."""
|
||||
return self.address or self.company.primary_address
|
||||
|
||||
@property
|
||||
def status_text(self):
|
||||
"""Return the text representation of the current status. This will consider any custom status."""
|
||||
if self.get_custom_status() is not None:
|
||||
from generic.states.custom import (
|
||||
get_logical_value as get_custom_state_logical_value,
|
||||
)
|
||||
|
||||
custom_status = get_custom_state_logical_value(
|
||||
self.get_custom_status(), model=self._meta.model_name
|
||||
)
|
||||
return custom_status.label
|
||||
else:
|
||||
return self.status_class.label(self.get_status())
|
||||
|
||||
@classmethod
|
||||
def get_status_class(cls):
|
||||
"""Return the enumeration class which represents the 'status' field for this model."""
|
||||
|
|
@ -692,11 +707,6 @@ class PurchaseOrder(TotalPriceMixin, Order):
|
|||
help_text=_('Purchase order status'),
|
||||
)
|
||||
|
||||
@property
|
||||
def status_text(self):
|
||||
"""Return the text representation of the status field."""
|
||||
return PurchaseOrderStatus.text(self.status)
|
||||
|
||||
supplier = models.ForeignKey(
|
||||
Company,
|
||||
on_delete=models.SET_NULL,
|
||||
|
|
@ -1443,11 +1453,6 @@ class SalesOrder(TotalPriceMixin, Order):
|
|||
help_text=_('Sales order status'),
|
||||
)
|
||||
|
||||
@property
|
||||
def status_text(self) -> str:
|
||||
"""Return the text representation of the status field."""
|
||||
return SalesOrderStatus.text(self.status)
|
||||
|
||||
customer_reference = models.CharField(
|
||||
max_length=64,
|
||||
blank=True,
|
||||
|
|
|
|||
|
|
@ -127,6 +127,14 @@ class AbstractOrderSerializer(
|
|||
# status field cannot be set directly
|
||||
status = serializers.IntegerField(read_only=True, label=_('Order Status'))
|
||||
|
||||
# can be set directly, but must be valid for the current order status
|
||||
status_custom_key = serializers.IntegerField(
|
||||
label=_('Custom Status Key'),
|
||||
help_text=_('Update order status to a custom value for this logical value'),
|
||||
allow_null=True,
|
||||
default=None,
|
||||
)
|
||||
|
||||
# Reference string is *required*
|
||||
reference = serializers.CharField(required=True)
|
||||
|
||||
|
|
@ -199,6 +207,31 @@ class AbstractOrderSerializer(
|
|||
self.Meta.model.validate_reference_field(reference)
|
||||
return reference
|
||||
|
||||
def validate_status_custom_key(self, value):
|
||||
"""Validate the status_custom_key field.
|
||||
|
||||
Ensure the custom status key is valid for the logical order status.
|
||||
"""
|
||||
if value is None:
|
||||
return value
|
||||
|
||||
from generic.states.custom import get_logical_value
|
||||
|
||||
if not isinstance(value, int):
|
||||
raise ValidationError(_('Custom status key must be an integer'))
|
||||
|
||||
try:
|
||||
custom_status = get_logical_value(
|
||||
value, model=self.Meta.model._meta.model_name
|
||||
)
|
||||
except:
|
||||
raise ValidationError(_('Invalid custom status key'))
|
||||
|
||||
if custom_status.logical_key is not self.instance.status:
|
||||
raise ValidationError(_('Invalid custom status key for this order status'))
|
||||
|
||||
return value
|
||||
|
||||
@staticmethod
|
||||
def annotate_queryset(queryset):
|
||||
"""Add extra information to the queryset."""
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import json
|
|||
from datetime import date, datetime, timedelta
|
||||
from typing import Optional
|
||||
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import connection
|
||||
from django.test.utils import CaptureQueriesContext
|
||||
|
|
@ -240,6 +241,77 @@ class PurchaseOrderTest(OrderTest):
|
|||
self.assertEqual(data['pk'], 1)
|
||||
self.assertEqual(data['description'], 'Ordering some screws')
|
||||
|
||||
def test_po_status_custom_key_options(self):
|
||||
"""Test that status_custom_key is exposed as writable in options."""
|
||||
self.assignRole('purchase_order.add')
|
||||
|
||||
response = self.options(self.LIST_URL, expected_code=200)
|
||||
post = response.data['actions']['POST']
|
||||
|
||||
self.assertIn('status_custom_key', post)
|
||||
self.assertEqual(post['status_custom_key']['required'], False)
|
||||
self.assertEqual(post['status_custom_key']['read_only'], False)
|
||||
|
||||
def test_po_status_custom_key_patch_valid(self):
|
||||
"""Test patching a valid custom status key for the current PO status."""
|
||||
self.assignRole('purchase_order.change')
|
||||
|
||||
po = models.PurchaseOrder.objects.get(pk=1)
|
||||
self.assertEqual(po.status, PurchaseOrderStatus.PENDING.value)
|
||||
|
||||
custom_status = InvenTreeCustomUserStateModel.objects.create(
|
||||
key=901,
|
||||
name='PO Pending Custom',
|
||||
label='PO Pending Custom',
|
||||
color='secondary',
|
||||
logical_key=PurchaseOrderStatus.PENDING.value,
|
||||
model=ContentType.objects.get_for_model(models.PurchaseOrder),
|
||||
reference_status='PurchaseOrderStatus',
|
||||
)
|
||||
|
||||
url = reverse('api-po-detail', kwargs={'pk': po.pk})
|
||||
response = self.patch(
|
||||
url, {'status_custom_key': custom_status.key}, expected_code=200
|
||||
)
|
||||
|
||||
self.assertEqual(response.data['status'], PurchaseOrderStatus.PENDING.value)
|
||||
self.assertEqual(response.data['status_custom_key'], custom_status.key)
|
||||
|
||||
def test_po_status_custom_key_patch_invalid(self):
|
||||
"""Test patching an invalid custom status key for a PO."""
|
||||
self.assignRole('purchase_order.change')
|
||||
|
||||
po = models.PurchaseOrder.objects.get(pk=1)
|
||||
url = reverse('api-po-detail', kwargs={'pk': po.pk})
|
||||
|
||||
response = self.patch(url, {'status_custom_key': 999999}, expected_code=400)
|
||||
|
||||
self.assertIn('status_custom_key', response.data)
|
||||
|
||||
def test_po_status_custom_key_patch_wrong_logical_status(self):
|
||||
"""Test patching a custom key mapped to a different logical status."""
|
||||
self.assignRole('purchase_order.change')
|
||||
|
||||
po = models.PurchaseOrder.objects.get(pk=1)
|
||||
self.assertEqual(po.status, PurchaseOrderStatus.PENDING.value)
|
||||
|
||||
custom_status = InvenTreeCustomUserStateModel.objects.create(
|
||||
key=902,
|
||||
name='PO Placed Custom',
|
||||
label='PO Placed Custom',
|
||||
color='secondary',
|
||||
logical_key=PurchaseOrderStatus.PLACED.value,
|
||||
model=ContentType.objects.get_for_model(models.PurchaseOrder),
|
||||
reference_status='PurchaseOrderStatus',
|
||||
)
|
||||
|
||||
url = reverse('api-po-detail', kwargs={'pk': po.pk})
|
||||
response = self.patch(
|
||||
url, {'status_custom_key': custom_status.key}, expected_code=400
|
||||
)
|
||||
|
||||
self.assertIn('status_custom_key', response.data)
|
||||
|
||||
def test_po_reference(self):
|
||||
"""Test that a reference with a too big / small reference is handled correctly."""
|
||||
# get permissions
|
||||
|
|
@ -1950,6 +2022,63 @@ class SalesOrderTest(OrderTest):
|
|||
reverse('api-so-detail', kwargs={'pk': 1}), ['customer_detail']
|
||||
)
|
||||
|
||||
def test_so_custom_status_query_count(self):
|
||||
"""Test that listing SalesOrders with custom statuses does not cause N+1 queries.
|
||||
|
||||
Ensures that resolving the 'status_text' field for custom status values
|
||||
is O(1) in database queries, not O(N) relative to the number of results.
|
||||
"""
|
||||
so_content_type = ContentType.objects.get_for_model(models.SalesOrder)
|
||||
|
||||
logical_keys = [
|
||||
SalesOrderStatus.PENDING.value,
|
||||
SalesOrderStatus.IN_PROGRESS.value,
|
||||
SalesOrderStatus.SHIPPED.value,
|
||||
SalesOrderStatus.ON_HOLD.value,
|
||||
SalesOrderStatus.COMPLETE.value,
|
||||
SalesOrderStatus.CANCELLED.value,
|
||||
SalesOrderStatus.PENDING.value,
|
||||
SalesOrderStatus.IN_PROGRESS.value,
|
||||
SalesOrderStatus.SHIPPED.value,
|
||||
SalesOrderStatus.ON_HOLD.value,
|
||||
]
|
||||
|
||||
custom_statuses = [
|
||||
InvenTreeCustomUserStateModel.objects.create(
|
||||
key=2000 + i,
|
||||
name=f'SoCustomStatus{i}',
|
||||
label=f'SO Custom Status Label {i}',
|
||||
color='secondary',
|
||||
logical_key=logical_keys[i],
|
||||
model=so_content_type,
|
||||
reference_status='SalesOrderStatus',
|
||||
)
|
||||
for i in range(10)
|
||||
]
|
||||
|
||||
customer = Company.objects.filter(is_customer=True).first()
|
||||
models.SalesOrder.objects.bulk_create([
|
||||
models.SalesOrder(
|
||||
customer=customer,
|
||||
reference=f'SO-QTEST-{i}',
|
||||
status=custom_statuses[i % 10].logical_key,
|
||||
status_custom_key=custom_statuses[i % 10].key,
|
||||
)
|
||||
for i in range(100)
|
||||
])
|
||||
|
||||
for limit in [1, 5, 10, 25, 50, 100]:
|
||||
response = self.get(
|
||||
self.LIST_URL,
|
||||
data={'limit': limit},
|
||||
expected_code=200,
|
||||
max_query_count=50,
|
||||
)
|
||||
|
||||
for result in response.data['results']:
|
||||
self.assertIn('status_text', result)
|
||||
self.assertIsNotNone(result['status_text'])
|
||||
|
||||
|
||||
class SalesOrderLineItemTest(OrderTest):
|
||||
"""Tests for the SalesOrderLineItem API."""
|
||||
|
|
|
|||
|
|
@ -523,7 +523,7 @@ class OrderTest(ExchangeRateMixin, PluginRegistryMixin, TestCase):
|
|||
|
||||
msg = messages.first()
|
||||
|
||||
self.assertEqual(msg.target_object_id, 1)
|
||||
self.assertEqual(msg.target_object_id, str(1))
|
||||
self.assertEqual(msg.name, 'Overdue Purchase Order')
|
||||
|
||||
def test_new_po_notification(self):
|
||||
|
|
|
|||
|
|
@ -68,6 +68,7 @@ logger = structlog.get_logger('inventree')
|
|||
|
||||
class PartCategory(
|
||||
InvenTree.models.PluginValidationMixin,
|
||||
InvenTree.models.InvenTreeParameterMixin,
|
||||
InvenTree.models.MetadataMixin,
|
||||
InvenTree.models.PathStringMixin,
|
||||
InvenTree.models.InvenTreeTree,
|
||||
|
|
|
|||
|
|
@ -103,6 +103,8 @@ class CategorySerializer(
|
|||
'structural',
|
||||
'icon',
|
||||
'parent_default_location',
|
||||
# Optional fields
|
||||
'parameters',
|
||||
]
|
||||
read_only_fields = ['level', 'pathstring']
|
||||
|
||||
|
|
@ -176,6 +178,8 @@ class CategorySerializer(
|
|||
|
||||
parent_default_location = serializers.IntegerField(read_only=True, allow_null=True)
|
||||
|
||||
parameters = common.filters.enable_parameters_filter()
|
||||
|
||||
|
||||
class CategoryTree(InvenTree.serializers.InvenTreeModelSerializer):
|
||||
"""Serializer for PartCategory tree."""
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import django_filters.rest_framework.filters as rest_filters
|
|||
from django_filters.rest_framework import DjangoFilterBackend
|
||||
from django_filters.rest_framework.filterset import FilterSet
|
||||
from drf_spectacular.utils import extend_schema
|
||||
from rest_framework import status
|
||||
from rest_framework import permissions, status
|
||||
from rest_framework.exceptions import NotFound
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
|
|
@ -174,7 +174,10 @@ class PluginDetail(RetrieveDestroyAPI):
|
|||
|
||||
queryset = PluginConfig.objects.all()
|
||||
serializer_class = PluginSerializers.PluginConfigSerializer
|
||||
permission_classes = [InvenTree.permissions.IsSuperuserOrReadOnlyOrScope]
|
||||
permission_classes = [
|
||||
permissions.IsAuthenticated,
|
||||
InvenTree.permissions.IsSuperuserOrReadOnlyOrScope,
|
||||
]
|
||||
lookup_field = 'key'
|
||||
lookup_url_kwarg = 'plugin'
|
||||
|
||||
|
|
@ -201,6 +204,7 @@ class PluginAdminDetail(RetrieveAPI):
|
|||
|
||||
queryset = PluginConfig.objects.all()
|
||||
serializer_class = PluginSerializers.PluginAdminDetailSerializer
|
||||
permission_classes = [InvenTree.permissions.IsAdminOrAdminScope]
|
||||
lookup_field = 'key'
|
||||
lookup_url_kwarg = 'plugin'
|
||||
|
||||
|
|
@ -292,7 +296,10 @@ class PluginSettingList(ListAPI):
|
|||
queryset = PluginSetting.objects.all()
|
||||
serializer_class = PluginSerializers.PluginSettingSerializer
|
||||
|
||||
permission_classes = [InvenTree.permissions.GlobalSettingsPermissions]
|
||||
permission_classes = [
|
||||
permissions.IsAuthenticated,
|
||||
InvenTree.permissions.GlobalSettingsPermissions,
|
||||
]
|
||||
|
||||
filter_backends = [DjangoFilterBackend]
|
||||
|
||||
|
|
@ -365,7 +372,10 @@ class PluginAllSettingList(APIView):
|
|||
- GET: return all settings for a plugin config
|
||||
"""
|
||||
|
||||
permission_classes = [InvenTree.permissions.GlobalSettingsPermissions]
|
||||
permission_classes = [
|
||||
permissions.IsAuthenticated,
|
||||
InvenTree.permissions.GlobalSettingsPermissions,
|
||||
]
|
||||
|
||||
@extend_schema(
|
||||
responses={200: PluginSerializers.PluginSettingSerializer(many=True)}
|
||||
|
|
@ -393,6 +403,11 @@ class PluginSettingDetail(RetrieveUpdateAPI):
|
|||
queryset = PluginSetting.objects.all()
|
||||
serializer_class = PluginSerializers.PluginSettingSerializer
|
||||
|
||||
permission_classes = [
|
||||
permissions.IsAuthenticated,
|
||||
InvenTree.permissions.GlobalSettingsPermissions,
|
||||
]
|
||||
|
||||
def get_object(self):
|
||||
"""Lookup the plugin setting object, based on the URL.
|
||||
|
||||
|
|
@ -415,9 +430,6 @@ class PluginSettingDetail(RetrieveUpdateAPI):
|
|||
setting_key, plugin=plugin.plugin_config()
|
||||
)
|
||||
|
||||
# Staff permission required
|
||||
permission_classes = [InvenTree.permissions.GlobalSettingsPermissions]
|
||||
|
||||
|
||||
class PluginUserSettingList(APIView):
|
||||
"""List endpoint for all user settings for a specific plugin.
|
||||
|
|
|
|||
|
|
@ -29,7 +29,13 @@ class PartStocktakeExportOptionsSerializer(serializers.Serializer):
|
|||
export_include_variant_items = serializers.BooleanField(
|
||||
default=False,
|
||||
label=_('Include Variant Items'),
|
||||
help_text=_('Include part variant stock in pricing calculations'),
|
||||
help_text=_('Include part variant stock in stocktake data'),
|
||||
)
|
||||
|
||||
export_exclude_zero_stock_entries = serializers.BooleanField(
|
||||
default=False,
|
||||
label=_('Exclude Zero Stock Entries'),
|
||||
help_text=_('Exclude parts with zero stock from the exported dataset'),
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -43,7 +49,7 @@ class PartStocktakeExporter(DataExportMixin, InvenTreePlugin):
|
|||
SLUG = 'inventree-stocktake-exporter'
|
||||
TITLE = _('Part Stocktake Exporter')
|
||||
DESCRIPTION = _('Exporter for part stocktake data')
|
||||
VERSION = '1.0.0'
|
||||
VERSION = '1.1.0'
|
||||
AUTHOR = _('InvenTree contributors')
|
||||
|
||||
ExportOptionsSerializer = PartStocktakeExportOptionsSerializer
|
||||
|
|
@ -78,6 +84,9 @@ class PartStocktakeExporter(DataExportMixin, InvenTreePlugin):
|
|||
'pk',
|
||||
'name',
|
||||
'IPN',
|
||||
'active',
|
||||
'component',
|
||||
'assembly',
|
||||
'description',
|
||||
'category',
|
||||
'allocated_to_build_orders',
|
||||
|
|
@ -124,26 +133,36 @@ class PartStocktakeExporter(DataExportMixin, InvenTreePlugin):
|
|||
export_pricing_data = context.get('export_pricing_data', True)
|
||||
include_external_items = context.get('export_include_external_items', False)
|
||||
include_variant_items = context.get('export_include_variant_items', False)
|
||||
exclude_zero_stock = context.get('export_exclude_zero_stock_entries', False)
|
||||
|
||||
data = super().export_data(
|
||||
queryset, serializer_class, headers, context, output, **kwargs
|
||||
)
|
||||
|
||||
if export_pricing_data:
|
||||
for row in data:
|
||||
quantity = Decimal(row.get('total_in_stock', 0))
|
||||
output_data = []
|
||||
|
||||
if not include_external_items:
|
||||
quantity -= Decimal(row.get('external_stock', 0))
|
||||
for row in data:
|
||||
quantity = Decimal(row.get('total_in_stock', 0))
|
||||
|
||||
if not include_variant_items:
|
||||
quantity -= Decimal(row.get('variant_stock', 0))
|
||||
if not include_external_items:
|
||||
quantity -= Decimal(row.get('external_stock', 0))
|
||||
|
||||
if quantity < 0:
|
||||
quantity = Decimal(0)
|
||||
if not include_variant_items:
|
||||
quantity -= Decimal(row.get('variant_stock', 0))
|
||||
|
||||
pricing_min = row.get('pricing_min', None)
|
||||
pricing_max = row.get('pricing_max', None)
|
||||
if quantity < 0:
|
||||
quantity = Decimal(0)
|
||||
|
||||
if exclude_zero_stock:
|
||||
continue
|
||||
|
||||
if export_pricing_data:
|
||||
pricing_min = row.get('pricing_min', None) or row.get(
|
||||
'pricing_max', None
|
||||
)
|
||||
pricing_max = row.get('pricing_max', None) or row.get(
|
||||
'pricing_min', None
|
||||
)
|
||||
|
||||
if pricing_min is not None:
|
||||
pricing_min = Decimal(pricing_min)
|
||||
|
|
@ -157,4 +176,6 @@ class PartStocktakeExporter(DataExportMixin, InvenTreePlugin):
|
|||
pricing_max * quantity, rounding=10
|
||||
)
|
||||
|
||||
return data
|
||||
output_data.append(row)
|
||||
|
||||
return output_data
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ class StocktakeExporterTest(InvenTreeAPITestCase):
|
|||
'Minimum Unit Cost',
|
||||
'Maximum Total Cost',
|
||||
],
|
||||
excluded_cols=['Active', 'External Stock', 'Variant Stock'],
|
||||
excluded_cols=['External Stock', 'Variant Stock'],
|
||||
)
|
||||
|
||||
# Now, with additional parameters specific to the plugin
|
||||
|
|
@ -60,7 +60,6 @@ class StocktakeExporterTest(InvenTreeAPITestCase):
|
|||
'External Stock',
|
||||
'Variant Stock',
|
||||
],
|
||||
excluded_cols=['Active'],
|
||||
)
|
||||
|
||||
# Finally, exclude pricing data entirely
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ from common.models import DataOutput
|
|||
from InvenTree.helpers import str2bool
|
||||
from plugin import InvenTreePlugin
|
||||
from plugin.mixins import LabelPrintingMixin, SettingsMixin
|
||||
from report.fetcher import InvenTreeURLFetcher
|
||||
from report.models import LabelTemplate
|
||||
|
||||
logger = structlog.get_logger('inventree')
|
||||
|
|
@ -168,7 +169,7 @@ class InvenTreeLabelSheetPlugin(LabelPrintingMixin, SettingsMixin, InvenTreePlug
|
|||
generated_file = ContentFile(html_data, 'labels.html')
|
||||
else:
|
||||
# Render HTML to PDF
|
||||
html = weasyprint.HTML(string=html_data)
|
||||
html = weasyprint.HTML(string=html_data, url_fetcher=InvenTreeURLFetcher())
|
||||
document = html.render().write_pdf()
|
||||
generated_file = ContentFile(document, 'labels.pdf')
|
||||
|
||||
|
|
|
|||
|
|
@ -147,6 +147,7 @@ def get_git_log(path):
|
|||
datetime.datetime.fromtimestamp(commit.author_time).isoformat(),
|
||||
commit.message.decode().split('\n')[0],
|
||||
]
|
||||
repo.close()
|
||||
except KeyError:
|
||||
logger.debug('No HEAD tag found in git repo at path %s', path)
|
||||
except NotGitRepository:
|
||||
|
|
|
|||
|
|
@ -1027,7 +1027,11 @@ class PluginsRegistry:
|
|||
data = md5()
|
||||
|
||||
# Hash for all loaded plugins
|
||||
for slug, plug in self.plugins.items():
|
||||
# Note: Sort by slug, so the hash is independent of discovery order.
|
||||
# Different processes can discover the same plugins in a different
|
||||
# order, and the hash must represent the registry *state*, not the
|
||||
# iteration order of any particular process.
|
||||
for slug, plug in sorted(self.plugins.items(), key=lambda item: item[0]):
|
||||
data.update(str(slug).encode())
|
||||
data.update(str(plug.name).encode())
|
||||
data.update(str(plug.version).encode())
|
||||
|
|
|
|||
|
|
@ -863,3 +863,48 @@ class PluginLockedSettingsTest(PluginMixin, InvenTreeAPITestCase):
|
|||
self.assertFalse(
|
||||
response.data['read_only'], msg=f'{key} should not be read_only'
|
||||
)
|
||||
|
||||
|
||||
class PluginUnauthenticatedAccessTest(PluginMixin, InvenTreeAPITestCase):
|
||||
"""Ensure plugin API endpoints reject unauthenticated requests.
|
||||
|
||||
Tests the four endpoints hardened on the permissions-fix branch:
|
||||
- PluginDetail (api-plugin-detail)
|
||||
- PluginSettingList (api-plugin-setting-list)
|
||||
- PluginAllSettingList (api-plugin-settings)
|
||||
- PluginSettingDetail (api-plugin-setting-detail)
|
||||
"""
|
||||
|
||||
superuser = True
|
||||
PLUGIN_SLUG = 'sample'
|
||||
SETTING_KEY = 'API_KEY'
|
||||
|
||||
def setUp(self):
|
||||
"""Activate sample plugin, then log out to simulate an anonymous client."""
|
||||
super().setUp()
|
||||
from plugin.registry import registry
|
||||
|
||||
registry.set_plugin_state(self.PLUGIN_SLUG, True)
|
||||
self.client.logout()
|
||||
|
||||
def test_plugin_detail_unauthenticated(self):
|
||||
"""GET /api/plugins/<slug>/ must return 401 for unauthenticated users."""
|
||||
url = reverse('api-plugin-detail', kwargs={'plugin': self.PLUGIN_SLUG})
|
||||
self.get(url, expected_code=401)
|
||||
|
||||
def test_plugin_setting_list_unauthenticated(self):
|
||||
"""GET /api/plugins/settings/ must return 401 for unauthenticated users."""
|
||||
self.get(reverse('api-plugin-setting-list'), expected_code=401)
|
||||
|
||||
def test_plugin_all_settings_unauthenticated(self):
|
||||
"""GET /api/plugins/<slug>/settings/ must return 401 for unauthenticated users."""
|
||||
url = reverse('api-plugin-settings', kwargs={'plugin': self.PLUGIN_SLUG})
|
||||
self.get(url, expected_code=401)
|
||||
|
||||
def test_plugin_setting_detail_unauthenticated(self):
|
||||
"""GET /api/plugins/<slug>/settings/<key>/ must return 401 for unauthenticated users."""
|
||||
url = reverse(
|
||||
'api-plugin-setting-detail',
|
||||
kwargs={'plugin': self.PLUGIN_SLUG, 'key': self.SETTING_KEY},
|
||||
)
|
||||
self.get(url, expected_code=401)
|
||||
|
|
|
|||
|
|
@ -538,6 +538,30 @@ class RegistryTests(TestQueryMixin, PluginRegistryMixin, TestCase):
|
|||
registry.registry_hash = 'abc'
|
||||
self.assertTrue(registry.check_reload())
|
||||
|
||||
def test_registry_hash_order_independence(self):
|
||||
"""Test that the registry hash does not depend on plugin iteration order.
|
||||
|
||||
Different processes (gunicorn workers, background worker, shell) can
|
||||
discover the same set of plugins in a different order. If the hash
|
||||
depends on iteration order, processes disagree about the hash for the
|
||||
same registry state, and ping-pong each other into endless reloads
|
||||
via check_reload.
|
||||
"""
|
||||
original_plugins = registry.plugins
|
||||
|
||||
# Reversing a dict with fewer than 2 entries would not change anything
|
||||
self.assertGreater(len(original_plugins), 1)
|
||||
|
||||
try:
|
||||
hash_original = registry.calculate_plugin_hash()
|
||||
|
||||
# Simulate a process which discovered the same plugins in reverse order
|
||||
registry.plugins = dict(reversed(list(original_plugins.items())))
|
||||
|
||||
self.assertEqual(hash_original, registry.calculate_plugin_hash())
|
||||
finally:
|
||||
registry.plugins = original_plugins
|
||||
|
||||
def test_builtin_mandatory_plugins(self):
|
||||
"""Test that mandatory builtin plugins are always loaded."""
|
||||
from plugin.models import PluginConfig
|
||||
|
|
|
|||
|
|
@ -7,8 +7,8 @@ from django.utils.translation import gettext_lazy as _
|
|||
from django.views.decorators.cache import never_cache
|
||||
|
||||
import django_filters.rest_framework.filters as rest_filters
|
||||
from django_filters.rest_framework import DjangoFilterBackend
|
||||
from django_filters.rest_framework.filterset import FilterSet
|
||||
from rest_framework.exceptions import PermissionDenied
|
||||
from rest_framework.generics import GenericAPIView
|
||||
from rest_framework.response import Response
|
||||
|
||||
|
|
@ -16,10 +16,11 @@ import InvenTree.permissions
|
|||
import report.helpers
|
||||
import report.models
|
||||
import report.serializers
|
||||
import users.permissions
|
||||
from common.models import DataOutput
|
||||
from common.serializers import DataOutputSerializer
|
||||
from InvenTree.api import meta_path
|
||||
from InvenTree.filters import InvenTreeSearchFilter
|
||||
from InvenTree.filters import SEARCH_ORDER_FILTER
|
||||
from InvenTree.mixins import ListCreateAPI, RetrieveUpdateDestroyAPI
|
||||
from plugin import PluginMixinEnum
|
||||
from plugin.builtin.labels.inventree_label import InvenTreeLabelPlugin
|
||||
|
|
@ -79,7 +80,7 @@ class ReportFilter(ReportFilterBase):
|
|||
"""Filter options."""
|
||||
|
||||
model = report.models.ReportTemplate
|
||||
fields = ['landscape']
|
||||
fields = ['landscape', 'merge', 'attach_to_model', 'enabled', 'model_type']
|
||||
|
||||
|
||||
class LabelFilter(ReportFilterBase):
|
||||
|
|
@ -89,7 +90,7 @@ class LabelFilter(ReportFilterBase):
|
|||
"""Filter options."""
|
||||
|
||||
model = report.models.LabelTemplate
|
||||
fields = []
|
||||
fields = ['enabled']
|
||||
|
||||
|
||||
class LabelPrint(GenericAPIView):
|
||||
|
|
@ -161,6 +162,14 @@ class LabelPrint(GenericAPIView):
|
|||
|
||||
template = serializer.validated_data['template']
|
||||
|
||||
model_class = template.get_model()
|
||||
if model_class and not users.permissions.check_user_permission(
|
||||
request.user, model_class, 'view'
|
||||
):
|
||||
raise PermissionDenied(
|
||||
_('You do not have permission to view this model type')
|
||||
)
|
||||
|
||||
if template.width <= 0 or template.height <= 0:
|
||||
raise ValidationError({'template': _('Invalid label dimensions')})
|
||||
|
||||
|
|
@ -174,7 +183,7 @@ class LabelPrint(GenericAPIView):
|
|||
|
||||
plugin = self.get_plugin_class(plugin_key, raise_error=True)
|
||||
|
||||
instances = template.get_model().objects.filter(pk__in=items)
|
||||
instances = model_class.objects.filter(pk__in=items)
|
||||
|
||||
# Sort the instances by the order of the provided items
|
||||
instances = sorted(instances, key=lambda item: items.index(item.pk))
|
||||
|
|
@ -236,9 +245,9 @@ class LabelTemplateList(TemplatePermissionMixin, LabelTemplateMixin, ListCreateA
|
|||
"""API endpoint for viewing list of LabelTemplate objects."""
|
||||
|
||||
filterset_class = LabelFilter
|
||||
filter_backends = [DjangoFilterBackend, InvenTreeSearchFilter]
|
||||
filter_backends = SEARCH_ORDER_FILTER
|
||||
search_fields = ['name', 'description']
|
||||
ordering_fields = ['name', 'enabled']
|
||||
ordering_fields = ['name', 'enabled', 'width', 'height']
|
||||
|
||||
|
||||
class LabelTemplateDetail(
|
||||
|
|
@ -261,9 +270,18 @@ class ReportPrint(GenericAPIView):
|
|||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
template = serializer.validated_data['template']
|
||||
|
||||
model_class = template.get_model()
|
||||
if model_class and not users.permissions.check_user_permission(
|
||||
request.user, model_class, 'view'
|
||||
):
|
||||
raise PermissionDenied(
|
||||
_('You do not have permission to view this model type')
|
||||
)
|
||||
|
||||
items = serializer.validated_data['items']
|
||||
|
||||
instances = template.get_model().objects.filter(pk__in=items)
|
||||
instances = model_class.objects.filter(pk__in=items)
|
||||
|
||||
# Sort the instances by the order of the provided items
|
||||
instances = sorted(instances, key=lambda item: items.index(item.pk))
|
||||
|
|
@ -322,7 +340,7 @@ class ReportTemplateList(TemplatePermissionMixin, ReportTemplateMixin, ListCreat
|
|||
"""API endpoint for viewing list of ReportTemplate objects."""
|
||||
|
||||
filterset_class = ReportFilter
|
||||
filter_backends = [DjangoFilterBackend, InvenTreeSearchFilter]
|
||||
filter_backends = SEARCH_ORDER_FILTER
|
||||
search_fields = ['name', 'description']
|
||||
ordering_fields = ['name', 'enabled']
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,61 @@
|
|||
"""WeasyPrint URL fetcher with security restrictions for report generation."""
|
||||
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import structlog
|
||||
from weasyprint.urls import URLFetcher
|
||||
|
||||
logger = structlog.get_logger('inventree')
|
||||
|
||||
|
||||
class InvenTreeURLFetcher(URLFetcher):
|
||||
"""WeasyPrint URL fetcher restricted to safe origins."""
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
"""Disable redirect following so a same-origin URL cannot be used for SSRF."""
|
||||
kwargs.setdefault('allow_redirects', False)
|
||||
super().__init__(**kwargs)
|
||||
|
||||
def fetch(self, url, headers=None):
|
||||
"""Validate *url* before delegating to the parent fetcher."""
|
||||
parsed = urlparse(url)
|
||||
scheme = parsed.scheme.lower()
|
||||
|
||||
if scheme in ('data', 'http', 'https'):
|
||||
self._validate_http_url(url, parsed)
|
||||
return super().fetch(url, headers)
|
||||
|
||||
if scheme == 'file':
|
||||
logger.warning("InvenTreeURLFetcher: blocked file:// URL: '%s'", url)
|
||||
raise ValueError(
|
||||
f'file:// URLs are not permitted in report templates: {url}'
|
||||
)
|
||||
|
||||
raise ValueError(f"URL scheme '{scheme}' is not permitted in report templates")
|
||||
|
||||
def _validate_http_url(self, url: str, parsed) -> None:
|
||||
"""Raise if HTTP/HTTPS fetching is disabled or the URL is an SSRF risk."""
|
||||
from common.settings import get_global_setting
|
||||
from InvenTree.helpers_model import validate_url_no_ssrf
|
||||
|
||||
if not parsed.netloc:
|
||||
# data: URIs — self-contained, no network access required.
|
||||
return
|
||||
|
||||
if not get_global_setting('REPORT_FETCH_URLS', cache=False):
|
||||
logger.warning(
|
||||
"InvenTreeURLFetcher: blocked URL '%s': remote fetching is disabled (REPORT_FETCH_URLS=False)",
|
||||
url,
|
||||
)
|
||||
raise ValueError(
|
||||
f'Remote URL fetching is disabled in report templates: {url}'
|
||||
)
|
||||
|
||||
try:
|
||||
validate_url_no_ssrf(url)
|
||||
except ValueError:
|
||||
logger.warning(
|
||||
"InvenTreeURLFetcher: blocked URL '%s': resolves to a private or reserved address",
|
||||
url,
|
||||
)
|
||||
raise
|
||||
|
|
@ -36,6 +36,8 @@ from plugin.registry import registry
|
|||
|
||||
try:
|
||||
from weasyprint import HTML
|
||||
|
||||
from report.fetcher import InvenTreeURLFetcher
|
||||
except OSError as err: # pragma: no cover
|
||||
print(f'OSError: {err}')
|
||||
print("Unable to import 'weasyprint' module.")
|
||||
|
|
@ -277,7 +279,9 @@ class ReportTemplateBase(
|
|||
bytes: PDF data
|
||||
"""
|
||||
html = self.render_as_string(instance, context=context, **kwargs)
|
||||
pdf = HTML(string=html).write_pdf(pdf_forms=True)
|
||||
pdf = HTML(string=html, url_fetcher=InvenTreeURLFetcher()).write_pdf(
|
||||
pdf_forms=True
|
||||
)
|
||||
|
||||
return pdf
|
||||
|
||||
|
|
|
|||
|
|
@ -110,7 +110,7 @@ class ReportPrintSerializer(serializers.Serializer):
|
|||
fields = ['template', 'items']
|
||||
|
||||
template = serializers.PrimaryKeyRelatedField(
|
||||
queryset=report.models.ReportTemplate.objects.all(),
|
||||
queryset=report.models.ReportTemplate.objects.filter(enabled=True),
|
||||
many=False,
|
||||
required=True,
|
||||
allow_null=False,
|
||||
|
|
@ -151,7 +151,7 @@ class LabelPrintSerializer(serializers.Serializer):
|
|||
super().__init__(*args, **kwargs)
|
||||
|
||||
template = serializers.PrimaryKeyRelatedField(
|
||||
queryset=report.models.LabelTemplate.objects.all(),
|
||||
queryset=report.models.LabelTemplate.objects.filter(enabled=True),
|
||||
many=False,
|
||||
required=True,
|
||||
allow_null=False,
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
import base64
|
||||
import logging
|
||||
import mimetypes
|
||||
from datetime import date, datetime
|
||||
from decimal import Decimal, InvalidOperation
|
||||
from io import BytesIO
|
||||
|
|
@ -288,13 +289,21 @@ def asset(filename: str, raise_error: bool = False) -> str | None:
|
|||
else:
|
||||
return None
|
||||
|
||||
# In debug mode, return a web URL to the asset file (rather than a local file path)
|
||||
# In debug mode, return a web URL to the asset file (rather than encoded data)
|
||||
if get_global_setting('REPORT_DEBUG_MODE', cache=False):
|
||||
return default_storage.url(str(full_path))
|
||||
|
||||
storage_path = default_storage.path(str(full_path))
|
||||
file_data = get_media_file_contents(full_path, raise_error=raise_error)
|
||||
|
||||
return f'file://{storage_path}'
|
||||
if not file_data:
|
||||
return None
|
||||
|
||||
mime_type, _encoding = mimetypes.guess_type(str(filename))
|
||||
if not mime_type:
|
||||
mime_type = 'application/octet-stream'
|
||||
|
||||
encoded = base64.b64encode(file_data).decode('ascii')
|
||||
return f'data:{mime_type};base64,{encoded}'
|
||||
|
||||
|
||||
@register.simple_tag()
|
||||
|
|
|
|||
|
|
@ -88,7 +88,9 @@ class ReportTagTest(PartImageTestMixin, InvenTreeTestCase):
|
|||
|
||||
self.debug_mode(False)
|
||||
asset = report_tags.asset('test.txt')
|
||||
self.assertEqual(asset, f'file://{settings.MEDIA_ROOT}/report/assets/test.txt')
|
||||
# Non-debug mode returns a base64 data: URI (no file:// URLs)
|
||||
self.assertIsNotNone(asset)
|
||||
self.assertTrue(asset.startswith('data:text/plain;base64,'))
|
||||
|
||||
# Test for attempted path traversal
|
||||
with self.assertRaises(ValidationError):
|
||||
|
|
|
|||
|
|
@ -2,12 +2,14 @@
|
|||
|
||||
import os
|
||||
from io import StringIO
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.apps import apps
|
||||
from django.conf import settings
|
||||
from django.core.cache import cache
|
||||
from django.core.files.base import ContentFile
|
||||
from django.core.files.storage import default_storage
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
|
||||
from pypdf import PdfReader
|
||||
|
|
@ -17,7 +19,7 @@ from build.models import Build
|
|||
from common.models import Attachment
|
||||
from common.settings import set_global_setting
|
||||
from InvenTree.unit_test import AdminTestCase, InvenTreeAPITestCase
|
||||
from order.models import ReturnOrder, SalesOrder
|
||||
from order.models import PurchaseOrder, ReturnOrder, SalesOrder
|
||||
from part.models import Part
|
||||
from plugin.registry import registry
|
||||
from report.models import LabelTemplate, ReportTemplate
|
||||
|
|
@ -731,9 +733,343 @@ class TestReportTest(PrintTestMixins, ReportTest):
|
|||
self.run_print_test(SalesOrder, 'salesorder', label=False)
|
||||
|
||||
|
||||
class ReportPrintPermissionTest(InvenTreeAPITestCase):
|
||||
"""Test that the report print endpoint checks VIEW permission on the associated model type."""
|
||||
|
||||
fixtures = [
|
||||
'category',
|
||||
'part',
|
||||
'company',
|
||||
'location',
|
||||
'supplier_part',
|
||||
'stock',
|
||||
'order',
|
||||
]
|
||||
|
||||
superuser = False
|
||||
roles = []
|
||||
|
||||
def setUp(self):
|
||||
"""Setup for permission tests."""
|
||||
cache.clear()
|
||||
apps.get_app_config('report').create_default_reports()
|
||||
return super().setUp()
|
||||
|
||||
def test_report_print_model_permission(self):
|
||||
"""A user without VIEW permission on the model type must receive 403; granting the role allows printing."""
|
||||
template = ReportTemplate.objects.filter(
|
||||
enabled=True, model_type='purchaseorder'
|
||||
).first()
|
||||
self.assertIsNotNone(template)
|
||||
|
||||
items = PurchaseOrder.objects.all()[:2]
|
||||
self.assertGreater(len(items), 0)
|
||||
|
||||
url = reverse('api-report-print')
|
||||
post_data = {'template': template.pk, 'items': [item.pk for item in items]}
|
||||
|
||||
# No roles assigned: expect permission denied
|
||||
self.post(url, data=post_data, expected_code=403)
|
||||
|
||||
# Grant view access to purchase orders
|
||||
self.assignRole('purchase_order.view')
|
||||
cache.clear()
|
||||
|
||||
# Should now succeed
|
||||
self.post(url, data=post_data, expected_code=201)
|
||||
|
||||
def test_report_print_disabled_template(self):
|
||||
"""Printing against a disabled report template must be rejected."""
|
||||
self.assignRole('purchase_order.view')
|
||||
cache.clear()
|
||||
|
||||
template = ReportTemplate.objects.filter(
|
||||
enabled=True, model_type='purchaseorder'
|
||||
).first()
|
||||
self.assertIsNotNone(template)
|
||||
|
||||
items = PurchaseOrder.objects.all()[:2]
|
||||
self.assertGreater(len(items), 0)
|
||||
|
||||
url = reverse('api-report-print')
|
||||
post_data = {'template': template.pk, 'items': [item.pk for item in items]}
|
||||
|
||||
# Enabled template: should succeed
|
||||
self.post(url, data=post_data, expected_code=201)
|
||||
|
||||
# Disable the template and retry: should be rejected
|
||||
template.enabled = False
|
||||
template.save()
|
||||
|
||||
self.post(url, data=post_data, expected_code=400)
|
||||
|
||||
|
||||
class LabelPrintPermissionTest(InvenTreeAPITestCase):
|
||||
"""Test that the label print endpoint checks VIEW permission on the associated model type."""
|
||||
|
||||
fixtures = ['category', 'part', 'company', 'location', 'supplier_part', 'stock']
|
||||
|
||||
superuser = False
|
||||
roles = []
|
||||
|
||||
def setUp(self):
|
||||
"""Setup for permission tests."""
|
||||
cache.clear()
|
||||
apps.get_app_config('report').create_default_labels()
|
||||
return super().setUp()
|
||||
|
||||
def test_label_print_model_permission(self):
|
||||
"""A user without VIEW permission on the model type must receive 403; granting the role allows printing."""
|
||||
template = LabelTemplate.objects.filter(enabled=True, model_type='part').first()
|
||||
self.assertIsNotNone(template)
|
||||
self.assertGreater(template.width, 0)
|
||||
self.assertGreater(template.height, 0)
|
||||
|
||||
items = Part.objects.all()[:2]
|
||||
self.assertGreater(len(items), 0)
|
||||
|
||||
url = reverse('api-label-print')
|
||||
post_data = {'template': template.pk, 'items': [item.pk for item in items]}
|
||||
|
||||
# No roles assigned: expect permission denied
|
||||
self.post(url, data=post_data, expected_code=403)
|
||||
|
||||
# Grant view access to parts
|
||||
self.assignRole('part.view')
|
||||
cache.clear()
|
||||
|
||||
# Should now succeed
|
||||
self.post(url, data=post_data, expected_code=201)
|
||||
|
||||
def test_label_print_disabled_template(self):
|
||||
"""Printing against a disabled label template must be rejected."""
|
||||
self.assignRole('part.view')
|
||||
cache.clear()
|
||||
|
||||
template = LabelTemplate.objects.filter(enabled=True, model_type='part').first()
|
||||
self.assertIsNotNone(template)
|
||||
self.assertGreater(template.width, 0)
|
||||
self.assertGreater(template.height, 0)
|
||||
|
||||
items = Part.objects.all()[:2]
|
||||
self.assertGreater(len(items), 0)
|
||||
|
||||
url = reverse('api-label-print')
|
||||
post_data = {'template': template.pk, 'items': [item.pk for item in items]}
|
||||
|
||||
# Enabled template: should succeed
|
||||
self.post(url, data=post_data, expected_code=201)
|
||||
|
||||
# Disable the template and retry: should be rejected
|
||||
template.enabled = False
|
||||
template.save()
|
||||
|
||||
self.post(url, data=post_data, expected_code=400)
|
||||
|
||||
|
||||
class AdminTest(AdminTestCase):
|
||||
"""Tests for the admin interface integration."""
|
||||
|
||||
def test_admin(self):
|
||||
"""Test the admin URL."""
|
||||
self.helper(model=ReportTemplate)
|
||||
|
||||
|
||||
class URLFetcherE2ETest(ReportTest):
|
||||
"""End-to-end test: the URL fetcher blocks malicious URLs during a real PDF render.
|
||||
|
||||
Extends ReportTest so that fixtures, auth, and default report templates are all
|
||||
available. The print task runs synchronously in test mode (no workers), so the
|
||||
render completes inline and log output is captured within the same request.
|
||||
"""
|
||||
|
||||
def test_file_url_blocked_in_render(self):
|
||||
"""A template embedding a file:// URL must still produce a PDF, but the URL must be blocked and logged."""
|
||||
from io import StringIO
|
||||
|
||||
# Upload a minimal report template that embeds a malicious file:// reference.
|
||||
html = (
|
||||
'<html><body>'
|
||||
'<img src="file:///etc/passwd">'
|
||||
'<p>Security test content</p>'
|
||||
'</body></html>'
|
||||
)
|
||||
template_io = StringIO(html)
|
||||
template_io.name = 'security_test_template.html'
|
||||
|
||||
response = self.post(
|
||||
reverse('api-report-template-list'),
|
||||
data={
|
||||
'name': 'Security Test',
|
||||
'description': 'Tests that file:// URLs are blocked during rendering',
|
||||
'template': template_io,
|
||||
'model_type': 'stockitem',
|
||||
},
|
||||
format=None,
|
||||
expected_code=201,
|
||||
)
|
||||
template_pk = response.data['pk']
|
||||
|
||||
item = StockItem.objects.first()
|
||||
self.assertIsNotNone(item)
|
||||
|
||||
# Render the template. WeasyPrint catches the ValueError from our fetcher and
|
||||
# continues, so the PDF is still generated — the blocked resource is just skipped.
|
||||
with self.assertLogs('inventree', level='WARNING') as captured:
|
||||
response = self.post(
|
||||
reverse('api-report-print'),
|
||||
{'template': template_pk, 'items': [item.pk]},
|
||||
expected_code=201,
|
||||
)
|
||||
|
||||
# A PDF output should have been produced despite the blocked resource.
|
||||
self.assertTrue(response.data['output'].endswith('.pdf'))
|
||||
|
||||
# The fetcher must have logged a warning identifying the blocked URL.
|
||||
blocked_warnings = [
|
||||
msg
|
||||
for msg in captured.output
|
||||
if 'blocked file://' in msg and '/etc/passwd' in msg
|
||||
]
|
||||
self.assertTrue(
|
||||
blocked_warnings, 'Expected a blocked file:// warning in the log output'
|
||||
)
|
||||
|
||||
def test_ssrf_url_blocked_in_render(self):
|
||||
"""A template embedding an HTTP URL to a private/reserved address must be blocked and logged."""
|
||||
from io import StringIO
|
||||
|
||||
# 127.0.0.1 is loopback — validate_url_no_ssrf rejects it regardless of port.
|
||||
html = (
|
||||
'<html><body>'
|
||||
'<img src="http://127.0.0.1/ssrf-probe">'
|
||||
'<p>Security test content</p>'
|
||||
'</body></html>'
|
||||
)
|
||||
template_io = StringIO(html)
|
||||
template_io.name = 'ssrf_test_template.html'
|
||||
|
||||
response = self.post(
|
||||
reverse('api-report-template-list'),
|
||||
data={
|
||||
'name': 'SSRF Test',
|
||||
'description': 'Tests that SSRF URLs are blocked during rendering',
|
||||
'template': template_io,
|
||||
'model_type': 'stockitem',
|
||||
},
|
||||
format=None,
|
||||
expected_code=201,
|
||||
)
|
||||
template_pk = response.data['pk']
|
||||
|
||||
item = StockItem.objects.first()
|
||||
self.assertIsNotNone(item)
|
||||
|
||||
# Render the template. WeasyPrint catches the ValueError from our fetcher and
|
||||
# continues, so the PDF is still generated — the blocked resource is just skipped.
|
||||
with self.assertLogs('inventree', level='WARNING') as captured:
|
||||
response = self.post(
|
||||
reverse('api-report-print'),
|
||||
{'template': template_pk, 'items': [item.pk]},
|
||||
expected_code=201,
|
||||
)
|
||||
|
||||
# A PDF output should have been produced despite the blocked resource.
|
||||
self.assertTrue(response.data['output'].endswith('.pdf'))
|
||||
|
||||
# The fetcher must have logged a warning for the blocked SSRF attempt.
|
||||
blocked_warnings = [
|
||||
msg
|
||||
for msg in captured.output
|
||||
if 'blocked URL' in msg and '127.0.0.1' in msg
|
||||
]
|
||||
self.assertTrue(
|
||||
blocked_warnings, 'Expected a blocked SSRF URL warning in the log output'
|
||||
)
|
||||
|
||||
def test_fetch_urls_disabled_blocks_http(self):
|
||||
"""When REPORT_FETCH_URLS is False, any http/https URL in a template must be blocked."""
|
||||
from io import StringIO
|
||||
|
||||
from common.settings import set_global_setting
|
||||
|
||||
# Use a publicly routable address so the test would reach the network if
|
||||
# our guard were absent — we want to confirm it is stopped by the setting,
|
||||
# not by a secondary SSRF IP check.
|
||||
html = (
|
||||
'<html><body>'
|
||||
'<img src="https://example.com/image.png">'
|
||||
'<p>Security test content</p>'
|
||||
'</body></html>'
|
||||
)
|
||||
template_io = StringIO(html)
|
||||
template_io.name = 'fetch_disabled_test_template.html'
|
||||
|
||||
response = self.post(
|
||||
reverse('api-report-template-list'),
|
||||
data={
|
||||
'name': 'Fetch Disabled Test',
|
||||
'description': 'Tests that HTTP fetching is blocked when REPORT_FETCH_URLS=False',
|
||||
'template': template_io,
|
||||
'model_type': 'stockitem',
|
||||
},
|
||||
format=None,
|
||||
expected_code=201,
|
||||
)
|
||||
template_pk = response.data['pk']
|
||||
|
||||
item = StockItem.objects.first()
|
||||
self.assertIsNotNone(item)
|
||||
|
||||
set_global_setting('REPORT_FETCH_URLS', False, change_user=None)
|
||||
|
||||
with self.assertLogs('inventree', level='WARNING') as captured:
|
||||
response = self.post(
|
||||
reverse('api-report-print'),
|
||||
{'template': template_pk, 'items': [item.pk]},
|
||||
expected_code=201,
|
||||
)
|
||||
|
||||
self.assertTrue(response.data['output'].endswith('.pdf'))
|
||||
|
||||
blocked_warnings = [
|
||||
msg
|
||||
for msg in captured.output
|
||||
if 'REPORT_FETCH_URLS' in msg and 'example.com' in msg
|
||||
]
|
||||
self.assertTrue(
|
||||
blocked_warnings, 'Expected a REPORT_FETCH_URLS warning in the log output'
|
||||
)
|
||||
|
||||
|
||||
class URLFetcherTest(TestCase):
|
||||
"""Tests for InvenTreeURLFetcher security restrictions."""
|
||||
|
||||
def setUp(self):
|
||||
"""Import fetcher for each test."""
|
||||
from report.fetcher import InvenTreeURLFetcher
|
||||
|
||||
self.fetcher = InvenTreeURLFetcher()
|
||||
|
||||
def test_file_url_blocked(self):
|
||||
"""file:// URLs must always be rejected regardless of path."""
|
||||
for url in [
|
||||
'file:///etc/passwd',
|
||||
'file:///proc/self/environ',
|
||||
f'file://{settings.MEDIA_ROOT}/report/assets/anything.png',
|
||||
f'file://{settings.STATIC_ROOT}/some/font.ttf',
|
||||
]:
|
||||
with self.assertRaises(ValueError, msg=f'Expected block for {url}'):
|
||||
self.fetcher.fetch(url)
|
||||
|
||||
def test_unknown_scheme_blocked(self):
|
||||
"""Non-http/data/file schemes must be rejected."""
|
||||
for url in ['ftp://example.com/file.txt', 'javascript://x']:
|
||||
with self.assertRaises(ValueError, msg=f'Expected block for {url}'):
|
||||
self.fetcher.fetch(url)
|
||||
|
||||
def test_data_uri_allowed(self):
|
||||
"""data: URIs must always be permitted."""
|
||||
with patch('weasyprint.urls.URLFetcher.fetch', return_value={}):
|
||||
self.fetcher.fetch('data:image/png;base64,abc123')
|
||||
self.fetcher.fetch('data:text/css;base64,abc123')
|
||||
|
|
|
|||
|
|
@ -101,16 +101,16 @@ blessed==1.44.0 \
|
|||
# via
|
||||
# -c src/backend/requirements.txt
|
||||
# -r src/backend/requirements.in
|
||||
boto3==1.43.17 \
|
||||
--hash=sha256:8cf48babdd52ff0e2d891dc661143780b361d3776a3be06cd719da0696995074 \
|
||||
--hash=sha256:f6b3862a0b14e237f9323223ee76b0563e87a6bbe6d94a42e7b008a901ba8950
|
||||
boto3==1.43.23 \
|
||||
--hash=sha256:5d26498702ffd021dc0d57d0eefcc7101cd995ea0ed08c057c9b631efccbaa48 \
|
||||
--hash=sha256:8afc058924ef8a5c62467fe2e1e2e0304c22018587a044714da89f9c602ba856
|
||||
# via
|
||||
# -c src/backend/requirements.txt
|
||||
# django-anymail
|
||||
# django-storages
|
||||
botocore==1.43.17 \
|
||||
--hash=sha256:27f4ecb80cf1e5be70415fc4a4d3db3907d41ef8178c9df822364f275427d375 \
|
||||
--hash=sha256:499af7c942ecfd404322974e82c6b5d05a8ea16e9f19320b353e16f401adc5b4
|
||||
botocore==1.43.23 \
|
||||
--hash=sha256:69ff3d951cb644d1d84db646663c7eb919dc9c0c47e5768e947c8a71121b3d77 \
|
||||
--hash=sha256:a6737c598750f330bfa8ef2be2d9fa84b5d2d643b6bbb0d22e129e03b7535df1
|
||||
# via
|
||||
# -c src/backend/requirements.txt
|
||||
# boto3
|
||||
|
|
@ -449,56 +449,56 @@ charset-normalizer==3.4.7 \
|
|||
# via
|
||||
# -c src/backend/requirements.txt
|
||||
# requests
|
||||
cryptography==48.0.0 \
|
||||
--hash=sha256:0890f502ddf7d9c6426129c3f49f5c0a39278ed7cd6322c8755ffca6ee675a13 \
|
||||
--hash=sha256:0c558d2cdffd8f4bbb30fc7134c74d2ca9a476f830bb053074498fbc86f41ed6 \
|
||||
--hash=sha256:16cd65b9330583e4619939b3a3843eec1e6e789744bb01e7c7e2e62e33c239c8 \
|
||||
--hash=sha256:18349bbc56f4743c8b12dc32e2bccb2cf83ee8b69a3bba74ef8ae857e26b3d25 \
|
||||
--hash=sha256:1e2d54c8be6152856a36f0882ab231e70f8ec7f14e93cf87db8a2ed056bf160c \
|
||||
--hash=sha256:22a5cb272895dce158b2cacdfdc3debd299019659f42947dbdac6f32d68fe832 \
|
||||
--hash=sha256:27241b1dc9962e056062a8eef1991d02c3a24569c95975bd2322a8a52c6e5e12 \
|
||||
--hash=sha256:2b4d59804e8408e2fea7d1fbaf218e5ec984325221db76e6a241a9abd6cdd95c \
|
||||
--hash=sha256:2eb992bbd4661238c5a397594c83f5b4dc2bc5b848c365c8f991b6780efcc5c7 \
|
||||
--hash=sha256:369a6348999f94bbd53435c894377b20ab95f25a9065c283570e70150d8abc3c \
|
||||
--hash=sha256:3cb07a3ed6431663cd321ea8a000a1314c74211f823e4177fefa2255e057d1ec \
|
||||
--hash=sha256:40ba1f85eaa6959837b1d51c9767e230e14612eea4ef110ee8854ada22da1bf5 \
|
||||
--hash=sha256:4defde8685ae324a9eb9d818717e93b4638ef67070ac9bc15b8ca85f63048355 \
|
||||
--hash=sha256:55b7718303bf06a5753dcdccf2f3945cf18ad7bffde41b61226e4db31ab89a9c \
|
||||
--hash=sha256:561215ea3879cb1cbbf272867e2efda62476f240fb58c64de6b393ae19246741 \
|
||||
--hash=sha256:58d00498e8933e4a194f3076aee1b4a97dfec1a6da444535755822fe5d8b0b86 \
|
||||
--hash=sha256:59baa2cb386c4f0b9905bd6eb4c2a79a69a128408fd31d32ca4d7102d4156321 \
|
||||
--hash=sha256:5a5ed8fde7a1d09376ca0b40e68cd59c69fe23b1f9768bd5824f54681626032a \
|
||||
--hash=sha256:5b012212e08b8dd5edc78ef54da83dd9892fd9105323b3993eff6bea65dc21d7 \
|
||||
--hash=sha256:5c3932f4436d1cccb036cb0eaef46e6e2db91035166f1ad6505c3c9d5a635920 \
|
||||
--hash=sha256:614d0949f4790582d2cc25553abd09dd723025f0c0e7c67376a1d77196743d6e \
|
||||
--hash=sha256:76341972e1eff8b4bea859f09c0d3e64b96ce931b084f9b9b7db8ef364c30eff \
|
||||
--hash=sha256:77a2ccbbe917f6710e05ba9adaa25fb5075620bf3ea6fb751997875aff4ae4bd \
|
||||
--hash=sha256:7995ef305d7165c3f11ae07f2517e5a4f1d5c18da1376a0a9ed496336b69e5f3 \
|
||||
--hash=sha256:7ce4bfae76319a532a2dc68f82cc32f5676ee792a983187dac07183690e5c66f \
|
||||
--hash=sha256:7e8eac43dfca5c4cccc6dad9a80504436fca53bb9bc3100a2386d730fbe6b602 \
|
||||
--hash=sha256:84cf79f0dc8b36ac5da873481716e87aef31fcfa0444f9e1d8b4b2cece142855 \
|
||||
--hash=sha256:8c7378637d7d88016fa6791c159f698b3d3eed28ebf844ac36b9dc04a14dae18 \
|
||||
--hash=sha256:8cd666227ef7af430aa5914a9910e0ddd703e75f039cef0825cd0da71b6b711a \
|
||||
--hash=sha256:906cbf0670286c6e0044156bc7d4af9cbb0ef6db9f73e52c3ec56ba6bdde5336 \
|
||||
--hash=sha256:9071196d81abc88b3516ac8cdfad32e2b66dd4a5393a8e68a961e9161ddc6239 \
|
||||
--hash=sha256:9249e3cd978541d665967ac2cb2787fd6a62bddf1e75b3e347a594d7dacf4f74 \
|
||||
--hash=sha256:984a20b0f62a26f48a3396c72e4bc34c66e356d356bf370053066b3b6d54634a \
|
||||
--hash=sha256:9be5aafa5736574f8f15f262adc81b2a9869e2cfe9014d52a44633905b40d52c \
|
||||
--hash=sha256:9c459db21422be75e2809370b829a87eb37f74cd785fc4aa9ea1e5f43b47cda4 \
|
||||
--hash=sha256:9ccdac7d40688ecb5a3b4a604b8a88c8002e3442d6c60aead1db2a89a041560c \
|
||||
--hash=sha256:a0e692c683f4df67815a2d258b324e66f4738bd7a96a218c826dce4f4bd05d8f \
|
||||
--hash=sha256:a5da777e32ffed6f85a7b2b3f7c5cbc88c146bfcd0a1d7baf5fcc6c52ee35dd4 \
|
||||
--hash=sha256:a64697c641c7b1b2178e573cbc31c7c6684cd56883a478d75143dbb7118036db \
|
||||
--hash=sha256:ad64688338ed4bc1a6618076ba75fd7194a5f1797ac60b47afe926285adb3166 \
|
||||
--hash=sha256:bd72e68b06bb1e96913f97dd4901119bc17f39d4586a5adf2d3e47bc2b9d58b5 \
|
||||
--hash=sha256:c17dfe85494deaeddc5ce251aebd1d60bbe6afc8b62071bb0b469431a000124f \
|
||||
--hash=sha256:c18684a7f0cc9a3cb60328f496b8e3372def7c5d2df39ac267878b05565aaaae \
|
||||
--hash=sha256:cc90c0b39b2e3c65ef52c804b72e3c58f8a04ab2a1871272798e5f9572c17d20 \
|
||||
--hash=sha256:db63bf618e5dea46c07de12e900fe1cdd2541e6dc9dbae772a70b7d4d4765f6a \
|
||||
--hash=sha256:ea8990436d914540a40ab24b6a77c0969695ed52f4a4874c5137ccf7045a7057 \
|
||||
--hash=sha256:ecde28a596bead48b0cfd2a1b4416c3d43074c2d785e3a398d7ec1fc4d0f7fbb \
|
||||
--hash=sha256:f5333311663ea94f75dd408665686aaf426563556bb5283554a3539177e03b8c \
|
||||
--hash=sha256:fdfef35d751d510fcef5252703621574364fec16418c4a1e5e1055248401054b
|
||||
cryptography==48.0.1 \
|
||||
--hash=sha256:08a597acce1ff37f347400087776599e2348a3a8bc53b44120e463cd274efe4a \
|
||||
--hash=sha256:09f73a725d582cef64b91281a322cd798d14a33b2b6f2b7ad9531dc336d84c02 \
|
||||
--hash=sha256:0df56b056bc17c1b7d6821dfa65216e62bd232d8ab05eb3db44e71d235651471 \
|
||||
--hash=sha256:0ee6ea481db1ab889cba043ec1eda17bb9c1ea79db6722f779c3667f9f70322f \
|
||||
--hash=sha256:15254441469dd6bf027039453288e2072124f8b6603563f5d759e1c9b69273fa \
|
||||
--hash=sha256:266f4ee051abb2f725b74ef8072b521ce1feacf685a3364fa6a6b45548db791a \
|
||||
--hash=sha256:2c37f2461406063b417837f5f3daab668652acd82423efcd7f0a9f04be972de1 \
|
||||
--hash=sha256:32143b24adb918f078134e1e230f1eb8cc04886b92c28b5f0041aaf3e5699225 \
|
||||
--hash=sha256:33842cf0888951cef5bc7ac724ab844a42044c1727b967b7f8997289a0464f92 \
|
||||
--hash=sha256:3752f2dbc8f07a30aad2932c986cea495b03bb554887828225da104f732852b6 \
|
||||
--hash=sha256:39489bfca54c7a1f6b297efcd8bc608ab92d16c4ca631b0cad4da46724588b24 \
|
||||
--hash=sha256:3e4a1a3232eef2e6c732827d5722db29a0cc8b27af2a4d865b094cf954be9ca1 \
|
||||
--hash=sha256:3fd2ca57062b241c856670b073487d2e86c4637937ca5601e48f97bf8e11fc8f \
|
||||
--hash=sha256:42fcd8e26fe555d9b3577a135f5091fefa0aa4e99129c23fb56787a1bd4ada72 \
|
||||
--hash=sha256:43c5835e2cb98c8733d86f57d6fc879b613f5c3478607281c3e36daffc6dd8a6 \
|
||||
--hash=sha256:48fe40804d4caa2288f24e70ca8c64c42dd826da0ad7e4f1b41b2128d679e6c8 \
|
||||
--hash=sha256:4ab0a343c807bbcd90c971cd1ecf072937cd01847a9e002bef88fb47ac6be577 \
|
||||
--hash=sha256:4fdc69f8e4316bcf0c8c8ec1f26f285d12e8142d88d96c876a59a03be3f6ae67 \
|
||||
--hash=sha256:6184ca7b174f28d7c703f1290d4b297217c45355f77a98f67e9b7f14549ac54a \
|
||||
--hash=sha256:66fd0771e7b9c6dcd44cf1120690d2338d16d72795cf40cae2786a39eba65429 \
|
||||
--hash=sha256:6b2c0c3e6ccf3ade7750f836ef3ee36eea250cc467d45c256895573ac08cc6f1 \
|
||||
--hash=sha256:735824ec41b7f74a7c45fb1591349333e4c696cb6c044e5f46356e560143e4cd \
|
||||
--hash=sha256:7e234ac052af99f2700826a5c29ea99d9c1b1f80341cde62d11c8154dc8e0bd9 \
|
||||
--hash=sha256:869c3b8a53bfe27147832df48b32adadf558249d50e76cb3769d40e986b13265 \
|
||||
--hash=sha256:86be3b1b0b6bf09482fb50a979c508d2950ed95f5621ec77f4e385962006b83a \
|
||||
--hash=sha256:86fe77abb1bd87afb251d4d02ada7ecf53a32cee9b67d976abb2e45a13297475 \
|
||||
--hash=sha256:88c852a0ae366e262e5a1744b685e6a433dc8788dd2a277e418bf4904203609d \
|
||||
--hash=sha256:8ace4507d1e6533c125f4fac754f8bb8b6a74c08e92179dabd7e16571a3efbf3 \
|
||||
--hash=sha256:92a46e1d638daa264ba2971c0b0489c9409787943efae4d60ffda3d091ef832c \
|
||||
--hash=sha256:9621de99d2da096006b629979efd8ae7eb2d8b822488d0c89ee4000c306c59b1 \
|
||||
--hash=sha256:9a49ca6c81417f6a5edb50375a60cccdd70fa0a91a5211829dbea74eba94d2ac \
|
||||
--hash=sha256:9bd3f92d76217892b15df84ca256c2c113d386fdda7a7d8691aeeced976507c6 \
|
||||
--hash=sha256:9de21387aa95e2a895823d0745b430bed4f33503ba9ab5e0b5311f33e37d66d2 \
|
||||
--hash=sha256:b024e784ad6c077ee0147b35ea9cbfc1e34e1fd4c1dcca214c2794d73a12df08 \
|
||||
--hash=sha256:b4e391975f038e66432328639620a4aff2d307513b004f1ca06d6225bced815c \
|
||||
--hash=sha256:b74ca3b8e5ecdd833bf6a002ca41b4793bb27fb8f1c06ffaf2643c9e9140e31b \
|
||||
--hash=sha256:b7a2d1a937a738a881737cec135a38bb61470589b17515b9f73f571d0ae10401 \
|
||||
--hash=sha256:b9a32b876490d66c8bcc9963ef220199569748434ab01a9d6aaeabf88e7f5158 \
|
||||
--hash=sha256:bd81490cd5801d755cf97bb68ac191f14b708470b1c7cf4580f669b9c9264cd8 \
|
||||
--hash=sha256:c1400da5e32a43253392277eac7490a60e497d810a63dd5608d71bbd7af507c9 \
|
||||
--hash=sha256:d069066deead00ac7f090be101be875a06855908f7ec004c27b8fefb4acfb411 \
|
||||
--hash=sha256:d5d30989c6917b478b5817902e85fddaea2261efa8648383d965381ccb9e1ac4 \
|
||||
--hash=sha256:df637c05205ea7c1d7fbcbe54bbfea648a52951155f997af13d895d0ecc96991 \
|
||||
--hash=sha256:e361afba8918070d376df76f408a4f67fec0ee9cff81a99e48fe9a233ef59e17 \
|
||||
--hash=sha256:eb86ce1af36fe65041b6db9a8bb064ee621a7e5fded0f80d475ec243477cd242 \
|
||||
--hash=sha256:f0d27a5696721ef7a672b8c810f6aded391058e0b9486e63e6d93baf765da691 \
|
||||
--hash=sha256:f2ceef93cb096aa3c4cc4b5c94ca6131f9196d28c64d6111533402a9b2054d41 \
|
||||
--hash=sha256:f817adc181390bd54f2f700107a7419040fb7c1bdf2fc26f36551a06a68c3345 \
|
||||
--hash=sha256:fe0180af5bf9236518a087e35bf2d9a347d5f5f51e63c579d683ddff424e3d46
|
||||
# via
|
||||
# -c src/backend/requirements.txt
|
||||
# -r src/backend/requirements.in
|
||||
|
|
@ -684,9 +684,9 @@ django-recurrence==1.14 \
|
|||
# via
|
||||
# -c src/backend/requirements.txt
|
||||
# django-ical
|
||||
django-redis==6.0.0 \
|
||||
--hash=sha256:20bf0063a8abee567eb5f77f375143c32810c8700c0674ced34737f8de4e36c0 \
|
||||
--hash=sha256:2d9cb12a20424a4c4dde082c6122f486628bae2d9c2bee4c0126a4de7fda00dd
|
||||
django-redis==7.0.0 \
|
||||
--hash=sha256:4b23aa6e0cd0937bb1242e9a463809e6004de3ca2150f34e986306bb6220d688 \
|
||||
--hash=sha256:e48491c862f4350b0747ceb1016700686fb93c4f4e0fb9c490fe6c6658ffd933
|
||||
# via
|
||||
# -c src/backend/requirements.txt
|
||||
# -r src/backend/requirements.in
|
||||
|
|
@ -719,9 +719,9 @@ django-storages[s3, sftp]==1.14.6 \
|
|||
# via
|
||||
# -c src/backend/requirements.txt
|
||||
# -r src/backend/requirements.in
|
||||
django-structlog==10.0.0 \
|
||||
--hash=sha256:4e3fa4a930697fb9b649470e389419bb73b916a1ecf4f4bf2f8727f5cbfdb002 \
|
||||
--hash=sha256:4f9db3cb7b308df6aa4afe1353d9c19d5bac757022ddbbb5c24f3d0d6a91a240
|
||||
django-structlog==10.1.0 \
|
||||
--hash=sha256:450842bfb03887fc14469570769e515d22ea67d753848a25cdbd05405382c090 \
|
||||
--hash=sha256:812f61dea873e559511f42c0f3a7cdc36d4472f6cc15460565a97e5cbe06a76d
|
||||
# via
|
||||
# -c src/backend/requirements.txt
|
||||
# -r src/backend/requirements.in
|
||||
|
|
@ -762,49 +762,49 @@ drf-spectacular==0.29.0 \
|
|||
# via
|
||||
# -c src/backend/requirements.txt
|
||||
# -r src/backend/requirements.in
|
||||
dulwich==1.2.5 \
|
||||
--hash=sha256:00b54a1d56ddbacdd8eadd6d4787a51b3a05fefa30eadbf9165fd283a00b90ed \
|
||||
--hash=sha256:0395b2c8924c3424bafe2d9c1edd5348cc4b21ce9c1d6655bf01f9a5c47164c8 \
|
||||
--hash=sha256:07cb75b58216440e2c170fff4f3d55a5f387358d9489863af8cb11f24ee37121 \
|
||||
--hash=sha256:1679b376433a0fc7f36586afda1d4ed7427afa7a79d4bf17e5014474eea69fa4 \
|
||||
--hash=sha256:1699a4cf8d44c174408325a9594a1498d05786cea34e3004c8732420ee1b8182 \
|
||||
--hash=sha256:1ba83ec3cfb4c506c277400357a51523c8258fa07b841ee06e8e1071da4cfed1 \
|
||||
--hash=sha256:1c151a7f3995ccf9d433a603b747e76141a7ebe7c385c8909e9f7e7a6422c28f \
|
||||
--hash=sha256:2f10dafa1ef5660b1331364bc8d68446448608a8d8f493ed0e260eaf5133e71c \
|
||||
--hash=sha256:2f90d68bfa97c4ca71de7507984365aefe27b6d248cb28dc99644d0f3ae8c60b \
|
||||
--hash=sha256:41ccffb0521f3f9ad73fac78772f321d731607336cee48911e7c26963459481c \
|
||||
--hash=sha256:46db47394ba8a95748ae739f5d3a5a3e1724a2f857bf2437bc71bfc0baaed91d \
|
||||
--hash=sha256:5108acead814d1de8b6262d6d8fb90af7e82f5a4d83788b6b48e39d01800a92f \
|
||||
--hash=sha256:517fb7e20f91d2bd48dc5de9edc90ff8974a5512ce7f243284b191f8be6344c3 \
|
||||
--hash=sha256:53599909d54a2fae49fcd50047f1daf4b8b9eda6a5500a08b71da689f5431c24 \
|
||||
--hash=sha256:556593fd11637f80f6018bee1916b1a84f5b420423b470ebb3f1a782ad6ef081 \
|
||||
--hash=sha256:5e067b7feceb7034bc99e7c7143a704f1d97d4be7027d9a0aa5a83c0657ff091 \
|
||||
--hash=sha256:66aded7d364341b55941973a1562323f25bd205f0809692b687ec36ccd31242c \
|
||||
--hash=sha256:6c683c0f4a062894b6826c61102d415dae86ade61a10003c82ccc2b91858d5fb \
|
||||
--hash=sha256:701a9ecf7a8a44f5e2459e46befa93530cf36a8b1ae3140aefc007db1d7d0207 \
|
||||
--hash=sha256:77d2d2e43ad975459491de1ebf47990c74ff17f12586c8561e9890239bc422db \
|
||||
--hash=sha256:827366331603150de5976d72dd456a3fd5fc91e856471dc1d10fd64758c05f02 \
|
||||
--hash=sha256:8929134acf4ff967203df7600b38535f9b5b590462067a7e30dbce01acb97af9 \
|
||||
--hash=sha256:8e55f36a7f52ba0976dd72100273523908b16fb9dda6ce96d9aa9df9cceed4cc \
|
||||
--hash=sha256:9008ef25cabd379cda4fa86000fc38ca14b72afe17db798a8c85c0b2b7ce4d1e \
|
||||
--hash=sha256:93d2d87acf75d60c5a2b8c5c8a45aff17bbbd00c17bdccb4ba013d3ab590a65f \
|
||||
--hash=sha256:9693d2c9e226b2ea855c1dc3a87e2f4d972f7523fc0f7924e5997e9f4c23d97f \
|
||||
--hash=sha256:9b6d234f1f91335e9f01d9daac42ddc2d2e5c2fdbe285d8eeef50353b283648a \
|
||||
--hash=sha256:9f3c98f5fa90a842c1f545463834f712aa2eed785fc3d5e42836c0df2d691bb6 \
|
||||
--hash=sha256:9fc113c1348c7eb22c4e8790f68b562bb4f42a721fafb813e89a57e9cd632040 \
|
||||
--hash=sha256:a5549f4afc973e0a15ea6b0244d57f848d3f3ee13dac557eb311024aebebf128 \
|
||||
--hash=sha256:a6620963196c49212c511cd909f367dacf771f199a27d116f357cc671ea956c7 \
|
||||
--hash=sha256:a70477c991e96cfe8fdd7c866e7251faf71b38bfeb51d6f27554c9cce1caabf3 \
|
||||
--hash=sha256:ade416833214f3ee13af9b0199fff4de00fa6e0fde3deced776532fd91df5515 \
|
||||
--hash=sha256:c65230abaa52c72093b70d3b499d5689d1d8f9627e88ad3c3b4f8154e86ac0c8 \
|
||||
--hash=sha256:ccc58f26a1b94bef255316311678b03854f7192069bdf11cf501a6c85f61b83c \
|
||||
--hash=sha256:d33bae2b3292ed0235522682316251658187f43b1ebad6cd2b127069b94afb3b \
|
||||
--hash=sha256:d46e35c473646efb3b2ff8032f37ac5b6d48da52a669577187d3796a6d5987a3 \
|
||||
--hash=sha256:d8f7ea8f47e38e5b0de3fab97e07e9c9161ffddc90b3964512cab2b7749df4e6 \
|
||||
--hash=sha256:dd9569bc26174a3437d749114d36c81fc6c7478b55370ae50125e34e9629e4fe \
|
||||
--hash=sha256:df4ac3746099562c8160d78d55bb2fa10c9ada7ef970af3e2536bd133cb7830e \
|
||||
--hash=sha256:e128cddeccae4146b556684a0d5426454fff5bfe7306862e5a8ce6b471568af4 \
|
||||
--hash=sha256:fa37da7ad16c47391016b5f984fb60e175e1ab0b478f04920fd6d1f61123ce4d
|
||||
dulwich==1.2.6 \
|
||||
--hash=sha256:04252b107a1600325f5f0301dde8b5b62f5bb51a0467e360070baddbb4edcea7 \
|
||||
--hash=sha256:116ac7decb923a473540bf813c1ceb061bef07209fad5fb002d867f1907f9393 \
|
||||
--hash=sha256:11b1f5a6a6075ab4f906dfb755c1d805c8c898ba4f4816b0fdb6123e113030ac \
|
||||
--hash=sha256:1c35c294acfc5a0a88d01d5db1abeba550bf6274bcc3fddbf8b365e9eea280da \
|
||||
--hash=sha256:204d14692fb1dd850ab773690f7530f4065f405e9e7dd3f85bdf92e9330ffa2d \
|
||||
--hash=sha256:21e2e9b81ab04ad83f2d4101ac515ef56ee08d06fd853c1a7ac255f20bb49963 \
|
||||
--hash=sha256:27db364f2f3cf5b0dddd44d6c2ae9a20f6021e2bae8b1268fa689076f0192244 \
|
||||
--hash=sha256:371394e2c6f3f9789cdc0abb965dae9bc62e79984b84f35339e9d466598c9fb0 \
|
||||
--hash=sha256:405cfd53a99374ff03aacdd7a86d6a07615feca072ed69721f49ae2ebaa3eab4 \
|
||||
--hash=sha256:493e2ea0f23a8e9aae8e3000a366d1fbf0ed2c13eaf8f41863f050c6392ef138 \
|
||||
--hash=sha256:4940fbf7cb37870686c63dfc7682e1afdab0e55b663bb614572909b68e775d31 \
|
||||
--hash=sha256:4cf80217e73a039614dde5ab2c74917833632912b788074bc7158058aafbf3e5 \
|
||||
--hash=sha256:5ff9f36c95deaf7eb5d6ccde4c68adbcb932a87e03c1b479a8d94d779e7cc5d2 \
|
||||
--hash=sha256:6993ad48f92dc38a43e3c1bf25efb03a62fc2cf4db86a2e904b6c7176dafc3d5 \
|
||||
--hash=sha256:6d9720d591052730775dcbf450f0cd5b35162f4eeb4754337a5d763326481b2f \
|
||||
--hash=sha256:6fcbb3dec5733898be2114476ff5abaa1dbb8a6d28ffbe492b3225a5a556197e \
|
||||
--hash=sha256:6fd9911fb57ee2d6eefaf895df65e1139fbc911fa560e959b38feabe5f15003f \
|
||||
--hash=sha256:72512e2a22df6fb65ba7b66f5037046019a12343f6e9e54f42bcc4a68ab3d628 \
|
||||
--hash=sha256:72ac4f3fc92d54115ba2d812263117d9577b17f4c62ae8f170c177515f62e9d3 \
|
||||
--hash=sha256:794a85b8b9d4ad57d02c8cb455735419ac50c0f2e3d26d83873e34abee58cb1b \
|
||||
--hash=sha256:79728d98e0ec184856d71fd0d55abbf5ac7345b5baea9f2d1533a4de9064e13d \
|
||||
--hash=sha256:7b4a2f497718bfe1a3b21f933ee27c111b9cea560c0b2d8a6d939e1b5f297f79 \
|
||||
--hash=sha256:7c187efaebb72146245ebcb872b89fdc99314fa37442119c5a5feb18af3f4b8a \
|
||||
--hash=sha256:824b7f5b22b128c1e1ad7c655e9790e2d75c7ab1ba1e40a708024193f1dc47a3 \
|
||||
--hash=sha256:82e8810e57f9651a624116e3fede33276f89406cb910f517b944105e284e6755 \
|
||||
--hash=sha256:8d8175dbe4feaf62bcafc8708448bfe223b4dfc71609be25c0cf2b0962abc36c \
|
||||
--hash=sha256:9139d0110580a3038048286e761e9be166ec40a2eb19218b41b75541c5d87a86 \
|
||||
--hash=sha256:9e357d825b82e7fec2b83cd8e50f3c099c14c1070e1df961bfefb83943dc1582 \
|
||||
--hash=sha256:ad4b6114440f9cf72315b173532ee3284f27a288b8a24bc27e45b2e54593720d \
|
||||
--hash=sha256:c60ddc8206e04e8e08208eac80130004eff0d587c82d398beeca7330cade061f \
|
||||
--hash=sha256:c639a8c9fb7e745749f2dcbd5b63a82df2fc99cfe62e2c3654ec025a42d2e51f \
|
||||
--hash=sha256:cb1f8d658f36b2ac3982715dc3e49f0d741a3e5a8c40136bebb6d8493968aa12 \
|
||||
--hash=sha256:cdd15b8442b527575d733d90cfd6d3c4cbaebf989e2298b0cb57a7916c66254f \
|
||||
--hash=sha256:dd2783352917b7cb3ab12b7c3f7757210d93af6df0bd2d876a8e5b53b2feb3eb \
|
||||
--hash=sha256:dd2b66c915f1b22ca6533b48e8ee435800b25f74f419c40e1a92271666d8b297 \
|
||||
--hash=sha256:e103584421b7205f022bd413a324ff26905ffa84fcc1536f5787bf554d5d390b \
|
||||
--hash=sha256:e995ad77b0685747bdb51f7a5cd7e6cb8efe73e29517b0f2c95fc2e6d10d5a90 \
|
||||
--hash=sha256:eb27a9ebe9029c872abadf4f9dcb18c9f6a4b7a4afe137f79a61df1ae59dc6bf \
|
||||
--hash=sha256:f682671a2e19b7b4caa572ff3073557de049a153946305e051a4f50bb0e5e1bd \
|
||||
--hash=sha256:f887643cf1c7a04e898547bd9f0acf6654d772ebd153012433ef950315dcf776 \
|
||||
--hash=sha256:fa7a089298fcbdaed493dd25c2f13574ccfc708f89a7aae8e3c25fd8393f5c81 \
|
||||
--hash=sha256:fae59c5e345f5ca234c85d157f1c7d5e0086126b45b5f7cfa66ffe41d049fdd6
|
||||
# via
|
||||
# -c src/backend/requirements.txt
|
||||
# -r src/backend/requirements.in
|
||||
|
|
@ -899,68 +899,58 @@ googleapis-common-protos==1.75.0 \
|
|||
# -c src/backend/requirements.txt
|
||||
# opentelemetry-exporter-otlp-proto-grpc
|
||||
# opentelemetry-exporter-otlp-proto-http
|
||||
grpcio==1.80.0 \
|
||||
--hash=sha256:00168469238b022500e486c1c33916acf2f2a9b2c022202cf8a1885d2e3073c1 \
|
||||
--hash=sha256:02e64bb0bb2da14d947a49e6f120a75e947250aebe65f9629b62bb1f5c14e6e9 \
|
||||
--hash=sha256:05d55e1798756282cddd52d56c896b3e7d673e3a8798c2f1cd05ba249a3bb4de \
|
||||
--hash=sha256:09e5e478b3d14afd23f12e49e8b44c8684ac3c5f08561c43a5b9691c54d136ab \
|
||||
--hash=sha256:0cb517eb1d0d0aaf1d87af7cc5b801d686557c1d88b2619f5e31fab3c2315921 \
|
||||
--hash=sha256:1b97cd29a8eda100b559b455331c487a80915b6ea6bd91cf3e89836c4ee8d957 \
|
||||
--hash=sha256:256507e2f524092f1473071a05e65a5b10d84b82e3ff24c5b571513cfaa61e2f \
|
||||
--hash=sha256:29aca15edd0688c22ba01d7cc01cb000d72b2033f4a3c72a81a19b56fd143257 \
|
||||
--hash=sha256:2bea16af2750fd0a899bf1abd9022244418b55d1f37da2202249ba4ba673838d \
|
||||
--hash=sha256:2dcc70e9f0ba987526e8e8603a610fb4f460e42899e74e7a518bf3c68fe1bf05 \
|
||||
--hash=sha256:2ed770b4c06984f3b47eb0517b1c69ad0b84ef3f40128f51448433be904634cd \
|
||||
--hash=sha256:31b9ac4ad1aa28ffee5503821fafd09e4da0a261ce1c1281c6c8da0423c83b6e \
|
||||
--hash=sha256:33eb763f18f006dc7fee1e69831d38d23f5eccd15b2e0f92a13ee1d9242e5e02 \
|
||||
--hash=sha256:367ce30ba67d05e0592470428f0ec1c31714cab9ef19b8f2e37be1f4c7d32fae \
|
||||
--hash=sha256:3b01e1f5464c583d2f567b2e46ff0d516ef979978f72091fd81f5ab7fa6e2e7f \
|
||||
--hash=sha256:3cb8130ba457d2aa09fa6b7c3ed6b6e4e6a2685fce63cb803d479576c4d80e21 \
|
||||
--hash=sha256:3d4147a97c8344d065d01bbf8b6acec2cf86fb0400d40696c8bdad34a64ffc0e \
|
||||
--hash=sha256:43168871f170d1e4ed16ae03d10cd21efa29f190e710a624cee7e5ae07da6f4f \
|
||||
--hash=sha256:448c884b668b868562b1bda833c5fce6272d26e1926ec46747cda05741d302c1 \
|
||||
--hash=sha256:4560cf0e86514595dbbd330cd65b7afad4b5c4b8c4905c041cfffa138d45e6fd \
|
||||
--hash=sha256:46c2390b59d67f84e882694d489f5b45707c657832d7934859ceb8c33f467069 \
|
||||
--hash=sha256:4e78c4ac0d97dc2e569b2f4bcbbb447491167cb358d1a389fc4af71ab6f70411 \
|
||||
--hash=sha256:4ed39fbdcf9b87370f6e8df4e39ca7b38b3e5e9d1b0013c7b6be9639d6578d14 \
|
||||
--hash=sha256:50a9871536d71c4fba24ee856abc03a87764570f0c457dd8db0b4018f379fed9 \
|
||||
--hash=sha256:51b4a7189b0bef2aa30adce3c78f09c83526cf3dddb24c6a96555e3b97340440 \
|
||||
--hash=sha256:52d143637e3872633fc7dd7c3c6a1c84e396b359f3a72e215f8bf69fd82084fc \
|
||||
--hash=sha256:5c07e82e822e1161354e32da2662f741a4944ea955f9f580ec8fb409dd6f6060 \
|
||||
--hash=sha256:627fb7312171cdc52828bd6fac8d7028ff2a64b89f1957b6f3416caa2218d141 \
|
||||
--hash=sha256:68e5851ac4b9afe07e7f84483803ad167852570d65326b34d54ca560bfa53fb6 \
|
||||
--hash=sha256:7b641fc3f1dc647bfd80bd713addc68f6d145956f64677e56d9ebafc0bd72388 \
|
||||
--hash=sha256:8502122a3cc1714038e39a0b071acb1207ca7844208d5ea0d091317555ee7106 \
|
||||
--hash=sha256:873ff5d17d68992ef6605330127425d2fc4e77e612fa3c3e0ed4e668685e3140 \
|
||||
--hash=sha256:886457a7768e408cdce226ad1ca67d2958917d306523a0e21e1a2fdaa75c9c9c \
|
||||
--hash=sha256:8ac393b58aa16991a2f1144ec578084d544038c12242da3a215966b512904d0f \
|
||||
--hash=sha256:8eb613f02d34721f1acf3626dfdb3545bd3c8505b0e52bf8b5710a28d02e8aa7 \
|
||||
--hash=sha256:92d787312e613754d4d8b9ca6d3297e69994a7912a32fa38c4c4e01c272974b0 \
|
||||
--hash=sha256:93b6f823810720912fd131f561f91f5fed0fda372b6b7028a2681b8194d5d294 \
|
||||
--hash=sha256:9a6284a5d907c37db53350645567c522be314bac859a64a7a5ca63b77bb7958f \
|
||||
--hash=sha256:9fe648599c0e37594c4809d81a9e77bd138cc82eb8baa71b6a86af65426723ff \
|
||||
--hash=sha256:a1dc80fe55685b4a543555e6eef975303b36c8db1023b1599b094b92aa77965f \
|
||||
--hash=sha256:a361c20ec1ccd3c3953d20fb6d7b4125093bdd10dff44c5e2bbb39e58917cedc \
|
||||
--hash=sha256:a72d84ad0514db063e21887fbacd1fd7acb4d494a564cae22227cd45c7fbf199 \
|
||||
--hash=sha256:aacdfb4ed3eb919ca997504d27e03d5dba403c85130b8ed450308590a738f7a4 \
|
||||
--hash=sha256:ba0915d51fd4ced2db5ff719f84e270afe0e2d4c45a7bdb1e8d036e4502928c2 \
|
||||
--hash=sha256:ba0db34f7e1d803a878284cd70e4c63cb6ae2510ba51937bf8f45ba997cefcf7 \
|
||||
--hash=sha256:bac1d573dfa84ce59a5547073e28fa7326d53352adda6912e362da0b917fcef4 \
|
||||
--hash=sha256:c51bf8ac4575af2e0678bccfb07e47321fc7acb5049b4482832c5c195e04e13a \
|
||||
--hash=sha256:c624cc9f1008361014378c9d776de7182b11fe8b2e5a81bc69f23a295f2a1ad0 \
|
||||
--hash=sha256:c71309cfce2f22be26aa4a847357c502db6c621f1a49825ae98aa0907595b193 \
|
||||
--hash=sha256:ce1794f4ea6cc3ca29463f42d665c32ba1b964b48958a66497917fe9069f26e6 \
|
||||
--hash=sha256:d334591df610ab94714048e0d5b4f3dd5ad1bee74dfec11eee344220077a79de \
|
||||
--hash=sha256:d8e11f167935b3eb089ac9038e1a063e6d7dbe995c0bb4a661e614583352e76f \
|
||||
--hash=sha256:dc053420fc75749c961e2a4c906398d7c15725d36ccc04ae6d16093167223b58 \
|
||||
--hash=sha256:deb10a1528473c11f72a0939eed36d83e847d7cbb63e8cc5611fb7a912d38614 \
|
||||
--hash=sha256:dfab85db094068ff42e2a3563f60ab3dddcc9d6488a35abf0132daec13209c8a \
|
||||
--hash=sha256:e172cf795a3ba5246d3529e4d34c53db70e888fa582a8ffebd2e6e48bc0cba50 \
|
||||
--hash=sha256:e9e408fc016dffd20661f0126c53d8a31c2821b5c13c5d67a0f5ed5de93319ad \
|
||||
--hash=sha256:ec0a592e926071b4abad50c1495cd0d0d513324b3ff5e7267067c33ba27506e4 \
|
||||
--hash=sha256:f14b618fc30de822681ee986cfdcc2d9327229dc4c98aed16896761cacd468b9 \
|
||||
--hash=sha256:f49eddcac43c3bf350c0385366a58f36bed8cc2c0ec35ef7b74b49e56552c0c2 \
|
||||
--hash=sha256:f7691a6788ad9196872f95716df5bc643ebba13c97140b7a5ee5c8e75d1dea81
|
||||
grpcio==1.81.0 \
|
||||
--hash=sha256:0fba53cb96004b2b7fb758b46b2288cb49d0b658316a4e73f3ef67230616ee65 \
|
||||
--hash=sha256:194eddfacc84d80f50512e9fd4ee851d5f2499f18f299c95aa8fb4748f0537e0 \
|
||||
--hash=sha256:19f201da7b4e5c0559198abe5a97157e726f3abe6e8f5e832d4a50740f6dcc22 \
|
||||
--hash=sha256:21ec30b9ea320c8207ea7cd05873ad64aa69fdd0e81b6758b3347983ba20b50a \
|
||||
--hash=sha256:275144b0115353339dbb8a6f28a9cf8997b5bf40e37f8f66ac0b0ea57e95b43f \
|
||||
--hash=sha256:300f3337b6425fd16ead9a4f9b2ac25801acb64aa5bc0b99eb69901645b2b1d2 \
|
||||
--hash=sha256:3755c9669307cad18e7e009860fdea98118978d2300451bd8530a53048e741e7 \
|
||||
--hash=sha256:3d4e0ce5a40a998cf608c8ba60ecfe18fdf364a9aa193ae4ac3faeecd0e86757 \
|
||||
--hash=sha256:40edffb4ec3689373825d367c4457727047a6e554f03245265ecc8cc03215f22 \
|
||||
--hash=sha256:43c121e135ae44d1559b430db2b2dfad7421cbbe40e1deba506c7dc62b439719 \
|
||||
--hash=sha256:4e032feb3bfb4e2749b140a2302a6baa8ead1b9781ff5cf7094e4402b5e9372e \
|
||||
--hash=sha256:5192857589f223e5a98ff0e31f6e551b19040e647d17bfe10116c8a2ce3b8696 \
|
||||
--hash=sha256:57b3b0e73a518fa286959b40c3eddd02703504ca186e8b7b2945954519bd8b2c \
|
||||
--hash=sha256:5e925a70fe99fe5794f7beca0ea034c75f068afcc356d79047e73f99cdcca34c \
|
||||
--hash=sha256:62bbe463c9f0f2ff24e31bd25f8dd8b4bae78900e315915a3195a0ef1471a855 \
|
||||
--hash=sha256:638ccc1b86f7540170a169cb900799b9296a1381e47879ce60b0de9d3db73d33 \
|
||||
--hash=sha256:725801c7086d7e4cd160e42bb2f54e0aeb976b9568df3cc6f843b15d29b79fb1 \
|
||||
--hash=sha256:77eb4e9fe61486bd1198cc7236ebb0f70e66234e63c0348f40bc2553ed16a88b \
|
||||
--hash=sha256:7915a2e63acdc05264a206e1bddfd8e1fb8a29e406c18d72d30f8c124e021374 \
|
||||
--hash=sha256:794e6aa648e8df47d8f908dc8c3b42347d04ec58438f1dcd4e445f09b4f6b0ce \
|
||||
--hash=sha256:8226ba097eed660ef14d36c6a69b85038552bb8b6d17b44a5aa6f9abf48b8e08 \
|
||||
--hash=sha256:87e33b7afcfb3585121b5f007d2c52b8c534104d18f556e840d35193ca2a9141 \
|
||||
--hash=sha256:8bb1789c94322a13336a2b6c58d9c14d68f8628b6e24205a799c69f5bf8516ce \
|
||||
--hash=sha256:8c0855a350886f713b9e458e2a10d208009dcaa849f574e39cd6067db1fe1279 \
|
||||
--hash=sha256:909bb3222b53235498d2c5817a0596d82b0aaea490ba93fdf1b060e2938a543c \
|
||||
--hash=sha256:97bbd623f7ded558fd4f7cb5a4f600c4d4de65c5dd364c83a5b14b2a10a2d3b5 \
|
||||
--hash=sha256:98c6240f563178fc5877bd50e6ff274463e53e1472128f4110742450739659fa \
|
||||
--hash=sha256:9f355384e5543ab77a755a7085225ecc19f32b76032e851cbd8145715d79dec8 \
|
||||
--hash=sha256:a524cd530900bd24511fcb7f2ed144da4ea37711c4b094475d0bceca7a93a170 \
|
||||
--hash=sha256:a5acd7efd3b1fe9b4eb0bcaaa1507eed68a0ad0678b654c3f7b464df9ba9dca5 \
|
||||
--hash=sha256:a9351055f52660b58f3d4890ea66188b5134399f82b11aa0c55bd4b99eff5390 \
|
||||
--hash=sha256:aa948712c8e5fa40ec250870bda14bc7578e1bb832a8912d9d2a0f720518edbe \
|
||||
--hash=sha256:aaaa4f7f2057d795952e4eacf3f342be8b5b156992f6ac85023c8b98794ebd47 \
|
||||
--hash=sha256:b4108e5d9d0f651b7eea749116181fe6c315b145661a80ec31f05ec2dbe21af7 \
|
||||
--hash=sha256:b76ea9d55cd08fcdbda25d28e0f76679536710acb7fbd5b1f70cb4ac49317265 \
|
||||
--hash=sha256:b8b025b6af43ee0ad4a70307025d77bcab5adde7c4597786010d802c203e9fc5 \
|
||||
--hash=sha256:b93cee313cae4e113fbb3a0ce1ea5633db6f63cfde2b2dc1d817429026b2a50b \
|
||||
--hash=sha256:c197e2ef75a442528072b29e9755da299110e8610e8bcbb59a6b4cf55384f005 \
|
||||
--hash=sha256:c36f5d5e97944cbda2d4096b4ae262e6e68506246b61582acf1b8591607f3ccc \
|
||||
--hash=sha256:c4fe218c5a35e1d87a5a26544237f1fa41dfd9cbd3c856b0810a30061f8b0aaf \
|
||||
--hash=sha256:c6ff087cb1f563f47b504b4e29e684129fc5ae4863faf3ebca08a327764ee6cb \
|
||||
--hash=sha256:cd78145b7f7784661c524624f3526c9c6f891b30a4b54cb93a40806d0d0d61e9 \
|
||||
--hash=sha256:db217c2e52931719f9937bd12082cd4d7b495b35803d5760686975c285924bf8 \
|
||||
--hash=sha256:dbdb99986548a7e87f8343805ef315fd4eb50ffaabf4fb1206e42f2542bb805d \
|
||||
--hash=sha256:e4d053900a0d24b75d7521139a3872150301b3d6bde3bed5e12318fb25791e4d \
|
||||
--hash=sha256:e7746ba3e6efc9e2b748eff59470a2b8684d5a9ec607c6580bcaa5be175820bc \
|
||||
--hash=sha256:f345de40ef2e65f63645d53d251824e6070e07804827c5b00ec2e44555f9f901 \
|
||||
--hash=sha256:f750a091fff3a3991731abc1f818bdc64874bb3528162732cb4d45f2e07821a6 \
|
||||
--hash=sha256:f85570a016d794c29b1e76cf22f67af4486ddbe779e0f30674f138fa4e1769ec \
|
||||
--hash=sha256:fbbe81314a9d92156abce8b62c09364eb8bafc0ca2a19919a45ec64b5c6cb664 \
|
||||
--hash=sha256:ff83d889e3ebf6341c8c7864ad8031591ad5ca61599072fc511644d1eb962d2b
|
||||
# via
|
||||
# -c src/backend/requirements.txt
|
||||
# -r src/backend/requirements.in
|
||||
|
|
@ -977,9 +967,9 @@ icalendar==7.1.2 \
|
|||
# via
|
||||
# -c src/backend/requirements.txt
|
||||
# django-ical
|
||||
idna==3.17 \
|
||||
--hash=sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c \
|
||||
--hash=sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f
|
||||
idna==3.18 \
|
||||
--hash=sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2 \
|
||||
--hash=sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848
|
||||
# via
|
||||
# -c src/backend/requirements.txt
|
||||
# django-anymail
|
||||
|
|
@ -1717,9 +1707,9 @@ pynacl==1.6.2 \
|
|||
# via
|
||||
# -c src/backend/requirements.txt
|
||||
# paramiko
|
||||
pypdf==6.12.2 \
|
||||
--hash=sha256:111669eb6680c04495ae0c113a1476e3bf93a95761d23c7406b591c80a6490b1 \
|
||||
--hash=sha256:67b2699357a1f3f4c945940ea80826349ee507c9e2577724a14b4941982c104d
|
||||
pypdf==6.13.0 \
|
||||
--hash=sha256:558683ec9daf6b91c280c322c84c32f5cc216afd3eaa3a37de5ae88ae0c3b787 \
|
||||
--hash=sha256:de1294ae49d6956edb4e5c41527fb9e8716ddd2b120f2185c68aab784d4ffe60
|
||||
# via
|
||||
# -c src/backend/requirements.txt
|
||||
# -r src/backend/requirements.in
|
||||
|
|
@ -2110,9 +2100,9 @@ s3transfer==0.18.0 \
|
|||
# via
|
||||
# -c src/backend/requirements.txt
|
||||
# boto3
|
||||
sentry-sdk==2.61.0 \
|
||||
--hash=sha256:1ca9b4bb777eb5be67004edab7eb894f21c6301f1d05ed64966719ad5d1764ce \
|
||||
--hash=sha256:ec4d30273909cb1d198e03208b16ee70e2bc5d90a16fd9f1fb2fc6a72e1f03dc
|
||||
sentry-sdk==2.61.1 \
|
||||
--hash=sha256:9c6adccb3feefa9ba032c8d295ca477575c2f11896046a2b0ad686c47c4af555 \
|
||||
--hash=sha256:fa36eaf4b8ad708f718500d4bdcc1532637526a22beb874d88cbc0a46458b5ae
|
||||
# via
|
||||
# -c src/backend/requirements.txt
|
||||
# -r src/backend/requirements.in
|
||||
|
|
@ -2167,9 +2157,9 @@ tinyhtml5==2.1.0 \
|
|||
# via
|
||||
# -c src/backend/requirements.txt
|
||||
# weasyprint
|
||||
tqdm==4.67.3 \
|
||||
--hash=sha256:7d825f03f89244ef73f1d4ce193cb1774a8179fd96f31d7e1dcde62092b960bb \
|
||||
--hash=sha256:ee1e4c0e59148062281c49d80b25b67771a127c85fc9676d3be5f243206826bf
|
||||
tqdm==4.68.1 \
|
||||
--hash=sha256:fc163d96b287bd031e1aa24421ce4411b25559bd0a1be4fe649bdaa4d2c02bf5 \
|
||||
--hash=sha256:fea4a90e4023f764914569f7802a297277c5ab1a66be5144143e142e1a4031d8
|
||||
# via
|
||||
# -c src/backend/requirements.txt
|
||||
# -r src/backend/requirements.in
|
||||
|
|
@ -2218,9 +2208,9 @@ wcwidth==0.7.0 \
|
|||
# -c src/backend/requirements.txt
|
||||
# blessed
|
||||
# prettytable
|
||||
weasyprint==68.1 \
|
||||
--hash=sha256:4dc3ba63c68bbbce3e9617cb2226251c372f5ee90a8a484503b1c099da9cf5be \
|
||||
--hash=sha256:d3b752049b453a5c95edb27ce78d69e9319af5a34f257fa0f4c738c701b4184e
|
||||
weasyprint==69.0 \
|
||||
--hash=sha256:475951cfd917014de6d4d005caff48c6aa867e7e42b80cd5b16a0484a1609ee6 \
|
||||
--hash=sha256:a7a32f39ca16bd82ef11de99c92ea4b5f14951c9033af035e451ce4f4ee0a88c
|
||||
# via
|
||||
# -c src/backend/requirements.txt
|
||||
# -r src/backend/requirements.in
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue