2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-12-19 10:43:28 +00:00
This commit is contained in:
Matthias Mair
2023-11-13 21:23:25 +01:00
753 changed files with 330259 additions and 121682 deletions

33
.deepsource.toml Normal file
View File

@@ -0,0 +1,33 @@
version = 1
exclude_patterns = [
"docs/docs/javascripts/**", # Docs: Helpers
"docs/ci/**", # Docs: CI
"InvenTree/InvenTree/static/**", # Backend: CUI static files
"ci/**", # Backend: CI
"InvenTree/**/migrations/*.py", # Backend: Migration files
"src/frontend/src/locales/**", # Frontend: Translations
]
test_patterns = ["**/test_*.py", "**/test.py", "**/tests.py"]
[[analyzers]]
name = "shell"
[[analyzers]]
name = "javascript"
[analyzers.meta]
plugins = ["react"]
[[analyzers]]
name = "python"
[analyzers.meta]
runtime_version = "3.x.x"
[[analyzers]]
name = "docker"
[[analyzers]]
name = "test-coverage"
enabled = false

View File

@@ -7,7 +7,7 @@ FROM mcr.microsoft.com/vscode/devcontainers/python:0-${VARIANT}
ARG WORKSPACE="/workspaces/InvenTree"
# [Choice] Node.js version: none, lts/*, 16, 14, 12, 10
ARG NODE_VERSION="none"
ARG NODE_VERSION="18"
RUN if [ "${NODE_VERSION}" != "none" ]; then su vscode -c "umask 0002 && . /usr/local/share/nvm/nvm.sh && nvm install ${NODE_VERSION} 2>&1"; fi
# [Optional] If your pip requirements rarely change, uncomment this section to add them to the image.
@@ -28,18 +28,21 @@ RUN apt-get update && export DEBIAN_FRONTEND=noninteractive && \
# PostgreSQL support
libpq-dev \
# MySQL / MariaDB support
default-libmysqlclient-dev mariadb-client && \
apt-get autoclean && apt-get autoremove
default-libmysqlclient-dev mariadb-client \
# LDAP support
libldap2-dev libsasl2-dev && \
apt-get autoclean && apt-get autoremove && \
rm -rf /var/lib/apt/lists/*
# [Optional] Uncomment this line to install global node packages.
# RUN su vscode -c "source /usr/local/share/nvm/nvm.sh && npm install -g <your-package-here>" 2>&1
# Update pip
RUN pip install --upgrade pip
RUN pip install --no-cache-dir --upgrade pip
# Install required base-level python packages
COPY ./docker/requirements.txt base_requirements.txt
RUN pip install --disable-pip-version-check -U -r base_requirements.txt
RUN pip install --disable-pip-version-check --no-cache-dir -U -r base_requirements.txt
# preserve command history between container starts
# Ref: https://code.visualstudio.com/remote/advancedcontainers/persist-bash-history

View File

@@ -68,7 +68,7 @@
"remoteEnv": {
// InvenTree config
"INVENTREE_DEBUG": "True",
"INVENTREE_DEBUG_LEVEL": "INFO",
"INVENTREE_LOG_LEVEL": "INFO",
"INVENTREE_DB_ENGINE": "sqlite3",
"INVENTREE_DB_NAME": "${containerWorkspaceFolder}/dev/database.sqlite3",
"INVENTREE_MEDIA_ROOT": "${containerWorkspaceFolder}/dev/media",

View File

@@ -14,8 +14,9 @@ python3 -m venv dev/venv
# setup InvenTree server
pip install invoke
invoke update
invoke update --no-frontend
invoke setup-dev
invoke frontend-install
# remove existing gitconfig created by "Avoiding Dubious Ownership" step
# so that it gets copied from host to the container to have your global

1
.github/FUNDING.yml vendored
View File

@@ -1,3 +1,4 @@
github: inventree
ko_fi: inventree
patreon: inventree
custom: [paypal.me/inventree]

View File

@@ -1,5 +1,5 @@
name: 'Migration test'
description: 'Run migration test sequenze'
description: 'Run migration test sequence'
author: 'InvenTree'
runs:

View File

@@ -35,12 +35,12 @@ runs:
using: 'composite'
steps:
- name: Checkout Code
uses: actions/checkout@93ea575cb5d8a053eaa0ac8fa3b40d7e05a33cc8 # pin@v3.1.0
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # pin@v4.1.1
# Python installs
- name: Set up Python ${{ env.python_version }}
if: ${{ inputs.python == 'true' }}
uses: actions/setup-python@13ae5bb136fac2878aff31522b9efb785519f984 # pin@v4.3.0
uses: actions/setup-python@65d7f2d534ac1bc67fcd62888c5f4f3d2cb2b236 # pin@v4.7.1
with:
python-version: ${{ env.python_version }}
cache: pip
@@ -58,7 +58,7 @@ runs:
# NPM installs
- name: Install node.js ${{ env.node_version }}
if: ${{ inputs.npm == 'true' }}
uses: actions/setup-node@969bd2663942d722d85b6a8626225850c2f7be4b # pin to v3.5.0
uses: actions/setup-node@1a4442cacd436585916779262731d5b162bc6ec7 # pin to v3.8.2
with:
node-version: ${{ env.node_version }}
cache: 'npm'

View File

@@ -7,7 +7,7 @@ name: Backport
on:
pull_request_target:
types: ["labeled", "closed"]
types: [ "labeled", "closed" ]
jobs:
backport:
@@ -22,7 +22,7 @@ jobs:
)
steps:
- name: Backport Action
uses: sqren/backport-github-action@v8.9.3
uses: sqren/backport-github-action@f54e19901f2a57f8b82360f2490d47ee82ec82c6 # pin@v9.2.2
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
auto_backport_label_prefix: backport-to-

View File

@@ -25,9 +25,9 @@ jobs:
steps:
- name: Checkout Code
uses: actions/checkout@93ea575cb5d8a053eaa0ac8fa3b40d7e05a33cc8 # pin@v3.1.0
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # pin@v4.1.1
- name: Set Up Python ${{ env.python_version }}
uses: actions/setup-python@13ae5bb136fac2878aff31522b9efb785519f984 # pin@v4.3.0
uses: actions/setup-python@65d7f2d534ac1bc67fcd62888c5f4f3d2cb2b236 # pin@v4.7.1
with:
python-version: ${{ env.python_version }}
cache: 'pip'

View File

@@ -20,7 +20,6 @@ on:
push:
branches:
- 'master'
# pull_request:
# branches:
# - 'master'
@@ -39,9 +38,9 @@ jobs:
python_version: 3.9
steps:
- name: Check out repo
uses: actions/checkout@93ea575cb5d8a053eaa0ac8fa3b40d7e05a33cc8 # pin@v3.1.0
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # pin@v4.1.1
- name: Set Up Python ${{ env.python_version }}
uses: actions/setup-python@13ae5bb136fac2878aff31522b9efb785519f984 # pin@v4.3.0
uses: actions/setup-python@65d7f2d534ac1bc67fcd62888c5f4f3d2cb2b236 # pin@v4.7.1
with:
python-version: ${{ env.python_version }}
- name: Version Check
@@ -60,7 +59,7 @@ jobs:
docker-compose run inventree-dev-server invoke update
docker-compose run inventree-dev-server invoke setup-dev
docker-compose up -d
docker-compose run inventree-dev-server pip install --upgrade setuptools
docker-compose run inventree-dev-server pip install setuptools==68.1.2
docker-compose run inventree-dev-server invoke wait
- name: Check Data Directory
# The following file structure should have been created by the docker image
@@ -78,26 +77,27 @@ jobs:
run: |
echo "GITHUB_TOKEN=${{ secrets.GITHUB_TOKEN }}" >> docker.dev.env
docker-compose run inventree-dev-server invoke test --disable-pty
docker-compose run inventree-dev-server invoke test --migrations --disable-pty
docker-compose down
- name: Set up QEMU
if: github.event_name != 'pull_request'
uses: docker/setup-qemu-action@e81a89b1732b9c48d79cd809d8d81d79c4647a18 # pin@v2.1.0
uses: docker/setup-qemu-action@68827325e0b33c7199eb31dd4e31fbe9023e06e3 # pin@v3.0.0
- name: Set up Docker Buildx
if: github.event_name != 'pull_request'
uses: docker/setup-buildx-action@95cb08cb2672c73d4ffd2f422e6d11953d2a9c70 # pin@v2.1.0
uses: docker/setup-buildx-action@f95db51fddba0c2d1ec667646a06c2ce06100226 # pin@v3.0.0
- name: Set up cosign
if: github.event_name != 'pull_request'
uses: sigstore/cosign-installer@7cc35d7fdbe70d4278a0c96779081e6fac665f88 # pin@v2.8.0
uses: sigstore/cosign-installer@11086d25041f77fe8fe7b9ea4e48e3b9192b8f19 # pin@v3.1.2
- name: Login to Dockerhub
if: github.event_name != 'pull_request'
uses: docker/login-action@f4ef78c080cd8ba55a85445d5b36e214a81df20a # pin@v2.1.0
uses: docker/login-action@343f7c4344506bcbf9b4de18042ae17996df046d # pin@v3.0.0
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Log into registry ghcr.io
if: github.event_name != 'pull_request'
uses: docker/login-action@f4ef78c080cd8ba55a85445d5b36e214a81df20a # pin@v2
uses: docker/login-action@343f7c4344506bcbf9b4de18042ae17996df046d # pin@v3.0.0
with:
registry: ghcr.io
username: ${{ github.actor }}
@@ -106,7 +106,7 @@ jobs:
- name: Extract Docker metadata
if: github.event_name != 'pull_request'
id: meta
uses: docker/metadata-action@12cce9efe0d49980455aaaca9b071c0befcdd702 # pin@v4.1.0
uses: docker/metadata-action@96383f45573cb7f253c731d3b3ab81c87ef81934 # pin@v5.0.0
with:
images: |
inventree/inventree
@@ -115,7 +115,7 @@ jobs:
- name: Build and Push
id: build-and-push
if: github.event_name != 'pull_request'
uses: docker/build-push-action@c56af957549030174b10d6867f20e78cfd7debc5 # pin@v3.2.0
uses: docker/build-push-action@0565240e2d4ab88bba5387d719585280857ece09 # pin@v5.0.0
with:
context: .
platforms: linux/amd64,linux/arm64
@@ -132,5 +132,4 @@ jobs:
if: ${{ false }} # github.event_name != 'pull_request'
env:
COSIGN_EXPERIMENTAL: "true"
run: cosign sign ${{ steps.meta.outputs.tags }}@${{
steps.build-and-push.outputs.digest }}
run: cosign sign ${{ steps.meta.outputs.tags }}@${{ steps.build-and-push.outputs.digest }}

View File

@@ -4,9 +4,9 @@ name: QC
on:
push:
branches-ignore: ['l10*']
branches-ignore: [ 'l10*' ]
pull_request:
branches-ignore: ['l10*']
branches-ignore: [ 'l10*' ]
env:
python_version: 3.9
@@ -28,17 +28,24 @@ jobs:
outputs:
server: ${{ steps.filter.outputs.server }}
migrations: ${{ steps.filter.outputs.migrations }}
frontend: ${{ steps.filter.outputs.frontend }}
steps:
- uses: actions/checkout@93ea575cb5d8a053eaa0ac8fa3b40d7e05a33cc8 # pin@v3.1.0
- uses: dorny/paths-filter@v2
id: filter
with:
filters: |
server:
- 'InvenTree/**'
- 'requirements.txt'
- 'requirements-dev.txt'
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # pin@v4.1.1
- uses: dorny/paths-filter@4512585405083f25c027a35db413c2b3b9006d50 # pin@v2.11.1
id: filter
with:
filters: |
server:
- 'InvenTree/**'
- 'requirements.txt'
- 'requirements-dev.txt'
migrations:
- '**/migrations/**'
- '.github/workflows**'
frontend:
- 'src/frontend/**'
pep_style:
name: Style [Python]
@@ -48,7 +55,7 @@ jobs:
if: needs.paths-filter.outputs.server == 'true'
steps:
- uses: actions/checkout@93ea575cb5d8a053eaa0ac8fa3b40d7e05a33cc8 # pin@v3.1.0
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # pin@v4.1.1
- name: Environment Setup
uses: ./.github/actions/setup
with:
@@ -57,13 +64,13 @@ jobs:
run: flake8 InvenTree --extend-ignore=D
javascript:
name: Style [JS]
name: Style - Classic UI [JS]
runs-on: ubuntu-20.04
needs: pep_style
needs: [ 'pep_style', 'pre-commit' ]
steps:
- uses: actions/checkout@93ea575cb5d8a053eaa0ac8fa3b40d7e05a33cc8 # pin@v3.1.0
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # pin@v4.1.1
- name: Environment Setup
uses: ./.github/actions/setup
with:
@@ -81,13 +88,13 @@ jobs:
pre-commit:
name: Style [pre-commit]
runs-on: ubuntu-20.04
needs: pep_style
needs: paths-filter
if: needs.paths-filter.outputs.server == 'true' || needs.paths-filter.outputs.frontend == 'true'
steps:
- uses: actions/checkout@93ea575cb5d8a053eaa0ac8fa3b40d7e05a33cc8 # pin@v3.1.0
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # pin@v4.1.1
- name: Set up Python ${{ env.python_version }}
uses: actions/setup-python@13ae5bb136fac2878aff31522b9efb785519f984 # pin@v4.3.0
uses: actions/setup-python@65d7f2d534ac1bc67fcd62888c5f4f3d2cb2b236 # pin@v4.7.1
with:
python-version: ${{ env.python_version }}
cache: 'pip'
@@ -106,9 +113,9 @@ jobs:
steps:
- name: Checkout Code
uses: actions/checkout@93ea575cb5d8a053eaa0ac8fa3b40d7e05a33cc8 # pin@v3.1.0
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # pin@v4.1.1
- name: Set up Python ${{ env.python_version }}
uses: actions/setup-python@13ae5bb136fac2878aff31522b9efb785519f984 # pin@v4.3.0
uses: actions/setup-python@65d7f2d534ac1bc67fcd62888c5f4f3d2cb2b236 # pin@v4.7.1
with:
python-version: ${{ env.python_version }}
- name: Check Config
@@ -138,16 +145,16 @@ jobs:
INVENTREE_PYTHON_TEST_PASSWORD: testpassword
steps:
- uses: actions/checkout@93ea575cb5d8a053eaa0ac8fa3b40d7e05a33cc8 # pin@v3.1.0
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # pin@v4.1.1
- name: Environment Setup
uses: ./.github/actions/setup
with:
apt-dependency: gettext poppler-utils
dev-install: true
update: true
npm: true
- name: Download Python Code For `${{ env.wrapper_name }}`
run: git clone --depth 1 https://github.com/inventree/${{ env.wrapper_name }}
./${{ env.wrapper_name }}
run: git clone --depth 1 https://github.com/inventree/${{ env.wrapper_name }} ./${{ env.wrapper_name }}
- name: Start InvenTree Server
run: |
invoke delete-data -f
@@ -168,7 +175,7 @@ jobs:
continue-on-error: true
steps:
- uses: actions/checkout@93ea575cb5d8a053eaa0ac8fa3b40d7e05a33cc8 # pin@v3.1.0
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # pin@v4.1.1
- name: Environment Setup
uses: ./.github/actions/setup
with:
@@ -180,7 +187,7 @@ jobs:
name: Tests - DB [SQLite] + Coverage
runs-on: ubuntu-20.04
needs: [ 'javascript', 'pre-commit' ]
needs: [ 'pep_style', 'pre-commit' ]
continue-on-error: true # continue if a step fails so that coverage gets pushed
env:
@@ -190,7 +197,7 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
steps:
- uses: actions/checkout@93ea575cb5d8a053eaa0ac8fa3b40d7e05a33cc8 # pin@v3.1.0
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # pin@v4.1.1
- name: Environment Setup
uses: ./.github/actions/setup
with:
@@ -204,16 +211,16 @@ jobs:
- name: Check Migration Files
run: python3 ci/check_migration_files.py
- name: Coverage Tests
run: invoke coverage
run: invoke test --coverage
- name: Upload Coverage Report
uses: coverallsapp/github-action@v2
uses: coverallsapp/github-action@3dfc5567390f6fa9267c0ee9c251e4c8c3f18949 # pin@v2.2.3
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
postgres:
name: Tests - DB [PostgreSQL]
runs-on: ubuntu-20.04
needs: [ 'javascript', 'pre-commit' ]
needs: [ 'pep_style', 'pre-commit' ]
env:
INVENTREE_DB_ENGINE: django.db.backends.postgresql
@@ -240,7 +247,7 @@ jobs:
- 6379:6379
steps:
- uses: actions/checkout@93ea575cb5d8a053eaa0ac8fa3b40d7e05a33cc8 # pin@v3.1.0
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # pin@v4.1.1
- name: Environment Setup
uses: ./.github/actions/setup
with:
@@ -257,8 +264,7 @@ jobs:
name: Tests - DB [MySQL]
runs-on: ubuntu-20.04
needs: [ 'javascript', 'pre-commit' ]
if: github.event_name == 'push'
needs: [ 'pep_style', 'pre-commit' ]
env:
# Database backend configuration
@@ -279,13 +285,12 @@ jobs:
MYSQL_USER: inventree
MYSQL_PASSWORD: password
MYSQL_ROOT_PASSWORD: password
options: --health-cmd="mysqladmin ping" --health-interval=5s --health-timeout=2s
--health-retries=3
options: --health-cmd="mysqladmin ping" --health-interval=5s --health-timeout=2s --health-retries=3
ports:
- 3306:3306
steps:
- uses: actions/checkout@93ea575cb5d8a053eaa0ac8fa3b40d7e05a33cc8 # pin@v3.1.0
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # pin@v4.1.1
- name: Environment Setup
uses: ./.github/actions/setup
with:
@@ -297,3 +302,148 @@ jobs:
run: invoke test
- name: Data Export Test
uses: ./.github/actions/migration
migration-tests:
name: Tests - Migrations [PostgreSQL]
runs-on: ubuntu-latest
needs: paths-filter
if: github.ref == 'refs/heads/master' && needs.paths-filter.outputs.migrations == 'true'
env:
INVENTREE_DB_ENGINE: django.db.backends.postgresql
INVENTREE_DB_NAME: inventree
INVENTREE_DB_USER: inventree
INVENTREE_DB_PASSWORD: password
INVENTREE_DB_HOST: '127.0.0.1'
INVENTREE_DB_PORT: 5432
INVENTREE_DEBUG: info
INVENTREE_PLUGINS_ENABLED: false
services:
postgres:
image: postgres:14
env:
POSTGRES_USER: inventree
POSTGRES_PASSWORD: password
ports:
- 5432:5432
steps:
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # pin@v4.1.1
- name: Environment Setup
uses: ./.github/actions/setup
with:
apt-dependency: gettext poppler-utils libpq-dev
pip-dependency: psycopg2
dev-install: true
update: true
- name: Run Tests
run: invoke test --migrations --report
migrations-checks:
name: Tests - Full Migration [SQLite]
runs-on: ubuntu-latest
needs: paths-filter
if: github.ref == 'refs/heads/master' && needs.paths-filter.outputs.migrations == 'true'
env:
INVENTREE_DB_ENGINE: sqlite3
INVENTREE_DB_NAME: /home/runner/work/InvenTree/db.sqlite3
INVENTREE_DEBUG: info
INVENTREE_PLUGINS_ENABLED: false
steps:
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # pin@v4.1.1
name: Checkout Code
- name: Environment Setup
uses: ./.github/actions/setup
with:
install: true
- name: Fetch Database
run: git clone --depth 1 https://github.com/inventree/test-db ./test-db
- name: Latest Database
run: |
cp test-db/latest.sqlite3 /home/runner/work/InvenTree/db.sqlite3
chmod +rw /home/runner/work/InvenTree/db.sqlite3
invoke migrate
- name: 0.10.0 Database
run: |
rm /home/runner/work/InvenTree/db.sqlite3
cp test-db/stable_0.10.0.sqlite3 /home/runner/work/InvenTree/db.sqlite3
chmod +rw /home/runner/work/InvenTree/db.sqlite3
invoke migrate
- name: 0.11.0 Database
run: |
rm /home/runner/work/InvenTree/db.sqlite3
cp test-db/stable_0.11.0.sqlite3 /home/runner/work/InvenTree/db.sqlite3
chmod +rw /home/runner/work/InvenTree/db.sqlite3
invoke migrate
- name: 0.12.0 Database
run: |
rm /home/runner/work/InvenTree/db.sqlite3
cp test-db/stable_0.12.0.sqlite3 /home/runner/work/InvenTree/db.sqlite3
chmod +rw /home/runner/work/InvenTree/db.sqlite3
invoke migrate
platform_ui:
name: Tests - Platform UI
runs-on: ubuntu-20.04
timeout-minutes: 60
needs: [ 'pre-commit', 'paths-filter' ]
if: needs.paths-filter.outputs.frontend == 'true'
env:
INVENTREE_DB_ENGINE: sqlite3
INVENTREE_DB_NAME: /home/runner/work/InvenTree/db.sqlite3
INVENTREE_DEBUG: True
INVENTREE_PLUGINS_ENABLED: false
steps:
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # pin@v4.1.1
- name: Environment Setup
uses: ./.github/actions/setup
with:
npm: true
install: true
update: true
- name: Set up test data
run: invoke setup-test -i
- name: Install dependencies
run: inv frontend-compile
- name: Install Playwright Browsers
run: cd src/frontend && npx playwright install --with-deps
- name: Run Playwright tests
run: cd src/frontend && npx playwright test
- uses: actions/upload-artifact@a8a3f3ad30e3422c9c7b888a15615d19a852ae32 # pin@v3.1.3
if: always()
with:
name: playwright-report
path: src/frontend/playwright-report/
retention-days: 30
platform_ui_build:
name: Build - UI Platform
runs-on: ubuntu-20.04
timeout-minutes: 60
steps:
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # pin@v4.1.1
- name: Environment Setup
uses: ./.github/actions/setup
with:
npm: true
- name: Install dependencies
run: cd src/frontend && yarn install
- name: Build frontend
run: cd src/frontend && npm run build
- name: Zip frontend
run: |
cd InvenTree/web/static
zip -r frontend-build.zip web/
- uses: actions/upload-artifact@a8a3f3ad30e3422c9c7b888a15615d19a852ae32 # pin@v3.1.3
with:
name: frontend-build
path: InvenTree/web/static/web

View File

@@ -13,15 +13,39 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
steps:
- name: Checkout Code
uses: actions/checkout@93ea575cb5d8a053eaa0ac8fa3b40d7e05a33cc8 # pin@v3.1.0
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # pin@v4.1.1
- name: Version Check
run: |
pip install requests
python3 ci/version_check.py
- name: Push to Stable Branch
uses: ad-m/github-push-action@4dcce6dea3e3c8187237fc86b7dfdc93e5aaae58 # pin@master
uses: ad-m/github-push-action@d91a481090679876dfc4178fef17f286781251df # pin@v0.8.0
if: env.stable_release == 'true'
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
branch: stable
force: true
publish-build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # pin@v4.1.1
- name: Environment Setup
uses: ./.github/actions/setup
with:
npm: true
- name: Install dependencies
run: cd src/frontend && yarn install
- name: Build frontend
run: cd src/frontend && npm run build
- name: Zip frontend
run: |
cd InvenTree/web/static/web
zip -r ../frontend-build.zip *
- uses: svenstaro/upload-release-action@1beeb572c19a9242f4361f4cee78f8e0d9aec5df # pin@2.7.0
with:
repo_token: ${{ secrets.GITHUB_TOKEN }}
file: InvenTree/web/static/frontend-build.zip
asset_name: frontend-build.zip
tag: ${{ github.ref }}
overwrite: true

33
.github/workflows/sponsors.yml vendored Normal file
View File

@@ -0,0 +1,33 @@
name: Generate Sponsors README
on:
workflow_dispatch:
schedule:
- cron: 30 15 * * 0-6
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout Code
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11
- name: Generate Sponsors
uses: JamesIves/github-sponsors-readme-action@a2d75a8d58b117b19777a910e284ccb082aaf117
with:
token: ${{ secrets.INVENTREE_SPONSORS_TOKEN }}
file: 'README.md'
organization: true
- name: Commit files
run: |
git config --local user.email "41898282+github-actions[bot]@users.noreply.github.com"
git config --local user.name "github-actions[bot]"
git checkout -b sponsors
git add README.md
git commit -m "updated sponsors"
- name: Push Changes
uses: ad-m/github-push-action@d91a481090679876dfc4178fef17f286781251df
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
branch: sponsors
force: true

View File

@@ -14,11 +14,10 @@ jobs:
pull-requests: write
steps:
- uses: actions/stale@5ebf00ea0e4c1561e9b43a292ed34424fb1d4578 # pin@v6.0.1
- uses: actions/stale@1160a2240286f5da8ec72b1c0816ce2481aabf84 # pin@v8.0.0
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
stale-issue-message: 'This issue seems stale. Please react to show this is still
important.'
stale-issue-message: 'This issue seems stale. Please react to show this is still important.'
stale-pr-message: 'This PR seems stale. Please react to show this is still important.'
stale-issue-label: 'inactive'
stale-pr-label: 'inactive'

View File

@@ -21,11 +21,15 @@ jobs:
steps:
- name: Checkout Code
uses: actions/checkout@93ea575cb5d8a053eaa0ac8fa3b40d7e05a33cc8 # pin@v3.1.0
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # pin@v4.1.1
- name: Set up Python 3.9
uses: actions/setup-python@13ae5bb136fac2878aff31522b9efb785519f984 # pin@v4.3.0
uses: actions/setup-python@65d7f2d534ac1bc67fcd62888c5f4f3d2cb2b236 # pin@v4.7.1
with:
python-version: 3.9
- name: Set up Node 16
uses: actions/setup-node@1a4442cacd436585916779262731d5b162bc6ec7 # pin to v3.8.2
with:
node-version: 16
- name: Install Dependencies
run: |
sudo apt-get update
@@ -33,8 +37,7 @@ jobs:
pip3 install invoke
invoke install
- name: Make Translations
run: |
invoke translate
run: invoke translate
- name: Commit files
run: |
git config --local user.email "41898282+github-actions[bot]@users.noreply.github.com"
@@ -43,7 +46,7 @@ jobs:
git add "*.po"
git commit -m "updated translation base"
- name: Push changes
uses: ad-m/github-push-action@4dcce6dea3e3c8187237fc86b7dfdc93e5aaae58 # pin@master
uses: ad-m/github-push-action@d91a481090679876dfc4178fef17f286781251df # pin@v0.8.0
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
branch: l10

View File

@@ -9,14 +9,13 @@ jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@93ea575cb5d8a053eaa0ac8fa3b40d7e05a33cc8 # pin@v3.1.0
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # pin@v4.1.1
- name: Setup
run: pip install -r requirements-dev.txt
- name: Update requirements.txt
run: pip-compile --output-file=requirements.txt requirements.in -U
- name: Update requirements-dev.txt
run: pip-compile --generate-hashes --output-file=requirements-dev.txt
requirements-dev.in -U
run: pip-compile --generate-hashes --output-file=requirements-dev.txt requirements-dev.in -U
- uses: stefanzweifel/git-auto-commit-action@fd157da78fa13d9383e5580d1fd1184d89554b51 # pin@v4.15.1
with:
commit_message: "[Bot] Updated dependency"

11
.gitignore vendored
View File

@@ -40,9 +40,11 @@ inventree-demo-dataset/
inventree-data/
dummy_image.*
_tmp.csv
inventree/label.pdf
inventree/label.png
inventree/my_special*
InvenTree/label.pdf
InvenTree/label.png
label.pdf
label.png
InvenTree/my_special*
_tests*.txt
# Local static and media file storage (only when running in development mode)
@@ -101,3 +103,6 @@ InvenTree/plugins/
# Compiled translation files
*.mo
# web frontend (static files)
InvenTree/web/static

View File

@@ -19,8 +19,9 @@ before:
- contrib/packager.io/before.sh
dependencies:
- curl
- python3
- python3-venv
- python3.9
- python3.9-venv
- python3.9-dev
- python3-pip
- python3-cffi
- python3-brotli
@@ -35,3 +36,4 @@ dependencies:
targets:
ubuntu-20.04: true
debian-11: true
debian-12: true

View File

@@ -3,18 +3,19 @@
exclude: |
(?x)^(
InvenTree/InvenTree/static/.*|
InvenTree/locale/.*
InvenTree/locale/.*|
src/frontend/src/locales/.*
)$
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.4.0
rev: v4.5.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
- id: check-yaml
- id: mixed-line-ending
- repo: https://github.com/pycqa/flake8
rev: '6.0.0'
rev: '6.1.0'
hooks:
- id: flake8
additional_dependencies: [
@@ -23,14 +24,15 @@ repos:
'flake8-docstrings',
'flake8-string-format',
'flake8-tidy-imports',
'pep8-naming'
'pep8-naming',
'flake8-logging'
]
- repo: https://github.com/pycqa/isort
rev: '5.12.0'
hooks:
- id: isort
- repo: https://github.com/jazzband/pip-tools
rev: 6.13.0
rev: 7.3.0
hooks:
- id: pip-compile
name: pip-compile requirements-dev.in
@@ -41,16 +43,37 @@ repos:
args: [requirements.in, -o, requirements.txt]
files: ^requirements\.(in|txt)$
- repo: https://github.com/Riverside-Healthcare/djLint
rev: v1.29.0
rev: v1.34.0
hooks:
- id: djlint-django
- repo: https://github.com/codespell-project/codespell
rev: v2.2.4
rev: v2.2.6
hooks:
- id: codespell
exclude: >
(?x)^(
docs/docs/stylesheets/.*|
docs/docs/javascripts/.*|
docs/docs/webfonts/.*
docs/docs/webfonts/.* |
src/frontend/src/locales/.* |
)$
- repo: https://github.com/pre-commit/mirrors-prettier
rev: "v3.0.3"
hooks:
- id: prettier
files: ^src/frontend/.*\.(js|jsx|ts|tsx)$
additional_dependencies:
- "prettier@^2.4.1"
- "@trivago/prettier-plugin-sort-imports"
- repo: https://github.com/pre-commit/mirrors-eslint
rev: "v8.51.0"
hooks:
- id: eslint
additional_dependencies:
- eslint@^8.41.0
- eslint-config-google@^0.14.0
- eslint-plugin-react@6.10.3
- babel-eslint@6.1.2
- "@typescript-eslint/eslint-plugin@latest"
- "@typescript-eslint/parser"
files: ^src/frontend/.*\.(js|jsx|ts|tsx)$

6
.vscode/tasks.json vendored
View File

@@ -6,6 +6,12 @@
"version": "2.0.0",
"tasks": [
{
"label": "worker",
"type": "shell",
"command": "inv worker",
"problemMatcher": [],
},
{
"label": "clean-settings",
"type": "shell",

View File

@@ -19,7 +19,7 @@ pip install invoke && invoke setup-dev --tests
```bash
git clone https://github.com/inventree/InvenTree.git && cd InvenTree
docker compose run inventree-dev-server invoke install
docker compose run inventree-dev-server invoke setup-test
docker compose run inventree-dev-server invoke setup-test --dev
docker compose up -d
```
@@ -33,7 +33,7 @@ Run the following command to set up all toolsets for development.
invoke setup-dev
```
*We recommend you run this command before starting to contribute. This will install and set up `pre-commit` to run some checks before each commit and help reduce the style errors.*
*We recommend you run this command before starting to contribute. This will install and set up `pre-commit` to run some checks before each commit and help reduce errors.*
## Branches and Versioning
@@ -82,10 +82,11 @@ The HEAD of the "stable" branch represents the latest stable release code.
## Environment
### Target version
We are currently targeting:
| Name | Minimum version |
|---|---|
| Python | 3.9 |
| Django | 3.2 |
| Name | Minimum version | Note |
|---|---| --- |
| Python | 3.9 | |
| Django | 3.2 | |
| Node | 18 | Only needed for frontend development |
### Auto creating updates
The following tools can be used to auto-upgrade syntax that was depreciated in new versions:
@@ -135,10 +136,27 @@ To run only partial tests, for example for a module use:
invoke test --runtest order
```
To see all the available options:
```
invoke test --help
```
## Code Style
Submitted Python code is automatically checked against PEP style guidelines. Locally you can run `invoke style` to ensure the style checks will pass, before submitting the PR.
Please write docstrings for each function and class - we follow the [google doc-style](https://google.github.io/styleguide/pyguide.html#38-comments-and-docstrings) for python. Docstrings for general javascript code is encouraged! Docstyles are checked by `invoke style`.
Code style is automatically checked as part of the project's CI pipeline on GitHub. This means that any pull requests which do not conform to the style guidelines will fail CI checks.
### Backend Code
Backend code (Python) is checked against the [PEP style guidelines](https://peps.python.org/pep-0008/). Please write docstrings for each function and class - we follow the [google doc-style](https://google.github.io/styleguide/pyguide.html#38-comments-and-docstrings) for python.
### Frontend Code
Frontend code (Javascript) is checked using [eslint](https://eslint.org/). While docstrings are not enforced for front-end code, good code documentation is encouraged!
### Running Checks Locally
If you have followed the setup devtools procedure, then code style checking is performend automatically whenever you commit changes to the code.
### Django templates
@@ -193,8 +211,11 @@ HTML and javascript files are passed through the django templating engine. Trans
```
## Github use
### Tags
The tags describe issues and PRs in multiple areas:
| Area | Name | Description |
| --- | --- | --- |
| Triage Labels | | |

View File

@@ -9,7 +9,7 @@
# - Runs InvenTree web server under django development server
# - Monitors source files for any changes, and live-reloads server
FROM python:3.9-slim as inventree_base
FROM python:3.10-alpine3.18 as inventree_base
# Build arguments for this image
ARG commit_hash=""
@@ -17,6 +17,8 @@ ARG commit_date=""
ARG commit_tag=""
ENV PYTHONUNBUFFERED 1
ENV PIP_DISABLE_PIP_VERSION_CHECK 1
ENV INVOKE_RUN_SHELL="/bin/ash"
ENV INVENTREE_LOG_LEVEL="WARNING"
ENV INVENTREE_DOCKER="true"
@@ -51,44 +53,64 @@ LABEL org.label-schema.schema-version="1.0" \
org.label-schema.vcs-url="https://github.com/inventree/InvenTree.git" \
org.label-schema.vcs-ref=${commit_tag}
# RUN apt-get upgrade && apt-get update
RUN apt-get update
# Install required system packages
RUN apt-get install -y --no-install-recommends \
git gcc g++ gettext gnupg libffi-dev libssl-dev \
# Weasyprint requirements : https://doc.courtbouillon.org/weasyprint/stable/first_steps.html#debian-11
poppler-utils libpango-1.0-0 libpangoft2-1.0-0 \
RUN apk add --no-cache \
git gettext py-cryptography \
# Image format support
libjpeg-dev webp libwebp-dev \
libjpeg libwebp zlib \
# Weasyprint requirements : https://doc.courtbouillon.org/weasyprint/stable/first_steps.html#alpine-3-12
py3-pip py3-pillow py3-cffi py3-brotli pango poppler-utils openldap \
# SQLite support
sqlite3 \
sqlite \
# PostgreSQL support
libpq-dev postgresql-client \
postgresql-libs postgresql-client \
# MySQL / MariaDB support
default-libmysqlclient-dev mariadb-client && \
apt-get autoclean && apt-get autoremove
mariadb-connector-c-dev mariadb-client && \
# fonts
apk --update --upgrade --no-cache add fontconfig ttf-freefont font-noto terminus-font && fc-cache -f
# Update pip
RUN pip install --upgrade pip
EXPOSE 8000
# For ARMv7 architecture, add the pinwheels repo (for cryptography library)
RUN mkdir -p ${INVENTREE_HOME}
WORKDIR ${INVENTREE_HOME}
COPY ./docker/requirements.txt base_requirements.txt
COPY ./requirements.txt ./
# For ARMv7 architecture, add the piwheels repo (for cryptography library)
# Otherwise, we have to build from source, which is difficult
# Ref: https://github.com/inventree/InvenTree/pull/4598
RUN \
if [ `dpkg --print-architecture` = "armhf" ]; then \
printf "[global]\nextra-index-url=https://www.piwheels.org/simple\n" > /etc/pip.conf ; \
fi
RUN if [ `apk --print-arch` = "armv7" ]; then \
printf "[global]\nextra-index-url=https://www.piwheels.org/simple\n" > /etc/pip.conf ; \
fi
# Install required base-level python packages
COPY ./docker/requirements.txt base_requirements.txt
RUN pip install --disable-pip-version-check -U -r base_requirements.txt
RUN apk add --no-cache --virtual .build-deps \
gcc g++ musl-dev openssl-dev libffi-dev cargo python3-dev openldap-dev \
# Image format dev libs
jpeg-dev openjpeg-dev libwebp-dev zlib-dev \
# DB specific dev libs
postgresql-dev sqlite-dev mariadb-dev && \
pip install -r base_requirements.txt -r requirements.txt --no-cache-dir && \
apk --purge del .build-deps
COPY tasks.py docker/gunicorn.conf.py docker/init.sh ./
RUN chmod +x init.sh
ENTRYPOINT ["/bin/sh", "./init.sh"]
# Frontend builder image:
FROM inventree_base as frontend
RUN apk add --no-cache --update nodejs npm && npm install -g yarn
RUN yarn config set network-timeout 600000 -g
COPY InvenTree ${INVENTREE_HOME}/InvenTree
COPY src ${INVENTREE_HOME}/src
COPY tasks.py ${INVENTREE_HOME}/tasks.py
RUN cd ${INVENTREE_HOME}/InvenTree && inv frontend-compile
# InvenTree production image:
# - Copies required files from local directory
# - Installs required python packages from requirements.txt
# - Starts a gunicorn webserver
FROM inventree_base as production
ENV INVENTREE_DEBUG=False
@@ -98,35 +120,22 @@ ENV INVENTREE_COMMIT_HASH="${commit_hash}"
ENV INVENTREE_COMMIT_DATE="${commit_date}"
# Copy source code
COPY InvenTree ${INVENTREE_HOME}/InvenTree
# Copy other key files
COPY requirements.txt ${INVENTREE_HOME}/requirements.txt
COPY tasks.py ${INVENTREE_HOME}/tasks.py
COPY docker/gunicorn.conf.py ${INVENTREE_HOME}/gunicorn.conf.py
COPY docker/init.sh ${INVENTREE_MNG_DIR}/init.sh
# Need to be running from within this directory
WORKDIR ${INVENTREE_MNG_DIR}
# Drop to the inventree user for the production image
#RUN adduser inventree
#RUN chown -R inventree:inventree ${INVENTREE_HOME}
#USER inventree
# Install InvenTree packages
RUN pip3 install --user --disable-pip-version-check -r ${INVENTREE_HOME}/requirements.txt
# Server init entrypoint
ENTRYPOINT ["/bin/bash", "./init.sh"]
COPY InvenTree ./InvenTree
COPY --from=frontend ${INVENTREE_HOME}/InvenTree/web/static/web ./InvenTree/web/static/web
# Launch the production server
# TODO: Work out why environment variables cannot be interpolated in this command
# TODO: e.g. -b ${INVENTREE_WEB_ADDR}:${INVENTREE_WEB_PORT} fails here
CMD gunicorn -c ./gunicorn.conf.py InvenTree.wsgi -b 0.0.0.0:8000 --chdir ./InvenTree
FROM inventree_base as dev
# Install nodejs / npm / yarn
RUN apk add --no-cache --update nodejs npm && npm install -g yarn
RUN yarn config set network-timeout 600000 -g
# The development image requires the source code to be mounted to /home/inventree/
# So from here, we don't actually "do" anything, apart from some file management
@@ -139,7 +148,7 @@ ENV INVENTREE_PY_ENV="${INVENTREE_DATA_DIR}/env"
WORKDIR ${INVENTREE_HOME}
# Entrypoint ensures that we are running in the python virtual environment
ENTRYPOINT ["/bin/bash", "./docker/init.sh"]
ENTRYPOINT ["/bin/ash", "./docker/init.sh"]
# Launch the development server
CMD ["invoke", "server", "-a", "${INVENTREE_WEB_ADDR}:${INVENTREE_WEB_PORT}"]

View File

@@ -1,5 +1,11 @@
"""Admin classes"""
from django.contrib import admin
from django.http.request import HttpRequest
from djmoney.contrib.exchange.admin import RateAdmin
from djmoney.contrib.exchange.models import Rate
from import_export.exceptions import ImportExportError
from import_export.resources import ModelResource
@@ -10,8 +16,38 @@ class InvenTreeResource(ModelResource):
Ref: https://owasp.org/www-community/attacks/CSV_Injection
"""
MAX_IMPORT_ROWS = 1000
MAX_IMPORT_COLS = 100
def import_data_inner(
self,
dataset,
dry_run,
raise_errors,
using_transactions,
collect_failed_rows,
rollback_on_validation_errors=None,
**kwargs
):
"""Override the default import_data_inner function to provide better error handling"""
if len(dataset) > self.MAX_IMPORT_ROWS:
raise ImportExportError(f"Dataset contains too many rows (max {self.MAX_IMPORT_ROWS})")
if len(dataset.headers) > self.MAX_IMPORT_COLS:
raise ImportExportError(f"Dataset contains too many columns (max {self.MAX_IMPORT_COLS})")
return super().import_data_inner(
dataset,
dry_run,
raise_errors,
using_transactions,
collect_failed_rows,
rollback_on_validation_errors=rollback_on_validation_errors,
**kwargs
)
def export_resource(self, obj):
"""Custom function to override default row export behaviour.
"""Custom function to override default row export behavior.
Specifically, strip illegal leading characters to prevent formula injection
"""
@@ -31,3 +67,26 @@ class InvenTreeResource(ModelResource):
row[idx] = val
return row
def get_fields(self, **kwargs):
"""Return fields, with some common exclusions"""
fields = super().get_fields(**kwargs)
fields_to_exclude = [
'metadata',
'lft', 'rght', 'tree_id', 'level',
]
return [f for f in fields if f.column_name not in fields_to_exclude]
class CustomRateAdmin(RateAdmin):
"""Admin interface for the Rate class"""
def has_add_permission(self, request: HttpRequest) -> bool:
"""Disable the 'add' permission for Rate objects"""
return False
admin.site.unregister(Rate)
admin.site.register(Rate, CustomRateAdmin)

View File

@@ -6,25 +6,87 @@ from django.http import JsonResponse
from django.utils.translation import gettext_lazy as _
from django_q.models import OrmQ
from rest_framework import permissions
from drf_spectacular.utils import OpenApiResponse, extend_schema
from rest_framework import permissions, serializers
from rest_framework.response import Response
from rest_framework.serializers import ValidationError
from rest_framework.views import APIView
import InvenTree.version
import users.models
from InvenTree.filters import SEARCH_ORDER_FILTER
from InvenTree.mixins import ListCreateAPI
from InvenTree.permissions import RolePermission
from part.templatetags.inventree_extras import plugins_info
from plugin.serializers import MetadataSerializer
from users.models import ApiToken
from .mixins import RetrieveUpdateAPI
from .status import is_worker_running
from .version import (inventreeApiVersion, inventreeInstanceName,
inventreeVersion)
from .email import is_email_configured
from .mixins import ListAPI, RetrieveUpdateAPI
from .status import check_system_health, is_worker_running
from .version import inventreeApiText
from .views import AjaxView
class VersionView(APIView):
"""Simple JSON endpoint for InvenTree version information."""
permission_classes = [
permissions.IsAdminUser,
]
def get(self, request, *args, **kwargs):
"""Return information about the InvenTree server."""
return JsonResponse({
'dev': InvenTree.version.isInvenTreeDevelopmentVersion(),
'up_to_date': InvenTree.version.isInvenTreeUpToDate(),
'version': {
'server': InvenTree.version.inventreeVersion(),
'api': InvenTree.version.inventreeApiVersion(),
'commit_hash': InvenTree.version.inventreeCommitHash(),
'commit_date': InvenTree.version.inventreeCommitDate(),
'commit_branch': InvenTree.version.inventreeBranch(),
'python': InvenTree.version.inventreePythonVersion(),
'django': InvenTree.version.inventreeDjangoVersion()
},
'links': {
'doc': InvenTree.version.inventreeDocUrl(),
'code': InvenTree.version.inventreeGithubUrl(),
'credit': InvenTree.version.inventreeCreditsUrl(),
'app': InvenTree.version.inventreeAppUrl(),
'bug': f'{InvenTree.version.inventreeGithubUrl()}/issues'
}
})
class VersionSerializer(serializers.Serializer):
"""Serializer for a single version."""
version = serializers.CharField()
date = serializers.CharField()
gh = serializers.CharField()
text = serializers.CharField()
latest = serializers.BooleanField()
class Meta:
"""Meta class for VersionSerializer."""
fields = ['version', 'date', 'gh', 'text', 'latest']
class VersionApiSerializer(serializers.Serializer):
"""Serializer for the version api endpoint."""
VersionSerializer(many=True)
class VersionTextView(ListAPI):
"""Simple JSON endpoint for InvenTree version text."""
permission_classes = [permissions.IsAdminUser]
@extend_schema(responses={200: OpenApiResponse(response=VersionApiSerializer)})
def list(self, request, *args, **kwargs):
"""Return information about the InvenTree server."""
return JsonResponse(inventreeApiText())
class InfoView(AjaxView):
"""Simple JSON endpoint for InvenTree information.
@@ -35,38 +97,95 @@ class InfoView(AjaxView):
def worker_pending_tasks(self):
"""Return the current number of outstanding background tasks"""
return OrmQ.objects.count()
def get(self, request, *args, **kwargs):
"""Serve current server information."""
is_staff = request.user.is_staff
if not is_staff and request.user.is_anonymous:
# Might be Token auth - check if so
is_staff = self.check_auth_header(request)
data = {
'server': 'InvenTree',
'version': inventreeVersion(),
'instance': inventreeInstanceName(),
'apiVersion': inventreeApiVersion(),
'version': InvenTree.version.inventreeVersion(),
'instance': InvenTree.version.inventreeInstanceName(),
'apiVersion': InvenTree.version.inventreeApiVersion(),
'worker_running': is_worker_running(),
'worker_pending_tasks': self.worker_pending_tasks(),
'plugins_enabled': settings.PLUGINS_ENABLED,
'active_plugins': plugins_info(),
'email_configured': is_email_configured(),
'debug_mode': settings.DEBUG,
'docker_mode': settings.DOCKER,
'system_health': check_system_health() if is_staff else None,
'database': InvenTree.version.inventreeDatabase()if is_staff else None,
'platform': InvenTree.version.inventreePlatform() if is_staff else None,
'installer': InvenTree.version.inventreeInstaller() if is_staff else None,
'target': InvenTree.version.inventreeTarget()if is_staff else None,
}
return JsonResponse(data)
def check_auth_header(self, request):
"""Check if user is authenticated via a token in the header."""
# TODO @matmair: remove after refacgtor of Token check is done
headers = request.headers.get('Authorization', request.headers.get('authorization'))
if not headers:
return False
auth = headers.strip()
if not (auth.lower().startswith('token') and len(auth.split()) == 2):
return False
token_key = auth.split()[1]
try:
token = ApiToken.objects.get(key=token_key)
if token.active and token.user and token.user.is_staff:
return True
except ApiToken.DoesNotExist:
pass
return False
class NotFoundView(AjaxView):
"""Simple JSON view when accessing an invalid API view."""
permission_classes = [permissions.AllowAny]
def get(self, request, *args, **kwargs):
"""Process an `not found` event on the API."""
data = {
'details': _('API endpoint not found'),
'url': request.build_absolute_uri(),
}
def not_found(self, request):
"""Return a 404 error"""
return JsonResponse(
{
'detail': _('API endpoint not found'),
'url': request.build_absolute_uri(),
},
status=404
)
return JsonResponse(data, status=404)
def options(self, request, *args, **kwargs):
"""Return 404"""
return self.not_found(request)
def get(self, request, *args, **kwargs):
"""Return 404"""
return self.not_found(request)
def post(self, request, *args, **kwargs):
"""Return 404"""
return self.not_found(request)
def patch(self, request, *args, **kwargs):
"""Return 404"""
return self.not_found(request)
def put(self, request, *args, **kwargs):
"""Return 404"""
return self.not_found(request)
def delete(self, request, *args, **kwargs):
"""Return 404"""
return self.not_found(request)
class BulkDeleteMixin:
@@ -183,10 +302,8 @@ class APIDownloadMixin:
if export_format and export_format in ['csv', 'tsv', 'xls', 'xlsx']:
queryset = self.filter_queryset(self.get_queryset())
return self.download_queryset(queryset, export_format)
else:
# Default to the parent class implementation
return super().get(request, *args, **kwargs)
# Default to the parent class implementation
return super().get(request, *args, **kwargs)
def download_queryset(self, queryset, export_format):
"""This function must be implemented to provide a downloadFile request."""
@@ -203,6 +320,12 @@ class AttachmentMixin:
filter_backends = SEARCH_ORDER_FILTER
search_fields = [
'attachment',
'comment',
'link',
]
def perform_create(self, serializer):
"""Save the user information when a file is uploaded."""
attachment = serializer.save()
@@ -225,7 +348,6 @@ class APISearchView(APIView):
def get_result_types(self):
"""Construct a list of search types we can return"""
import build.api
import company.api
import order.api
@@ -248,7 +370,6 @@ class APISearchView(APIView):
def post(self, request, *args, **kwargs):
"""Perform search query against available models"""
data = request.data
results = {}
@@ -262,6 +383,11 @@ class APISearchView(APIView):
'offset': 0,
}
if 'search' not in data:
raise ValidationError({
'search': 'Search term must be provided',
})
for key, cls in self.get_result_types().items():
# Only return results which are specifically requested
if key in data:

View File

@@ -2,10 +2,99 @@
# InvenTree API version
INVENTREE_API_VERSION = 123
INVENTREE_API_VERSION = 150
"""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 = """
v150 -> 2023-11-07: https://github.com/inventree/InvenTree/pull/5875
- Extended user API endpoints to enable ordering
- Extended user API endpoints to enable user role changes
- Added endpoint to create a new user
v149 -> 2023-11-07 : https://github.com/inventree/InvenTree/pull/5876
- Add 'building' quantity to BomItem serializer
- Add extra ordering options for the BomItem list API
v148 -> 2023-11-06 : https://github.com/inventree/InvenTree/pull/5872
- Allow "quantity" to be specified when installing an item into another item
v147 -> 2023-11-04: https://github.com/inventree/InvenTree/pull/5860
- Adds "completed_lines" field to SalesOrder API endpoint
- Adds "completed_lines" field to PurchaseOrder API endpoint
v146 -> 2023-11-02: https://github.com/inventree/InvenTree/pull/5822
- Extended SSO Provider endpoint to contain if a provider is configured
- Adds API endpoints for Email Address model
v145 -> 2023-10-30: https://github.com/inventree/InvenTree/pull/5786
- Allow printing labels via POST including printing options in the body
v144 -> 2023-10-23: https://github.com/inventree/InvenTree/pull/5811
- Adds version information API endpoint
v143 -> 2023-10-29: https://github.com/inventree/InvenTree/pull/5810
- Extends the status endpoint to include information about system status and health
v142 -> 2023-10-20: https://github.com/inventree/InvenTree/pull/5759
- Adds generic API endpoints for looking up status models
v141 -> 2023-10-23 : https://github.com/inventree/InvenTree/pull/5774
- Changed 'part.responsible' from User to Owner
v140 -> 2023-10-20 : https://github.com/inventree/InvenTree/pull/5664
- Expand API token functionality
- Multiple API tokens can be generated per user
v139 -> 2023-10-11 : https://github.com/inventree/InvenTree/pull/5509
- Add new BarcodePOReceive endpoint to receive line items by scanning supplier barcodes
v138 -> 2023-10-11 : https://github.com/inventree/InvenTree/pull/5679
- Settings keys are no longer case sensitive
- Include settings units in API serializer
v137 -> 2023-10-04 : https://github.com/inventree/InvenTree/pull/5588
- Adds StockLocationType API endpoints
- Adds custom_icon, location_type to StockLocation endpoint
v136 -> 2023-09-23 : https://github.com/inventree/InvenTree/pull/5595
- Adds structural to StockLocation and PartCategory tree endpoints
v135 -> 2023-09-19 : https://github.com/inventree/InvenTree/pull/5569
- Adds location path detail to StockLocation and StockItem API endpoints
- Adds category path detail to PartCategory and Part API endpoints
v134 -> 2023-09-11 : https://github.com/inventree/InvenTree/pull/5525
- Allow "Attachment" list endpoints to be searched by attachment, link and comment fields
v133 -> 2023-09-08 : https://github.com/inventree/InvenTree/pull/5518
- Add extra optional fields which can be used for StockAdjustment endpoints
v132 -> 2023-09-07 : https://github.com/inventree/InvenTree/pull/5515
- Add 'issued_by' filter to BuildOrder API list endpoint
v131 -> 2023-08-09 : https://github.com/inventree/InvenTree/pull/5415
- Annotate 'available_variant_stock' to the SalesOrderLine serializer
v130 -> 2023-07-14 : https://github.com/inventree/InvenTree/pull/5251
- Refactor label printing interface
v129 -> 2023-07-06 : https://github.com/inventree/InvenTree/pull/5189
- Changes 'serial_lte' and 'serial_gte' stock filters to point to 'serial_int' field
v128 -> 2023-07-06 : https://github.com/inventree/InvenTree/pull/5186
- Adds 'available' filter for BuildLine API endpoint
v127 -> 2023-06-24 : https://github.com/inventree/InvenTree/pull/5094
- Enhancements for the PartParameter API endpoints
v126 -> 2023-06-19 : https://github.com/inventree/InvenTree/pull/5075
- Adds API endpoint for setting the "category" for multiple parts simultaneously
v125 -> 2023-06-17 : https://github.com/inventree/InvenTree/pull/5064
- Adds API endpoint for setting the "status" field for multiple stock items simultaneously
v124 -> 2023-06-17 : https://github.com/inventree/InvenTree/pull/5057
- Add "created_before" and "created_after" filters to the Part API
v123 -> 2023-06-15 : https://github.com/inventree/InvenTree/pull/5019
- Add Metadata to: Plugin Config
@@ -34,18 +123,17 @@ v117 -> 2023-05-22 : https://github.com/inventree/InvenTree/pull/4854
v116 -> 2023-05-18 : https://github.com/inventree/InvenTree/pull/4823
- Updates to part parameter implementation, to use physical units
v115 - > 2023-05-18 : https://github.com/inventree/InvenTree/pull/4846
v115 -> 2023-05-18 : https://github.com/inventree/InvenTree/pull/4846
- Adds ability to partially scrap a build output
v114 -> 2023-05-16 : https://github.com/inventree/InvenTree/pull/4825
- Adds "delivery_date" to shipments
>>>>>>> inventree/master
v113 -> 2023-05-13 : https://github.com/inventree/InvenTree/pull/4800
- Adds API endpoints for scrapping a build output
v112 -> 2023-05-13: https://github.com/inventree/InvenTree/pull/4741
- Adds flag use_pack_size to the stock addition API, which allows addings packs
- Adds flag use_pack_size to the stock addition API, which allows adding packs
v111 -> 2023-05-02 : https://github.com/inventree/InvenTree/pull/4367
- Adds tags to the Part serializer
@@ -125,7 +213,7 @@ v90 -> 2023-01-25 : https://github.com/inventree/InvenTree/pull/4186/files
v89 -> 2023-01-25 : https://github.com/inventree/InvenTree/pull/4214
- Adds updated field to SupplierPart API
- Adds API date orddering for supplier part list
- Adds API date ordering for supplier part list
v88 -> 2023-01-17: https://github.com/inventree/InvenTree/pull/4225
- Adds 'priority' field to Build model and api endpoints

View File

@@ -9,12 +9,13 @@ from django.conf import settings
from django.contrib.auth import get_user_model
from django.core.exceptions import AppRegistryNotReady
from django.db import transaction
from django.db.utils import IntegrityError
from django.db.utils import IntegrityError, OperationalError
import InvenTree.conversion
import InvenTree.tasks
from InvenTree.config import get_setting
from InvenTree.ready import canAppAccessDatabase, isInTestMode
from InvenTree.ready import (canAppAccessDatabase, isInMainThread,
isInTestMode, isPluginRegistryLoaded)
logger = logging.getLogger("inventree")
@@ -34,8 +35,11 @@ class InvenTreeConfig(AppConfig):
- Collecting notification methods
- Adding users set in the current environment
"""
# skip loading if plugin registry is not loaded or we run in a background thread
if not isPluginRegistryLoaded() or not isInMainThread():
return
if canAppAccessDatabase() or settings.TESTING_ENV:
InvenTree.tasks.check_for_migrations(worker=False)
self.remove_obsolete_tasks()
@@ -44,11 +48,13 @@ class InvenTreeConfig(AppConfig):
if not isInTestMode(): # pragma: no cover
self.update_exchange_rates()
# Let the background worker check for migrations
InvenTree.tasks.offload_task(InvenTree.tasks.check_for_migrations)
self.collect_notification_methods()
# Ensure the unit registry is loaded
InvenTree.conversion.reload_unit_registry()
InvenTree.conversion.get_unit_registry()
if canAppAccessDatabase() or settings.TESTING_ENV:
self.add_user_on_startup()
@@ -66,23 +72,61 @@ class InvenTreeConfig(AppConfig):
return
# Remove any existing obsolete tasks
Schedule.objects.filter(func__in=obsolete).delete()
try:
Schedule.objects.filter(func__in=obsolete).delete()
except Exception:
logger.exception("Failed to remove obsolete tasks - database not ready")
def start_background_tasks(self):
"""Start all background tests for InvenTree."""
logger.info("Starting background tasks...")
from django_q.models import Schedule
# List of existing scheduled tasks (in the database)
existing_tasks = {}
for existing_task in Schedule.objects.all():
existing_tasks[existing_task.func] = existing_task
tasks_to_create = []
tasks_to_update = []
# List of collected tasks found with the @scheduled_task decorator
tasks = InvenTree.tasks.tasks.task_list
for task in tasks:
ref_name = f'{task.func.__module__}.{task.func.__name__}'
InvenTree.tasks.schedule_task(
ref_name,
schedule_type=task.interval,
minutes=task.minutes,
)
if ref_name in existing_tasks.keys():
# This task already exists - update the details if required
existing_task = existing_tasks[ref_name]
if existing_task.schedule_type != task.interval or existing_task.minutes != task.minutes:
existing_task.schedule_type = task.interval
existing_task.minutes = task.minutes
tasks_to_update.append(existing_task)
else:
# This task does *not* already exist - create it
tasks_to_create.append(
Schedule(
name=ref_name,
func=ref_name,
schedule_type=task.interval,
minutes=task.minutes,
)
)
if len(tasks_to_create) > 0:
Schedule.objects.bulk_create(tasks_to_create)
logger.info("Created %s new scheduled tasks", len(tasks_to_create))
if len(tasks_to_update) > 0:
Schedule.objects.bulk_update(tasks_to_update, ['schedule_type', 'minutes'])
logger.info("Updated %s existing scheduled tasks", len(tasks_to_update))
# Put at least one task onto the background worker stack,
# which will be processed as soon as the worker comes online
@@ -91,11 +135,10 @@ class InvenTreeConfig(AppConfig):
force_async=True,
)
logger.info(f"Started {len(tasks)} scheduled background tasks...")
logger.info("Started %s scheduled background tasks...", len(tasks))
def collect_tasks(self):
"""Collect all background tasks."""
for app_name, app in apps.app_configs.items():
if app_name == 'InvenTree':
continue
@@ -104,7 +147,7 @@ class InvenTreeConfig(AppConfig):
try:
import_module(f'{app.module.__package__}.tasks')
except Exception as e: # pragma: no cover
logger.error(f"Error loading tasks for {app_name}: {e}")
logger.exception("Error loading tasks for %s: %s", app_name, e)
def update_exchange_rates(self): # pragma: no cover
"""Update exchange rates each time the server is started.
@@ -140,7 +183,7 @@ class InvenTreeConfig(AppConfig):
# Backend currency has changed?
if base_currency != backend.base_currency:
logger.info(f"Base currency changed from {backend.base_currency} to {base_currency}")
logger.info("Base currency changed from %s to %s", backend.base_currency, base_currency)
update = True
except (ExchangeBackend.DoesNotExist):
@@ -154,8 +197,10 @@ class InvenTreeConfig(AppConfig):
if update:
try:
update_exchange_rates()
except OperationalError:
logger.warning("Could not update exchange rates - database not ready")
except Exception as e:
logger.error(f"Error updating exchange rates: {e} ({type(e)})")
logger.exception("Error updating exchange rates: %s (%s)", e, type(e))
def add_user_on_startup(self):
"""Add a user on startup."""
@@ -182,7 +227,7 @@ class InvenTreeConfig(AppConfig):
# not all needed variables set
if set_variables < 3:
logger.warn('Not all required settings for adding a user on startup are present:\nINVENTREE_ADMIN_USER, INVENTREE_ADMIN_EMAIL, INVENTREE_ADMIN_PASSWORD')
logger.warning('Not all required settings for adding a user on startup are present:\nINVENTREE_ADMIN_USER, INVENTREE_ADMIN_EMAIL, INVENTREE_ADMIN_PASSWORD')
settings.USER_ADDED = True
return
@@ -191,12 +236,12 @@ class InvenTreeConfig(AppConfig):
try:
with transaction.atomic():
if user.objects.filter(username=add_user).exists():
logger.info(f"User {add_user} already exists - skipping creation")
logger.info("User %s already exists - skipping creation", add_user)
else:
new_user = user.objects.create_superuser(add_user, add_email, add_password)
logger.info(f'User {str(new_user)} was created!')
except IntegrityError as _e:
logger.warning(f'The user "{add_user}" could not be created due to the following error:\n{str(_e)}')
logger.info('User %s was created!', str(new_user))
except IntegrityError:
logger.warning('The user "%s" could not be created', add_user)
# do not try again
settings.USER_ADDED = True

View File

@@ -7,6 +7,7 @@ import os
import random
import shutil
import string
import warnings
from pathlib import Path
logger = logging.getLogger('inventree')
@@ -23,7 +24,6 @@ def to_list(value, delimiter=','):
However, the same setting may be specified via an environment variable,
using a comma delimited string!
"""
if type(value) in [list, tuple]:
return value
@@ -45,13 +45,13 @@ def to_dict(value):
if value is None:
return {}
if type(value) == dict:
if isinstance(value, dict):
return value
try:
return json.loads(value)
except Exception as error:
logger.error(f"Failed to parse value '{value}' as JSON with error {error}. Ensure value is a valid JSON string.")
logger.exception("Failed to parse value '%s' as JSON with error %s. Ensure value is a valid JSON string.", value, error)
return {}
@@ -70,7 +70,6 @@ def ensure_dir(path: Path) -> None:
If it does not exist, create it.
"""
if not path.exists():
path.mkdir(parents=True, exist_ok=True)
@@ -143,7 +142,6 @@ def get_setting(env_var=None, config_key=None, default_value=None, typecast=None
"""
def try_typecasting(value, source: str):
"""Attempt to typecast the value"""
# Force 'list' of strings
if typecast is list:
value = to_list(value)
@@ -159,7 +157,7 @@ def get_setting(env_var=None, config_key=None, default_value=None, typecast=None
set_metadata(source)
return val
except Exception as error:
logger.error(f"Failed to typecast '{env_var}' with value '{value}' to type '{typecast}' with error {error}")
logger.exception("Failed to typecast '%s' with value '%s' to type '%s' with error %s", env_var, value, typecast, error)
set_metadata(source)
return value
@@ -201,13 +199,11 @@ def get_setting(env_var=None, config_key=None, default_value=None, typecast=None
def get_boolean_setting(env_var=None, config_key=None, default_value=False):
"""Helper function for retrieving a boolean configuration setting"""
return is_true(get_setting(env_var, config_key, default_value))
def get_media_dir(create=True):
"""Return the absolute path for the 'media' directory (where uploaded files are stored)"""
md = get_setting('INVENTREE_MEDIA_ROOT', 'media_root')
if not md:
@@ -223,7 +219,6 @@ def get_media_dir(create=True):
def get_static_dir(create=True):
"""Return the absolute path for the 'static' directory (where static files are stored)"""
sd = get_setting('INVENTREE_STATIC_ROOT', 'static_root')
if not sd:
@@ -239,7 +234,6 @@ def get_static_dir(create=True):
def get_backup_dir(create=True):
"""Return the absolute path for the backup directory"""
bd = get_setting('INVENTREE_BACKUP_DIR', 'backup_dir')
if not bd:
@@ -258,7 +252,6 @@ def get_plugin_file():
Note: It will be created if it does not already exist!
"""
# Check if the plugin.txt file (specifying required plugins) is specified
plugin_file = get_setting('INVENTREE_PLUGIN_FILE', 'plugin_file')
@@ -272,7 +265,7 @@ def get_plugin_file():
if not plugin_file.exists():
logger.warning("Plugin configuration file does not exist - creating default file")
logger.info(f"Creating plugin file at '{plugin_file}'")
logger.info("Creating plugin file at '%s'", plugin_file)
ensure_dir(plugin_file.parent)
# If opening the file fails (no write permission, for example), then this will throw an error
@@ -281,6 +274,11 @@ def get_plugin_file():
return plugin_file
def get_plugin_dir():
"""Returns the path of the custom plugins directory"""
return get_setting('INVENTREE_PLUGIN_DIR', 'plugin_dir')
def get_secret_key():
"""Return the secret key value which will be used by django.
@@ -291,7 +289,6 @@ def get_secret_key():
C) Look for default key file "secret_key.txt"
D) Create "secret_key.txt" if it does not exist
"""
# Look for environment variable
if secret_key := get_setting('INVENTREE_SECRET_KEY', 'secret_key'):
logger.info("SECRET_KEY loaded by INVENTREE_SECRET_KEY") # pragma: no cover
@@ -305,7 +302,7 @@ def get_secret_key():
secret_key_file = get_base_dir().joinpath("secret_key.txt").resolve()
if not secret_key_file.exists():
logger.info(f"Generating random key file at '{secret_key_file}'")
logger.info("Generating random key file at '%s'", secret_key_file)
ensure_dir(secret_key_file.parent)
# Create a random key file
@@ -313,7 +310,7 @@ def get_secret_key():
key = ''.join([random.choice(options) for i in range(100)])
secret_key_file.write_text(key)
logger.info(f"Loading SECRET_KEY from '{secret_key_file}'")
logger.debug("Loading SECRET_KEY from '%s'", secret_key_file)
key_data = secret_key_file.read_text().strip()
@@ -336,12 +333,67 @@ def get_custom_file(env_ref: str, conf_ref: str, log_ref: str, lookup_media: boo
static_storage = StaticFilesStorage()
if static_storage.exists(value):
logger.info(f"Loading {log_ref} from static directory: {value}")
logger.info("Loading %s from %s directory: %s", log_ref, 'static', value)
elif lookup_media and default_storage.exists(value):
logger.info(f"Loading {log_ref} from media directory: {value}")
logger.info("Loading %s from %s directory: %s", log_ref, 'media', value)
else:
add_dir_str = ' or media' if lookup_media else ''
logger.warning(f"The {log_ref} file '{value}' could not be found in the static{add_dir_str} directories")
logger.warning("The %s file '%s' could not be found in the static %s directories", log_ref, value, add_dir_str)
value = False
return value
def get_frontend_settings(debug=True):
"""Return a dictionary of settings for the frontend interface.
Note that the new config settings use the 'FRONTEND' key,
whereas the legacy key was 'PUI' (platform UI) which is now deprecated
"""
# Legacy settings
pui_settings = get_setting('INVENTREE_PUI_SETTINGS', 'pui_settings', {}, typecast=dict)
if len(pui_settings) > 0:
warnings.warn(
"The 'INVENTREE_PUI_SETTINGS' key is deprecated. Please use 'INVENTREE_FRONTEND_SETTINGS' instead",
DeprecationWarning, stacklevel=2
)
# New settings
frontend_settings = get_setting('INVENTREE_FRONTEND_SETTINGS', 'frontend_settings', {}, typecast=dict)
# Merge settings
settings = {**pui_settings, **frontend_settings}
# Set the base URL
if 'base_url' not in settings:
base_url = get_setting('INVENTREE_PUI_URL_BASE', 'pui_url_base', '')
if base_url:
warnings.warn(
"The 'INVENTREE_PUI_URL_BASE' key is deprecated. Please use 'INVENTREE_FRONTEND_URL_BASE' instead",
DeprecationWarning, stacklevel=2
)
else:
base_url = get_setting('INVENTREE_FRONTEND_URL_BASE', 'frontend_url_base', 'platform')
settings['base_url'] = base_url
# Set the server list
settings['server_list'] = settings.get('server_list', [])
# Set the debug flag
settings['debug'] = debug
if 'environment' not in settings:
settings['environment'] = 'development' if debug else 'production'
if debug and 'show_server_selector' not in settings:
# In debug mode, show server selector by default
settings['show_server_selector'] = True
elif len(settings['server_list']) == 0:
# If no servers are specified, show server selector
settings['show_server_selector'] = True
return settings

View File

@@ -1,5 +1,7 @@
"""Helper functions for converting between units."""
import logging
from django.core.exceptions import ValidationError
from django.utils.translation import gettext_lazy as _
@@ -8,15 +10,16 @@ import pint
_unit_registry = None
logger = logging.getLogger('inventree')
def get_unit_registry():
"""Return a custom instance of the Pint UnitRegistry."""
global _unit_registry
# Cache the unit registry for speedier access
if _unit_registry is None:
reload_unit_registry()
return reload_unit_registry()
return _unit_registry
@@ -25,27 +28,52 @@ def reload_unit_registry():
This function is called at startup, and whenever the database is updated.
"""
import time
t_start = time.time()
global _unit_registry
_unit_registry = pint.UnitRegistry()
_unit_registry = None
reg = pint.UnitRegistry()
# Define some "standard" additional units
_unit_registry.define('piece = 1')
_unit_registry.define('each = 1 = ea')
_unit_registry.define('dozen = 12 = dz')
_unit_registry.define('hundred = 100')
_unit_registry.define('thousand = 1000')
reg.define('piece = 1')
reg.define('each = 1 = ea')
reg.define('dozen = 12 = dz')
reg.define('hundred = 100')
reg.define('thousand = 1000')
# TODO: Allow for custom units to be defined in the database
# Allow for custom units to be defined in the database
try:
from common.models import CustomUnit
for cu in CustomUnit.objects.all():
try:
reg.define(cu.fmt_string())
except Exception as e:
logger.exception('Failed to load custom unit: %s - %s', cu.fmt_string(), e)
# Once custom units are loaded, save registry
_unit_registry = reg
except Exception:
# Database is not ready, or CustomUnit model is not available
pass
dt = time.time() - t_start
logger.debug('Loaded unit registry in %s.3f s', dt)
return reg
def convert_physical_value(value: str, unit: str = None):
def convert_physical_value(value: str, unit: str = None, strip_units=True):
"""Validate that the provided value is a valid physical quantity.
Arguments:
value: Value to validate (str)
unit: Optional unit to convert to, and validate against
strip_units: If True, strip units from the returned value, and return only the dimension
Raises:
ValidationError: If the value is invalid or cannot be converted to the specified unit
@@ -53,45 +81,93 @@ def convert_physical_value(value: str, unit: str = None):
Returns:
The converted quantity, in the specified units
"""
original = str(value).strip()
# Ensure that the value is a string
value = str(value).strip()
value = str(value).strip() if value else ''
unit = str(unit).strip() if unit else ''
# Error on blank values
if not value:
raise ValidationError(_('No value provided'))
# Create a "backup" value which be tried if the first value fails
# e.g. value = "10k" and unit = "ohm" -> "10kohm"
# e.g. value = "10m" and unit = "F" -> "10mF"
if unit:
backup_value = value + unit
else:
backup_value = None
ureg = get_unit_registry()
error = ''
try:
# Convert to a quantity
val = ureg.Quantity(value)
value = ureg.Quantity(value)
if unit:
if val.units == ureg.dimensionless:
# If the provided value is dimensionless, assume that the unit is correct
val = ureg.Quantity(value, unit)
if is_dimensionless(value):
magnitude = value.to_base_units().magnitude
value = ureg.Quantity(magnitude, unit)
else:
# Convert to the provided unit (may raise an exception)
val = val.to(unit)
value = value.to(unit)
# At this point we *should* have a valid pint value
# To double check, look at the maginitude
float(val.magnitude)
except (TypeError, ValueError):
error = _('Provided value is not a valid number')
except (pint.errors.UndefinedUnitError, pint.errors.DefinitionSyntaxError):
error = _('Provided value has an invalid unit')
except pint.errors.DimensionalityError:
error = _('Provided value could not be converted to the specified unit')
except Exception:
if backup_value:
try:
value = ureg.Quantity(backup_value)
except Exception:
value = None
else:
value = None
if error:
if value is None:
if unit:
error += f' ({unit})'
raise ValidationError(_(f'Could not convert {original} to {unit}'))
else:
raise ValidationError(_("Invalid quantity supplied"))
raise ValidationError(error)
# Calculate the "magnitude" of the value, as a float
# If the value is specified strangely (e.g. as a fraction or a dozen), this can cause issues
# So, we ensure that it is converted to a floating point value
# If we wish to return a "raw" value, some trickery is required
try:
if unit:
magnitude = ureg.Quantity(value.to(ureg.Unit(unit))).magnitude
else:
magnitude = ureg.Quantity(value.to_base_units()).magnitude
# Return the converted value
return val
magnitude = float(ureg.Quantity(magnitude).to_base_units().magnitude)
except Exception as exc:
raise ValidationError(_(f'Invalid quantity supplied ({exc})'))
if strip_units:
return magnitude
elif unit or value.units:
return ureg.Quantity(magnitude, unit or value.units)
return ureg.Quantity(magnitude)
def is_dimensionless(value):
"""Determine if the provided value is 'dimensionless'
A dimensionless value might look like:
0.1
1/2 dozen
three thousand
1.2 dozen
(etc)
"""
ureg = get_unit_registry()
# Ensure the provided value is in the right format
value = ureg.Quantity(value)
if value.units == ureg.dimensionless:
return True
if value.to_base_units().units == ureg.dimensionless:
return True
# At this point, the value is not dimensionless
return False

View File

@@ -17,6 +17,7 @@ def is_email_configured():
NOTE: This does not check if the configuration is valid!
"""
configured = True
testing = settings.TESTING
if InvenTree.ready.isInTestMode():
return False
@@ -28,24 +29,30 @@ def is_email_configured():
configured = False
# Display warning unless in test mode
if not settings.TESTING: # pragma: no cover
if not testing: # pragma: no cover
logger.debug("EMAIL_HOST is not configured")
# Display warning unless in test mode
if not settings.EMAIL_HOST_USER and not settings.TESTING: # pragma: no cover
if not settings.EMAIL_HOST_USER and not testing: # pragma: no cover
logger.debug("EMAIL_HOST_USER is not configured")
# Display warning unless in test mode
if not settings.EMAIL_HOST_PASSWORD and not settings.TESTING: # pragma: no cover
if not settings.EMAIL_HOST_PASSWORD and testing: # pragma: no cover
logger.debug("EMAIL_HOST_PASSWORD is not configured")
# Email sender must be configured
if not settings.DEFAULT_FROM_EMAIL:
configured = False
if not testing: # pragma: no cover
logger.debug("DEFAULT_FROM_EMAIL is not configured")
return configured
def send_email(subject, body, recipients, from_email=None, html_message=None):
"""Send an email with the specified subject and body, to the specified recipients list."""
if type(recipients) == str:
if isinstance(recipients, str):
recipients = [recipients]
import InvenTree.ready

View File

@@ -31,7 +31,6 @@ def log_error(path):
Arguments:
path: The 'path' (most likely a URL) associated with this error (optional)
"""
kind, info, data = sys.exc_info()
# Check if the error is on the ignore list

View File

@@ -1,74 +1,100 @@
"""Exchangerate backend to use `exchangerate.host` to get rates."""
"""Custom exchange backend which hooks into the InvenTree plugin system to fetch exchange rates from an external API."""
import ssl
from urllib.error import URLError
from urllib.request import urlopen
import logging
from django.db.utils import OperationalError
from django.db.transaction import atomic
import certifi
from djmoney.contrib.exchange.backends.base import SimpleExchangeBackend
from djmoney.contrib.exchange.models import ExchangeBackend, Rate
from common.settings import currency_code_default, currency_codes
logger = logging.getLogger('inventree')
class InvenTreeExchange(SimpleExchangeBackend):
"""Backend for automatically updating currency exchange rates.
Uses the `exchangerate.host` service API
Uses the plugin system to actually fetch the rates from an external API.
"""
name = "InvenTreeExchange"
def __init__(self):
"""Set API url."""
self.url = "https://api.exchangerate.host/latest"
super().__init__()
def get_params(self):
"""Placeholder to set API key. Currently not required by `exchangerate.host`."""
# No API key is required
return {
}
def get_response(self, **kwargs):
"""Custom code to get response from server.
Note: Adds a 5-second timeout
"""
url = self.get_url(**kwargs)
try:
context = ssl.create_default_context(cafile=certifi.where())
response = urlopen(url, timeout=5, context=context)
return response.read()
except Exception:
# Something has gone wrong, but we can just try again next time
# Raise a TypeError so the outer function can handle this
raise TypeError
def update_rates(self, base_currency=None):
def get_rates(self, **kwargs) -> None:
"""Set the requested currency codes and get rates."""
# Set default - see B008
from common.models import InvenTreeSetting
from plugin import registry
base_currency = kwargs.get('base_currency', currency_code_default())
symbols = kwargs.get('symbols', currency_codes())
# Find the selected exchange rate plugin
slug = InvenTreeSetting.get_setting('CURRENCY_UPDATE_PLUGIN', '', create=False)
if slug:
plugin = registry.get_plugin(slug)
else:
plugin = None
if not plugin:
# Find the first active currency exchange plugin
plugins = registry.with_mixin('currencyexchange', active=True)
if len(plugins) > 0:
plugin = plugins[0]
if not plugin:
logger.warning('No active currency exchange plugins found - skipping update')
return {}
logger.info("Running exchange rate update using plugin '%s'", plugin.name)
# Plugin found - run the update task
try:
rates = plugin.update_exchange_rates(base_currency, symbols)
except Exception as exc:
logger.exception("Exchange rate update failed: %s", exc)
return {}
if not rates:
logger.warning("Exchange rate update failed - no data returned from plugin %s", slug)
return {}
# Update exchange rates based on returned data
if type(rates) is not dict:
logger.warning("Invalid exchange rate data returned from plugin %s (type %s)", slug, type(rates))
return {}
# Ensure base currency is provided
rates[base_currency] = 1.00
return rates
@atomic
def update_rates(self, base_currency=None, **kwargs):
"""Call to update all exchange rates"""
backend, _ = ExchangeBackend.objects.update_or_create(name=self.name, defaults={"base_currency": base_currency})
if base_currency is None:
base_currency = currency_code_default()
symbols = ','.join(currency_codes())
symbols = currency_codes()
try:
super().update_rates(base=base_currency, symbols=symbols)
# catch connection errors
except URLError:
print('Encountered connection error while updating')
except TypeError:
print('Exchange returned invalid response')
except OperationalError as e:
if 'SerializationFailure' in e.__cause__.__class__.__name__:
print('Serialization Failure while updating exchange rates')
# We are just going to swallow this exception because the
# exchange rates will be updated later by the scheduled task
else:
# Other operational errors probably are still show stoppers
# so reraise them so that the log contains the stacktrace
raise
logger.info("Updating exchange rates for %s (%s currencies)", base_currency, len(symbols))
# Fetch new rates from the backend
# If the backend fails, the existing rates will not be updated
rates = self.get_rates(base_currency=base_currency, symbols=symbols)
if rates:
# Clear out existing rates
backend.clear_rates()
Rate.objects.bulk_create([
Rate(currency=currency, value=amount, backend=backend)
for currency, amount in rates.items()
])
else:
logger.info("No exchange rates returned from backend - currencies not updated")
logger.info("Updated exchange rates for %s", base_currency)

View File

@@ -18,11 +18,10 @@ from .validators import AllowedURLValidator, allowable_url_schemes
class InvenTreeRestURLField(RestURLField):
"""Custom field for DRF with custom scheme vaildators."""
"""Custom field for DRF with custom scheme validators."""
def __init__(self, **kwargs):
"""Update schemes."""
# Enforce 'max length' parameter in form validation
if 'max_length' not in kwargs:
kwargs['max_length'] = 200
@@ -38,19 +37,28 @@ class InvenTreeURLField(models.URLField):
def __init__(self, **kwargs):
"""Initialization method for InvenTreeURLField"""
# Max length for InvenTreeURLField is set to 200
kwargs['max_length'] = 200
super().__init__(**kwargs)
def money_kwargs():
def money_kwargs(**kwargs):
"""Returns the database settings for MoneyFields."""
from common.settings import currency_code_default, currency_code_mappings
kwargs = {}
kwargs['currency_choices'] = currency_code_mappings()
kwargs['default_currency'] = currency_code_default()
# Default values (if not specified)
if 'max_digits' not in kwargs:
kwargs['max_digits'] = 19
if 'decimal_places' not in kwargs:
kwargs['decimal_places'] = 6
if 'currency_choices' not in kwargs:
kwargs['currency_choices'] = currency_code_mappings()
if 'default_currency' not in kwargs:
kwargs['default_currency'] = currency_code_default()
return kwargs
@@ -64,16 +72,8 @@ class InvenTreeModelMoneyField(ModelMoneyField):
# remove currency information for a clean migration
kwargs['default_currency'] = ''
kwargs['currency_choices'] = []
else:
# set defaults
kwargs.update(money_kwargs())
# Default values (if not specified)
if 'max_digits' not in kwargs:
kwargs['max_digits'] = 19
if 'decimal_places' not in kwargs:
kwargs['decimal_places'] = 6
kwargs = money_kwargs(**kwargs)
# Set a minimum value validator
validators = kwargs.get('validators', [])
@@ -115,11 +115,7 @@ class InvenTreeMoneyField(MoneyField):
def __init__(self, *args, **kwargs):
"""Override initial values with the real info from database."""
kwargs.update(money_kwargs())
kwargs['max_digits'] = 19
kwargs['decimal_places'] = 6
kwargs = money_kwargs(**kwargs)
super().__init__(*args, **kwargs)
@@ -151,7 +147,6 @@ class DatePickerFormField(forms.DateField):
def round_decimal(value, places, normalize=False):
"""Round value to the specified number of places."""
if type(value) in [Decimal, float]:
value = round(value, places)
@@ -188,7 +183,6 @@ class RoundingDecimalField(models.DecimalField):
def formfield(self, **kwargs):
"""Return a Field instance for this field."""
kwargs['form_class'] = RoundingDecimalFormField
return super().formfield(**kwargs)

View File

@@ -15,7 +15,6 @@ class InvenTreeSearchFilter(filters.SearchFilter):
The following query params are available to 'augment' the search (in decreasing order of priority)
- search_regex: If True, search is performed on 'regex' comparison
"""
regex = InvenTree.helpers.str2bool(request.query_params.get('search_regex', False))
search_fields = super().get_search_fields(view, request)
@@ -36,7 +35,6 @@ class InvenTreeSearchFilter(filters.SearchFilter):
Depending on the request parameters, we may "augment" these somewhat
"""
whole = InvenTree.helpers.str2bool(request.query_params.get('search_whole', False))
terms = []

View File

@@ -3,15 +3,20 @@
import re
import string
from django.conf import settings
from django.utils import translation
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:
"""Extract formatting information from the provided format string.
Returns a dict object which contains structured information about the format groups
"""
groups = string.Formatter().parse(fmt_string)
info = {}
@@ -62,7 +67,6 @@ def construct_format_regex(fmt_string: str) -> str:
Raises:
ValueError: Format string is invalid
"""
pattern = "^"
for group in string.Formatter().parse(fmt_string):
@@ -121,7 +125,6 @@ def validate_string(value: str, fmt_string: str) -> str:
Raises:
ValueError: The provided format string is invalid
"""
pattern = construct_format_regex(fmt_string)
result = re.match(pattern, value)
@@ -145,7 +148,6 @@ def extract_named_group(name: str, value: str, fmt_string: str) -> str:
NameError: named value does not exist in the format string
IndexError: named value could not be found in the provided entry
"""
info = parse_format_string(fmt_string)
if name not in info.keys():
@@ -164,3 +166,34 @@ def extract_named_group(name: str, value: str, fmt_string: str) -> str:
# And return the value we are interested in
# Note: This will raise an IndexError if the named group was not matched
return result.group(name)
def format_money(money: Money, decimal_places: int = None, format: str = None) -> str:
"""Format money object according to the currently set local
Args:
decimal_places: Number of decimal places to use
format: Format pattern according LDML / the babel format pattern syntax (https://babel.pocoo.org/en/latest/numbers.html)
Returns:
str: The formatted string
Raises:
ValueError: format string is incorrectly specified
"""
language = None and translation.get_language() or settings.LANGUAGE_CODE
locale = Locale.parse(translation.to_locale(language))
if format:
pattern = parse_pattern(format)
else:
pattern = locale.currency_formats["standard"]
if decimal_places is not None:
pattern.frac_prec = (decimal_places, decimal_places)
return pattern.apply(
money.amount,
locale,
currency=money.currency.code,
currency_digits=decimal_places is None,
decimal_quantization=decimal_places is not None,
)

View File

@@ -12,7 +12,7 @@ from django.urls import reverse
from django.utils.translation import gettext_lazy as _
from allauth.account.adapter import DefaultAccountAdapter
from allauth.account.forms import SignupForm, set_form_field_order
from allauth.account.forms import LoginForm, SignupForm, set_form_field_order
from allauth.exceptions import ImmediateHttpResponse
from allauth.socialaccount.adapter import DefaultSocialAccountAdapter
from allauth_2fa.adapter import OTPAdapter
@@ -21,6 +21,8 @@ from crispy_forms.bootstrap import (AppendedText, PrependedAppendedText,
PrependedText)
from crispy_forms.helper import FormHelper
from crispy_forms.layout import Field, Layout
from dj_rest_auth.registration.serializers import RegisterSerializer
from rest_framework import serializers
from common.models import InvenTreeSetting
from InvenTree.exceptions import log_error
@@ -65,10 +67,10 @@ class HelperForm(forms.ModelForm):
# Look for font-awesome icons
if prefix and prefix.startswith('fa-'):
prefix = r"<i class='fas {fa}'/>".format(fa=prefix)
prefix = f"<i class='fas {prefix}'/>"
if suffix and suffix.startswith('fa-'):
suffix = r"<i class='fas {fa}'/>".format(fa=suffix)
suffix = f"<i class='fas {suffix}'/>"
if prefix and suffix:
layouts.append(
@@ -159,11 +161,29 @@ class SetPasswordForm(HelperForm):
old_password = forms.CharField(
label=_("Old password"),
strip=False,
required=False,
widget=forms.PasswordInput(attrs={'autocomplete': 'current-password', 'autofocus': True}),
)
# override allauth
class CustomLoginForm(LoginForm):
"""Custom login form to override default allauth behaviour"""
def login(self, request, redirect_url=None):
"""Perform login action.
First check that:
- A valid user has been supplied
"""
if not self.user:
# No user supplied - redirect to the login page
return HttpResponseRedirect(reverse('account_login'))
# Now perform default login action
return super().login(request, redirect_url)
class CustomSignupForm(SignupForm):
"""Override to use dynamic settings."""
@@ -193,7 +213,7 @@ class CustomSignupForm(SignupForm):
set_form_field_order(self, ["username", "email", "email2", "password1", "password2", ])
def clean(self):
"""Make sure the supllied emails match if enabled in settings."""
"""Make sure the supplied emails match if enabled in settings."""
cleaned_data = super().clean()
# check for two mail fields
@@ -206,6 +226,11 @@ class CustomSignupForm(SignupForm):
return cleaned_data
def registration_enabled():
"""Determine whether user registration is enabled."""
return settings.EMAIL_HOST and (InvenTreeSetting.get_setting('LOGIN_ENABLE_REG') or InvenTreeSetting.get_setting('LOGIN_ENABLE_SSO_REG'))
class RegistratonMixin:
"""Mixin to check if registration should be enabled."""
@@ -214,7 +239,7 @@ class RegistratonMixin:
Configure the class variable `REGISTRATION_SETTING` to set which setting should be used, default: `LOGIN_ENABLE_REG`.
"""
if settings.EMAIL_HOST and (InvenTreeSetting.get_setting('LOGIN_ENABLE_REG') or InvenTreeSetting.get_setting('LOGIN_ENABLE_SSO_REG')):
if registration_enabled():
return super().is_open_for_signup(request, *args, **kwargs)
return False
@@ -226,7 +251,7 @@ class RegistratonMixin:
split_email = email.split('@')
if len(split_email) != 2:
logger.error(f'The user {email} has an invalid email address')
logger.error('The user %s has an invalid email address', email)
raise forms.ValidationError(_('The provided primary email address is not valid.'))
mailoptions = mail_restriction.split(',')
@@ -238,7 +263,7 @@ class RegistratonMixin:
if split_email[1] == option[1:]:
return super().clean_email(email)
logger.info(f'The provided email domain for {email} is not approved')
logger.info('The provided email domain for %s is not approved', email)
raise forms.ValidationError(_('The provided email domain is not approved.'))
def save_user(self, request, user, form, commit=True):
@@ -253,7 +278,7 @@ class RegistratonMixin:
group = Group.objects.get(id=start_group)
user.groups.add(group)
except Group.DoesNotExist:
logger.error('The setting `SIGNUP_GROUP` contains an non existent group', start_group)
logger.exception('The setting `SIGNUP_GROUP` contains an non existent group', start_group)
user.save()
return user
@@ -285,6 +310,14 @@ class CustomAccountAdapter(CustomUrlMixin, RegistratonMixin, OTPAdapter, Default
return False
def get_email_confirmation_url(self, request, emailconfirmation):
"""Construct the email confirmation url"""
from InvenTree.helpers_model import construct_absolute_url
url = super().get_email_confirmation_url(request, emailconfirmation)
url = construct_absolute_url(url)
return url
class CustomSocialAccountAdapter(CustomUrlMixin, RegistratonMixin, DefaultSocialAccountAdapter):
"""Override of adapter to use dynamic settings."""
@@ -319,3 +352,20 @@ class CustomSocialAccountAdapter(CustomUrlMixin, RegistratonMixin, DefaultSocial
# Otherwise defer to the original allauth adapter.
return super().login(request, user)
# override dj-rest-auth
class CustomRegisterSerializer(RegisterSerializer):
"""Override of serializer to use dynamic settings."""
email = serializers.EmailField()
def __init__(self, instance=None, data=..., **kwargs):
"""Check settings to influence which fields are needed."""
kwargs['email_required'] = InvenTreeSetting.get_setting('LOGIN_MAIL_REQUIRED')
super().__init__(instance, data, **kwargs)
def save(self, request):
"""Override to check if registration is open."""
if registration_enabled():
return super().save(request)
raise forms.ValidationError(_('Registration is disabled.'))

View File

@@ -51,7 +51,6 @@ def constructPathString(path, max_chars=250):
path: A list of strings e.g. ['path', 'to', 'location']
max_chars: Maximum number of characters
"""
pathstring = '/'.join(path)
# Replace middle elements to limit the pathstring
@@ -93,7 +92,6 @@ def getBlankThumbnail():
def getLogoImage(as_file=False, custom=True):
"""Return the InvenTree logo image, or a custom logo if available."""
"""Return the path to the logo-file."""
if custom and settings.CUSTOM_LOGO:
@@ -109,20 +107,17 @@ def getLogoImage(as_file=False, custom=True):
if storage is not None:
if as_file:
return f"file://{storage.path(settings.CUSTOM_LOGO)}"
else:
return storage.url(settings.CUSTOM_LOGO)
return storage.url(settings.CUSTOM_LOGO)
# If we have got to this point, return the default logo
if as_file:
path = settings.STATIC_ROOT.joinpath('img/inventree.png')
return f"file://{path}"
else:
return getStaticUrl('img/inventree.png')
return getStaticUrl('img/inventree.png')
def getSplashScren(custom=True):
def getSplashScreen(custom=True):
"""Return the InvenTree splash screen, or a custom splash if available"""
static_storage = StaticFilesStorage()
if custom and settings.CUSTOM_SPLASH:
@@ -159,8 +154,7 @@ def str2bool(text, test=True):
"""
if test:
return str(text).lower() in ['1', 'y', 'yes', 't', 'true', 'ok', 'on', ]
else:
return str(text).lower() in ['0', 'n', 'no', 'none', 'f', 'false', 'off', ]
return str(text).lower() in ['0', 'n', 'no', 'none', 'f', 'false', 'off', ]
def str2int(text, default=None):
@@ -185,8 +179,7 @@ def is_bool(text):
return True
elif str2bool(text, False):
return True
else:
return False
return False
def isNull(text):
@@ -333,12 +326,11 @@ def MakeBarcode(cls_name, object_pk: int, object_data=None, **kwargs):
object_type: string describing the object type e.g. 'StockItem'
object_id: ID (Primary Key) of the object in the database
object_url: url for JSON API detail view of the object
data: Python dict object containing extra datawhich will be rendered to string (must only contain stringable values)
data: Python dict object containing extra data which will be rendered to string (must only contain stringable values)
Returns:
json string of the supplied data plus some other data
"""
if object_data is None:
object_data = {}
@@ -387,13 +379,13 @@ def DownloadFile(data, filename, content_type='application/text', inline=False)
filename = WrapWithQuotes(filename)
length = len(data)
if type(data) == str:
if isinstance(data, str):
wrapper = FileWrapper(io.StringIO(data))
else:
wrapper = FileWrapper(io.BytesIO(data))
response = StreamingHttpResponse(wrapper, content_type=content_type)
if type(data) == str:
if isinstance(data, str):
length = len(bytes(data, response.charset))
response['Content-Length'] = length
@@ -415,7 +407,6 @@ def increment_serial_number(serial: str):
Returns:
incremented value, or None if incrementing could not be performed.
"""
from plugin.registry import registry
# Ensure we start with a string value
@@ -441,7 +432,7 @@ def extract_serial_numbers(input_string, expected_quantity: int, starting_value=
- Individual serials are separated by comma: 1, 2, 3, 6,22
- Sequential ranges with provided limits are separated by hyphens: 1-5, 20 - 40
- The "next" available serial number can be specified with the tilde (~) character
- Serial numbers can be supplied as <start>+ for getting all expecteded numbers starting from <start>
- Serial numbers can be supplied as <start>+ for getting all expected numbers starting from <start>
- Serial numbers can be supplied as <start>+<length> for getting <length> numbers starting from <start>
Actual generation of sequential serials is passed to the 'validation' plugin mixin,
@@ -452,7 +443,6 @@ def extract_serial_numbers(input_string, expected_quantity: int, starting_value=
expected_quantity: The number of (unique) serial numbers we expect
starting_value: Provide a starting value for the sequence (or None)
"""
if starting_value is None:
starting_value = increment_serial_number(None)
@@ -530,7 +520,7 @@ def extract_serial_numbers(input_string, expected_quantity: int, starting_value=
if a == b:
# Invalid group
add_error(_("Invalid group range: {g}").format(g=group))
add_error(_(f"Invalid group range: {group}"))
continue
group_items = []
@@ -559,13 +549,13 @@ def extract_serial_numbers(input_string, expected_quantity: int, starting_value=
break
if len(group_items) > remaining:
add_error(_("Group range {g} exceeds allowed quantity ({q})".format(g=group, q=expected_quantity)))
add_error(_(f"Group range {group} exceeds allowed quantity ({expected_quantity})"))
elif len(group_items) > 0 and group_items[0] == a and group_items[-1] == b:
# In this case, the range extraction looks like it has worked
for item in group_items:
add_serial(item)
else:
add_error(_("Invalid group range: {g}").format(g=group))
add_error(_(f"Invalid group range: {group}"))
else:
# In the case of a different number of hyphens, simply add the entire group
@@ -583,14 +573,14 @@ def extract_serial_numbers(input_string, expected_quantity: int, starting_value=
sequence_count = max(0, expected_quantity - len(serials))
if len(items) > 2 or len(items) == 0:
add_error(_("Invalid group sequence: {g}").format(g=group))
add_error(_(f"Invalid group sequence: {group}"))
continue
elif len(items) == 2:
try:
if items[1]:
sequence_count = int(items[1]) + 1
except ValueError:
add_error(_("Invalid group sequence: {g}").format(g=group))
add_error(_(f"Invalid group sequence: {group}"))
continue
value = items[0]
@@ -605,7 +595,7 @@ def extract_serial_numbers(input_string, expected_quantity: int, starting_value=
for item in sequence_items:
add_serial(item)
else:
add_error(_("Invalid group sequence: {g}").format(g=group))
add_error(_(f"Invalid group sequence: {group}"))
else:
# At this point, we assume that the 'group' is just a single serial value
@@ -618,7 +608,7 @@ def extract_serial_numbers(input_string, expected_quantity: int, starting_value=
raise ValidationError([_("No serial numbers found")])
if len(errors) == 0 and len(serials) != expected_quantity:
raise ValidationError([_("Number of unique serial numbers ({s}) must match quantity ({q})").format(s=len(serials), q=expected_quantity)])
raise ValidationError([_(f"Number of unique serial numbers ({len(serials)}) must match quantity ({expected_quantity})")])
return serials
@@ -656,7 +646,7 @@ def validateFilterString(value, model=None):
if len(pair) != 2:
raise ValidationError(
"Invalid group: {g}".format(g=group)
f"Invalid group: {group}"
)
k, v = pair
@@ -666,7 +656,7 @@ def validateFilterString(value, model=None):
if not k or not v:
raise ValidationError(
"Invalid group: {g}".format(g=group)
f"Invalid group: {group}"
)
results[k] = v
@@ -724,7 +714,6 @@ def strip_html_tags(value: str, raise_error=True, field_name=None):
If raise_error is True, a ValidationError will be thrown if HTML tags are detected
"""
cleaned = clean(
value,
strip=True,
@@ -756,7 +745,6 @@ def strip_html_tags(value: str, raise_error=True, field_name=None):
def remove_non_printable_characters(value: str, remove_newline=True, remove_ascii=True, remove_unicode=True):
"""Remove non-printable / control characters from the provided string"""
cleaned = value
if remove_ascii:
@@ -787,7 +775,6 @@ def hash_barcode(barcode_data):
We first remove any non-printable characters from the barcode data,
as some browsers have issues scanning characters in.
"""
barcode_data = str(barcode_data).strip()
barcode_data = remove_non_printable_characters(barcode_data)
@@ -813,7 +800,6 @@ def get_objectreference(obj, type_ref: str = 'content_type', object_ref: str = '
The method name must always be the name of the field prefixed by 'get_'
"""
model_cls = getattr(obj, type_ref)
obj_id = getattr(obj, object_ref)

View File

@@ -3,13 +3,13 @@
import io
import logging
from decimal import Decimal
from urllib.parse import urljoin
from django.conf import settings
from django.core.validators import URLValidator
from django.db.utils import OperationalError, ProgrammingError
from django.utils.translation import gettext_lazy as _
import moneyed.localization
import requests
from djmoney.contrib.exchange.models import convert_money
from djmoney.money import Money
@@ -21,6 +21,7 @@ import InvenTree.helpers_model
import InvenTree.version
from common.notifications import (InvenTreeNotificationBodies,
NotificationBody, trigger_notification)
from InvenTree.format import format_money
logger = logging.getLogger('inventree')
@@ -40,7 +41,6 @@ def construct_absolute_url(*arg, **kwargs):
2. If the InvenTree setting INVENTREE_BASE_URL is set, use that
3. Otherwise, use the current request URL (if available)
"""
relative_url = '/'.join(arg)
# If a site URL is provided, use that
@@ -50,9 +50,7 @@ def construct_absolute_url(*arg, **kwargs):
# Otherwise, try to use the InvenTree setting
try:
site_url = common.models.InvenTreeSetting.get_setting('INVENTREE_BASE_URL', create=False, cache=False)
except ProgrammingError:
pass
except OperationalError:
except (ProgrammingError, OperationalError):
pass
if not site_url:
@@ -66,14 +64,7 @@ def construct_absolute_url(*arg, **kwargs):
# No site URL available, return the relative URL
return relative_url
# Strip trailing slash from base url
if site_url.endswith('/'):
site_url = site_url[:-1]
if relative_url.startswith('/'):
relative_url = relative_url[1:]
return f"{site_url}/{relative_url}"
return urljoin(site_url, relative_url)
def get_base_url(**kwargs):
@@ -104,7 +95,6 @@ def download_image_from_url(remote_url, timeout=2.5):
ValueError: Server responded with invalid 'Content-Length' value
TypeError: Response is not a valid image
"""
# Check that the provided URL at least looks valid
validator = URLValidator()
validator(remote_url)
@@ -177,18 +167,16 @@ def download_image_from_url(remote_url, timeout=2.5):
return img
def render_currency(money, decimal_places=None, currency=None, include_symbol=True, min_decimal_places=None, max_decimal_places=None):
def render_currency(money, decimal_places=None, currency=None, min_decimal_places=None, max_decimal_places=None):
"""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
include_symbol: Render with the appropriate currency symbol
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.
"""
if money in [None, '']:
return '-'
@@ -227,11 +215,7 @@ def render_currency(money, decimal_places=None, currency=None, include_symbol=Tr
decimal_places = max(decimal_places, max_decimal_places)
return moneyed.localization.format_money(
money,
decimal_places=decimal_places,
include_symbol=include_symbol,
)
return format_money(money, decimal_places=decimal_places)
def getModelsWithMixin(mixin_class) -> list:
@@ -242,7 +226,6 @@ def getModelsWithMixin(mixin_class) -> list:
Returns:
List of models that inherit from the given mixin class
"""
from django.contrib.contenttypes.models import ContentType
db_models = [x.model_class() for x in ContentType.objects.all() if x is not None]
@@ -262,32 +245,49 @@ def notify_responsible(instance, sender, content: NotificationBody = InvenTreeNo
content (NotificationBody, optional): _description_. Defaults to InvenTreeNotificationBodies.NewOrder.
exclude (User, optional): User instance that should be excluded. Defaults to None.
"""
if instance.responsible is not None:
# Setup context for notification parsing
content_context = {
'instance': str(instance),
'verbose_name': sender._meta.verbose_name,
'app_label': sender._meta.app_label,
'model_name': sender._meta.model_name,
}
notify_users([instance.responsible], instance, sender, content=content, exclude=exclude)
# Setup notification context
context = {
'instance': instance,
'name': content.name.format(**content_context),
'message': content.message.format(**content_context),
'link': InvenTree.helpers_model.construct_absolute_url(instance.get_absolute_url()),
'template': {
'html': content.template.format(**content_context),
'subject': content.name.format(**content_context),
}
}
# Create notification
trigger_notification(
instance,
content.slug.format(**content_context),
targets=[instance.responsible],
target_exclude=[exclude],
context=context,
)
def notify_users(users, instance, sender, content: NotificationBody = InvenTreeNotificationBodies.NewOrder, exclude=None):
"""Notify all passed users or groups.
Parses the supplied content with the provided instance and sender and sends a notification to all users,
excluding the optional excluded list.
Args:
users: List of users or groups to notify
instance: The newly created instance
sender: Sender model reference
content (NotificationBody, optional): _description_. Defaults to InvenTreeNotificationBodies.NewOrder.
exclude (User, optional): User instance that should be excluded. Defaults to None.
"""
# Setup context for notification parsing
content_context = {
'instance': str(instance),
'verbose_name': sender._meta.verbose_name,
'app_label': sender._meta.app_label,
'model_name': sender._meta.model_name,
}
# Setup notification context
context = {
'instance': instance,
'name': content.name.format(**content_context),
'message': content.message.format(**content_context),
'link': InvenTree.helpers_model.construct_absolute_url(instance.get_absolute_url()),
'template': {
'subject': content.name.format(**content_context),
}
}
if content.template:
context['template']['html'] = content.template.format(**content_context)
# Create notification
trigger_notification(
instance,
content.slug.format(**content_context),
targets=users,
target_exclude=[exclude],
context=context,
)

View File

@@ -0,0 +1,75 @@
"""Functions for magic login."""
from django.conf import settings
from django.contrib.auth.models import User
from django.contrib.sites.models import Site
from django.core.mail import send_mail
from django.template.loader import render_to_string
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
import sesame.utils
from rest_framework import serializers
from rest_framework.response import Response
from rest_framework.views import APIView
def send_simple_login_email(user, link):
"""Send an email with the login link to this user."""
site = Site.objects.get_current()
context = {
"username": user.username,
"site_name": site.name,
"link": link,
}
email_plaintext_message = render_to_string("InvenTree/user_simple_login.txt", context)
send_mail(
_(f"[{site.name}] Log in to the app"),
email_plaintext_message,
settings.DEFAULT_FROM_EMAIL,
[user.email],
)
class GetSimpleLoginSerializer(serializers.Serializer):
"""Serializer for the simple login view."""
email = serializers.CharField(label=_("Email"))
class GetSimpleLoginView(APIView):
"""View to send a simple login link."""
permission_classes = ()
serializer_class = GetSimpleLoginSerializer
def post(self, request, *args, **kwargs):
"""Get the token for the current user or fail."""
serializer = self.serializer_class(data=request.data)
serializer.is_valid(raise_exception=True)
self.email_submitted(email=serializer.data["email"])
return Response({"status": "ok"})
def email_submitted(self, email):
"""Notify user about link."""
user = self.get_user(email)
if user is None:
print("user not found:", email)
return
link = self.create_link(user)
send_simple_login_email(user, link)
def get_user(self, email):
"""Find the user with this email address."""
try:
return User.objects.get(email=email)
except User.DoesNotExist:
return None
def create_link(self, user):
"""Create a login link for this user."""
link = reverse("sesame-login")
link = self.request.build_absolute_uri(link)
link += sesame.utils.get_query_string(user)
return link

View File

@@ -23,7 +23,7 @@ class Command(BaseCommand):
for setting in db_settings:
if setting.key not in model_settings:
setting.delete()
logger.info(f"deleted setting '{setting.key}'")
logger.info("deleted setting '%s'", setting.key)
# user settings
db_settings = InvenTreeUserSetting.objects.all()
@@ -33,6 +33,6 @@ class Command(BaseCommand):
for setting in db_settings:
if setting.key not in model_settings:
setting.delete()
logger.info(f"deleted user setting '{setting.key}'")
logger.info("deleted user setting '%s'", setting.key)
logger.info("checked all settings")

View File

@@ -12,7 +12,6 @@ from django.utils.translation import override as lang_over
def render_file(file_name, source, target, locales, ctx):
"""Renders a file into all provided locales."""
for locale in locales:
# Enforce lower-case for locale names

View File

@@ -26,14 +26,14 @@ class Command(BaseCommand):
img = model.image
logger.info(f"Generating thumbnail image for '{img}'")
logger.info("Generating thumbnail image for '%s'", img)
try:
model.image.render_variations(replace=False)
except FileNotFoundError:
logger.warning(f"Warning: Image file '{img}' is missing")
logger.warning("Warning: Image file '%s' is missing", img)
except UnidentifiedImageError:
logger.warning(f"Warning: Image file '{img}' is not a valid image")
logger.warning("Warning: Image file '%s' is not a valid image", img)
def handle(self, *args, **kwargs):
"""Rebuild all thumbnail images."""
@@ -43,7 +43,7 @@ class Command(BaseCommand):
try:
self.rebuild_thumbnail(part)
except (OperationalError, ProgrammingError):
logger.error("ERROR: Database read error.")
logger.exception("ERROR: Database read error.")
break
logger.info("Rebuilding Company thumbnails")
@@ -52,5 +52,5 @@ class Command(BaseCommand):
try:
self.rebuild_thumbnail(company)
except (OperationalError, ProgrammingError):
logger.error("ERROR: abase read error.")
logger.exception("ERROR: abase read error.")
break

View File

@@ -18,7 +18,7 @@ class Command(BaseCommand):
while not connected:
time.sleep(5)
time.sleep(2)
try:
connection.ensure_connection()

View File

@@ -10,6 +10,7 @@ from rest_framework.utils import model_meta
import InvenTree.permissions
import users.models
from InvenTree.helpers import str2bool
from InvenTree.serializers import DependentField
logger = logging.getLogger('inventree')
@@ -84,6 +85,10 @@ class InvenTreeMetadata(SimpleMetadata):
'DELETE': 'delete',
}
# let the view define a custom rolemap
if hasattr(view, "rolemap"):
rolemap.update(view.rolemap)
# Remove any HTTP methods that the user does not have permission for
for method, permission in rolemap.items():
@@ -238,6 +243,10 @@ class InvenTreeMetadata(SimpleMetadata):
We take the regular DRF metadata and add our own unique flavor
"""
# Try to add the child property to the dependent field to be used by the super call
if self.label_lookup[field] == 'dependent field':
field.get_child(raise_exception=True)
# Run super method first
field_info = super().get_field_info(field)
@@ -271,4 +280,11 @@ class InvenTreeMetadata(SimpleMetadata):
else:
field_info['api_url'] = model.get_api_url()
# Add more metadata about dependent fields
if field_info['type'] == 'dependent field':
field_info['depends_on'] = field.depends_on
return field_info
InvenTreeMetadata.label_lookup[DependentField] = "dependent field"

View File

@@ -12,9 +12,9 @@ from django.urls import Resolver404, include, re_path, resolve, reverse_lazy
from allauth_2fa.middleware import (AllauthTwoFactorMiddleware,
BaseRequire2FAMiddleware)
from error_report.middleware import ExceptionProcessor
from rest_framework.authtoken.models import Token
from InvenTree.urls import frontendpatterns
from users.models import ApiToken
logger = logging.getLogger("inventree")
@@ -64,6 +64,9 @@ class AuthRequiredMiddleware(object):
elif request.path_info.startswith('/accounts/'):
authorized = True
elif request.path_info.startswith(f'/{settings.FRONTEND_URL_BASE}/') or request.path_info.startswith('/assets/') or request.path_info == f'/{settings.FRONTEND_URL_BASE}':
authorized = True
elif 'Authorization' in request.headers.keys() or 'authorization' in request.headers.keys():
auth = request.headers.get('Authorization', request.headers.get('authorization')).strip()
@@ -72,14 +75,16 @@ class AuthRequiredMiddleware(object):
# Does the provided token match a valid user?
try:
token = Token.objects.get(key=token_key)
token = ApiToken.objects.get(key=token_key)
# Provide the user information to the request
request.user = token.user
authorized = True
if token.active and token.user:
except Token.DoesNotExist:
logger.warning(f"Access denied for unknown token {token_key}")
# Provide the user information to the request
request.user = token.user
authorized = True
except ApiToken.DoesNotExist:
logger.warning("Access denied for unknown token %s", token_key)
# No authorization was found for the request
if not authorized:
@@ -105,10 +110,8 @@ class AuthRequiredMiddleware(object):
# Save the 'next' parameter to pass through to the login view
return redirect(f'{reverse_lazy("account_login")}?next={request.path}')
else:
# Return a 401 (Unauthorized) response code for this request
return HttpResponse('Unauthorized', status=401)
# Return a 401 (Unauthorized) response code for this request
return HttpResponse('Unauthorized', status=401)
response = self.get_response(request)
@@ -122,7 +125,6 @@ class Check2FAMiddleware(BaseRequire2FAMiddleware):
"""Check if user is required to have MFA enabled."""
def require_2fa(self, request):
"""Use setting to check if MFA should be enforced for frontend page."""
from common.models import InvenTreeSetting
try:

View File

@@ -9,6 +9,59 @@ from InvenTree.fields import InvenTreeNotesField
from InvenTree.helpers import remove_non_printable_characters, strip_html_tags
class DiffMixin:
"""Mixin which can be used to determine which fields have changed, compared to the instance saved to the database."""
def get_db_instance(self):
"""Return the instance of the object saved in the database.
Returns:
object: Instance of the object saved in the database
"""
if self.pk:
try:
return self.__class__.objects.get(pk=self.pk)
except self.__class__.DoesNotExist:
pass
return None
def get_field_deltas(self):
"""Return a dict of field deltas.
Compares the current instance with the instance saved in the database,
and returns a dict of fields which have changed.
Returns:
dict: Dict of field deltas
"""
db_instance = self.get_db_instance()
if db_instance is None:
return {}
deltas = {}
for field in self._meta.fields:
if field.name == 'id':
continue
if getattr(self, field.name) != getattr(db_instance, field.name):
deltas[field.name] = {
'old': getattr(db_instance, field.name),
'new': getattr(self, field.name),
}
return deltas
def has_field_changed(self, field_name):
"""Determine if a particular field has changed."""
return field_name in self.get_field_deltas()
class CleanMixin():
"""Model mixin class which cleans inputs using the Mozilla bleach tools."""
@@ -49,7 +102,6 @@ class CleanMixin():
Ref: https://github.com/mozilla/bleach/issues/192
"""
cleaned = strip_html_tags(data, field_name=field)
# By default, newline characters are removed
@@ -88,12 +140,11 @@ class CleanMixin():
`ugly`. Prevents XSS on the server-level.
Args:
data (dict): Data that should be sanatized.
data (dict): Data that should be Sanitized.
Returns:
dict: Provided data sanatized; still in the same order.
dict: Provided data Sanitized; still in the same order.
"""
clean_data = {}
for k, v in data.items():

View File

@@ -12,7 +12,7 @@ from django.contrib.auth.models import User
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError
from django.db import models
from django.db.models.signals import post_save, pre_delete
from django.db.models.signals import post_save
from django.dispatch import receiver
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
@@ -73,7 +73,6 @@ class MetadataMixin(models.Model):
def validate_metadata(self):
"""Validate the metadata field."""
# Ensure that the 'metadata' field is a valid dict object
if self.metadata is None:
self.metadata = {}
@@ -202,7 +201,6 @@ class ReferenceIndexingMixin(models.Model):
This is defined by a global setting object, specified by the REFERENCE_PATTERN_SETTING attribute
"""
# By default, we return an empty string
if cls.REFERENCE_PATTERN_SETTING is None:
return ''
@@ -218,7 +216,6 @@ class ReferenceIndexingMixin(models.Model):
- Returns a python dict object which contains the context data for formatting the reference string.
- The default implementation provides some default context information
"""
return {
'ref': cls.get_next_reference(),
'date': datetime.now(),
@@ -230,18 +227,15 @@ class ReferenceIndexingMixin(models.Model):
In practice, this means the item with the highest reference value
"""
query = cls.objects.all().order_by('-reference_int', '-pk')
if query.exists():
return query.first()
else:
return None
return None
@classmethod
def get_next_reference(cls):
"""Return the next available reference value for this particular class."""
# Find the "most recent" item
latest = cls.get_most_recent_item()
@@ -270,7 +264,6 @@ class ReferenceIndexingMixin(models.Model):
@classmethod
def generate_reference(cls):
"""Generate the next 'reference' field based on specified pattern"""
fmt = cls.get_reference_pattern()
ctx = cls.get_reference_context()
@@ -310,7 +303,6 @@ class ReferenceIndexingMixin(models.Model):
@classmethod
def validate_reference_pattern(cls, pattern):
"""Ensure that the provided pattern is valid"""
ctx = cls.get_reference_context()
try:
@@ -336,7 +328,6 @@ class ReferenceIndexingMixin(models.Model):
@classmethod
def validate_reference_field(cls, value):
"""Check that the provided 'reference' value matches the requisite pattern"""
pattern = cls.get_reference_pattern()
value = str(value).strip()
@@ -368,7 +359,6 @@ class ReferenceIndexingMixin(models.Model):
If we cannot extract using the pattern for some reason, fallback to the entire reference
"""
try:
# Extract named group based on provided pattern
reference = InvenTree.format.extract_named_group('ref', reference, cls.get_reference_pattern())
@@ -390,7 +380,6 @@ class ReferenceIndexingMixin(models.Model):
def extract_int(reference, clip=0x7fffffff, allow_negative=False):
"""Extract an integer out of reference."""
# Default value if we cannot convert to an integer
ref_int = 0
@@ -440,7 +429,8 @@ class InvenTreeAttachment(models.Model):
An attachment can be either an uploaded file, or an external URL
Attributes:
attachment: File
attachment: Upload file
link: External URL
comment: String descriptor for the attachment
user: User associated with file upload
upload_date: Date the file was uploaded
@@ -480,8 +470,7 @@ class InvenTreeAttachment(models.Model):
"""Human name for attachment."""
if self.attachment is not None:
return os.path.basename(self.attachment.name)
else:
return str(self.link)
return str(self.link)
attachment = models.FileField(upload_to=rename_attachment, verbose_name=_('Attachment'),
help_text=_('Select file to attach'),
@@ -511,8 +500,7 @@ class InvenTreeAttachment(models.Model):
"""Base name/path for attachment."""
if self.attachment:
return os.path.basename(self.attachment.name)
else:
return None
return None
@basename.setter
def basename(self, fn):
@@ -534,7 +522,7 @@ class InvenTreeAttachment(models.Model):
# Check that there are no directory tricks going on...
if new_file.parent != attachment_dir:
logger.error(f"Attempted to rename attachment outside valid directory: '{new_file}'")
logger.error("Attempted to rename attachment outside valid directory: '%s'", new_file)
raise ValidationError(_("Invalid attachment directory"))
# Ignore further checks if the filename is not actually being renamed
@@ -551,7 +539,7 @@ class InvenTreeAttachment(models.Model):
raise ValidationError(_("Filename missing extension"))
if not old_file.exists():
logger.error(f"Trying to rename attachment '{old_file}' which does not exist")
logger.error("Trying to rename attachment '%s' which does not exist", old_file)
return
if new_file.exists():
@@ -564,6 +552,21 @@ class InvenTreeAttachment(models.Model):
except Exception:
raise ValidationError(_("Error renaming file"))
def fully_qualified_url(self):
"""Return a 'fully qualified' URL for this attachment.
- If the attachment is a link to an external resource, return the link
- If the attachment is an uploaded file, return the fully qualified media URL
"""
if self.link:
return self.link
if self.attachment:
media_url = InvenTree.helpers.getMediaUrl(self.attachment.url)
return InvenTree.helpers_model.construct_absolute_url(media_url)
return ''
class InvenTreeTree(MPTTModel):
"""Provides an abstracted self-referencing tree model for data categories.
@@ -577,6 +580,10 @@ class InvenTreeTree(MPTTModel):
parent: The item immediately above this one. An item with a null parent is a top-level item
"""
# How items (not nodes) are hooked into the tree
# e.g. for StockLocation, this value is 'location'
ITEM_PARENT_KEY = None
class Meta:
"""Metaclass defines extra model properties."""
abstract = True
@@ -585,13 +592,130 @@ class InvenTreeTree(MPTTModel):
"""Set insert order."""
order_insertion_by = ['name']
def delete(self, delete_children=False, delete_items=False):
"""Handle the deletion of a tree node.
1. Update nodes and items under the current node
2. Delete this node
3. Rebuild the model tree
4. Rebuild the path for any remaining lower nodes
"""
tree_id = self.tree_id if self.parent else None
# Ensure that we have the latest version of the database object
try:
self.refresh_from_db()
except self.__class__.DoesNotExist:
# If the object no longer exists, raise a ValidationError
raise ValidationError("Object %s of type %s no longer exists", str(self), str(self.__class__))
# Cache node ID values for lower nodes, before we delete this one
lower_nodes = list(self.get_descendants(include_self=False).values_list('pk', flat=True))
# 1. Update nodes and items under the current node
self.handle_tree_delete(delete_children=delete_children, delete_items=delete_items)
# 2. Delete *this* node
super().delete()
# 3. Update the tree structure
if tree_id:
self.__class__.objects.partial_rebuild(tree_id)
else:
self.__class__.objects.rebuild()
# 4. Rebuild the path for any remaining lower nodes
nodes = self.__class__.objects.filter(pk__in=lower_nodes)
nodes_to_update = []
for node in nodes:
new_path = node.construct_pathstring()
if new_path != node.pathstring:
node.pathstring = new_path
nodes_to_update.append(node)
if len(nodes_to_update) > 0:
self.__class__.objects.bulk_update(nodes_to_update, ['pathstring'])
def handle_tree_delete(self, delete_children=False, delete_items=False):
"""Delete a single instance of the tree, based on provided kwargs.
Removing a tree "node" from the database must be considered carefully,
based on what the user intends for any items which exist *under* that node.
- "children" are any nodes which exist *under* this node (e.g. PartCategory)
- "items" are any items which exist *under* this node (e.g. Part)
Arguments:
delete_children: If True, delete all child items
delete_items: If True, delete all items associated with this node
There are multiple scenarios we can consider here:
A) delete_children = True and delete_items = True
B) delete_children = True and delete_items = False
C) delete_children = False and delete_items = True
D) delete_children = False and delete_items = False
"""
child_nodes = self.get_descendants(include_self=False)
# Case A: Delete all child items, and all child nodes.
# - Delete all items at any lower level
# - Delete all descendant nodes
if delete_children and delete_items:
self.get_items(cascade=True).delete()
self.delete_nodes(child_nodes)
# Case B: Delete all child nodes, but move all child items up to the parent
# - Move all items at any lower level to the parent of this item
# - Delete all descendant nodes
elif delete_children and not delete_items:
self.get_items(cascade=True).update(**{
self.ITEM_PARENT_KEY: self.parent
})
self.delete_nodes(child_nodes)
# Case C: Delete all child items, but keep all child nodes
# - Remove all items directly associated with this node
# - Move any direct child nodes up one level
elif not delete_children and delete_items:
self.get_items(cascade=False).delete()
self.get_children().update(parent=self.parent)
# Case D: Keep all child items, and keep all child nodes
# - Move all items directly associated with this node up one level
# - Move any direct child nodes up one level
elif not delete_children and not delete_items:
self.get_items(cascade=False).update(**{
self.ITEM_PARENT_KEY: self.parent
})
self.get_children().update(parent=self.parent)
def delete_nodes(self, nodes):
"""Delete a set of nodes from the tree.
1. First, set the "parent" value for selected nodes to None
2. Then, perform bulk deletion of selected nodes
Step 1. is required because we cannot guarantee the order-of-operations in the db backend
Arguments:
nodes: A queryset of nodes to delete
"""
nodes.update(parent=None)
nodes.delete()
def validate_unique(self, exclude=None):
"""Validate that this tree instance satisfies our uniqueness requirements.
Note that a 'unique_together' requirement for ('name', 'parent') is insufficient,
as it ignores cases where parent=None (i.e. top-level items)
"""
super().validate_unique(exclude)
results = self.__class__.objects.filter(
@@ -612,9 +736,14 @@ class InvenTreeTree(MPTTModel):
}
}
def construct_pathstring(self):
"""Construct the pathstring for this tree node"""
return InvenTree.helpers.constructPathString(
[item.name for item in self.path]
)
def save(self, *args, **kwargs):
"""Custom save method for InvenTreeTree abstract model"""
try:
super().save(*args, **kwargs)
except InvalidMove:
@@ -624,9 +753,7 @@ class InvenTreeTree(MPTTModel):
})
# Re-calculate the 'pathstring' field
pathstring = InvenTree.helpers.constructPathString(
[item.name for item in self.path]
)
pathstring = self.construct_pathstring()
if pathstring != self.pathstring:
@@ -638,9 +765,20 @@ class InvenTreeTree(MPTTModel):
self.pathstring = pathstring
super().save(*args, **kwargs)
# Ensure that the pathstring changes are propagated down the tree also
for child in self.get_children():
child.save(*args, **kwargs)
# Update the pathstring for any child nodes
lower_nodes = self.get_descendants(include_self=False)
nodes_to_update = []
for node in lower_nodes:
new_path = node.construct_pathstring()
if new_path != node.pathstring:
node.pathstring = new_path
nodes_to_update.append(node)
if len(nodes_to_update) > 0:
self.__class__.objects.bulk_update(nodes_to_update, ['pathstring'])
name = models.CharField(
blank=False,
@@ -672,16 +810,15 @@ class InvenTreeTree(MPTTModel):
help_text=_('Path')
)
@property
def item_count(self):
"""Return the number of items which exist *under* this node in the tree.
def get_items(self, cascade=False):
"""Return a queryset of items which exist *under* this node in the tree.
Here an 'item' is considered to be the 'leaf' at the end of each branch,
and the exact nature here will depend on the class implementation.
- For a StockLocation instance, this would be a queryset of StockItem objects
- For a PartCategory instance, this would be a queryset of Part objects
The default implementation returns zero
The default implementation returns an empty list
"""
return 0
raise NotImplementedError(f"items() method not implemented for {type(self)}")
def getUniqueParents(self):
"""Return a flat set of all parent items that exist above this node.
@@ -742,9 +879,26 @@ class InvenTreeTree(MPTTModel):
"""
return self.parentpath + [self]
def get_path(self):
"""Return a list of element in the item tree.
Contains the full path to this item, with each entry containing the following data:
{
pk: <pk>,
name: <name>,
}
"""
return [
{
'pk': item.pk,
'name': item.name
} for item in self.path
]
def __str__(self):
"""String representation of a category is the full path to that category."""
return "{path} - {desc}".format(path=self.pathstring, desc=self.description)
return f"{self.pathstring} - {self.description}"
class InvenTreeNotesMixin(models.Model):
@@ -804,13 +958,11 @@ class InvenTreeBarcodeMixin(models.Model):
@classmethod
def barcode_model_type(cls):
"""Return the model 'type' for creating a custom QR code."""
# By default, use the name of the class
return cls.__name__.lower()
def format_barcode(self, **kwargs):
"""Return a JSON string for formatting a QR code for this model instance."""
return InvenTree.helpers.MakeBarcode(
self.__class__.barcode_model_type(),
self.pk,
@@ -820,18 +972,15 @@ class InvenTreeBarcodeMixin(models.Model):
@property
def barcode(self):
"""Format a minimal barcode string (e.g. for label printing)"""
return self.format_barcode(brief=True)
@classmethod
def lookup_barcode(cls, barcode_hash):
"""Check if a model instance exists with the specified third-party barcode hash."""
return cls.objects.filter(barcode_hash=barcode_hash).first()
def assign_barcode(self, barcode_hash=None, barcode_data=None, raise_error=True, save=True):
"""Assign an external (third-party) barcode to this object."""
# Must provide either barcode_hash or barcode_data
if barcode_hash is None and barcode_data is None:
raise ValueError("Provide either 'barcode_hash' or 'barcode_data'")
@@ -859,34 +1008,21 @@ class InvenTreeBarcodeMixin(models.Model):
def unassign_barcode(self):
"""Unassign custom barcode from this model"""
self.barcode_data = ''
self.barcode_hash = ''
self.save()
@receiver(pre_delete, sender=InvenTreeTree, dispatch_uid='tree_pre_delete_log')
def before_delete_tree_item(sender, instance, using, **kwargs):
"""Receives pre_delete signal from InvenTreeTree object.
Before an item is deleted, update each child object to point to the parent of the object being deleted.
"""
# Update each tree item below this one
for child in instance.children.all():
child.parent = instance.parent
child.save()
@receiver(post_save, sender=Error, dispatch_uid='error_post_save_notification')
def after_error_logged(sender, instance: Error, created: bool, **kwargs):
"""Callback when a server error is logged.
- Send a UI notification to all users with staff status
"""
if created:
try:
import common.models
import common.notifications
users = get_user_model().objects.filter(is_staff=True)
@@ -902,14 +1038,21 @@ def after_error_logged(sender, instance: Error, created: bool, **kwargs):
'link': link
}
common.notifications.trigger_notification(
instance,
'inventree.error_log',
context=context,
targets=users,
delivery_methods={common.notifications.UIMessageNotification, },
)
target_users = []
for user in users:
if common.models.InvenTreeUserSetting.get_setting('NOTIFICATION_ERROR_REPORT', True, user=user):
target_users.append(user)
if len(target_users) > 0:
common.notifications.trigger_notification(
instance,
'inventree.error_log',
context=context,
targets=target_users,
delivery_methods={common.notifications.UIMessageNotification, },
)
except Exception as exc:
"""We do not want to throw an exception while reporting an exception"""
logger.error(exc)
logger.error(exc) # noqa: LOG005

View File

@@ -9,7 +9,6 @@ import users.models
def get_model_for_view(view, raise_error=True):
"""Attempt to introspect the 'model' type for an API view"""
if hasattr(view, 'get_permission_model'):
return view.get_permission_model()
@@ -62,6 +61,10 @@ class RolePermission(permissions.BasePermission):
'DELETE': 'delete',
}
# let the view define a custom rolemap
if hasattr(view, "rolemap"):
rolemap.update(view.rolemap)
permission = rolemap[request.method]
# The required role may be defined for the view class

View File

@@ -1,5 +1,6 @@
"""Functions to check if certain parts of InvenTree are ready."""
import os
import sys
@@ -18,6 +19,18 @@ def isRunningMigrations():
return 'migrate' in sys.argv or 'makemigrations' in sys.argv
def isInMainThread():
"""Django runserver starts two processes, one for the actual dev server and the other to reload the application.
- The RUN_MAIN env is set in that case. However if --noreload is applied, this variable
is not set because there are no different threads.
"""
if "runserver" in sys.argv and "--noreload" not in sys.argv:
return os.environ.get('RUN_MAIN', None) == "true"
return True
def canAppAccessDatabase(allow_test: bool = False, allow_plugins: bool = False, allow_shell: bool = False):
"""Returns True if the apps.py file can access database records.
@@ -65,3 +78,19 @@ def canAppAccessDatabase(allow_test: bool = False, allow_plugins: bool = False,
return False
return True
def isPluginRegistryLoaded():
"""Ensures that the plugin registry is already loaded.
The plugin registry reloads all apps onetime after starting if there are AppMixin plugins,
so that the discovered AppConfigs are added to Django. This triggers the ready function of
AppConfig to execute twice. Add this check to prevent from running two times.
Note: All apps using this check need to be registered after the plugins app in settings.py
Returns: 'False' if the registry has not fully loaded the plugins yet.
"""
from plugin import registry
return registry.plugins_loaded

View File

@@ -44,7 +44,7 @@ ALLOWED_ATTRIBUTES_SVG = [
def sanitize_svg(file_data, strip: bool = True, elements: str = ALLOWED_ELEMENTS_SVG, attributes: str = ALLOWED_ATTRIBUTES_SVG) -> str:
"""Sanatize a SVG file.
"""Sanitize a SVG file.
Args:
file_data (str): SVG as string.
@@ -55,9 +55,8 @@ def sanitize_svg(file_data, strip: bool = True, elements: str = ALLOWED_ELEMENTS
Returns:
str: Sanitzied SVG file.
"""
# Handle byte-encoded data
if type(file_data) == bytes:
if isinstance(file_data, bytes):
file_data = file_data.decode('utf-8')
cleaned = clean(

View File

@@ -10,14 +10,13 @@ import rest_framework.exceptions
import sentry_sdk
from sentry_sdk.integrations.django import DjangoIntegration
from InvenTree.version import INVENTREE_SW_VERSION
import InvenTree.version
logger = logging.getLogger('inventree')
def default_sentry_dsn():
"""Return the default Sentry.io DSN for InvenTree"""
return 'https://3928ccdba1d34895abde28031fd00100@o378676.ingest.sentry.io/6494600'
@@ -26,11 +25,11 @@ def sentry_ignore_errors():
These error types will *not* be reported to sentry.io.
"""
return [
Http404,
ValidationError,
rest_framework.exceptions.AuthenticationFailed,
rest_framework.exceptions.NotAuthenticated,
rest_framework.exceptions.PermissionDenied,
rest_framework.exceptions.ValidationError,
]
@@ -38,7 +37,6 @@ def sentry_ignore_errors():
def init_sentry(dsn, sample_rate, tags):
"""Initialize sentry.io error reporting"""
logger.info("Initializing sentry.io integration")
sentry_sdk.init(
@@ -47,20 +45,26 @@ def init_sentry(dsn, sample_rate, tags):
traces_sample_rate=sample_rate,
send_default_pii=True,
ignore_errors=sentry_ignore_errors(),
release=INVENTREE_SW_VERSION,
release=InvenTree.version.INVENTREE_SW_VERSION,
environment='development' if InvenTree.version.isInvenTreeDevelopmentVersion() else 'production'
)
for key, val in tags.items():
sentry_sdk.set_tag(f'inventree_{key}', val)
sentry_sdk.set_tag('api', InvenTree.version.inventreeApiVersion())
sentry_sdk.set_tag('platform', InvenTree.version.inventreePlatform())
sentry_sdk.set_tag('git_branch', InvenTree.version.inventreeBranch())
sentry_sdk.set_tag('git_commit', InvenTree.version.inventreeCommitHash())
sentry_sdk.set_tag('git_date', InvenTree.version.inventreeCommitDate())
def report_exception(exc):
"""Report an exception to sentry.io"""
if settings.SENTRY_ENABLED and settings.SENTRY_DSN:
if not any(isinstance(exc, e) for e in sentry_ignore_errors()):
logger.info(f"Reporting exception to sentry.io: {exc}")
logger.info("Reporting exception to sentry.io: %s", exc)
try:
sentry_sdk.capture_exception(exc)

View File

@@ -2,10 +2,12 @@
import os
from collections import OrderedDict
from copy import deepcopy
from decimal import Decimal
from django.conf import settings
from django.contrib.auth.models import User
from django.contrib.sites.models import Site
from django.core.exceptions import ValidationError as DjangoValidationError
from django.db import models
from django.utils.translation import gettext_lazy as _
@@ -15,7 +17,7 @@ from djmoney.contrib.django_rest_framework.fields import MoneyField
from djmoney.money import Money
from djmoney.utils import MONEY_CLASSES, get_currency_field_name
from rest_framework import serializers
from rest_framework.exceptions import ValidationError
from rest_framework.exceptions import PermissionDenied, ValidationError
from rest_framework.fields import empty
from rest_framework.serializers import DecimalField
from rest_framework.utils import model_meta
@@ -43,7 +45,6 @@ class InvenTreeMoneySerializer(MoneyField):
def get_value(self, data):
"""Test that the returned amount is a valid Decimal."""
amount = super(DecimalField, self).get_value(data)
# Convert an empty string to None
@@ -73,7 +74,6 @@ class InvenTreeCurrencySerializer(serializers.ChoiceField):
def __init__(self, *args, **kwargs):
"""Initialize the currency serializer"""
choices = currency_code_mappings()
allow_blank = kwargs.get('allow_blank', False) or kwargs.get('allow_null', False)
@@ -95,6 +95,93 @@ class InvenTreeCurrencySerializer(serializers.ChoiceField):
super().__init__(*args, **kwargs)
class DependentField(serializers.Field):
"""A dependent field can be used to dynamically return child fields based on the value of other fields."""
child = None
def __init__(self, *args, depends_on, field_serializer, **kwargs):
"""A dependent field can be used to dynamically return child fields based on the value of other fields.
Example:
This example adds two fields. If the client selects integer, an integer field will be shown, but if he
selects char, an char field will be shown. For any other value, nothing will be shown.
class TestSerializer(serializers.Serializer):
select_type = serializers.ChoiceField(choices=[
("integer", "Integer"),
("char", "Char"),
])
my_field = DependentField(depends_on=["select_type"], field_serializer="get_my_field")
def get_my_field(self, fields):
if fields["select_type"] == "integer":
return serializers.IntegerField()
if fields["select_type"] == "char":
return serializers.CharField()
"""
super().__init__(*args, **kwargs)
self.depends_on = depends_on
self.field_serializer = field_serializer
def get_child(self, raise_exception=False):
"""This method tries to extract the child based on the provided data in the request by the client."""
data = deepcopy(self.context["request"].data)
def visit_parent(node):
"""Recursively extract the data for the parent field/serializer in reverse."""
nonlocal data
if node.parent:
visit_parent(node.parent)
# only do for composite fields and stop right before the current field
if hasattr(node, "child") and node is not self and isinstance(data, dict):
data = data.get(node.field_name, None)
visit_parent(self)
# ensure that data is a dictionary and that a parent exists
if not isinstance(data, dict) or self.parent is None:
return
# check if the request data contains the dependent fields, otherwise skip getting the child
for f in self.depends_on:
if not data.get(f, None):
return
# partially validate the data for options requests that set raise_exception while calling .get_child(...)
if raise_exception:
validation_data = {k: v for k, v in data.items() if k in self.depends_on}
serializer = self.parent.__class__(context=self.context, data=validation_data, partial=True)
serializer.is_valid(raise_exception=raise_exception)
# try to get the field serializer
field_serializer = getattr(self.parent, self.field_serializer)
child = field_serializer(data)
if not child:
return
self.child = child
self.child.bind(field_name='', parent=self)
def to_internal_value(self, data):
"""This method tries to convert the data to an internal representation based on the defined to_internal_value method on the child."""
self.get_child()
if self.child:
return self.child.to_internal_value(data)
return None
def to_representation(self, value):
"""This method tries to convert the data to representation based on the defined to_representation method on the child."""
self.get_child()
if self.child:
return self.child.to_representation(value)
return None
class InvenTreeModelSerializer(serializers.ModelSerializer):
"""Inherits the standard Django ModelSerializer class, but also ensures that the underlying model class data are checked on validation."""
@@ -197,7 +284,6 @@ class InvenTreeModelSerializer(serializers.ModelSerializer):
def create(self, validated_data):
"""Custom create method which supports field adjustment"""
initial_data = validated_data.copy()
# Remove any fields which do not exist on the model
@@ -221,7 +307,6 @@ class InvenTreeModelSerializer(serializers.ModelSerializer):
In addition to running validators on the serializer fields,
this class ensures that the underlying model is also validated.
"""
# Run any native validation checks first (may raise a ValidationError)
data = super().run_validation(data)
@@ -298,9 +383,79 @@ class UserSerializer(InvenTreeModelSerializer):
'username',
'first_name',
'last_name',
'email'
'email',
]
read_only_fields = [
'username',
]
class ExendedUserSerializer(UserSerializer):
"""Serializer for a User with a bit more info."""
from users.serializers import GroupSerializer
groups = GroupSerializer(read_only=True, many=True)
class Meta(UserSerializer.Meta):
"""Metaclass defines serializer fields."""
fields = UserSerializer.Meta.fields + [
'groups',
'is_staff',
'is_superuser',
'is_active'
]
read_only_fields = UserSerializer.Meta.read_only_fields + [
'groups',
]
def validate(self, attrs):
"""Expanded validation for changing user role."""
# Check if is_staff or is_superuser is in attrs
role_change = 'is_staff' in attrs or 'is_superuser' in attrs
request_user = self.context['request'].user
if role_change:
if request_user.is_superuser:
# Superusers can change any role
pass
elif request_user.is_staff and 'is_superuser' not in attrs:
# Staff can change any role except is_superuser
pass
else:
raise PermissionDenied(_("You do not have permission to change this user role."))
return super().validate(attrs)
class UserCreateSerializer(ExendedUserSerializer):
"""Serializer for creating a new User."""
def validate(self, attrs):
"""Expanded valiadation for auth."""
# Check that the user trying to create a new user is a superuser
if not self.context['request'].user.is_superuser:
raise serializers.ValidationError(_("Only superusers can create new users"))
# Generate a random password
password = User.objects.make_random_password(length=14)
attrs.update({'password': password})
return super().validate(attrs)
def create(self, validated_data):
"""Send an e email to the user after creation."""
instance = super().create(validated_data)
# Make sure the user cannot login until they have set a password
instance.set_unusable_password()
# Send the user an onboarding email (from current site)
current_site = Site.objects.get_current()
domain = current_site.domain
instance.email_user(
subject=_(f"Welcome to {current_site.name}"),
message=_(f"Your account has been created.\n\nPlease use the password reset function to get access (at https://{domain})."),
)
return instance
class InvenTreeAttachmentSerializerField(serializers.FileField):
"""Override the DRF native FileField serializer, to remove the leading server path.
@@ -701,7 +856,6 @@ class RemoteImageMixin(metaclass=serializers.SerializerMetaclass):
def skip_create_fields(self):
"""Ensure the 'remote_image' field is skipped when creating a new instance"""
return [
'remote_image',
]
@@ -720,7 +874,6 @@ class RemoteImageMixin(metaclass=serializers.SerializerMetaclass):
- Attempt to download the image and store it against this object instance
- Catches and re-throws any errors
"""
if not url:
return

View File

@@ -26,14 +26,16 @@ from dotenv import load_dotenv
from InvenTree.config import get_boolean_setting, get_custom_file, get_setting
from InvenTree.sentry import default_sentry_dsn, init_sentry
from InvenTree.version import inventreeApiVersion
from InvenTree.version import checkMinPythonVersion, inventreeApiVersion
from . import config
checkMinPythonVersion()
INVENTREE_NEWS_URL = 'https://inventree.org/news/feed.atom'
# Determine if we are running in "test" mode e.g. "manage.py test"
TESTING = 'test' in sys.argv
TESTING = 'test' in sys.argv or 'TESTING' in os.environ
if TESTING:
@@ -76,6 +78,9 @@ if version_file.exists():
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = get_boolean_setting('INVENTREE_DEBUG', 'debug', True)
ENABLE_CLASSIC_FRONTEND = get_boolean_setting('INVENTREE_CLASSIC_FRONTEND', 'classic_frontend', True)
ENABLE_PLATFORM_FRONTEND = get_boolean_setting('INVENTREE_PLATFORM_FRONTEND', 'platform_frontend', True)
# Configure logging settings
log_level = get_setting('INVENTREE_LOG_LEVEL', 'log_level', 'WARNING')
@@ -106,6 +111,14 @@ LOGGING = {
},
}
# Optionally add database-level logging
if get_setting('INVENTREE_DB_LOGGING', 'db_logging', False):
LOGGING['loggers'] = {
'django.db.backends': {
'level': log_level or 'DEBUG',
},
}
# Get a logger instance for this setup file
logger = logging.getLogger("inventree")
@@ -128,8 +141,8 @@ ALLOWED_HOSTS = get_setting(
# Cross Origin Resource Sharing (CORS) options
# Only allow CORS access to API
CORS_URLS_REGEX = r'^/api/.*$'
# Only allow CORS access to API and media endpoints
CORS_URLS_REGEX = r'^/(api|media|static)/.*$'
# Extract CORS options from configuration file
CORS_ORIGIN_ALLOW_ALL = get_boolean_setting(
@@ -186,7 +199,18 @@ if DBBACKUP_STORAGE_OPTIONS is None:
'location': config.get_backup_dir(),
}
# Application definition
INVENTREE_ADMIN_ENABLED = get_boolean_setting(
'INVENTREE_ADMIN_ENABLED',
config_key='admin_enabled',
default_value=True
)
# Base URL for admin pages (default="admin")
INVENTREE_ADMIN_URL = get_setting(
'INVENTREE_ADMIN_URL',
config_key='admin_url',
default_value='admin'
)
INSTALLED_APPS = [
# Admin site integration
@@ -196,13 +220,14 @@ INSTALLED_APPS = [
'build.apps.BuildConfig',
'common.apps.CommonConfig',
'company.apps.CompanyConfig',
'plugin.apps.PluginAppConfig', # Plugin app runs before all apps that depend on the isPluginRegistryLoaded function
'label.apps.LabelConfig',
'order.apps.OrderConfig',
'part.apps.PartConfig',
'report.apps.ReportConfig',
'stock.apps.StockConfig',
'users.apps.UsersConfig',
'plugin.apps.PluginAppConfig',
'web',
'generic',
'InvenTree.apps.InvenTreeConfig', # InvenTree app runs last
@@ -220,7 +245,6 @@ INSTALLED_APPS = [
# Third part add-ons
'django_filters', # Extended filter functionality
'rest_framework', # DRF (Django Rest Framework)
'rest_framework.authtoken', # Token authentication for API
'corsheaders', # Cross-origin Resource Sharing for DRF
'crispy_forms', # Improved form rendering
'import_export', # Import / export tables to file
@@ -245,6 +269,8 @@ INSTALLED_APPS = [
'django_otp.plugins.otp_static', # Backup codes
'allauth_2fa', # MFA flow for allauth
'dj_rest_auth', # Authentication APIs - dj-rest-auth
'dj_rest_auth.registration', # Registration APIs - dj-rest-auth'
'drf_spectacular', # API documentation
'django_ical', # For exporting calendars
@@ -274,8 +300,66 @@ AUTHENTICATION_BACKENDS = CONFIG.get('authentication_backends', [
'django.contrib.auth.backends.RemoteUserBackend', # proxy login
'django.contrib.auth.backends.ModelBackend',
'allauth.account.auth_backends.AuthenticationBackend', # SSO login via external providers
"sesame.backends.ModelBackend", # Magic link login django-sesame
])
# LDAP support
LDAP_AUTH = get_boolean_setting("INVENTREE_LDAP_ENABLED", "ldap.enabled", False)
if LDAP_AUTH:
import ldap
from django_auth_ldap.config import LDAPSearch
AUTHENTICATION_BACKENDS.append("django_auth_ldap.backend.LDAPBackend")
# debug mode to troubleshoot configuration
LDAP_DEBUG = get_boolean_setting("INVENTREE_LDAP_DEBUG", "ldap.debug", False)
if LDAP_DEBUG:
if "loggers" not in LOGGING:
LOGGING["loggers"] = {}
LOGGING["loggers"]["django_auth_ldap"] = {"level": "DEBUG", "handlers": ["console"]}
# get global options from dict and use ldap.OPT_* as keys and values
global_options_dict = get_setting("INVENTREE_LDAP_GLOBAL_OPTIONS", "ldap.global_options", {}, dict)
global_options = {}
for k, v in global_options_dict.items():
# keys are always ldap.OPT_* constants
k_attr = getattr(ldap, k, None)
if not k.startswith("OPT_") or k_attr is None:
print(f"[LDAP] ldap.global_options, key '{k}' not found, skipping...")
continue
# values can also be other strings, e.g. paths
v_attr = v
if v.startswith("OPT_"):
v_attr = getattr(ldap, v, None)
if v_attr is None:
print(f"[LDAP] ldap.global_options, value key '{v}' not found, skipping...")
continue
global_options[k_attr] = v_attr
AUTH_LDAP_GLOBAL_OPTIONS = global_options
if LDAP_DEBUG:
print("[LDAP] ldap.global_options =", global_options)
AUTH_LDAP_SERVER_URI = get_setting("INVENTREE_LDAP_SERVER_URI", "ldap.server_uri")
AUTH_LDAP_START_TLS = get_boolean_setting("INVENTREE_LDAP_START_TLS", "ldap.start_tls", False)
AUTH_LDAP_BIND_DN = get_setting("INVENTREE_LDAP_BIND_DN", "ldap.bind_dn")
AUTH_LDAP_BIND_PASSWORD = get_setting("INVENTREE_LDAP_BIND_PASSWORD", "ldap.bind_password")
AUTH_LDAP_USER_SEARCH = LDAPSearch(
get_setting("INVENTREE_LDAP_SEARCH_BASE_DN", "ldap.search_base_dn"),
ldap.SCOPE_SUBTREE,
str(get_setting("INVENTREE_LDAP_SEARCH_FILTER_STR", "ldap.search_filter_str", "(uid= %(user)s)"))
)
AUTH_LDAP_USER_DN_TEMPLATE = get_setting("INVENTREE_LDAP_USER_DN_TEMPLATE", "ldap.user_dn_template")
AUTH_LDAP_USER_ATTR_MAP = get_setting("INVENTREE_LDAP_USER_ATTR_MAP", "ldap.user_attr_map", {
'first_name': 'givenName',
'last_name': 'sn',
'email': 'mail',
}, dict)
AUTH_LDAP_ALWAYS_UPDATE_USER = get_boolean_setting("INVENTREE_LDAP_ALWAYS_UPDATE_USER", "ldap.always_update_user", True)
AUTH_LDAP_CACHE_TIMEOUT = get_setting("INVENTREE_LDAP_CACHE_TIMEOUT", "ldap.cache_timeout", 3600, int)
DEBUG_TOOLBAR_ENABLED = DEBUG and get_setting('INVENTREE_DEBUG_TOOLBAR', 'debug_toolbar', False)
# If the debug toolbar is enabled, add the modules
@@ -307,14 +391,6 @@ if DEBUG:
INSTALLED_APPS.append('sslserver')
# InvenTree URL configuration
# Base URL for admin pages (default="admin")
INVENTREE_ADMIN_URL = get_setting(
'INVENTREE_ADMIN_URL',
config_key='admin_url',
default_value='admin'
)
ROOT_URLCONF = 'InvenTree.urls'
TEMPLATES = [
@@ -361,7 +437,7 @@ REST_FRAMEWORK = {
'DEFAULT_AUTHENTICATION_CLASSES': (
'rest_framework.authentication.BasicAuthentication',
'rest_framework.authentication.SessionAuthentication',
'rest_framework.authentication.TokenAuthentication',
'users.authentication.ApiTokenAuthentication',
),
'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.LimitOffsetPagination',
'DEFAULT_PERMISSION_CLASSES': (
@@ -373,13 +449,31 @@ REST_FRAMEWORK = {
'DEFAULT_METADATA_CLASS': 'InvenTree.metadata.InvenTreeMetadata',
'DEFAULT_RENDERER_CLASSES': [
'rest_framework.renderers.JSONRenderer',
]
],
'TOKEN_MODEL': 'users.models.ApiToken',
}
if DEBUG:
# Enable browsable API if in DEBUG mode
REST_FRAMEWORK['DEFAULT_RENDERER_CLASSES'].append('rest_framework.renderers.BrowsableAPIRenderer')
# dj-rest-auth
# JWT switch
USE_JWT = get_boolean_setting('INVENTREE_USE_JWT', 'use_jwt', False)
REST_USE_JWT = USE_JWT
OLD_PASSWORD_FIELD_ENABLED = True
REST_AUTH_REGISTER_SERIALIZERS = {'REGISTER_SERIALIZER': 'InvenTree.forms.CustomRegisterSerializer'}
# JWT settings - rest_framework_simplejwt
if USE_JWT:
JWT_AUTH_COOKIE = 'inventree-auth'
JWT_AUTH_REFRESH_COOKIE = 'inventree-token'
REST_FRAMEWORK['DEFAULT_AUTHENTICATION_CLASSES'] + (
'dj_rest_auth.jwt_auth.JWTCookieAuthentication',
)
INSTALLED_APPS.append('rest_framework_simplejwt')
# WSGI default setting
SPECTACULAR_SETTINGS = {
'TITLE': 'InvenTree API',
'DESCRIPTION': 'API for InvenTree - the intuitive open source inventory management system',
@@ -422,7 +516,7 @@ for key in db_keys:
try:
env_var = int(env_var)
except ValueError:
logger.error(f"Invalid number for {env_key}: {env_var}")
logger.exception("Invalid number for %s: %s", env_key, env_var)
# Override configuration value
db_config[key] = env_var
@@ -463,9 +557,9 @@ if 'sqlite' in db_engine:
db_name = str(Path(db_name).resolve())
db_config['NAME'] = db_name
logger.info(f"DB_ENGINE: {db_engine}")
logger.info(f"DB_NAME: {db_name}")
logger.info(f"DB_HOST: {db_host}")
logger.info("DB_ENGINE: %s", db_engine)
logger.info("DB_NAME: %s", db_name)
logger.info("DB_HOST: %s", db_host)
"""
In addition to base-level database configuration, we may wish to specify specific options to the database backend
@@ -662,6 +756,7 @@ Q_CLUSTER = {
'orm': 'default',
'cache': 'default',
'sync': False,
'poll': 1.5,
}
# Configure django-q sentry integration
@@ -718,7 +813,10 @@ LANGUAGE_CODE = get_setting('INVENTREE_LANGUAGE', 'language', 'en-us')
LANGUAGE_COOKIE_AGE = 2592000
# If a new language translation is supported, it must be added here
# After adding a new language, run the following command:
# python manage.py makemessages -l <language_code> -e html,js,py --no-wrap
LANGUAGES = [
('bg', _('Bulgarian')),
('cs', _('Czech')),
('da', _('Danish')),
('de', _('German')),
@@ -730,6 +828,7 @@ LANGUAGES = [
('fi', _('Finnish')),
('fr', _('French')),
('he', _('Hebrew')),
('hi', _('Hindi')),
('hu', _('Hungarian')),
('it', _('Italian')),
('ja', _('Japanese')),
@@ -738,16 +837,18 @@ LANGUAGES = [
('no', _('Norwegian')),
('pl', _('Polish')),
('pt', _('Portuguese')),
('pt-BR', _('Portuguese (Brazilian)')),
('pt-br', _('Portuguese (Brazilian)')),
('ru', _('Russian')),
('sl', _('Slovenian')),
('sv', _('Swedish')),
('th', _('Thai')),
('tr', _('Turkish')),
('vi', _('Vietnamese')),
('zh-hans', _('Chinese')),
('zh-hans', _('Chinese (Simplified)')),
('zh-hant', _('Chinese (Traditional)')),
]
# Testing interface translations
if get_boolean_setting('TEST_TRANSLATIONS', default_value=False): # pragma: no cover
# Set default language
@@ -785,7 +886,7 @@ CURRENCY_DECIMAL_PLACES = 6
# Check that each provided currency is supported
for currency in CURRENCIES:
if currency not in moneyed.CURRENCIES: # pragma: no cover
logger.error(f"Currency code '{currency}' is not supported")
logger.error("Currency code '%s' is not supported", currency)
sys.exit(1)
# Custom currency exchange backend
@@ -803,6 +904,10 @@ EMAIL_USE_SSL = get_boolean_setting('INVENTREE_EMAIL_SSL', 'email.ssl', False)
DEFAULT_FROM_EMAIL = get_setting('INVENTREE_EMAIL_SENDER', 'email.sender', '')
# If "from" email not specified, default to the username
if not DEFAULT_FROM_EMAIL:
DEFAULT_FROM_EMAIL = get_setting('INVENTREE_EMAIL_USERNAME', 'email.username', '')
EMAIL_USE_LOCALTIME = False
EMAIL_TIMEOUT = 60
@@ -849,10 +954,12 @@ ACCOUNT_LOGIN_ATTEMPTS_LIMIT = get_setting('INVENTREE_LOGIN_ATTEMPTS', 'login_at
ACCOUNT_DEFAULT_HTTP_PROTOCOL = get_setting('INVENTREE_LOGIN_DEFAULT_HTTP_PROTOCOL', 'login_default_protocol', 'http')
ACCOUNT_LOGOUT_ON_PASSWORD_CHANGE = True
ACCOUNT_PREVENT_ENUMERATION = True
# 2FA
REMOVE_SUCCESS_URL = 'settings'
# override forms / adapters
ACCOUNT_FORMS = {
'login': 'allauth.account.forms.LoginForm',
'login': 'InvenTree.forms.CustomLoginForm',
'signup': 'InvenTree.forms.CustomSignupForm',
'add_email': 'allauth.account.forms.AddEmailForm',
'change_password': 'allauth.account.forms.ChangePasswordForm',
@@ -929,7 +1036,7 @@ PLUGIN_FILE_CHECKED = False
SITE_URL = get_setting('INVENTREE_SITE_URL', 'site_url', None)
if SITE_URL:
logger.info(f"Site URL: {SITE_URL}")
logger.info("Site URL: %s", SITE_URL)
# Check that the site URL is valid
validator = URLValidator()
@@ -940,11 +1047,16 @@ CUSTOM_LOGO = get_custom_file('INVENTREE_CUSTOM_LOGO', 'customize.logo', 'custom
CUSTOM_SPLASH = get_custom_file('INVENTREE_CUSTOM_SPLASH', 'customize.splash', 'custom splash')
CUSTOMIZE = get_setting('INVENTREE_CUSTOMIZE', 'customize', {})
# Load settings for the frontend interface
FRONTEND_SETTINGS = config.get_frontend_settings(debug=DEBUG)
FRONTEND_URL_BASE = FRONTEND_SETTINGS.get('base_url', 'platform')
if DEBUG:
logger.info("InvenTree running with DEBUG enabled")
logger.info(f"MEDIA_ROOT: '{MEDIA_ROOT}'")
logger.info(f"STATIC_ROOT: '{STATIC_ROOT}'")
logger.info("MEDIA_ROOT: '%s'", MEDIA_ROOT)
logger.info("STATIC_ROOT: '%s'", STATIC_ROOT)
# Flags
FLAGS = {
@@ -961,7 +1073,12 @@ FLAGS = {
CUSTOM_FLAGS = get_setting('INVENTREE_FLAGS', 'flags', None, typecast=dict)
if CUSTOM_FLAGS:
if not isinstance(CUSTOM_FLAGS, dict):
logger.error(f"Invalid custom flags, must be valid dict: {CUSTOM_FLAGS}")
logger.error("Invalid custom flags, must be valid dict: %s", str(CUSTOM_FLAGS))
else:
logger.info(f"Custom flags: {CUSTOM_FLAGS}")
logger.info("Custom flags: %s", str(CUSTOM_FLAGS))
FLAGS.update(CUSTOM_FLAGS)
# Magic login django-sesame
SESAME_MAX_AGE = 300
# LOGIN_REDIRECT_URL = f"/{FRONTEND_URL_BASE}/logged-in/"
LOGIN_REDIRECT_URL = "/index/"

View File

@@ -0,0 +1,211 @@
"""API endpoints for social authentication with allauth."""
import logging
from importlib import import_module
from django.urls import include, path, reverse
from allauth.account.models import EmailAddress
from allauth.socialaccount import providers
from allauth.socialaccount.models import SocialApp
from allauth.socialaccount.providers.keycloak.views import \
KeycloakOAuth2Adapter
from allauth.socialaccount.providers.oauth2.views import (OAuth2Adapter,
OAuth2LoginView)
from drf_spectacular.utils import OpenApiResponse, extend_schema
from rest_framework.exceptions import NotFound
from rest_framework.permissions import AllowAny, IsAuthenticated
from rest_framework.response import Response
from common.models import InvenTreeSetting
from InvenTree.mixins import CreateAPI, ListAPI, ListCreateAPI
from InvenTree.serializers import InvenTreeModelSerializer
logger = logging.getLogger('inventree')
class GenericOAuth2ApiLoginView(OAuth2LoginView):
"""Api view to login a user with a social account"""
def dispatch(self, request, *args, **kwargs):
"""Dispatch the regular login view directly."""
return self.login(request, *args, **kwargs)
class GenericOAuth2ApiConnectView(GenericOAuth2ApiLoginView):
"""Api view to connect a social account to the current user"""
def dispatch(self, request, *args, **kwargs):
"""Dispatch the connect request directly."""
# Override the request method be in connection mode
request.GET = request.GET.copy()
request.GET['process'] = 'connect'
# Resume the dispatch
return super().dispatch(request, *args, **kwargs)
def handle_oauth2(adapter: OAuth2Adapter):
"""Define urls for oauth2 endpoints."""
return [
path('login/', GenericOAuth2ApiLoginView.adapter_view(adapter), name=f'{provider.id}_api_login'),
path('connect/', GenericOAuth2ApiConnectView.adapter_view(adapter), name=f'{provider.id}_api_connect'),
]
def handle_keycloak():
"""Define urls for keycloak."""
return [
path('login/', GenericOAuth2ApiLoginView.adapter_view(KeycloakOAuth2Adapter), name='keycloak_api_login'),
path('connect/', GenericOAuth2ApiConnectView.adapter_view(KeycloakOAuth2Adapter), name='keycloak_api_connet'),
]
legacy = {
'twitter': 'twitter_oauth2',
'bitbucket': 'bitbucket_oauth2',
'linkedin': 'linkedin_oauth2',
'vimeo': 'vimeo_oauth2',
'openid': 'openid_connect',
} # legacy connectors
# Collect urls for all loaded providers
social_auth_urlpatterns = []
provider_urlpatterns = []
for provider in providers.registry.get_list():
try:
prov_mod = import_module(provider.get_package() + ".views")
except ImportError:
continue
# Try to extract the adapter class
adapters = [cls for cls in prov_mod.__dict__.values() if isinstance(cls, type) and not cls == OAuth2Adapter and issubclass(cls, OAuth2Adapter)]
# Get urls
urls = []
if len(adapters) == 1:
urls = handle_oauth2(adapter=adapters[0])
else:
if provider.id in legacy:
logger.warning('`%s` is not supported on platform UI. Use `%s` instead.', provider.id, legacy[provider.id])
continue
elif provider.id == 'keycloak':
urls = handle_keycloak()
else:
logger.error('Found handler that is not yet ready for platform UI: `%s`. Open an feature request on GitHub if you need it implemented.', provider.id)
continue
provider_urlpatterns += [path(f'{provider.id}/', include(urls))]
social_auth_urlpatterns += provider_urlpatterns
class SocialProviderListView(ListAPI):
"""List of available social providers."""
permission_classes = (AllowAny,)
def get(self, request, *args, **kwargs):
"""Get the list of providers."""
provider_list = []
for provider in providers.registry.get_list():
provider_data = {
'id': provider.id,
'name': provider.name,
'login': request.build_absolute_uri(reverse(f'{provider.id}_api_login')),
'connect': request.build_absolute_uri(reverse(f'{provider.id}_api_connect')),
'configured': False
}
try:
provider_app = provider.get_app(request)
provider_data['display_name'] = provider_app.name
provider_data['configured'] = True
except SocialApp.DoesNotExist:
provider_data['display_name'] = provider.name
provider_list.append(provider_data)
data = {
'sso_enabled': InvenTreeSetting.get_setting('LOGIN_ENABLE_SSO'),
'sso_registration': InvenTreeSetting.get_setting('LOGIN_ENABLE_SSO_REG'),
'mfa_required': InvenTreeSetting.get_setting('LOGIN_ENFORCE_MFA'),
'providers': provider_list
}
return Response(data)
class EmailAddressSerializer(InvenTreeModelSerializer):
"""Serializer for the EmailAddress model."""
class Meta:
"""Meta options for EmailAddressSerializer."""
model = EmailAddress
fields = '__all__'
class EmptyEmailAddressSerializer(InvenTreeModelSerializer):
"""Empty Serializer for the EmailAddress model."""
class Meta:
"""Meta options for EmailAddressSerializer."""
model = EmailAddress
fields = []
class EmailListView(ListCreateAPI):
"""List of registered email addresses for current users."""
permission_classes = (IsAuthenticated,)
serializer_class = EmailAddressSerializer
def get_queryset(self):
"""Only return data for current user."""
return EmailAddress.objects.filter(user=self.request.user)
class EmailActionMixin(CreateAPI):
"""Mixin to modify email addresses for current users."""
serializer_class = EmptyEmailAddressSerializer
permission_classes = (IsAuthenticated,)
def get_queryset(self):
"""Filter queryset for current user."""
return EmailAddress.objects.filter(user=self.request.user, pk=self.kwargs['pk']).first()
@extend_schema(responses={200: OpenApiResponse(response=EmailAddressSerializer)})
def post(self, request, *args, **kwargs):
"""Filter item, run action and return data."""
email = self.get_queryset()
if not email:
raise NotFound
self.special_action(email, request, *args, **kwargs)
return Response(EmailAddressSerializer(email).data)
class EmailVerifyView(EmailActionMixin):
"""Re-verify an email for a currently logged in user."""
def special_action(self, email, request, *args, **kwargs):
"""Send confirmation."""
if email.verified:
return
email.send_confirmation(request)
class EmailPrimaryView(EmailActionMixin):
"""Make an email for a currently logged in user primary."""
def special_action(self, email, *args, **kwargs):
"""Mark email as primary."""
if email.primary:
return
email.set_as_primary()
class EmailRemoveView(EmailActionMixin):
"""Remove an email for a currently logged in user."""
def special_action(self, email, *args, **kwargs):
"""Delete email."""
email.delete()

View File

@@ -269,10 +269,6 @@ main {
}
/* Styles for table buttons and filtering */
.button-toolbar .btn {
margin-left: 1px;
margin-right: 1px;
}
.filter-list {
display: inline-block;
@@ -1101,3 +1097,7 @@ a {
align-items: center;
justify-content: space-between;
}
.large-treeview-icon {
font-size: 1em;
}

View File

@@ -8,7 +8,7 @@ from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from django_q.models import Success
from django_q.monitor import Stat
from django_q.status import Stat
import InvenTree.email
import InvenTree.ready
@@ -17,7 +17,7 @@ logger = logging.getLogger("inventree")
def is_worker_running(**kwargs):
"""Return True if the background worker process is oprational."""
"""Return True if the background worker process is operational."""
clusters = Stat.get_all()
if len(clusters) > 0:

View File

@@ -48,11 +48,11 @@ def schedule_task(taskname, **kwargs):
# If this task is already scheduled, don't schedule it again
# Instead, update the scheduling parameters
if Schedule.objects.filter(func=taskname).exists():
logger.debug(f"Scheduled task '{taskname}' already exists - updating!")
logger.debug("Scheduled task '%s' already exists - updating!", taskname)
Schedule.objects.filter(func=taskname).update(**kwargs)
else:
logger.info(f"Creating scheduled task '{taskname}'")
logger.info("Creating scheduled task '%s'", taskname)
Schedule.objects.create(
name=taskname,
@@ -89,15 +89,16 @@ def check_daily_holdoff(task_name: str, n_days: int = 1) -> bool:
Note that this function creates some *hidden* global settings (designated with the _ prefix),
which are used to keep a running track of when the particular task was was last run.
"""
from common.models import InvenTreeSetting
from InvenTree.ready import isInTestMode
if n_days <= 0:
logger.info(f"Specified interval for task '{task_name}' < 1 - task will not run")
logger.info("Specified interval for task '%s' < 1 - task will not run", task_name)
return False
# Sleep a random number of seconds to prevent worker conflict
time.sleep(random.randint(1, 5))
if not isInTestMode():
time.sleep(random.randint(1, 5))
attempt_key = f'_{task_name}_ATTEMPT'
success_key = f'_{task_name}_SUCCESS'
@@ -115,7 +116,7 @@ def check_daily_holdoff(task_name: str, n_days: int = 1) -> bool:
threshold = datetime.now() - timedelta(days=n_days)
if last_success > threshold:
logger.info(f"Last successful run for '{task_name}' was too recent - skipping task")
logger.info("Last successful run for '%s' was too recent - skipping task", task_name)
return False
# Check for any information we have about this task
@@ -132,7 +133,7 @@ def check_daily_holdoff(task_name: str, n_days: int = 1) -> bool:
threshold = datetime.now() - timedelta(hours=12)
if last_attempt > threshold:
logger.info(f"Last attempt for '{task_name}' was too recent - skipping task")
logger.info("Last attempt for '%s' was too recent - skipping task", task_name)
return False
# Record this attempt
@@ -144,29 +145,28 @@ def check_daily_holdoff(task_name: str, n_days: int = 1) -> bool:
def record_task_attempt(task_name: str):
"""Record that a multi-day task has been attempted *now*"""
from common.models import InvenTreeSetting
logger.info(f"Logging task attempt for '{task_name}'")
logger.info("Logging task attempt for '%s'", task_name)
InvenTreeSetting.set_setting(f'_{task_name}_ATTEMPT', datetime.now().isoformat(), None)
def record_task_success(task_name: str):
"""Record that a multi-day task was successful *now*"""
from common.models import InvenTreeSetting
InvenTreeSetting.set_setting(f'_{task_name}_SUCCESS', datetime.now().isoformat(), None)
def offload_task(taskname, *args, force_async=False, force_sync=False, **kwargs):
def offload_task(taskname, *args, force_async=False, force_sync=False, **kwargs) -> bool:
"""Create an AsyncTask if workers are running. This is different to a 'scheduled' task, in that it only runs once!
If workers are not running or force_sync flag
is set then the task is ran synchronously.
"""
If workers are not running or force_sync flag, is set then the task is ran synchronously.
Returns:
bool: True if the task was offloaded (or ran), False otherwise
"""
try:
import importlib
@@ -174,20 +174,33 @@ def offload_task(taskname, *args, force_async=False, force_sync=False, **kwargs)
from InvenTree.status import is_worker_running
except AppRegistryNotReady: # pragma: no cover
logger.warning(f"Could not offload task '{taskname}' - app registry not ready")
return
logger.warning("Could not offload task '%s' - app registry not ready", taskname)
if force_async:
# Cannot async the task, so return False
return False
else:
force_sync = True
except (OperationalError, ProgrammingError): # pragma: no cover
raise_warning(f"Could not offload task '{taskname}' - database not ready")
if force_async:
# Cannot async the task, so return False
return False
else:
force_sync = True
if force_async or (is_worker_running() and not force_sync):
# Running as asynchronous task
try:
task = AsyncTask(taskname, *args, **kwargs)
task.run()
except ImportError:
raise_warning(f"WARNING: '{taskname}' not started - Function not found")
raise_warning(f"WARNING: '{taskname}' not offloaded - Function not found")
return False
except Exception as exc:
raise_warning(f"WARNING: '{taskname}' not started due to {type(exc)}")
raise_warning(f"WARNING: '{taskname}' not offloaded due to {str(exc)}")
return False
else:
if callable(taskname):
@@ -200,14 +213,14 @@ def offload_task(taskname, *args, force_async=False, force_sync=False, **kwargs)
app_mod = app + '.' + mod
except ValueError:
raise_warning(f"WARNING: '{taskname}' not started - Malformed function path")
return
return False
# Import module from app
try:
_mod = importlib.import_module(app_mod)
except ModuleNotFoundError:
raise_warning(f"WARNING: '{taskname}' not started - No module named '{app_mod}'")
return
return False
# Retrieve function
try:
@@ -221,10 +234,17 @@ def offload_task(taskname, *args, force_async=False, force_sync=False, **kwargs)
_func = eval(func) # pragma: no cover
except NameError:
raise_warning(f"WARNING: '{taskname}' not started - No function named '{func}'")
return
return False
# Workers are not running: run it as synchronous task
_func(*args, **kwargs)
try:
_func(*args, **kwargs)
except Exception as exc:
raise_warning(f"WARNING: '{taskname}' not started due to {str(exc)}")
return False
# Finally, task either completed successfully or was offloaded
return True
@dataclass()
@@ -251,7 +271,7 @@ class ScheduledTask:
class TaskRegister:
"""Registry for periodicall tasks."""
"""Registry for periodic tasks."""
task_list: List[ScheduledTask] = []
def register(self, task, schedule, minutes: int = None):
@@ -267,8 +287,9 @@ def scheduled_task(interval: str, minutes: int = None, tasklist: TaskRegister =
Example:
```python
@register(ScheduledTask.DAILY)
def my_custom_funciton():
@scheduled_task(ScheduledTask.DAILY)
def my_custom_function():
# Perform a custom function once per day
...
```
@@ -340,7 +361,7 @@ def delete_successful_tasks():
)
if results.count() > 0:
logger.info(f"Deleting {results.count()} successful task records")
logger.info("Deleting %s successful task records", results.count())
results.delete()
except AppRegistryNotReady: # pragma: no cover
@@ -350,7 +371,6 @@ def delete_successful_tasks():
@scheduled_task(ScheduledTask.DAILY)
def delete_failed_tasks():
"""Delete failed task logs which are older than a specified period"""
try:
from django_q.models import Failure
@@ -365,7 +385,7 @@ def delete_failed_tasks():
)
if results.count() > 0:
logger.info(f"Deleting {results.count()} failed task records")
logger.info("Deleting %s failed task records", results.count())
results.delete()
except AppRegistryNotReady: # pragma: no cover
@@ -388,7 +408,7 @@ def delete_old_error_logs():
)
if errors.count() > 0:
logger.info(f"Deleting {errors.count()} old error logs")
logger.info("Deleting %s old error logs", errors.count())
errors.delete()
except AppRegistryNotReady: # pragma: no cover
@@ -399,7 +419,6 @@ def delete_old_error_logs():
@scheduled_task(ScheduledTask.DAILY)
def delete_old_notifications():
"""Delete old notification logs"""
try:
from common.models import (InvenTreeSetting, NotificationEntry,
NotificationMessage)
@@ -412,7 +431,7 @@ def delete_old_notifications():
)
if items.count() > 0:
logger.info(f"Deleted {items.count()} old notification entries")
logger.info("Deleted %s old notification entries", items.count())
items.delete()
items = NotificationMessage.objects.filter(
@@ -420,7 +439,7 @@ def delete_old_notifications():
)
if items.count() > 0:
logger.info(f"Deleted {items.count()} old notification messages")
logger.info("Deleted %s old notification messages", items.count())
items.delete()
except AppRegistryNotReady:
@@ -472,7 +491,7 @@ def check_for_updates():
match = re.match(r"^.*(\d+)\.(\d+)\.(\d+).*$", tag)
if len(match.groups()) != 3: # pragma: no cover
logger.warning(f"Version '{tag}' did not match expected pattern")
logger.warning("Version '%s' did not match expected pattern", tag)
return
latest_version = [int(x) for x in match.groups()]
@@ -480,7 +499,7 @@ def check_for_updates():
if len(latest_version) != 3:
raise ValueError(f"Version '{tag}' is not correct format") # pragma: no cover
logger.info(f"Latest InvenTree version: '{tag}'")
logger.info("Latest InvenTree version: '%s'", tag)
# Save the version to the database
common.models.InvenTreeSetting.set_setting(
@@ -494,38 +513,55 @@ def check_for_updates():
@scheduled_task(ScheduledTask.DAILY)
def update_exchange_rates():
"""Update currency exchange rates."""
def update_exchange_rates(force: bool = False):
"""Update currency exchange rates
Arguments:
force: If True, force the update to run regardless of the last update time
"""
try:
from djmoney.contrib.exchange.models import Rate
from common.models import InvenTreeSetting
from common.settings import currency_code_default, currency_codes
from InvenTree.exchange import InvenTreeExchange
except AppRegistryNotReady: # pragma: no cover
# Apps not yet loaded!
logger.info("Could not perform 'update_exchange_rates' - App registry not ready")
return
except Exception: # pragma: no cover
# Other error?
except Exception as exc: # pragma: no cover
logger.info("Could not perform 'update_exchange_rates' - %s", exc)
return
if not force:
interval = int(InvenTreeSetting.get_setting('CURRENCY_UPDATE_INTERVAL', 1, cache=False))
if not check_daily_holdoff('update_exchange_rates', interval):
logger.info("Skipping exchange rate update (interval not reached)")
return
backend = InvenTreeExchange()
base = currency_code_default()
logger.info(f"Updating exchange rates using base currency '{base}'")
logger.info("Updating exchange rates using base currency '%s'", base)
try:
backend.update_rates(base_currency=base)
# Remove any exchange rates which are not in the provided currencies
Rate.objects.filter(backend="InvenTreeExchange").exclude(currency__in=currency_codes()).delete()
# Record successful task execution
record_task_success('update_exchange_rates')
except OperationalError:
logger.warning("Could not update exchange rates - database not ready")
except Exception as e: # pragma: no cover
logger.error(f"Error updating exchange rates: {e} ({type(e)})")
logger.exception("Error updating exchange rates: %s", str(type(e)))
@scheduled_task(ScheduledTask.DAILY)
def run_backup():
"""Run the backup command."""
from common.models import InvenTreeSetting
if not InvenTreeSetting.get_setting('INVENTREE_BACKUP_ENABLE', False, cache=False):
@@ -547,72 +583,77 @@ def run_backup():
record_task_success('run_backup')
def get_migration_plan():
"""Returns a list of migrations which are needed to be run."""
executor = MigrationExecutor(connections[DEFAULT_DB_ALIAS])
plan = executor.migration_plan(executor.loader.graph.leaf_nodes())
return plan
@scheduled_task(ScheduledTask.DAILY)
def check_for_migrations(worker: bool = True):
def check_for_migrations():
"""Checks if migrations are needed.
If the setting auto_update is enabled we will start updating.
"""
# Test if auto-updates are enabled
if not get_setting('INVENTREE_AUTO_UPDATE', 'auto_update'):
return
from common.models import InvenTreeSetting
from plugin import registry
def set_pending_migrations(n: int):
"""Helper function to inform the user about pending migrations"""
logger.info('There are %s pending migrations', n)
InvenTreeSetting.set_setting('_PENDING_MIGRATIONS', n, None)
logger.info("Checking for pending database migrations")
# Force plugin registry reload
registry.check_reload()
plan = get_migration_plan()
n = len(plan)
# Check if there are any open migrations
if not plan:
logger.info('There are no open migrations')
set_pending_migrations(0)
return
logger.info('There are open migrations')
set_pending_migrations(n)
# Test if auto-updates are enabled
if not get_setting('INVENTREE_AUTO_UPDATE', 'auto_update'):
logger.info("Auto-update is disabled - skipping migrations")
return
# Log open migrations
for migration in plan:
logger.info(migration[0])
logger.info("- %s", str(migration[0]))
# Set the application to maintenance mode - no access from now on.
logger.info('Going into maintenance')
set_maintenance_mode(True)
logger.info('Mainentance mode is on now')
# Check if we are worker - go kill all other workers then.
# Only the frontend workers run updates.
if worker:
logger.info('Current process is a worker - shutting down cluster')
# Ok now we are ready to go ahead!
# To be sure we are in maintenance this is wrapped
with maintenance_mode_on():
logger.info('Starting migrations')
print('Starting migrations')
logger.info('Starting migration process...')
try:
call_command('migrate', interactive=False)
except NotSupportedError as e: # pragma: no cover
if settings.DATABASES['default']['ENGINE'] != 'django.db.backends.sqlite3':
raise e
logger.error(f'Error during migrations: {e}')
logger.exception('Error during migrations: %s', e)
else:
set_pending_migrations(0)
print('Migrations done')
logger.info('Ran migrations')
logger.info("Completed %s migrations", n)
# Make sure we are out of maintenance again
logger.info('Checking InvenTree left maintenance mode')
# Make sure we are out of maintenance mode
if get_maintenance_mode():
logger.warning('Mainentance was still on - releasing now')
logger.warning("Maintenance mode was not disabled - forcing it now")
set_maintenance_mode(False)
logger.info('Released out of maintenance')
logger.info("Manually released maintenance mode")
# We should be current now - triggering full reload to make sure all models
# are loaded fully in their new state.
registry.reload_plugins(full_reload=True, force_reload=True)
def get_migration_plan():
"""Returns a list of migrations which are needed to be run."""
executor = MigrationExecutor(connections[DEFAULT_DB_ALIAS])
plan = executor.migration_plan(executor.loader.graph.leaf_nodes())
return plan
registry.reload_plugins(full_reload=True, force_reload=True, collect=True)

View File

@@ -16,7 +16,6 @@ class InvenTreeTemplateLoader(CachedLoader):
Any custom report or label templates will be forced to reload (without cache).
This ensures that generated PDF reports / labels are always up-to-date.
"""
# List of template patterns to skip cache for
skip_cache_dirs = [
os.path.abspath(os.path.join(settings.MEDIA_ROOT, 'report')),

View File

@@ -0,0 +1,8 @@
Hello {{username}},
You requested that we send you a link to log in to our app:
{{link}}
Regards,
{{site_name}}

View File

@@ -267,7 +267,6 @@ class BulkDeleteTests(InvenTreeAPITestCase):
def test_errors(self):
"""Test that the correct errors are thrown"""
url = reverse('api-stock-test-result-list')
# DELETE without any of the required fields
@@ -316,9 +315,20 @@ class SearchTests(InvenTreeAPITestCase):
'sales_order',
]
def test_empty(self):
"""Test empty request"""
data = [
'',
None,
{},
]
for d in data:
response = self.post(reverse('api-search'), d, expected_code=400)
self.assertIn('Search term must be provided', str(response.data))
def test_results(self):
"""Test individual result types"""
response = self.post(
reverse('api-search'),
{
@@ -361,7 +371,6 @@ class SearchTests(InvenTreeAPITestCase):
def test_permissions(self):
"""Test that users with insufficient permissions are handled correctly"""
# First, remove all roles
for ruleset in self.group.rule_sets.all():
ruleset.can_view = False

View File

@@ -0,0 +1,41 @@
"""Tests for api_version."""
from django.urls import reverse
from InvenTree.api_version import INVENTREE_API_VERSION
from InvenTree.unit_test import InvenTreeAPITestCase
from InvenTree.version import inventreeApiText, parse_version_text
class ApiVersionTests(InvenTreeAPITestCase):
"""Tests for api_version functions and APIs."""
def test_api(self):
"""Test that the API text is correct."""
url = reverse('api-version-text')
response = self.client.get(url, format='json')
data = response.json()
self.assertEqual(len(data), 10)
def test_inventree_api_text(self):
"""Test that the inventreeApiText function works expected."""
# Normal run
resp = inventreeApiText()
self.assertEqual(len(resp), 10)
# More responses
resp = inventreeApiText(20)
self.assertEqual(len(resp), 20)
# Specific version
resp = inventreeApiText(start_version=5)
self.assertEqual(list(resp)[0], 'v5')
def test_parse_version_text(self):
"""Test that api version text is correctly parsed."""
resp = parse_version_text()
# Check that all texts are parsed
self.assertEqual(len(resp), INVENTREE_API_VERSION - 1)

View File

@@ -45,7 +45,6 @@ class ViewTests(InvenTreeTestCase):
def test_settings_page(self):
"""Test that the 'settings' page loads correctly"""
# Settings page loads
url = reverse('settings')
@@ -122,7 +121,6 @@ class ViewTests(InvenTreeTestCase):
def test_url_login(self):
"""Test logging in via arguments"""
# Log out
self.client.logout()
response = self.client.get("/index/")

View File

@@ -11,19 +11,23 @@ import django.core.exceptions as django_exceptions
from django.conf import settings
from django.contrib.auth import get_user_model
from django.contrib.sites.models import Site
from django.core import mail
from django.core.exceptions import ValidationError
from django.test import TestCase, override_settings
from django.urls import reverse
import pint.errors
from djmoney.contrib.exchange.exceptions import MissingRate
from djmoney.contrib.exchange.models import Rate, convert_money
from djmoney.money import Money
from sesame.utils import get_user
import InvenTree.conversion
import InvenTree.format
import InvenTree.helpers
import InvenTree.helpers_model
import InvenTree.tasks
from common.models import InvenTreeSetting
from common.models import CustomUnit, InvenTreeSetting
from common.settings import currency_codes
from InvenTree.sanitizer import sanitize_svg
from InvenTree.unit_test import InvenTreeTestCase
@@ -38,9 +42,41 @@ from .validators import validate_overage
class ConversionTest(TestCase):
"""Tests for conversion of physical units"""
def test_dimensionless_units(self):
"""Tests for 'dimensonless' unit quantities"""
def test_prefixes(self):
"""Test inputs where prefixes are used"""
tests = {
"3": 3,
"3m": 3,
"3mm": 0.003,
"3k": 3000,
"3u": 0.000003,
"3 inch": 0.0762,
}
for val, expected in tests.items():
q = InvenTree.conversion.convert_physical_value(val, 'm')
self.assertAlmostEqual(q, expected, 3)
def test_base_units(self):
"""Test conversion to specified base units"""
tests = {
"3": 3,
"3 dozen": 36,
"50 dozen kW": 600000,
"1 / 10": 0.1,
"1/2 kW": 500,
"1/2 dozen kW": 6000,
"0.005 MW": 5000,
}
for val, expected in tests.items():
q = InvenTree.conversion.convert_physical_value(val, 'W')
self.assertAlmostEqual(q, expected, places=2)
q = InvenTree.conversion.convert_physical_value(val, 'W', strip_units=False)
self.assertAlmostEqual(float(q.magnitude), expected, places=2)
def test_dimensionless_units(self):
"""Tests for 'dimensionless' unit quantities"""
# Test some dimensionless units
tests = {
'ea': 1,
@@ -50,11 +86,97 @@ class ConversionTest(TestCase):
'3 hundred': 300,
'2 thousand': 2000,
'12 pieces': 12,
'1 / 10': 0.1,
'1/2': 0.5,
'-1 / 16': -0.0625,
'3/2': 1.5,
'1/2 dozen': 6,
}
for val, expected in tests.items():
q = InvenTree.conversion.convert_physical_value(val).to_base_units()
self.assertEqual(q.magnitude, expected)
# Convert, and leave units
q = InvenTree.conversion.convert_physical_value(val, strip_units=False)
self.assertAlmostEqual(float(q.magnitude), expected, 3)
# Convert, and strip units
q = InvenTree.conversion.convert_physical_value(val)
self.assertAlmostEqual(q, expected, 3)
def test_invalid_units(self):
"""Test conversion with bad units"""
tests = {
'3': '10',
'13': '-?-',
'-3': 'xyz',
'-12': '-12',
'1/0': '1/0',
}
for val, unit in tests.items():
with self.assertRaises(ValidationError):
InvenTree.conversion.convert_physical_value(val, unit)
def test_invalid_values(self):
"""Test conversion of invalid inputs"""
inputs = [
'-x',
'1/0',
'xyz',
'12B45C'
]
for val in inputs:
# Test with a provided unit
with self.assertRaises(ValidationError):
InvenTree.conversion.convert_physical_value(val, 'meter')
# Test dimensionless
with self.assertRaises(ValidationError):
InvenTree.conversion.convert_physical_value(val)
def test_custom_units(self):
"""Tests for custom unit conversion"""
# Start with an empty set of units
CustomUnit.objects.all().delete()
InvenTree.conversion.reload_unit_registry()
# Ensure that the custom unit does *not* exist to start with
reg = InvenTree.conversion.get_unit_registry()
with self.assertRaises(pint.errors.UndefinedUnitError):
reg['hpmm']
# Create a new custom unit
CustomUnit.objects.create(
name='fanciful_unit',
definition='henry / mm',
symbol='hpmm',
)
# Reload registry
reg = InvenTree.conversion.get_unit_registry()
# Ensure that the custom unit is now available
reg['hpmm']
# Convert some values
tests = {
'1': 1,
'1 hpmm': 1000000,
'1 / 10 hpmm': 100000,
'1 / 100 hpmm': 10000,
'0.3 hpmm': 300000,
'-7hpmm': -7000000,
}
for val, expected in tests.items():
# Convert, and leave units
q = InvenTree.conversion.convert_physical_value(val, 'henry / km', strip_units=False)
self.assertAlmostEqual(float(q.magnitude), expected, 2)
# Convert and strip units
q = InvenTree.conversion.convert_physical_value(val, 'henry / km')
self.assertAlmostEqual(q, expected, 2)
class ValidatorTest(TestCase):
@@ -87,7 +209,6 @@ class FormatTest(TestCase):
def test_parse(self):
"""Tests for the 'parse_format_string' function"""
# Extract data from a valid format string
fmt = "PO-{abc:02f}-{ref:04d}-{date}-???"
@@ -109,7 +230,6 @@ class FormatTest(TestCase):
def test_create_regex(self):
"""Test function for creating a regex from a format string"""
tests = {
"PO-123-{ref:04f}": r"^PO\-123\-(?P<ref>.+)$",
"{PO}-???-{ref}-{date}-22": r"^(?P<PO>.+)\-...\-(?P<ref>.+)\-(?P<date>.+)\-22$",
@@ -122,7 +242,6 @@ class FormatTest(TestCase):
def test_validate_format(self):
"""Test that string validation works as expected"""
# These tests should pass
for value, pattern in {
"ABC-hello-123": "???-{q}-###",
@@ -143,7 +262,6 @@ class FormatTest(TestCase):
def test_extract_value(self):
"""Test that we can extract named values based on a format string"""
# Simple tests based on a straight-forward format string
fmt = "PO-###-{ref:04d}"
@@ -212,10 +330,64 @@ class FormatTest(TestCase):
"PO-###-{test}",
)
def test_currency_formatting(self):
"""Test that currency formatting works correctly for multiple currencies"""
test_data = (
(Money( 3651.285718, "USD"), 4, "$3,651.2857" ), # noqa: E201,E202
(Money(487587.849178, "CAD"), 5, "CA$487,587.84918"), # noqa: E201,E202
(Money( 0.348102, "EUR"), 1, "€0.3" ), # noqa: E201,E202
(Money( 0.916530, "GBP"), 1, "£0.9" ), # noqa: E201,E202
(Money( 61.031024, "JPY"), 3, "¥61.031" ), # noqa: E201,E202
(Money( 49609.694602, "JPY"), 1, "¥49,609.7" ), # noqa: E201,E202
(Money(155565.264777, "AUD"), 2, "A$155,565.26" ), # noqa: E201,E202
(Money( 0.820437, "CNY"), 4, "CN¥0.8204" ), # noqa: E201,E202
(Money( 7587.849178, "EUR"), 0, "€7,588" ), # noqa: E201,E202
(Money( 0.348102, "GBP"), 3, "£0.348" ), # noqa: E201,E202
(Money( 0.652923, "CHF"), 0, "CHF1" ), # noqa: E201,E202
(Money( 0.820437, "CNY"), 1, "CN¥0.8" ), # noqa: E201,E202
(Money(98789.5295680, "CHF"), 0, "CHF98,790" ), # noqa: E201,E202
(Money( 0.585787, "USD"), 1, "$0.6" ), # noqa: E201,E202
(Money( 0.690541, "CAD"), 3, "CA$0.691" ), # noqa: E201,E202
(Money( 427.814104, "AUD"), 5, "A$427.81410" ), # noqa: E201,E202
)
with self.settings(LANGUAGE_CODE="en-us"):
for value, decimal_places, expected_result in test_data:
result = InvenTree.format.format_money(value, decimal_places=decimal_places)
assert result == expected_result
class TestHelpers(TestCase):
"""Tests for InvenTree helper functions."""
def test_absolute_url(self):
"""Test helper function for generating an absolute URL"""
base = "https://demo.inventree.org:12345"
InvenTreeSetting.set_setting('INVENTREE_BASE_URL', base, change_user=None)
tests = {
"": base,
"api/": base + "/api/",
"/api/": base + "/api/",
"api": base + "/api",
"media/label/output/": base + "/media/label/output/",
"static/logo.png": base + "/static/logo.png",
"https://www.google.com": "https://www.google.com",
"https://demo.inventree.org:12345/out.html": "https://demo.inventree.org:12345/out.html",
"https://demo.inventree.org/test.html": "https://demo.inventree.org/test.html",
"http://www.cwi.nl:80/%7Eguido/Python.html": "http://www.cwi.nl:80/%7Eguido/Python.html",
"test.org": base + "/test.org",
}
for url, expected in tests.items():
# Test with supplied base URL
self.assertEqual(InvenTree.helpers_model.construct_absolute_url(url, site_url=base), expected)
# Test without supplied base URL
self.assertEqual(InvenTree.helpers_model.construct_absolute_url(url), expected)
def test_image_url(self):
"""Test if a filename looks like an image."""
for name in ['ape.png', 'bat.GiF', 'apple.WeBP', 'BiTMap.Bmp']:
@@ -263,9 +435,7 @@ class TestHelpers(TestCase):
def test_logo_image(self):
"""Test for retrieving logo image"""
# By default, there is no custom logo provided
logo = helpers.getLogoImage()
self.assertEqual(logo, '/static/img/inventree.png')
@@ -274,7 +444,6 @@ class TestHelpers(TestCase):
def test_download_image(self):
"""Test function for downloading image from remote URL"""
# Run check with a sequence of bad URLs
for url in [
"blog",
@@ -334,7 +503,6 @@ class TestHelpers(TestCase):
def test_model_mixin(self):
"""Test the getModelsWithMixin function"""
from InvenTree.models import InvenTreeBarcodeMixin
models = InvenTree.helpers_model.getModelsWithMixin(InvenTreeBarcodeMixin)
@@ -700,6 +868,7 @@ class CurrencyTests(TestCase):
else: # pragma: no cover
print("Exchange rate update failed - retrying")
print(f'Expected {currency_codes()}, got {[a.currency for a in rates]}')
time.sleep(1)
self.assertTrue(update_successful)
@@ -723,7 +892,7 @@ class CurrencyTests(TestCase):
class TestStatus(TestCase):
"""Unit tests for status functions."""
def test_check_system_healt(self):
def test_check_system_health(self):
"""Test that the system health check is false in testing -> background worker not running."""
self.assertEqual(status.check_system_health(), False)
@@ -819,7 +988,7 @@ class TestSettings(InvenTreeTestCase):
InvenTreeSetting.set_setting('PLUGIN_ON_STARTUP', True, self.user)
registry.reload_plugins(full_reload=True)
# Check that there was anotehr run
# Check that there was another run
response = registry.install_plugin_file()
self.assertEqual(response, True)
@@ -927,29 +1096,41 @@ class TestOffloadTask(InvenTreeTestCase):
Ref: https://github.com/inventree/InvenTree/pull/3273
"""
offload_task(
'dummy_tasks.parts',
part=Part.objects.get(pk=1),
cat=PartCategory.objects.get(pk=1),
force_async=True
)
offload_task(
self.assertTrue(offload_task(
'dummy_tasks.stock',
item=StockItem.objects.get(pk=1),
loc=StockLocation.objects.get(pk=1),
force_async=True
)
))
offload_task(
self.assertTrue(offload_task(
'dummy_task.numbers',
1, 2, 3, 4, 5,
force_async=True
)
))
# Offload a dummy task, but force sync
# This should fail, because the function does not exist
with self.assertLogs(logger='inventree', level='WARNING') as log:
self.assertFalse(offload_task(
'dummy_task.numbers',
1, 1, 1,
force_sync=True
))
self.assertIn("Malformed function path", str(log.output))
# Offload dummy task with a Part instance
# This should succeed, ensuring that the Part instance is correctly pickled
self.assertTrue(offload_task(
'dummy_tasks.parts',
part=Part.objects.get(pk=1),
cat=PartCategory.objects.get(pk=1),
force_async=True
))
def test_daily_holdoff(self):
"""Tests for daily task holdoff helper functions"""
import InvenTree.tasks
with self.assertLogs(logger='inventree', level='INFO') as cm:
@@ -1005,7 +1186,6 @@ class BarcodeMixinTest(InvenTreeTestCase):
def test_barcode_model_type(self):
"""Test that the barcode_model_type property works for each class"""
from part.models import Part
from stock.models import StockItem, StockLocation
@@ -1013,9 +1193,8 @@ class BarcodeMixinTest(InvenTreeTestCase):
self.assertEqual(StockItem.barcode_model_type(), 'stockitem')
self.assertEqual(StockLocation.barcode_model_type(), 'stocklocation')
def test_bacode_hash(self):
def test_barcode_hash(self):
"""Test that the barcode hashing function provides correct results"""
# Test multiple values for the hashing function
# This is to ensure that the hash function is always "backwards compatible"
hashing_tests = {
@@ -1042,5 +1221,42 @@ class SanitizerTest(TestCase):
# Test that valid string
self.assertEqual(valid_string, sanitize_svg(valid_string))
# Test that invalid string is cleanded
# Test that invalid string is cleaned
self.assertNotEqual(dangerous_string, sanitize_svg(dangerous_string))
class MagicLoginTest(InvenTreeTestCase):
"""Test magic login token generation."""
def test_generation(self):
"""Test that magic login tokens are generated correctly"""
# User does not exists
resp = self.client.post(reverse('sesame-generate'), {'email': 1})
self.assertEqual(resp.status_code, 200)
self.assertEqual(resp.data, {'status': 'ok'})
self.assertEqual(len(mail.outbox), 0)
# User exists
resp = self.client.post(reverse('sesame-generate'), {'email': self.user.email})
self.assertEqual(resp.status_code, 200)
self.assertEqual(resp.data, {'status': 'ok'})
self.assertEqual(len(mail.outbox), 1)
self.assertEqual(mail.outbox[0].subject, '[example.com] Log in to the app')
# Check that the token is in the email
self.assertTrue('http://testserver/api/email/login/' in mail.outbox[0].body)
token = mail.outbox[0].body.split('/')[-1].split('\n')[0][8:]
self.assertEqual(get_user(token), self.user)
# Log user off
self.client.logout()
# Check that the login works
resp = self.client.get(reverse('sesame-login') + '?sesame=' + token)
self.assertEqual(resp.status_code, 302)
self.assertEqual(resp.url, '/index/')
# Note: 2023-08-08 - This test has been changed because "platform UI" is not generally available yet
# TODO: In the future, the URL comparison will need to be reverted
# self.assertEqual(resp.url, f'/{settings.FRONTEND_URL_BASE}/logged-in/')
# And we should be logged in again
self.assertEqual(resp.wsgi_request.user, self.user)

View File

@@ -0,0 +1,49 @@
"""Translation helper functions"""
import json
from django.conf import settings
# translation completion stats
_translation_stats = None
def reload_translation_stats():
"""Reload the translation stats from the compiled file"""
global _translation_stats
STATS_FILE = settings.BASE_DIR.joinpath('InvenTree/locale_stats.json').absolute()
try:
with open(STATS_FILE, 'r') as f:
_translation_stats = json.load(f)
except Exception:
_translation_stats = None
return
keys = _translation_stats.keys()
# Note that the names used in the stats file may not align 100%
for (code, _lang) in settings.LANGUAGES:
if code in keys:
# Direct match, move on
continue
code_lower = code.lower().replace('-', '_')
for k in keys:
if k.lower() == code_lower:
# Make a copy of the code which matches
_translation_stats[code] = _translation_stats[k]
break
def get_translation_percent(lang_code):
"""Return the translation percentage for the given language code"""
if _translation_stats is None:
reload_translation_stats()
if _translation_stats is None:
return 0
return _translation_stats.get(lang_code, 0)

View File

@@ -143,7 +143,6 @@ class UserMixin:
def setUp(self):
"""Run setup for individual test methods"""
if self.auto_login:
self.client.login(username=self.username, password=self.password)
@@ -156,7 +155,6 @@ class UserMixin:
assign_all: Set to True to assign *all* roles
group: The group to assign roles to (or leave None to use the group assigned to this class)
"""
if group is None:
group = cls.group
@@ -207,7 +205,6 @@ class ExchangeRateMixin:
def generate_exchange_rates(self):
"""Helper function which generates some exchange rates to work with"""
rates = {
'AUD': 1.5,
'CAD': 1.7,
@@ -271,7 +268,6 @@ class InvenTreeAPITestCase(ExchangeRateMixin, UserMixin, APITestCase):
def checkResponse(self, url, method, expected_code, response):
"""Debug output for an unexpected response"""
# No expected code, return
if expected_code is None:
return
@@ -318,7 +314,6 @@ class InvenTreeAPITestCase(ExchangeRateMixin, UserMixin, APITestCase):
def post(self, url, data=None, expected_code=None, format='json'):
"""Issue a POST request."""
# Set default value - see B006
if data is None:
data = {}
@@ -331,7 +326,6 @@ class InvenTreeAPITestCase(ExchangeRateMixin, UserMixin, APITestCase):
def delete(self, url, data=None, expected_code=None, format='json'):
"""Issue a DELETE request."""
if data is None:
data = {}

View File

@@ -7,37 +7,48 @@ from django.conf import settings
from django.conf.urls.static import static
from django.contrib import admin
from django.urls import include, path, re_path
from django.views.decorators.csrf import csrf_exempt
from django.views.generic.base import RedirectView
from dj_rest_auth.registration.views import (ConfirmEmailView,
SocialAccountDisconnectView,
SocialAccountListView)
from drf_spectacular.views import SpectacularAPIView, SpectacularRedocView
from sesame.views import LoginView
from build.api import build_api_urls
import build.api
import common.api
import company.api
import label.api
import order.api
import part.api
import plugin.api
import report.api
import stock.api
import users.api
from build.urls import build_urls
from common.api import admin_api_urls, common_api_urls, settings_api_urls
from common.urls import common_urls
from company.api import company_api_urls
from company.urls import (company_urls, manufacturer_part_urls,
supplier_part_urls)
from label.api import label_api_urls
from order.api import order_api_urls
from order.urls import order_urls
from part.api import bom_api_urls, part_api_urls
from part.urls import part_urls
from plugin.api import plugin_api_urls
from plugin.urls import get_plugin_urls
from report.api import report_api_urls
from stock.api import stock_api_urls
from stock.urls import stock_urls
from users.api import user_urls
from web.urls import urlpatterns as platform_urls
from .api import APISearchView, InfoView, NotFoundView
from .api import (APISearchView, InfoView, NotFoundView, VersionTextView,
VersionView)
from .magic_login import GetSimpleLoginView
from .social_auth_urls import (EmailListView, EmailPrimaryView,
EmailRemoveView, EmailVerifyView,
SocialProviderListView, social_auth_urlpatterns)
from .views import (AboutView, AppearanceSelectView, CustomConnectionsView,
CustomEmailView, CustomLoginView,
CustomPasswordResetFromKeyView,
CustomSessionDeleteOtherView, CustomSessionDeleteView,
CustomTwoFactorRemove, DatabaseStatsView, DynamicJsView,
EditUserView, IndexView, NotificationsView, SearchView,
SetPasswordView, SettingsView, auth_request)
DatabaseStatsView, DynamicJsView, EditUserView, IndexView,
NotificationsView, SearchView, SetPasswordView,
SettingsView, auth_request)
admin.site.site_header = "InvenTree Admin"
@@ -47,29 +58,52 @@ apipatterns = [
# Global search
path('search/', APISearchView.as_view(), name='api-search'),
re_path(r'^settings/', include(settings_api_urls)),
re_path(r'^part/', include(part_api_urls)),
re_path(r'^bom/', include(bom_api_urls)),
re_path(r'^company/', include(company_api_urls)),
re_path(r'^stock/', include(stock_api_urls)),
re_path(r'^build/', include(build_api_urls)),
re_path(r'^order/', include(order_api_urls)),
re_path(r'^label/', include(label_api_urls)),
re_path(r'^report/', include(report_api_urls)),
re_path(r'^user/', include(user_urls)),
re_path(r'^admin/', include(admin_api_urls)),
re_path(r'^settings/', include(common.api.settings_api_urls)),
re_path(r'^part/', include(part.api.part_api_urls)),
re_path(r'^bom/', include(part.api.bom_api_urls)),
re_path(r'^company/', include(company.api.company_api_urls)),
re_path(r'^stock/', include(stock.api.stock_api_urls)),
re_path(r'^build/', include(build.api.build_api_urls)),
re_path(r'^order/', include(order.api.order_api_urls)),
re_path(r'^label/', include(label.api.label_api_urls)),
re_path(r'^report/', include(report.api.report_api_urls)),
re_path(r'^user/', include(users.api.user_urls)),
re_path(r'^admin/', include(common.api.admin_api_urls)),
# Plugin endpoints
path('', include(plugin_api_urls)),
path('', include(plugin.api.plugin_api_urls)),
# Common endpoints endpoint
path('', include(common_api_urls)),
path('', include(common.api.common_api_urls)),
# OpenAPI Schema
re_path('schema/', SpectacularAPIView.as_view(custom_settings={'SCHEMA_PATH_PREFIX': '/api/'}), name='schema'),
# InvenTree information endpoint
path('', InfoView.as_view(), name='api-inventree-info'),
# InvenTree information endpoints
path("version-text", VersionTextView.as_view(), name="api-version-text"), # version text
path('version/', VersionView.as_view(), name='api-version'), # version info
path('', InfoView.as_view(), name='api-inventree-info'), # server info
# Auth API endpoints
path('auth/', include([
re_path(r'^registration/account-confirm-email/(?P<key>[-:\w]+)/$', ConfirmEmailView.as_view(), name='account_confirm_email'),
path('registration/', include('dj_rest_auth.registration.urls')),
path('providers/', SocialProviderListView.as_view(), name='social_providers'),
path('emails/', include([path('<int:pk>/', include([
path('primary/', EmailPrimaryView.as_view(), name='email-primary'),
path('verify/', EmailVerifyView.as_view(), name='email-verify'),
path('remove/', EmailRemoveView().as_view(), name='email-remove'),])),
path('', EmailListView.as_view(), name='email-list')
])),
path('social/', include(social_auth_urlpatterns)),
path('social/', SocialAccountListView.as_view(), name='social_account_list'),
path('social/<int:pk>/disconnect/', SocialAccountDisconnectView.as_view(), name='social_account_disconnect'),
path('', include('dj_rest_auth.urls')),
])),
# Magic login URLs
path("email/generate/", csrf_exempt(GetSimpleLoginView().as_view()), name="sesame-generate",),
path("email/login/", LoginView.as_view(), name="sesame-login"),
# Unknown endpoint
re_path(r'^.*$', NotFoundView.as_view(), name='api-404'),
@@ -95,6 +129,7 @@ notifications_urls = [
dynamic_javascript_urls = [
re_path(r'^calendar.js', DynamicJsView.as_view(template_name='js/dynamic/calendar.js'), name='calendar.js'),
re_path(r'^nav.js', DynamicJsView.as_view(template_name='js/dynamic/nav.js'), name='nav.js'),
re_path(r'^permissions.js', DynamicJsView.as_view(template_name='js/dynamic/permissions.js'), name='permissions.js'),
re_path(r'^settings.js', DynamicJsView.as_view(template_name='js/dynamic/settings.js'), name='settings.js'),
]
@@ -110,6 +145,7 @@ translated_javascript_urls = [
re_path(r'^filters.js', DynamicJsView.as_view(template_name='js/translated/filters.js'), name='filters.js'),
re_path(r'^forms.js', DynamicJsView.as_view(template_name='js/translated/forms.js'), name='forms.js'),
re_path(r'^helpers.js', DynamicJsView.as_view(template_name='js/translated/helpers.js'), name='helpers.js'),
re_path(r'^index.js', DynamicJsView.as_view(template_name='js/translated/index.js'), name='index.js'),
re_path(r'^label.js', DynamicJsView.as_view(template_name='js/translated/label.js'), name='label.js'),
re_path(r'^model_renderers.js', DynamicJsView.as_view(template_name='js/translated/model_renderers.js'), name='model_renderers.js'),
re_path(r'^modals.js', DynamicJsView.as_view(template_name='js/translated/modals.js'), name='modals.js'),
@@ -142,7 +178,7 @@ backendpatterns = [
re_path(r'^api-doc/', SpectacularRedocView.as_view(url_name='schema'), name='api-doc'),
]
frontendpatterns = [
classic_frontendpatterns = [
# Apps
re_path(r'^build/', include(build_urls)),
@@ -164,10 +200,6 @@ frontendpatterns = [
re_path(r'^about/', AboutView.as_view(), name='about'),
re_path(r'^stats/', DatabaseStatsView.as_view(), name='stats'),
# admin sites
re_path(f'^{settings.INVENTREE_ADMIN_URL}/error_log/', include('error_report.urls')),
re_path(f'^{settings.INVENTREE_ADMIN_URL}/', admin.site.urls, name='inventree-admin'),
# DB user sessions
path('accounts/sessions/other/delete/', view=CustomSessionDeleteOtherView.as_view(), name='session_delete_other', ),
re_path(r'^accounts/sessions/(?P<pk>\w+)/delete/$', view=CustomSessionDeleteView.as_view(), name='session_delete', ),
@@ -178,10 +210,6 @@ frontendpatterns = [
re_path(r'^accounts/social/connections/', CustomConnectionsView.as_view(), name='socialaccount_connections'),
re_path(r"^accounts/password/reset/key/(?P<uidb36>[0-9A-Za-z]+)-(?P<key>.+)/$", CustomPasswordResetFromKeyView.as_view(), name="account_reset_password_from_key"),
# Temporary fix for django-allauth-2fa # TODO remove
# See https://github.com/inventree/InvenTree/security/advisories/GHSA-8j76-mm54-52xq
re_path(r'^accounts/two_factor/remove/?$', CustomTwoFactorRemove.as_view(), name='two-factor-remove'),
# Override login page
re_path("accounts/login/", CustomLoginView.as_view(), name="account_login"),
@@ -189,14 +217,32 @@ frontendpatterns = [
re_path(r'^accounts/', include('allauth.urls')), # included urlpatterns
]
new_frontendpatterns = platform_urls
urlpatterns = []
if settings.INVENTREE_ADMIN_ENABLED:
admin_url = settings.INVENTREE_ADMIN_URL,
urlpatterns += [
path(f'{admin_url}/error_log/', include('error_report.urls')),
path(f'{admin_url}/', admin.site.urls, name='inventree-admin'),
]
urlpatterns += backendpatterns
frontendpatterns = []
if settings.ENABLE_CLASSIC_FRONTEND:
frontendpatterns += classic_frontendpatterns
if settings.ENABLE_PLATFORM_FRONTEND:
frontendpatterns += new_frontendpatterns
urlpatterns += frontendpatterns
# Append custom plugin URLs (if plugin support is enabled)
if settings.PLUGINS_ENABLED:
frontendpatterns.append(get_plugin_urls())
urlpatterns = [
re_path('', include(frontendpatterns)),
re_path('', include(backendpatterns)),
]
urlpatterns.append(get_plugin_urls())
# Server running in "DEBUG" mode?
if settings.DEBUG:
@@ -213,5 +259,10 @@ if settings.DEBUG:
path('__debug__/', include(debug_toolbar.urls)),
] + urlpatterns
# Redirect for favicon.ico
urlpatterns.append(
path('favicon.ico', RedirectView.as_view(url=f'{settings.STATIC_URL}img/favicon/favicon.ico'))
)
# Send any unknown URLs to the parts page
urlpatterns += [re_path(r'^.*$', RedirectView.as_view(url='/index/', permanent=False), name='index')]

View File

@@ -17,7 +17,6 @@ import InvenTree.conversion
def validate_physical_units(unit):
"""Ensure that a given unit is a valid physical unit."""
unit = unit.strip()
# Ignore blank units
@@ -69,7 +68,6 @@ class AllowedURLValidator(validators.URLValidator):
def validate_purchase_order_reference(value):
"""Validate the 'reference' field of a PurchaseOrder."""
from order.models import PurchaseOrder
# If we get to here, run the "default" validation routine
@@ -78,7 +76,6 @@ def validate_purchase_order_reference(value):
def validate_sales_order_reference(value):
"""Validate the 'reference' field of a SalesOrder."""
from order.models import SalesOrder
# If we get to here, run the "default" validation routine
@@ -140,7 +137,6 @@ def validate_part_name_format(value):
Make sure that each template container has a field of Part Model
"""
# Make sure that the field_name exists in Part model
from part.models import Part

View File

@@ -7,6 +7,7 @@ import os
import pathlib
import platform
import re
import sys
from datetime import datetime as dt
from datetime import timedelta as td
@@ -15,19 +16,40 @@ from django.conf import settings
from dulwich.repo import NotGitRepository, Repo
from .api_version import INVENTREE_API_VERSION
from .api_version import INVENTREE_API_TEXT, INVENTREE_API_VERSION
# InvenTree software version
INVENTREE_SW_VERSION = "0.12.0 dev"
INVENTREE_SW_VERSION = "0.13.0 dev"
# Discover git
try:
main_repo = Repo(pathlib.Path(__file__).parent.parent.parent)
main_commit = main_repo[main_repo.head()]
except NotGitRepository:
except (NotGitRepository, FileNotFoundError):
main_commit = None
def checkMinPythonVersion():
"""Check that the Python version is at least 3.9"""
version = sys.version.split(" ")[0]
docs = "https://docs.inventree.org/en/stable/start/intro/#python-requirements"
msg = f"""
InvenTree requires Python 3.9 or above - you are running version {version}.
- Refer to the InvenTree documentation for more information:
- {docs}
"""
if sys.version_info.major < 3:
raise RuntimeError(msg)
if sys.version_info.major == 3 and sys.version_info.minor < 9:
raise RuntimeError(msg)
print(f"Python version {version} - {sys.executable}")
def inventreeInstanceName():
"""Returns the InstanceName settings for the current database."""
import common.models
@@ -41,8 +63,7 @@ def inventreeInstanceTitle():
if common.models.InvenTreeSetting.get_setting("INVENTREE_INSTANCE_TITLE", False):
return common.models.InvenTreeSetting.get_setting("INVENTREE_INSTANCE", "")
else:
return 'InvenTree'
return 'InvenTree'
def inventreeVersion():
@@ -73,8 +94,28 @@ def inventreeDocsVersion():
"""
if isInvenTreeDevelopmentVersion():
return "latest"
else:
return INVENTREE_SW_VERSION # pragma: no cover
return INVENTREE_SW_VERSION # pragma: no cover
def inventreeDocUrl():
"""Return URL for InvenTree documentation site."""
tag = inventreeDocsVersion()
return f"https://docs.inventree.org/en/{tag}"
def inventreeAppUrl():
"""Return URL for InvenTree app site."""
return f'{inventreeDocUrl()}/app/app',
def inventreeCreditsUrl():
"""Return URL for InvenTree credits site."""
return "https://docs.inventree.org/en/latest/credits/"
def inventreeGithubUrl():
"""Return URL for InvenTree github site."""
return "https://github.com/InvenTree/InvenTree/"
def isInvenTreeUpToDate():
@@ -101,11 +142,62 @@ def inventreeApiVersion():
return INVENTREE_API_VERSION
def parse_version_text():
"""Parse the version text to structured data."""
patched_data = INVENTREE_API_TEXT.split("\n\n")
# Remove first newline on latest version
patched_data[0] = patched_data[0].replace("\n", "", 1)
version_data = {}
for version in patched_data:
data = version.split("\n")
version_split = data[0].split(' -> ')
version_detail = version_split[1].split(':', 1) if len(version_split) > 1 else ['', ]
new_data = {
"version": version_split[0].strip(),
"date": version_detail[0].strip(),
"gh": version_detail[1].strip() if len(version_detail) > 1 else None,
"text": data[1:],
"latest": False,
}
version_data[new_data["version"]] = new_data
return version_data
INVENTREE_API_TEXT_DATA = parse_version_text()
"""Pre-processed API version text."""
def inventreeApiText(versions: int = 10, start_version: int = 0):
"""Returns API version descriptors.
Args:
versions: Number of versions to return. Default: 10
start_version: first version to report. Defaults to return the latest {versions} versions.
"""
version_data = INVENTREE_API_TEXT_DATA
# Define the range of versions to return
if start_version == 0:
start_version = INVENTREE_API_VERSION - versions
return {
f"v{a}": version_data.get(f"v{a}", None)
for a in range(start_version, start_version + versions)
}
def inventreeDjangoVersion():
"""Returns the version of Django library."""
return django.get_version()
def inventreePythonVersion():
"""Returns the version of python"""
return sys.version.split(' ')[0]
def inventreeCommitHash():
"""Returns the git commit hash for the running codebase."""
# First look in the environment variables, i.e. if running in docker
@@ -162,8 +254,11 @@ def inventreeBranch():
if main_commit is None:
return None
branch = main_repo.refs.follow(b'HEAD')[0][1].decode()
return branch.removeprefix('refs/heads/')
try:
branch = main_repo.refs.follow(b'HEAD')[0][1].decode()
return branch.removeprefix('refs/heads/')
except IndexError:
return None # pragma: no cover
def inventreeTarget():
@@ -175,5 +270,10 @@ def inventreeTarget():
def inventreePlatform():
"""Returns the platform for the instance."""
return platform.platform(aliased=True)
def inventreeDatabase():
"""Return the InvenTree database backend e.g. 'postgresql'."""
db = settings.DATABASES['default']
return db.get('ENGINE', None).replace('django.db.backends.', '')

View File

@@ -4,9 +4,6 @@ In particular these views provide base functionality for rendering Django forms
as JSON objects and passing them to modal forms (using jQuery / bootstrap).
"""
import json
from django.conf import settings
from django.contrib.auth import password_validation
from django.contrib.auth.mixins import (LoginRequiredMixin,
PermissionRequiredMixin)
@@ -27,7 +24,6 @@ from allauth.account.views import (EmailView, LoginView,
PasswordResetFromKeyView)
from allauth.socialaccount.forms import DisconnectForm
from allauth.socialaccount.views import ConnectionsView
from allauth_2fa.views import TwoFactorRemove
from djmoney.contrib.exchange.models import ExchangeBackend, Rate
from user_sessions.views import SessionDeleteOtherView, SessionDeleteView
@@ -47,8 +43,7 @@ def auth_request(request):
"""
if request.user.is_authenticated:
return HttpResponse(status=200)
else:
return HttpResponse(status=403)
return HttpResponse(status=403)
class InvenTreeRoleMixin(PermissionRequiredMixin):
@@ -226,8 +221,7 @@ class AjaxMixin(InvenTreeRoleMixin):
"""
if method == 'POST':
return self.request.POST.get(name, None)
else:
return self.request.GET.get(name, None)
return self.request.GET.get(name, None)
def get_data(self):
"""Get extra context data (default implementation is empty dict).
@@ -447,8 +441,7 @@ class SetPasswordView(AjaxUpdateView):
if valid:
# Old password must be correct
if not user.check_password(old_password):
if user.has_usable_password() and not user.check_password(old_password):
form.add_error('old_password', _('Wrong password provided'))
valid = False
@@ -532,14 +525,6 @@ class SettingsView(TemplateView):
except Exception:
ctx["rates_updated"] = None
# load locale stats
STAT_FILE = settings.BASE_DIR.joinpath('InvenTree/locale_stats.json').absolute()
try:
ctx["locale_stats"] = json.load(open(STAT_FILE, 'r'))
except Exception:
ctx["locale_stats"] = {}
# Forms and context for allauth
ctx['add_email_form'] = AddEmailForm
ctx["can_add_email"] = EmailAddress.objects.can_add_email(self.request.user)
@@ -640,8 +625,12 @@ class AppearanceSelectView(RedirectView):
user_theme = common_models.ColorTheme()
user_theme.user = request.user
user_theme.name = theme
user_theme.save()
if theme:
try:
user_theme.name = theme
user_theme.save()
except Exception:
pass
return redirect(reverse_lazy('settings'))
@@ -664,9 +653,3 @@ class NotificationsView(TemplateView):
"""View for showing notifications."""
template_name = "InvenTree/notifications/notifications.html"
# Custom 2FA removal form to allow custom redirect URL
class CustomTwoFactorRemove(TwoFactorRemove):
"""Specify custom URL redirect."""
success_url = reverse_lazy("settings")

View File

@@ -1,6 +1,6 @@
"""JSON API for the Build app."""
from django.db.models import F
from django.db.models import F, Q
from django.urls import include, path, re_path
from django.utils.translation import gettext_lazy as _
from django.contrib.auth.models import User
@@ -11,7 +11,7 @@ from django_filters.rest_framework import DjangoFilterBackend
from django_filters import rest_framework as rest_filters
from InvenTree.api import AttachmentMixin, APIDownloadMixin, ListCreateDestroyAPIView, MetadataView
from generic.states import StatusView
from generic.states.api import StatusView
from InvenTree.helpers import str2bool, isNull, DownloadFile
from InvenTree.status_codes import BuildStatus, BuildStatusGroups
from InvenTree.mixins import CreateAPI, RetrieveUpdateDestroyAPI, ListCreateAPI
@@ -35,6 +35,7 @@ class BuildFilter(rest_filters.FilterSet):
'parent',
'sales_order',
'part',
'issued_by',
]
status = rest_filters.NumberFilter(label='Status')
@@ -45,8 +46,7 @@ class BuildFilter(rest_filters.FilterSet):
"""Filter the queryset to either include or exclude orders which are active."""
if str2bool(value):
return queryset.filter(status__in=BuildStatusGroups.ACTIVE_CODES)
else:
return queryset.exclude(status__in=BuildStatusGroups.ACTIVE_CODES)
return queryset.exclude(status__in=BuildStatusGroups.ACTIVE_CODES)
overdue = rest_filters.BooleanFilter(label='Build is overdue', method='filter_overdue')
@@ -54,8 +54,7 @@ class BuildFilter(rest_filters.FilterSet):
"""Filter the queryset to either include or exclude orders which are overdue."""
if str2bool(value):
return queryset.filter(Build.OVERDUE_FILTER)
else:
return queryset.exclude(Build.OVERDUE_FILTER)
return queryset.exclude(Build.OVERDUE_FILTER)
assigned_to_me = rest_filters.BooleanFilter(label='assigned_to_me', method='filter_assigned_to_me')
@@ -68,8 +67,7 @@ class BuildFilter(rest_filters.FilterSet):
if value:
return queryset.filter(responsible__in=owners)
else:
return queryset.exclude(responsible__in=owners)
return queryset.exclude(responsible__in=owners)
assigned_to = rest_filters.NumberFilter(label='responsible', method='filter_responsible')
@@ -99,11 +97,9 @@ class BuildFilter(rest_filters.FilterSet):
def filter_has_project_code(self, queryset, name, value):
"""Filter by whether or not the order has a project code"""
if str2bool(value):
return queryset.exclude(project_code=None)
else:
return queryset.filter(project_code=None)
return queryset.filter(project_code=None)
class BuildList(APIDownloadMixin, ListCreateAPI):
@@ -234,7 +230,6 @@ class BuildDetail(RetrieveUpdateDestroyAPI):
def destroy(self, request, *args, **kwargs):
"""Only allow deletion of a BuildOrder if the build status is CANCELLED"""
build = self.get_object()
if build.status != BuildStatus.CANCELLED:
@@ -291,11 +286,26 @@ class BuildLineFilter(rest_filters.FilterSet):
def filter_allocated(self, queryset, name, value):
"""Filter by whether each BuildLine is fully allocated"""
if str2bool(value):
return queryset.filter(allocated__gte=F('quantity'))
else:
return queryset.filter(allocated__lt=F('quantity'))
return queryset.filter(allocated__lt=F('quantity'))
available = rest_filters.BooleanFilter(label=_('Available'), method='filter_available')
def filter_available(self, queryset, name, value):
"""Filter by whether there is sufficient stock available for each BuildLine:
To determine this, we need to know:
- The quantity required for each BuildLine
- The quantity available for each BuildLine
- The quantity allocated for each BuildLine
"""
flt = Q(quantity__lte=F('total_available_stock') + F('allocated'))
if str2bool(value):
return queryset.filter(flt)
return queryset.exclude(flt)
class BuildLineEndpoint:
@@ -308,10 +318,6 @@ class BuildLineEndpoint:
"""Override queryset to select-related and annotate"""
queryset = super().get_queryset()
queryset = queryset.select_related(
'build', 'bom_item',
)
queryset = build.serializers.BuildLineSerializer.annotate_queryset(queryset)
return queryset
@@ -496,8 +502,7 @@ class BuildItemFilter(rest_filters.FilterSet):
"""Filter the queryset based on whether build items are tracked"""
if str2bool(value):
return queryset.exclude(install_into=None)
else:
return queryset.filter(install_into=None)
return queryset.filter(install_into=None)
class BuildItemList(ListCreateAPI):
@@ -567,10 +572,6 @@ class BuildAttachmentList(AttachmentMixin, ListCreateDestroyAPIView):
queryset = BuildOrderAttachment.objects.all()
serializer_class = build.serializers.BuildAttachmentSerializer
filter_backends = [
DjangoFilterBackend,
]
filterset_fields = [
'build',
]

View File

@@ -28,6 +28,7 @@ from build.validators import generate_next_build_reference, validate_build_order
import InvenTree.fields
import InvenTree.helpers
import InvenTree.helpers_model
import InvenTree.mixins
import InvenTree.models
import InvenTree.ready
import InvenTree.tasks
@@ -44,7 +45,7 @@ import users.models
logger = logging.getLogger('inventree')
class Build(MPTTModel, InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models.InvenTreeNotesMixin, InvenTree.models.MetadataMixin, InvenTree.models.ReferenceIndexingMixin):
class Build(MPTTModel, InvenTree.mixins.DiffMixin, InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models.InvenTreeNotesMixin, InvenTree.models.MetadataMixin, InvenTree.models.ReferenceIndexingMixin):
"""A Build object organises the creation of new StockItem objects from other existing StockItem objects.
Attributes:
@@ -108,6 +109,12 @@ class Build(MPTTModel, InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models.
self.validate_reference_field(self.reference)
self.reference_int = self.rebuild_reference_field(self.reference)
# Prevent changing target part after creation
if self.has_field_changed('part'):
raise ValidationError({
'part': _('Build order part cannot be changed')
})
try:
super().save(*args, **kwargs)
except InvalidMove:
@@ -314,9 +321,8 @@ class Build(MPTTModel, InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models.
"""Return all Build Order objects under this one."""
if cascade:
return Build.objects.filter(parent=self.pk)
else:
descendants = self.get_descendants(include_self=True)
Build.objects.filter(parent__pk__in=[d.pk for d in descendants])
descendants = self.get_descendants(include_self=True)
Build.objects.filter(parent__pk__in=[d.pk for d in descendants])
def sub_build_count(self, cascade=True):
"""Return the number of sub builds under this one.
@@ -348,7 +354,6 @@ class Build(MPTTModel, InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models.
@property
def tracked_line_items(self):
"""Returns the "trackable" BOM lines for this BuildOrder."""
return self.build_lines.filter(bom_item__sub_part__trackable=True)
def has_tracked_line_items(self):
@@ -358,9 +363,13 @@ class Build(MPTTModel, InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models.
@property
def untracked_line_items(self):
"""Returns the "non trackable" BOM items for this BuildOrder."""
return self.build_lines.filter(bom_item__sub_part__trackable=False)
@property
def are_untracked_parts_allocated(self):
"""Returns True if all untracked parts are allocated for this BuildOrder."""
return self.is_fully_allocated(tracked=False)
def has_untracked_line_items(self):
"""Returns True if this BuildOrder has non trackable BomItems."""
return self.has_untracked_line_items.count() > 0
@@ -427,7 +436,6 @@ class Build(MPTTModel, InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models.
def is_partially_allocated(self):
"""Test is this build order has any stock allocated against it"""
return self.allocated_stock.count() > 0
@property
@@ -492,7 +500,6 @@ class Build(MPTTModel, InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models.
- Completed count must meet the required quantity
- Untracked parts must be allocated
"""
if self.incomplete_count > 0:
return False
@@ -714,14 +721,22 @@ class Build(MPTTModel, InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models.
if items.exists() and items.count() == 1:
stock_item = items[0]
# Allocate the stock item
BuildItem.objects.create(
build=self,
bom_item=bom_item,
stock_item=stock_item,
quantity=1,
install_into=output,
)
# Find the 'BuildLine' object which points to this BomItem
try:
build_line = BuildLine.objects.get(
build=self,
bom_item=bom_item
)
# Allocate the stock items against the BuildLine
BuildItem.objects.create(
build_line=build_line,
stock_item=stock_item,
quantity=1,
install_into=output,
)
except BuildLine.DoesNotExist:
pass
else:
"""Create a single build output of the given quantity."""
@@ -767,7 +782,6 @@ class Build(MPTTModel, InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models.
@transaction.atomic
def trim_allocated_stock(self):
"""Called after save to reduce allocated stock if the build order is now overallocated."""
# Only need to worry about untracked stock here
for build_line in self.untracked_line_items:
@@ -804,7 +818,6 @@ class Build(MPTTModel, InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models.
@transaction.atomic
def subtract_allocated_stock(self, user):
"""Called when the Build is marked as "complete", this function removes the allocated untracked items from stock."""
# Find all BuildItem objects which point to this build
items = self.allocated_stock.filter(
build_line__bom_item__sub_part__trackable=False
@@ -826,7 +839,6 @@ class Build(MPTTModel, InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models.
- Set the item status to "scrapped"
- Add a transaction entry to the stock item history
"""
if not output:
raise ValidationError(_("No build output specified"))
@@ -951,8 +963,7 @@ class Build(MPTTModel, InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models.
return 1
elif item.part in variant_parts:
return 2
else:
return 3
return 3
new_items = []
@@ -1056,7 +1067,6 @@ class Build(MPTTModel, InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models.
def unallocated_lines(self, tracked=None):
"""Returns a list of BuildLine objects which have not been fully allocated."""
lines = self.build_lines.all()
if tracked is True:
@@ -1083,7 +1093,6 @@ class Build(MPTTModel, InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models.
Returns:
True if the BuildOrder has been fully allocated, otherwise False
"""
lines = self.unallocated_lines(tracked=tracked)
return len(lines) == 0
@@ -1096,7 +1105,6 @@ class Build(MPTTModel, InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models.
To determine if the output has been fully allocated,
we need to test all "trackable" BuildLine objects
"""
for line in self.build_lines.filter(bom_item__sub_part__trackable=True):
# Grab all BuildItem objects which point to this output
allocations = BuildItem.objects.filter(
@@ -1121,7 +1129,6 @@ class Build(MPTTModel, InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models.
Returns:
True if any BuildLine has been over-allocated.
"""
for line in self.build_lines.all():
if line.is_overallocated():
return True
@@ -1146,18 +1153,17 @@ class Build(MPTTModel, InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models.
@transaction.atomic
def create_build_line_items(self, prevent_duplicates=True):
"""Create BuildLine objects for each BOM line in this BuildOrder."""
lines = []
bom_items = self.part.get_bom_items()
logger.info(f"Creating BuildLine objects for BuildOrder {self.pk} ({len(bom_items)} items))")
logger.info("Creating BuildLine objects for BuildOrder %s (%s items)", self.pk, len(bom_items))
# Iterate through each part required to build the parent part
for bom_item in bom_items:
if prevent_duplicates:
if BuildLine.objects.filter(build=self, bom_item=bom_item).exists():
logger.info(f"BuildLine already exists for BuildOrder {self.pk} and BomItem {bom_item.pk}")
logger.info("BuildLine already exists for BuildOrder %s and BomItem %s", self.pk, bom_item.pk)
continue
# Calculate required quantity
@@ -1173,12 +1179,12 @@ class Build(MPTTModel, InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models.
BuildLine.objects.bulk_create(lines)
logger.info(f"Created {len(lines)} BuildLine objects for BuildOrder")
if len(lines) > 0:
logger.info("Created %s BuildLine objects for BuildOrder", len(lines))
@transaction.atomic
def update_build_line_items(self):
"""Rebuild required quantity field for each BuildLine object"""
lines_to_update = []
for line in self.build_lines.all():
@@ -1187,7 +1193,7 @@ class Build(MPTTModel, InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models.
BuildLine.objects.bulk_update(lines_to_update, ['quantity'])
logger.info(f"Updated {len(lines_to_update)} BuildLine objects for BuildOrder")
logger.info("Updated %s BuildLine objects for BuildOrder", len(lines_to_update))
@receiver(post_save, sender=Build, dispatch_uid='build_post_save_log')
@@ -1282,7 +1288,6 @@ class BuildLine(models.Model):
def allocated_quantity(self):
"""Calculate the total allocated quantity for this BuildLine"""
# Queryset containing all BuildItem objects allocated against this BuildLine
allocations = self.allocations.all()
@@ -1298,7 +1303,6 @@ class BuildLine(models.Model):
def is_fully_allocated(self):
"""Return True if this BuildLine is fully allocated"""
if self.bom_item.consumable:
return True
@@ -1508,28 +1512,6 @@ class BuildItem(InvenTree.models.MetadataMixin, models.Model):
}
)
def getStockItemThumbnail(self):
"""Return qualified URL for part thumbnail image."""
thumb_url = None
if self.stock_item and self.stock_item.part:
try:
# Try to extract the thumbnail
thumb_url = self.stock_item.part.image.thumbnail.url
except Exception:
pass
if thumb_url is None and self.bom_item and self.bom_item.sub_part:
try:
thumb_url = self.bom_item.sub_part.image.thumbnail.url
except Exception:
pass
if thumb_url is not None:
return InvenTree.helpers.getMediaUrl(thumb_url)
else:
return InvenTree.helpers.getBlankThumbnail()
build_line = models.ForeignKey(
BuildLine,
on_delete=models.SET_NULL, null=True,

View File

@@ -129,7 +129,6 @@ class BuildSerializer(InvenTreeModelSerializer):
def validate_reference(self, reference):
"""Custom validation for the Build reference field"""
# Ensure the reference matches the required pattern
Build.validate_reference_field(reference)
@@ -209,7 +208,6 @@ class BuildOutputQuantitySerializer(BuildOutputSerializer):
def validate(self, data):
"""Validate the serializer data"""
data = super().validate(data)
output = data.get('output')
@@ -450,7 +448,6 @@ class BuildOutputScrapSerializer(serializers.Serializer):
def save(self):
"""Save the serializer to scrap the build outputs"""
build = self.context['build']
request = self.context['request']
data = self.validated_data
@@ -625,12 +622,11 @@ class BuildCompleteSerializer(serializers.Serializer):
This is so we can determine (at run time) whether the build is ready to be completed.
"""
build = self.context['build']
return {
'overallocated': build.is_overallocated(),
'allocated': build.is_fully_allocated(),
'allocated': build.are_untracked_parts_allocated,
'remaining': build.remaining,
'incomplete': build.incomplete_count,
}
@@ -663,7 +659,7 @@ class BuildCompleteSerializer(serializers.Serializer):
"""Check if the 'accept_unallocated' field is required"""
build = self.context['build']
if not build.is_fully_allocated() and not value:
if not build.are_untracked_parts_allocated and not value:
raise ValidationError(_('Required stock has not been fully allocated'))
return value
@@ -866,10 +862,6 @@ class BuildAllocationItemSerializer(serializers.Serializer):
'output': _('Build output cannot be specified for allocation of untracked parts'),
})
# Check if this allocation would be unique
if BuildItem.objects.filter(build_line=build_line, stock_item=stock_item, install_into=output).exists():
raise ValidationError(_('This stock item has already been allocated to this build output'))
return data
@@ -914,12 +906,16 @@ class BuildAllocationSerializer(serializers.Serializer):
try:
# Create a new BuildItem to allocate stock
BuildItem.objects.create(
build_item, created = BuildItem.objects.get_or_create(
build_line=build_line,
stock_item=stock_item,
quantity=quantity,
install_into=output
install_into=output,
)
if created:
build_item.quantity = quantity
else:
build_item.quantity += quantity
build_item.save()
except (ValidationError, DjangoValidationError) as exc:
# Catch model errors and re-throw as DRF errors
raise ValidationError(detail=serializers.as_serializer_error(exc))
@@ -1012,7 +1008,7 @@ class BuildItemSerializer(InvenTreeModelSerializer):
build = serializers.PrimaryKeyRelatedField(source='build_line.build', many=False, read_only=True)
# Extra (optional) detail fields
part_detail = PartBriefSerializer(source='stock_item.part', many=False, read_only=True)
part_detail = PartBriefSerializer(source='stock_item.part', many=False, read_only=True, pricing=False)
stock_item_detail = StockItemSerializerBrief(source='stock_item', read_only=True)
location_detail = LocationSerializer(source='stock_item.location', read_only=True)
build_detail = BuildSerializer(source='build_line.build', many=False, read_only=True)
@@ -1063,6 +1059,7 @@ class BuildLineSerializer(InvenTreeModelSerializer):
'available_stock',
'available_substitute_stock',
'available_variant_stock',
'total_available_stock',
]
read_only_fields = [
@@ -1074,8 +1071,8 @@ class BuildLineSerializer(InvenTreeModelSerializer):
quantity = serializers.FloatField()
# Foreign key fields
bom_item_detail = BomItemSerializer(source='bom_item', many=False, read_only=True)
part_detail = PartSerializer(source='bom_item.sub_part', many=False, read_only=True)
bom_item_detail = BomItemSerializer(source='bom_item', many=False, read_only=True, pricing=False)
part_detail = PartSerializer(source='bom_item.sub_part', many=False, read_only=True, pricing=False)
allocations = BuildItemSerializer(many=True, read_only=True)
# Annotated (calculated) fields
@@ -1084,6 +1081,7 @@ class BuildLineSerializer(InvenTreeModelSerializer):
available_stock = serializers.FloatField(read_only=True)
available_substitute_stock = serializers.FloatField(read_only=True)
available_variant_stock = serializers.FloatField(read_only=True)
total_available_stock = serializers.FloatField(read_only=True)
@staticmethod
def annotate_queryset(queryset):
@@ -1093,17 +1091,28 @@ class BuildLineSerializer(InvenTreeModelSerializer):
- available: Total stock available for allocation against this build line
- on_order: Total stock on order for this build line
"""
queryset = queryset.select_related(
'build', 'bom_item',
)
# Pre-fetch related fields
queryset = queryset.prefetch_related(
'bom_item__sub_part',
'bom_item__sub_part__stock_items',
'bom_item__sub_part__stock_items__allocations',
'bom_item__sub_part__stock_items__sales_order_allocations',
'bom_item__sub_part__tags',
'bom_item__substitutes',
'bom_item__substitutes__part__stock_items',
'bom_item__substitutes__part__stock_items__allocations',
'bom_item__substitutes__part__stock_items__sales_order_allocations',
'allocations',
'allocations__stock_item',
'allocations__stock_item__part',
'allocations__stock_item__location',
'allocations__stock_item__location__tags',
)
# Annotate the "allocated" quantity
@@ -1173,6 +1182,14 @@ class BuildLineSerializer(InvenTreeModelSerializer):
)
)
# Annotate with the 'total available stock'
queryset = queryset.annotate(
total_available_stock=ExpressionWrapper(
F('available_stock') + F('available_substitute_stock') + F('available_variant_stock'),
output_field=FloatField(),
)
)
return queryset

View File

@@ -24,6 +24,54 @@ import part.models as part_models
logger = logging.getLogger('inventree')
def update_build_order_lines(bom_item_pk: int):
"""Update all BuildOrderLineItem objects which reference a particular BomItem.
This task is triggered when a BomItem is created or updated.
"""
logger.info("Updating build order lines for BomItem %s", bom_item_pk)
bom_item = part_models.BomItem.objects.filter(pk=bom_item_pk).first()
# If the BomItem has been deleted, there is nothing to do
if not bom_item:
return
assemblies = bom_item.get_assemblies()
# Find all active builds which reference any of the parts
builds = build.models.Build.objects.filter(
part__in=list(assemblies),
status__in=BuildStatusGroups.ACTIVE_CODES
)
# Iterate through each build, and update the relevant line items
for bo in builds:
# Try to find a matching build order line
line = build.models.BuildLine.objects.filter(
build=bo,
bom_item=bom_item,
).first()
q = bom_item.get_required_quantity(bo.quantity)
if line:
# Ensure quantity is correct
if line.quantity != q:
line.quantity = q
line.save()
else:
# Create a new line item
build.models.BuildLine.objects.create(
build=bo,
bom_item=bom_item,
quantity=q,
)
if builds.count() > 0:
logger.info("Updated %s build orders for part %s", builds.count(), bom_item.part)
def check_build_stock(build: build.models.Build):
"""Check the required stock for a newly created build order.
@@ -45,7 +93,7 @@ def check_build_stock(build: build.models.Build):
part = build.part
except part_models.Part.DoesNotExist:
# Note: This error may be thrown during unit testing...
logger.error("Invalid build.part passed to 'build.tasks.check_build_stock'")
logger.exception("Invalid build.part passed to 'build.tasks.check_build_stock'")
return
for bom_item in part.get_bom_items():
@@ -86,7 +134,7 @@ def check_build_stock(build: build.models.Build):
if len(emails) > 0:
logger.info(f"Notifying users of stock required for build {build.pk}")
logger.info("Notifying users of stock required for build %s", build.pk)
context = {
'link': InvenTree.helpers_model.construct_absolute_url(build.get_absolute_url()),
@@ -107,7 +155,6 @@ def check_build_stock(build: build.models.Build):
def notify_overdue_build_order(bo: build.models.Build):
"""Notify appropriate users that a Build has just become 'overdue'"""
targets = []
if bo.issued_by:
@@ -153,7 +200,6 @@ def check_overdue_build_orders():
- Look at the 'target_date' of any outstanding BuildOrder objects
- If the 'target_date' expired *yesterday* then the order is just out of date
"""
yesterday = datetime.now().date() - timedelta(days=1)
overdue_orders = build.models.Build.objects.filter(

View File

@@ -29,10 +29,9 @@ src="{% static 'img/blank_image.png' %}"
{% block actions %}
<!-- Admin Display -->
{% if user.is_staff and roles.build.change %}
{% url 'admin:build_build_change' build.pk as url %}
{% admin_url user "build.build" build.pk as url %}
{% include "admin_button.html" with url=url %}
{% endif %}
{% if barcodes %}
<!-- Barcode actions menu -->
<div class='btn-group' role='group'>

View File

@@ -106,7 +106,7 @@
<tr>
<td><span class='fas fa-link'></span></td>
<td>{% trans "External Link" %}</td>
<td>{% include 'clip_link.html' with link=build.link %}</td>
<td>{% include 'clip_link.html' with link=build.link new_window=True %}</td>
</tr>
{% endif %}
{% if build.issued_by %}
@@ -165,9 +165,7 @@
</div>
<div class='panel-content'>
<div id='child-button-toolbar'>
<div class='button-toolbar container-fluid float-right'>
{% include "filter_list.html" with id='sub-build' %}
</div>
{% include "filter_list.html" with id='sub-build' %}
</div>
<table class='table table-striped table-condensed' id='sub-build-table' data-toolbar='#child-button-toolbar'></table>
</div>
@@ -199,26 +197,8 @@
</div>
</div>
<div class='panel-content'>
{% if build.active %}
{% if build.is_fully_allocated %}
<div class='alert alert-block alert-success'>
{% trans "Untracked stock has been fully allocated for this Build Order" %}
</div>
{% else %}
<div class='alert alert-block alert-danger'>
{% trans "Untracked stock has not been fully allocated for this Build Order" %}
</div>
{% endif %}
{% endif %}
<div id='build-lines-toolbar'>
<div class='button-toolbar container-fluid' style='float: right;'>
<div class='btn-group'>
<button id='allocate-selected-items' class='btn btn-success' title='{% trans "Allocate selected items" %}'>
<span class='fas fa-sign-in-alt'></span>
</button>
{% include "filter_list.html" with id='buildlines' %}
</div>
</div>
{% include "filter_list.html" with id='buildlines' %}
</div>
<table class='table table-striped table-condensed' id='build-lines-table' data-toolbar='#build-lines-toolbar'></table>
</div>
@@ -240,37 +220,7 @@
</div>
<div class='panel-content'>
<div id='build-output-toolbar'>
<div class='button-toolbar container-fluid'>
{% if build.active %}
<div class='btn-group'>
<!-- Build output actions -->
<div class='btn-group'>
<button id='output-options' class='btn btn-primary dropdown-toggle' type='button' data-bs-toggle='dropdown' title='{% trans "Output Actions" %}'>
<span class='fas fa-tools'></span> <span class='caret'></span>
</button>
<ul class='dropdown-menu'>
{% if roles.build.add %}
<li><a class='dropdown-item' href='#' id='multi-output-complete' title='{% trans "Complete selected build outputs" %}'>
<span class='fas fa-check-circle icon-green'></span> {% trans "Complete outputs" %}
</a></li>
{% endif %}
{% if roles.build.change %}
<li><a class='dropdown-item' href='#' id='multi-output-scrap' title='{% trans "Scrap selected build outputs" %}'>
<span class='fas fa-times-circle icon-red'></span> {% trans "Scrap outputs" %}
</a></li>
{% endif %}
{% if roles.build.delete %}
<li><a class='dropdown-item' href='#' id='multi-output-delete' title='{% trans "Delete selected build outputs" %}'>
<span class='fas fa-trash-alt icon-red'></span> {% trans "Delete outputs" %}
</a></li>
{% endif %}
</ul>
</div>
{% include "filter_list.html" with id='incompletebuilditems' %}
</div>
{% endif %}
</div>
{% include "filter_list.html" with id='incompletebuilditems' %}
</div>
<table class='table table-striped table-condensed' id='build-output-table' data-toolbar='#build-output-toolbar'></table>
</div>
@@ -356,9 +306,6 @@ onPanelLoad('completed', function() {
build: {{ build.id }},
is_building: false,
},
buttons: [
'#stock-options',
],
});
});
@@ -501,10 +448,6 @@ $('#btn-unallocate').on('click', function() {
});
});
$('#allocate-selected-items').click(function() {
allocateSelectedLines();
});
$("#btn-allocate").on('click', function() {
allocateSelectedLines();
});

View File

@@ -24,11 +24,7 @@
<div class='panel-content'>
<div id='button-toolbar'>
<div class='button-toolbar container-fluid' style='float: right;'>
<div class='btn-group' role='group'>
{% include "filter_list.html" with id="build" %}
</div>
</div>
{% include "filter_list.html" with id="build" %}
</div>
<table class='table table-striped table-condensed' id='build-table' data-toolbar='#button-toolbar'>

View File

@@ -279,7 +279,6 @@ class BuildTest(BuildAPITest):
def test_delete(self):
"""Test that we can delete a BuildOrder via the API"""
bo = Build.objects.get(pk=1)
url = reverse('api-build-detail', kwargs={'pk': bo.pk})
@@ -684,9 +683,7 @@ class BuildAllocationTest(BuildAPITest):
def test_invalid_bom_item(self):
"""Test by passing an invalid BOM item."""
# Find the right (in this case, wrong) BuildLine instance
si = StockItem.objects.get(pk=11)
lines = self.build.build_lines.all()
@@ -718,7 +715,6 @@ class BuildAllocationTest(BuildAPITest):
This should result in creation of a new BuildItem object
"""
# Find the correct BuildLine
si = StockItem.objects.get(pk=2)
@@ -753,6 +749,79 @@ class BuildAllocationTest(BuildAPITest):
self.assertEqual(allocation.bom_item.pk, 1)
self.assertEqual(allocation.stock_item.pk, 2)
def test_reallocate(self):
"""Test reallocating an existing built item with the same stock item.
This should increment the quantity of the existing BuildItem object
"""
# Find the correct BuildLine
si = StockItem.objects.get(pk=2)
right_line = None
for line in self.build.build_lines.all():
if line.bom_item.sub_part.pk == si.part.pk:
right_line = line
break
self.post(
self.url,
{
"items": [
{
"build_line": right_line.pk,
"stock_item": 2,
"quantity": 3000,
}
]
},
expected_code=201
)
# A new BuildItem should have been created
self.assertEqual(self.n + 1, BuildItem.objects.count())
allocation = BuildItem.objects.last()
self.assertEqual(allocation.quantity, 3000)
self.assertEqual(allocation.bom_item.pk, 1)
self.assertEqual(allocation.stock_item.pk, 2)
# Try to allocate more than the required quantity (this should fail)
self.post(
self.url,
{
"items": [
{
"build_line": right_line.pk,
"stock_item": 2,
"quantity": 2001,
}
]
},
expected_code=400
)
allocation.refresh_from_db()
self.assertEqual(allocation.quantity, 3000)
# Try to allocate the remaining items
self.post(
self.url,
{
"items": [
{
"build_line": right_line.pk,
"stock_item": 2,
"quantity": 2000,
}
]
},
expected_code=201
)
allocation.refresh_from_db()
self.assertEqual(allocation.quantity, 5000)
class BuildOverallocationTest(BuildAPITest):
"""Unit tests for over allocation of stock items against a build order.
@@ -801,7 +870,6 @@ class BuildOverallocationTest(BuildAPITest):
def test_setup(self):
"""Validate expected state after set-up."""
self.assertEqual(self.build.incomplete_outputs.count(), 0)
self.assertEqual(self.build.complete_outputs.count(), 1)
self.assertEqual(self.build.completed, self.build.quantity)
@@ -966,7 +1034,6 @@ class BuildOutputScrapTest(BuildAPITest):
def scrap(self, build_id, data, expected_code=None):
"""Helper method to POST to the scrap API"""
url = reverse('api-build-output-scrap', kwargs={'pk': build_id})
response = self.post(url, data, expected_code=expected_code)
@@ -975,7 +1042,6 @@ class BuildOutputScrapTest(BuildAPITest):
def test_invalid_scraps(self):
"""Test that invalid scrap attempts are rejected"""
# Test with missing required fields
response = self.scrap(1, {}, expected_code=400)
@@ -1039,7 +1105,6 @@ class BuildOutputScrapTest(BuildAPITest):
def test_valid_scraps(self):
"""Test that valid scrap attempts succeed"""
# Create a build output
build = Build.objects.get(pk=1)

View File

@@ -45,7 +45,6 @@ class BuildTestBase(TestCase):
- 7 x output_2
"""
super().setUpTestData()
# Create a base "Part"
@@ -145,7 +144,7 @@ class BuildTest(BuildTestBase):
def test_ref_int(self):
"""Test the "integer reference" field used for natural sorting"""
# Set build reference to new value
common.models.InvenTreeSetting.set_setting('BUILDORDER_REFERENCE_PATTERN', 'BO-{ref}-???', change_user=None)
refs = {
@@ -168,11 +167,12 @@ class BuildTest(BuildTestBase):
build.save()
self.assertEqual(build.reference_int, ref_int)
# Set build reference back to default value
common.models.InvenTreeSetting.set_setting('BUILDORDER_REFERENCE_PATTERN', 'BO-{ref:04d}', change_user=None)
def test_ref_validation(self):
"""Test that the reference field validation works as expected"""
# Default reference pattern = 'BO-{ref:04d}
# These patterns should fail
for ref in [
'BO-1234x',
@@ -214,9 +214,11 @@ class BuildTest(BuildTestBase):
title='Valid reference',
)
# Set build reference back to default value
common.models.InvenTreeSetting.set_setting('BUILDORDER_REFERENCE_PATTERN', 'BO-{ref:04d}', change_user=None)
def test_next_ref(self):
"""Test that the next reference is automatically generated"""
common.models.InvenTreeSetting.set_setting('BUILDORDER_REFERENCE_PATTERN', 'XYZ-{ref:06d}', change_user=None)
build = Build.objects.create(
@@ -238,9 +240,11 @@ class BuildTest(BuildTestBase):
self.assertEqual(build.reference, 'XYZ-000988')
self.assertEqual(build.reference_int, 988)
# Set build reference back to default value
common.models.InvenTreeSetting.set_setting('BUILDORDER_REFERENCE_PATTERN', 'BO-{ref:04d}', change_user=None)
def test_init(self):
"""Perform some basic tests before we start the ball rolling"""
self.assertEqual(StockItem.objects.count(), 10)
# Build is PENDING
@@ -262,7 +266,6 @@ class BuildTest(BuildTestBase):
def test_build_item_clean(self):
"""Ensure that dodgy BuildItem objects cannot be created"""
stock = StockItem.objects.create(part=self.assembly, quantity=99)
# Create a BuiltItem which points to an invalid StockItem
@@ -289,7 +292,6 @@ class BuildTest(BuildTestBase):
def test_duplicate_bom_line(self):
"""Try to add a duplicate BOM item - it should be allowed"""
BomItem.objects.create(
part=self.assembly,
sub_part=self.sub_part_1,
@@ -303,7 +305,6 @@ class BuildTest(BuildTestBase):
output: StockItem object (or None)
allocations: Map of {StockItem: quantity}
"""
items_to_create = []
for item, quantity in allocations.items():
@@ -325,7 +326,6 @@ class BuildTest(BuildTestBase):
def test_partial_allocation(self):
"""Test partial allocation of stock"""
# Fully allocate tracked stock against build output 1
self.allocate_stock(
self.output_1,
@@ -399,7 +399,6 @@ class BuildTest(BuildTestBase):
def test_overallocation_and_trim(self):
"""Test overallocation of stock and trim function"""
# Fully allocate tracked stock (not eligible for trimming)
self.allocate_stock(
self.output_1,
@@ -472,11 +471,31 @@ class BuildTest(BuildTestBase):
# Check that the "consumed_by" item count has increased
self.assertEqual(StockItem.objects.filter(consumed_by=self.build).count(), n + 8)
def test_change_part(self):
"""Try to change target part after creating a build"""
bo = Build.objects.create(
reference='BO-9999',
title='Some new build',
part=self.assembly,
quantity=5,
issued_by=get_user_model().objects.get(pk=1),
)
assembly_2 = Part.objects.create(
name="Another assembly",
description="A different assembly",
assembly=True,
)
# Should not be able to change the part after the Build is saved
with self.assertRaises(ValidationError):
bo.part = assembly_2
bo.save()
def test_cancel(self):
"""Test cancellation of the build"""
# TODO
"""
self.allocate_stock(50, 50, 200, self.output_1)
self.build.cancel_build(None)
@@ -487,7 +506,6 @@ class BuildTest(BuildTestBase):
def test_complete(self):
"""Test completion of a build output"""
self.stock_1_1.quantity = 1000
self.stock_1_1.save()
@@ -557,7 +575,6 @@ class BuildTest(BuildTestBase):
def test_overdue_notification(self):
"""Test sending of notifications when a build order is overdue."""
self.build.target_date = datetime.now().date() - timedelta(days=1)
self.build.save()
@@ -573,7 +590,6 @@ class BuildTest(BuildTestBase):
def test_new_build_notification(self):
"""Test that a notification is sent when a new build is created"""
Build.objects.create(
reference='BO-9999',
title='Some new build',
@@ -599,7 +615,6 @@ class BuildTest(BuildTestBase):
def test_metadata(self):
"""Unit tests for the metadata field."""
# Make sure a BuildItem exists before trying to run this test
b = BuildItem(stock_item=self.stock_1_2, build_line=self.line_1, install_into=self.output_1, quantity=10)
b.save()
@@ -654,7 +669,6 @@ class AutoAllocationTests(BuildTestBase):
A "fully auto" allocation should allocate *all* of these stock items to the build
"""
# No build item allocations have been made against the build
self.assertEqual(self.build.allocated_stock.count(), 0)
@@ -707,7 +721,6 @@ class AutoAllocationTests(BuildTestBase):
def test_fully_auto(self):
"""We should be able to auto-allocate against a build in a single go"""
self.build.auto_allocate_stock(
interchangeable=True,
substitutes=True,

View File

@@ -111,7 +111,6 @@ class TestReferencePatternMigration(MigratorTestCase):
def prepare(self):
"""Create some initial data prior to migration"""
Setting = self.old_state.apps.get_model('common', 'inventreesetting')
# Create a custom existing prefix so we can confirm the operation is working
@@ -141,7 +140,6 @@ class TestReferencePatternMigration(MigratorTestCase):
def test_reference_migration(self):
"""Test that the reference fields have been correctly updated"""
Build = self.new_state.apps.get_model('build', 'build')
for build in Build.objects.all():
@@ -170,7 +168,6 @@ class TestBuildLineCreation(MigratorTestCase):
def prepare(self):
"""Create data to work with"""
# Model references
Part = self.old_state.apps.get_model('part', 'part')
BomItem = self.old_state.apps.get_model('part', 'bomitem')
@@ -235,7 +232,6 @@ class TestBuildLineCreation(MigratorTestCase):
def test_build_line_creation(self):
"""Test that the BuildLine objects have been created correctly"""
Build = self.new_state.apps.get_model('build', 'build')
BomItem = self.new_state.apps.get_model('part', 'bomitem')
BuildLine = self.new_state.apps.get_model('build', 'buildline')

View File

@@ -3,7 +3,6 @@
def generate_next_build_reference():
"""Generate the next available BuildOrder reference"""
from build.models import Build
return Build.generate_reference()
@@ -11,7 +10,6 @@ def generate_next_build_reference():
def validate_build_order_reference_pattern(pattern):
"""Validate the BuildOrder reference 'pattern' setting"""
from build.models import Build
Build.validate_reference_pattern(pattern)
@@ -19,7 +17,6 @@ def validate_build_order_reference_pattern(pattern):
def validate_build_order_reference(value):
"""Validate that the BuildOrder reference field matches the required pattern."""
from build.models import Build
# If we get to here, run the "default" validation routine

View File

@@ -16,8 +16,7 @@ class SettingsAdmin(ImportExportModelAdmin):
"""Prevent the 'key' field being edited once the setting is created."""
if obj:
return ['key']
else:
return []
return []
class UserSettingsAdmin(ImportExportModelAdmin):
@@ -29,8 +28,7 @@ class UserSettingsAdmin(ImportExportModelAdmin):
"""Prevent the 'key' field being edited once the setting is created."""
if obj:
return ['key']
else:
return []
return []
class WebhookAdmin(ImportExportModelAdmin):

View File

@@ -18,6 +18,7 @@ from rest_framework.views import APIView
import common.models
import common.serializers
from generic.states.api import AllStatusViews, StatusView
from InvenTree.api import BulkDeleteMixin, MetadataView
from InvenTree.config import CONFIG_LOOKUPS
from InvenTree.filters import ORDER_FILTER, SEARCH_ORDER_FILTER
@@ -113,7 +114,6 @@ class CurrencyExchangeView(APIView):
def get(self, request, format=None):
"""Return information on available currency conversions"""
# Extract a list of all available rates
try:
rates = Rate.objects.all()
@@ -157,10 +157,9 @@ class CurrencyRefreshView(APIView):
def post(self, request, *args, **kwargs):
"""Performing a POST request will update currency exchange rates"""
from InvenTree.tasks import update_exchange_rates
update_exchange_rates()
update_exchange_rates(force=True)
return Response({
'success': 'Exchange rates updated',
@@ -192,6 +191,11 @@ class GlobalSettingsList(SettingsList):
queryset = common.models.InvenTreeSetting.objects.exclude(key__startswith="_")
serializer_class = common.serializers.GlobalSettingsSerializer
def list(self, request, *args, **kwargs):
"""Ensure all global settings are created"""
common.models.InvenTreeSetting.build_default_values()
return super().list(request, *args, **kwargs)
class GlobalSettingsPermissions(permissions.BasePermission):
"""Special permission class to determine if the user is "staff"."""
@@ -203,9 +207,8 @@ class GlobalSettingsPermissions(permissions.BasePermission):
if request.method in ['GET', 'HEAD', 'OPTIONS']:
return True
else:
# Any other methods require staff access permissions
return user.is_staff
# Any other methods require staff access permissions
return user.is_staff
except AttributeError: # pragma: no cover
return False
@@ -223,9 +226,9 @@ class GlobalSettingsDetail(RetrieveUpdateAPI):
def get_object(self):
"""Attempt to find a global setting object with the provided key."""
key = self.kwargs['key']
key = str(self.kwargs['key']).upper()
if key not in common.models.InvenTreeSetting.SETTINGS.keys():
if key.startswith('_') or key not in common.models.InvenTreeSetting.SETTINGS.keys():
raise NotFound()
return common.models.InvenTreeSetting.get_setting_object(
@@ -245,6 +248,11 @@ class UserSettingsList(SettingsList):
queryset = common.models.InvenTreeUserSetting.objects.all()
serializer_class = common.serializers.UserSettingsSerializer
def list(self, request, *args, **kwargs):
"""Ensure all user settings are created"""
common.models.InvenTreeUserSetting.build_default_values(user=request.user)
return super().list(request, *args, **kwargs)
def filter_queryset(self, queryset):
"""Only list settings which apply to the current user."""
try:
@@ -284,9 +292,9 @@ class UserSettingsDetail(RetrieveUpdateAPI):
def get_object(self):
"""Attempt to find a user setting object with the provided key."""
key = self.kwargs['key']
key = str(self.kwargs['key']).upper()
if key not in common.models.InvenTreeUserSetting.SETTINGS.keys():
if key.startswith('_') or key not in common.models.InvenTreeUserSetting.SETTINGS.keys():
raise NotFound()
return common.models.InvenTreeUserSetting.get_setting_object(
@@ -373,7 +381,6 @@ class NotificationList(NotificationMessageMixin, BulkDeleteMixin, ListAPI):
def filter_delete_queryset(self, queryset, request):
"""Ensure that the user can only delete their *own* notifications"""
queryset = queryset.filter(user=request.user)
return queryset
@@ -486,6 +493,23 @@ class ProjectCodeDetail(RetrieveUpdateDestroyAPI):
permission_classes = [permissions.IsAuthenticated, IsStaffOrReadOnly]
class CustomUnitList(ListCreateAPI):
"""List view for custom units"""
queryset = common.models.CustomUnit.objects.all()
serializer_class = common.serializers.CustomUnitSerializer
permission_classes = [permissions.IsAuthenticated, IsStaffOrReadOnly]
filter_backends = SEARCH_ORDER_FILTER
class CustomUnitDetail(RetrieveUpdateDestroyAPI):
"""Detail view for a particular custom unit"""
queryset = common.models.CustomUnit.objects.all()
serializer_class = common.serializers.CustomUnitSerializer
permission_classes = [permissions.IsAuthenticated, IsStaffOrReadOnly]
class FlagList(ListAPI):
"""List view for feature flags."""
@@ -554,6 +578,14 @@ common_api_urls = [
re_path(r'^.*$', ProjectCodeList.as_view(), name='api-project-code-list'),
])),
# Custom physical units
re_path(r'^units/', include([
path(r'<int:pk>/', include([
re_path(r'^.*$', CustomUnitDetail.as_view(), name='api-custom-unit-detail'),
])),
re_path(r'^.*$', CustomUnitList.as_view(), name='api-custom-unit-list'),
])),
# Currencies
re_path(r'^currency/', include([
re_path(r'^exchange/', CurrencyExchangeView.as_view(), name='api-currency-exchange'),
@@ -586,6 +618,14 @@ common_api_urls = [
path('<str:key>/', FlagDetail.as_view(), name='api-flag-detail'),
re_path(r'^.*$', FlagList.as_view(), name='api-flag-list'),
])),
# Status
path('generic/status/', include([
path(f'<str:{StatusView.MODEL_REF}>/', include([
path('', StatusView.as_view(), name='api-status'),
])),
path('', AllStatusViews.as_view(), name='api-status-all'),
])),
]
admin_api_urls = [

View File

@@ -1,4 +1,4 @@
# Generated by Django 3.2.18 on 2023-04-17 05:54
# Generated by Django 3.2.18 on 2023-04-17 05:55
from django.conf import settings
from django.db import migrations, models

View File

@@ -0,0 +1,22 @@
# Generated by Django 3.2.20 on 2023-07-18 11:38
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('common', '0019_projectcode_metadata'),
]
operations = [
migrations.CreateModel(
name='CustomUnit',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(help_text='Unit name', max_length=50, unique=True, verbose_name='Name')),
('symbol', models.CharField(blank=True, help_text='Optional unit symbol', max_length=10, unique=True, verbose_name='Symbol')),
('definition', models.CharField(help_text='Unit definition', max_length=50, verbose_name='Definition')),
],
),
]

View File

@@ -0,0 +1,23 @@
# Generated by Django 3.2.20 on 2023-08-05 17:48
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('common', '0020_customunit'),
]
operations = [
migrations.AlterField(
model_name='inventreesetting',
name='value',
field=models.CharField(blank=True, help_text='Settings value', max_length=2000),
),
migrations.AlterField(
model_name='inventreeusersetting',
name='value',
field=models.CharField(blank=True, help_text='Settings value', max_length=2000),
),
]

View File

@@ -30,7 +30,9 @@ from django.core.exceptions import AppRegistryNotReady, ValidationError
from django.core.validators import (MaxValueValidator, MinValueValidator,
URLValidator)
from django.db import models, transaction
from django.db.utils import IntegrityError, OperationalError
from django.db.models.signals import post_delete, post_save
from django.db.utils import IntegrityError, OperationalError, ProgrammingError
from django.dispatch.dispatcher import receiver
from django.urls import reverse
from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _
@@ -48,6 +50,7 @@ import InvenTree.ready
import InvenTree.tasks
import InvenTree.validators
import order.validators
import report.helpers
from plugin import registry
logger = logging.getLogger('inventree')
@@ -72,8 +75,19 @@ class MetaMixin(models.Model):
)
class EmptyURLValidator(URLValidator):
"""Validator for filed with url - that can be empty."""
class BaseURLValidator(URLValidator):
"""Validator for the InvenTree base URL:
- Allow empty value
- Allow value without specified TLD (top level domain)
"""
def __init__(self, schemes=None, **kwargs):
"""Custom init routine"""
super().__init__(schemes, **kwargs)
# Override default host_re value - allow optional tld regex
self.host_re = '(' + self.hostname_re + self.domain_re + f'({self.tld_re})?' + '|localhost)'
def __call__(self, value):
"""Make sure empty values pass."""
@@ -127,6 +141,7 @@ class SettingsKeyType(TypedDict, total=False):
before_save: Function that gets called after save with *args, **kwargs (optional)
after_save: Function that gets called after save with *args, **kwargs (optional)
protected: Protected values are not returned to the client, instead "***" is returned (optional, default: False)
required: Is this setting required to work, can be used in combination with .check_all_settings(...) (optional, default: False)
model: Auto create a dropdown menu to select an associated model instance (e.g. 'company.company', 'auth.user' and 'auth.group' are possible too, optional)
"""
@@ -140,6 +155,7 @@ class SettingsKeyType(TypedDict, total=False):
before_save: Callable[..., None]
after_save: Callable[..., None]
protected: bool
required: bool
model: str
@@ -166,7 +182,7 @@ class BaseInvenTreeSetting(models.Model):
do_cache = kwargs.pop('cache', True)
self.clean(**kwargs)
self.clean()
self.validate_unique()
# Execute before_save action
@@ -181,6 +197,39 @@ class BaseInvenTreeSetting(models.Model):
# Execute after_save action
self._call_settings_function('after_save', args, kwargs)
@classmethod
def build_default_values(cls, **kwargs):
"""Ensure that all values defined in SETTINGS are present in the database
If a particular setting is not present, create it with the default value
"""
cache_key = f"BUILD_DEFAULT_VALUES:{str(cls.__name__)}"
if InvenTree.helpers.str2bool(cache.get(cache_key, False)):
# Already built default values
return
try:
existing_keys = cls.objects.filter(**kwargs).values_list('key', flat=True)
settings_keys = cls.SETTINGS.keys()
missing_keys = set(settings_keys) - set(existing_keys)
if len(missing_keys) > 0:
logger.info("Building %s default values for %s", len(missing_keys), str(cls))
cls.objects.bulk_create([
cls(
key=key,
value=cls.get_setting_default(key),
**kwargs
) for key in missing_keys if not key.startswith('_')
])
except Exception as exc:
logger.exception("Failed to build default values for %s (%s)", str(cls), str(type(exc)))
pass
cache.set(cache_key, True, timeout=3600)
def _call_settings_function(self, reference: str, args, kwargs):
"""Call a function associated with a particular setting.
@@ -190,7 +239,7 @@ class BaseInvenTreeSetting(models.Model):
kwargs: Keyword arguments to pass to the function
"""
# Get action
setting = self.get_setting_definition(self.key, *args, **kwargs)
setting = self.get_setting_definition(self.key, *args, **{**self.get_filters_for_instance(), **kwargs})
settings_fnc = setting.get(reference, None)
# Execute if callable
@@ -204,14 +253,13 @@ class BaseInvenTreeSetting(models.Model):
def save_to_cache(self):
"""Save this setting object to cache"""
ckey = self.cache_key
# skip saving to cache if no pk is set
if self.pk is None:
return
logger.debug(f"Saving setting '{ckey}' to cache")
logger.debug("Saving setting '%s' to cache", ckey)
try:
cache.set(
@@ -232,7 +280,6 @@ class BaseInvenTreeSetting(models.Model):
- The unique KEY string
- Any key:value kwargs associated with the particular setting type (e.g. user-id)
"""
key = f"{str(cls.__name__)}:{setting_key}"
for k, v in kwargs.items():
@@ -250,13 +297,15 @@ class BaseInvenTreeSetting(models.Model):
return {key: getattr(self, key, None) for key in self.extra_unique_fields if hasattr(self, key)}
@classmethod
def allValues(cls, exclude_hidden=False, **kwargs):
"""Return a dict of "all" defined global settings.
def all_settings(cls, *, exclude_hidden=False, settings_definition: Union[Dict[str, SettingsKeyType], None] = None, **kwargs):
"""Return a list of "all" defined settings.
This performs a single database lookup,
and then any settings which are not *in* the database
are assigned their default values
"""
filters = cls.get_filters(**kwargs)
results = cls.objects.all()
if exclude_hidden:
@@ -264,45 +313,83 @@ class BaseInvenTreeSetting(models.Model):
results = results.exclude(key__startswith='_')
# Optionally filter by other keys
results = results.filter(**cls.get_filters(**kwargs))
results = results.filter(**filters)
settings: Dict[str, BaseInvenTreeSetting] = {}
# Query the database
settings = {}
for setting in results:
if setting.key:
settings[setting.key.upper()] = setting.value
settings[setting.key.upper()] = setting
# Specify any "default" values which are not in the database
for key in cls.SETTINGS.keys():
settings_definition = settings_definition or cls.SETTINGS
for key, setting in settings_definition.items():
if key.upper() not in settings:
settings[key.upper()] = cls.get_setting_default(key)
settings[key.upper()] = cls(
key=key.upper(),
value=cls.get_setting_default(key, **filters),
**filters
)
if exclude_hidden:
hidden = cls.SETTINGS[key].get('hidden', False)
# remove any hidden settings
if exclude_hidden and setting.get("hidden", False):
del settings[key.upper()]
if hidden:
# Remove hidden items
del settings[key.upper()]
# format settings values and remove protected
for key, setting in settings.items():
validator = cls.get_setting_validator(key, **filters)
for key, value in settings.items():
validator = cls.get_setting_validator(key)
if cls.is_protected(key):
value = '***'
if cls.is_protected(key, **filters) and setting.value != "":
setting.value = '***'
elif cls.validator_is_bool(validator):
value = InvenTree.helpers.str2bool(value)
setting.value = InvenTree.helpers.str2bool(setting.value)
elif cls.validator_is_int(validator):
try:
value = int(value)
setting.value = int(setting.value)
except ValueError:
value = cls.get_setting_default(key)
settings[key] = value
setting.value = cls.get_setting_default(key, **filters)
return settings
@classmethod
def allValues(cls, *, exclude_hidden=False, settings_definition: Union[Dict[str, SettingsKeyType], None] = None, **kwargs):
"""Return a dict of "all" defined global settings.
This performs a single database lookup,
and then any settings which are not *in* the database
are assigned their default values
"""
all_settings = cls.all_settings(exclude_hidden=exclude_hidden, settings_definition=settings_definition, **kwargs)
settings: Dict[str, Any] = {}
for key, setting in all_settings.items():
settings[key] = setting.value
return settings
@classmethod
def check_all_settings(cls, *, exclude_hidden=False, settings_definition: Union[Dict[str, SettingsKeyType], None] = None, **kwargs):
"""Check if all required settings are set by definition.
Returns:
is_valid: Are all required settings defined
missing_settings: List of all settings that are missing (empty if is_valid is 'True')
"""
all_settings = cls.all_settings(exclude_hidden=exclude_hidden, settings_definition=settings_definition, **kwargs)
missing_settings: List[str] = []
for setting in all_settings.values():
if setting.required:
value = setting.value or cls.get_setting_default(setting.key, **kwargs)
if value == "":
missing_settings.append(setting.key.upper())
return len(missing_settings) == 0, missing_settings
@classmethod
def get_setting_definition(cls, key, **kwargs):
"""Return the 'definition' of a particular settings value, as a dict object.
@@ -317,8 +404,7 @@ class BaseInvenTreeSetting(models.Model):
if settings is not None and key in settings:
return settings[key]
else:
return {}
return {}
@classmethod
def get_setting_name(cls, key, **kwargs):
@@ -371,8 +457,7 @@ class BaseInvenTreeSetting(models.Model):
if callable(default):
return default()
else:
return default
return default
@classmethod
def get_setting_choices(cls, key, **kwargs):
@@ -406,6 +491,17 @@ class BaseInvenTreeSetting(models.Model):
**cls.get_filters(**kwargs),
}
# Unless otherwise specified, attempt to create the setting
create = kwargs.pop('create', True)
# Prevent saving to the database during data import
if InvenTree.ready.isImportingData():
create = False
# Prevent saving to the database during migrations
if InvenTree.ready.isRunningMigrations():
create = False
# Perform cache lookup by default
do_cache = kwargs.pop('cache', True)
@@ -428,15 +524,12 @@ class BaseInvenTreeSetting(models.Model):
setting = settings.filter(**filters).first()
except (ValueError, cls.DoesNotExist):
setting = None
except (IntegrityError, OperationalError):
except (IntegrityError, OperationalError, ProgrammingError):
setting = None
# Setting does not exist! (Try to create it)
if not setting:
# Unless otherwise specified, attempt to create the setting
create = kwargs.pop('create', True)
# Prevent creation of new settings objects when importing data
if InvenTree.ready.isImportingData() or not InvenTree.ready.canAppAccessDatabase(allow_test=True, allow_shell=True):
create = False
@@ -456,7 +549,7 @@ class BaseInvenTreeSetting(models.Model):
# Wrap this statement in "atomic", so it can be rolled back if it fails
with transaction.atomic():
setting.save(**kwargs)
except (IntegrityError, OperationalError):
except (IntegrityError, OperationalError, ProgrammingError):
# It might be the case that the database isn't created yet
pass
except ValidationError:
@@ -513,6 +606,8 @@ class BaseInvenTreeSetting(models.Model):
if change_user is not None and not change_user.is_staff:
return
attempts = int(kwargs.get('attempts', 3))
filters = {
'key__iexact': key,
@@ -533,12 +628,26 @@ class BaseInvenTreeSetting(models.Model):
if setting.is_bool():
value = InvenTree.helpers.str2bool(value)
setting.value = str(value)
setting.save()
try:
setting.value = str(value)
setting.save()
except ValidationError as exc:
# We need to know about validation errors
raise exc
except IntegrityError:
# Likely a race condition has caused a duplicate entry to be created
if attempts > 0:
# Try again
logger.info("Duplicate setting key '%s' for %s - trying again", key, str(cls))
cls.set_setting(key, value, change_user, create=create, attempts=attempts - 1, **kwargs)
except Exception as exc:
# Some other error
logger.exception("Error setting setting '%s' for %s: %s", key, str(cls), str(type(exc)))
pass
key = models.CharField(max_length=50, blank=False, unique=False, help_text=_('Settings key (must be unique - case insensitive)'))
value = models.CharField(max_length=200, blank=True, unique=False, help_text=_('Settings value'))
value = models.CharField(max_length=2000, blank=True, unique=False, help_text=_('Settings value'))
@property
def name(self):
@@ -560,7 +669,7 @@ class BaseInvenTreeSetting(models.Model):
"""Return units for setting."""
return self.__class__.get_setting_units(self.key, **self.get_filters_for_instance())
def clean(self, **kwargs):
def clean(self):
"""If a validator (or multiple validators) are defined for a particular setting key, run them against the 'value' field."""
super().clean()
@@ -571,7 +680,7 @@ class BaseInvenTreeSetting(models.Model):
elif self.is_bool():
self.value = self.as_bool()
validator = self.__class__.get_setting_validator(self.key, **kwargs)
validator = self.__class__.get_setting_validator(self.key, **self.get_filters_for_instance())
if validator is not None:
self.run_validator(validator)
@@ -713,19 +822,19 @@ class BaseInvenTreeSetting(models.Model):
try:
(app, mdl) = model_name.strip().split('.')
except ValueError:
logger.error(f"Invalid 'model' parameter for setting {self.key} : '{model_name}'")
logger.exception("Invalid 'model' parameter for setting '%s': '%s'", self.key, model_name)
return None
app_models = apps.all_models.get(app, None)
if app_models is None:
logger.error(f"Error retrieving model class '{model_name}' for setting '{self.key}' - no app named '{app}'")
logger.error("Error retrieving model class '%s' for setting '%s' - no app named '%s'", model_name, self.key, app)
return None
model = app_models.get(mdl, None)
if model is None:
logger.error(f"Error retrieving model class '{model_name}' for setting '{self.key}' - no model named '{mdl}'")
logger.error("Error retrieving model class '%s' for setting '%s' - no model named '%s'", model_name, self.key, mdl)
return None
# Looks like we have found a model!
@@ -778,9 +887,7 @@ class BaseInvenTreeSetting(models.Model):
elif self.is_model():
return 'related field'
else:
return 'string'
return 'string'
@classmethod
def validator_is_bool(cls, validator):
@@ -829,7 +936,7 @@ class BaseInvenTreeSetting(models.Model):
@classmethod
def is_protected(cls, key, **kwargs):
"""Check if the setting value is protected."""
setting = cls.get_setting_definition(key, **kwargs)
setting = cls.get_setting_definition(key, **cls.get_filters(**kwargs))
return setting.get('protected', False)
@@ -838,6 +945,18 @@ class BaseInvenTreeSetting(models.Model):
"""Returns if setting is protected from rendering."""
return self.__class__.is_protected(self.key, **self.get_filters_for_instance())
@classmethod
def is_required(cls, key, **kwargs):
"""Check if this setting value is required."""
setting = cls.get_setting_definition(key, **cls.get_filters(**kwargs))
return setting.get("required", False)
@property
def required(self):
"""Returns if setting is required."""
return self.__class__.is_required(self.key, **self.get_filters_for_instance())
def settings_group_options():
"""Build up group tuple for settings based on your choices."""
@@ -871,16 +990,37 @@ def validate_email_domains(setting):
raise ValidationError(_(f'Invalid domain name: {domain}'))
def currency_exchange_plugins():
"""Return a set of plugin choices which can be used for currency exchange"""
try:
from plugin import registry
plugs = registry.with_mixin('currencyexchange', active=True)
except Exception:
plugs = []
return [
('', _('No plugin')),
] + [(plug.slug, plug.human_name) for plug in plugs]
def update_exchange_rates(setting):
"""Update exchange rates when base currency is changed"""
if InvenTree.ready.isImportingData():
return
if not InvenTree.ready.canAppAccessDatabase():
return
InvenTree.tasks.update_exchange_rates()
InvenTree.tasks.update_exchange_rates(force=True)
def reload_plugin_registry(setting):
"""When a core plugin setting is changed, reload the plugin registry"""
from plugin import registry
logger.info("Reloading plugin registry due to change in setting '%s'", setting.key)
registry.reload_plugins(full_reload=True, force_reload=True, collect=True)
class InvenTreeSetting(BaseInvenTreeSetting):
@@ -932,6 +1072,13 @@ class InvenTreeSetting(BaseInvenTreeSetting):
'hidden': True,
},
'_PENDING_MIGRATIONS': {
'name': _('Pending migrations'),
'description': _('Number of pending database migrations'),
'default': 0,
'validator': int,
},
'INVENTREE_INSTANCE': {
'name': _('Server Instance Name'),
'default': 'InvenTree',
@@ -962,7 +1109,7 @@ class InvenTreeSetting(BaseInvenTreeSetting):
'INVENTREE_BASE_URL': {
'name': _('Base URL'),
'description': _('Base URL for server instance'),
'validator': EmptyURLValidator(),
'validator': BaseURLValidator(),
'default': '',
'after_save': update_instance_url,
},
@@ -975,6 +1122,24 @@ class InvenTreeSetting(BaseInvenTreeSetting):
'after_save': update_exchange_rates,
},
'CURRENCY_UPDATE_INTERVAL': {
'name': _('Currency Update Interval'),
'description': _('How often to update exchange rates (set to zero to disable)'),
'default': 1,
'units': _('days'),
'validator': [
int,
MinValueValidator(0),
],
},
'CURRENCY_UPDATE_PLUGIN': {
'name': _('Currency Update Plugin'),
'description': _('Currency update plugin to use'),
'choices': currency_exchange_plugins,
'default': 'inventreecurrencyexchange'
},
'INVENTREE_DOWNLOAD_FROM_URL': {
'name': _('Download from URL'),
'description': _('Allow download of remote images and files from external URL'),
@@ -1081,7 +1246,7 @@ class InvenTreeSetting(BaseInvenTreeSetting):
'BARCODE_ENABLE': {
'name': _('Barcode Support'),
'description': _('Enable barcode scanner support'),
'description': _('Enable barcode scanner support in the web interface'),
'default': True,
'validator': bool,
},
@@ -1249,6 +1414,13 @@ class InvenTreeSetting(BaseInvenTreeSetting):
'default': '',
},
'PART_PARAMETER_ENFORCE_UNITS': {
'name': _('Enforce Parameter Units'),
'description': _('If units are provided, parameter values must match the specified units'),
'default': True,
'validator': bool,
},
'PRICING_DECIMAL_PLACES_MIN': {
'name': _('Minimum Pricing Decimal Places'),
'description': _('Minimum number of decimal places to display when rendering pricing data'),
@@ -1377,11 +1549,7 @@ class InvenTreeSetting(BaseInvenTreeSetting):
'name': _('Page Size'),
'description': _('Default page size for PDF reports'),
'default': 'A4',
'choices': [
('A4', 'A4'),
('Legal', 'Legal'),
('Letter', 'Letter')
],
'choices': report.helpers.report_page_size_options,
},
'REPORT_ENABLE_TEST_REPORT': {
@@ -1629,7 +1797,7 @@ class InvenTreeSetting(BaseInvenTreeSetting):
'description': _('Enable plugins to add URL routes'),
'default': False,
'validator': bool,
'requires_restart': True,
'after_save': reload_plugin_registry,
},
'ENABLE_PLUGINS_NAVIGATION': {
@@ -1637,7 +1805,7 @@ class InvenTreeSetting(BaseInvenTreeSetting):
'description': _('Enable plugins to integrate into navigation'),
'default': False,
'validator': bool,
'requires_restart': True,
'after_save': reload_plugin_registry,
},
'ENABLE_PLUGINS_APP': {
@@ -1645,7 +1813,7 @@ class InvenTreeSetting(BaseInvenTreeSetting):
'description': _('Enable plugins to add apps'),
'default': False,
'validator': bool,
'requires_restart': True,
'after_save': reload_plugin_registry,
},
'ENABLE_PLUGINS_SCHEDULE': {
@@ -1653,7 +1821,7 @@ class InvenTreeSetting(BaseInvenTreeSetting):
'description': _('Enable plugins to run scheduled tasks'),
'default': False,
'validator': bool,
'requires_restart': True,
'after_save': reload_plugin_registry,
},
'ENABLE_PLUGINS_EVENTS': {
@@ -1661,7 +1829,7 @@ class InvenTreeSetting(BaseInvenTreeSetting):
'description': _('Enable plugins to respond to internal events'),
'default': False,
'validator': bool,
'requires_restart': True,
'after_save': reload_plugin_registry,
},
"PROJECT_CODES_ENABLED": {
@@ -1678,6 +1846,13 @@ class InvenTreeSetting(BaseInvenTreeSetting):
'default': False,
},
'STOCKTAKE_EXCLUDE_EXTERNAL': {
'name': _('Exclude External Locations'),
'description': _('Exclude stock items in external locations from stocktake calculations'),
'validator': bool,
'default': False,
},
'STOCKTAKE_AUTO_DAYS': {
'name': _('Automatic Stocktake Period'),
'description': _('Number of days between automatic stocktake recording (set to zero to disable)'),
@@ -1720,13 +1895,12 @@ class InvenTreeSetting(BaseInvenTreeSetting):
if options:
return options.get('requires_restart', False)
else:
return False
return False
def label_printer_options():
"""Build a list of available label printer options."""
printers = [('', _('No Printer (Export to PDF)'))]
printers = []
label_printer_plugins = registry.with_mixin('labels')
if label_printer_plugins:
printers.extend([(p.slug, p.name + ' - ' + p.human_name) for p in label_printer_plugins])
@@ -1775,13 +1949,6 @@ class InvenTreeUserSetting(BaseInvenTreeSetting):
'validator': bool,
},
'PART_RECENT_COUNT': {
'name': _('Recent Part Count'),
'description': _('Number of recent parts to display on index page'),
'default': 10,
'validator': [int, MinValueValidator(1)]
},
'HOMEPAGE_BOM_REQUIRES_VALIDATION': {
'name': _('Show unvalidated BOMs'),
'description': _('Show BOMs that await validation on the homepage'),
@@ -1796,13 +1963,6 @@ class InvenTreeUserSetting(BaseInvenTreeSetting):
'validator': bool,
},
'STOCK_RECENT_COUNT': {
'name': _('Recent Stock Count'),
'description': _('Number of recent stock items to display on index page'),
'default': 10,
'validator': [int, MinValueValidator(1)]
},
'HOMEPAGE_STOCK_LOW': {
'name': _('Show low stock'),
'description': _('Show low stock items on the homepage'),
@@ -2135,6 +2295,13 @@ class InvenTreeUserSetting(BaseInvenTreeSetting):
'default': '',
},
'NOTIFICATION_ERROR_REPORT': {
'name': _('Receive error reports'),
'description': _('Receive notifications for system errors'),
'default': True,
'validator': bool,
}
}
typ = 'user'
@@ -2194,7 +2361,7 @@ class PriceBreak(MetaMixin):
try:
converted = convert_money(self.price, currency_code)
except MissingRate:
logger.warning(f"No currency conversion rate available for {self.price_currency} -> {currency_code}")
logger.warning("No currency conversion rate available for %s -> %s", self.price_currency, currency_code)
return self.price.amount
return converted.amount
@@ -2266,8 +2433,7 @@ def get_price(instance, quantity, moq=True, multiples=True, currency=None, break
if pb_found:
cost = pb_cost * quantity
return InvenTree.helpers.normalize(cost + instance.base_cost)
else:
return None
return None
class ColorTheme(models.Model):
@@ -2720,7 +2886,6 @@ class NewsFeedEntry(models.Model):
def rename_notes_image(instance, filename):
"""Function for renaming uploading image file. Will store in the 'notes' directory."""
fname = os.path.basename(filename)
return os.path.join('notes', fname)
@@ -2740,3 +2905,88 @@ class NotesImage(models.Model):
user = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True)
date = models.DateTimeField(auto_now_add=True)
class CustomUnit(models.Model):
"""Model for storing custom physical unit definitions
Model Attributes:
name: Name of the unit
definition: Definition of the unit
symbol: Symbol for the unit (e.g. 'm' for 'metre') (optional)
Refer to the pint documentation for further information on unit definitions.
https://pint.readthedocs.io/en/stable/advanced/defining.html
"""
def fmt_string(self):
"""Construct a unit definition string e.g. 'dog_year = 52 * day = dy'"""
fmt = f'{self.name} = {self.definition}'
if self.symbol:
fmt += f' = {self.symbol}'
return fmt
def clean(self):
"""Validate that the provided custom unit is indeed valid"""
super().clean()
from InvenTree.conversion import get_unit_registry
registry = get_unit_registry()
# Check that the 'name' field is valid
self.name = self.name.strip()
# Cannot be zero length
if not self.name.isidentifier():
raise ValidationError({
'name': _('Unit name must be a valid identifier')
})
self.definition = self.definition.strip()
# Check that the 'definition' is valid, by itself
try:
registry.Quantity(self.definition)
except Exception as exc:
raise ValidationError({
'definition': str(exc)
})
# Finally, test that the entire custom unit definition is valid
try:
registry.define(self.fmt_string())
except Exception as exc:
raise ValidationError(str(exc))
name = models.CharField(
max_length=50,
verbose_name=_('Name'),
help_text=_('Unit name'),
unique=True, blank=False,
)
symbol = models.CharField(
max_length=10,
verbose_name=_('Symbol'),
help_text=_('Optional unit symbol'),
unique=True, blank=True,
)
definition = models.CharField(
max_length=50,
verbose_name=_('Definition'),
help_text=_('Unit definition'),
blank=False,
)
@receiver(post_save, sender=CustomUnit, dispatch_uid='custom_unit_saved')
@receiver(post_delete, sender=CustomUnit, dispatch_uid='custom_unit_deleted')
def after_custom_unit_updated(sender, instance, **kwargs):
"""Callback when a custom unit is updated or deleted"""
# Force reload of the unit registry
from InvenTree.conversion import reload_unit_registry
reload_unit_registry()

View File

@@ -35,7 +35,7 @@ class NotificationMethod:
This checks that:
- All needed functions are implemented
- The method is not disabled via plugin
- All needed contaxt values were provided
- All needed context values were provided
"""
# Check if a sending fnc is defined
if (not hasattr(self, 'send')) and (not hasattr(self, 'send_bulk')):
@@ -196,7 +196,7 @@ class MethodStorageClass:
filtered_list[ref] = item
storage.liste = list(filtered_list.values())
logger.info(f'Found {len(storage.liste)} notification methods')
logger.info('Found %s notification methods', len(storage.liste))
def get_usersettings(self, user) -> list:
"""Returns all user settings for a specific user.
@@ -242,7 +242,6 @@ class UIMessageNotification(SingleNotificationMethod):
def get_targets(self):
"""Only send notifications for active users"""
return [target for target in self.targets if target.is_active]
def send(self, target):
@@ -279,7 +278,7 @@ class NotificationBody:
name: str
slug: str
message: str
template: str
template: str = None
class InvenTreeNotificationBodies:
@@ -339,10 +338,10 @@ def trigger_notification(obj, category=None, obj_ref='pk', **kwargs):
delta = timedelta(days=1)
if common.models.NotificationEntry.check_recent(category, obj_ref_value, delta):
logger.info(f"Notification '{category}' has recently been sent for '{str(obj)}' - SKIPPING")
logger.info("Notification '%s' has recently been sent for '%s' - SKIPPING", category, str(obj))
return
logger.info(f"Gathering users for notification '{category}'")
logger.info("Gathering users for notification '%s'", category)
if target_exclude is None:
target_exclude = set()
@@ -376,10 +375,10 @@ def trigger_notification(obj, category=None, obj_ref='pk', **kwargs):
target_users.add(user)
# Unhandled type
else:
logger.error(f"Unknown target passed to trigger_notification method: {target}")
logger.error("Unknown target passed to trigger_notification method: %s", target)
if target_users:
logger.info(f"Sending notification '{category}' for '{str(obj)}'")
logger.info("Sending notification '%s' for '%s'", category, str(obj))
# Collect possible methods
if delivery_methods is None:
@@ -388,19 +387,19 @@ def trigger_notification(obj, category=None, obj_ref='pk', **kwargs):
delivery_methods = (delivery_methods - IGNORED_NOTIFICATION_CLS)
for method in delivery_methods:
logger.info(f"Triggering notification method '{method.METHOD_NAME}'")
logger.info("Triggering notification method '%s'", method.METHOD_NAME)
try:
deliver_notification(method, obj, category, target_users, context)
except NotImplementedError as error:
# Allow any single notification method to fail, without failing the others
logger.error(error)
logger.error(error) # noqa: LOG005
except Exception as error:
logger.error(error)
logger.error(error) # noqa: LOG005
# Set delivery flag
common.models.NotificationEntry.notify(category, obj_ref_value)
else:
logger.info(f"No possible users for notification '{category}'")
logger.info("No possible users for notification '%s'", category)
def trigger_superuser_notification(plugin: PluginConfig, msg: str):
@@ -440,7 +439,7 @@ def deliver_notification(cls: NotificationMethod, obj, category: str, targets, c
if method.targets and len(method.targets) > 0:
# Log start
logger.info(f"Notify users via '{method.METHOD_NAME}' for notification '{category}' for '{str(obj)}'")
logger.info("Notify users via '%s' for notification '%s' for '%s'", method.METHOD_NAME, category, str(obj))
# Run setup for delivery method
method.setup()
@@ -465,6 +464,6 @@ def deliver_notification(cls: NotificationMethod, obj, category: str, targets, c
method.cleanup()
# Log results
logger.info(f"Notified {success_count} users via '{method.METHOD_NAME}' for notification '{category}' for '{str(obj)}' successfully")
logger.info("Notified %s users via '%s' for notification '%s' for '%s' successfully", success_count, method.METHOD_NAME, category, str(obj))
if not success:
logger.info("There were some problems")

View File

@@ -13,6 +13,25 @@ from InvenTree.serializers import (InvenTreeImageSerializerField,
InvenTreeModelSerializer)
class SettingsValueField(serializers.Field):
"""Custom serializer field for a settings value."""
def get_attribute(self, instance):
"""Return the object instance, not the attribute value."""
return instance
def to_representation(self, instance):
"""Return the value of the setting:
- Protected settings are returned as '***'
"""
return '***' if instance.protected else str(instance.value)
def to_internal_value(self, data):
"""Return the internal value of the setting"""
return str(data)
class SettingsSerializer(InvenTreeModelSerializer):
"""Base serializer for a settings object."""
@@ -30,6 +49,10 @@ class SettingsSerializer(InvenTreeModelSerializer):
api_url = serializers.CharField(read_only=True)
value = SettingsValueField()
units = serializers.CharField(read_only=True)
def get_choices(self, obj):
"""Returns the choices available for a given item."""
results = []
@@ -45,16 +68,6 @@ class SettingsSerializer(InvenTreeModelSerializer):
return results
def get_value(self, obj):
"""Make sure protected values are not returned."""
# never return protected values
if obj.protected:
result = '***'
else:
result = obj.value
return result
class GlobalSettingsSerializer(SettingsSerializer):
"""Serializer for the InvenTreeSetting model."""
@@ -70,6 +83,7 @@ class GlobalSettingsSerializer(SettingsSerializer):
'name',
'description',
'type',
'units',
'choices',
'model_name',
'api_url',
@@ -92,6 +106,7 @@ class UserSettingsSerializer(SettingsSerializer):
'description',
'user',
'type',
'units',
'choices',
'model_name',
'api_url',
@@ -177,7 +192,6 @@ class NotificationMessageSerializer(InvenTreeModelSerializer):
def get_target(self, obj):
"""Function to resolve generic object reference to target."""
target = get_objectreference(obj, 'target_content_type', 'target_object_id')
if target and 'link' not in target:
@@ -285,3 +299,18 @@ class FlagSerializer(serializers.Serializer):
data['conditions'] = self.instance[instance]
return data
class CustomUnitSerializer(InvenTreeModelSerializer):
"""DRF serializer for CustomUnit model."""
class Meta:
"""Meta options for CustomUnitSerializer."""
model = common_models.CustomUnit
fields = [
'pk',
'name',
'symbol',
'definition',
]

View File

@@ -3,6 +3,7 @@
import logging
from django.conf import settings
from django.core.cache import cache
from moneyed import CURRENCIES
@@ -11,11 +12,15 @@ logger = logging.getLogger('inventree')
def currency_code_default():
"""Returns the default currency code (or USD if not specified)"""
from common.models import InvenTreeSetting
cached_value = cache.get('currency_code_default', '')
if cached_value:
return cached_value
try:
code = InvenTreeSetting.get_setting('INVENTREE_DEFAULT_CURRENCY', create=False, cache=False)
code = InvenTreeSetting.get_setting('INVENTREE_DEFAULT_CURRENCY', backup_value='', create=True, cache=True)
except Exception: # pragma: no cover
# Database may not yet be ready, no need to throw an error here
code = ''
@@ -23,6 +28,9 @@ def currency_code_default():
if code not in CURRENCIES:
code = 'USD' # pragma: no cover
# Cache the value for a short amount of time
cache.set('currency_code_default', code, 30)
return code

View File

@@ -93,7 +93,7 @@ def delete_old_notes_images():
# Remove any notes which point to non-existent image files
for note in NotesImage.objects.all():
if not os.path.exists(note.image.path):
logger.info(f"Deleting note {note.image.path} - image file does not exist")
logger.info("Deleting note %s - image file does not exist", note.image.path)
note.delete()
note_classes = getModelsWithMixin(InvenTreeNotesMixin)
@@ -112,7 +112,7 @@ def delete_old_notes_images():
break
if not found:
logger.info(f"Deleting note {img} - image file not linked to a note")
logger.info("Deleting note %s - image file not linked to a note", img)
note.delete()
# Finally, remove any images in the notes dir which are not linked to a note
@@ -136,5 +136,5 @@ def delete_old_notes_images():
break
if not found:
logger.info(f"Deleting note {image} - image file not linked to a note")
logger.info("Deleting note %s - image file not linked to a note", image)
os.remove(os.path.join(notes_dir, image))

View File

@@ -5,9 +5,11 @@ import json
import time
from datetime import timedelta
from http import HTTPStatus
from unittest import mock
from django.contrib.auth import get_user_model
from django.core.cache import cache
from django.core.exceptions import ValidationError
from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import Client, TestCase
from django.urls import reverse
@@ -21,9 +23,10 @@ from plugin import registry
from plugin.models import NotificationUserSetting
from .api import WebhookView
from .models import (ColorTheme, InvenTreeSetting, InvenTreeUserSetting,
NotesImage, NotificationEntry, NotificationMessage,
ProjectCode, WebhookEndpoint, WebhookMessage)
from .models import (ColorTheme, CustomUnit, InvenTreeSetting,
InvenTreeUserSetting, NotesImage, NotificationEntry,
NotificationMessage, ProjectCode, WebhookEndpoint,
WebhookMessage)
CONTENT_TYPE_JSON = 'application/json'
@@ -105,6 +108,70 @@ class SettingsTest(InvenTreeTestCase):
self.assertIn('PART_COPY_TESTS', result)
self.assertIn('STOCK_OWNERSHIP_CONTROL', result)
self.assertIn('SIGNUP_GROUP', result)
self.assertIn('SERVER_RESTART_REQUIRED', result)
result = InvenTreeSetting.allValues(exclude_hidden=True)
self.assertNotIn('SERVER_RESTART_REQUIRED', result)
def test_all_settings(self):
"""Make sure that the all_settings function returns correctly"""
result = InvenTreeSetting.all_settings()
self.assertIn("INVENTREE_INSTANCE", result)
self.assertIsInstance(result['INVENTREE_INSTANCE'], InvenTreeSetting)
@mock.patch("common.models.InvenTreeSetting.get_setting_definition")
def test_check_all_settings(self, get_setting_definition):
"""Make sure that the check_all_settings function returns correctly"""
# define partial schema
settings_definition = {
"AB": { # key that's has not already been accessed
"required": True,
},
"CD": {
"required": True,
"protected": True,
},
"EF": {}
}
def mocked(key, **kwargs):
return settings_definition.get(key, {})
get_setting_definition.side_effect = mocked
self.assertEqual(InvenTreeSetting.check_all_settings(settings_definition=settings_definition), (False, ["AB", "CD"]))
InvenTreeSetting.set_setting('AB', "hello", self.user)
InvenTreeSetting.set_setting('CD', "world", self.user)
self.assertEqual(InvenTreeSetting.check_all_settings(), (True, []))
@mock.patch("common.models.InvenTreeSetting.get_setting_definition")
def test_settings_validator(self, get_setting_definition):
"""Make sure that the validator function gets called on set setting."""
def validator(x):
if x == "hello":
return x
raise ValidationError(f"{x} is not valid")
mock_validator = mock.Mock(side_effect=validator)
# define partial schema
settings_definition = {
"AB": { # key that's has not already been accessed
"validator": mock_validator,
},
}
def mocked(key, **kwargs):
return settings_definition.get(key, {})
get_setting_definition.side_effect = mocked
InvenTreeSetting.set_setting("AB", "hello", self.user)
mock_validator.assert_called_with("hello")
with self.assertRaises(ValidationError):
InvenTreeSetting.set_setting("AB", "world", self.user)
mock_validator.assert_called_with("world")
def run_settings_check(self, key, setting):
"""Test that all settings are valid.
@@ -201,7 +268,6 @@ class SettingsTest(InvenTreeTestCase):
def test_global_setting_caching(self):
"""Test caching operations for the global settings class"""
key = 'PART_NAME_FORMAT'
cache_key = InvenTreeSetting.create_cache_key(key)
@@ -223,7 +289,6 @@ class SettingsTest(InvenTreeTestCase):
def test_user_setting_caching(self):
"""Test caching operation for the user settings class"""
cache.clear()
# Generate a number of new users
@@ -268,8 +333,10 @@ class GlobalSettingsApiTest(InvenTreeAPITestCase):
response = self.get(url, expected_code=200)
n_public_settings = len([k for k in InvenTreeSetting.SETTINGS.keys() if not k.startswith('_')])
# Number of results should match the number of settings
self.assertEqual(len(response.data), len(InvenTreeSetting.SETTINGS.keys()))
self.assertEqual(len(response.data), n_public_settings)
def test_company_name(self):
"""Test a settings object lifecycle e2e."""
@@ -541,7 +608,6 @@ class NotificationUserSettingsApiTest(InvenTreeAPITestCase):
def test_setting(self):
"""Test the string name for NotificationUserSetting."""
NotificationUserSetting.set_setting('NOTIFICATION_METHOD_MAIL', True, change_user=self.user, user=self.user)
test_setting = NotificationUserSetting.get_setting_object('NOTIFICATION_METHOD_MAIL', user=self.user)
self.assertEqual(str(test_setting), 'NOTIFICATION_METHOD_MAIL (for testuser): True')
@@ -754,7 +820,6 @@ class NotificationTest(InvenTreeAPITestCase):
def test_api_list(self):
"""Test list URL."""
url = reverse('api-notifications-list')
self.get(url, expected_code=200)
@@ -774,7 +839,6 @@ class NotificationTest(InvenTreeAPITestCase):
def test_bulk_delete(self):
"""Tests for bulk deletion of user notifications"""
from error_report.models import Error
# Create some notification messages by throwing errors
@@ -843,7 +907,7 @@ class CommonTest(InvenTreeAPITestCase):
from plugin import registry
# set flag true
common.models.InvenTreeSetting.set_setting('SERVER_RESTART_REQUIRED', False, None)
common.models.InvenTreeSetting.set_setting('SERVER_RESTART_REQUIRED', True, None)
# reload the app
registry.reload_plugins()
@@ -950,7 +1014,6 @@ class CurrencyAPITests(InvenTreeAPITestCase):
def test_exchange_endpoint(self):
"""Test that the currency exchange endpoint works as expected"""
response = self.get(reverse('api-currency-exchange'), expected_code=200)
self.assertIn('base_currency', response.data)
@@ -958,7 +1021,6 @@ class CurrencyAPITests(InvenTreeAPITestCase):
def test_refresh_endpoint(self):
"""Call the 'refresh currencies' endpoint"""
from djmoney.contrib.exchange.models import Rate
# Delete any existing exchange rate data
@@ -984,7 +1046,6 @@ class NotesImageTest(InvenTreeAPITestCase):
def test_invalid_files(self):
"""Test that invalid files are rejected."""
n = NotesImage.objects.count()
# Test upload of a simple text file
@@ -1016,7 +1077,6 @@ class NotesImageTest(InvenTreeAPITestCase):
def test_valid_image(self):
"""Test upload of a valid image file"""
n = NotesImage.objects.count()
# Construct a simple image file
@@ -1026,7 +1086,7 @@ class NotesImageTest(InvenTreeAPITestCase):
image.save(output, format='PNG')
contents = output.getvalue()
response = self.post(
self.post(
reverse('api-notes-image-list'),
data={
'image': SimpleUploadedFile('test.png', contents, content_type='image/png'),
@@ -1035,8 +1095,6 @@ class NotesImageTest(InvenTreeAPITestCase):
expected_code=201
)
print(response.data)
# Check that a new file has been created
self.assertEqual(NotesImage.objects.count(), n + 1)
@@ -1065,13 +1123,11 @@ class ProjectCodesTest(InvenTreeAPITestCase):
def test_list(self):
"""Test that the list endpoint works as expected"""
response = self.get(self.url, expected_code=200)
self.assertEqual(len(response.data), ProjectCode.objects.count())
def test_delete(self):
"""Test we can delete a project code via the API"""
n = ProjectCode.objects.count()
# Get the first project code
@@ -1088,7 +1144,6 @@ class ProjectCodesTest(InvenTreeAPITestCase):
def test_duplicate_code(self):
"""Test that we cannot create two project codes with the same code"""
# Create a new project code
response = self.post(
self.url,
@@ -1103,7 +1158,6 @@ class ProjectCodesTest(InvenTreeAPITestCase):
def test_write_access(self):
"""Test that non-staff users have read-only access"""
# By default user has staff access, can create a new project code
response = self.post(
self.url,
@@ -1149,3 +1203,87 @@ class ProjectCodesTest(InvenTreeAPITestCase):
},
expected_code=403
)
class CustomUnitAPITest(InvenTreeAPITestCase):
"""Unit tests for the CustomUnit API"""
@property
def url(self):
"""Return the API endpoint for the CustomUnit list"""
return reverse('api-custom-unit-list')
@classmethod
def setUpTestData(cls):
"""Construct some initial test fixture data"""
super().setUpTestData()
units = [
CustomUnit(name='metres_per_amp', definition='meter / ampere', symbol='m/A'),
CustomUnit(name='hectares_per_second', definition='hectares per second', symbol='ha/s'),
]
CustomUnit.objects.bulk_create(units)
def test_list(self):
"""Test API list functionality"""
response = self.get(self.url, expected_code=200)
self.assertEqual(len(response.data), CustomUnit.objects.count())
def test_edit(self):
"""Test edit permissions for CustomUnit model"""
unit = CustomUnit.objects.first()
# Try to edit without permission
self.user.is_staff = False
self.user.save()
self.patch(
reverse('api-custom-unit-detail', kwargs={'pk': unit.pk}),
{
'name': 'new_unit_name',
},
expected_code=403
)
# Ok, what if we have permission?
self.user.is_staff = True
self.user.save()
self.patch(
reverse('api-custom-unit-detail', kwargs={'pk': unit.pk}),
{
'name': 'new_unit_name',
},
# expected_code=200
)
unit.refresh_from_db()
self.assertEqual(unit.name, 'new_unit_name')
def test_validation(self):
"""Test that validation works as expected"""
unit = CustomUnit.objects.first()
self.user.is_staff = True
self.user.save()
# Test invalid 'name' values (must be valid identifier)
invalid_name_values = [
'1',
'1abc',
'abc def',
'abc-def',
'abc.def',
]
url = reverse('api-custom-unit-detail', kwargs={'pk': unit.pk})
for name in invalid_name_values:
self.patch(
url,
{
'name': name,
},
expected_code=400
)

View File

@@ -480,8 +480,7 @@ class FileManagementAjaxView(AjaxView):
self.render_done(form)
data = {'form_valid': True, 'success': _('Parts imported')}
return self.renderJsonResponse(request, data=data)
else:
self.storage.current_step = self.steps.next
self.storage.current_step = self.steps.next
return self.renderJsonResponse(request, data={'form_valid': None})

View File

@@ -9,9 +9,9 @@ from import_export.fields import Field
from InvenTree.admin import InvenTreeResource
from part.models import Part
from .models import (Company, ManufacturerPart, ManufacturerPartAttachment,
ManufacturerPartParameter, SupplierPart,
SupplierPriceBreak)
from .models import (Address, Company, Contact, ManufacturerPart,
ManufacturerPartAttachment, ManufacturerPartParameter,
SupplierPart, SupplierPriceBreak)
class CompanyResource(InvenTreeResource):
@@ -187,6 +187,60 @@ class SupplierPriceBreakAdmin(ImportExportModelAdmin):
autocomplete_fields = ('part',)
class AddressResource(InvenTreeResource):
"""Class for managing Address data import/export"""
class Meta:
"""Metaclass defining extra options"""
model = Address
skip_unchanged = True
report_skipped = False
clean_model_instances = True
company = Field(attribute='company', widget=widgets.ForeignKeyWidget(Company))
class AddressAdmin(ImportExportModelAdmin):
"""Admin class for the Address model"""
resource_class = AddressResource
list_display = ('company', 'line1', 'postal_code', 'country')
search_fields = [
'company',
'country',
'postal_code',
]
class ContactResource(InvenTreeResource):
"""Class for managing Contact data import/export"""
class Meta:
"""Metaclass defining extra options"""
model = Contact
skip_unchanged = True
report_skipped = False
clean_model_instances = True
company = Field(attribute='company', widget=widgets.ForeignKeyWidget(Company))
class ContactAdmin(ImportExportModelAdmin):
"""Admin class for the Contact model"""
resource_class = ContactResource
list_display = ('company', 'name', 'role', 'email', 'phone')
search_fields = [
'company',
'name',
'email',
]
admin.site.register(Company, CompanyAdmin)
admin.site.register(SupplierPart, SupplierPartAdmin)
admin.site.register(SupplierPriceBreak, SupplierPriceBreakAdmin)
@@ -194,3 +248,6 @@ admin.site.register(SupplierPriceBreak, SupplierPriceBreakAdmin)
admin.site.register(ManufacturerPart, ManufacturerPartAdmin)
admin.site.register(ManufacturerPartAttachment, ManufacturerPartAttachmentAdmin)
admin.site.register(ManufacturerPartParameter, ManufacturerPartParameterAdmin)
admin.site.register(Address, AddressAdmin)
admin.site.register(Contact, ContactAdmin)

View File

@@ -4,7 +4,6 @@ from django.db.models import Q
from django.urls import include, path, re_path
from django_filters import rest_framework as rest_filters
from django_filters.rest_framework import DjangoFilterBackend
import part.models
from InvenTree.api import (AttachmentMixin, ListCreateDestroyAPIView,
@@ -14,11 +13,12 @@ from InvenTree.filters import (ORDER_FILTER, SEARCH_ORDER_FILTER,
from InvenTree.helpers import str2bool
from InvenTree.mixins import ListCreateAPI, RetrieveUpdateDestroyAPI
from .models import (Company, CompanyAttachment, Contact, ManufacturerPart,
ManufacturerPartAttachment, ManufacturerPartParameter,
SupplierPart, SupplierPriceBreak)
from .serializers import (CompanyAttachmentSerializer, CompanySerializer,
ContactSerializer,
from .models import (Address, Company, CompanyAttachment, Contact,
ManufacturerPart, ManufacturerPartAttachment,
ManufacturerPartParameter, SupplierPart,
SupplierPriceBreak)
from .serializers import (AddressSerializer, CompanyAttachmentSerializer,
CompanySerializer, ContactSerializer,
ManufacturerPartAttachmentSerializer,
ManufacturerPartParameterSerializer,
ManufacturerPartSerializer, SupplierPartSerializer,
@@ -88,10 +88,6 @@ class CompanyAttachmentList(AttachmentMixin, ListCreateDestroyAPIView):
queryset = CompanyAttachment.objects.all()
serializer_class = CompanyAttachmentSerializer
filter_backends = [
DjangoFilterBackend,
]
filterset_fields = [
'company',
]
@@ -135,6 +131,32 @@ class ContactDetail(RetrieveUpdateDestroyAPI):
serializer_class = ContactSerializer
class AddressList(ListCreateDestroyAPIView):
"""API endpoint for list view of Address model"""
queryset = Address.objects.all()
serializer_class = AddressSerializer
filter_backends = SEARCH_ORDER_FILTER
filterset_fields = [
'company',
]
ordering_fields = [
'title',
]
ordering = 'title'
class AddressDetail(RetrieveUpdateDestroyAPI):
"""API endpoint for a single Address object"""
queryset = Address.objects.all()
serializer_class = AddressSerializer
class ManufacturerPartFilter(rest_filters.FilterSet):
"""Custom API filters for the ManufacturerPart list endpoint."""
@@ -219,10 +241,6 @@ class ManufacturerPartAttachmentList(AttachmentMixin, ListCreateDestroyAPIView):
queryset = ManufacturerPartAttachment.objects.all()
serializer_class = ManufacturerPartAttachmentSerializer
filter_backends = [
DjangoFilterBackend,
]
filterset_fields = [
'manufacturer_part',
]
@@ -364,7 +382,6 @@ class SupplierPartList(ListCreateDestroyAPIView):
def get_serializer(self, *args, **kwargs):
"""Return serializer instance for this endpoint"""
# Do we wish to include extra detail?
try:
params = self.request.query_params
@@ -471,7 +488,6 @@ class SupplierPriceBreakList(ListCreateAPI):
def get_serializer(self, *args, **kwargs):
"""Return serializer instance for this endpoint"""
try:
params = self.request.query_params
@@ -568,6 +584,11 @@ company_api_urls = [
re_path(r'^.*$', ContactList.as_view(), name='api-contact-list'),
])),
re_path(r'^address/', include([
path('<int:pk>/', AddressDetail.as_view(), name='api-address-detail'),
re_path(r'^.*$', AddressList.as_view(), name='api-address-list'),
])),
re_path(r'^.*$', CompanyList.as_view(), name='api-company-list'),
]

View File

@@ -0,0 +1,33 @@
# Generated by Django 3.2.18 on 2023-05-02 19:56
import InvenTree.fields
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('company', '0062_contact_metadata'),
]
operations = [
migrations.CreateModel(
name='Address',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('title', models.CharField(help_text='Title describing the address entry', max_length=100, verbose_name='Address title')),
('primary', models.BooleanField(default=False, help_text='Set as primary address', verbose_name='Primary address')),
('line1', models.CharField(blank=True, help_text='Address line 1', max_length=50, verbose_name='Line 1')),
('line2', models.CharField(blank=True, help_text='Address line 2', max_length=50, verbose_name='Line 2')),
('postal_code', models.CharField(blank=True, help_text='Postal code', max_length=10, verbose_name='Postal code')),
('postal_city', models.CharField(blank=True, help_text='Postal code city', max_length=50, verbose_name='City')),
('province', models.CharField(blank=True, help_text='State or province', max_length=50, verbose_name='State/Province')),
('country', models.CharField(blank=True, help_text='Address country', max_length=50, verbose_name='Country')),
('shipping_notes', models.CharField(blank=True, help_text='Notes for shipping courier', max_length=100, verbose_name='Courier shipping notes')),
('internal_shipping_notes', models.CharField(blank=True, help_text='Shipping notes for internal use', max_length=100, verbose_name='Internal shipping notes')),
('link', InvenTree.fields.InvenTreeURLField(blank=True, help_text='Link to address information (external)', verbose_name='Link')),
('company', models.ForeignKey(help_text='Select company', on_delete=django.db.models.deletion.CASCADE, related_name='addresses', to='company.company', verbose_name='Company')),
],
),
]

View File

@@ -0,0 +1,37 @@
# Generated by Django 3.2.18 on 2023-05-02 20:41
from django.db import migrations
def move_address_to_new_model(apps, schema_editor):
Company = apps.get_model('company', 'Company')
Address = apps.get_model('company', 'Address')
for company in Company.objects.all():
if company.address != '':
# Address field might exceed length of new model fields
l1 = company.address[:50]
l2 = company.address[50:100]
Address.objects.create(company=company,
title="Primary",
primary=True,
line1=l1,
line2=l2)
company.address = ''
company.save()
def revert_address_move(apps, schema_editor):
Address = apps.get_model('company', 'Address')
for address in Address.objects.all():
address.company.address = f'{address.line1}{address.line2}'
address.company.save()
address.delete()
class Migration(migrations.Migration):
dependencies = [
('company', '0063_auto_20230502_1956'),
]
operations = [
migrations.RunPython(move_address_to_new_model, reverse_code=revert_address_move)
]

View File

@@ -0,0 +1,17 @@
# Generated by Django 3.2.18 on 2023-05-13 14:53
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('company', '0064_move_address_field_to_address_model'),
]
operations = [
migrations.RemoveField(
model_name='company',
name='address',
),
]

View File

@@ -0,0 +1,22 @@
# Generated by Django 3.2.19 on 2023-06-16 20:59
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('company', '0065_remove_company_address'),
]
operations = [
migrations.AlterModelOptions(
name='address',
options={'verbose_name_plural': 'Addresses'},
),
migrations.AlterField(
model_name='address',
name='postal_city',
field=models.CharField(blank=True, help_text='Postal code city/region', max_length=50, verbose_name='City/Region'),
),
]

View File

@@ -0,0 +1,19 @@
# Generated by Django 3.2.22 on 2023-10-24 16:44
from django.db import migrations
import djmoney.models.fields
class Migration(migrations.Migration):
dependencies = [
('company', '0066_auto_20230616_2059'),
]
operations = [
migrations.AlterField(
model_name='supplierpricebreak',
name='price_currency',
field=djmoney.models.fields.CurrencyField(choices=[], default='', editable=False, max_length=3, null=True),
),
]

View File

@@ -50,7 +50,7 @@ def rename_company_image(instance, filename):
else:
ext = ''
fn = 'company_{pk}_img'.format(pk=instance.pk)
fn = f'company_{instance.pk}_img'
if ext:
fn += '.' + ext
@@ -72,7 +72,7 @@ class Company(InvenTreeNotesMixin, MetadataMixin, models.Model):
name: Brief name of the company
description: Longer form description
website: URL for the company website
address: Postal address
address: One-line string representation of primary address
phone: contact phone number
email: contact email address
link: Secondary URL e.g. for link to internal Wiki page
@@ -114,10 +114,6 @@ class Company(InvenTreeNotesMixin, MetadataMixin, models.Model):
help_text=_('Company website URL')
)
address = models.CharField(max_length=200,
verbose_name=_('Address'),
blank=True, help_text=_('Company address'))
phone = models.CharField(max_length=50,
verbose_name=_('Phone number'),
blank=True, help_text=_('Contact phone number'))
@@ -158,6 +154,21 @@ class Company(InvenTreeNotesMixin, MetadataMixin, models.Model):
validators=[InvenTree.validators.validate_currency_code],
)
@property
def address(self):
"""Return the string representation for the primary address
This property exists for backwards compatibility
"""
addr = self.primary_address
return str(addr) if addr is not None else None
@property
def primary_address(self):
"""Returns address object of primary address. Parsed by serializer"""
return Address.objects.filter(company=self.id).filter(primary=True).first()
@property
def currency_code(self):
"""Return the currency code associated with this company.
@@ -174,7 +185,7 @@ class Company(InvenTreeNotesMixin, MetadataMixin, models.Model):
def __str__(self):
"""Get string representation of a Company."""
return "{n} - {d}".format(n=self.name, d=self.description)
return f"{self.name} - {self.description}"
def get_absolute_url(self):
"""Get the web URL for the detail view for this Company."""
@@ -184,15 +195,13 @@ class Company(InvenTreeNotesMixin, MetadataMixin, models.Model):
"""Return the URL of the image for this company."""
if self.image:
return InvenTree.helpers.getMediaUrl(self.image.url)
else:
return InvenTree.helpers.getBlankImage()
return InvenTree.helpers.getBlankImage()
def get_thumbnail_url(self):
"""Return the URL for the thumbnail image for this Company."""
if self.image:
return InvenTree.helpers.getMediaUrl(self.image.thumbnail.url)
else:
return InvenTree.helpers.getBlankThumbnail()
return InvenTree.helpers.getBlankThumbnail()
@property
def parts(self):
@@ -253,6 +262,131 @@ class Contact(MetadataMixin, models.Model):
role = models.CharField(max_length=100, blank=True)
class Address(models.Model):
"""An address represents a physical location where the company is located. It is possible for a company to have multiple locations
Attributes:
company: Company link for this address
title: Human-readable name for the address
primary: True if this is the company's primary address
line1: First line of address
line2: Optional line two for address
postal_code: Postal code, city and state
country: Location country
shipping_notes: Notes for couriers transporting shipments to this address
internal_shipping_notes: Internal notes regarding shipping to this address
link: External link to additional address information
"""
class Meta:
"""Metaclass defines extra model options"""
verbose_name_plural = "Addresses"
def __init__(self, *args, **kwargs):
"""Custom init function"""
super().__init__(*args, **kwargs)
def __str__(self):
"""Defines string representation of address to supple a one-line to API calls"""
available_lines = [self.line1,
self.line2,
self.postal_code,
self.postal_city,
self.province,
self.country
]
populated_lines = []
for line in available_lines:
if len(line) > 0:
populated_lines.append(line)
return ", ".join(populated_lines)
def save(self, *args, **kwargs):
"""Run checks when saving an address:
- If this address is marked as "primary", ensure that all other addresses for this company are marked as non-primary
"""
others = list(Address.objects.filter(company=self.company).exclude(pk=self.pk).all())
# If this is the *only* address for this company, make it the primary one
if len(others) == 0:
self.primary = True
super().save(*args, **kwargs)
# Once this address is saved, check others
if self.primary:
for addr in others:
if addr.primary:
addr.primary = False
addr.save()
@staticmethod
def get_api_url():
"""Return the API URL associated with the Contcat model"""
return reverse('api-address-list')
company = models.ForeignKey(Company, related_name='addresses',
on_delete=models.CASCADE,
verbose_name=_('Company'),
help_text=_('Select company'))
title = models.CharField(max_length=100,
verbose_name=_('Address title'),
help_text=_('Title describing the address entry'),
blank=False)
primary = models.BooleanField(default=False,
verbose_name=_('Primary address'),
help_text=_('Set as primary address'))
line1 = models.CharField(max_length=50,
verbose_name=_('Line 1'),
help_text=_('Address line 1'),
blank=True)
line2 = models.CharField(max_length=50,
verbose_name=_('Line 2'),
help_text=_('Address line 2'),
blank=True)
postal_code = models.CharField(max_length=10,
verbose_name=_('Postal code'),
help_text=_('Postal code'),
blank=True)
postal_city = models.CharField(max_length=50,
verbose_name=_('City/Region'),
help_text=_('Postal code city/region'),
blank=True)
province = models.CharField(max_length=50,
verbose_name=_('State/Province'),
help_text=_('State or province'),
blank=True)
country = models.CharField(max_length=50,
verbose_name=_('Country'),
help_text=_('Address country'),
blank=True)
shipping_notes = models.CharField(max_length=100,
verbose_name=_('Courier shipping notes'),
help_text=_('Notes for shipping courier'),
blank=True)
internal_shipping_notes = models.CharField(max_length=100,
verbose_name=_('Internal shipping notes'),
help_text=_('Shipping notes for internal use'),
blank=True)
link = InvenTreeURLField(blank=True,
verbose_name=_('Link'),
help_text=_('Link to address information (external)'))
class ManufacturerPart(MetadataMixin, models.Model):
"""Represents a unique part as provided by a Manufacturer Each ManufacturerPart is identified by a MPN (Manufacturer Part Number) Each ManufacturerPart is also linked to a Part object. A Part may be available from multiple manufacturers.
@@ -489,11 +623,12 @@ class SupplierPart(MetadataMixin, InvenTreeBarcodeMixin, common.models.MetaMixin
try:
# Attempt conversion to specified unit
native_value = InvenTree.conversion.convert_physical_value(
self.pack_quantity, self.part.units
self.pack_quantity, self.part.units,
strip_units=False
)
# If part units are not provided, value must be dimensionless
if not self.part.units and native_value.units not in ['', 'dimensionless']:
if not self.part.units and not InvenTree.conversion.is_dimensionless(native_value):
raise ValidationError({
'pack_quantity': _("Pack units must be compatible with the base part units")
})
@@ -615,7 +750,6 @@ class SupplierPart(MetadataMixin, InvenTreeBarcodeMixin, common.models.MetaMixin
def base_quantity(self, quantity=1) -> Decimal:
"""Calculate the base unit quantiy for a given quantity."""
q = Decimal(quantity) * Decimal(self.pack_quantity_native)
q = round(q, 10).normalize()
@@ -640,7 +774,6 @@ class SupplierPart(MetadataMixin, InvenTreeBarcodeMixin, common.models.MetaMixin
def update_available_quantity(self, quantity):
"""Update the available quantity for this SupplierPart"""
self.available = quantity
self.availability_updated = datetime.now()
self.save()
@@ -714,8 +847,7 @@ class SupplierPart(MetadataMixin, InvenTreeBarcodeMixin, common.models.MetaMixin
if q is None or r is None:
return 0
else:
return max(q - r, 0)
return max(q - r, 0)
def purchase_orders(self):
"""Returns a list of purchase orders relating to this supplier part."""
@@ -778,7 +910,6 @@ class SupplierPriceBreak(common.models.PriceBreak):
@receiver(post_save, sender=SupplierPriceBreak, dispatch_uid='post_save_supplier_price_break')
def after_save_supplier_price(sender, instance, created, **kwargs):
"""Callback function when a SupplierPriceBreak is created or updated"""
if InvenTree.ready.canAppAccessDatabase() and not InvenTree.ready.isImportingData():
if instance.part and instance.part.part:
@@ -788,7 +919,6 @@ def after_save_supplier_price(sender, instance, created, **kwargs):
@receiver(post_delete, sender=SupplierPriceBreak, dispatch_uid='post_delete_supplier_price_break')
def after_delete_supplier_price(sender, instance, **kwargs):
"""Callback function when a SupplierPriceBreak is deleted"""
if InvenTree.ready.canAppAccessDatabase() and not InvenTree.ready.isImportingData():
if instance.part and instance.part.part:

View File

@@ -20,9 +20,10 @@ from InvenTree.serializers import (InvenTreeAttachmentSerializer,
RemoteImageMixin)
from part.serializers import PartBriefSerializer
from .models import (Company, CompanyAttachment, Contact, ManufacturerPart,
ManufacturerPartAttachment, ManufacturerPartParameter,
SupplierPart, SupplierPriceBreak)
from .models import (Address, Company, CompanyAttachment, Contact,
ManufacturerPart, ManufacturerPartAttachment,
ManufacturerPartParameter, SupplierPart,
SupplierPriceBreak)
class CompanyBriefSerializer(InvenTreeModelSerializer):
@@ -45,6 +46,50 @@ class CompanyBriefSerializer(InvenTreeModelSerializer):
image = serializers.CharField(source='get_thumbnail_url', read_only=True)
class AddressSerializer(InvenTreeModelSerializer):
"""Serializer for the Address Model"""
class Meta:
"""Metaclass options"""
model = Address
fields = [
'pk',
'company',
'title',
'primary',
'line1',
'line2',
'postal_code',
'postal_city',
'province',
'country',
'shipping_notes',
'internal_shipping_notes',
'link',
]
class AddressBriefSerializer(InvenTreeModelSerializer):
"""Serializer for Address Model (limited)"""
class Meta:
"""Metaclass options"""
model = Address
fields = [
'pk',
'line1',
'line2',
'postal_code',
'postal_city',
'province',
'country',
'shipping_notes',
'internal_shipping_notes'
]
class CompanySerializer(RemoteImageMixin, InvenTreeModelSerializer):
"""Serializer for Company object (full detail)"""
@@ -73,11 +118,13 @@ class CompanySerializer(RemoteImageMixin, InvenTreeModelSerializer):
'parts_supplied',
'parts_manufactured',
'remote_image',
'address_count',
'primary_address'
]
@staticmethod
def annotate_queryset(queryset):
"""Annoate the supplied queryset with aggregated information"""
"""Annotate the supplied queryset with aggregated information"""
# Add count of parts manufactured
queryset = queryset.annotate(
parts_manufactured=SubqueryCount('manufactured_parts')
@@ -87,14 +134,21 @@ class CompanySerializer(RemoteImageMixin, InvenTreeModelSerializer):
parts_supplied=SubqueryCount('supplied_parts')
)
queryset = queryset.annotate(
address_count=SubqueryCount('addresses')
)
return queryset
primary_address = AddressSerializer(required=False, allow_null=True, read_only=True)
url = serializers.CharField(source='get_absolute_url', read_only=True)
image = InvenTreeImageSerializerField(required=False, allow_null=True)
parts_supplied = serializers.IntegerField(read_only=True)
parts_manufactured = serializers.IntegerField(read_only=True)
address_count = serializers.IntegerField(read_only=True)
currency = InvenTreeCurrencySerializer(help_text=_('Default currency used for this supplier'), required=True)
@@ -288,7 +342,6 @@ class SupplierPartSerializer(InvenTreeTagModelSerializer):
def __init__(self, *args, **kwargs):
"""Initialize this serializer with extra detail fields as required"""
# Check if 'available' quantity was supplied
self.has_available_quantity = 'available' in kwargs.get('data', {})
@@ -319,6 +372,7 @@ class SupplierPartSerializer(InvenTreeTagModelSerializer):
# Annotated field showing total in-stock quantity
in_stock = serializers.FloatField(read_only=True)
available = serializers.FloatField(required=False)
part_detail = PartBriefSerializer(source='part', many=False, read_only=True)
@@ -348,7 +402,6 @@ class SupplierPartSerializer(InvenTreeTagModelSerializer):
Fields:
in_stock: Current stock quantity for each SupplierPart
"""
queryset = queryset.annotate(
in_stock=part.filters.annotate_total_stock()
)
@@ -357,7 +410,6 @@ class SupplierPartSerializer(InvenTreeTagModelSerializer):
def update(self, supplier_part, data):
"""Custom update functionality for the serializer"""
available = data.pop('available', None)
response = super().update(supplier_part, data)
@@ -369,7 +421,6 @@ class SupplierPartSerializer(InvenTreeTagModelSerializer):
def create(self, validated_data):
"""Extract manufacturer data and process ManufacturerPart."""
# Extract 'available' quantity from the serializer
available = validated_data.pop('available', None)
@@ -414,7 +465,6 @@ class SupplierPriceBreakSerializer(InvenTreeModelSerializer):
def __init__(self, *args, **kwargs):
"""Initialize this serializer with extra fields as required"""
supplier_detail = kwargs.pop('supplier_detail', False)
part_detail = kwargs.pop('part_detail', False)

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