Merge branch 'master' into matmair/issue11460

This commit is contained in:
Matthias Mair 2026-06-17 23:33:57 +02:00 committed by GitHub
commit f13e20f784
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
192 changed files with 144306 additions and 134652 deletions

View File

@ -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

View File

@ -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 }}

View File

@ -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

View File

@ -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

View File

@ -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 🌈

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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.

View File

@ -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

View File

@ -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 \

View File

@ -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

View File

@ -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.

View File

@ -1,5 +1,5 @@
---
title: Report and LabelGeneration
title: Report and Label Generation
---
## Custom Reports

View File

@ -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

View File

@ -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

View File

@ -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 \

View File

@ -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."""

View File

@ -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)

View File

@ -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

View File

@ -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()

View File

@ -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.'

View File

@ -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')

View File

@ -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

View File

@ -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(

View File

@ -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

View File

@ -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),
),
]

View File

@ -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')

View File

@ -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)

View File

@ -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'),

View File

@ -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())

View File

@ -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."""

View File

@ -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()

View File

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

View File

@ -152,6 +152,36 @@ class DataImportSession(models.Model):
return supported_models().get(self.model_type, None)
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)

View File

@ -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)

View File

@ -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

View File

@ -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()

View File

@ -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."""

View File

@ -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,

View File

@ -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."""

View File

@ -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."""

View File

@ -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):

View File

@ -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,

View File

@ -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."""

View File

@ -10,7 +10,7 @@ import django_filters.rest_framework.filters as rest_filters
from django_filters.rest_framework import DjangoFilterBackend
from django_filters.rest_framework.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.

View File

@ -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

View File

@ -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

View File

@ -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')

View File

@ -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:

View File

@ -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())

View File

@ -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)

View File

@ -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

View File

@ -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']

View File

@ -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

View File

@ -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

View File

@ -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,

View File

@ -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()

View File

@ -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):

View File

@ -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')

View File

@ -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