2
0
mirror of https://github.com/inventree/InvenTree.git synced 2026-07-05 22:52:33 +00:00

Merge branch 'master' of https://github.com/inventree/InvenTree into feat--matrix-testing

This commit is contained in:
Matthias Mair
2026-01-19 09:05:38 +01:00
194 changed files with 82100 additions and 80171 deletions
+1 -1
View File
@@ -171,7 +171,7 @@ jobs:
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # pin@v3.7.0
- name: Set up Docker Buildx
if: github.event_name != 'pull_request'
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # pin@v3.11.1
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # pin@v3.12.0
- name: Set up cosign
if: github.event_name != 'pull_request'
uses: sigstore/cosign-installer@faadad0cce49287aee09b3a48701e75088a2c6ad # pin@v4.0.0
+45 -19
View File
@@ -39,6 +39,7 @@ jobs:
force: ${{ steps.force.outputs.force }}
cicd: ${{ steps.filter.outputs.cicd }}
requirements: ${{ steps.filter.outputs.requirements }}
runner-perf: ${{ steps.runner-perf.outputs.runner }}
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # pin@v6.0.1
@@ -74,6 +75,10 @@ jobs:
if: |
contains(github.event.pull_request.labels.*.name, 'dependency') ||
contains(github.event.pull_request.labels.*.name, 'full-run')
- name: Which runner to use?
id: runner-perf
# decide if we are running in inventree/inventree -> use codspeed-macro runner else ubuntu-24.04
run: echo "runner=$([[ '${{ github.repository }}' == 'inventree/InvenTree' ]] && echo 'codspeed-macro' || echo 'ubuntu-24.04')" >> $GITHUB_OUTPUT
pre-commit:
name: Style [pre-commit]
@@ -116,7 +121,7 @@ jobs:
update: true
- name: Check types
run: |
ty check --python ${Python_ROOT_DIR}/bin/python3
ty check --python ${Python_ROOT_DIR}/bin/python3 src/backend
mkdocs:
name: Style [Documentation]
@@ -176,7 +181,7 @@ jobs:
- name: Export API Documentation
run: invoke dev.schema --ignore-warnings --filename src/backend/InvenTree/schema.yml
- name: Upload schema
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # pin@v5.0.0
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # pin@v6.0.0
with:
name: schema.yml
path: src/backend/InvenTree/schema.yml
@@ -225,17 +230,17 @@ jobs:
- name: Extract settings / tags
run: invoke int.export-definitions --basedir docs
- name: Upload settings
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # pin@v5.0.0
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # pin@v6.0.0
with:
name: inventree_settings.json
path: docs/generated/inventree_settings.json
- name: Upload tags
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # pin@v5.0.0
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # pin@v6.0.0
with:
name: inventree_tags.yml
path: docs/generated/inventree_tags.yml
- name: Upload filters
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # pin@v5.0.0
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # pin@v6.0.0
with:
name: inventree_filters.yml
path: docs/generated/inventree_filters.yml
@@ -258,7 +263,7 @@ jobs:
- name: Create artifact directory
run: mkdir -p artifact
- name: Download schema artifact
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # pin@v6.0.0
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # pin@v7.0.0
with:
path: artifact
merge-multiple: true
@@ -275,17 +280,20 @@ jobs:
echo "after move"
ls -la artifact
rm -rf artifact
- uses: stefanzweifel/git-auto-commit-action@28e16e81777b558cc906c8750092100bbb34c5e3 # pin@v7.0.0
- uses: stefanzweifel/git-auto-commit-action@04702edda442b2e678b25b537cec683a1493fcb9 # pin@v7.1.0
name: Commit schema changes
with:
commit_message: "Update API schema for ${{ env.version }} / ${{ github.sha }}"
python:
name: Tests - inventree-python
runs-on: ubuntu-24.04
runs-on: ${{ needs.paths-filter.outputs.runner-perf }}
needs: ["pre-commit", "paths-filter"]
if: needs.paths-filter.outputs.server == 'true' || needs.paths-filter.outputs.force == 'true'
permissions:
contents: read
id-token: write
env:
WRAPPER_NAME: inventree-python
@@ -300,6 +308,7 @@ jobs:
INVENTREE_SITE_URL: http://127.0.0.1:12345
INVENTREE_DEBUG: true
INVENTREE_LOG_LEVEL: WARNING
node_version: '>=20.19.6'
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # pin@v6.0.1
@@ -311,6 +320,7 @@ jobs:
apt-dependency: gettext poppler-utils
dev-install: true
update: true
npm: true
- name: Download Python Code For `${WRAPPER_NAME}`
run: git clone --depth 1 https://github.com/inventree/${WRAPPER_NAME} ./${WRAPPER_NAME}
- name: Start InvenTree Server
@@ -324,6 +334,18 @@ jobs:
cd ${WRAPPER_NAME}
invoke check-server
coverage run -m unittest discover -s test/
- name: Prepare environment for performance tests
run: |
pip uninstall pytest-django -y
cd ${WRAPPER_NAME}
pip install .
- name: Performance Reporting
uses: CodSpeedHQ/action@dbda7111f8ac363564b0c51b992d4ce76bb89f2f # pin@v4
# check if we are in inventree/inventree - reporting only works in that OIDC context
if: github.repository == 'inventree/InvenTree'
with:
mode: walltime
run: pytest ./src/performance --codspeed
coverage:
name: Tests - DB [SQLite] + Coverage ${{ matrix.python_version }}
@@ -363,13 +385,13 @@ jobs:
- name: Coverage Tests
run: invoke dev.test --check --coverage --translations
- name: Upload raw coverage to artifacts
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # pin@v5.0.0
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # pin@v6.0.0
with:
name: coverage
path: .coverage
retention-days: 14
- name: Upload coverage reports to Codecov
uses: codecov/codecov-action@5a1091511ad55cbe89839c7260b706298ca349f7 # pin@v5.5.1
uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # pin@v5.5.2
if: always()
with:
token: ${{ secrets.CODECOV_TOKEN }}
@@ -378,10 +400,11 @@ jobs:
performance:
name: Tests - Performance
runs-on: ubuntu-24.04
runs-on: ${{ needs.paths-filter.outputs.runner-perf }}
needs: ["pre-commit", "paths-filter"]
if: needs.paths-filter.outputs.server == 'true' || needs.paths-filter.outputs.force == 'true'
# check if we are in inventree/inventree - reporting only works in that OIDC context
if: (needs.paths-filter.outputs.server == 'true' || needs.paths-filter.outputs.force == 'true') && github.repository == 'inventree/InvenTree'
permissions:
contents: read
id-token: write
@@ -403,10 +426,13 @@ jobs:
apt-dependency: gettext poppler-utils
dev-install: true
update: true
npm: true
env:
node_version: '>=20.19.0'
- name: Performance Reporting
uses: CodSpeedHQ/action@346a2d8a8d9d38909abd0bc3d23f773110f076ad # pin@v4
uses: CodSpeedHQ/action@dbda7111f8ac363564b0c51b992d4ce76bb89f2f # pin@v4
with:
mode: simulation
mode: walltime
run: inv dev.test --pytest
postgres:
@@ -545,7 +571,7 @@ jobs:
- name: Run Tests
run: invoke dev.test --check --migrations --report --coverage --translations
- name: Upload coverage reports to Codecov
uses: codecov/codecov-action@5a1091511ad55cbe89839c7260b706298ca349f7 # pin@v5.5.1
uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # pin@v5.5.2
if: always()
with:
token: ${{ secrets.CODECOV_TOKEN }}
@@ -667,7 +693,7 @@ jobs:
- name: Run Playwright tests
id: tests
run: cd src/frontend && npx nyc playwright test
- uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # pin@v5.0.0
- uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # pin@v6.0.0
if: ${{ !cancelled() && steps.tests.outcome == 'failure' }}
with:
name: playwright-report
@@ -676,7 +702,7 @@ jobs:
- name: Report coverage
run: cd src/frontend && npx nyc report --report-dir ./coverage --temp-dir .nyc_output --reporter=lcov --exclude-after-remap false
- name: Upload coverage reports to Codecov
uses: codecov/codecov-action@5a1091511ad55cbe89839c7260b706298ca349f7 # pin@v5.5.1
uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # pin@v5.5.2
with:
token: ${{ secrets.CODECOV_TOKEN }}
slug: inventree/InvenTree
@@ -712,7 +738,7 @@ jobs:
run: |
cd src/backend/InvenTree/web/static
zip -r frontend-build.zip web/ web/.vite
- uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # pin@v5.0.0
- uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # pin@v6.0.0
with:
name: frontend-build
path: src/backend/InvenTree/web/static/web
@@ -737,7 +763,7 @@ jobs:
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Upload SARIF file
uses: github/codeql-action/upload-sarif@fdbfb4d2750291e159f0156def62b853c2798ca2 # pin@v3
uses: github/codeql-action/upload-sarif@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 # pin@v3
with:
sarif_file: results.sarif
category: zizmor
+4 -2
View File
@@ -55,7 +55,7 @@ jobs:
- name: Build frontend
run: cd src/frontend && npm run compile && npm run build
- name: Create SBOM for frontend
uses: anchore/sbom-action@fbfd9c6c189226748411491745178e0c2017392d # pin@v0
uses: anchore/sbom-action@a930d0ac434e3182448fe678398ba5713717112a # pin@v0
with:
artifact-name: frontend-build.spdx
path: src/frontend
@@ -65,13 +65,15 @@ jobs:
run: cd src/backend/InvenTree/web/static/web/.vite && echo "${REF_NAME}" > tag.txt
env:
REF_NAME: ${{ github.ref_name }}
- name: Write version file - SOURCE
run: cd src/backend/InvenTree/web/static/web/.vite && echo "GitHub Actions build on $(date --utc +%Y-%m-%dT%H:%M:%SZ)" > source.txt
- name: Zip frontend
run: |
cd src/backend/InvenTree/web/static/web
zip -r ../frontend-build.zip * .vite
- name: Attest Build Provenance
id: attest
uses: actions/attest-build-provenance@977bb373ede98d70efdf65b84cb5f73e068dcc2a # pin@v1
uses: actions/attest-build-provenance@00014ed6ed5efc5b1ab7f7f34a39eb55d41aa4f8 # pin@v1
with:
subject-path: "${{ github.workspace }}/src/backend/InvenTree/web/static/frontend-build.zip"
+2 -2
View File
@@ -59,7 +59,7 @@ jobs:
# Upload the results as artifacts (optional). Commenting out will disable uploads of run results in SARIF
# format to the repository Actions tab.
- name: "Upload artifact"
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with:
name: SARIF file
path: results.sarif
@@ -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@fdbfb4d2750291e159f0156def62b853c2798ca2 # v4.31.5
uses: github/codeql-action/upload-sarif@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 # v4.31.9
with:
sarif_file: results.sarif
+3 -3
View File
@@ -18,7 +18,7 @@ repos:
exclude: mkdocs.yml
- id: mixed-line-ending
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.14.8
rev: v0.14.10
hooks:
- id: ruff-format
args: [--preview]
@@ -29,7 +29,7 @@ repos:
--preview
]
- repo: https://github.com/astral-sh/uv-pre-commit
rev: 0.9.16
rev: 0.9.22
hooks:
- id: pip-compile
name: pip-compile requirements-dev.in
@@ -71,7 +71,7 @@ repos:
src/frontend/vite.config.ts |
)$
- repo: https://github.com/biomejs/pre-commit
rev: v2.3.8
rev: v2.3.10
hooks:
- id: biome-check
additional_dependencies: ["@biomejs/biome@1.9.4"]
+2
View File
@@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Breaking Changes
- [#10699](https://github.com/inventree/InvenTree/pull/10699) removes the `PartParameter` and `PartParameterTempalate` models (and associated API endpoints). These have been replaced with generic `Parameter` and `ParameterTemplate` models (and API endpoints). Any external client applications which made use of the old endpoints will need to be updated.
- [#11035](https://github.com/inventree/InvenTree/pull/11035) moves to a single endpoint for all metadata operations. The previous endpoints for PartMetadata, SupplierPartMetadata, etc have been removed. Any external client applications which made use of the old endpoints will need to be updated.
### Added
@@ -27,6 +28,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Removed python 3.9 / 3.10 support as part of Django 5.2 upgrade in [#10730](https://github.com/inventree/InvenTree/pull/10730)
- Removed the "PartParameter" and "PartParameterTemplate" models (and associated API endpoints) in [#10699](https://github.com/inventree/InvenTree/pull/10699)
- Removed the "ManufacturerPartParameter" model (and associated API endpoints) [#10699](https://github.com/inventree/InvenTree/pull/10699)
- Removed individual metadata endpoints for all models ([#11035](https://github.com/inventree/InvenTree/pull/11035))
## 1.1.0 - 2025-11-02
+2 -1
View File
@@ -56,7 +56,7 @@ InvenTree is designed to be **extensible**, and provides multiple options for **
* [InvenTree API](https://docs.inventree.org/en/latest/api/)
* [Python module](https://docs.inventree.org/en/latest/api/python/)
* [Plugin interface](https://docs.inventree.org/en/latest/plugins/)
* [Third party tools](https://docs.inventree.org/en/latest/plugins/integrate/)
* [Third party tools](https://inventree.org/extend/integrate/)
<!-- TechStack -->
### :space_invader: Tech Stack
@@ -200,6 +200,7 @@ Find a full list of used third-party libraries in the license information dialog
</a>
<a href="https://www.netlify.com"> <img src="https://www.netlify.com/v3/img/components/netlify-color-bg.svg" alt="Deploys by Netlify" /> </a>
<a href="https://crowdin.com"> <img src="https://crowdin.com/images/crowdin-logo.svg" alt="Translation by Crowdin" /> </a> <br>
<a href="https://codspeed.io/inventree/InvenTree?utm_source=badge"><img src="https://img.shields.io/endpoint?url=https://codspeed.io/badge.json" alt="CodSpeed Badge"/></a>
</p>
+3 -1
View File
@@ -3,7 +3,7 @@
# - gunicorn as the InvenTree web server
# - django-q as the InvenTree background worker process
# - Caddy as a reverse proxy
# - redis as the cache manager (optional, disabled by default)
# - redis as the cache manager (optional, enabled by default)
# ---------------------
# READ BEFORE STARTING!
@@ -64,6 +64,8 @@ services:
- .env
expose:
- ${INVENTREE_CACHE_PORT:-6379}
volumes:
- ${INVENTREE_EXT_VOLUME}/redis:/data
restart: always
# InvenTree web server service
+8 -8
View File
@@ -1,12 +1,12 @@
# This file was autogenerated by uv via the following command:
# uv pip compile contrib/container/requirements.in -o contrib/container/requirements.txt --python-version=3.11 -b src/backend/requirements.txt
asgiref==3.10.0
asgiref==3.11.0
# via django
django==5.2.9
django==5.2.10
# via
# -r contrib/container/requirements.in
# django-auth-ldap
django-auth-ldap==5.2.0
django-auth-ldap==5.3.0
# via -r contrib/container/requirements.in
gunicorn==23.0.0
# via -r contrib/container/requirements.in
@@ -20,11 +20,11 @@ packaging==25.0
# via
# gunicorn
# mariadb
psycopg[binary, pool]==3.2.12
psycopg[binary, pool]==3.3.2
# via -r contrib/container/requirements.in
psycopg-binary==3.2.12
psycopg-binary==3.3.2
# via psycopg
psycopg-pool==3.2.7
psycopg-pool==3.3.0
# via psycopg
pyasn1==0.6.1
# via
@@ -40,13 +40,13 @@ pyyaml==6.0.3
# via -r contrib/container/requirements.in
setuptools==80.9.0
# via -r contrib/container/requirements.in
sqlparse==0.5.3
sqlparse==0.5.5
# via django
typing-extensions==4.15.0
# via
# psycopg
# psycopg-pool
uv==0.9.8
uv==0.9.22
# via -r contrib/container/requirements.in
wheel==0.45.1
# via -r contrib/container/requirements.in
+3 -5
View File
@@ -1,6 +1,6 @@
# This file was autogenerated by uv via the following command:
# uv pip compile contrib/dev_reqs/requirements.in -o contrib/dev_reqs/requirements.txt -b src/backend/requirements.txt
certifi==2025.10.5
certifi==2026.1.4
# via requests
charset-normalizer==3.4.4
# via requests
@@ -14,11 +14,9 @@ pyyaml==6.0.3
# via -r contrib/dev_reqs/requirements.in
requests==2.32.5
# via -r contrib/dev_reqs/requirements.in
ruamel-yaml==0.18.15
ruamel-yaml==0.19.1
# via jc
ruamel-yaml-clib==0.2.15
# via ruamel-yaml
urllib3==2.6.0
urllib3==2.6.3
# via requests
xmltodict==1.0.2
# via jc
Binary file not shown.

Before

Width:  |  Height:  |  Size: 87 KiB

+1 -6
View File
@@ -42,12 +42,7 @@ print(part.metadata)
### API Access
For models which provide this metadata field, access is also provided via the API. Append `/metadata/` to the detail endpoint for a particular model instance to access.
For example:
{{ image("plugin/model_metadata_api.png", "Access model metadata via API", maxheight="400px") }}
For models which provide this metadata field, access is also provided via the API. Use the generic `/metadata/<modelname>/<object id>/` endpoint to retrieve or update metadata information.
#### PUT vs PATCH
+1 -1
View File
@@ -26,7 +26,7 @@ invoke superuser
Or, if you are running InvenTree in a Docker container:
```bash
docker exec -rm -it inventree-server invoke superuser
docker exec -it inventree-server invoke superuser
```
### User Management
+2 -2
View File
@@ -333,8 +333,8 @@ The following email settings are available:
| INVENTREE_EMAIL_PORT | email.port | Email server port | 25 |
| INVENTREE_EMAIL_USERNAME | email.username | Email account username | *Not specified* |
| INVENTREE_EMAIL_PASSWORD | email.password | Email account password | *Not specified* |
| INVENTREE_EMAIL_TLS | email.tls | Enable TLS support | False |
| INVENTREE_EMAIL_SSL | email.ssl | Enable SSL support | False |
| INVENTREE_EMAIL_TLS | email.tls | Enable STARTTLS support (commonly port 567) | False |
| INVENTREE_EMAIL_SSL | email.ssl | Enable legacy SSL/TLS support (commonly port 465) | False |
| INVENTREE_EMAIL_SENDER | email.sender | Sending email address | *Not specified* |
| INVENTREE_EMAIL_PREFIX | email.prefix | Prefix for subject text | [InvenTree] |
+24 -26
View File
@@ -1,25 +1,25 @@
# This file was autogenerated by uv via the following command:
# uv pip compile docs/requirements.in -o docs/requirements.txt -b src/backend/requirements.txt
anyio==4.10.0
anyio==4.12.1
# via httpx
babel==2.17.0
# via
# mkdocs-git-revision-date-localized-plugin
# mkdocs-material
backrefs==5.9
backrefs==6.1
# via mkdocs-material
beautifulsoup4==4.13.4
beautifulsoup4==4.14.3
# via mkdocs-mermaid2-plugin
bracex==2.6
# via wcmatch
certifi==2025.8.3
certifi==2026.1.4
# via
# httpcore
# httpx
# requests
charset-normalizer==3.4.2
charset-normalizer==3.4.4
# via requests
click==8.1.8
click==8.3.1
# via
# mkdocs
# neoteroi-mkdocs
@@ -29,7 +29,7 @@ colorama==0.4.6
# mkdocs-material
editorconfig==0.17.1
# via jsbeautifier
essentials==1.1.6
essentials==1.1.9
# via essentials-openapi
essentials-openapi==1.3.0
# via neoteroi-mkdocs
@@ -37,9 +37,9 @@ ghp-import==2.1.0
# via mkdocs
gitdb==4.0.12
# via gitpython
gitpython==3.1.45
gitpython==3.1.46
# via mkdocs-git-revision-date-localized-plugin
griffe==1.11.0
griffe==1.15.0
# via mkdocstrings-python
h11==0.16.0
# via httpcore
@@ -51,7 +51,7 @@ httpcore==1.0.9
# via httpx
httpx==0.28.1
# via neoteroi-mkdocs
idna==3.10
idna==3.11
# via
# anyio
# httpx
@@ -65,16 +65,16 @@ jinja2==3.1.6
# neoteroi-mkdocs
jsbeautifier==1.15.4
# via mkdocs-mermaid2-plugin
markdown==3.8.2
markdown==3.10
# via
# mkdocs
# mkdocs-autorefs
# mkdocs-material
# mkdocstrings
# pymdown-extensions
markdown-it-py==3.0.0
markdown-it-py==4.0.0
# via rich
markupsafe==3.0.2
markupsafe==3.0.3
# via
# essentials-openapi
# jinja2
@@ -100,7 +100,7 @@ mkdocs==1.6.1
# mkdocs-simple-hooks
# mkdocstrings
# neoteroi-mkdocs
mkdocs-autorefs==1.4.2
mkdocs-autorefs==1.4.3
# via
# mkdocstrings
# mkdocstrings-python
@@ -112,7 +112,7 @@ mkdocs-include-markdown-plugin==7.2.0
# via -r docs/requirements.in
mkdocs-macros-plugin==1.5.0
# via -r docs/requirements.in
mkdocs-material==9.7.0
mkdocs-material==9.7.1
# via -r docs/requirements.in
mkdocs-material-extensions==1.3.1
# via mkdocs-material
@@ -126,7 +126,7 @@ mkdocstrings[python]==1.0.0
# via
# -r docs/requirements.in
# mkdocstrings-python
mkdocstrings-python==1.16.12
mkdocstrings-python==2.0.1
# via mkdocstrings
neoteroi-mkdocs==1.2.0
# via -r docs/requirements.in
@@ -136,17 +136,17 @@ packaging==25.0
# mkdocs-macros-plugin
paginate==0.5.7
# via mkdocs-material
pathspec==0.12.1
pathspec==1.0.1
# via
# mkdocs
# mkdocs-macros-plugin
platformdirs==4.3.8
platformdirs==4.5.1
# via mkdocs-get-deps
pygments==2.19.2
# via
# mkdocs-material
# rich
pymdown-extensions==10.16.1
pymdown-extensions==10.20
# via
# mkdocs-material
# mkdocs-mermaid2-plugin
@@ -170,7 +170,7 @@ requests==2.32.5
# mkdocs-macros-plugin
# mkdocs-material
# mkdocs-mermaid2-plugin
rich==14.1.0
rich==14.2.0
# via neoteroi-mkdocs
setuptools==80.9.0
# via mkdocs-mermaid2-plugin
@@ -180,19 +180,17 @@ six==1.17.0
# python-dateutil
smmap==5.0.2
# via gitdb
sniffio==1.3.1
# via anyio
soupsieve==2.7
soupsieve==2.8.1
# via beautifulsoup4
super-collections==0.6.2
# via mkdocs-macros-plugin
termcolor==3.1.0
termcolor==3.3.0
# via mkdocs-macros-plugin
typing-extensions==4.14.1
typing-extensions==4.15.0
# via
# anyio
# beautifulsoup4
urllib3==2.6.0
urllib3==2.6.3
# via requests
watchdog==6.0.0
# via mkdocs
+130 -31
View File
@@ -5,15 +5,19 @@ import json
from pathlib import Path
from django.conf import settings
from django.contrib.contenttypes.models import ContentType
from django.db import transaction
from django.http import JsonResponse
from django.urls import path, reverse
from django.utils.translation import gettext_lazy as _
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
from rest_framework.generics import GenericAPIView
from rest_framework.request import clone_request
from rest_framework.response import Response
from rest_framework.serializers import ValidationError
from rest_framework.views import APIView
@@ -22,13 +26,13 @@ import InvenTree.config
import InvenTree.permissions
import InvenTree.version
from common.settings import get_global_setting
from InvenTree import helpers
from InvenTree import helpers, ready
from InvenTree.auth_overrides import registration_enabled
from InvenTree.mixins import ListCreateAPI
from InvenTree.sso import sso_registration_enabled
from plugin.serializers import MetadataSerializer
from users.models import ApiToken
from users.permissions import check_user_permission
from users.permissions import check_user_permission, prefetch_rule_sets
from .helpers import plugins_info
from .helpers_email import is_email_configured
@@ -764,6 +768,13 @@ class APISearchView(GenericAPIView):
search_filters = self.get_result_filters()
# Create a clone of the request object to modify
# Use GET method for the individual list views
cloned_request = clone_request(request, 'GET')
# Fetch and cache all groups associated with the current user
groups = prefetch_rule_sets(request.user)
for key, cls in self.get_result_types().items():
# Only return results which are specifically requested
if key in data:
@@ -787,57 +798,145 @@ class APISearchView(GenericAPIView):
view = cls()
# Override regular query params with specific ones for this search request
request._request.GET = params
view.request = request
cloned_request._request.GET = params
view.request = cloned_request
view.format_kwarg = 'format'
# Check permissions and update results dict with particular query
model = view.serializer_class.Meta.model
if not check_user_permission(
request.user, model, 'view', groups=groups
):
results[key] = {
'error': _('User does not have permission to view this model')
}
continue
try:
if check_user_permission(request.user, model, 'view'):
results[key] = view.list(request, *args, **kwargs).data
else:
results[key] = {
'error': _(
'User does not have permission to view this model'
)
}
results[key] = view.list(request, *args, **kwargs).data
except Exception as exc:
results[key] = {'error': str(exc)}
return Response(results)
class MetadataView(RetrieveUpdateAPI):
"""Generic API endpoint for reading and editing metadata for a model."""
class GenericMetadataView(RetrieveUpdateAPI):
"""Metadata for specific instance; see https://docs.inventree.org/en/stable/plugins/metadata/ for more detail on how metadata works. Most core models support metadata."""
model = None # Placeholder for the model class
@classmethod
def as_view(cls, model, lookup_field=None, **initkwargs):
"""Override to ensure model specific rendering."""
if model is None:
raise ValidationError(
"MetadataView defined without 'model' arg"
) # pragma: no cover
initkwargs['model'] = model
# Set custom lookup field (instead of default 'pk' value) if supplied
if lookup_field:
initkwargs['lookup_field'] = lookup_field
return super().as_view(**initkwargs)
serializer_class = MetadataSerializer
permission_classes = [InvenTree.permissions.ContentTypePermission]
def get_permission_model(self):
"""Return the 'permission' model associated with this view."""
return self.model
model_name = self.kwargs.get('model', None)
if model_name is None:
raise ValidationError(
"GenericMetadataView called without 'model' URL parameter"
) # pragma: no cover
model = ContentType.objects.filter(model=model_name).first()
if model is None:
raise ValidationError(
f"GenericMetadataView called with invalid model '{model_name}'"
) # pragma: no cover
return model.model_class()
def get_queryset(self):
"""Return the queryset for this endpoint."""
return self.model.objects.all()
model = self.get_permission_model()
return model.objects.all()
def get_serializer(self, *args, **kwargs):
"""Return MetadataSerializer instance."""
is_gen = ready.isGeneratingSchema()
# Detect if we are currently generating the OpenAPI schema
if self.model is None and not is_gen:
self.model = self.get_permission_model()
if self.model is None and is_gen:
# Provide a default model for schema generation
import users.models
self.model = users.models.User
return MetadataSerializer(self.model, *args, **kwargs)
def dispatch(self, request, *args, **kwargs):
"""Override dispatch to set lookup field dynamically."""
self.lookup_field = self.kwargs.get('lookup_field', 'pk')
self.lookup_url_kwarg = (
'lookup_value' if 'lookup_field' in self.kwargs else 'pk'
)
return super().dispatch(request, *args, **kwargs)
class SimpleGenericMetadataView(GenericMetadataView):
"""Simplified version of GenericMetadataView which always uses 'pk' as the lookup field."""
def dispatch(self, request, *args, **kwargs):
"""Override dispatch to set lookup field to 'pk'."""
self.lookup_field = 'pk'
self.lookup_url_kwarg = None
return super().dispatch(request, *args, **kwargs)
@extend_schema(operation_id='metadata_pk_retrieve')
def get(self, request, *args, **kwargs):
"""Perform a GET request to retrieve metadata for the given object."""
return super().get(request, *args, **kwargs)
@extend_schema(operation_id='metadata_pk_update')
def put(self, request, *args, **kwargs):
"""Perform a PUT request to update metadata for the given object."""
return super().put(request, *args, **kwargs)
@extend_schema(operation_id='metadata_pk_partial_update')
def patch(self, request, *args, **kwargs):
"""Perform a PATCH request to partially update metadata for the given object."""
return super().patch(request, *args, **kwargs)
class MetadataRedirectView(RedirectView):
"""Redirect to the generic metadata view for a given model."""
model_name = None # Placeholder for the model class
lookup_field = 'pk'
lookup_field_ref = 'pk'
permanent = True
def get_redirect_url(self, *args, **kwargs) -> str | None:
"""Return the redirect URL for this view."""
_kwargs = {
'model': self.model_name,
'lookup_value': self.kwargs.get(self.lookup_field_ref, None),
'lookup_field': self.lookup_field,
}
return reverse('api-generic-metadata', args=args, kwargs=_kwargs)
def meta_path(model, lookup_field: str = 'pk', lookup_field_ref: str = 'pk'):
"""Helper function for constructing metadata path for a given model.
Arguments:
model: The model class to use
lookup_field: The lookup field to use (if not 'pk')
lookup_field_ref: The reference name for the lookup field in the request(if not 'pk')
Returns:
A path to the generic metadata view for the given model
"""
if model is None:
raise ValidationError(
"redirect_metadata_view called without 'model' arg"
) # pragma: no cover
return path(
'metadata/',
MetadataRedirectView.as_view(
model_name=model._meta.model_name,
lookup_field=lookup_field,
lookup_field_ref=lookup_field_ref,
),
)
+23 -1
View File
@@ -1,11 +1,33 @@
"""InvenTree API version information."""
# InvenTree API version
INVENTREE_API_VERSION = 435
INVENTREE_API_VERSION = 442
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
INVENTREE_API_TEXT = """
v442 -> 2026-01-17 : https://github.com/inventree/InvenTree/pull/11157
- Typo fixes, no functional changes
v441 -> 2026-01-17 : https://github.com/inventree/InvenTree/pull/11153
- Allow export of supplier part pricing data
v440 -> 2026-01-15 : https://github.com/inventree/InvenTree/pull/10796
- Adds confirm and confirm_text to all settings
v439 -> 2026-01-09 : https://github.com/inventree/InvenTree/pull/11092
- Add missing nullable annotations
v438 -> 2026-01-09 : https://github.com/inventree/InvenTree/pull/11104
- Adds a simpler / faster health check endpoint at /api/system/health/
v437 -> 2026-01-07 : https://github.com/inventree/InvenTree/pull/11084
- Adds generic parameter support for the StockLocation model
v436 -> 2026-01-06 : https://github.com/inventree/InvenTree/pull/11035
- Removes model-specific metadata endpoints and replaces them with redirects
- Adds new generic /api/metadata/<model_name>/ endpoint to retrieve metadata for any model
v435 -> 2025-12-16 : https://github.com/inventree/InvenTree/pull/11030
- Adds token refresh endpoint to auth API
@@ -122,7 +122,8 @@ def get_email_for_user(user) -> Optional[str]:
# Otherwise, find first matching email
# Priority is given to primary or verified email addresses
if (
email := EmailAddress.objects.filter(user=user)
email := EmailAddress.objects
.filter(user=user)
.order_by('-primary', '-verified')
.first()
):
@@ -13,33 +13,59 @@ class Command(BaseCommand):
def add_arguments(self, parser):
"""Add the arguments."""
parser.add_argument('mail', type=str)
parser.add_argument('--mail', type=str, nargs='?')
parser.add_argument('--username', type=str, nargs='?')
def handle(self, *args, mail, **kwargs):
"""Remove MFA for the supplied user (by mail)."""
def handle(self, *args, mail, username, **kwargs):
"""Remove MFA for the supplied user (by mail or username)."""
user = get_user_model()
mfa_user = [
*set(
user.objects.filter(email=mail)
| user.objects.filter(emailaddress__email=mail)
)
]
mfa_user = []
success = False
if len(mfa_user) == 0:
logger.warning('No user with this mail associated')
elif len(mfa_user) > 1:
emails_list = ', '.join(
sorted(
{b.email for a in mfa_user for b in a.emailaddress_set.all()}
| {a.email for a in mfa_user}
if mail is not None:
mfa_user = [
*set(
user.objects.filter(email=mail)
| user.objects.filter(emailaddress__email=mail)
)
)
usernames_list = ', '.join(sorted({a.username for a in mfa_user}))
logger.error(
f"Multiple users found with the provided email; Usernames: '{usernames_list}', Emails: '{emails_list}'"
)
]
if len(mfa_user) == 0:
logger.warning('No user with this mail associated')
elif len(mfa_user) > 1:
emails_list = ', '.join(
sorted(
{b.email for a in mfa_user for b in a.emailaddress_set.all()}
| {a.email for a in mfa_user}
)
)
usernames_list = ', '.join(sorted({a.username for a in mfa_user}))
logger.error(
f"Multiple users found with the provided email; Usernames: '{usernames_list}', Emails: '{emails_list}'"
)
else:
# found exactly one user
success = True
elif username is not None:
mfa_user = user.objects.filter(username=username)
if len(mfa_user) == 0:
logger.warning('No user with this username associated')
elif (
len(mfa_user) > 1
): # pragma: no cover # Should not be possible due to unique constraint
logger.error('Multiple users found with the provided username')
else:
# found exactly one user
success = True
else:
# and clean out all MFA methods
logger.error('No mail or username provided')
raise ValueError(
'Error: one of the following arguments is required: mail, username'
)
# Clean out all MFA methods
if success:
auths = mfa_user[0].authenticator_set.all()
length = len(auths)
auths.delete()
@@ -71,7 +71,8 @@ class Command(spectacular.Command):
for p_name, p_spec in spec['paths'].items():
# strip path name
p_name = (
p_name.removeprefix(dja_path_prefix)
p_name
.removeprefix(dja_path_prefix)
.removeprefix('/_allauth/browser/v1/')
.removeprefix('/_allauth/app/v1/')
)
@@ -42,7 +42,7 @@ def get_token_from_request(request):
def ensure_slashes(path: str):
"""Ensure that slashes are suroudning the passed path."""
"""Ensure that slashes are surrounding the passed path."""
if not path.startswith('/'):
path = f'/{path}'
if not path.endswith('/'):
@@ -59,6 +59,7 @@ urls = [
paths_ignore_handling = [
'/api/',
'/plugin/',
reverse('auth-check'),
settings.MEDIA_URL,
settings.STATIC_URL,
@@ -68,7 +69,7 @@ paths_ignore_handling = [
paths_own_security = [
'/api/', # DRF handles API
'/o/', # oAuth2 library - has its own auth model
'/anymail/', # Mails - wehbhooks etc
'/anymail/', # Mails - webhooks etc
'/accounts/', # allauth account management - has its own auth model
'/assets/', # Web assets - only used for testing, no security model needed
ensure_slashes(
@@ -322,7 +323,7 @@ class InvenTreeHostSettingsMiddleware(MiddlewareMixin):
# treat the accessed scheme and host
accessed_scheme = request._current_scheme_host
referer = urlsplit(accessed_scheme)
referrer = urlsplit(accessed_scheme)
site_url = urlsplit(settings.SITE_URL)
@@ -330,8 +331,8 @@ class InvenTreeHostSettingsMiddleware(MiddlewareMixin):
site_url_match = (
(
# Exact match on domain
is_same_domain(referer.netloc, site_url.netloc)
and referer.scheme == site_url.scheme
is_same_domain(referrer.netloc, site_url.netloc)
and referrer.scheme == site_url.scheme
)
or (
# Lax protocol match, accessed URL starts with SITE_URL
@@ -341,7 +342,7 @@ class InvenTreeHostSettingsMiddleware(MiddlewareMixin):
or (
# Lax protocol match, same domain
settings.SITE_LAX_PROTOCOL_CHECK
and referer.hostname == site_url.hostname
and referrer.hostname == site_url.hostname
)
)
@@ -367,7 +368,7 @@ class InvenTreeHostSettingsMiddleware(MiddlewareMixin):
trusted_origins_match = (
# Matching domain found in allowed origins
any(
is_same_domain(referer.netloc, host)
is_same_domain(referrer.netloc, host)
for host in [
urlsplit(origin).netloc.lstrip('*')
for origin in settings.CSRF_TRUSTED_ORIGINS
@@ -377,7 +378,7 @@ class InvenTreeHostSettingsMiddleware(MiddlewareMixin):
# Lax protocol match allowed
settings.SITE_LAX_PROTOCOL_CHECK
and any(
referer.hostname == urlsplit(origin).hostname
referrer.hostname == urlsplit(origin).hostname
for origin in settings.CSRF_TRUSTED_ORIGINS
)
)
+8 -1
View File
@@ -239,10 +239,17 @@ class OutputOptionsMixin:
def get_serializer(self, *args, **kwargs):
"""Return serializer instance with output options applied."""
if self.output_options and hasattr(self, 'request'):
request = getattr(self, 'request', None)
if self.output_options and request:
params = self.request.query_params
kwargs.update(self.output_options.format_params(params))
# Ensure the request is included in the serializer context
context = kwargs.get('context', {})
context['request'] = request
kwargs['context'] = context
serializer = super().get_serializer(*args, **kwargs)
# Check if the serializer actually can be filtered - makes not much sense to use this mixin without that prerequisite
+4 -2
View File
@@ -610,7 +610,8 @@ class InvenTreeParameterMixin(InvenTreePermissionCheckMixin, models.Model):
def get_parameters(self) -> QuerySet:
"""Return all Parameter instances for this model."""
return (
self.parameters_list.all()
self.parameters_list
.all()
.prefetch_related('template', 'model_type')
.order_by('template__name')
)
@@ -752,7 +753,8 @@ class InvenTreeTree(ContentTypeMixin, MPTTModel):
for child in self.get_children():
# Store a flattened list of node IDs for each of the lower trees
nodes = list(
child.get_descendants(include_self=True)
child
.get_descendants(include_self=True)
.values_list('pk', flat=True)
.distinct()
)
@@ -470,3 +470,25 @@ class DataImporterPermission(OASTokenMixin, permissions.BasePermission):
)
return True
class ContentTypePermission(OASTokenMixin, permissions.BasePermission):
"""Mixin class for determining if the user has correct permissions."""
ENFORCE_USER_PERMS = True
def has_permission(self, request, view):
"""Class level permission checks are handled via InvenTree.permissions.IsAuthenticatedOrReadScope."""
return request.user and request.user.is_authenticated
def get_required_alternate_scopes(self, request, view):
"""Return the required scopes for the current request."""
return map_scope(roles=_roles)
def has_object_permission(self, request, view, obj):
"""Check if the user has permission to access the object."""
if model_class := obj.__class__:
return users.permissions.check_user_permission(
request.user, model_class, 'change'
)
return False
+19 -12
View File
@@ -21,6 +21,7 @@ from rest_framework import serializers
from rest_framework.exceptions import ValidationError
from rest_framework.fields import empty
from rest_framework.mixins import ListModelMixin
from rest_framework.permissions import SAFE_METHODS
from rest_framework.serializers import DecimalField
from rest_framework.utils import model_meta
from taggit.serializers import TaggitSerializer, TagListSerializerField
@@ -163,10 +164,15 @@ class FilterableSerializerMixin:
def gather_filters(self, kwargs) -> None:
"""Gather filterable fields through introspection."""
context = kwargs.get('context', {})
request = context.get('request', None) or getattr(self, 'request', None)
# Gather query parameters from the request context
query_params = dict(getattr(request, 'query_params', {})) if request else {}
# Fast exit if this has already been done or would not have any effect
if getattr(self, '_was_filtered', False) or not hasattr(self, 'fields'):
return
self._was_filtered = True
# Actually gather the filterable fields
# Also see `enable_filter` where` is_filterable and is_filterable_vals are set
@@ -176,21 +182,22 @@ class FilterableSerializerMixin:
if getattr(a, 'is_filterable', None)
}
# Gather query parameters from the request context
query_params = {}
if context := kwargs.get('context', {}):
query_params = dict(getattr(context.get('request', {}), 'query_params', {}))
# Remove filter args from kwargs to avoid issues with super().__init__
popped_kwargs = {} # store popped kwargs as a arg might be reused for multiple fields
tgs_vals: dict[str, bool] = {}
for k, v in self.filter_targets.items():
pop_ref = v['filter_name'] or k
val = kwargs.pop(pop_ref, popped_kwargs.get(pop_ref))
# Optionally also look in query parameters
if val is None and self.filter_on_query and v.get('filter_by_query', True):
# Note that we only do this for a top-level serializer, to avoid issues with nested serializers
if (
request
and val is None
and self.filter_on_query
and v.get('filter_by_query', True)
):
val = query_params.pop(pop_ref, None)
if isinstance(val, list) and len(val) == 1:
val = val[0]
@@ -199,7 +206,9 @@ class FilterableSerializerMixin:
tgs_vals[k] = (
str2bool(val) if isinstance(val, (str, int, float)) else val
) # Support for various filtering style for backwards compatibility
self.filter_target_values = tgs_vals
self._was_filtered = True
# Ensure this mixin is not broadly applied as it is expensive on scale (total CI time increased by 21% when running all coverage tests)
if len(self.filter_targets) == 0 and not self.no_filters:
@@ -216,14 +225,12 @@ class FilterableSerializerMixin:
):
return
# Skip filtering when exporting data - leave all fields intact
if getattr(self, '_exporting_data', False):
return
is_exporting = getattr(self, '_exporting_data', False)
# Skip filtering for a write requests - all fields should be present for data creation
if request := self.context.get('request', None):
if method := getattr(request, 'method', None):
if str(method).lower() in ['post', 'put', 'patch']:
if method not in SAFE_METHODS and not is_exporting:
return
# Throw out fields which are not requested (either by default or explicitly)
@@ -19,40 +19,50 @@ class CommandTestCase(TestCase):
def test_remove_mfa(self):
"""Test the remove_mfa command."""
def get_dummyuser(uname='admin'):
admin = User.objects.create_user(
username=uname, email=f'{uname}@example.org'
)
admin.authenticator_set.create(type='TOTP', data={})
self.assertEqual(admin.authenticator_set.all().count(), 1)
return admin
# missing arg
with self.assertRaises(Exception) as cm:
call_command('remove_mfa', verbosity=0)
self.assertEqual(
'Error: the following arguments are required: mail', str(cm.exception)
'Error: one of the following arguments is required: mail, username',
str(cm.exception),
)
# no user
with self.assertLogs('inventree') as cm:
self.assertFalse(
call_command('remove_mfa', 'admin@example.org', verbosity=0)
call_command('remove_mfa', mail='admin@example.org', verbosity=0)
)
self.assertIn('No user with this mail associated', str(cm[1]))
# correct removal
my_admin1 = User.objects.create_user(
username='admin', email='admin@example.org'
)
my_admin1.authenticator_set.create(type='TOTP', data={})
self.assertEqual(my_admin1.authenticator_set.all().count(), 1)
output = call_command('remove_mfa', 'admin@example.org', verbosity=0)
my_admin1 = get_dummyuser()
output = call_command('remove_mfa', mail=my_admin1.email, verbosity=0)
self.assertEqual(output, 'done')
self.assertEqual(my_admin1.authenticator_set.all().count(), 0)
# two users with same email
my_admin2 = User.objects.create_user(
username='admin2', email='admin@example.org'
)
my_admin2 = User.objects.create_user(username='admin2', email=my_admin1.email)
my_admin2.emailaddress_set.create(email='456')
my_admin2.emailaddress_set.create(email='123')
with self.assertLogs('inventree') as cm:
self.assertFalse(
call_command('remove_mfa', 'admin@example.org', verbosity=0)
call_command('remove_mfa', mail=my_admin1.email, verbosity=0)
)
self.assertIn('Multiple users found with the provided email', str(cm[1]))
self.assertIn('admin, admin2', str(cm[1]))
self.assertIn('123, 456, admin@example.org', str(cm[1]))
self.assertIn(f'123, 456, {my_admin1.email}', str(cm[1]))
# correct removal by username
my_admin3 = get_dummyuser('admin3')
output = call_command('remove_mfa', username=my_admin3.username, verbosity=0)
self.assertEqual(output, 'done')
self.assertEqual(my_admin3.authenticator_set.all().count(), 0)
@@ -157,7 +157,7 @@ class FilteredSerializers(InvenTreeAPITestCase):
_ = BadSerializer()
self.assertTrue(True) # Dummy assertion to ensure we reach here
def test_failiure_OutputOptionsMixin(self):
def test_failure_OutputOptionsMixin(self):
"""Test failure case for OutputOptionsMixin."""
class BadSerializer(InvenTree.serializers.InvenTreeModelSerializer):
+3 -1
View File
@@ -655,7 +655,9 @@ class InvenTreeAPITestCase(
# Append URL params
url += '?' + '&'.join([f'{key}={value}' for key, value in params.items()])
response = self.client.get(url, data=None, format='json')
response = self.get(
url, data=None, format='json', expected_code=expected_code, **kwargs
)
self.check_response(url, response, expected_code=expected_code)
# Check that the response is of the correct type
+9 -3
View File
@@ -12,6 +12,8 @@ import sys
from datetime import datetime as dt
from datetime import timedelta as td
from django.conf import settings
from .api_version import INVENTREE_API_TEXT, INVENTREE_API_VERSION
# InvenTree software version
@@ -22,6 +24,7 @@ MIN_PYTHON_VERSION = (3, 11)
logger = logging.getLogger('inventree')
git_warning_txt = 'INVE-W3: Could not detect git information.'
# Discover git
try:
@@ -33,8 +36,11 @@ try:
main_repo = Repo(pathlib.Path(__file__).parent.parent.parent.parent.parent)
main_commit = main_repo[main_repo.head()]
except NotGitRepository:
# If we are running in a docker container, the repo may not be available
logger.warning('INVE-W3: Could not detect git information.')
# If we are running in a docker container, the repo may not be available, only logging as warning if not in docker
if settings.DOCKER:
logger.info(git_warning_txt)
else:
logger.warning(git_warning_txt)
main_repo = None
main_commit = None
@@ -51,7 +57,7 @@ except ImportError:
main_commit = None
main_branch = None
except Exception as exc:
logger.warning('INVE-W3: Could not detect git information.', exc_info=exc)
logger.warning(git_warning_txt, exc_info=exc)
main_repo = None
main_commit = None
main_branch = None
+3 -11
View File
@@ -24,7 +24,7 @@ from build.models import Build, BuildItem, BuildLine
from build.status_codes import BuildStatus, BuildStatusGroups
from data_exporter.mixins import DataExportViewMixin
from generic.states.api import StatusView
from InvenTree.api import BulkDeleteMixin, MetadataView, ParameterListMixin
from InvenTree.api import BulkDeleteMixin, ParameterListMixin, meta_path
from InvenTree.fields import InvenTreeOutputOption, OutputConfiguration
from InvenTree.filters import (
SEARCH_ORDER_FILTER_ALIAS,
@@ -960,11 +960,7 @@ build_api_urls = [
path(
'<int:pk>/',
include([
path(
'metadata/',
MetadataView.as_view(model=BuildItem),
name='api-build-item-metadata',
),
meta_path(BuildItem),
path('', BuildItemDetail.as_view(), name='api-build-item-detail'),
]),
),
@@ -1007,11 +1003,7 @@ build_api_urls = [
path('finish/', BuildFinish.as_view(), name='api-build-finish'),
path('cancel/', BuildCancel.as_view(), name='api-build-cancel'),
path('unallocate/', BuildUnallocate.as_view(), name='api-build-unallocate'),
path(
'metadata/',
MetadataView.as_view(model=Build),
name='api-build-metadata',
),
meta_path(Build),
path('', BuildDetail.as_view(), name='api-build-detail'),
]),
),
@@ -15,6 +15,6 @@ class Migration(migrations.Migration):
migrations.AddField(
model_name='build',
name='sales_order',
field=models.ForeignKey(blank=True, help_text='SalesOrder to which this build is allocated', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='builds', to='order.SalesOrder'),
field=models.ForeignKey(blank=True, help_text='Sales Order to which this build is allocated', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='builds', to='order.SalesOrder'),
),
]
@@ -50,7 +50,7 @@ class Migration(migrations.Migration):
migrations.AlterField(
model_name='build',
name='sales_order',
field=models.ForeignKey(blank=True, help_text='SalesOrder to which this build is allocated', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='builds', to='order.SalesOrder', verbose_name='Sales Order Reference'),
field=models.ForeignKey(blank=True, help_text='Sales Order to which this build is allocated', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='builds', to='order.SalesOrder', verbose_name='Sales Order Reference'),
),
migrations.AlterField(
model_name='build',
@@ -37,7 +37,7 @@ class Migration(migrations.Migration):
migrations.AlterField(
model_name='build',
name='parent',
field=mptt.fields.TreeForeignKey(blank=True, help_text='BuildOrder to which this build is allocated', null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='children', to='build.Build', verbose_name='Parent Build'),
field=mptt.fields.TreeForeignKey(blank=True, help_text='Build Order to which this build is allocated', null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='children', to='build.Build', verbose_name='Parent Build'),
),
migrations.AlterField(
model_name='build',
@@ -15,6 +15,6 @@ class Migration(migrations.Migration):
migrations.AlterField(
model_name='build',
name='parent',
field=mptt.fields.TreeForeignKey(blank=True, help_text='BuildOrder to which this build is allocated', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='children', to='build.Build', verbose_name='Parent Build'),
field=mptt.fields.TreeForeignKey(blank=True, help_text='Build Order to which this build is allocated', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='children', to='build.Build', verbose_name='Parent Build'),
),
]
+66 -56
View File
@@ -263,7 +263,7 @@ class Build(
null=True,
related_name='children',
verbose_name=_('Parent Build'),
help_text=_('BuildOrder to which this build is allocated'),
help_text=_('Build Order to which this build is allocated'),
)
part = models.ForeignKey(
@@ -282,7 +282,7 @@ class Build(
related_name='builds',
null=True,
blank=True,
help_text=_('SalesOrder to which this build is allocated'),
help_text=_('Sales Order to which this build is allocated'),
)
take_from = models.ForeignKey(
@@ -1254,7 +1254,9 @@ class Build(
output.build = self
output.is_building = False
output.location = location
output.status = status
# Assign the stock status
output.set_status(status)
output.save(add_note=False)
@@ -1326,7 +1328,7 @@ class Build(
# Check which parts we can "use" (may include variants and substitutes)
available_parts = bom_item.get_valid_parts_for_allocation(
allow_variants=True, allow_substitutes=substitutes
allow_variants=True, allow_inactive=False, allow_substitutes=substitutes
)
# Look for available stock items
@@ -1369,10 +1371,7 @@ class Build(
key=lambda item, b=bom_item, v=variant_parts: stock_sort(item, b, v),
)
if len(available_stock) == 0:
# No stock items are available
continue
elif len(available_stock) == 1 or interchangeable:
if len(available_stock) == 1 or interchangeable:
# Either there is only a single stock item available,
# or all items are "interchangeable" and we don't care where we take stock from
@@ -1746,11 +1745,10 @@ class BuildItem(InvenTree.models.InvenTreeMetadataModel):
def save(self, *args, **kwargs):
"""Custom save method for the BuildItem model."""
self.clean()
self.clean(raise_error=False)
super().save()
def clean(self):
def clean(self, raise_error: bool = True):
"""Check validity of this BuildItem instance.
The following checks are performed:
@@ -1774,47 +1772,7 @@ class BuildItem(InvenTree.models.InvenTreeMetadataModel):
)
)
# Allocated quantity cannot exceed available stock quantity
if self.quantity > self.stock_item.quantity:
q = InvenTree.helpers.normalize(self.quantity)
a = InvenTree.helpers.normalize(self.stock_item.quantity)
raise ValidationError({
'quantity': _(
f'Allocated quantity ({q}) must not exceed available stock quantity ({a})'
)
})
# Ensure that we do not 'over allocate' a stock item
available = decimal.Decimal(self.stock_item.quantity)
quantity = decimal.Decimal(self.quantity)
build_allocation_count = decimal.Decimal(
self.stock_item.build_allocation_count(
exclude_allocations={'pk': self.pk}
)
)
sales_allocation_count = decimal.Decimal(
self.stock_item.sales_order_allocation_count()
)
total_allocation = (
build_allocation_count + sales_allocation_count + quantity
)
if total_allocation > available:
raise ValidationError({'quantity': _('Stock item is over-allocated')})
# Allocated quantity must be positive
if self.quantity <= 0:
raise ValidationError({
'quantity': _('Allocation quantity must be greater than zero')
})
# Quantity must be 1 for serialized stock
if self.stock_item.serialized and self.quantity != 1:
raise ValidationError({
'quantity': _('Quantity must be 1 for serialized stock')
})
self.check_allocated_quantity(raise_error=raise_error)
except stock.models.StockItem.DoesNotExist:
raise ValidationError('Stock item must be specified')
@@ -1876,6 +1834,60 @@ class BuildItem(InvenTree.models.InvenTreeMetadataModel):
'stock_item': _('Selected stock item does not match BOM line')
})
def check_allocated_quantity(self, raise_error: bool = False):
"""Ensure that the allocated quantity is valid.
Will reduce the allocated quantity if it exceeds available stock.
Arguments:
raise_error: If True, raise ValidationError on failure
Raises:
ValidationError: If the allocated quantity is invalid and raise_error is True
"""
error = None
# Allocated quantity must be positive
if self.quantity <= 0:
self.quantity = 0
error = {'quantity': _('Allocated quantity must be greater than zero')}
# Quantity must be 1 for serialized stock
if self.stock_item.serialized and self.quantity != 1:
self.quantity = 1
raise ValidationError({
'quantity': _('Quantity must be 1 for serialized stock')
})
# Allocated quantity cannot exceed available stock quantity
if self.quantity > self.stock_item.quantity:
q = InvenTree.helpers.normalize(self.quantity)
a = InvenTree.helpers.normalize(self.stock_item.quantity)
self.quantity = self.stock_item.quantity
error = {
'quantity': _(
f'Allocated quantity ({q}) must not exceed available stock quantity ({a})'
)
}
# Ensure that we do not 'over allocate' a stock item
available = decimal.Decimal(self.stock_item.quantity)
quantity = decimal.Decimal(self.quantity)
build_allocation_count = decimal.Decimal(
self.stock_item.build_allocation_count(exclude_allocations={'pk': self.pk})
)
sales_allocation_count = decimal.Decimal(
self.stock_item.sales_order_allocation_count()
)
total_allocation = build_allocation_count + sales_allocation_count + quantity
if total_allocation > available:
error = {'quantity': _('Stock item is over-allocated')}
if error and raise_error:
raise ValidationError(error)
@property
def build(self):
"""Return the BuildOrder associated with this BuildItem."""
@@ -1887,7 +1899,7 @@ class BuildItem(InvenTree.models.InvenTreeMetadataModel):
return self.build_line.bom_item if self.build_line else None
@transaction.atomic
def complete_allocation(self, quantity=None, notes='', user=None) -> None:
def complete_allocation(self, quantity=None, notes: str = '', user=None) -> None:
"""Complete the allocation of this BuildItem into the output stock item.
Arguments:
@@ -1910,9 +1922,7 @@ class BuildItem(InvenTree.models.InvenTreeMetadataModel):
# Ensure we are not allocating more than available
if quantity > item.quantity:
raise ValidationError({
'quantity': _('Allocated quantity exceeds available stock quantity')
})
quantity = item.quantity
# Split the allocated stock if there are more available than allocated
if item.quantity > quantity:
+5 -1
View File
@@ -1378,7 +1378,9 @@ class BuildLineSerializer(
)
allocations = enable_filter(
BuildItemSerializer(many=True, read_only=True, build_detail=False),
BuildItemSerializer(
many=True, read_only=True, allow_null=True, build_detail=False
),
True,
prefetch_fields=[
'allocations',
@@ -1426,6 +1428,7 @@ class BuildLineSerializer(
source='bom_item',
many=False,
read_only=True,
allow_null=True,
pricing=False,
substitutes=False,
sub_part_detail=False,
@@ -1455,6 +1458,7 @@ class BuildLineSerializer(
source='bom_item.sub_part',
many=False,
read_only=True,
allow_null=True,
pricing=False,
),
False,
+76 -26
View File
@@ -7,6 +7,7 @@ from django.conf import settings
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError
from django.db.models import Q
from django.http import JsonResponse
from django.http.response import HttpResponse
from django.urls import include, path, re_path
from django.utils.decorators import method_decorator
@@ -33,11 +34,18 @@ import common.filters
import common.models
import common.serializers
import InvenTree.conversion
import InvenTree.ready
from common.icons import get_icon_packs
from common.settings import get_global_setting
from data_exporter.mixins import DataExportViewMixin
from generic.states.api import urlpattern as generic_states_api_urls
from InvenTree.api import BulkCreateMixin, BulkDeleteMixin, MetadataView
from InvenTree.api import (
BulkCreateMixin,
BulkDeleteMixin,
GenericMetadataView,
SimpleGenericMetadataView,
meta_path,
)
from InvenTree.config import CONFIG_LOOKUPS
from InvenTree.filters import (
ORDER_FILTER,
@@ -1065,6 +1073,48 @@ class TestEmail(CreateAPI):
) # pragma: no cover
class HealthCheckStatusSerializer(serializers.Serializer):
"""Status of the overall system health."""
status = serializers.ChoiceField(
help_text='Health status of the InvenTree server',
choices=['ok', 'loading'],
read_only=True,
default='ok',
)
class HealthCheckView(APIView):
"""Simple JSON endpoint for InvenTree health check.
Intended to be used by external services to confirm that the InvenTree server is running.
"""
permission_classes = [AllowAnyOrReadScope]
@extend_schema(
responses={
200: OpenApiResponse(
response=HealthCheckStatusSerializer,
description='InvenTree server health status',
)
}
)
def get(self, request, *args, **kwargs):
"""Simple health check endpoint for monitoring purposes.
Use the root API endpoint for more detailed information (using an authenticated request).
"""
status = (
InvenTree.ready.isPluginRegistryLoaded()
if settings.PLUGINS_ENABLED
else True
)
return JsonResponse(
{'status': 'ok' if status else 'loading'}, status=200 if status else 503
)
selection_urls = [
path(
'<int:pk>/',
@@ -1154,11 +1204,7 @@ common_api_urls = [
path(
'<int:pk>/',
include([
path(
'metadata/',
MetadataView.as_view(model=common.models.Attachment),
name='api-attachment-metadata',
),
meta_path(common.models.Attachment),
path('', AttachmentDetail.as_view(), name='api-attachment-detail'),
]),
),
@@ -1175,13 +1221,7 @@ common_api_urls = [
path(
'<int:pk>/',
include([
path(
'metadata/',
MetadataView.as_view(
model=common.models.ParameterTemplate
),
name='api-parameter-template-metadata',
),
meta_path(common.models.ParameterTemplate),
path(
'',
ParameterTemplateDetail.as_view(),
@@ -1199,11 +1239,7 @@ common_api_urls = [
path(
'<int:pk>/',
include([
path(
'metadata/',
MetadataView.as_view(model=common.models.Parameter),
name='api-parameter-metadata',
),
meta_path(common.models.Parameter),
path('', ParameterDetail.as_view(), name='api-parameter-detail'),
]),
),
@@ -1217,6 +1253,22 @@ common_api_urls = [
path('', ErrorMessageList.as_view(), name='api-error-list'),
]),
),
# Metadata
path(
'metadata/',
include([
path(
'<str:model>/<str:lookup_field>/<str:lookup_value>/',
GenericMetadataView.as_view(),
name='api-generic-metadata',
),
path(
'<str:model>/<int:pk>/',
SimpleGenericMetadataView.as_view(),
name='api-generic-metadata',
),
]),
),
# Project codes
path(
'project-code/',
@@ -1224,14 +1276,7 @@ common_api_urls = [
path(
'<int:pk>/',
include([
path(
'metadata/',
MetadataView.as_view(
model=common.models.ProjectCode,
permission_classes=[IsStaffOrReadOnlyScope],
),
name='api-project-code-metadata',
),
meta_path(common.models.ProjectCode),
path(
'', ProjectCodeDetail.as_view(), name='api-project-code-detail'
),
@@ -1345,6 +1390,11 @@ common_api_urls = [
path('', DataOutputList.as_view(), name='api-data-output-list'),
]),
),
# System APIs (related to basic system functions)
path(
'system/',
include([path('health/', HealthCheckView.as_view(), name='api-system-health')]),
),
]
admin_api_urls = [
+16
View File
@@ -970,6 +970,22 @@ class BaseInvenTreeSetting(models.Model):
return setting.get('model', None)
def confirm(self) -> bool:
"""Return if this setting requires confirmation on change."""
setting = self.get_setting_definition(
self.key, **self.get_filters_for_instance()
)
return setting.get('confirm', False)
def confirm_text(self) -> str:
"""Return the confirmation text for this setting, if provided."""
setting = self.get_setting_definition(
self.key, **self.get_filters_for_instance()
)
return setting.get('confirm_text', '')
def model_filters(self) -> Optional[dict]:
"""Return the model filters associated with this setting."""
setting = self.get_setting_definition(
@@ -177,9 +177,9 @@ def trigger_notification(obj: Model, category: str = '', obj_ref: str = 'pk', **
# Filter out any users who are inactive, or do not have the required model permissions
valid_users = list(
filter(
lambda u: u
and u.is_active
and (not obj or check_user_permission(u, obj, 'view')),
lambda u: (
u and u.is_active and (not obj or check_user_permission(u, obj, 'view'))
),
list(target_users),
)
)
+39 -10
View File
@@ -88,6 +88,18 @@ class SettingsSerializer(InvenTreeModelSerializer):
choices = serializers.SerializerMethodField()
def get_choices(self, obj) -> list:
"""Returns the choices available for a given item."""
results = []
choices = obj.choices()
if choices:
for choice in choices:
results.append({'value': choice[0], 'display_name': choice[1]})
return results
model_name = serializers.CharField(read_only=True, allow_null=True)
model_filters = serializers.DictField(read_only=True)
@@ -108,17 +120,26 @@ class SettingsSerializer(InvenTreeModelSerializer):
typ = serializers.CharField(read_only=True)
def get_choices(self, obj) -> list:
"""Returns the choices available for a given item."""
results = []
confirm = serializers.BooleanField(
read_only=True,
help_text=_('Indicates if changing this setting requires confirmation'),
)
choices = obj.choices()
confirm_text = serializers.CharField(read_only=True)
if choices:
for choice in choices:
results.append({'value': choice[0], 'display_name': choice[1]})
return results
def is_valid(self, *, raise_exception=False):
"""Validate the setting, including confirmation if required."""
ret = super().is_valid(raise_exception=raise_exception)
# Check if confirmation was provided if required
if self.instance.confirm():
req_data = self.context['request'].data
if not 'manual_confirm' in req_data or not req_data['manual_confirm']:
raise serializers.ValidationError({
'manual_confirm': _(
'This setting requires confirmation before changing. Please confirm the change.'
)
})
return ret
class GlobalSettingsSerializer(SettingsSerializer):
@@ -141,6 +162,8 @@ class GlobalSettingsSerializer(SettingsSerializer):
'api_url',
'typ',
'read_only',
'confirm',
'confirm_text',
]
read_only = serializers.SerializerMethodField(
@@ -184,6 +207,8 @@ class UserSettingsSerializer(SettingsSerializer):
'model_name',
'api_url',
'typ',
'confirm',
'confirm_text',
]
user = serializers.PrimaryKeyRelatedField(read_only=True)
@@ -232,6 +257,8 @@ class GenericReferencedSettingSerializer(SettingsSerializer):
'typ',
'units',
'required',
'confirm',
'confirm_text',
]
# set Meta class
@@ -808,7 +835,9 @@ class ParameterSerializer(
)
updated_by_detail = enable_filter(
UserSerializer(source='updated_by', read_only=True, many=False),
UserSerializer(
source='updated_by', read_only=True, allow_null=True, many=False
),
True,
prefetch_fields=['updated_by'],
)
@@ -106,6 +106,20 @@ def reload_plugin_registry(setting):
registry.reload_plugins(full_reload=True, force_reload=True, collect=True)
def enforce_mfa(setting):
"""Enforce multifactor authentication for all users."""
from allauth.usersessions.models import UserSession
from common.models import logger
logger.info(
'Enforcing multifactor authentication for all users by signing out all sessions.'
)
for session in UserSession.objects.all():
session.end()
logger.info('All user sessions have been ended.')
def barcode_plugins() -> list:
"""Return a list of plugin choices which can be used for barcode generation."""
try:
@@ -1007,6 +1021,11 @@ SYSTEM_SETTINGS: dict[str, InvenTreeSettingsKeyType] = {
'description': _('Users must use multifactor security.'),
'default': False,
'validator': bool,
'confirm': True,
'confirm_text': _(
'Enabling this setting will require all users to set up multifactor authentication. All sessions will be disconnected immediately.'
),
'after_save': enforce_mfa,
},
'PLUGIN_ON_STARTUP': {
'name': _('Check plugins on startup'),
@@ -32,6 +32,8 @@ class SettingsKeyType(TypedDict, total=False):
protected: Protected values are not returned to the client, instead "***" is returned (optional, default: False)
required: Is this setting required to work, can be used in combination with .check_all_settings(...) (optional, default: False)
model: Auto create a dropdown menu to select an associated model instance (e.g. 'company.company', 'auth.user' and 'auth.group' are possible too, optional)
confirm: Require an explicit confirmation before changing the setting (optional, default: False)
confirm_text: Text to display in the confirmation dialog (optional)
"""
name: str
@@ -46,6 +48,8 @@ class SettingsKeyType(TypedDict, total=False):
protected: bool
required: bool
model: str
confirm: bool
confirm_text: str
class InvenTreeSettingsKeyType(SettingsKeyType):
+43
View File
@@ -408,6 +408,8 @@ class SettingsTest(InvenTreeTestCase):
'requires_restart',
'after_save',
'before_save',
'confirm',
'confirm_text',
]
for k in setting:
@@ -641,6 +643,18 @@ class GlobalSettingsApiTest(InvenTreeAPITestCase):
setting.refresh_from_db()
self.assertEqual(setting.value, val)
def test_mfa_change(self):
"""Test that changes in LOGIN_ENFORCE_MFA are handled correctly."""
# Setup admin users
self.user.usersession_set.create(ip='192.168.1.1')
self.assertEqual(self.user.usersession_set.count(), 1)
# Enable enforced MFA
set_global_setting('LOGIN_ENFORCE_MFA', True)
# There should be no user sessions now
self.assertEqual(self.user.usersession_set.count(), 0)
def test_api_detail(self):
"""Test that we can access the detail view for a setting based on the <key>."""
# These keys are invalid, and should return 404
@@ -1471,6 +1485,35 @@ class CommonTest(InvenTreeAPITestCase):
self.user.is_superuser = False
self.user.save()
def test_health_api(self):
"""Test health check URL."""
from plugin import registry
# Fully started system - ok
response_data = self.get(reverse('api-system-health'), expected_code=200).json()
self.assertIn('status', response_data)
self.assertEqual(response_data['status'], 'ok')
# Simulate plugin reloading - Not ready
try:
registry.plugins_loaded = False
response_data = self.get(
reverse('api-system-health'), expected_code=503
).json()
self.assertIn('status', response_data)
self.assertEqual(response_data['status'], 'loading')
finally:
registry.plugins_loaded = True
# No plugins enabled - still ok
with self.settings(PLUGINS_ENABLED=False):
self.assertEqual(
self.get(reverse('api-system-health'), expected_code=200).json()[
'status'
],
'ok',
)
class CurrencyAPITests(InvenTreeAPITestCase):
"""Unit tests for the currency exchange API endpoints."""
+10 -22
View File
@@ -9,7 +9,7 @@ from django_filters.rest_framework.filterset import FilterSet
import part.models
from data_exporter.mixins import DataExportViewMixin
from InvenTree.api import ListCreateDestroyAPIView, MetadataView, ParameterListMixin
from InvenTree.api import ListCreateDestroyAPIView, ParameterListMixin, meta_path
from InvenTree.fields import InvenTreeOutputOption, OutputConfiguration
from InvenTree.filters import SEARCH_ORDER_FILTER, SEARCH_ORDER_FILTER_ALIAS
from InvenTree.mixins import (
@@ -447,7 +447,11 @@ class SupplierPriceBreakOutputOptions(OutputConfiguration):
class SupplierPriceBreakList(
SupplierPriceBreakMixin, SerializerContextMixin, OutputOptionsMixin, ListCreateAPI
DataExportViewMixin,
SupplierPriceBreakMixin,
SerializerContextMixin,
OutputOptionsMixin,
ListCreateAPI,
):
"""API endpoint for list view of SupplierPriceBreak object.
@@ -476,11 +480,7 @@ manufacturer_part_api_urls = [
path(
'<int:pk>/',
include([
path(
'metadata/',
MetadataView.as_view(model=ManufacturerPart),
name='api-manufacturer-part-metadata',
),
meta_path(ManufacturerPart),
path(
'',
ManufacturerPartDetail.as_view(),
@@ -497,11 +497,7 @@ supplier_part_api_urls = [
path(
'<int:pk>/',
include([
path(
'metadata/',
MetadataView.as_view(model=SupplierPart),
name='api-supplier-part-metadata',
),
meta_path(SupplierPart),
path('', SupplierPartDetail.as_view(), name='api-supplier-part-detail'),
]),
),
@@ -532,11 +528,7 @@ company_api_urls = [
path(
'<int:pk>/',
include([
path(
'metadata/',
MetadataView.as_view(model=Company),
name='api-company-metadata',
),
meta_path(Company),
path('', CompanyDetail.as_view(), name='api-company-detail'),
]),
),
@@ -546,11 +538,7 @@ company_api_urls = [
path(
'<int:pk>/',
include([
path(
'metadata/',
MetadataView.as_view(model=Contact),
name='api-contact-metadata',
),
meta_path(Contact),
path('', ContactDetail.as_view(), name='api-contact-detail'),
]),
),
+3 -7
View File
@@ -268,11 +268,7 @@ class ManufacturerPartSerializer(
source='part', many=False, read_only=True, allow_null=True
),
True,
prefetch_fields=[
Prefetch(
'part', queryset=part.models.Part.objects.select_related('pricing_data')
)
],
prefetch_fields=['part', 'part__pricing_data', 'part__category'],
)
pretty_name = enable_filter(
@@ -438,7 +434,7 @@ class SupplierPartSerializer(
label=_('Part'), source='part', many=False, read_only=True, allow_null=True
),
False,
prefetch_fields=['part'],
prefetch_fields=['part', 'part__pricing_data'],
)
supplier_detail = enable_filter(
@@ -544,8 +540,8 @@ class SupplierPartSerializer(
@register_importer()
class SupplierPriceBreakSerializer(
SupplierPriceBreakBriefSerializer,
DataImportExportSerializerMixin,
SupplierPriceBreakBriefSerializer,
InvenTreeModelSerializer,
):
"""Serializer for SupplierPriceBreak object.
+3 -53
View File
@@ -505,7 +505,9 @@ class ManufacturerTest(InvenTreeAPITestCase):
def test_manufacturer_part_detail(self):
"""Tests for the ManufacturerPart detail endpoint."""
url = reverse('api-manufacturer-part-detail', kwargs={'pk': 1})
mp = ManufacturerPart.objects.first()
url = reverse('api-manufacturer-part-detail', kwargs={'pk': mp.pk})
response = self.get(url)
self.assertEqual(response.data['MPN'], 'MPN123')
@@ -747,58 +749,6 @@ class SupplierPartTest(InvenTreeAPITestCase):
)
class CompanyMetadataAPITest(InvenTreeAPITestCase):
"""Unit tests for the various metadata endpoints of API."""
fixtures = [
'category',
'part',
'location',
'company',
'contact',
'manufacturer_part',
'supplier_part',
]
roles = ['company.change', 'purchase_order.change', 'part.change']
def metatester(self, apikey, model):
"""Generic tester."""
modeldata = model.objects.first()
# Useless test unless a model object is found
self.assertIsNotNone(modeldata)
url = reverse(apikey, kwargs={'pk': modeldata.pk})
# Metadata is initially null
self.assertIsNone(modeldata.metadata)
numstr = f'12{len(apikey)}'
self.patch(
url,
{'metadata': {f'abc-{numstr}': f'xyz-{apikey}-{numstr}'}},
expected_code=200,
)
# Refresh
modeldata.refresh_from_db()
self.assertEqual(
modeldata.get_metadata(f'abc-{numstr}'), f'xyz-{apikey}-{numstr}'
)
def test_metadata(self):
"""Test all endpoints."""
for apikey, model in {
'api-manufacturer-part-metadata': ManufacturerPart,
'api-supplier-part-metadata': SupplierPart,
'api-company-metadata': Company,
'api-contact-metadata': Contact,
}.items():
self.metatester(apikey, model)
class SupplierPriceBreakAPITest(InvenTreeAPITestCase):
"""Unit tests for the SupplierPart price break API."""
+12 -1
View File
@@ -337,6 +337,12 @@ class DataExportViewMixin:
# Update the output instance with the total number of items to export
output.total = queryset.count()
output.save()
request = context.get('request', None)
if request:
query_params = getattr(request, 'query_params', {})
context.update(**query_params)
context['request'] = request
data = None
serializer = serializer_class(context=context, exporting=True)
@@ -363,7 +369,12 @@ class DataExportViewMixin:
# The returned data *must* be a list of dict objects
try:
data = export_plugin.export_data(
queryset, serializer_class, headers, export_context, output
queryset,
serializer_class,
headers,
export_context,
output,
serializer_context=context,
)
except Exception as e:
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
+1
View File
@@ -17,6 +17,7 @@ def main():
'available on your PYTHONPATH environment variable? Did you '
'forget to activate a virtual environment?'
) from exc
execute_from_command_line(sys.argv)
+17 -57
View File
@@ -31,8 +31,8 @@ from generic.states.api import StatusView
from InvenTree.api import (
BulkUpdateMixin,
ListCreateDestroyAPIView,
MetadataView,
ParameterListMixin,
meta_path,
)
from InvenTree.fields import InvenTreeOutputOption, OutputConfiguration
from InvenTree.filters import (
@@ -362,7 +362,9 @@ class PurchaseOrderOutputOptions(OutputConfiguration):
class PurchaseOrderMixin(SerializerContextMixin):
"""Mixin class for PurchaseOrder endpoints."""
queryset = models.PurchaseOrder.objects.all()
queryset = models.PurchaseOrder.objects.all().prefetch_related(
'supplier', 'created_by'
)
serializer_class = serializers.PurchaseOrderSerializer
def get_queryset(self, *args, **kwargs):
@@ -371,8 +373,6 @@ class PurchaseOrderMixin(SerializerContextMixin):
queryset = serializers.PurchaseOrderSerializer.annotate_queryset(queryset)
queryset = queryset.prefetch_related('supplier', 'created_by')
return queryset
@@ -824,15 +824,15 @@ class SalesOrderFilter(OrderFilter):
class SalesOrderMixin(SerializerContextMixin):
"""Mixin class for SalesOrder endpoints."""
queryset = models.SalesOrder.objects.all()
queryset = models.SalesOrder.objects.all().prefetch_related(
'customer', 'created_by'
)
serializer_class = serializers.SalesOrderSerializer
def get_queryset(self, *args, **kwargs):
"""Return annotated queryset for this endpoint."""
queryset = super().get_queryset(*args, **kwargs)
queryset = queryset.prefetch_related('customer', 'created_by')
queryset = serializers.SalesOrderSerializer.annotate_queryset(queryset)
return queryset
@@ -1888,11 +1888,7 @@ order_api_urls = [
name='api-po-complete',
),
path('issue/', PurchaseOrderIssue.as_view(), name='api-po-issue'),
path(
'metadata/',
MetadataView.as_view(model=models.PurchaseOrder),
name='api-po-metadata',
),
meta_path(models.PurchaseOrder),
path(
'receive/',
PurchaseOrderReceive.as_view(),
@@ -1920,11 +1916,7 @@ order_api_urls = [
path(
'<int:pk>/',
include([
path(
'metadata/',
MetadataView.as_view(model=models.PurchaseOrderLineItem),
name='api-po-line-metadata',
),
meta_path(models.PurchaseOrderLineItem),
path(
'',
PurchaseOrderLineItemDetail.as_view(),
@@ -1942,11 +1934,7 @@ order_api_urls = [
path(
'<int:pk>/',
include([
path(
'metadata/',
MetadataView.as_view(model=models.PurchaseOrderExtraLine),
name='api-po-extra-line-metadata',
),
meta_path(models.PurchaseOrderExtraLine),
path(
'',
PurchaseOrderExtraLineDetail.as_view(),
@@ -1974,11 +1962,7 @@ order_api_urls = [
SalesOrderShipmentComplete.as_view(),
name='api-so-shipment-ship',
),
path(
'metadata/',
MetadataView.as_view(model=models.SalesOrderShipment),
name='api-so-shipment-metadata',
),
meta_path(models.SalesOrderShipment),
path(
'',
SalesOrderShipmentDetail.as_view(),
@@ -2015,11 +1999,7 @@ order_api_urls = [
SalesOrderComplete.as_view(),
name='api-so-complete',
),
path(
'metadata/',
MetadataView.as_view(model=models.SalesOrder),
name='api-so-metadata',
),
meta_path(models.SalesOrder),
# SalesOrder detail endpoint
path('', SalesOrderDetail.as_view(), name='api-so-detail'),
]),
@@ -2042,11 +2022,7 @@ order_api_urls = [
path(
'<int:pk>/',
include([
path(
'metadata/',
MetadataView.as_view(model=models.SalesOrderLineItem),
name='api-so-line-metadata',
),
meta_path(models.SalesOrderLineItem),
path(
'',
SalesOrderLineItemDetail.as_view(),
@@ -2064,11 +2040,7 @@ order_api_urls = [
path(
'<int:pk>/',
include([
path(
'metadata/',
MetadataView.as_view(model=models.SalesOrderExtraLine),
name='api-so-extra-line-metadata',
),
meta_path(models.SalesOrderExtraLine),
path(
'',
SalesOrderExtraLineDetail.as_view(),
@@ -2120,11 +2092,7 @@ order_api_urls = [
ReturnOrderReceive.as_view(),
name='api-return-order-receive',
),
path(
'metadata/',
MetadataView.as_view(model=models.ReturnOrder),
name='api-return-order-metadata',
),
meta_path(models.ReturnOrder),
path(
'', ReturnOrderDetail.as_view(), name='api-return-order-detail'
),
@@ -2148,11 +2116,7 @@ order_api_urls = [
path(
'<int:pk>/',
include([
path(
'metadata/',
MetadataView.as_view(model=models.ReturnOrderLineItem),
name='api-return-order-line-metadata',
),
meta_path(models.ReturnOrderLineItem),
path(
'',
ReturnOrderLineItemDetail.as_view(),
@@ -2179,11 +2143,7 @@ order_api_urls = [
path(
'<int:pk>/',
include([
path(
'metadata/',
MetadataView.as_view(model=models.ReturnOrderExtraLine),
name='api-return-order-extra-line-metadata',
),
meta_path(models.ReturnOrderExtraLine),
path(
'',
ReturnOrderExtraLineDetail.as_view(),
@@ -34,7 +34,7 @@ class Migration(migrations.Migration):
('reference', models.CharField(blank=True, help_text='Line item reference', max_length=100, verbose_name='Reference')),
('notes', models.CharField(blank=True, help_text='Line item notes', max_length=500, verbose_name='Notes')),
('target_date', models.DateField(blank=True, help_text='Target date for this line item (leave blank to use the target date from the order)', null=True, verbose_name='Target Date')),
('received_date', models.DateField(blank=True, help_text='The date this this return item was received', null=True, verbose_name='Received Date')),
('received_date', models.DateField(blank=True, help_text='The date this return item was received', null=True, verbose_name='Received Date')),
('outcome', models.PositiveIntegerField(choices=[(10, 'Pending'), (20, 'Return'), (30, 'Repair'), (50, 'Refund'), (40, 'Replace'), (60, 'Reject')], default=10, help_text='Outcome for this line item', verbose_name='Outcome')),
('price_currency', djmoney.models.fields.CurrencyField(choices=[], default='', editable=False, max_length=3)),
('price', InvenTree.fields.InvenTreeModelMoneyField(blank=True, currency_choices=[], decimal_places=6, default_currency='', help_text='Cost associated with return or repair for this line item', max_digits=19, null=True, validators=[djmoney.models.validators.MinMoneyValidator(0)], verbose_name='Price')),
+5 -4
View File
@@ -432,7 +432,8 @@ class Order(
Makes use of the overdue_filter() method to avoid code duplication
"""
return (
self.__class__.objects.filter(pk=self.pk)
self.__class__.objects
.filter(pk=self.pk)
.filter(self.__class__.overdue_filter())
.exists()
)
@@ -2909,7 +2910,7 @@ class ReturnOrder(TotalPriceMixin, Order):
line.item = stock_item
line.save()
status = kwargs.get('status')
status = kwargs.get('status', StockStatus.QUARANTINED.value)
if status is None:
status = StockStatus.QUARANTINED.value
@@ -2920,7 +2921,7 @@ class ReturnOrder(TotalPriceMixin, Order):
deltas['customer'] = stock_item.customer.pk
# Update the StockItem
stock_item.status = status
stock_item.set_status(status)
stock_item.location = location
stock_item.customer = None
stock_item.sales_order = None
@@ -3021,7 +3022,7 @@ class ReturnOrderLineItem(StatusCodeMixin, OrderLineItem):
null=True,
blank=True,
verbose_name=_('Received Date'),
help_text=_('The date this this return item was received'),
help_text=_('The date this return item was received'),
)
@property
-60
View File
@@ -2751,63 +2751,3 @@ class ReturnOrderLineItemTests(InvenTreeAPITestCase):
line = models.ReturnOrderLineItem.objects.get(pk=1)
self.assertEqual(float(line.price.amount), 15.75)
class OrderMetadataAPITest(InvenTreeAPITestCase):
"""Unit tests for the various metadata endpoints of API."""
fixtures = [
'category',
'part',
'company',
'location',
'supplier_part',
'stock',
'order',
'sales_order',
'return_order',
]
roles = ['purchase_order.change', 'sales_order.change', 'return_order.change']
def metatester(self, apikey, model):
"""Generic tester."""
modeldata = model.objects.first()
# Useless test unless a model object is found
self.assertIsNotNone(modeldata)
url = reverse(apikey, kwargs={'pk': modeldata.pk})
# Metadata is initially null
self.assertIsNone(modeldata.metadata)
numstr = f'12{len(apikey)}'
self.patch(
url,
{'metadata': {f'abc-{numstr}': f'xyz-{apikey}-{numstr}'}},
expected_code=200,
)
# Refresh
modeldata.refresh_from_db()
self.assertEqual(
modeldata.get_metadata(f'abc-{numstr}'), f'xyz-{apikey}-{numstr}'
)
def test_metadata(self):
"""Test all endpoints."""
for apikey, model in {
'api-po-metadata': models.PurchaseOrder,
'api-po-line-metadata': models.PurchaseOrderLineItem,
'api-po-extra-line-metadata': models.PurchaseOrderExtraLine,
'api-so-shipment-metadata': models.SalesOrderShipment,
'api-so-metadata': models.SalesOrder,
'api-so-line-metadata': models.SalesOrderLineItem,
'api-so-extra-line-metadata': models.SalesOrderExtraLine,
'api-return-order-metadata': models.ReturnOrder,
'api-return-order-line-metadata': models.ReturnOrderLineItem,
'api-return-order-extra-line-metadata': models.ReturnOrderExtraLine,
}.items():
self.metatester(apikey, model)
+11 -37
View File
@@ -18,8 +18,8 @@ from InvenTree.api import (
BulkDeleteMixin,
BulkUpdateMixin,
ListCreateDestroyAPIView,
MetadataView,
ParameterListMixin,
meta_path,
)
from InvenTree.fields import InvenTreeOutputOption, OutputConfiguration
from InvenTree.filters import (
@@ -1009,7 +1009,9 @@ class PartMixin(SerializerContextMixin):
"""Mixin class for Part API endpoints."""
serializer_class = part_serializers.PartSerializer
queryset = Part.objects.all().select_related('pricing_data')
queryset = (
Part.objects.all().select_related('pricing_data').prefetch_related('category')
)
starred_parts = None
is_create = False
@@ -1498,13 +1500,7 @@ part_api_urls = [
path(
'<int:pk>/',
include([
path(
'metadata/',
MetadataView.as_view(
model=PartCategoryParameterTemplate
),
name='api-part-category-parameter-metadata',
),
meta_path(PartCategoryParameterTemplate),
path(
'',
CategoryParameterDetail.as_view(),
@@ -1523,11 +1519,7 @@ part_api_urls = [
path(
'<int:pk>/',
include([
path(
'metadata/',
MetadataView.as_view(model=PartCategory),
name='api-part-category-metadata',
),
meta_path(PartCategory),
# PartCategory detail endpoint
path('', CategoryDetail.as_view(), name='api-part-category-detail'),
]),
@@ -1542,11 +1534,7 @@ part_api_urls = [
path(
'<int:pk>/',
include([
path(
'metadata/',
MetadataView.as_view(model=PartTestTemplate),
name='api-part-test-template-metadata',
),
meta_path(PartTestTemplate),
path(
'',
PartTestTemplateDetail.as_view(),
@@ -1592,11 +1580,7 @@ part_api_urls = [
path(
'<int:pk>/',
include([
path(
'metadata/',
MetadataView.as_view(model=PartRelated),
name='api-part-related-metadata',
),
meta_path(PartRelated),
path(
'', PartRelatedDetail.as_view(), name='api-part-related-detail'
),
@@ -1647,9 +1631,7 @@ part_api_urls = [
'bom-validate/', PartValidateBOM.as_view(), name='api-part-bom-validate'
),
# Part metadata
path(
'metadata/', MetadataView.as_view(model=Part), name='api-part-metadata'
),
meta_path(Part),
# Part pricing
path('pricing/', PartPricingDetail.as_view(), name='api-part-pricing'),
# Part detail endpoint
@@ -1667,11 +1649,7 @@ bom_api_urls = [
path(
'<int:pk>/',
include([
path(
'metadata/',
MetadataView.as_view(model=BomItemSubstitute),
name='api-bom-substitute-metadata',
),
meta_path(BomItemSubstitute),
path(
'',
BomItemSubstituteDetail.as_view(),
@@ -1688,11 +1666,7 @@ bom_api_urls = [
'<int:pk>/',
include([
path('validate/', BomItemValidate.as_view(), name='api-bom-item-validate'),
path(
'metadata/',
MetadataView.as_view(model=BomItem),
name='api-bom-item-metadata',
),
meta_path(BomItem),
path('', BomDetail.as_view(), name='api-bom-item-detail'),
]),
),
+6 -3
View File
@@ -297,7 +297,8 @@ def annotate_variant_quantity(subquery: Q, reference: str = 'quantity') -> Query
"""
return Coalesce(
Subquery(
subquery.annotate(
subquery
.annotate(
total=Func(F(reference), function='SUM', output_field=FloatField())
)
.values('total')
@@ -324,7 +325,8 @@ def annotate_category_parts() -> QuerySet:
return Coalesce(
Subquery(
subquery.annotate(
subquery
.annotate(
total=Func(F('pk'), function='COUNT', output_field=IntegerField())
)
.values('total')
@@ -369,7 +371,8 @@ def annotate_sub_categories() -> QuerySet:
return Coalesce(
Subquery(
subquery.annotate(
subquery
.annotate(
total=Func(F('pk'), function='COUNT', output_field=IntegerField())
)
.values('total')
+22 -6
View File
@@ -225,7 +225,8 @@ class PartCategory(
def prefetch_parts_parameters(self, cascade=True):
"""Prefectch parts parameters."""
return (
self.get_parts(cascade=cascade)
self
.get_parts(cascade=cascade)
.prefetch_related('parameters_list', 'parameters_list__template')
.all()
)
@@ -615,7 +616,8 @@ class Part(
if previous.image is not None and self.image != previous.image:
# Are there any (other) parts which reference the image?
n_refs = (
Part.objects.filter(image=previous.image)
Part.objects
.filter(image=previous.image)
.exclude(pk=self.pk)
.count()
)
@@ -1040,7 +1042,8 @@ class Part(
self.revision_of
and self.revision
and (
Part.objects.exclude(pk=self.pk)
Part.objects
.exclude(pk=self.pk)
.filter(revision_of=self.revision_of, revision=self.revision)
.exists()
)
@@ -1049,7 +1052,8 @@ class Part(
# Ensure unique across (Name, revision, IPN) (as specified)
if (self.revision or self.IPN) and (
Part.objects.exclude(pk=self.pk)
Part.objects
.exclude(pk=self.pk)
.filter(name=self.name, revision=self.revision, IPN=self.IPN)
.exists()
):
@@ -1815,7 +1819,7 @@ class Part(
if include_external is False:
# Exclude stock entries which are not 'internal'
query = query.filter(external=False)
query = query.filter(location__external=False)
if location:
locations = location.get_descendants(include_self=True)
@@ -3828,10 +3832,18 @@ class BomItem(InvenTree.models.MetadataMixin, InvenTree.models.InvenTreeModel):
return assemblies
def get_valid_parts_for_allocation(
self, allow_variants=True, allow_substitutes=True
self,
allow_variants: bool = True,
allow_substitutes: bool = True,
allow_inactive: bool = True,
):
"""Return a list of valid parts which can be allocated against this BomItem.
Arguments:
allow_variants: If True, include variants of the sub_part
allow_substitutes: If True, include any directly specified substitute parts
allow_inactive: If True, include inactive parts in the returned list
Includes:
- The referenced sub_part
- Any directly specified substitute parts
@@ -3864,6 +3876,10 @@ class BomItem(InvenTree.models.MetadataMixin, InvenTree.models.InvenTreeModel):
if p.trackable != self.sub_part.trackable:
continue
# Filter by 'active' status
if not allow_inactive and not p.active:
continue
valid_parts.append(p)
return valid_parts
-59
View File
@@ -3175,65 +3175,6 @@ class PartInternalPriceBreakTest(InvenTreeAPITestCase):
p.refresh_from_db()
class PartMetadataAPITest(InvenTreeAPITestCase):
"""Unit tests for the various metadata endpoints of API."""
fixtures = [
'category',
'part',
'params',
'location',
'bom',
'company',
'test_templates',
'manufacturer_part',
'supplier_part',
'order',
'stock',
]
roles = ['part.change', 'part_category.change']
def metatester(self, apikey, model):
"""Generic tester."""
modeldata = model.objects.first()
# Useless test unless a model object is found
self.assertIsNotNone(modeldata)
url = reverse(apikey, kwargs={'pk': modeldata.pk})
# Metadata is initially null
self.assertIsNone(modeldata.metadata)
numstr = randint(100, 900)
self.patch(
url,
{'metadata': {f'abc-{numstr}': f'xyz-{apikey}-{numstr}'}},
expected_code=200,
)
# Refresh
modeldata.refresh_from_db()
self.assertEqual(
modeldata.get_metadata(f'abc-{numstr}'), f'xyz-{apikey}-{numstr}'
)
def test_metadata(self):
"""Test all endpoints."""
for apikey, model in {
'api-part-category-parameter-metadata': PartCategoryParameterTemplate,
'api-part-category-metadata': PartCategory,
'api-part-test-template-metadata': PartTestTemplate,
'api-part-related-metadata': PartRelated,
'api-part-metadata': Part,
'api-bom-substitute-metadata': BomItemSubstitute,
'api-bom-item-metadata': BomItem,
}.items():
self.metatester(apikey, model)
class PartTestTemplateTest(PartAPITestBase):
"""API unit tests for the PartTestTemplate model."""
+7 -11
View File
@@ -17,7 +17,7 @@ from rest_framework.views import APIView
import InvenTree.permissions
import plugin.serializers as PluginSerializers
from InvenTree.api import MetadataView
from InvenTree.api import meta_path
from InvenTree.filters import SEARCH_ORDER_FILTER
from InvenTree.helpers import str2bool
from InvenTree.mixins import (
@@ -509,11 +509,11 @@ class RegistryStatusView(APIView):
return Response(result)
class PluginMetadataView(MetadataView):
"""Metadata API endpoint for the PluginConfig model."""
# class PluginMetadataView(MetadataView):
# """Metadata API endpoint for the PluginConfig model."""
lookup_field = 'key'
lookup_url_kwarg = 'plugin'
# lookup_field = 'key'
# lookup_url_kwarg = 'plugin'
plugin_api_urls = [
@@ -576,12 +576,8 @@ plugin_api_urls = [
),
]),
),
path(
'metadata/',
PluginMetadataView.as_view(
model=PluginConfig, lookup_field='key'
),
name='api-plugin-metadata',
meta_path(
PluginConfig, lookup_field='key', lookup_field_ref='plugin'
),
path(
'activate/',
@@ -93,7 +93,8 @@ class BarcodeView(CreateAPIView):
if num_scans > max_scans:
n = num_scans - max_scans
old_scan_ids = list(
BarcodeScanResult.objects.all()
BarcodeScanResult.objects
.all()
.order_by('timestamp')
.values_list('pk', flat=True)[:n]
)
@@ -191,7 +191,8 @@ class SupplierBarcodeMixin(BarcodeMixin):
q1 = Q(manufacturer=supplier)
# Case 2: Supplied by this supplier
m = (
SupplierPart.objects.filter(supplier=supplier)
SupplierPart.objects
.filter(supplier=supplier)
.values_list('manufacturer_part', flat=True)
.distinct()
)
@@ -90,6 +90,7 @@ class DataExportMixin:
headers: OrderedDict,
context: dict,
output: DataOutput,
serializer_context: Optional[dict] = None,
**kwargs,
) -> list:
"""Export data from the queryset.
@@ -100,6 +101,7 @@ class DataExportMixin:
Arguments:
queryset: The queryset to export
serializer_class: The serializer class to use for exporting the data
serializer_context: Optional context for the serializer
headers: The headers for the export
context: Any custom context for the export (provided by the plugin serializer)
output: The DataOutput object for the export
@@ -107,7 +109,9 @@ class DataExportMixin:
Returns: The exported data (a list of dict objects)
"""
# The default implementation simply serializes the queryset
return serializer_class(queryset, many=True, exporting=True).data
return serializer_class(
queryset, many=True, exporting=True, context=serializer_context or {}
).data
def get_export_options_serializer(self, **kwargs) -> serializers.Serializer | None:
"""Return a serializer class with dynamic export options for this plugin.
@@ -128,7 +128,7 @@ class InvenTreeSlackNotifications(NotificationMixin, SettingsMixin, InvenTreePlu
SETTINGS = {
'NOTIFICATION_SLACK_URL': {
'name': _('Slack incoming webhook url'),
'name': _('Slack incoming webhook URL'),
'description': _('URL that is used to send messages to a slack channel'),
'protected': True,
}

Some files were not shown because too many files have changed in this diff Show More