Merge branch 'master' of https://github.com/inventree/InvenTree into feat--matrix-testing
This commit is contained in:
commit
af526f1ef8
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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,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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 |
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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] |
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 = [
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
]),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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')),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
]),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Reference in New Issue