Merge branch 'master' into feature/docker-health-checks
This commit is contained in:
commit
d311732226
|
|
@ -3,7 +3,7 @@
|
||||||
# In contrast with the "production" image (which is based on an Alpine image)
|
# In contrast with the "production" image (which is based on an Alpine image)
|
||||||
# we use a Debian-based image for the devcontainer
|
# we use a Debian-based image for the devcontainer
|
||||||
|
|
||||||
FROM mcr.microsoft.com/devcontainers/python:3.11-bookworm@sha256:e754c29c4e3ffcf6c794c1020e36a0812341d88ec9569a34704b975fa89e8848
|
FROM mcr.microsoft.com/devcontainers/python:3.12-trixie@sha256:5440cb68898d190ad6c6e8a4634ce89d0645bea47f9c8beb75612bb8e3983711
|
||||||
|
|
||||||
# InvenTree paths
|
# InvenTree paths
|
||||||
ENV INVENTREE_HOME="/home/inventree"
|
ENV INVENTREE_HOME="/home/inventree"
|
||||||
|
|
@ -25,7 +25,7 @@ RUN chmod +x init.sh
|
||||||
|
|
||||||
# Install required base packages
|
# Install required base packages
|
||||||
RUN apt update && apt install -y \
|
RUN apt update && apt install -y \
|
||||||
python3.11-dev python3.11-venv \
|
python3.12-dev python3.12-venv \
|
||||||
postgresql-client \
|
postgresql-client \
|
||||||
libldap2-dev libsasl2-dev \
|
libldap2-dev libsasl2-dev \
|
||||||
libpango1.0-0 libcairo2 \
|
libpango1.0-0 libcairo2 \
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ pool:
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
Python39:
|
Python39:
|
||||||
PYTHON_VERSION: '3.11'
|
PYTHON_VERSION: 3.12
|
||||||
maxParallel: 3
|
maxParallel: 3
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ on:
|
||||||
- l10
|
- l10
|
||||||
|
|
||||||
env:
|
env:
|
||||||
python_version: 3.11
|
python_version: 3.12
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
|
|
||||||
|
|
@ -62,7 +62,7 @@ jobs:
|
||||||
contents: read
|
contents: read
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
python_version: "3.11"
|
python_version: 3.12
|
||||||
runs-on: ubuntu-latest # in the future we can try to use alternative runners here
|
runs-on: ubuntu-latest # in the future we can try to use alternative runners here
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
|
|
@ -84,6 +84,16 @@ jobs:
|
||||||
docker run --rm inventree-test test -f /home/inventree/gunicorn.conf.py
|
docker run --rm inventree-test test -f /home/inventree/gunicorn.conf.py
|
||||||
docker run --rm inventree-test test -f /home/inventree/src/backend/requirements.txt
|
docker run --rm inventree-test test -f /home/inventree/src/backend/requirements.txt
|
||||||
docker run --rm inventree-test test -f /home/inventree/src/backend/InvenTree/manage.py
|
docker run --rm inventree-test test -f /home/inventree/src/backend/InvenTree/manage.py
|
||||||
|
# Check that backend translations were compiled into the image (a few representative languages)
|
||||||
|
docker run --rm inventree-test test -f /home/inventree/src/backend/InvenTree/locale/de/LC_MESSAGES/django.mo
|
||||||
|
docker run --rm inventree-test test -f /home/inventree/src/backend/InvenTree/locale/fr/LC_MESSAGES/django.mo
|
||||||
|
docker run --rm inventree-test test -f /home/inventree/src/backend/InvenTree/locale/ru/LC_MESSAGES/django.mo
|
||||||
|
docker run --rm inventree-test test -f /home/inventree/src/backend/InvenTree/locale/zh_Hans/LC_MESSAGES/django.mo
|
||||||
|
# Check that frontend translations (lingui) were compiled into the static build output (a few representative languages)
|
||||||
|
docker run --rm inventree-test grep -q '"src/locales/de/messages.ts"' /home/inventree/src/backend/InvenTree/web/static/web/.vite/manifest.json
|
||||||
|
docker run --rm inventree-test grep -q '"src/locales/fr/messages.ts"' /home/inventree/src/backend/InvenTree/web/static/web/.vite/manifest.json
|
||||||
|
docker run --rm inventree-test grep -q '"src/locales/ru/messages.ts"' /home/inventree/src/backend/InvenTree/web/static/web/.vite/manifest.json
|
||||||
|
docker run --rm inventree-test grep -q '"src/locales/zh_Hans/messages.ts"' /home/inventree/src/backend/InvenTree/web/static/web/.vite/manifest.json
|
||||||
- name: Build Docker Image
|
- name: Build Docker Image
|
||||||
# Build the development docker image (using docker-compose.yml)
|
# Build the development docker image (using docker-compose.yml)
|
||||||
run: docker compose --project-directory . -f contrib/container/dev-docker-compose.yml build --no-cache
|
run: docker compose --project-directory . -f contrib/container/dev-docker-compose.yml build --no-cache
|
||||||
|
|
@ -124,7 +134,7 @@ jobs:
|
||||||
contents: read
|
contents: read
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
python_version: "3.11"
|
python_version: 3.12
|
||||||
runs-on: ubuntu-latest # in the future we can try to use alternative runners here
|
runs-on: ubuntu-latest # in the future we can try to use alternative runners here
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
|
|
@ -148,7 +158,7 @@ jobs:
|
||||||
id-token: write
|
id-token: write
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
python_version: "3.11"
|
python_version: 3.12
|
||||||
runs-on: ubuntu-latest # in the future we can try to use alternative runners here
|
runs-on: ubuntu-latest # in the future we can try to use alternative runners here
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ on:
|
||||||
branches-ignore: ["l10*"]
|
branches-ignore: ["l10*"]
|
||||||
|
|
||||||
env:
|
env:
|
||||||
python_version: 3.11
|
python_version: 3.12
|
||||||
node_version: 24
|
node_version: 24
|
||||||
# The OS version must be set per job
|
# The OS version must be set per job
|
||||||
server_start_sleep: 60
|
server_start_sleep: 60
|
||||||
|
|
@ -195,6 +195,9 @@ jobs:
|
||||||
--health-interval 10s
|
--health-interval 10s
|
||||||
--health-timeout 5s
|
--health-timeout 5s
|
||||||
--health-retries 5
|
--health-retries 5
|
||||||
|
permissions:
|
||||||
|
contents: read # Required for actions/checkout
|
||||||
|
id-token: write # Required for GitHub OIDC
|
||||||
|
|
||||||
env:
|
env:
|
||||||
COVERAGE: true
|
COVERAGE: true
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
|
||||||
env:
|
env:
|
||||||
python_version: 3.11
|
python_version: 3.12
|
||||||
|
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ on:
|
||||||
branches-ignore: ["l10*"]
|
branches-ignore: ["l10*"]
|
||||||
|
|
||||||
env:
|
env:
|
||||||
python_version: 3.11
|
python_version: 3.12
|
||||||
node_version: 24
|
node_version: 24
|
||||||
# The OS version must be set per job
|
# The OS version must be set per job
|
||||||
server_start_sleep: 60
|
server_start_sleep: 60
|
||||||
|
|
@ -222,7 +222,7 @@ jobs:
|
||||||
echo "Downloaded api.yaml"
|
echo "Downloaded api.yaml"
|
||||||
- name: Running OpenAPI Spec diff action
|
- name: Running OpenAPI Spec diff action
|
||||||
id: breaking_changes
|
id: breaking_changes
|
||||||
uses: oasdiff/oasdiff-action/diff@3530478ec30f84adedbfeb28f0d9527a290f50a9 # pin@main
|
uses: oasdiff/oasdiff-action/diff@5fbe96ede8d0c53aeadef122d7a0abb79152d493 # pin@main
|
||||||
with:
|
with:
|
||||||
base: "api.yaml"
|
base: "api.yaml"
|
||||||
revision: "src/backend/InvenTree/schema.yml"
|
revision: "src/backend/InvenTree/schema.yml"
|
||||||
|
|
@ -363,7 +363,7 @@ jobs:
|
||||||
pip install .
|
pip install .
|
||||||
if: needs.paths-filter.outputs.submit-performance == 'true'
|
if: needs.paths-filter.outputs.submit-performance == 'true'
|
||||||
- name: Performance Reporting
|
- name: Performance Reporting
|
||||||
uses: CodSpeedHQ/action@9d332c4d90b43981c3e55ae8e38e68709996240f # pin@v4.17.0
|
uses: CodSpeedHQ/action@c145068895e045cc725ee76fcd2307624b65c3af # pin@v4.17.5
|
||||||
# check if we are in inventree/inventree - reporting only works in that OIDC context
|
# check if we are in inventree/inventree - reporting only works in that OIDC context
|
||||||
if: github.repository == 'inventree/InvenTree' && needs.paths-filter.outputs.submit-performance == 'true'
|
if: github.repository == 'inventree/InvenTree' && needs.paths-filter.outputs.submit-performance == 'true'
|
||||||
with:
|
with:
|
||||||
|
|
@ -379,7 +379,7 @@ jobs:
|
||||||
continue-on-error: true # continue if a step fails so that coverage gets pushed
|
continue-on-error: true # continue if a step fails so that coverage gets pushed
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
python_version: [3.11, 3.14]
|
python_version: [3.12, 3.14]
|
||||||
|
|
||||||
env:
|
env:
|
||||||
INVENTREE_DB_NAME: ./inventree.sqlite
|
INVENTREE_DB_NAME: ./inventree.sqlite
|
||||||
|
|
@ -454,7 +454,7 @@ jobs:
|
||||||
env:
|
env:
|
||||||
node_version: '>=24'
|
node_version: '>=24'
|
||||||
- name: Performance Reporting
|
- name: Performance Reporting
|
||||||
uses: CodSpeedHQ/action@9d332c4d90b43981c3e55ae8e38e68709996240f # pin@v4.17.0
|
uses: CodSpeedHQ/action@c145068895e045cc725ee76fcd2307624b65c3af # pin@v4.17.5
|
||||||
with:
|
with:
|
||||||
mode: walltime
|
mode: walltime
|
||||||
run: inv dev.test --pytest
|
run: inv dev.test --pytest
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ on:
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
env:
|
env:
|
||||||
python_version: 3.11
|
python_version: 3.12
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
stable:
|
stable:
|
||||||
|
|
@ -151,9 +151,9 @@ jobs:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
target:
|
target:
|
||||||
- ubuntu:22.04
|
|
||||||
- ubuntu:24.04
|
- ubuntu:24.04
|
||||||
- debian:12
|
- ubuntu:26.04
|
||||||
|
- debian:13
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3
|
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ on:
|
||||||
- master
|
- master
|
||||||
|
|
||||||
env:
|
env:
|
||||||
python_version: 3.11
|
python_version: 3.12
|
||||||
node_version: 24
|
node_version: 24
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
|
|
@ -56,7 +56,7 @@ jobs:
|
||||||
echo "Resetting to HEAD~"
|
echo "Resetting to HEAD~"
|
||||||
git reset HEAD~ || true
|
git reset HEAD~ || true
|
||||||
- name: crowdin action
|
- name: crowdin action
|
||||||
uses: crowdin/github-action@8868a33591d21088edfc398968173a3b98d51706 # pin@v2
|
uses: crowdin/github-action@52aa776766211d83d975df51f3b9c53c2f8ba35f # pin@v2
|
||||||
with:
|
with:
|
||||||
upload_sources: true
|
upload_sources: true
|
||||||
upload_translations: false
|
upload_translations: false
|
||||||
|
|
|
||||||
10
.pkgr.yml
10
.pkgr.yml
|
|
@ -21,9 +21,9 @@ before:
|
||||||
dependencies:
|
dependencies:
|
||||||
- curl
|
- curl
|
||||||
- poppler-utils
|
- poppler-utils
|
||||||
- "python3.11 | python3.12 | python3.13 | python3.14"
|
- "python3.12 | python3.13 | python3.14"
|
||||||
- "python3.11-venv | python3.12-venv | python3.13-venv | python3.14-venv"
|
- "python3.12-venv | python3.13-venv | python3.14-venv"
|
||||||
- "python3.11-dev | python3.12-dev | python3.13-dev | python3.14-dev"
|
- "python3.12-dev | python3.13-dev | python3.14-dev"
|
||||||
- python3-pip
|
- python3-pip
|
||||||
- python3-cffi
|
- python3-cffi
|
||||||
- python3-brotli
|
- python3-brotli
|
||||||
|
|
@ -36,6 +36,6 @@ dependencies:
|
||||||
- jq
|
- jq
|
||||||
- "libffi7 | libffi8"
|
- "libffi7 | libffi8"
|
||||||
targets:
|
targets:
|
||||||
ubuntu-22.04: true
|
|
||||||
ubuntu-24.04: true
|
ubuntu-24.04: true
|
||||||
debian-12: true
|
ubuntu-26.04: true
|
||||||
|
debian-13: true
|
||||||
|
|
|
||||||
|
|
@ -37,7 +37,7 @@ repos:
|
||||||
files: src/backend/requirements-dev\.(in|txt)$
|
files: src/backend/requirements-dev\.(in|txt)$
|
||||||
- id: pip-compile
|
- id: pip-compile
|
||||||
name: pip-compile requirements-dev.in 3.14
|
name: pip-compile requirements-dev.in 3.14
|
||||||
args: [src/backend/requirements-dev.in, -o, src/backend/requirements-dev-3.14.txt, -c, src/backend/requirements-3.14.txt, --python-version=3.14, -c, src/backend/requirements.txt]
|
args: [src/backend/requirements-dev.in, -o, src/backend/requirements-dev-3.14.txt, -c, src/backend/requirements-3.14.txt, --python-version=3.14, -c, src/backend/requirements.txt, -c, src/backend/requirements-dev.txt]
|
||||||
files: src/backend/requirements-dev\.(in|txt)$
|
files: src/backend/requirements-dev\.(in|txt)$
|
||||||
- id: pip-compile
|
- id: pip-compile
|
||||||
name: pip-compile requirements.txt
|
name: pip-compile requirements.txt
|
||||||
|
|
@ -97,4 +97,4 @@ repos:
|
||||||
rev: 0.4.3
|
rev: 0.4.3
|
||||||
hooks:
|
hooks:
|
||||||
- id: teyit
|
- id: teyit
|
||||||
language_version: python3.11
|
language_version: python3.12
|
||||||
|
|
|
||||||
|
|
@ -47,7 +47,7 @@
|
||||||
"name": "InvenTree invoke schema",
|
"name": "InvenTree invoke schema",
|
||||||
"type": "debugpy",
|
"type": "debugpy",
|
||||||
"request": "launch",
|
"request": "launch",
|
||||||
"program": "${workspaceFolder}/.venv/lib/python3.11/site-packages/invoke/__main__.py",
|
"program": "${workspaceFolder}/.venv/lib/python3.12/site-packages/invoke/__main__.py",
|
||||||
"cwd": "${workspaceFolder}",
|
"cwd": "${workspaceFolder}",
|
||||||
"args": [
|
"args": [
|
||||||
"dev.schema","--ignore-warnings"
|
"dev.schema","--ignore-warnings"
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
See [CONTRIBUTING.md](CONTRIBUTING.md) for codebase guidance.
|
||||||
|
Security reports must follow the security policy in [SECURITY.md](docs/docs/SECURITY.md) and provide how they interact with the [threat model](docs/docs/concepts/threat_model.md).
|
||||||
|
|
||||||
|
Do not open a pull request without manual review by a human. Do not file AI generated issues under any circumstances.
|
||||||
|
Using AI generated content in issue/PR discussions might lead to code of conduct violations and/or bans.
|
||||||
17
CHANGELOG.md
17
CHANGELOG.md
|
|
@ -5,7 +5,19 @@ All major notable changes to this project will be documented in this file (start
|
||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
## Unreleased - YYYY-MM-DD
|
## Unreleased - xxxx.xx.xx
|
||||||
|
|
||||||
|
### Breaking Changes
|
||||||
|
|
||||||
|
- [#12223](https://github.com/inventree/InvenTree/pull/12223) removes support for python 3.11 and stops providing packages for Debian 11 and Ubuntu 20.04.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
|
||||||
|
## 1.4.0 - 2026-06-24
|
||||||
|
|
||||||
### Breaking Changes
|
### Breaking Changes
|
||||||
|
|
||||||
|
|
@ -16,6 +28,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
|
- [#12208](https://github.com/inventree/InvenTree/pull/12208) adds custom locale support for rendering currencies, dates and numbers within reports. This allows users to specify a custom locale for report rendering, which can be used to control the formatting of dates, numbers and currency values in the generated reports.
|
||||||
|
- [#12204](https://github.com/inventree/InvenTree/pull/12204) adds new filtering options to PartCategoryTree and StockLocationTree API endpoints, allowing tree data to be fetched dynamically
|
||||||
- [#12165](https://github.com/inventree/InvenTree/pull/12165) adds support for parameters against the PartCategory model
|
- [#12165](https://github.com/inventree/InvenTree/pull/12165) adds support for parameters against the PartCategory model
|
||||||
- [#12103](https://github.com/inventree/InvenTree/pull/12103) adds column-based filtering to table views in the user interface. This extends the existing table filtering functionality by allowing users to apply filters directly to individual columns.
|
- [#12103](https://github.com/inventree/InvenTree/pull/12103) adds column-based filtering to table views in the user interface. This extends the existing table filtering functionality by allowing users to apply filters directly to individual columns.
|
||||||
- [#12093](https://github.com/inventree/InvenTree/pull/12093) adds "read_only" attribute to PluginSetting API endpoint, which indicates whether a particular plugin setting is read-only (i.e. cannot be modified via the API)
|
- [#12093](https://github.com/inventree/InvenTree/pull/12093) adds "read_only" attribute to PluginSetting API endpoint, which indicates whether a particular plugin setting is read-only (i.e. cannot be modified via the API)
|
||||||
|
|
@ -38,6 +52,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
|
- [#12197](https://github.com/inventree/InvenTree/pull/12197) requires staff permissions to restart machines via the API.
|
||||||
- [#12142](https://github.com/inventree/InvenTree/pull/12142) prevents users from printing reports or labels against models for which they do not have adequate permissions. This change improves the security of the system by ensuring that users cannot access or print reports or labels for models they do not have permission to view.
|
- [#12142](https://github.com/inventree/InvenTree/pull/12142) prevents users from printing reports or labels against models for which they do not have adequate permissions. This change improves the security of the system by ensuring that users cannot access or print reports or labels for models they do not have permission to view.
|
||||||
- [#11990](https://github.com/inventree/InvenTree/pull/11990) build output operations performed via the API now offload the work to a background task, and now return a task ID which can be used to monitor the progress of the task. This allows for better performance and responsiveness when performing build output operations, as the work is performed asynchronously in the background.
|
- [#11990](https://github.com/inventree/InvenTree/pull/11990) build output operations performed via the API now offload the work to a background task, and now return a task ID which can be used to monitor the progress of the task. This allows for better performance and responsiveness when performing build output operations, as the work is performed asynchronously in the background.
|
||||||
- [#11825](https://github.com/inventree/InvenTree/pull/11825) adds a new "bom" ruleset and associated permissions for BOM management, separate from the "part" ruleset which remains focused on part management. This allows for more granular control over user permissions, allowing users to have different levels of access to part management and BOM management functionality.
|
- [#11825](https://github.com/inventree/InvenTree/pull/11825) adds a new "bom" ruleset and associated permissions for BOM management, separate from the "part" ruleset which remains focused on part management. This allows for more granular control over user permissions, allowing users to have different levels of access to part management and BOM management functionality.
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
See [CONTRIBUTING.md](CONTRIBUTING.md) for codebase guidance.
|
||||||
|
Security reports must follow the security policy in [SECURITY.md](docs/docs/SECURITY.md) and provide how they interact with the [threat model](docs/docs/concepts/threat_model.md).
|
||||||
|
|
||||||
|
Do not open a pull request without manual review by a human. Do not file AI generated issues under any circumstances.
|
||||||
|
Using AI generated content in issue/PR discussions might lead to code of conduct violations and/or bans.
|
||||||
218
CONTRIBUTING.md
218
CONTRIBUTING.md
|
|
@ -1,13 +1,27 @@
|
||||||
### Contributing to InvenTree
|
# Contributing to InvenTree
|
||||||
|
|
||||||
Hi there, thank you for your interest in contributing!
|
Hi there, thank you for your interest in contributing!
|
||||||
Please read our contribution guidelines, before submitting your first pull request to the InvenTree codebase.
|
Please read our contribution guidelines, before submitting your first pull request to the InvenTree codebase.
|
||||||
|
|
||||||
### Project File Structure
|
## About InvenTree
|
||||||
|
|
||||||
|
InvenTree is an open-source inventory management system designed for makers, engineers, and small manufacturers.
|
||||||
|
|
||||||
|
The codebase is split into two independently deployable layers:
|
||||||
|
|
||||||
|
**Backend — Django (Python)**
|
||||||
|
A REST API built with Django and Django REST Framework. It owns all business logic, the database schema, background task processing (Django-Q2), plugin execution, and report generation. The backend can be used standalone via the API without the frontend.
|
||||||
|
|
||||||
|
**Frontend — React (TypeScript)**
|
||||||
|
A single-page application built with React 19, Mantine 9, and Vite. It communicates exclusively through the backend REST API. It has its own build pipeline, test suite (Playwright), and i18n system (Lingui). The frontend lives entirely in `src/frontend/` and has no Django awareness.
|
||||||
|
|
||||||
|
When making changes, be clear about which layer you are working in — they have separate toolchains, test runners, and linting rules.
|
||||||
|
|
||||||
|
## Project File Structure
|
||||||
|
|
||||||
The InvenTree project is split into two main components: frontend and backend. This source is located in the `src` directory. All other files are used for project management, documentation, and testing.
|
The InvenTree project is split into two main components: frontend and backend. This source is located in the `src` directory. All other files are used for project management, documentation, and testing.
|
||||||
|
|
||||||
```bash
|
```
|
||||||
InvenTree/
|
InvenTree/
|
||||||
├─ .devops/ # Files for Azure DevOps
|
├─ .devops/ # Files for Azure DevOps
|
||||||
├─ .github/ # Files for GitHub
|
├─ .github/ # Files for GitHub
|
||||||
|
|
@ -39,11 +53,209 @@ InvenTree/
|
||||||
│ │ ├─ tsconfig.json # Settings for frontend compilation
|
│ │ ├─ tsconfig.json # Settings for frontend compilation
|
||||||
├─ .pkgr.yml # Build definition for Debian/Ubuntu packages
|
├─ .pkgr.yml # Build definition for Debian/Ubuntu packages
|
||||||
├─ .pre-commit-config.yaml # Code formatter/linter configuration
|
├─ .pre-commit-config.yaml # Code formatter/linter configuration
|
||||||
|
├─ biome.json # JS/TS linting and formatting config
|
||||||
├─ CONTRIBUTING.md # Contribution guidelines and overview
|
├─ CONTRIBUTING.md # Contribution guidelines and overview
|
||||||
├─ Procfile # Process definition for Debian/Ubuntu packages
|
├─ Procfile # Process definition for Debian/Ubuntu packages
|
||||||
|
├─ pyproject.toml # Python tooling config (Ruff, pytest, coverage, djLint, ty)
|
||||||
├─ README.md # General project information and overview
|
├─ README.md # General project information and overview
|
||||||
├─ SECURITY.md # Project security policy
|
├─ SECURITY.md # Project security policy
|
||||||
├─ tasks.py # Action definitions for development, testing and deployment
|
├─ tasks.py # Action definitions for development, testing and deployment
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Backend app domains
|
||||||
|
|
||||||
|
The following Django apps are defined in `src/backend/InvenTree/`:
|
||||||
|
|
||||||
|
| Directory | Domain |
|
||||||
|
|-----------|--------|
|
||||||
|
| `build/` | Build (manufacturing/assembly) orders |
|
||||||
|
| `common/` | Shared models, settings, utilities |
|
||||||
|
| `company/` | Suppliers, customers, manufacturers |
|
||||||
|
| `data_exporter/` | Data export functionality |
|
||||||
|
| `generic/` | Shared enums, events, and state machine utilities |
|
||||||
|
| `importer/` | Data import functionality |
|
||||||
|
| `InvenTree/` | Core configuration (settings, URLs, middleware) |
|
||||||
|
| `machine/` | Support for external machines and devices |
|
||||||
|
| `order/` | Purchase orders and sales orders |
|
||||||
|
| `part/` | Parts catalogue and categories |
|
||||||
|
| `stock/` | Stock items and locations |
|
||||||
|
| `report/` | Report templates and generation |
|
||||||
|
| `plugin/` | Plugin system |
|
||||||
|
| `users/` | User accounts and permissions |
|
||||||
|
| `web/` | Serves the React frontend static build to Django |
|
||||||
|
|
||||||
|
### Frontend layout
|
||||||
|
|
||||||
|
```
|
||||||
|
src/frontend/src/
|
||||||
|
components/ # Reusable React components
|
||||||
|
pages/ # Page-level route components
|
||||||
|
tables/ # Data table components
|
||||||
|
forms/ # Form components
|
||||||
|
hooks/ # Custom React hooks
|
||||||
|
states/ # Zustand global state
|
||||||
|
locales/ # i18n translation catalogues (Lingui)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Development Setup
|
||||||
|
|
||||||
|
The project uses [Invoke](https://www.pyinvoketasks.com/) (`tasks.py`) as the task runner.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# One-time setup: creates venv at dev/venv/, installs deps, sets up pre-commit hooks
|
||||||
|
invoke dev.setup-dev
|
||||||
|
|
||||||
|
# Apply database migrations
|
||||||
|
invoke migrate
|
||||||
|
|
||||||
|
# Create an admin account (required to log in)
|
||||||
|
invoke superuser
|
||||||
|
|
||||||
|
# Terminal 1 — Django dev server (port 8000)
|
||||||
|
invoke dev.server
|
||||||
|
|
||||||
|
# Terminal 2 — Vite dev server with HMR (port 5173)
|
||||||
|
invoke dev.frontend-server
|
||||||
|
|
||||||
|
# Terminal 3 (optional) — Background task worker
|
||||||
|
invoke worker
|
||||||
|
```
|
||||||
|
|
||||||
|
A VS Code Dev Container configuration is available at `.devcontainer/` and includes PostgreSQL 15 and Redis 7 sidecar services.
|
||||||
|
|
||||||
|
To see all available tasks, run `invoke --list`.
|
||||||
|
|
||||||
|
## Running Backend Tests
|
||||||
|
|
||||||
|
Always pass `--keepdb` to backend test commands. It reuses the existing test database instead of rebuilding it from scratch on every run, which is significantly faster.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# All backend tests
|
||||||
|
invoke dev.test --keepdb
|
||||||
|
|
||||||
|
# Specific test module
|
||||||
|
invoke dev.test --keepdb --runtest=company.test_api
|
||||||
|
|
||||||
|
# With coverage
|
||||||
|
invoke dev.test --keepdb --coverage
|
||||||
|
|
||||||
|
# Migration tests only (skip --keepdb here — migrations must run against a fresh DB)
|
||||||
|
invoke dev.test --migrations
|
||||||
|
```
|
||||||
|
|
||||||
|
## Running Frontend Tests
|
||||||
|
|
||||||
|
Frontend tests use [Playwright](https://playwright.dev/) and target Chromium and Firefox. The test runner automatically starts the Vite dev server (port 5173), the Django backend (port 8000), and the background worker — no manual server startup is needed.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Open Playwright's interactive UI (recommended for local development)
|
||||||
|
invoke dev.frontend-test
|
||||||
|
|
||||||
|
# Run tests in headless mode from the frontend directory
|
||||||
|
cd src/frontend && npx playwright test
|
||||||
|
|
||||||
|
# Run a specific test file
|
||||||
|
cd src/frontend && npx playwright test tests/pui_login.spec.ts
|
||||||
|
|
||||||
|
# Run tests in a specific browser only
|
||||||
|
cd src/frontend && npx playwright test --project=chromium
|
||||||
|
```
|
||||||
|
|
||||||
|
Test files live in `src/frontend/tests/` and follow the `pui_*.spec.ts` naming convention.
|
||||||
|
|
||||||
|
Locally, tests run with a single worker (required for Vite HMR compatibility). In CI they run with multiple workers against a production build for speed.
|
||||||
|
|
||||||
|
## Code Style and Linting
|
||||||
|
|
||||||
|
All formatting and linting runs automatically on commit via pre-commit hooks. Never skip them.
|
||||||
|
|
||||||
|
### Python (Backend)
|
||||||
|
|
||||||
|
Tool: **Ruff** (replaces Black, isort, flake8, and others).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ruff check src/backend/ # Lint
|
||||||
|
ruff format src/backend/ # Format
|
||||||
|
```
|
||||||
|
|
||||||
|
- Google-style docstrings enforced
|
||||||
|
- Enabled rule sets: A, B, C, D, F, I, N, SIM, PIE, PLE, PLW, RUF, UP, W
|
||||||
|
- Type checking: `ty` (configured in `pyproject.toml`)
|
||||||
|
|
||||||
|
### Django Templates
|
||||||
|
|
||||||
|
Tool: **djLint**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
djlint src/backend/ --check
|
||||||
|
```
|
||||||
|
|
||||||
|
### JavaScript / TypeScript (Frontend)
|
||||||
|
|
||||||
|
Tool: **Biome** (replaces ESLint + Prettier).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd src/frontend
|
||||||
|
yarn biome check . # Lint + format check
|
||||||
|
yarn biome format . # Format only
|
||||||
|
```
|
||||||
|
|
||||||
|
- Single quotes, space indentation
|
||||||
|
- `noUnusedImports` is an error
|
||||||
|
|
||||||
|
## Making Changes
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
|
||||||
|
- Each Django app has its own `models.py`, `serializers.py`, `views.py`, `urls.py`, and `filters.py`.
|
||||||
|
- API endpoints use Django REST Framework; document them with drf-spectacular decorators.
|
||||||
|
- After changing models, create a migration: `invoke dev.makemigrations`.
|
||||||
|
- Tests live in `test_*.py` files within each app directory.
|
||||||
|
- Background tasks go through Django-Q2; define them in the app's `tasks.py`.
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
|
||||||
|
- Pages register themselves in `src/frontend/src/router.tsx`.
|
||||||
|
- Server state fetching uses TanStack Query (React Query); avoid raw `useEffect` for data fetching.
|
||||||
|
- Global UI state uses Zustand stores in `states/`.
|
||||||
|
- UI components come from Mantine 9.x; use the Mantine component library before writing custom CSS.
|
||||||
|
- Add i18n strings with Lingui (`t` macro / `Trans` component); run `invoke int.frontend-trans` to extract from source and compile. (`invoke dev.translate` is for the full translation pipeline and is not intended for local use.)
|
||||||
|
- Playwright tests live in `src/frontend/tests/`.
|
||||||
|
|
||||||
|
### Dependencies
|
||||||
|
|
||||||
|
**Python:** Add packages to `src/backend/requirements.in` (or `requirements-dev.in` for dev-only tools). The pre-commit hook runs `pip-compile` automatically on commit to regenerate `requirements.txt`. Never edit `requirements.txt` directly.
|
||||||
|
|
||||||
|
**Frontend:** Add packages with `yarn add <package>` from `src/frontend/`.
|
||||||
|
|
||||||
|
### Migrations
|
||||||
|
|
||||||
|
- Never edit existing migration files; always generate new ones.
|
||||||
|
- Keep migrations reversible where possible.
|
||||||
|
- Migration tests run in CI under the tag `migration_test`.
|
||||||
|
|
||||||
|
## CI / CD
|
||||||
|
|
||||||
|
GitHub Actions workflows live in `.github/workflows/`. Key workflows:
|
||||||
|
|
||||||
|
| Workflow | Purpose |
|
||||||
|
|----------|---------|
|
||||||
|
| `qc_checks.yaml` | Lint, type-check, backend tests, API schema |
|
||||||
|
| `frontend.yaml` | Playwright E2E tests, frontend build |
|
||||||
|
| `docker.yaml` | Docker image builds |
|
||||||
|
| `translations.yaml` | Crowdin i18n sync |
|
||||||
|
|
||||||
|
CI uses path-based filtering — only affected jobs run per PR. Coverage tracked via Codecov; quality analysis via SonarCloud.
|
||||||
|
|
||||||
|
## Key Conventions
|
||||||
|
|
||||||
|
- **REST API versioning:** Endpoint changes must not break the published API schema. Run `invoke dev.schema` and check the diff before opening a PR.
|
||||||
|
- **Plugin safety:** The plugin system (`plugin/`) is a public extension point; avoid breaking its interfaces.
|
||||||
|
- **No hardcoded secrets:** gitleaks runs in pre-commit and CI — any credential-shaped string will block merges.
|
||||||
|
- **Database portability:** Code must work on PostgreSQL, MySQL/MariaDB, and SQLite. Avoid database-specific SQL or ORM features.
|
||||||
|
- **Frontend translations:** Every user-visible string must be wrapped in a Lingui `t` macro or `<Trans>` component.
|
||||||
|
- **Test tagging:** Tag slow or special tests (`@tag('migration_test')`, `@tag('performance_test')`) so CI can filter them selectively.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
Refer to our [contribution guidelines](https://docs.inventree.org/en/latest/develop/contributing/) for more information!
|
Refer to our [contribution guidelines](https://docs.inventree.org/en/latest/develop/contributing/) for more information!
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@ flag_management:
|
||||||
carryforward: true
|
carryforward: true
|
||||||
statuses:
|
statuses:
|
||||||
- type: project
|
- type: project
|
||||||
target: 40%
|
target: 38%
|
||||||
- name: web
|
- name: web
|
||||||
carryforward: true
|
carryforward: true
|
||||||
statuses:
|
statuses:
|
||||||
|
|
|
||||||
|
|
@ -145,6 +145,9 @@ ENV PATH=/root/.local/bin:$PATH
|
||||||
COPY --from=builder_stage ${INVENTREE_BACKEND_DIR}/InvenTree/web/static/web ${INVENTREE_BACKEND_DIR}/InvenTree/web/static/web
|
COPY --from=builder_stage ${INVENTREE_BACKEND_DIR}/InvenTree/web/static/web ${INVENTREE_BACKEND_DIR}/InvenTree/web/static/web
|
||||||
COPY --from=builder_stage /root/.local /root/.local
|
COPY --from=builder_stage /root/.local /root/.local
|
||||||
|
|
||||||
|
# Compile Django backend translations during image build
|
||||||
|
RUN bash -c "cd '${INVENTREE_HOME}' && invoke int.backend-compilemessages"
|
||||||
|
|
||||||
# Launch the production server
|
# Launch the production server
|
||||||
CMD ["sh", "-c", "exec gunicorn -c ./gunicorn.conf.py InvenTree.wsgi -b ${INVENTREE_WEB_ADDR}:${INVENTREE_WEB_PORT} --chdir ${INVENTREE_BACKEND_DIR}/InvenTree"]
|
CMD ["sh", "-c", "exec gunicorn -c ./gunicorn.conf.py InvenTree.wsgi -b ${INVENTREE_WEB_ADDR}:${INVENTREE_WEB_PORT} --chdir ${INVENTREE_BACKEND_DIR}/InvenTree"]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -236,26 +236,26 @@ typing-extensions==4.15.0 \
|
||||||
# via
|
# via
|
||||||
# -c src/backend/requirements.txt
|
# -c src/backend/requirements.txt
|
||||||
# psycopg-pool
|
# psycopg-pool
|
||||||
uv==0.11.19 \
|
uv==0.11.23 \
|
||||||
--hash=sha256:28b0d612a766eb25756dbaa315433b726e93affa467d29a2682cc317547952ba \
|
--hash=sha256:03fbb0a1c7b6d15e96778bdd79e8d1826c6259fea17fc13337fb0744136953f2 \
|
||||||
--hash=sha256:2d663bacb97e2e8412d1c26eace28c7ebbde9d6f5d7d78760fafd114d693817f \
|
--hash=sha256:3a900c8fd757f8c3da9dc5532d9a22d30540e91fdefd63c93909fedbfd756655 \
|
||||||
--hash=sha256:2e0e0b8ad59ec56f1440d6e4313b64a1d8119275dcec73d19eef33c43f99428c \
|
--hash=sha256:576d776a1ca62e3d8aba99f0d8ec607db91a5ebaf52feaff820f28ed820e1665 \
|
||||||
--hash=sha256:301fd78309fc545c2cec2bfcc61a6bbdde876856c6d2041502737cf44085c178 \
|
--hash=sha256:61e6bd7e7f0fe24f103540ba19516443bea6e689022c787217310a1e64558e3f \
|
||||||
--hash=sha256:32d7988c0dfb6f90941f201c871a4478e96e4f2a32bdb2256d62a78ee20593fc \
|
--hash=sha256:6d7cea7d9ade3c1c3e3db1dfcc23d335bceaabf38f51e442b6f57f8f7885a9a6 \
|
||||||
--hash=sha256:480fc34a8d0967af6a90b3f99a6e5687cd5c6e29528de96bec04d6e305a59363 \
|
--hash=sha256:7a85330de0a7eb0d5c6cf03c80edfb86facad19df367a0b52fc906db1ab15ce9 \
|
||||||
--hash=sha256:50e4d4796ca1a6da359a4f723a0fea86640c381d3ff4fa759a41badd7cb52dee \
|
--hash=sha256:91d19c4249d7437b69b91c385134360d7ed9d0ee2e2e83e81d369867151e78c2 \
|
||||||
--hash=sha256:574f5dd4f31666661ea6386d3b91c5f0e8b84a8cae98ebba447c4674f2e6a4c7 \
|
--hash=sha256:985aa93c9d6223e32fc747e09662537c4073c9ebef59c0a4fd7c6949d1d24fb3 \
|
||||||
--hash=sha256:62b0b35a51d3034ff30ecd0f381e9bbc20d5b335754f54b098da29424d551ceb \
|
--hash=sha256:9dd412127cbe0e115bd3fc5c6cbe9cf59f593273fafab9f7dc6b2ac95efcc7c1 \
|
||||||
--hash=sha256:65e932720daed1af1f720a0ff5f9b33ee5f7ad97488dcceceb85154fc1323b82 \
|
--hash=sha256:ac337dacd640aab1ef97cf00f8c01e2889f0fd0ef8460a0f4e816bf12bb5988b \
|
||||||
--hash=sha256:7222f45b5541551057bfc2e3021f113800704f665c119fdf3ea700c6c4859b21 \
|
--hash=sha256:b3f515fd6b43068f241467496bced62cb2ed36d52d4c0877cfe61a1240713d32 \
|
||||||
--hash=sha256:731d9fab8db5d41590af64236d03f8069c8da665fd0f9493b85985f19c86cd90 \
|
--hash=sha256:b8abe7d6f5e0d92bd41a9c000bbd9c8387af7886df4790c0451a34e781b8a075 \
|
||||||
--hash=sha256:7fdd881cd6d80782afcf8c1d446dd15a42985167fd812b763d38ba1e4a8d944d \
|
--hash=sha256:bbc41182d655f92cd380ecdf378da7fc1598c6b19057208f450f0ee9c259f46a \
|
||||||
--hash=sha256:8f90b6687a480d154595aa619fb836a9a20d00ce37293db8099aad924f2b18f9 \
|
--hash=sha256:c2089b992919858dabae89d410cbb5cecf9034d26bbb04f14e6da52dffced290 \
|
||||||
--hash=sha256:a98495b9dd67287d8c1a0786f98cb037a50f0ee6c3d648572edaa7137aabc277 \
|
--hash=sha256:ca1d37e851fb9323250385403d8512a71c0d1b6162c729ff4909f37cfd067920 \
|
||||||
--hash=sha256:aa6a7e8d07b33ad22f4732848ebb1d9486503973c248d6e632c06ce4339fe347 \
|
--hash=sha256:d256f90513d01ff6cbc2f17d88c0ccde65d138500df547ece214e6a50731c4b7 \
|
||||||
--hash=sha256:c729f56ffef9b945053412c839695e8a0b13758aa15b7763e95a7dd539a6f522 \
|
--hash=sha256:d62410e5f60a961cfda00ead8a1cc5fd37d052afda021099e488e90c15419beb \
|
||||||
--hash=sha256:f4aa17ffd719daf37b7a6265efd3ee4922a8ddaabaf0406d2b28c7e5ce2f20ff \
|
--hash=sha256:e7e215d69ea21fd5824a63edf8fef933bee2c028a0c2930651cfa6b88ca4ff8e \
|
||||||
--hash=sha256:f56f5bf853626a30423052d7ee00bf5cc940a08347d6ee7ede96862d084054a5
|
--hash=sha256:f2476dda35866ea3ded3a5905759da2d32dfac36dfd5b3428191a99a8ce15b02
|
||||||
# via -r contrib/container/requirements.in
|
# via -r contrib/container/requirements.in
|
||||||
wheel==0.47.0 \
|
wheel==0.47.0 \
|
||||||
--hash=sha256:212281cab4dff978f6cedd499cd893e1f620791ca6ff7107cf270781e587eced \
|
--hash=sha256:212281cab4dff978f6cedd499cd893e1f620791ca6ff7107cf270781e587eced \
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
# This file was autogenerated by uv via the following command:
|
# This file was autogenerated by uv via the following command:
|
||||||
# uv pip compile contrib/dev_reqs/requirements.in -o contrib/dev_reqs/requirements.txt -c src/backend/requirements.txt
|
# uv pip compile contrib/dev_reqs/requirements.in -o contrib/dev_reqs/requirements.txt -c src/backend/requirements.txt
|
||||||
certifi==2026.5.20 \
|
certifi==2026.6.17 \
|
||||||
--hash=sha256:3c52e209ba0a4ad7aebe60436a4ab349c39e1e602e8c134221e546902ad25897 \
|
--hash=sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432 \
|
||||||
--hash=sha256:69dea482ab64caa7b9f6aba1c6bf48bb6a5448d1c0f1b17ab42ad8c763a5344d
|
--hash=sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db
|
||||||
# via
|
# via
|
||||||
# -c src/backend/requirements.txt
|
# -c src/backend/requirements.txt
|
||||||
# requests
|
# requests
|
||||||
|
|
|
||||||
|
|
@ -79,7 +79,7 @@ function detect_python() {
|
||||||
echo "${On_Red}"
|
echo "${On_Red}"
|
||||||
echo "# POI07| Python ${SETUP_PYTHON} not found - aborting!"
|
echo "# POI07| Python ${SETUP_PYTHON} not found - aborting!"
|
||||||
echo "# POI07| Please ensure python can be executed with the command '$SETUP_PYTHON' by the current user '$USER'."
|
echo "# POI07| Please ensure python can be executed with the command '$SETUP_PYTHON' by the current user '$USER'."
|
||||||
echo "# POI07| If you are using a different python version, please set the environment variable SETUP_PYTHON to the correct command - eg. 'python3.11'."
|
echo "# POI07| If you are using a different python version, please set the environment variable SETUP_PYTHON to the correct command - eg. 'python3.12'."
|
||||||
echo "${Color_Off}"
|
echo "${Color_Off}"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@ export DATA_DIR=${APP_HOME}/data
|
||||||
export SETUP_NGINX_FILE=${SETUP_NGINX_FILE:-/etc/nginx/sites-enabled/inventree.conf}
|
export SETUP_NGINX_FILE=${SETUP_NGINX_FILE:-/etc/nginx/sites-enabled/inventree.conf}
|
||||||
export SETUP_ADMIN_PASSWORD_FILE=${CONF_DIR}/admin_password.txt
|
export SETUP_ADMIN_PASSWORD_FILE=${CONF_DIR}/admin_password.txt
|
||||||
export SETUP_NO_CALLS=${SETUP_NO_CALLS:-false}
|
export SETUP_NO_CALLS=${SETUP_NO_CALLS:-false}
|
||||||
export SETUP_PYTHON=${SETUP_PYTHON:-python3.11}
|
export SETUP_PYTHON=${SETUP_PYTHON:-python3.12}
|
||||||
export SETUP_ADMIN_NOCREATION=${SETUP_ADMIN_NOCREATION:-false}
|
export SETUP_ADMIN_NOCREATION=${SETUP_ADMIN_NOCREATION:-false}
|
||||||
# SETUP_DEBUG can be set to get debug info
|
# SETUP_DEBUG can be set to get debug info
|
||||||
# SETUP_EXTRA_PIP can be set to install extra pip packages
|
# SETUP_EXTRA_PIP can be set to install extra pip packages
|
||||||
|
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 40 KiB |
|
|
@ -63,3 +63,9 @@ Currency exchange rates are updated periodically, using the configured currency
|
||||||
## Pricing Settings
|
## Pricing Settings
|
||||||
|
|
||||||
Refer to the [global settings](../settings/global.md#pricing-and-currency) documentation for more information on available currency settings.
|
Refer to the [global settings](../settings/global.md#pricing-and-currency) documentation for more information on available currency settings.
|
||||||
|
|
||||||
|
## Rendering Currencies in Reports
|
||||||
|
|
||||||
|
Currency values can be rendered in report templates using the [`render_currency`](../report/helpers.md#render_currency) helper function. This function formats a currency amount according to a locale, and supports currency conversion within the template.
|
||||||
|
|
||||||
|
See the [report helpers documentation](../report/helpers.md#currency-formatting) for full details and examples.
|
||||||
|
|
|
||||||
|
|
@ -91,6 +91,18 @@ Click on the navigation tree icon to expand the tree and view the available navi
|
||||||
|
|
||||||
{{ image("concepts/ui_navigation_tree.png", "Navigation Tree") }}
|
{{ image("concepts/ui_navigation_tree.png", "Navigation Tree") }}
|
||||||
|
|
||||||
|
#### Searching
|
||||||
|
|
||||||
|
The navigation tree includes a search bar at the top of the panel. Typing into the search bar filters the tree to show only entries that match the search query. When a search is active, all matching results are expanded and displayed in a flat list. Clearing the search field returns the tree to its normal browsing mode.
|
||||||
|
|
||||||
|
#### Highlight Selected Entry
|
||||||
|
|
||||||
|
The currently selected entry in the navigation tree is highlighted with a distinct background color, making it easy to identify the active page or section within the hierarchy.
|
||||||
|
|
||||||
|
#### Auto-Expand to Selected Entry
|
||||||
|
|
||||||
|
When the navigation tree is opened, it automatically expands to reveal the currently selected entry. All ancestor nodes in the hierarchy are expanded so the active entry is immediately visible, without requiring manual navigation through the tree.
|
||||||
|
|
||||||
## Dashboard
|
## Dashboard
|
||||||
|
|
||||||
The dashboard provides a customizable landing page for users when they log in to the system. The dashboard can be configured to display a variety of widgets and information panels, providing users with quick access to important data and actions.
|
The dashboard provides a customizable landing page for users when they log in to the system. The dashboard can be configured to display a variety of widgets and information panels, providing users with quick access to important data and actions.
|
||||||
|
|
|
||||||
|
|
@ -4,10 +4,10 @@ import json
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from distutils.version import StrictVersion # type: ignore[import]
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
|
from packaging.version import Version
|
||||||
|
|
||||||
here = Path(__file__).parent
|
here = Path(__file__).parent
|
||||||
|
|
||||||
|
|
@ -57,7 +57,7 @@ def fetch_rtd_versions():
|
||||||
print('No RTD token found - skipping RTD version fetch')
|
print('No RTD token found - skipping RTD version fetch')
|
||||||
|
|
||||||
# Sort versions by version number
|
# Sort versions by version number
|
||||||
versions = sorted(versions, key=lambda x: StrictVersion(x['version']), reverse=True)
|
versions = sorted(versions, key=lambda x: Version(x['version']), reverse=True)
|
||||||
|
|
||||||
# Add "latest" version first
|
# Add "latest" version first
|
||||||
if not any(x['title'] == 'latest' for x in versions):
|
if not any(x['title'] == 'latest' for x in versions):
|
||||||
|
|
|
||||||
|
|
@ -54,14 +54,14 @@ class InvenTreeBarcodePlugin(BarcodeMixin, InvenTreePlugin):
|
||||||
VERSION = "0.0.1"
|
VERSION = "0.0.1"
|
||||||
AUTHOR = "Michael"
|
AUTHOR = "Michael"
|
||||||
|
|
||||||
def scan(self, barcode_data):
|
def scan(self, barcode_data, user, **kwargs):
|
||||||
if barcode_data.startswith("PART-"):
|
if barcode_data.startswith("PART-"):
|
||||||
try:
|
try:
|
||||||
pk = int(barcode_data.split("PART-")[1])
|
pk = int(barcode_data.split("PART-")[1])
|
||||||
instance = Part.objects.get(pk=pk)
|
instance = Part.objects.get(pk=pk)
|
||||||
label = Part.barcode_model_type()
|
label = Part.barcode_model_type()
|
||||||
|
|
||||||
return {label: instance.format_matched_response()}
|
return {label: instance.format_matched_response(user=user)}
|
||||||
except Part.DoesNotExist:
|
except Part.DoesNotExist:
|
||||||
pass
|
pass
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -320,15 +320,86 @@ The helper function `format_number` allows for some common number formatting opt
|
||||||
show_docstring_description: false
|
show_docstring_description: false
|
||||||
show_source: False
|
show_source: False
|
||||||
|
|
||||||
#### Example
|
#### Examples
|
||||||
|
|
||||||
```html
|
```html
|
||||||
{% raw %}
|
{% raw %}
|
||||||
{% load report %}
|
{% load report %}
|
||||||
{% format_number 3.14159265359 decimal_places=5, leading=3 %}
|
|
||||||
<!-- output: 0003.14159 -->
|
<!-- Basic usage: strip trailing zeros -->
|
||||||
|
{% format_number 3.14159265359 decimal_places=5 %}
|
||||||
|
<!-- output: 3.14159 -->
|
||||||
|
|
||||||
|
<!-- Leading zeros with 'leading' option -->
|
||||||
|
{% format_number 3.14159265359 decimal_places=5 leading=3 %}
|
||||||
|
<!-- output: 003.14159 -->
|
||||||
|
|
||||||
|
<!-- Round to integer -->
|
||||||
{% format_number 3.14159265359 integer=True %}
|
{% format_number 3.14159265359 integer=True %}
|
||||||
<!-- output: 3 -->
|
<!-- output: 3 -->
|
||||||
|
|
||||||
|
<!-- Thousands separator -->
|
||||||
|
{% format_number 9988776.5 decimal_places=2 separator=True %}
|
||||||
|
<!-- output: 9,988,776.50 -->
|
||||||
|
|
||||||
|
<!-- Locale-aware formatting: decimal comma, dot thousands separator -->
|
||||||
|
{% format_number 9988776.5 decimal_places=2 separator=True locale='de-de' %}
|
||||||
|
<!-- output: 9.988.776,50 -->
|
||||||
|
|
||||||
|
<!-- Scale a value with a multiplier before formatting -->
|
||||||
|
{% format_number 0.175 multiplier=100 decimal_places=1 %}
|
||||||
|
<!-- output: 17.5 -->
|
||||||
|
|
||||||
|
<!-- Allow up to N significant decimal places, but suppress trailing zeros -->
|
||||||
|
{% format_number 1234.5 decimal_places=2 max_decimal_places=6 %}
|
||||||
|
<!-- output: 1234.5 -->
|
||||||
|
|
||||||
|
{% endraw %}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Custom Format Strings
|
||||||
|
|
||||||
|
The `fmt` argument accepts a [Unicode number pattern](https://unicode.org/reports/tr35/tr35-numbers.html#Number_Format_Patterns) string (the same syntax used by [Babel](https://babel.pocoo.org/en/latest/numbers.html)). When `fmt` is provided it takes complete priority over the `decimal_places`, `max_decimal_places`, `leading`, and `separator` arguments — those arguments are silently ignored.
|
||||||
|
|
||||||
|
The `integer` and `multiplier` arguments **are** still applied to the number before the format string is used.
|
||||||
|
|
||||||
|
| Symbol | Meaning |
|
||||||
|
| --- | --- |
|
||||||
|
| `0` | Required digit — always rendered, even if zero |
|
||||||
|
| `#` | Optional digit — suppressed when not significant |
|
||||||
|
| `,` | Grouping separator (position defines group size) |
|
||||||
|
| `.` | Decimal separator |
|
||||||
|
|
||||||
|
Common patterns:
|
||||||
|
|
||||||
|
| Pattern | Example output |
|
||||||
|
| --- | --- |
|
||||||
|
| `0` | `1235` |
|
||||||
|
| `#,##0` | `1,235` |
|
||||||
|
| `0.00` | `1234.57` |
|
||||||
|
| `#,##0.00` | `1,234.57` |
|
||||||
|
| `000` | `007` |
|
||||||
|
|
||||||
|
```html
|
||||||
|
{% raw %}
|
||||||
|
{% load report %}
|
||||||
|
|
||||||
|
<!-- Two decimal places, no grouping -->
|
||||||
|
{% format_number 1234.5678 fmt='0.00' %}
|
||||||
|
<!-- output: 1234.57 -->
|
||||||
|
|
||||||
|
<!-- Two decimal places with thousands separator -->
|
||||||
|
{% format_number 1234.5678 fmt='#,##0.00' %}
|
||||||
|
<!-- output: 1,234.57 -->
|
||||||
|
|
||||||
|
<!-- Same pattern, German locale: dot thousands, comma decimal -->
|
||||||
|
{% format_number 1234.5678 fmt='#,##0.00' locale='de-de' %}
|
||||||
|
<!-- output: 1.234,57 -->
|
||||||
|
|
||||||
|
<!-- Integer with thousands separator, large number -->
|
||||||
|
{% format_number 9988776655.4321 fmt='#,##0' integer=True %}
|
||||||
|
<!-- output: 9,988,776,655 -->
|
||||||
|
|
||||||
{% endraw %}
|
{% endraw %}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -336,6 +407,25 @@ The helper function `format_number` allows for some common number formatting opt
|
||||||
|
|
||||||
For rendering date and datetime information, the following helper functions are available:
|
For rendering date and datetime information, the following helper functions are available:
|
||||||
|
|
||||||
|
Both functions resolve their output using the following priority order:
|
||||||
|
|
||||||
|
1. **`fmt=` argument** — a [strftime format string](https://docs.python.org/3/library/datetime.html#format-codes). When provided, this takes full priority; `locale` and `date_format` are ignored.
|
||||||
|
2. **`locale=` argument** — when no `fmt` is given, Babel formats the value using the style set by `date_format` (default `medium`).
|
||||||
|
3. **Server `LANGUAGE_CODE`** — used as the locale when no `locale=` argument is supplied.
|
||||||
|
|
||||||
|
#### Date Format Styles
|
||||||
|
|
||||||
|
The `date_format` argument controls how Babel renders the date when locale-aware formatting is used. The four named styles are:
|
||||||
|
|
||||||
|
| Style | `format_date` example (en-us, 2025-01-12) | `format_datetime` example (en-us, 2025-01-12 14:30) |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `full` | `Sunday, January 12, 2025` | `Sunday, January 12, 2025 at 2:30:00 PM UTC` |
|
||||||
|
| `long` | `January 12, 2025` | `January 12, 2025 at 2:30:00 PM UTC` |
|
||||||
|
| `medium` *(default)* | `Jan 12, 2025` | `Jan 12, 2025, 2:30:00 PM` |
|
||||||
|
| `short` | `1/12/25` | `1/12/25, 2:30 PM` |
|
||||||
|
|
||||||
|
The exact output varies by locale — the table above uses `en-us`.
|
||||||
|
|
||||||
### format_date
|
### format_date
|
||||||
|
|
||||||
::: report.templatetags.report.format_date
|
::: report.templatetags.report.format_date
|
||||||
|
|
@ -343,6 +433,35 @@ For rendering date and datetime information, the following helper functions are
|
||||||
show_docstring_description: false
|
show_docstring_description: false
|
||||||
show_source: False
|
show_source: False
|
||||||
|
|
||||||
|
#### Examples
|
||||||
|
|
||||||
|
```html
|
||||||
|
{% raw %}
|
||||||
|
{% load report %}
|
||||||
|
|
||||||
|
<!-- Default: medium style, locale from LANGUAGE_CODE -->
|
||||||
|
{% format_date my_date %}
|
||||||
|
<!-- output (en-us): Jan 12, 2025 -->
|
||||||
|
|
||||||
|
<!-- Explicit strftime format string — locale and date_format are ignored -->
|
||||||
|
{% format_date my_date fmt="%d/%m/%Y" %}
|
||||||
|
<!-- output: 12/01/2025 -->
|
||||||
|
|
||||||
|
<!-- Locale-aware, default medium style -->
|
||||||
|
{% format_date my_date locale='en-us' %}
|
||||||
|
<!-- output: Jan 12, 2025 -->
|
||||||
|
|
||||||
|
<!-- Short style -->
|
||||||
|
{% format_date my_date locale='en-us' date_format='short' %}
|
||||||
|
<!-- output: 1/12/25 -->
|
||||||
|
|
||||||
|
<!-- Full style -->
|
||||||
|
{% format_date my_date locale='en-us' date_format='full' %}
|
||||||
|
<!-- output: Sunday, January 12, 2025 -->
|
||||||
|
|
||||||
|
{% endraw %}
|
||||||
|
```
|
||||||
|
|
||||||
### format_datetime
|
### format_datetime
|
||||||
|
|
||||||
::: report.templatetags.report.format_datetime
|
::: report.templatetags.report.format_datetime
|
||||||
|
|
@ -350,20 +469,31 @@ For rendering date and datetime information, the following helper functions are
|
||||||
show_docstring_description: false
|
show_docstring_description: false
|
||||||
show_source: False
|
show_source: False
|
||||||
|
|
||||||
### Date Formatting
|
#### Examples
|
||||||
|
|
||||||
If not specified, these methods return a result which uses ISO formatting. Refer to the [datetime format codes](https://docs.python.org/3/library/datetime.html#format-codes) for more information! |
|
|
||||||
|
|
||||||
|
|
||||||
### Example
|
|
||||||
|
|
||||||
A simple example of using the date formatting helper functions:
|
|
||||||
|
|
||||||
```html
|
```html
|
||||||
{% raw %}
|
{% raw %}
|
||||||
{% load report %}
|
{% load report %}
|
||||||
Date: {% format_date my_date timezone="Australia/Sydney" %}
|
|
||||||
Datetime: {% format_datetime my_datetime format="%d-%m-%Y %H:%M%S" %}
|
<!-- Default: medium style, locale from LANGUAGE_CODE -->
|
||||||
|
{% format_datetime my_datetime %}
|
||||||
|
<!-- output (en-us): Jan 12, 2025, 2:30:00 PM -->
|
||||||
|
|
||||||
|
<!-- Explicit strftime format — locale and date_format are ignored -->
|
||||||
|
{% format_datetime my_datetime fmt="%d-%m-%Y %H:%M" %}
|
||||||
|
<!-- output: 12-01-2025 14:30 -->
|
||||||
|
|
||||||
|
<!-- Locale-aware, default medium style -->
|
||||||
|
{% format_datetime my_datetime locale='en-us' %}
|
||||||
|
<!-- output: Jan 12, 2025, 2:30:00 PM -->
|
||||||
|
|
||||||
|
<!-- Short style -->
|
||||||
|
{% format_datetime my_datetime locale='de-de' date_format='short' %}
|
||||||
|
<!-- output: 12.01.25, 14:30 -->
|
||||||
|
|
||||||
|
<!-- Convert to a specific timezone before formatting -->
|
||||||
|
{% format_datetime my_datetime timezone="Australia/Sydney" locale='en-au' %}
|
||||||
|
|
||||||
{% endraw %}
|
{% endraw %}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -373,11 +503,115 @@ Datetime: {% format_datetime my_datetime format="%d-%m-%Y %H:%M%S" %}
|
||||||
|
|
||||||
The helper function `render_currency` allows for simple rendering of currency data. This function can also convert the specified amount of currency into a different target currency:
|
The helper function `render_currency` allows for simple rendering of currency data. This function can also convert the specified amount of currency into a different target currency:
|
||||||
|
|
||||||
::: InvenTree.helpers_model.render_currency
|
::: report.templatetags.report.render_currency
|
||||||
options:
|
options:
|
||||||
show_docstring_description: false
|
show_docstring_description: false
|
||||||
show_source: False
|
show_source: False
|
||||||
|
|
||||||
|
#### Decimal Places
|
||||||
|
|
||||||
|
When no decimal place arguments are provided, the locale/currency standard is used (e.g. 2 places for USD, 0 for JPY).
|
||||||
|
|
||||||
|
`decimal_places` and `max_decimal_places` work the same way as in [`format_number`](#format_number):
|
||||||
|
|
||||||
|
| Argument | Effect |
|
||||||
|
| --- | --- |
|
||||||
|
| `decimal_places=N` | Forces exactly N decimal digits (zero-padded) |
|
||||||
|
| `max_decimal_places=M` | Allows up to M decimal digits, suppressing trailing zeros beyond `decimal_places` |
|
||||||
|
| Both set | Forced minimum of `decimal_places`, optional up to `max_decimal_places` |
|
||||||
|
| Neither set | Locale/currency default (e.g. 2 for USD) |
|
||||||
|
|
||||||
|
```html
|
||||||
|
{% raw %}
|
||||||
|
{% load report %}
|
||||||
|
|
||||||
|
<!-- locale default for USD: 2 decimal places -->
|
||||||
|
{% render_currency order.total_price currency='USD' %}
|
||||||
|
<!-- output: $1,234.56 -->
|
||||||
|
|
||||||
|
<!-- force 3 decimal places -->
|
||||||
|
{% render_currency order.total_price currency='USD' decimal_places=3 %}
|
||||||
|
<!-- output: $1,234.560 -->
|
||||||
|
|
||||||
|
<!-- at least 2, up to 4 — trailing zeros beyond the value are suppressed -->
|
||||||
|
{% render_currency order.total_price currency='USD' decimal_places=2 max_decimal_places=4 %}
|
||||||
|
<!-- output: $1,234.5600 or $1,234.56 depending on the value -->
|
||||||
|
|
||||||
|
{% endraw %}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Locale and Symbol Rendering
|
||||||
|
|
||||||
|
The locale controls how the currency symbol and separators are rendered. For example, `USD 1234.56` with various locales:
|
||||||
|
|
||||||
|
| Locale | Output |
|
||||||
|
| --- | --- |
|
||||||
|
| `en-us` | `$1,234.56` |
|
||||||
|
| `en-gb` | `US$1,234.56` |
|
||||||
|
| `en-au` | `USD1,234.56` |
|
||||||
|
| `de-de` | `1.234,56 $` |
|
||||||
|
|
||||||
|
The locale is resolved in the following priority order:
|
||||||
|
|
||||||
|
1. **Explicit `locale=` argument** — highest priority, always wins
|
||||||
|
2. **Server `LANGUAGE_CODE`** — fallback
|
||||||
|
|
||||||
|
#### Leading Digits
|
||||||
|
|
||||||
|
The `leading` argument specifies the minimum number of digits to render before the decimal point (zero-padded). This works identically to `leading` in [`format_number`](#format_number):
|
||||||
|
|
||||||
|
```html
|
||||||
|
{% raw %}
|
||||||
|
{% load report %}
|
||||||
|
|
||||||
|
<!-- default: no padding -->
|
||||||
|
{% render_currency order.total_price currency='USD' %}
|
||||||
|
<!-- output: $1.23 -->
|
||||||
|
|
||||||
|
<!-- force at least 4 integer digits -->
|
||||||
|
{% render_currency order.total_price currency='USD' leading=4 %}
|
||||||
|
<!-- output: $0,001.23 -->
|
||||||
|
|
||||||
|
{% endraw %}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Custom Format Strings
|
||||||
|
|
||||||
|
The `fmt` argument accepts a [Unicode number pattern](https://unicode.org/reports/tr35/tr35-numbers.html#Number_Format_Patterns) string (same syntax as [`format_number`](#custom-format-strings)). **When `fmt` is provided, it takes complete priority over `decimal_places`, `max_decimal_places`, and `leading`** — those arguments are ignored.
|
||||||
|
|
||||||
|
The `locale`, `currency`, `multiplier`, and `include_symbol` arguments are still applied when `fmt` is set.
|
||||||
|
|
||||||
|
To include the currency symbol in a `fmt` pattern, use the `¤` placeholder. Without it, no symbol appears regardless of `include_symbol`.
|
||||||
|
|
||||||
|
| Pattern | Example output (en-us, USD) |
|
||||||
|
| --- | --- |
|
||||||
|
| `#,##0.00` | `1,234.56` (no symbol) |
|
||||||
|
| `¤#,##0.00` | `$1,234.56` |
|
||||||
|
| `¤#,##0.0000` | `$1,234.5600` |
|
||||||
|
| `¤ #,##0.00` | `$ 1,234.56` |
|
||||||
|
|
||||||
|
```html
|
||||||
|
{% raw %}
|
||||||
|
{% load report %}
|
||||||
|
|
||||||
|
<!-- No symbol (no ¤ in pattern) -->
|
||||||
|
{% render_currency order.total_price currency='USD' fmt='#,##0.00' %}
|
||||||
|
<!-- output: 1,234.56 -->
|
||||||
|
|
||||||
|
<!-- Symbol via ¤ placeholder -->
|
||||||
|
{% render_currency order.total_price currency='USD' fmt='¤#,##0.0000' locale='en-us' %}
|
||||||
|
<!-- output: $1,234.5600 -->
|
||||||
|
|
||||||
|
<!-- fmt + locale: de-de separators -->
|
||||||
|
{% render_currency order.total_price currency='USD' fmt='#,##0.00' locale='de-de' %}
|
||||||
|
<!-- output: 1.234,56 -->
|
||||||
|
|
||||||
|
<!-- fmt takes priority — decimal_places=2 is ignored -->
|
||||||
|
{% render_currency order.total_price currency='USD' fmt='0.0000' decimal_places=2 %}
|
||||||
|
<!-- output: 1234.5600 -->
|
||||||
|
|
||||||
|
{% endraw %}
|
||||||
|
```
|
||||||
|
|
||||||
#### Example
|
#### Example
|
||||||
|
|
||||||
|
|
@ -392,8 +626,12 @@ The helper function `render_currency` allows for simple rendering of currency da
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
|
<!-- Force 2 decimal places, convert to NZD -->
|
||||||
Total Price: {% render_currency order.total_price currency='NZD' decimal_places=2 %}
|
Total Price: {% render_currency order.total_price currency='NZD' decimal_places=2 %}
|
||||||
|
|
||||||
|
<!-- US-style symbol, regardless of server locale -->
|
||||||
|
Total Price: {% render_currency order.total_price currency='USD' locale='en-us' %}
|
||||||
|
|
||||||
{% endraw %}
|
{% endraw %}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -325,7 +325,7 @@ ARG INVENTREE_TAG
|
||||||
|
|
||||||
# prebuild stage - needs a lot of build dependencies
|
# prebuild stage - needs a lot of build dependencies
|
||||||
# make sure, the alpine and python version matches the version used in the inventree base image
|
# make sure, the alpine and python version matches the version used in the inventree base image
|
||||||
FROM python:3.11-alpine3.18 as prebuild
|
FROM python:3.12-alpine3.18 as prebuild
|
||||||
|
|
||||||
# Install whatever development dependency is needed (e.g. cups-dev, gcc, the musl-dev build tools and the pip pycups package)
|
# Install whatever development dependency is needed (e.g. cups-dev, gcc, the musl-dev build tools and the pip pycups package)
|
||||||
RUN apk add --no-cache cups-dev gcc musl-dev && \
|
RUN apk add --no-cache cups-dev gcc musl-dev && \
|
||||||
|
|
|
||||||
|
|
@ -366,7 +366,7 @@ extra:
|
||||||
# provider: google
|
# provider: google
|
||||||
# property: UA-143467500-1
|
# property: UA-143467500-1
|
||||||
|
|
||||||
min_python_version: 3.11
|
min_python_version: 3.12
|
||||||
min_invoke_version: 2.0.0
|
min_invoke_version: 2.0.0
|
||||||
django_version: 5.2
|
django_version: 5.2
|
||||||
docker_postgres_version: 17
|
docker_postgres_version: 17
|
||||||
|
|
|
||||||
|
|
@ -28,9 +28,9 @@ bracex==2.6 \
|
||||||
--hash=sha256:0b0049264e7340b3ec782b5cb99beb325f36c3782a32e36e876452fd49a09952 \
|
--hash=sha256:0b0049264e7340b3ec782b5cb99beb325f36c3782a32e36e876452fd49a09952 \
|
||||||
--hash=sha256:98f1347cd77e22ee8d967a30ad4e310b233f7754dbf31ff3fceb76145ba47dc7
|
--hash=sha256:98f1347cd77e22ee8d967a30ad4e310b233f7754dbf31ff3fceb76145ba47dc7
|
||||||
# via wcmatch
|
# via wcmatch
|
||||||
certifi==2026.5.20 \
|
certifi==2026.6.17 \
|
||||||
--hash=sha256:3c52e209ba0a4ad7aebe60436a4ab349c39e1e602e8c134221e546902ad25897 \
|
--hash=sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432 \
|
||||||
--hash=sha256:69dea482ab64caa7b9f6aba1c6bf48bb6a5448d1c0f1b17ab42ad8c763a5344d
|
--hash=sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db
|
||||||
# via
|
# via
|
||||||
# -c src/backend/requirements.txt
|
# -c src/backend/requirements.txt
|
||||||
# httpcore
|
# httpcore
|
||||||
|
|
|
||||||
|
|
@ -97,25 +97,25 @@ skip-magic-trailing-comma = true
|
||||||
line-ending = "auto"
|
line-ending = "auto"
|
||||||
|
|
||||||
[tool.uv.pip]
|
[tool.uv.pip]
|
||||||
python-version = "3.11"
|
python-version = "3.12"
|
||||||
no-strip-extras=true
|
no-strip-extras=true
|
||||||
generate-hashes=true
|
generate-hashes=true
|
||||||
|
|
||||||
[tool.ty.environment]
|
[tool.ty.environment]
|
||||||
root = ["src/backend/InvenTree"]
|
extra-paths = ["src/backend/InvenTree"]
|
||||||
|
|
||||||
[tool.ty.rules]
|
[tool.ty.rules]
|
||||||
unresolved-reference="ignore" # 21 # see https://github.com/astral-sh/ty/issues/220
|
unresolved-reference="ignore" # 21 # see https://github.com/astral-sh/ty/issues/220
|
||||||
unresolved-attribute="ignore" # 505 # need Plugin Mixin typing
|
unresolved-attribute="ignore" # 505 # need Plugin Mixin typing
|
||||||
call-non-callable="ignore" # 8 ##
|
call-non-callable="ignore" # 8 ##
|
||||||
invalid-assignment="ignore" # 17 # need to wait for better django field stubs
|
invalid-assignment="ignore" # 17 # need to wait for better django field stubs
|
||||||
# invalid-method-override="ignore" # 99
|
invalid-method-override="ignore" # 104
|
||||||
invalid-return-type="ignore" # 22 ##
|
invalid-return-type="ignore" # 22 ##
|
||||||
# possibly-missing-attribute="ignore" # 25 # https://github.com/astral-sh/ty/issues/164
|
# possibly-missing-attribute="ignore" # 25 # https://github.com/astral-sh/ty/issues/164
|
||||||
unknown-argument="ignore" # 3 # need to wait for better django field stubs
|
unknown-argument="ignore" # 3 # need to wait for better django field stubs
|
||||||
invalid-argument-type="ignore" # 49
|
invalid-argument-type="ignore" # 49
|
||||||
no-matching-overload="ignore" # 3 # need to wait for betterdjango field stubs
|
no-matching-overload="ignore" # 3 # need to wait for betterdjango field stubs
|
||||||
possibly-unbound-attribute="ignore" # 21
|
# possibly-unbound-attribute="ignore" # 21
|
||||||
|
|
||||||
[tool.coverage.run]
|
[tool.coverage.run]
|
||||||
source = ["src/backend/InvenTree", "InvenTree"]
|
source = ["src/backend/InvenTree", "InvenTree"]
|
||||||
|
|
|
||||||
|
|
@ -9,9 +9,9 @@ python:
|
||||||
- requirements: src/backend/requirements.txt
|
- requirements: src/backend/requirements.txt
|
||||||
|
|
||||||
build:
|
build:
|
||||||
os: "ubuntu-22.04"
|
os: "ubuntu-24.04"
|
||||||
tools:
|
tools:
|
||||||
python: "3.11"
|
python: "3.12"
|
||||||
jobs:
|
jobs:
|
||||||
post_install:
|
post_install:
|
||||||
- pip install -U invoke
|
- pip install -U invoke
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ from rest_framework.serializers import ValidationError
|
||||||
from rest_framework.views import APIView
|
from rest_framework.views import APIView
|
||||||
|
|
||||||
import InvenTree.config
|
import InvenTree.config
|
||||||
|
import InvenTree.filters
|
||||||
import InvenTree.permissions
|
import InvenTree.permissions
|
||||||
import InvenTree.version
|
import InvenTree.version
|
||||||
from common.settings import get_global_setting
|
from common.settings import get_global_setting
|
||||||
|
|
@ -493,7 +494,7 @@ class BulkCreateMixin:
|
||||||
if unique_create_fields := getattr(self, 'unique_create_fields', None):
|
if unique_create_fields := getattr(self, 'unique_create_fields', None):
|
||||||
existing = collections.defaultdict(list)
|
existing = collections.defaultdict(list)
|
||||||
for idx, item in enumerate(data):
|
for idx, item in enumerate(data):
|
||||||
key = tuple(item[v] for v in list(unique_create_fields)) # type: ignore[not-subscriptable]
|
key = tuple(item[v] for v in list(unique_create_fields)) # ty: ignore[not-subscriptable]
|
||||||
existing[key].append(idx)
|
existing[key].append(idx)
|
||||||
|
|
||||||
unique_errors = [[] for _ in range(len(data))]
|
unique_errors = [[] for _ in range(len(data))]
|
||||||
|
|
@ -963,3 +964,43 @@ def meta_path(model, lookup_field: str = 'pk', lookup_field_ref: str = 'pk'):
|
||||||
lookup_field_ref=lookup_field_ref,
|
lookup_field_ref=lookup_field_ref,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TreeMixin:
|
||||||
|
"""A mixin class for supporting tree-structured data in the API."""
|
||||||
|
|
||||||
|
# Any API view which inherits from this mixin must define a 'model_class' attribute
|
||||||
|
model_class = None
|
||||||
|
|
||||||
|
filter_backends = InvenTree.filters.SEARCH_ORDER_FILTER
|
||||||
|
search_fields = ['name', 'description']
|
||||||
|
ordering_fields = ['level', 'name', 'subcategories']
|
||||||
|
ordering_field_aliases = {'level': ['level', 'name'], 'name': ['name', 'level']}
|
||||||
|
ordering = ['level']
|
||||||
|
|
||||||
|
def filter_queryset(self, queryset):
|
||||||
|
"""Filter the queryset, and provide extra support for tree-structured data."""
|
||||||
|
queryset = super().filter_queryset(queryset)
|
||||||
|
|
||||||
|
# If a search term is provided, include all ancestors of matched items in the results
|
||||||
|
if self.request.query_params.get('search', '').strip():
|
||||||
|
ancestors = self.model_class.objects.get_queryset_ancestors(
|
||||||
|
queryset, include_self=True
|
||||||
|
)
|
||||||
|
queryset = queryset | ancestors
|
||||||
|
|
||||||
|
# If a specific ID is provided to "expand_to", include all ancestors and siblings
|
||||||
|
if expand_to := self.request.query_params.get('expand_to'):
|
||||||
|
try:
|
||||||
|
target = self.model_class.objects.get(pk=int(expand_to))
|
||||||
|
target_ancestors = target.get_ancestors(include_self=True)
|
||||||
|
queryset = queryset | target_ancestors
|
||||||
|
|
||||||
|
# We also want to include the "sibling" nodes of the expanded item
|
||||||
|
siblings = target.get_siblings(include_self=True)
|
||||||
|
queryset = queryset | siblings
|
||||||
|
|
||||||
|
except (self.model_class.DoesNotExist, ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
return queryset.distinct()
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,17 @@
|
||||||
"""InvenTree API version information."""
|
"""InvenTree API version information."""
|
||||||
|
|
||||||
# InvenTree API version
|
# InvenTree API version
|
||||||
INVENTREE_API_VERSION = 509
|
INVENTREE_API_VERSION = 511
|
||||||
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
|
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
|
||||||
|
|
||||||
INVENTREE_API_TEXT = """
|
INVENTREE_API_TEXT = """
|
||||||
|
|
||||||
|
v511 -> 2026-06-19 : https://github.com/inventree/InvenTree/pull/12204
|
||||||
|
- Adds new filtering options to PartCategoryTree and StockLocationTree API endpoints
|
||||||
|
|
||||||
|
v510 -> 2026-06-18 : https://github.com/inventree/InvenTree/pull/12197
|
||||||
|
- Require "staff" access permissions for the machine restart API endpoint
|
||||||
|
|
||||||
v509 -> 2026-06-17 : https://github.com/inventree/InvenTree/pull/12184
|
v509 -> 2026-06-17 : https://github.com/inventree/InvenTree/pull/12184
|
||||||
- Adds "completed_row_count_history" and "row_count_history" fields to the DataImportSession model, which store the historic count of completed rows and total rows for a data import session.
|
- Adds "completed_row_count_history" and "row_count_history" fields to the DataImportSession model, which store the historic count of completed rows and total rows for a data import session.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -114,9 +114,12 @@ class RegistrationMixin:
|
||||||
"""
|
"""
|
||||||
if registration_enabled(self.REGISTRATION_SETTING):
|
if registration_enabled(self.REGISTRATION_SETTING):
|
||||||
return True
|
return True
|
||||||
logger.warning(
|
# Only warn when this is an actual signup submission, not when called as
|
||||||
f'INVE-W12: Signup attempt blocked, because registration is disabled via setting {self.REGISTRATION_SETTING}.'
|
# a feature-availability check during login or other auth flows.
|
||||||
)
|
if request and request.method == 'POST' and 'signup' in request.path:
|
||||||
|
logger.warning(
|
||||||
|
f'INVE-W12: Signup attempt blocked, because registration is disabled via setting {self.REGISTRATION_SETTING}.'
|
||||||
|
)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def clean_email(self, email):
|
def clean_email(self, email):
|
||||||
|
|
|
||||||
|
|
@ -19,10 +19,10 @@ logger = structlog.get_logger('inventree')
|
||||||
|
|
||||||
|
|
||||||
def log_error(
|
def log_error(
|
||||||
path,
|
path: Optional[str] = None,
|
||||||
error_name=None,
|
error_name: Optional[str] = None,
|
||||||
error_info=None,
|
error_info: Optional[str] = None,
|
||||||
error_data=None,
|
error_data: Optional[str] = None,
|
||||||
scope: Optional[str] = None,
|
scope: Optional[str] = None,
|
||||||
plugin: Optional[str] = None,
|
plugin: Optional[str] = None,
|
||||||
):
|
):
|
||||||
|
|
|
||||||
|
|
@ -2,16 +2,9 @@
|
||||||
|
|
||||||
import re
|
import re
|
||||||
import string
|
import string
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from django.conf import settings
|
|
||||||
from django.utils import translation
|
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
from babel import Locale
|
|
||||||
from babel.numbers import parse_pattern
|
|
||||||
from djmoney.money import Money
|
|
||||||
|
|
||||||
|
|
||||||
def parse_format_string(fmt_string: str) -> dict:
|
def parse_format_string(fmt_string: str) -> dict:
|
||||||
"""Extract formatting information from the provided format string.
|
"""Extract formatting information from the provided format string.
|
||||||
|
|
@ -180,43 +173,3 @@ def extract_named_group(name: str, value: str, fmt_string: str) -> str:
|
||||||
# And return the value we are interested in
|
# And return the value we are interested in
|
||||||
# Note: This will raise an IndexError if the named group was not matched
|
# Note: This will raise an IndexError if the named group was not matched
|
||||||
return result.group(name)
|
return result.group(name)
|
||||||
|
|
||||||
|
|
||||||
def format_money(
|
|
||||||
money: Money,
|
|
||||||
decimal_places: Optional[int] = None,
|
|
||||||
fmt: Optional[str] = None,
|
|
||||||
include_symbol: bool = True,
|
|
||||||
) -> str:
|
|
||||||
"""Format money object according to the currently set local.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
money (Money): The money object to format
|
|
||||||
decimal_places (int): Number of decimal places to use
|
|
||||||
fmt (str): Format pattern according LDML / the babel format pattern syntax (https://babel.pocoo.org/en/latest/numbers.html)
|
|
||||||
include_symbol (bool): Whether to include the currency symbol in the formatted output
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
str: The formatted string
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
ValueError: format string is incorrectly specified
|
|
||||||
"""
|
|
||||||
language = (None) or settings.LANGUAGE_CODE
|
|
||||||
locale = Locale.parse(translation.to_locale(language))
|
|
||||||
if fmt:
|
|
||||||
pattern = parse_pattern(fmt)
|
|
||||||
else:
|
|
||||||
pattern = locale.currency_formats['standard']
|
|
||||||
if decimal_places is not None:
|
|
||||||
pattern.frac_prec = (decimal_places, decimal_places)
|
|
||||||
|
|
||||||
result = pattern.apply(
|
|
||||||
money.amount,
|
|
||||||
locale,
|
|
||||||
currency=money.currency.code if include_symbol else '',
|
|
||||||
currency_digits=decimal_places is None,
|
|
||||||
decimal_quantization=decimal_places is not None,
|
|
||||||
)
|
|
||||||
|
|
||||||
return result
|
|
||||||
|
|
|
||||||
|
|
@ -3,12 +3,10 @@
|
||||||
import io
|
import io
|
||||||
import ipaddress
|
import ipaddress
|
||||||
import socket
|
import socket
|
||||||
from decimal import Decimal
|
|
||||||
from typing import Optional, cast
|
from typing import Optional, cast
|
||||||
from urllib.parse import urljoin, urlparse
|
from urllib.parse import urljoin, urlparse
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core.exceptions import ValidationError
|
|
||||||
from django.core.validators import URLValidator
|
from django.core.validators import URLValidator
|
||||||
from django.db.utils import OperationalError, ProgrammingError
|
from django.db.utils import OperationalError, ProgrammingError
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
@ -16,8 +14,6 @@ from django.utils.translation import gettext_lazy as _
|
||||||
import requests
|
import requests
|
||||||
import requests.exceptions
|
import requests.exceptions
|
||||||
import structlog
|
import structlog
|
||||||
from djmoney.contrib.exchange.models import convert_money
|
|
||||||
from djmoney.money import Money
|
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
|
|
||||||
from common.notifications import (
|
from common.notifications import (
|
||||||
|
|
@ -31,7 +27,6 @@ from InvenTree.cache import (
|
||||||
get_session_cache,
|
get_session_cache,
|
||||||
set_session_cache,
|
set_session_cache,
|
||||||
)
|
)
|
||||||
from InvenTree.format import format_money
|
|
||||||
from InvenTree.ready import ignore_ready_warning
|
from InvenTree.ready import ignore_ready_warning
|
||||||
|
|
||||||
logger = structlog.get_logger('inventree')
|
logger = structlog.get_logger('inventree')
|
||||||
|
|
@ -252,85 +247,6 @@ def download_image_from_url(
|
||||||
return img
|
return img
|
||||||
|
|
||||||
|
|
||||||
def render_currency(
|
|
||||||
money: Money,
|
|
||||||
decimal_places: Optional[int] = None,
|
|
||||||
currency: Optional[str] = None,
|
|
||||||
multiplier: Optional[Decimal] = None,
|
|
||||||
min_decimal_places: Optional[int] = None,
|
|
||||||
max_decimal_places: Optional[int] = None,
|
|
||||||
include_symbol: bool = True,
|
|
||||||
):
|
|
||||||
"""Render a currency / Money object to a formatted string (e.g. for reports).
|
|
||||||
|
|
||||||
Arguments:
|
|
||||||
money: The Money instance to be rendered
|
|
||||||
decimal_places: The number of decimal places to render to. If unspecified, uses the PRICING_DECIMAL_PLACES setting.
|
|
||||||
currency: Optionally convert to the specified currency
|
|
||||||
multiplier: An optional multiplier to apply to the money amount before rendering
|
|
||||||
min_decimal_places: The minimum number of decimal places to render to. If unspecified, uses the PRICING_DECIMAL_PLACES_MIN setting.
|
|
||||||
max_decimal_places: The maximum number of decimal places to render to. If unspecified, uses the PRICING_DECIMAL_PLACES setting.
|
|
||||||
include_symbol: If True, include the currency symbol in the output
|
|
||||||
"""
|
|
||||||
if money in [None, '']:
|
|
||||||
return '-'
|
|
||||||
|
|
||||||
if type(money) is not Money:
|
|
||||||
# Try to convert to a Money object
|
|
||||||
try:
|
|
||||||
money = Money(
|
|
||||||
Decimal(str(money)),
|
|
||||||
currency or get_global_setting('INVENTREE_DEFAULT_CURRENCY'),
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
raise ValidationError(
|
|
||||||
f"render_currency: {_('Invalid money value')}: '{money}' ({type(money).__name__})"
|
|
||||||
)
|
|
||||||
|
|
||||||
if currency is not None:
|
|
||||||
# Attempt to convert to the provided currency
|
|
||||||
# If cannot be done, leave the original
|
|
||||||
try:
|
|
||||||
money = convert_money(money, currency)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
if multiplier is not None:
|
|
||||||
try:
|
|
||||||
money *= Decimal(str(multiplier).strip())
|
|
||||||
except Exception:
|
|
||||||
raise ValidationError(
|
|
||||||
f"render_currency: {_('Invalid multiplier value')}: '{multiplier}' ({type(multiplier).__name__})"
|
|
||||||
)
|
|
||||||
|
|
||||||
if min_decimal_places is None or not isinstance(min_decimal_places, (int, float)):
|
|
||||||
min_decimal_places = get_global_setting('PRICING_DECIMAL_PLACES_MIN', 0)
|
|
||||||
|
|
||||||
if max_decimal_places is None or not isinstance(max_decimal_places, (int, float)):
|
|
||||||
max_decimal_places = get_global_setting('PRICING_DECIMAL_PLACES', 6)
|
|
||||||
|
|
||||||
value = Decimal(str(money.amount)).normalize()
|
|
||||||
value = str(value)
|
|
||||||
|
|
||||||
if decimal_places is not None and isinstance(decimal_places, (int, float)):
|
|
||||||
# Decimal place count is provided, use it
|
|
||||||
pass
|
|
||||||
elif '.' in value:
|
|
||||||
# If the value has a decimal point, use the number of decimal places in the value
|
|
||||||
decimal_places = len(value.split('.')[-1])
|
|
||||||
else:
|
|
||||||
# No decimal point, use 2 as a default
|
|
||||||
decimal_places = 2
|
|
||||||
|
|
||||||
# Clip the decimal places to the specified range
|
|
||||||
decimal_places = max(decimal_places, min_decimal_places)
|
|
||||||
decimal_places = min(decimal_places, max_decimal_places)
|
|
||||||
|
|
||||||
return format_money(
|
|
||||||
money, decimal_places=decimal_places, include_symbol=include_symbol
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@ignore_ready_warning
|
@ignore_ready_warning
|
||||||
def getModelsWithMixin(mixin_class) -> list:
|
def getModelsWithMixin(mixin_class) -> list:
|
||||||
"""Return a list of database models that inherit from the given mixin class.
|
"""Return a list of database models that inherit from the given mixin class.
|
||||||
|
|
|
||||||
|
|
@ -106,6 +106,32 @@ apps_mfa_bypass = [
|
||||||
"""App namespaces that bypass MFA enforcement - normal security model is still enforced."""
|
"""App namespaces that bypass MFA enforcement - normal security model is still enforced."""
|
||||||
|
|
||||||
|
|
||||||
|
def csrf_failure(request, reason=''):
|
||||||
|
"""Custom CSRF failure handler.
|
||||||
|
|
||||||
|
Returns a JSON response for API/headless requests so the frontend can
|
||||||
|
provide a meaningful error message to the user
|
||||||
|
"""
|
||||||
|
from django.views.csrf import csrf_failure as django_default
|
||||||
|
|
||||||
|
if (
|
||||||
|
request.path.startswith('/_allauth/')
|
||||||
|
or request.path.startswith('/api/')
|
||||||
|
or 'application/json' in request.headers.get('Accept', '')
|
||||||
|
or 'application/json' in request.headers.get('Content-Type', '')
|
||||||
|
):
|
||||||
|
return JsonResponse(
|
||||||
|
{
|
||||||
|
'detail': _(
|
||||||
|
'CSRF verification failed. Ensure INVENTREE_SITE_URL and INVENTREE_TRUSTED_ORIGINS are configured correctly.'
|
||||||
|
)
|
||||||
|
},
|
||||||
|
status=403,
|
||||||
|
)
|
||||||
|
|
||||||
|
return django_default(request, reason=reason)
|
||||||
|
|
||||||
|
|
||||||
class AuthRequiredMiddleware:
|
class AuthRequiredMiddleware:
|
||||||
"""Check for user to be authenticated."""
|
"""Check for user to be authenticated."""
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ from django_q.models import Task
|
||||||
from error_report.models import Error
|
from error_report.models import Error
|
||||||
from mptt.exceptions import InvalidMove
|
from mptt.exceptions import InvalidMove
|
||||||
from mptt.models import MPTTModel, TreeForeignKey
|
from mptt.models import MPTTModel, TreeForeignKey
|
||||||
|
from rest_framework.exceptions import PermissionDenied
|
||||||
from stdimage.models import StdImageField
|
from stdimage.models import StdImageField
|
||||||
from taggit.managers import TaggableManager
|
from taggit.managers import TaggableManager
|
||||||
|
|
||||||
|
|
@ -568,7 +569,7 @@ class InvenTreeParameterMixin(InvenTreePermissionCheckMixin, models.Model):
|
||||||
if 'parameters_list' in cache:
|
if 'parameters_list' in cache:
|
||||||
return cache['parameters_list']
|
return cache['parameters_list']
|
||||||
|
|
||||||
return self.parameters_list.all()
|
return self.parameters_list.all().prefetch_related('template')
|
||||||
|
|
||||||
def delete(self, *args, **kwargs):
|
def delete(self, *args, **kwargs):
|
||||||
"""Handle the deletion of a model instance.
|
"""Handle the deletion of a model instance.
|
||||||
|
|
@ -1334,8 +1335,16 @@ class InvenTreeBarcodeMixin(models.Model):
|
||||||
|
|
||||||
return generate_barcode(self)
|
return generate_barcode(self)
|
||||||
|
|
||||||
def format_matched_response(self):
|
def format_matched_response(self, user, **kwargs):
|
||||||
"""Format a standard response for a matched barcode."""
|
"""Format a standard response for a matched barcode."""
|
||||||
|
# Check permission for this object
|
||||||
|
from users.permissions import check_user_permission
|
||||||
|
|
||||||
|
if not check_user_permission(user, self, 'view'):
|
||||||
|
raise PermissionDenied(
|
||||||
|
_('User does not have permission to view this model')
|
||||||
|
)
|
||||||
|
|
||||||
data = {'pk': self.pk}
|
data = {'pk': self.pk}
|
||||||
|
|
||||||
if hasattr(self, 'get_api_url'):
|
if hasattr(self, 'get_api_url'):
|
||||||
|
|
|
||||||
|
|
@ -282,7 +282,7 @@ def postprocess_print_stats(result, generator, request, public):
|
||||||
scopes = {}
|
scopes = {}
|
||||||
for path, details in rlt_dict.items():
|
for path, details in rlt_dict.items():
|
||||||
if details['oauth']:
|
if details['oauth']:
|
||||||
for scope in details['oauth']:
|
for scope in list(details['oauth']):
|
||||||
if scope not in scopes:
|
if scope not in scopes:
|
||||||
scopes[scope] = []
|
scopes[scope] = []
|
||||||
scopes[scope].append(path)
|
scopes[scope].append(path)
|
||||||
|
|
@ -343,7 +343,7 @@ def schema_for_view_output_options(view_class):
|
||||||
return extended_view
|
return extended_view
|
||||||
|
|
||||||
|
|
||||||
def exclude_from_schema(klass: type, alternative_path: str) -> type:
|
def exclude_from_schema(klass: type[Any], alternative_path: str) -> type[Any]:
|
||||||
"""Decorator to exclude a view from the OpenAPI schema.
|
"""Decorator to exclude a view from the OpenAPI schema.
|
||||||
|
|
||||||
This is used to hide legacy endpoints from the schema, while still retaining them for backwards compatibility.
|
This is used to hide legacy endpoints from the schema, while still retaining them for backwards compatibility.
|
||||||
|
|
|
||||||
|
|
@ -9,8 +9,8 @@ def get_ldap_config(debug: bool = False) -> dict:
|
||||||
The returned settings will be updated into the globals() object,
|
The returned settings will be updated into the globals() object,
|
||||||
and will be used to configure the LDAP authentication backend.
|
and will be used to configure the LDAP authentication backend.
|
||||||
"""
|
"""
|
||||||
import django_auth_ldap.config # type: ignore[unresolved-import]
|
import django_auth_ldap.config # ty: ignore[unresolved-import]
|
||||||
import ldap # type: ignore[unresolved-import]
|
import ldap # ty: ignore[unresolved-import]
|
||||||
|
|
||||||
# get global options from dict and use ldap.OPT_* as keys and values
|
# get global options from dict and use ldap.OPT_* as keys and values
|
||||||
global_options_dict = get_setting(
|
global_options_dict = get_setting(
|
||||||
|
|
|
||||||
|
|
@ -851,6 +851,7 @@ valid_cookie_modes = ['lax', 'strict', 'none']
|
||||||
COOKIE_MODE = COOKIE_MODE.capitalize() if COOKIE_MODE in valid_cookie_modes else False
|
COOKIE_MODE = COOKIE_MODE.capitalize() if COOKIE_MODE in valid_cookie_modes else False
|
||||||
|
|
||||||
# Additional CSRF settings
|
# Additional CSRF settings
|
||||||
|
CSRF_FAILURE_VIEW = 'InvenTree.middleware.csrf_failure'
|
||||||
CSRF_HEADER_NAME = 'HTTP_X_CSRFTOKEN'
|
CSRF_HEADER_NAME = 'HTTP_X_CSRFTOKEN'
|
||||||
CSRF_COOKIE_NAME = 'csrftoken'
|
CSRF_COOKIE_NAME = 'csrftoken'
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,7 @@
|
||||||
|
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
from django.conf import settings
|
from django.http import Http404, HttpRequest
|
||||||
from django.http import Http404
|
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
|
||||||
from error_report.models import Error
|
from error_report.models import Error
|
||||||
|
|
@ -289,18 +288,63 @@ class MiddlewareTests(InvenTreeTestCase):
|
||||||
response, 'window.INVENTREE_SETTINGS', status_code=500
|
response, 'window.INVENTREE_SETTINGS', status_code=500
|
||||||
)
|
)
|
||||||
|
|
||||||
# Log stuff # TODO remove
|
|
||||||
print(
|
|
||||||
'###DBG-TST###',
|
|
||||||
'site',
|
|
||||||
settings.SITE_URL,
|
|
||||||
'trusted',
|
|
||||||
settings.CSRF_TRUSTED_ORIGINS,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Check that the correct step triggers the error message
|
# Check that the correct step triggers the error message
|
||||||
self.assertContains(
|
self.assertContains(
|
||||||
response,
|
response,
|
||||||
'INVE-E7: The visited path `http://testserver` does not match',
|
'INVE-E7: The visited path `http://testserver` does not match',
|
||||||
status_code=500,
|
status_code=500,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_csrf_failure(self):
|
||||||
|
"""Test the custom CSRF failure handler."""
|
||||||
|
from InvenTree.middleware import csrf_failure
|
||||||
|
|
||||||
|
EXPECTED_DETAIL = 'CSRF verification failed. Ensure INVENTREE_SITE_URL and INVENTREE_TRUSTED_ORIGINS are configured correctly.'
|
||||||
|
|
||||||
|
def make_request(path, headers=None):
|
||||||
|
request = HttpRequest()
|
||||||
|
request.path = path
|
||||||
|
request.META['SERVER_NAME'] = 'testserver'
|
||||||
|
request.META['SERVER_PORT'] = '80'
|
||||||
|
for key, value in (headers or {}).items():
|
||||||
|
request.META[f'HTTP_{key.upper().replace("-", "_")}'] = value
|
||||||
|
return request
|
||||||
|
|
||||||
|
# API path -> JSON 403 with meaningful message
|
||||||
|
response = csrf_failure(
|
||||||
|
make_request('/api/part/'), reason='origin check failed'
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 403)
|
||||||
|
import json
|
||||||
|
|
||||||
|
data = json.loads(response.content)
|
||||||
|
self.assertEqual(data['detail'], EXPECTED_DETAIL)
|
||||||
|
|
||||||
|
# allauth headless path -> JSON 403
|
||||||
|
response = csrf_failure(make_request('/_allauth/browser/v1/auth/login'))
|
||||||
|
self.assertEqual(response.status_code, 403)
|
||||||
|
data = json.loads(response.content)
|
||||||
|
self.assertEqual(data['detail'], EXPECTED_DETAIL)
|
||||||
|
|
||||||
|
# Accept: application/json header -> JSON 403
|
||||||
|
response = csrf_failure(
|
||||||
|
make_request('/some/other/path/', {'Accept': 'application/json'}),
|
||||||
|
reason='origin check failed',
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 403)
|
||||||
|
data = json.loads(response.content)
|
||||||
|
self.assertEqual(data['detail'], EXPECTED_DETAIL)
|
||||||
|
|
||||||
|
# Content-Type: application/json header -> JSON 403
|
||||||
|
response = csrf_failure(
|
||||||
|
make_request('/some/other/path/', {'Content-Type': 'application/json'}),
|
||||||
|
reason='origin check failed',
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 403)
|
||||||
|
data = json.loads(response.content)
|
||||||
|
self.assertEqual(data['detail'], EXPECTED_DETAIL)
|
||||||
|
|
||||||
|
# Plain browser request -> falls back to Django default CSRF page (403 HTML, not JSON)
|
||||||
|
response = csrf_failure(make_request('/web/'), reason='origin check failed')
|
||||||
|
self.assertEqual(response.status_code, 403)
|
||||||
|
self.assertNotIn(b'application/json', response.get('Content-Type', '').encode())
|
||||||
|
|
|
||||||
|
|
@ -608,35 +608,6 @@ class FormatTest(TestCase):
|
||||||
with self.assertRaises(ValueError):
|
with self.assertRaises(ValueError):
|
||||||
InvenTree.format.extract_named_group('test', 'PO-ABC-xyz', 'PO-###-{test}')
|
InvenTree.format.extract_named_group('test', 'PO-ABC-xyz', 'PO-###-{test}')
|
||||||
|
|
||||||
def test_currency_formatting(self):
|
|
||||||
"""Test that currency formatting works correctly for multiple currencies."""
|
|
||||||
test_data = (
|
|
||||||
(Money(3651.285718, 'USD'), 4, True, '$3,651.2857'),
|
|
||||||
(Money(487587.849178, 'CAD'), 5, True, 'CA$487,587.84918'),
|
|
||||||
(Money(0.348102, 'EUR'), 1, False, '0.3'),
|
|
||||||
(Money(0.916530, 'GBP'), 1, True, '£0.9'),
|
|
||||||
(Money(61.031024, 'JPY'), 3, False, '61.031'),
|
|
||||||
(Money(49609.694602, 'JPY'), 1, True, '¥49,609.7'),
|
|
||||||
(Money(155565.264777, 'AUD'), 2, False, '155,565.26'),
|
|
||||||
(Money(0.820437, 'CNY'), 4, True, 'CN¥0.8204'),
|
|
||||||
(Money(7587.849178, 'EUR'), 0, True, '€7,588'),
|
|
||||||
(Money(0.348102, 'GBP'), 3, False, '0.348'),
|
|
||||||
(Money(0.652923, 'CHF'), 0, True, 'CHF1'),
|
|
||||||
(Money(0.820437, 'CNY'), 1, True, 'CN¥0.8'),
|
|
||||||
(Money(98789.5295680, 'CHF'), 0, False, '98,790'),
|
|
||||||
(Money(0.585787, 'USD'), 1, True, '$0.6'),
|
|
||||||
(Money(0.690541, 'CAD'), 3, True, 'CA$0.691'),
|
|
||||||
(Money(427.814104, 'AUD'), 5, True, 'A$427.81410'),
|
|
||||||
)
|
|
||||||
|
|
||||||
with self.settings(LANGUAGE_CODE='en-us'):
|
|
||||||
for value, decimal_places, include_symbol, expected_result in test_data:
|
|
||||||
result = InvenTree.format.format_money(
|
|
||||||
value, decimal_places=decimal_places, include_symbol=include_symbol
|
|
||||||
)
|
|
||||||
|
|
||||||
self.assertEqual(result, expected_result)
|
|
||||||
|
|
||||||
|
|
||||||
class TestHelpers(TestCase):
|
class TestHelpers(TestCase):
|
||||||
"""Tests for InvenTree helper functions."""
|
"""Tests for InvenTree helper functions."""
|
||||||
|
|
|
||||||
|
|
@ -15,10 +15,10 @@ from datetime import timedelta as td
|
||||||
from .api_version import INVENTREE_API_TEXT, INVENTREE_API_VERSION
|
from .api_version import INVENTREE_API_TEXT, INVENTREE_API_VERSION
|
||||||
|
|
||||||
# InvenTree software version
|
# InvenTree software version
|
||||||
INVENTREE_SW_VERSION = '1.4.0 dev'
|
INVENTREE_SW_VERSION = '1.5.0 dev'
|
||||||
|
|
||||||
# Minimum supported Python version
|
# Minimum supported Python version
|
||||||
MIN_PYTHON_VERSION = (3, 11)
|
MIN_PYTHON_VERSION = (3, 12)
|
||||||
|
|
||||||
logger = logging.getLogger('inventree')
|
logger = logging.getLogger('inventree')
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
from django.db.models import F, OuterRef, Q, Subquery, Sum
|
from django.db.models import DecimalField, F, OuterRef, Q, Subquery, Sum
|
||||||
from django.db.models.functions import Coalesce
|
from django.db.models.functions import Coalesce
|
||||||
from django.urls import include, path
|
from django.urls import include, path
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
@ -395,9 +395,7 @@ class BuildList(
|
||||||
serializer = self.get_serializer(data=self.clean_data(request.data))
|
serializer = self.get_serializer(data=self.clean_data(request.data))
|
||||||
serializer.is_valid(raise_exception=True)
|
serializer.is_valid(raise_exception=True)
|
||||||
|
|
||||||
build = serializer.save()
|
serializer.save(issued_by=request.user)
|
||||||
build.issued_by = request.user
|
|
||||||
build.save()
|
|
||||||
|
|
||||||
headers = self.get_success_headers(serializer.data)
|
headers = self.get_success_headers(serializer.data)
|
||||||
return Response(
|
return Response(
|
||||||
|
|
@ -496,9 +494,25 @@ class BuildLineFilter(FilterSet):
|
||||||
|
|
||||||
def filter_allocated(self, queryset, name, value):
|
def filter_allocated(self, queryset, name, value):
|
||||||
"""Filter by whether each BuildLine is fully allocated."""
|
"""Filter by whether each BuildLine is fully allocated."""
|
||||||
|
allocated_subquery = (
|
||||||
|
BuildItem.objects
|
||||||
|
.filter(build_line=OuterRef('pk'))
|
||||||
|
.values('build_line')
|
||||||
|
.annotate(total=Sum('quantity'))
|
||||||
|
.values('total')
|
||||||
|
)
|
||||||
|
|
||||||
|
queryset = queryset.alias(
|
||||||
|
allocated_quantity=Coalesce(
|
||||||
|
Subquery(allocated_subquery), 0, output_field=DecimalField()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
if str2bool(value):
|
if str2bool(value):
|
||||||
return queryset.filter(allocated__gte=F('quantity') - F('consumed'))
|
return queryset.filter(
|
||||||
return queryset.filter(allocated__lt=F('quantity') - F('consumed'))
|
allocated_quantity__gte=F('quantity') - F('consumed')
|
||||||
|
)
|
||||||
|
return queryset.filter(allocated_quantity__lt=F('quantity') - F('consumed'))
|
||||||
|
|
||||||
consumed = rest_filters.BooleanFilter(label=_('Consumed'), method='filter_consumed')
|
consumed = rest_filters.BooleanFilter(label=_('Consumed'), method='filter_consumed')
|
||||||
|
|
||||||
|
|
@ -530,7 +544,9 @@ class BuildLineFilter(FilterSet):
|
||||||
)
|
)
|
||||||
|
|
||||||
queryset = queryset.alias(
|
queryset = queryset.alias(
|
||||||
allocated_quantity=Coalesce(Subquery(allocated_subquery), 0)
|
allocated_quantity=Coalesce(
|
||||||
|
Subquery(allocated_subquery), 0, output_field=DecimalField()
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
# A query filter construct to determine the total quantity available for this BuildLine,
|
# A query filter construct to determine the total quantity available for this BuildLine,
|
||||||
|
|
|
||||||
|
|
@ -479,9 +479,7 @@ class NotesImageList(ListCreateAPI):
|
||||||
|
|
||||||
def perform_create(self, serializer):
|
def perform_create(self, serializer):
|
||||||
"""Create (upload) a new notes image."""
|
"""Create (upload) a new notes image."""
|
||||||
image = serializer.save()
|
serializer.save(user=self.request.user)
|
||||||
image.user = self.request.user
|
|
||||||
image.save()
|
|
||||||
|
|
||||||
|
|
||||||
class ProjectCodeList(DataExportViewMixin, ListCreateAPI):
|
class ProjectCodeList(DataExportViewMixin, ListCreateAPI):
|
||||||
|
|
@ -579,7 +577,7 @@ class CustomUnitViewset(DataExportViewMixin, viewsets.ModelViewSet):
|
||||||
return {
|
return {
|
||||||
'name': k,
|
'name': k,
|
||||||
'is_alias': reg.get_name(k) == k,
|
'is_alias': reg.get_name(k) == k,
|
||||||
'compatible_units': [str(a) for a in unit.compatible_units()],
|
'compatible_units': [str(a) for a in unit.compatible_units()], # ty:ignore[missing-argument]
|
||||||
'isdimensionless': unit.dimensionless,
|
'isdimensionless': unit.dimensionless,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -810,9 +808,7 @@ class AttachmentList(AttachmentMixin, BulkDeleteMixin, ListCreateAPI):
|
||||||
|
|
||||||
def perform_create(self, serializer):
|
def perform_create(self, serializer):
|
||||||
"""Save the user information when a file is uploaded."""
|
"""Save the user information when a file is uploaded."""
|
||||||
attachment = serializer.save()
|
serializer.save(upload_user=self.request.user)
|
||||||
attachment.upload_user = self.request.user
|
|
||||||
attachment.save()
|
|
||||||
|
|
||||||
def validate_delete(self, queryset, request) -> None:
|
def validate_delete(self, queryset, request) -> None:
|
||||||
"""Ensure that the user has correct permissions for a bulk-delete.
|
"""Ensure that the user has correct permissions for a bulk-delete.
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,7 @@
|
||||||
"""Types for settings."""
|
"""Types for settings."""
|
||||||
|
|
||||||
import sys
|
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from typing import Any, TypedDict
|
from typing import Any, NotRequired, TypedDict
|
||||||
|
|
||||||
if sys.version_info >= (3, 11):
|
|
||||||
from typing import NotRequired # pragma: no cover
|
|
||||||
else:
|
|
||||||
|
|
||||||
class NotRequired: # pragma: no cover
|
|
||||||
"""NotRequired type helper is only supported with Python 3.11+."""
|
|
||||||
|
|
||||||
def __class_getitem__(cls, item):
|
|
||||||
"""Return the item."""
|
|
||||||
return item
|
|
||||||
|
|
||||||
|
|
||||||
class SettingsKeyType(TypedDict, total=False):
|
class SettingsKeyType(TypedDict, total=False):
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import re
|
||||||
|
|
||||||
from django.core.exceptions import SuspiciousFileOperation, ValidationError
|
from django.core.exceptions import SuspiciousFileOperation, ValidationError
|
||||||
from django.core.files.storage import default_storage
|
from django.core.files.storage import default_storage
|
||||||
|
from django.utils import translation
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
import common.icons
|
import common.icons
|
||||||
|
|
@ -171,3 +172,17 @@ def validate_variable_string(value: str):
|
||||||
"""The passed value must be a valid variable identifier string."""
|
"""The passed value must be a valid variable identifier string."""
|
||||||
if not re.match(r'^[a-zA-Z_][a-zA-Z0-9_]*$', value):
|
if not re.match(r'^[a-zA-Z_][a-zA-Z0-9_]*$', value):
|
||||||
raise ValidationError(_('Value must be a valid variable identifier'))
|
raise ValidationError(_('Value must be a valid variable identifier'))
|
||||||
|
|
||||||
|
|
||||||
|
def validate_locale(value: str):
|
||||||
|
"""Validate that the provided value is a valid locale string."""
|
||||||
|
from babel import Locale
|
||||||
|
from babel.core import UnknownLocaleError
|
||||||
|
|
||||||
|
if not value:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
Locale.parse(translation.to_locale(value))
|
||||||
|
except (UnknownLocaleError, ValueError) as e:
|
||||||
|
raise ValidationError(f"Invalid locale value: '{value}' - {e}")
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ TESTING = 'test' in sys.argv
|
||||||
|
|
||||||
def clear():
|
def clear():
|
||||||
if not TESTING: # pragma: no cover
|
if not TESTING: # pragma: no cover
|
||||||
os.system('cls' if os.name == 'nt' else 'clear')
|
os.system('cls' if os.name == 'nt' else 'clear') # ty:ignore[deprecated]
|
||||||
|
|
||||||
|
|
||||||
def reverse_association(apps, schema_editor): # pragma: no cover
|
def reverse_association(apps, schema_editor): # pragma: no cover
|
||||||
|
|
|
||||||
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
|
|
@ -3,6 +3,7 @@
|
||||||
from django.urls import include, path, re_path
|
from django.urls import include, path, re_path
|
||||||
|
|
||||||
from drf_spectacular.utils import extend_schema
|
from drf_spectacular.utils import extend_schema
|
||||||
|
from rest_framework import permissions
|
||||||
from rest_framework.exceptions import NotFound
|
from rest_framework.exceptions import NotFound
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
from rest_framework.views import APIView
|
from rest_framework.views import APIView
|
||||||
|
|
@ -142,7 +143,10 @@ class MachineRestart(APIView):
|
||||||
- POST: restart machine by pk
|
- POST: restart machine by pk
|
||||||
"""
|
"""
|
||||||
|
|
||||||
permission_classes = [InvenTree.permissions.IsAuthenticatedOrReadScope]
|
permission_classes = [
|
||||||
|
permissions.IsAuthenticated,
|
||||||
|
InvenTree.permissions.IsStaffOrReadOnlyScope,
|
||||||
|
]
|
||||||
|
|
||||||
@extend_schema(
|
@extend_schema(
|
||||||
request=None, responses={200: MachineSerializers.MachineRestartSerializer()}
|
request=None, responses={200: MachineSerializers.MachineRestartSerializer()}
|
||||||
|
|
|
||||||
|
|
@ -287,14 +287,23 @@ class MachineAPITest(TestMachineRegistryMixin, InvenTreeAPITestCase):
|
||||||
active=True,
|
active=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
restart_url = reverse('api-machine-restart', kwargs={'pk': machine.pk})
|
||||||
|
|
||||||
|
# Non-staff users must not be able to restart a machine
|
||||||
|
self.user.is_staff = False
|
||||||
|
self.user.save()
|
||||||
|
self.post(restart_url, expected_code=403)
|
||||||
|
|
||||||
|
# Restore staff access
|
||||||
|
self.user.is_staff = True
|
||||||
|
self.user.save()
|
||||||
|
|
||||||
# verify machine status before restart
|
# verify machine status before restart
|
||||||
response = self.get(reverse('api-machine-detail', kwargs={'pk': machine.pk}))
|
response = self.get(reverse('api-machine-detail', kwargs={'pk': machine.pk}))
|
||||||
self.assertEqual(response.data['status_text'], '')
|
self.assertEqual(response.data['status_text'], '')
|
||||||
|
|
||||||
# restart the machine
|
# restart the machine
|
||||||
response = self.post(
|
self.post(restart_url, expected_code=200)
|
||||||
reverse('api-machine-restart', kwargs={'pk': machine.pk}), expected_code=200
|
|
||||||
)
|
|
||||||
|
|
||||||
# verify machine status after restart
|
# verify machine status after restart
|
||||||
response = self.get(reverse('api-machine-detail', kwargs={'pk': machine.pk}))
|
response = self.get(reverse('api-machine-detail', kwargs={'pk': machine.pk}))
|
||||||
|
|
|
||||||
|
|
@ -103,9 +103,7 @@ class OrderCreateMixin:
|
||||||
serializer = self.get_serializer(data=self.clean_data(request.data))
|
serializer = self.get_serializer(data=self.clean_data(request.data))
|
||||||
serializer.is_valid(raise_exception=True)
|
serializer.is_valid(raise_exception=True)
|
||||||
|
|
||||||
item = serializer.save()
|
serializer.save(created_by=request.user)
|
||||||
item.created_by = request.user
|
|
||||||
item.save()
|
|
||||||
|
|
||||||
headers = self.get_success_headers(serializer.data)
|
headers = self.get_success_headers(serializer.data)
|
||||||
return Response(
|
return Response(
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@ class SalesOrderEvents(BaseEventEnum):
|
||||||
"""Event enumeration for the SalesOrder models."""
|
"""Event enumeration for the SalesOrder models."""
|
||||||
|
|
||||||
ISSUED = 'salesorder.issued'
|
ISSUED = 'salesorder.issued'
|
||||||
HOLD = 'salesorder.onhold'
|
HOLD = 'salesorder.hold'
|
||||||
COMPLETED = 'salesorder.completed'
|
COMPLETED = 'salesorder.completed'
|
||||||
CANCELLED = 'salesorder.cancelled'
|
CANCELLED = 'salesorder.cancelled'
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -272,7 +272,7 @@ class ReturnOrderReportContext(report.mixins.BaseReportContext, TypedDict):
|
||||||
customer: Optional[Company]
|
customer: Optional[Company]
|
||||||
|
|
||||||
|
|
||||||
class TransferOrderReportContext(report.mixins.BaseReportContext, TypedDict):
|
class TransferOrderReportContext(BaseOrderReportContext, TypedDict):
|
||||||
"""Context for the transfer order model.
|
"""Context for the transfer order model.
|
||||||
|
|
||||||
Attributes:
|
Attributes:
|
||||||
|
|
@ -639,7 +639,8 @@ class PurchaseOrder(TotalPriceMixin, Order):
|
||||||
def report_context(self) -> PurchaseOrderReportContext:
|
def report_context(self) -> PurchaseOrderReportContext:
|
||||||
"""Return report context data for this PurchaseOrder."""
|
"""Return report context data for this PurchaseOrder."""
|
||||||
return_ctx = super().report_context()
|
return_ctx = super().report_context()
|
||||||
return_ctx.update({'supplier': self.supplier})
|
|
||||||
|
return_ctx.update({'supplier': self.supplier}) # ty:ignore[invalid-key]
|
||||||
return return_ctx
|
return return_ctx
|
||||||
|
|
||||||
def get_absolute_url(self) -> str:
|
def get_absolute_url(self) -> str:
|
||||||
|
|
@ -1372,7 +1373,8 @@ class SalesOrder(TotalPriceMixin, Order):
|
||||||
def report_context(self) -> SalesOrderReportContext:
|
def report_context(self) -> SalesOrderReportContext:
|
||||||
"""Generate report context data for this SalesOrder."""
|
"""Generate report context data for this SalesOrder."""
|
||||||
return_ctx = super().report_context()
|
return_ctx = super().report_context()
|
||||||
return_ctx.update({'customer': self.customer})
|
|
||||||
|
return_ctx.update({'customer': self.customer}) # ty:ignore[invalid-key]
|
||||||
return return_ctx
|
return return_ctx
|
||||||
|
|
||||||
def get_absolute_url(self) -> str:
|
def get_absolute_url(self) -> str:
|
||||||
|
|
@ -2931,7 +2933,8 @@ class ReturnOrder(TotalPriceMixin, Order):
|
||||||
def report_context(self) -> ReturnOrderReportContext:
|
def report_context(self) -> ReturnOrderReportContext:
|
||||||
"""Generate report context data for this ReturnOrder."""
|
"""Generate report context data for this ReturnOrder."""
|
||||||
return_ctx = super().report_context()
|
return_ctx = super().report_context()
|
||||||
return_ctx.update({'customer': self.customer})
|
|
||||||
|
return_ctx.update({'customer': self.customer}) # ty:ignore[invalid-key]
|
||||||
return return_ctx
|
return return_ctx
|
||||||
|
|
||||||
def get_absolute_url(self):
|
def get_absolute_url(self):
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ from InvenTree.api import (
|
||||||
BulkUpdateMixin,
|
BulkUpdateMixin,
|
||||||
ListCreateDestroyAPIView,
|
ListCreateDestroyAPIView,
|
||||||
ParameterListMixin,
|
ParameterListMixin,
|
||||||
|
TreeMixin,
|
||||||
meta_path,
|
meta_path,
|
||||||
)
|
)
|
||||||
from InvenTree.fields import InvenTreeOutputOption, OutputConfiguration
|
from InvenTree.fields import InvenTreeOutputOption, OutputConfiguration
|
||||||
|
|
@ -284,25 +285,38 @@ class CategoryDetail(CategoryMixin, OutputOptionsMixin, CustomRetrieveUpdateDest
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class CategoryTree(ListAPI):
|
class CategoryTreeFilter(FilterSet):
|
||||||
|
"""Custom filterset class for the CategoryTree endpoint."""
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
"""Metaclass options for this filterset."""
|
||||||
|
|
||||||
|
model = PartCategory
|
||||||
|
fields = ['parent', 'tree_id', 'level']
|
||||||
|
|
||||||
|
max_level = rest_filters.NumberFilter(
|
||||||
|
label=_('Max Level'),
|
||||||
|
method='filter_max_level',
|
||||||
|
help_text=_('Limit the depth of the category tree'),
|
||||||
|
)
|
||||||
|
|
||||||
|
def filter_max_level(self, queryset, name, value):
|
||||||
|
"""Filter by the maximum depth of the category tree."""
|
||||||
|
return queryset.filter(level__lte=value)
|
||||||
|
|
||||||
|
|
||||||
|
class CategoryTree(TreeMixin, ListAPI):
|
||||||
"""API endpoint for accessing a list of PartCategory objects ready for rendering a tree."""
|
"""API endpoint for accessing a list of PartCategory objects ready for rendering a tree."""
|
||||||
|
|
||||||
|
model_class = PartCategory
|
||||||
queryset = PartCategory.objects.all()
|
queryset = PartCategory.objects.all()
|
||||||
serializer_class = part_serializers.CategoryTree
|
serializer_class = part_serializers.CategoryTreeSerializer
|
||||||
|
filterset_class = CategoryTreeFilter
|
||||||
filter_backends = ORDER_FILTER
|
|
||||||
|
|
||||||
ordering_fields = ['level', 'name', 'subcategories']
|
|
||||||
|
|
||||||
ordering_field_aliases = {'level': ['level', 'name'], 'name': ['name', 'level']}
|
|
||||||
|
|
||||||
# Order by tree level (top levels first) and then name
|
|
||||||
ordering = ['level', 'name']
|
|
||||||
|
|
||||||
def get_queryset(self, *args, **kwargs):
|
def get_queryset(self, *args, **kwargs):
|
||||||
"""Return an annotated queryset for the CategoryTree endpoint."""
|
"""Return an annotated queryset for the CategoryTree endpoint."""
|
||||||
queryset = super().get_queryset(*args, **kwargs)
|
queryset = super().get_queryset(*args, **kwargs)
|
||||||
queryset = part_serializers.CategoryTree.annotate_queryset(queryset)
|
queryset = part_serializers.CategoryTreeSerializer.annotate_queryset(queryset)
|
||||||
return queryset
|
return queryset
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -181,14 +181,25 @@ class CategorySerializer(
|
||||||
parameters = common.filters.enable_parameters_filter()
|
parameters = common.filters.enable_parameters_filter()
|
||||||
|
|
||||||
|
|
||||||
class CategoryTree(InvenTree.serializers.InvenTreeModelSerializer):
|
class CategoryTreeSerializer(InvenTree.serializers.InvenTreeModelSerializer):
|
||||||
"""Serializer for PartCategory tree."""
|
"""Serializer for PartCategory tree."""
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
"""Metaclass defining serializer fields."""
|
"""Metaclass defining serializer fields."""
|
||||||
|
|
||||||
model = PartCategory
|
model = PartCategory
|
||||||
fields = ['pk', 'name', 'parent', 'icon', 'structural', 'subcategories']
|
fields = [
|
||||||
|
'pk',
|
||||||
|
'name',
|
||||||
|
'description',
|
||||||
|
'pathstring',
|
||||||
|
'parent',
|
||||||
|
'tree_id',
|
||||||
|
'level',
|
||||||
|
'icon',
|
||||||
|
'structural',
|
||||||
|
'subcategories',
|
||||||
|
]
|
||||||
|
|
||||||
subcategories = serializers.IntegerField(label=_('Subcategories'), read_only=True)
|
subcategories = serializers.IntegerField(label=_('Subcategories'), read_only=True)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -153,7 +153,9 @@ class BarcodeView(CreateAPIView):
|
||||||
|
|
||||||
for current_plugin in plugins:
|
for current_plugin in plugins:
|
||||||
try:
|
try:
|
||||||
result = current_plugin.scan(barcode)
|
result = current_plugin.scan(barcode, user=request.user, **kwargs)
|
||||||
|
except PermissionDenied as exc:
|
||||||
|
raise exc
|
||||||
except Exception:
|
except Exception:
|
||||||
log_error('BarcodeView.scan_barcode', plugin=current_plugin.slug)
|
log_error('BarcodeView.scan_barcode', plugin=current_plugin.slug)
|
||||||
continue
|
continue
|
||||||
|
|
@ -282,7 +284,7 @@ class BarcodeAssign(BarcodeView):
|
||||||
|
|
||||||
# First check if the provided barcode matches an existing database entry
|
# First check if the provided barcode matches an existing database entry
|
||||||
if inventree_barcode_plugin:
|
if inventree_barcode_plugin:
|
||||||
result = inventree_barcode_plugin.scan(barcode)
|
result = inventree_barcode_plugin.scan(barcode, user=request.user, **kwargs)
|
||||||
|
|
||||||
if result is not None:
|
if result is not None:
|
||||||
result['error'] = _('Barcode matches existing item')
|
result['error'] = _('Barcode matches existing item')
|
||||||
|
|
@ -459,7 +461,9 @@ class BarcodePOAllocate(BarcodeView):
|
||||||
manufacturer_part=response.get('manufacturerpart', None),
|
manufacturer_part=response.get('manufacturerpart', None),
|
||||||
)
|
)
|
||||||
response['success'] = _('Matched supplier part')
|
response['success'] = _('Matched supplier part')
|
||||||
response['supplierpart'] = supplier_part.format_matched_response()
|
response['supplierpart'] = supplier_part.format_matched_response(
|
||||||
|
user=request.user
|
||||||
|
)
|
||||||
except ValidationError as e:
|
except ValidationError as e:
|
||||||
response['error'] = str(e)
|
response['error'] = str(e)
|
||||||
|
|
||||||
|
|
@ -524,7 +528,7 @@ class BarcodePOReceive(BarcodeView):
|
||||||
filter(lambda plugin: plugin.name == 'InvenTreeBarcode', plugins)
|
filter(lambda plugin: plugin.name == 'InvenTreeBarcode', plugins)
|
||||||
)
|
)
|
||||||
|
|
||||||
if result := internal_barcode_plugin.scan(barcode):
|
if result := internal_barcode_plugin.scan(barcode, user=request.user, **kwargs):
|
||||||
if 'stockitem' in result:
|
if 'stockitem' in result:
|
||||||
response['error'] = _('Item has already been received')
|
response['error'] = _('Item has already been received')
|
||||||
self.log_scan(request, response, False)
|
self.log_scan(request, response, False)
|
||||||
|
|
|
||||||
|
|
@ -42,7 +42,7 @@ class BarcodeMixin:
|
||||||
"""Does this plugin have everything needed to process a barcode."""
|
"""Does this plugin have everything needed to process a barcode."""
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def scan(self, barcode_data):
|
def scan(self, barcode_data: str, user, **kwargs) -> dict | None:
|
||||||
"""Scan a barcode against this plugin.
|
"""Scan a barcode against this plugin.
|
||||||
|
|
||||||
This method is explicitly called from the /scan/ API endpoint,
|
This method is explicitly called from the /scan/ API endpoint,
|
||||||
|
|
@ -261,7 +261,7 @@ class SupplierBarcodeMixin(BarcodeMixin):
|
||||||
'extract_barcode_fields must be implemented by each plugin'
|
'extract_barcode_fields must be implemented by each plugin'
|
||||||
)
|
)
|
||||||
|
|
||||||
def scan(self, barcode_data: str) -> dict | None:
|
def scan(self, barcode_data: str, user, **kwargs) -> dict | None:
|
||||||
"""Perform a generic 'scan' operation on a supplier barcode.
|
"""Perform a generic 'scan' operation on a supplier barcode.
|
||||||
|
|
||||||
The supplier barcode may provide sufficient information to match against
|
The supplier barcode may provide sufficient information to match against
|
||||||
|
|
@ -297,7 +297,7 @@ class SupplierBarcodeMixin(BarcodeMixin):
|
||||||
for k, v in matches.items():
|
for k, v in matches.items():
|
||||||
if v and hasattr(v, 'pk'):
|
if v and hasattr(v, 'pk'):
|
||||||
has_match = True
|
has_match = True
|
||||||
data[k] = v.format_matched_response()
|
data[k] = v.format_matched_response(user=user)
|
||||||
|
|
||||||
if not has_match:
|
if not has_match:
|
||||||
return None
|
return None
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue