mirror of
https://github.com/inventree/InvenTree.git
synced 2025-12-19 10:43:28 +00:00
Merge branch 'master' of https://github.com/inventree/inventree
This commit is contained in:
33
.deepsource.toml
Normal file
33
.deepsource.toml
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
1
.github/FUNDING.yml
vendored
@@ -1,3 +1,4 @@
|
||||
github: inventree
|
||||
ko_fi: inventree
|
||||
patreon: inventree
|
||||
custom: [paypal.me/inventree]
|
||||
|
||||
2
.github/actions/migration/action.yaml
vendored
2
.github/actions/migration/action.yaml
vendored
@@ -1,5 +1,5 @@
|
||||
name: 'Migration test'
|
||||
description: 'Run migration test sequenze'
|
||||
description: 'Run migration test sequence'
|
||||
author: 'InvenTree'
|
||||
|
||||
runs:
|
||||
|
||||
6
.github/actions/setup/action.yaml
vendored
6
.github/actions/setup/action.yaml
vendored
@@ -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'
|
||||
|
||||
4
.github/workflows/backport.yml
vendored
4
.github/workflows/backport.yml
vendored
@@ -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-
|
||||
|
||||
4
.github/workflows/check_translations.yaml
vendored
4
.github/workflows/check_translations.yaml
vendored
@@ -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'
|
||||
|
||||
25
.github/workflows/docker.yaml
vendored
25
.github/workflows/docker.yaml
vendored
@@ -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 }}
|
||||
|
||||
222
.github/workflows/qc_checks.yaml
vendored
222
.github/workflows/qc_checks.yaml
vendored
@@ -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
|
||||
|
||||
28
.github/workflows/release.yml
vendored
28
.github/workflows/release.yml
vendored
@@ -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
33
.github/workflows/sponsors.yml
vendored
Normal 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
|
||||
5
.github/workflows/stale.yml
vendored
5
.github/workflows/stale.yml
vendored
@@ -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'
|
||||
|
||||
13
.github/workflows/translations.yml
vendored
13
.github/workflows/translations.yml
vendored
@@ -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
|
||||
|
||||
5
.github/workflows/update.yml.disabled
vendored
5
.github/workflows/update.yml.disabled
vendored
@@ -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
11
.gitignore
vendored
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
6
.vscode/tasks.json
vendored
@@ -6,6 +6,12 @@
|
||||
|
||||
"version": "2.0.0",
|
||||
"tasks": [
|
||||
{
|
||||
"label": "worker",
|
||||
"type": "shell",
|
||||
"command": "inv worker",
|
||||
"problemMatcher": [],
|
||||
},
|
||||
{
|
||||
"label": "clean-settings",
|
||||
"type": "shell",
|
||||
|
||||
@@ -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 | | |
|
||||
|
||||
105
Dockerfile
105
Dockerfile
@@ -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}"]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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.'))
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
75
InvenTree/InvenTree/magic_login.py
Normal file
75
InvenTree/InvenTree/magic_login.py
Normal 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
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -18,7 +18,7 @@ class Command(BaseCommand):
|
||||
|
||||
while not connected:
|
||||
|
||||
time.sleep(5)
|
||||
time.sleep(2)
|
||||
|
||||
try:
|
||||
connection.ensure_connection()
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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/"
|
||||
|
||||
211
InvenTree/InvenTree/social_auth_urls.py
Normal file
211
InvenTree/InvenTree/social_auth_urls.py
Normal 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()
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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')),
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
Hello {{username}},
|
||||
|
||||
You requested that we send you a link to log in to our app:
|
||||
|
||||
{{link}}
|
||||
|
||||
Regards,
|
||||
{{site_name}}
|
||||
@@ -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
|
||||
|
||||
41
InvenTree/InvenTree/test_api_version.py
Normal file
41
InvenTree/InvenTree/test_api_version.py
Normal 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)
|
||||
@@ -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/")
|
||||
|
||||
@@ -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)
|
||||
|
||||
49
InvenTree/InvenTree/translation.py
Normal file
49
InvenTree/InvenTree/translation.py
Normal 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)
|
||||
@@ -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 = {}
|
||||
|
||||
|
||||
@@ -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')]
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.', '')
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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',
|
||||
]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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'>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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'>
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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
|
||||
|
||||
22
InvenTree/common/migrations/0020_customunit.py
Normal file
22
InvenTree/common/migrations/0020_customunit.py
Normal 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')),
|
||||
],
|
||||
),
|
||||
]
|
||||
23
InvenTree/common/migrations/0021_auto_20230805_1748.py
Normal file
23
InvenTree/common/migrations/0021_auto_20230805_1748.py
Normal 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),
|
||||
),
|
||||
]
|
||||
@@ -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()
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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',
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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})
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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'),
|
||||
|
||||
]
|
||||
|
||||
33
InvenTree/company/migrations/0063_auto_20230502_1956.py
Normal file
33
InvenTree/company/migrations/0063_auto_20230502_1956.py
Normal 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')),
|
||||
],
|
||||
),
|
||||
]
|
||||
@@ -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)
|
||||
]
|
||||
17
InvenTree/company/migrations/0065_remove_company_address.py
Normal file
17
InvenTree/company/migrations/0065_remove_company_address.py
Normal 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',
|
||||
),
|
||||
]
|
||||
22
InvenTree/company/migrations/0066_auto_20230616_2059.py
Normal file
22
InvenTree/company/migrations/0066_auto_20230616_2059.py
Normal 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'),
|
||||
),
|
||||
]
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
@@ -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:
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user