mirror of
https://github.com/inventree/InvenTree.git
synced 2025-12-18 04:45:12 -06:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
df0bb34620 | ||
|
|
75a397aab8 | ||
|
|
f4fd84f50f |
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
|
||||
@@ -1,43 +0,0 @@
|
||||
# Dockerfile for the InvenTree devcontainer
|
||||
|
||||
# In contrast with the "production" image (which is based on an Alpine image)
|
||||
# we use a Debian-based image for the devcontainer
|
||||
|
||||
FROM mcr.microsoft.com/devcontainers/python:3.11-bookworm@sha256:5140e54af7a0399a4932dd4c4653d085fcf451b093d7424867df1828ffbb9b81
|
||||
|
||||
# InvenTree paths
|
||||
ENV INVENTREE_HOME="/home/inventree"
|
||||
ENV INVENTREE_DATA_DIR="${INVENTREE_HOME}/dev"
|
||||
ENV INVENTREE_STATIC_ROOT="${INVENTREE_DATA_DIR}/static"
|
||||
ENV INVENTREE_MEDIA_ROOT="${INVENTREE_DATA_DIR}/media"
|
||||
ENV INVENTREE_BACKUP_DIR="${INVENTREE_DATA_DIR}/backup"
|
||||
ENV INVENTREE_PLUGIN_DIR="${INVENTREE_DATA_DIR}/plugins"
|
||||
ENV INVENTREE_CONFIG_FILE="${INVENTREE_DATA_DIR}/config.yaml"
|
||||
ENV INVENTREE_SECRET_KEY_FILE="${INVENTREE_DATA_DIR}/secret_key.txt"
|
||||
ENV INVENTREE_OIDC_PRIVATE_KEY_FILE="${INVENTREE_DATA_DIR}/oidc.pem"
|
||||
|
||||
# Required for running playwright within devcontainer
|
||||
ENV DISPLAY=:0
|
||||
ENV LIBGL_ALWAYS_INDIRECT=1
|
||||
|
||||
COPY contrib/container/init.sh ./
|
||||
RUN chmod +x init.sh
|
||||
|
||||
# Install required base packages
|
||||
RUN apt update && apt install -y \
|
||||
python3.11-dev python3.11-venv \
|
||||
postgresql-client \
|
||||
libldap2-dev libsasl2-dev \
|
||||
libpango1.0-0 libcairo2 \
|
||||
poppler-utils weasyprint
|
||||
|
||||
# Install packages required for frontend development
|
||||
RUN apt install -y \
|
||||
yarn nodejs npm
|
||||
|
||||
# Update to the latest stable node version
|
||||
RUN npm install -g n --ignore-scripts && n lts
|
||||
|
||||
RUN yarn config set network-timeout 600000 -g
|
||||
|
||||
ENTRYPOINT ["/bin/bash", "./init.sh"]
|
||||
@@ -31,24 +31,17 @@
|
||||
"ms-python.python",
|
||||
"ms-python.vscode-pylance",
|
||||
"batisteo.vscode-django",
|
||||
"eamodio.gitlens",
|
||||
"biomejs.biome"
|
||||
"eamodio.gitlens"
|
||||
]
|
||||
}
|
||||
},
|
||||
|
||||
// Use 'forwardPorts' to make a list of ports inside the container available locally.
|
||||
"forwardPorts": [5173, 5432, 6379, 8000, 8080],
|
||||
"forwardPorts": [5173, 8000, 8080],
|
||||
"portsAttributes": {
|
||||
"5173": {
|
||||
"label": "Vite Server"
|
||||
},
|
||||
"5432": {
|
||||
"label": "PostgreSQL Database"
|
||||
},
|
||||
"6379": {
|
||||
"label": "Redis Server"
|
||||
},
|
||||
"8000": {
|
||||
"label": "InvenTree Server"
|
||||
},
|
||||
|
||||
@@ -1,46 +1,40 @@
|
||||
version: "3"
|
||||
|
||||
services:
|
||||
db:
|
||||
image: postgres:15
|
||||
image: postgres:13
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
expose:
|
||||
- 5432/tcp
|
||||
volumes:
|
||||
- ../dev-db/:/var/lib/postgresql/data:z
|
||||
- inventreedatabase:/var/lib/postgresql/data:z
|
||||
environment:
|
||||
POSTGRES_DB: inventree
|
||||
POSTGRES_USER: inventree_user
|
||||
POSTGRES_PASSWORD: inventree_password
|
||||
|
||||
redis:
|
||||
image: redis:7.0
|
||||
restart: always
|
||||
ports:
|
||||
- 6379
|
||||
|
||||
inventree:
|
||||
ports:
|
||||
- 8000:8000
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: .devcontainer/Dockerfile
|
||||
target: dev
|
||||
args:
|
||||
base_image: "mcr.microsoft.com/vscode/devcontainers/base:alpine-3.18"
|
||||
data_dir: "dev"
|
||||
volumes:
|
||||
- ../:/home/inventree:z
|
||||
- /tmp/.X11-unix:/tmp/.X11-unix
|
||||
|
||||
environment:
|
||||
INVENTREE_DEBUG: True
|
||||
INVENTREE_DB_ENGINE: postgresql
|
||||
INVENTREE_DB_NAME: inventree
|
||||
INVENTREE_DB_HOST: db
|
||||
INVENTREE_DB_USER: inventree_user
|
||||
INVENTREE_DB_PASSWORD: inventree_password
|
||||
INVENTREE_DEBUG: True
|
||||
INVENTREE_CACHE_HOST: redis
|
||||
INVENTREE_CACHE_PORT: 6379
|
||||
INVENTREE_PLUGINS_ENABLED: True
|
||||
INVENTREE_SITE_URL: http://localhost:8000
|
||||
INVENTREE_CORS_ORIGIN_ALLOW_ALL: True
|
||||
INVENTREE_PY_ENV: /home/inventree/dev/venv
|
||||
INVENTREE_DEVCONTAINER: True
|
||||
|
||||
depends_on:
|
||||
- db
|
||||
|
||||
volumes:
|
||||
inventreedatabase:
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
echo "Running postCreateCommand.sh ..."
|
||||
|
||||
# Avoiding Dubious Ownership in Dev Containers for setup commands that use git
|
||||
git config --global --add safe.directory /home/inventree
|
||||
@@ -10,30 +7,16 @@ git config --global --add safe.directory /home/inventree
|
||||
python3 -m venv /home/inventree/dev/venv --system-site-packages --upgrade-deps
|
||||
. /home/inventree/dev/venv/bin/activate
|
||||
|
||||
# remove existing gitconfig created by "Avoiding Dubious Ownership" step
|
||||
# so that it gets copied from host to the container to have your global
|
||||
# git config in container
|
||||
rm -f /home/vscode/.gitconfig
|
||||
|
||||
# Fix issue related to CFFI version mismatch
|
||||
pip uninstall cffi -y
|
||||
sudo apt remove --purge -y python3-cffi
|
||||
pip install --no-cache-dir --force-reinstall --ignore-installed cffi
|
||||
|
||||
# Upgrade pip
|
||||
python3 -m pip install --upgrade pip
|
||||
|
||||
# Ensure the correct invoke is available
|
||||
pip3 install --ignore-installed --upgrade invoke Pillow
|
||||
|
||||
# install base level packages
|
||||
pip3 install -Ur contrib/container/requirements.txt --require-hashes
|
||||
|
||||
# Run initial InvenTree server setup
|
||||
invoke update -s
|
||||
|
||||
# Configure dev environment
|
||||
invoke dev.setup-dev
|
||||
invoke setup-dev
|
||||
|
||||
# Install required frontend packages
|
||||
invoke int.frontend-install
|
||||
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
|
||||
# git config in container
|
||||
rm -f /home/vscode/.gitconfig
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
trigger:
|
||||
batch: true
|
||||
branches:
|
||||
include:
|
||||
- master
|
||||
- stable
|
||||
- refs/tags/*
|
||||
paths:
|
||||
include:
|
||||
- src/backend
|
||||
|
||||
pool:
|
||||
vmImage: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
Python39:
|
||||
PYTHON_VERSION: '3.9'
|
||||
maxParallel: 3
|
||||
|
||||
steps:
|
||||
- task: UsePythonVersion@0
|
||||
inputs:
|
||||
versionSpec: '$(PYTHON_VERSION)'
|
||||
architecture: 'x64'
|
||||
|
||||
- task: PythonScript@0
|
||||
displayName: 'Export project path'
|
||||
inputs:
|
||||
scriptSource: 'inline'
|
||||
script: |
|
||||
"""Search all subdirectories for `manage.py`."""
|
||||
from glob import iglob
|
||||
from os import path
|
||||
# Python >= 3.5
|
||||
manage_py = next(iglob(path.join('**', 'manage.py'), recursive=True), None)
|
||||
if not manage_py:
|
||||
raise SystemExit('Could not find a Django project')
|
||||
project_location = path.dirname(path.abspath(manage_py))
|
||||
print('Found Django project in', project_location)
|
||||
print('##vso[task.setvariable variable=projectRoot]{}'.format(project_location))
|
||||
|
||||
- script: |
|
||||
python -m pip install --upgrade pip setuptools wheel uv
|
||||
uv pip install --require-hashes -r src/backend/requirements.txt
|
||||
uv pip install --require-hashes -r src/backend/requirements-dev.txt
|
||||
sudo apt-get install poppler-utils
|
||||
sudo apt-get install libpoppler-dev
|
||||
uv pip install unittest-xml-reporting coverage invoke
|
||||
displayName: 'Install prerequisites'
|
||||
env:
|
||||
UV_SYSTEM_PYTHON: 1
|
||||
|
||||
- script: |
|
||||
pushd '$(projectRoot)'
|
||||
invoke update --uv
|
||||
coverage run manage.py test --testrunner xmlrunner.extra.djangotestrunner.XMLTestRunner --no-input
|
||||
coverage xml -i
|
||||
displayName: 'Run tests'
|
||||
env:
|
||||
INVENTREE_DB_ENGINE: sqlite3
|
||||
INVENTREE_DB_NAME: inventree
|
||||
INVENTREE_MEDIA_ROOT: ./media
|
||||
INVENTREE_STATIC_ROOT: ./static
|
||||
INVENTREE_BACKUP_DIR: ./backup
|
||||
INVENTREE_SITE_URL: http://localhost:8000
|
||||
INVENTREE_PLUGINS_ENABLED: true
|
||||
UV_SYSTEM_PYTHON: 1
|
||||
INVENTREE_DEBUG: true
|
||||
INVENTREE_LOG_LEVEL: INFO
|
||||
|
||||
- task: PublishTestResults@2
|
||||
inputs:
|
||||
testResultsFiles: "**/TEST-*.xml"
|
||||
testRunTitle: 'Python $(PYTHON_VERSION)'
|
||||
condition: succeededOrFailed()
|
||||
|
||||
- task: PublishCodeCoverageResults@2
|
||||
inputs:
|
||||
summaryFileLocation: '$(System.DefaultWorkingDirectory)/**/coverage.xml'
|
||||
71
.devops/testing_ci.yml
Normal file
71
.devops/testing_ci.yml
Normal file
@@ -0,0 +1,71 @@
|
||||
# Python Django
|
||||
# Test a Django project on multiple versions of Python.
|
||||
# Add steps that analyze code, save build artifacts, deploy, and more:
|
||||
# https://docs.microsoft.com/azure/devops/pipelines/languages/python
|
||||
|
||||
trigger:
|
||||
- master
|
||||
|
||||
pool:
|
||||
vmImage: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
Python39:
|
||||
PYTHON_VERSION: '3.9'
|
||||
maxParallel: 3
|
||||
|
||||
steps:
|
||||
- task: UsePythonVersion@0
|
||||
inputs:
|
||||
versionSpec: '$(PYTHON_VERSION)'
|
||||
architecture: 'x64'
|
||||
|
||||
- task: PythonScript@0
|
||||
displayName: 'Export project path'
|
||||
inputs:
|
||||
scriptSource: 'inline'
|
||||
script: |
|
||||
"""Search all subdirectories for `manage.py`."""
|
||||
from glob import iglob
|
||||
from os import path
|
||||
# Python >= 3.5
|
||||
manage_py = next(iglob(path.join('**', 'manage.py'), recursive=True), None)
|
||||
if not manage_py:
|
||||
raise SystemExit('Could not find a Django project')
|
||||
project_location = path.dirname(path.abspath(manage_py))
|
||||
print('Found Django project in', project_location)
|
||||
print('##vso[task.setvariable variable=projectRoot]{}'.format(project_location))
|
||||
|
||||
- script: |
|
||||
python -m pip install --upgrade pip setuptools wheel
|
||||
pip install -r requirements.txt
|
||||
pip install -r requirements-dev.txt
|
||||
pip install unittest-xml-reporting coverage invoke
|
||||
sudo apt-get install poppler-utils
|
||||
sudo apt-get install libpoppler-dev
|
||||
displayName: 'Install prerequisites'
|
||||
|
||||
- script: |
|
||||
pushd '$(projectRoot)'
|
||||
invoke update
|
||||
coverage run manage.py test --testrunner xmlrunner.extra.djangotestrunner.XMLTestRunner --no-input
|
||||
coverage xml -i
|
||||
displayName: 'Run tests'
|
||||
env:
|
||||
INVENTREE_DB_ENGINE: sqlite3
|
||||
INVENTREE_DB_NAME: inventree
|
||||
INVENTREE_MEDIA_ROOT: ./media
|
||||
INVENTREE_STATIC_ROOT: ./static
|
||||
INVENTREE_BACKUP_DIR: ./backup
|
||||
INVENTREE_PLUGINS_ENABLED: true
|
||||
|
||||
- task: PublishTestResults@2
|
||||
inputs:
|
||||
testResultsFiles: "**/TEST-*.xml"
|
||||
testRunTitle: 'Python $(PYTHON_VERSION)'
|
||||
condition: succeededOrFailed()
|
||||
|
||||
- task: PublishCodeCoverageResults@1
|
||||
inputs:
|
||||
codeCoverageTool: Cobertura
|
||||
summaryFileLocation: '$(System.DefaultWorkingDirectory)/**/coverage.xml'
|
||||
27
.eslintrc.yml
Normal file
27
.eslintrc.yml
Normal file
@@ -0,0 +1,27 @@
|
||||
env:
|
||||
commonjs: false
|
||||
browser: true
|
||||
es2021: true
|
||||
jquery: true
|
||||
extends:
|
||||
- eslint:recommended
|
||||
parserOptions:
|
||||
ecmaVersion: 12
|
||||
rules:
|
||||
no-var: off
|
||||
guard-for-in: off
|
||||
no-trailing-spaces: off
|
||||
camelcase: off
|
||||
padded-blocks: off
|
||||
prefer-const: off
|
||||
max-len: off
|
||||
require-jsdoc: off
|
||||
valid-jsdoc: off
|
||||
no-multiple-empty-lines: off
|
||||
comma-dangle: off
|
||||
no-unused-vars: off
|
||||
no-useless-escape: off
|
||||
prefer-spread: off
|
||||
indent:
|
||||
- error
|
||||
- 4
|
||||
@@ -1,3 +0,0 @@
|
||||
# .git-blame-ignore-revs
|
||||
# Code Structure refactor https://github.com/inventree/InvenTree/pull/5582
|
||||
0bace3f3afaa213c63b5dcc70103f0d232637a9a
|
||||
11
.github/CODEOWNERS
vendored
11
.github/CODEOWNERS
vendored
@@ -1,13 +1,2 @@
|
||||
# General owner is the maintainers team
|
||||
* @SchrodingersGat
|
||||
|
||||
# plugins are co-owned
|
||||
/src/backend/InvenTree/plugin/ @SchrodingersGat @matmair
|
||||
/src/backend/InvenTree/plugins/ @SchrodingersGat @matmair
|
||||
|
||||
# Installer functions
|
||||
.pkgr.yml @matmair
|
||||
Procfile @matmair
|
||||
runtime.txt @matmair
|
||||
/contrib/installer @matmair
|
||||
/contrib/packager.io @matmair
|
||||
|
||||
3
.github/FUNDING.yml
vendored
3
.github/FUNDING.yml
vendored
@@ -1,3 +1,4 @@
|
||||
polar: inventree
|
||||
github: inventree
|
||||
ko_fi: inventree
|
||||
patreon: inventree
|
||||
custom: [paypal.me/inventree]
|
||||
|
||||
32
.github/ISSUE_TEMPLATE/bug_report.yaml
vendored
32
.github/ISSUE_TEMPLATE/bug_report.yaml
vendored
@@ -6,7 +6,7 @@ body:
|
||||
id: no-duplicate-issues
|
||||
attributes:
|
||||
label: "Please verify that this bug has NOT been raised before."
|
||||
description: "Search in the issues sections by clicking [HERE](https://github.com/inventree/inventree/issues?q=) and read the [Frequently Asked Questions](https://docs.inventree.org/en/latest/sref/faq)!"
|
||||
description: "Search in the issues sections by clicking [HERE](https://github.com/inventree/inventree/issues?q=) and read the [Frequently Asked Questions](https://docs.inventree.org/en/latest/faq/)!"
|
||||
options:
|
||||
- label: "I checked and didn't find a similar issue"
|
||||
required: true
|
||||
@@ -37,15 +37,13 @@ body:
|
||||
label: "Expected behaviour"
|
||||
description: "A clear and concise description of what you expected to happen."
|
||||
placeholder: "..."
|
||||
- type: dropdown
|
||||
- type: checkboxes
|
||||
id: deployment
|
||||
attributes:
|
||||
label: "Deployment Method"
|
||||
options:
|
||||
- Docker
|
||||
- Package
|
||||
- Bare metal
|
||||
- Other - added info in Steps to Reproduce
|
||||
- label: "Docker"
|
||||
- label: "Bare metal"
|
||||
- type: textarea
|
||||
id: version-info
|
||||
validations:
|
||||
@@ -54,25 +52,13 @@ body:
|
||||
label: "Version Information"
|
||||
description: "The version info block."
|
||||
placeholder: "You can get this by going to the `About InvenTree` section in the upper right corner and clicking on the `copy version information` button"
|
||||
- type: dropdown
|
||||
id: tried-reproduce
|
||||
- type: checkboxes
|
||||
id: can-reproduce
|
||||
attributes:
|
||||
label: Try to reproduce on the demo site
|
||||
description: You can sign in at [InvenTree Demo](https://demo.inventree.org) with admin:inventree. Note that this instance runs on the latest dev version, so your bug may be fixed there.
|
||||
label: "Please verify if you can reproduce this bug on the demo site."
|
||||
description: "You can sign in at [InvenTree Demo](https://demo.inventree.org) with admin:inventree. Note that this instance runs on the latest dev version, so your bug may be fixed there."
|
||||
options:
|
||||
- I did not try to reproduce
|
||||
- I tried to reproduce
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
id: result-reproduce
|
||||
attributes:
|
||||
label: Is the bug reproducible on the demo site?
|
||||
options:
|
||||
- Not reproducible
|
||||
- Reproducible
|
||||
validations:
|
||||
required: true
|
||||
- label: "I can reproduce this bug on the demo site."
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
|
||||
1
.github/ISSUE_TEMPLATE/config.yml
vendored
1
.github/ISSUE_TEMPLATE/config.yml
vendored
@@ -1 +0,0 @@
|
||||
blank_issues_enabled: false
|
||||
8
.github/actions/migration/action.yaml
vendored
8
.github/actions/migration/action.yaml
vendored
@@ -9,9 +9,9 @@ runs:
|
||||
shell: bash
|
||||
run: |
|
||||
invoke migrate
|
||||
invoke dev.import-fixtures
|
||||
invoke import-fixtures
|
||||
invoke export-records -f data.json
|
||||
python3 ./src/backend/InvenTree/manage.py flush --noinput
|
||||
python3 ./InvenTree/manage.py flush --noinput
|
||||
invoke migrate
|
||||
invoke import-records -c -f data.json
|
||||
invoke import-records -c -f data.json
|
||||
invoke import-records -f data.json
|
||||
invoke import-records -f data.json
|
||||
|
||||
39
.github/actions/setup/action.yaml
vendored
39
.github/actions/setup/action.yaml
vendored
@@ -35,38 +35,28 @@ runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # pin@v4.2.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
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@0a5c61591373683505ea898e09a3ea4f39ef2b9c # pin@v5.0.0
|
||||
uses: actions/setup-python@65d7f2d534ac1bc67fcd62888c5f4f3d2cb2b236 # pin@v4.7.1
|
||||
with:
|
||||
python-version: ${{ env.python_version }}
|
||||
cache: pip
|
||||
cache-dependency-path: |
|
||||
src/backend/requirements.txt
|
||||
src/backend/requirements-dev.txt
|
||||
contrib/container/requirements.txt
|
||||
contrib/dev_reqs/requirements.txt
|
||||
- name: Install Base Python Dependencies
|
||||
if: ${{ inputs.python == 'true' }}
|
||||
shell: bash
|
||||
run: |
|
||||
python3 -m pip install -U pip
|
||||
pip3 install -U invoke wheel
|
||||
pip3 install 'uv>=0.9.6'
|
||||
- name: Allow uv to use the system Python by default
|
||||
run: echo "UV_SYSTEM_PYTHON=1" >> $GITHUB_ENV
|
||||
pip3 install invoke wheel uv
|
||||
- name: Set the VIRTUAL_ENV variable for uv to work
|
||||
run: echo "VIRTUAL_ENV=${Python_ROOT_DIR}" >> $GITHUB_ENV
|
||||
shell: bash
|
||||
- name: Install Specific Python Dependencies
|
||||
if: ${{ inputs.pip-dependency }}
|
||||
shell: bash
|
||||
run: uv pip install ${PIP_DEPS}
|
||||
env:
|
||||
PIP_DEPS: ${{ inputs.pip-dependency }}
|
||||
run: uv pip install ${{ inputs.pip-dependency }}
|
||||
|
||||
# NPM installs
|
||||
- name: Install node.js ${{ env.node_version }}
|
||||
@@ -74,22 +64,25 @@ runs:
|
||||
uses: actions/setup-node@1a4442cacd436585916779262731d5b162bc6ec7 # pin to v3.8.2
|
||||
with:
|
||||
node-version: ${{ env.node_version }}
|
||||
cache: 'npm'
|
||||
- name: Install npm packages
|
||||
if: ${{ inputs.npm == 'true' }}
|
||||
shell: bash
|
||||
run: npm install
|
||||
|
||||
# OS installs
|
||||
- name: Install OS Dependencies
|
||||
if: ${{ inputs.apt-dependency }}
|
||||
shell: bash
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install ${APT_DEPS}
|
||||
sudo apt-get install ${APT_DEPS}
|
||||
env:
|
||||
APT_DEPS: ${{ inputs.apt-dependency }}
|
||||
sudo apt-get install ${{ inputs.apt-dependency }}
|
||||
|
||||
# Invoke commands
|
||||
- name: Install dev requirements
|
||||
if: ${{ inputs.dev-install == 'true' || inputs.install == 'true' }}
|
||||
if: ${{ inputs.dev-install == 'true' ||inputs.install == 'true' }}
|
||||
shell: bash
|
||||
run: uv pip install --require-hashes -r src/backend/requirements-dev.txt
|
||||
run: uv pip install -r requirements-dev.txt
|
||||
- name: Run invoke install
|
||||
if: ${{ inputs.install == 'true' }}
|
||||
shell: bash
|
||||
@@ -97,4 +90,4 @@ runs:
|
||||
- name: Run invoke update
|
||||
if: ${{ inputs.update == 'true' }}
|
||||
shell: bash
|
||||
run: invoke update --uv --skip-backup --skip-static
|
||||
run: invoke update --uv
|
||||
|
||||
47
.github/dependabot.yml
vendored
47
.github/dependabot.yml
vendored
@@ -1,47 +0,0 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: github-actions
|
||||
directory: /
|
||||
schedule:
|
||||
interval: weekly
|
||||
groups:
|
||||
dependencies:
|
||||
patterns:
|
||||
- "*" # Include all dependencies
|
||||
|
||||
- package-ecosystem: docker
|
||||
directory: /contrib/container
|
||||
schedule:
|
||||
interval: weekly
|
||||
|
||||
- package-ecosystem: docker
|
||||
directory: /.devcontainer
|
||||
schedule:
|
||||
interval: weekly
|
||||
|
||||
- package-ecosystem: pip
|
||||
directories:
|
||||
- /docs
|
||||
- /contrib/dev_reqs
|
||||
- /contrib/container
|
||||
- /src/backend
|
||||
schedule:
|
||||
interval: weekly
|
||||
day: friday
|
||||
groups:
|
||||
dependencies:
|
||||
patterns:
|
||||
- "*" # Include all dependencies
|
||||
assignees:
|
||||
- "matmair"
|
||||
versioning-strategy: increase
|
||||
|
||||
- package-ecosystem: npm
|
||||
directories:
|
||||
- /src/frontend
|
||||
schedule:
|
||||
interval: weekly
|
||||
groups:
|
||||
dependencies:
|
||||
patterns:
|
||||
- "*" # Include all dependencies
|
||||
7
.github/release.yml
vendored
7
.github/release.yml
vendored
@@ -4,7 +4,6 @@ changelog:
|
||||
exclude:
|
||||
labels:
|
||||
- translation
|
||||
- translations
|
||||
- documentation
|
||||
categories:
|
||||
- title: Breaking Changes
|
||||
@@ -14,9 +13,6 @@ changelog:
|
||||
- title: Security Patches
|
||||
labels:
|
||||
- security
|
||||
- title: Database Changes
|
||||
labels:
|
||||
- migration
|
||||
- title: New Features
|
||||
labels:
|
||||
- Semver-Minor
|
||||
@@ -35,9 +31,6 @@ changelog:
|
||||
- setup
|
||||
- demo
|
||||
- CI
|
||||
- title: Dependencies
|
||||
labels:
|
||||
- dependency
|
||||
- title: Other Changes
|
||||
labels:
|
||||
- "*"
|
||||
|
||||
100
.github/scripts/check_source_strings.py
vendored
100
.github/scripts/check_source_strings.py
vendored
@@ -1,100 +0,0 @@
|
||||
"""Script to check source strings for translations."""
|
||||
|
||||
import argparse
|
||||
import os
|
||||
|
||||
import rapidfuzz
|
||||
|
||||
BACKEND_SOURCE_FILE = [
|
||||
'..',
|
||||
'..',
|
||||
'src',
|
||||
'backend',
|
||||
'InvenTree',
|
||||
'locale',
|
||||
'en',
|
||||
'LC_MESSAGES',
|
||||
'django.po',
|
||||
]
|
||||
|
||||
FRONTEND_SOURCE_FILE = [
|
||||
'..',
|
||||
'..',
|
||||
'src',
|
||||
'frontend',
|
||||
'src',
|
||||
'locales',
|
||||
'en',
|
||||
'messages.po',
|
||||
]
|
||||
|
||||
|
||||
def extract_source_strings(file_path):
|
||||
"""Extract source strings from the provided file."""
|
||||
here = os.path.abspath(os.path.dirname(__file__))
|
||||
abs_file_path = os.path.abspath(os.path.join(here, *file_path))
|
||||
|
||||
sources = []
|
||||
|
||||
with open(abs_file_path, encoding='utf-8') as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if line.startswith('msgid '):
|
||||
msgid = line[6:].strip()
|
||||
|
||||
if msgid in sources:
|
||||
print(f'Duplicate source string: {msgid}')
|
||||
else:
|
||||
sources.append(msgid)
|
||||
|
||||
return sources
|
||||
|
||||
|
||||
def compare_source_strings(sources, threshold):
|
||||
"""Compare source strings to find duplicates (or close matches)."""
|
||||
issues = 0
|
||||
|
||||
for i, source in enumerate(sources):
|
||||
for other in sources[i + 1 :]:
|
||||
if other.lower() == source.lower():
|
||||
print(f'- Duplicate: {source} ~ {other}')
|
||||
issues += 1
|
||||
continue
|
||||
|
||||
ratio = rapidfuzz.fuzz.ratio(source, other)
|
||||
if ratio > threshold:
|
||||
print(f'- Close match: {source} ~ {other} ({ratio:.1f}%)')
|
||||
issues += 1
|
||||
|
||||
if issues:
|
||||
print(f' - Found {issues} issues.')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
parser = argparse.ArgumentParser(
|
||||
description='Check source strings for translations.'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--backend', action='store_true', help='Check backend source strings'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--frontend', action='store_true', help='Check frontend source strings'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--threshold',
|
||||
type=int,
|
||||
help='Set the threshold for string comparison',
|
||||
default=99,
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.backend:
|
||||
backend_sources = extract_source_strings(BACKEND_SOURCE_FILE)
|
||||
print('Backend source strings:', len(backend_sources))
|
||||
compare_source_strings(backend_sources, args.threshold)
|
||||
|
||||
if args.frontend:
|
||||
frontend_sources = extract_source_strings(FRONTEND_SOURCE_FILE)
|
||||
print('Frontend source strings:', len(frontend_sources))
|
||||
compare_source_strings(frontend_sources, args.threshold)
|
||||
320
.github/scripts/version_check.py
vendored
320
.github/scripts/version_check.py
vendored
@@ -1,320 +0,0 @@
|
||||
"""Ensure that the release tag matches the InvenTree version number.
|
||||
|
||||
Behaviour:
|
||||
master / main branch:
|
||||
- version number must end with 'dev'
|
||||
|
||||
tagged branch:
|
||||
- version number must match tag being built
|
||||
- version number cannot already exist as a release tag
|
||||
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import itertools
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import requests
|
||||
|
||||
REPO = os.getenv('GITHUB_REPOSITORY', 'inventree/inventree')
|
||||
GITHUB_API_URL = os.getenv('GITHUB_API_URL', 'https://api.github.com')
|
||||
|
||||
|
||||
def get_src_dir() -> Path:
|
||||
"""Return the path to the InvenTree source directory."""
|
||||
here = Path(__file__).parent.absolute()
|
||||
src_dir = here.joinpath('..', '..', 'src', 'backend', 'InvenTree', 'InvenTree')
|
||||
|
||||
if not src_dir.exists():
|
||||
raise FileNotFoundError(
|
||||
f"Could not find InvenTree source directory: '{src_dir}'"
|
||||
)
|
||||
|
||||
return src_dir
|
||||
|
||||
|
||||
def get_inventree_version() -> str:
|
||||
"""Return the InvenTree version string."""
|
||||
src_dir = get_src_dir()
|
||||
version_file = src_dir.joinpath('version.py')
|
||||
|
||||
if not version_file.exists():
|
||||
raise FileNotFoundError(
|
||||
f"Could not find InvenTree version file: '{version_file}'"
|
||||
)
|
||||
|
||||
with open(version_file, encoding='utf-8') as f:
|
||||
text = f.read()
|
||||
|
||||
# Extract the InvenTree software version
|
||||
results = re.findall(r"""INVENTREE_SW_VERSION = '(.*)'""", text)
|
||||
|
||||
if len(results) != 1:
|
||||
raise ValueError(f'Could not find INVENTREE_SW_VERSION in {version_file}')
|
||||
|
||||
return results[0]
|
||||
|
||||
|
||||
def get_api_version() -> str:
|
||||
"""Return the InvenTree API version string."""
|
||||
src_dir = get_src_dir()
|
||||
api_version_file = src_dir.joinpath('api_version.py')
|
||||
|
||||
if not api_version_file.exists():
|
||||
raise FileNotFoundError(
|
||||
f"Could not find InvenTree API version file: '{api_version_file}'"
|
||||
)
|
||||
|
||||
with open(api_version_file, encoding='utf-8') as f:
|
||||
text = f.read()
|
||||
|
||||
# Extract the InvenTree software version
|
||||
results = re.findall(r"""INVENTREE_API_VERSION = (.*)""", text)
|
||||
|
||||
if len(results) != 1:
|
||||
raise ValueError(
|
||||
f'Could not find INVENTREE_API_VERSION in {api_version_file}'
|
||||
)
|
||||
|
||||
return results[0].strip().strip('"').strip("'")
|
||||
|
||||
|
||||
def version_number_to_tuple(version_string: str) -> tuple[int, int, int, str]:
|
||||
"""Validate a version number string, and convert to a tuple of integers.
|
||||
|
||||
e.g. 1.1.0
|
||||
e.g. 1.1.0 dev
|
||||
e.g. 1.2.3-rc2
|
||||
"""
|
||||
pattern = r'^(\d+)\.(\d+)\.(\d+)[\s-]?(.*)?$'
|
||||
|
||||
match = re.match(pattern, version_string)
|
||||
|
||||
if not match or len(match.groups()) < 3:
|
||||
raise ValueError(
|
||||
f"Version string '{version_string}' did not match required pattern"
|
||||
)
|
||||
|
||||
result = tuple(int(x) for x in match.groups()[:3])
|
||||
|
||||
# Add optional prerelease tag
|
||||
if len(match.groups()) > 3:
|
||||
result += (match.groups()[3] or '',)
|
||||
else:
|
||||
result += ('',)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def get_existing_release_tags(include_prerelease: bool = True):
|
||||
"""Request information on existing releases via the GitHub API."""
|
||||
# Check for github token
|
||||
token = os.getenv('GITHUB_TOKEN', None)
|
||||
headers = None
|
||||
|
||||
if token:
|
||||
headers = {'Authorization': f'Bearer {token}'}
|
||||
|
||||
response = requests.get(f'{GITHUB_API_URL}/repos/{REPO}/releases', headers=headers)
|
||||
|
||||
if response.status_code != 200:
|
||||
raise ValueError(
|
||||
f'Unexpected status code from GitHub API: {response.status_code}'
|
||||
)
|
||||
|
||||
data = json.loads(response.text)
|
||||
|
||||
# Return a list of all tags
|
||||
tags = []
|
||||
|
||||
for release in data:
|
||||
tag = release['tag_name'].strip()
|
||||
|
||||
version_tuple = version_number_to_tuple(tag)
|
||||
|
||||
if len(version_tuple) >= 4 and version_tuple[3]:
|
||||
# Skip prerelease tags
|
||||
if not include_prerelease:
|
||||
print('-- skipping prerelease tag:', tag)
|
||||
continue
|
||||
|
||||
tags.append(tag)
|
||||
|
||||
return tags
|
||||
|
||||
|
||||
def check_version_number(version_string, allow_duplicate=False):
|
||||
"""Check the provided version number.
|
||||
|
||||
Returns True if the provided version is the 'newest' InvenTree release
|
||||
"""
|
||||
print(f"Checking version '{version_string}'")
|
||||
|
||||
version_tuple = version_number_to_tuple(version_string)
|
||||
|
||||
# Look through the existing releases
|
||||
existing = get_existing_release_tags(include_prerelease=False)
|
||||
|
||||
# Assume that this is the highest release, unless told otherwise
|
||||
highest_release = True
|
||||
|
||||
# A non-standard tag cannot be the 'highest' release
|
||||
if len(version_tuple) >= 4 and version_tuple[3]:
|
||||
highest_release = False
|
||||
print(f"-- Version tag '{version_string}' cannot be the highest release")
|
||||
|
||||
for release in existing:
|
||||
if version_string == release and not allow_duplicate:
|
||||
raise ValueError(f"Duplicate release '{version_string}' exists!")
|
||||
|
||||
release_tuple = version_number_to_tuple(release)
|
||||
|
||||
if release_tuple > version_tuple:
|
||||
highest_release = False
|
||||
print(f'Found newer release: {release!s}')
|
||||
|
||||
if highest_release:
|
||||
print(f"-- Version '{version_string}' is the highest release")
|
||||
|
||||
return highest_release
|
||||
|
||||
|
||||
def main() -> bool:
|
||||
"""Run the version check."""
|
||||
parser = argparse.ArgumentParser(description='InvenTree Version Check')
|
||||
parser.add_argument(
|
||||
'--show-version',
|
||||
action='store_true',
|
||||
help='Print the InvenTree version and exit',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--show-api-version',
|
||||
action='store_true',
|
||||
help='Print the InvenTree API version and exit',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--decrement-api',
|
||||
type=str,
|
||||
default='false',
|
||||
help='Decrement the API version by 1 and print',
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
inventree_version = get_inventree_version()
|
||||
inventree_api_version = int(get_api_version())
|
||||
|
||||
if args.show_version:
|
||||
print(inventree_version)
|
||||
sys.exit(0)
|
||||
|
||||
if args.show_api_version:
|
||||
if str(args.decrement_api).strip().lower() == 'true':
|
||||
inventree_api_version -= 1
|
||||
print(inventree_api_version)
|
||||
sys.exit(0)
|
||||
|
||||
# Ensure that we are running in GH Actions
|
||||
if os.environ.get('GITHUB_ACTIONS', '') != 'true':
|
||||
print('This script is intended to be run within a GitHub Action!')
|
||||
return False
|
||||
|
||||
print('Running InvenTree version check...')
|
||||
|
||||
# GITHUB_REF_TYPE may be either 'branch' or 'tag'
|
||||
GITHUB_REF_TYPE = os.environ['GITHUB_REF_TYPE']
|
||||
|
||||
# GITHUB_REF may be either 'refs/heads/<branch>' or 'refs/heads/<tag>'
|
||||
GITHUB_REF = os.environ['GITHUB_REF']
|
||||
GITHUB_REF_NAME = os.environ['GITHUB_REF_NAME']
|
||||
GITHUB_BASE_REF = os.environ['GITHUB_BASE_REF']
|
||||
|
||||
# Print out version information, makes debugging actions *much* easier!
|
||||
print(f'GITHUB_REF: {GITHUB_REF}')
|
||||
print(f'GITHUB_REF_NAME: {GITHUB_REF_NAME}')
|
||||
print(f'GITHUB_REF_TYPE: {GITHUB_REF_TYPE}')
|
||||
print(f'GITHUB_BASE_REF: {GITHUB_BASE_REF}')
|
||||
|
||||
print(
|
||||
f"InvenTree Version: '{inventree_version}' - {version_number_to_tuple(inventree_version)}"
|
||||
)
|
||||
print(f"InvenTree API Version: '{inventree_api_version}'")
|
||||
|
||||
# Check version number and look for existing versions
|
||||
# If a release is found which matches the current tag, throw an error
|
||||
|
||||
allow_duplicate = False
|
||||
|
||||
# Note: on a 'tag' (release) we *must* allow duplicate versions, as this *is* the version that has just been released
|
||||
if GITHUB_REF_TYPE == 'tag':
|
||||
allow_duplicate = True
|
||||
|
||||
# Note: on a push to 'stable' branch we also allow duplicates
|
||||
if GITHUB_BASE_REF == 'stable':
|
||||
allow_duplicate = True
|
||||
|
||||
highest_release = check_version_number(
|
||||
inventree_version, allow_duplicate=allow_duplicate
|
||||
)
|
||||
|
||||
# Determine which docker tag we are going to use
|
||||
docker_tags: Optional[list[str]] = None
|
||||
|
||||
if GITHUB_REF_TYPE == 'tag':
|
||||
# GITHUB_REF should be of the form /refs/heads/<tag>
|
||||
version_tag: str = GITHUB_REF.split('/')[-1]
|
||||
print(f"Checking requirements for tagged release - '{version_tag}':")
|
||||
|
||||
if version_tag != inventree_version:
|
||||
print(
|
||||
f"Version number '{inventree_version}' does not match tag '{version_tag}'"
|
||||
)
|
||||
sys.exit
|
||||
|
||||
docker_tags = [version_tag, 'stable'] if highest_release else [version_tag]
|
||||
|
||||
elif GITHUB_REF_TYPE == 'branch':
|
||||
# Otherwise we know we are targeting the 'master' branch
|
||||
docker_tags = ['latest']
|
||||
highest_release = False
|
||||
|
||||
else:
|
||||
print('Unsupported branch / version combination:')
|
||||
print(f'InvenTree Version: {inventree_version}')
|
||||
print('GITHUB_REF_TYPE:', GITHUB_REF_TYPE)
|
||||
print('GITHUB_BASE_REF:', GITHUB_BASE_REF)
|
||||
print('GITHUB_REF:', GITHUB_REF)
|
||||
return False
|
||||
|
||||
if docker_tags is None:
|
||||
print('Docker tags could not be determined')
|
||||
return False
|
||||
|
||||
print(f"Version check passed for '{inventree_version}'!")
|
||||
print(f"Docker tags: '{docker_tags}'")
|
||||
|
||||
target_repos = [REPO.lower(), f'ghcr.io/{REPO.lower()}']
|
||||
|
||||
# Ref: https://getridbug.com/python/how-to-set-environment-variables-in-github-actions-using-python/
|
||||
with open(os.getenv('GITHUB_ENV'), 'a', encoding='utf-8') as env_file:
|
||||
# Construct tag string
|
||||
tag_list = [[f'{r}:{t}' for t in docker_tags] for r in target_repos]
|
||||
tags = ','.join(itertools.chain(*tag_list))
|
||||
|
||||
env_file.write(f'docker_tags={tags}\n')
|
||||
|
||||
if GITHUB_REF_TYPE == 'tag' and highest_release:
|
||||
env_file.write('stable_release=true\n')
|
||||
return True
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
rslt = main()
|
||||
if rslt is not True:
|
||||
print('Version check failed!')
|
||||
sys.exit(1)
|
||||
39
.github/workflows/backport.yaml
vendored
39
.github/workflows/backport.yaml
vendored
@@ -1,39 +0,0 @@
|
||||
# Backport tagged issues to a stable branch.
|
||||
#
|
||||
# To enable backporting for a pullrequest, add the label "backport" to the PR.
|
||||
# Additionally, add a label with the prefix "backport-to-" and the target branch
|
||||
|
||||
name: Backport
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: ["labeled", "closed"]
|
||||
|
||||
jobs:
|
||||
backport:
|
||||
name: Backport PR
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
if: |
|
||||
github.event.pull_request.merged == true
|
||||
&& contains(github.event.pull_request.labels.*.name, 'backport')
|
||||
&& (
|
||||
(github.event.action == 'labeled' && github.event.label.name == 'backport')
|
||||
|| (github.event.action == 'closed')
|
||||
)
|
||||
steps:
|
||||
- name: Backport Action
|
||||
uses: sqren/backport-github-action@ad888e978060bc1b2798690dd9d03c4036560947 # pin@v9.2.2
|
||||
with:
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
auto_backport_label_prefix: backport-to-
|
||||
|
||||
- name: Info log
|
||||
if: ${{ success() }}
|
||||
run: cat ~/.backport/backport.info.log
|
||||
|
||||
- name: Debug log
|
||||
if: ${{ failure() }}
|
||||
run: cat ~/.backport/backport.debug.log
|
||||
37
.github/workflows/backport.yml
vendored
Normal file
37
.github/workflows/backport.yml
vendored
Normal file
@@ -0,0 +1,37 @@
|
||||
# Backport tagged issues to a stable branch.
|
||||
#
|
||||
# To enable backporting for a pullrequest, add the label "backport" to the PR.
|
||||
# Additionally, add a label with the prefix "backport-to-" and the target branch
|
||||
|
||||
name: Backport
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [ "labeled", "closed" ]
|
||||
|
||||
jobs:
|
||||
backport:
|
||||
name: Backport PR
|
||||
runs-on: ubuntu-latest
|
||||
if: |
|
||||
github.event.pull_request.merged == true
|
||||
&& contains(github.event.pull_request.labels.*.name, 'backport')
|
||||
&& (
|
||||
(github.event.action == 'labeled' && github.event.label.name == 'backport')
|
||||
|| (github.event.action == 'closed')
|
||||
)
|
||||
steps:
|
||||
- name: Backport Action
|
||||
uses: sqren/backport-github-action@f54e19901f2a57f8b82360f2490d47ee82ec82c6 # pin@v9.2.2
|
||||
with:
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
auto_backport_label_prefix: backport-to-
|
||||
add_original_reviewers: true
|
||||
|
||||
- name: Info log
|
||||
if: ${{ success() }}
|
||||
run: cat ~/.backport/backport.info.log
|
||||
|
||||
- name: Debug log
|
||||
if: ${{ failure() }}
|
||||
run: cat ~/.backport/backport.debug.log
|
||||
22
.github/workflows/check_translations.yaml
vendored
22
.github/workflows/check_translations.yaml
vendored
@@ -11,39 +11,29 @@ on:
|
||||
env:
|
||||
python_version: 3.9
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
|
||||
check:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
INVENTREE_DB_NAME: "./test_db.sqlite"
|
||||
INVENTREE_DB_NAME: './test_db.sqlite'
|
||||
INVENTREE_DB_ENGINE: django.db.backends.sqlite3
|
||||
INVENTREE_DEBUG: true
|
||||
INVENTREE_LOG_LEVEL: INFO
|
||||
INVENTREE_DEBUG: info
|
||||
INVENTREE_MEDIA_ROOT: ./media
|
||||
INVENTREE_STATIC_ROOT: ./static
|
||||
INVENTREE_BACKUP_DIR: ./backup
|
||||
INVENTREE_SITE_URL: http://localhost:8000
|
||||
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # pin@v5.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # pin@v4.1.1
|
||||
- name: Environment Setup
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
install: true
|
||||
apt-dependency: gettext
|
||||
- name: Test Translations
|
||||
run: invoke dev.translate
|
||||
- name: Check for Duplicates
|
||||
run: |
|
||||
python ./.github/scripts/check_source_strings.py --frontend --backend
|
||||
run: invoke translate
|
||||
- name: Check Migration Files
|
||||
run: python3 .github/scripts/check_migration_files.py
|
||||
run: python3 ci/check_migration_files.py
|
||||
|
||||
158
.github/workflows/docker.yaml
vendored
158
.github/workflows/docker.yaml
vendored
@@ -15,23 +15,17 @@ name: Docker
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
types: [ published ]
|
||||
|
||||
push:
|
||||
branches:
|
||||
- "master"
|
||||
- 'master'
|
||||
pull_request:
|
||||
branches:
|
||||
- "master"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
- 'master'
|
||||
|
||||
jobs:
|
||||
paths-filter:
|
||||
permissions:
|
||||
contents: read # for dorny/paths-filter to fetch a list of changed files
|
||||
pull-requests: read # for dorny/paths-filter to read pull requests
|
||||
name: Filter
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -39,64 +33,64 @@ jobs:
|
||||
docker: ${{ steps.filter.outputs.docker }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # pin@v5.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # pin@v3.0.2
|
||||
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # pin@v4.1.1
|
||||
- uses: dorny/paths-filter@4512585405083f25c027a35db413c2b3b9006d50 # pin@v2.11.1
|
||||
id: filter
|
||||
with:
|
||||
filters: |
|
||||
docker:
|
||||
- .github/workflows/docker.yaml
|
||||
- contrib/container/**
|
||||
- src/backend/InvenTree/InvenTree/settings.py
|
||||
- src/backend/requirements.txt
|
||||
- docker/**
|
||||
- docker-compose.yml
|
||||
- docker.dev.env
|
||||
- Dockerfile
|
||||
- requirements.txt
|
||||
- tasks.py
|
||||
|
||||
# Build the docker image
|
||||
build:
|
||||
name: Docker Build Test
|
||||
needs: paths-filter
|
||||
if: needs.paths-filter.outputs.docker == 'true' || github.event_name == 'release' || github.event_name == 'push' || contains(github.event.pull_request.labels.*.name, 'full-run')
|
||||
if: needs.paths-filter.outputs.docker == 'true' || github.event_name == 'release' || github.event_name == 'push'
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
id-token: write
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
python_version: "3.11"
|
||||
runs-on: ubuntu-latest # in the future we can try to use alternative runners here
|
||||
runs-on: ubuntu-latest # in the future we can try to use alternative runners here
|
||||
|
||||
steps:
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # pin@v5.0.0
|
||||
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # pin@v4.1.1
|
||||
- name: Set Up Python ${{ env.python_version }}
|
||||
uses: actions/setup-python@65d7f2d534ac1bc67fcd62888c5f4f3d2cb2b236 # pin@v4.7.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
python-version: ${{ env.python_version }}
|
||||
- name: Version Check
|
||||
run: |
|
||||
pip install requests
|
||||
pip install pyyaml
|
||||
python3 ci/version_check.py
|
||||
echo "git_commit_hash=$(git rev-parse --short HEAD)" >> $GITHUB_ENV
|
||||
echo "git_commit_date=$(git show -s --format=%ci)" >> $GITHUB_ENV
|
||||
- name: Test Docker Image
|
||||
id: test-docker
|
||||
run: |
|
||||
docker build . --target production --tag inventree-test -f contrib/container/Dockerfile
|
||||
docker run --rm inventree-test invoke version
|
||||
docker build . --target production --tag inventree-test
|
||||
docker run --rm inventree-test invoke --version
|
||||
docker run --rm inventree-test invoke --list
|
||||
docker run --rm inventree-test gunicorn --version
|
||||
docker run --rm inventree-test pg_dump --version
|
||||
docker run --rm inventree-test test -f /home/inventree/init.sh
|
||||
docker run --rm inventree-test test -f /home/inventree/tasks.py
|
||||
docker run --rm inventree-test test -f /home/inventree/gunicorn.conf.py
|
||||
docker run --rm inventree-test test -f /home/inventree/src/backend/requirements.txt
|
||||
docker run --rm inventree-test test -f /home/inventree/src/backend/InvenTree/manage.py
|
||||
- name: Build Docker Image
|
||||
# Build the development docker image (using docker-compose.yml)
|
||||
run: docker compose --project-directory . -f contrib/container/dev-docker-compose.yml build --no-cache
|
||||
run: docker-compose build --no-cache
|
||||
- name: Update Docker Image
|
||||
run: |
|
||||
docker compose --project-directory . -f contrib/container/dev-docker-compose.yml run inventree-dev-server invoke install
|
||||
docker compose --project-directory . -f contrib/container/dev-docker-compose.yml run inventree-dev-server invoke version
|
||||
docker compose --project-directory . -f contrib/container/dev-docker-compose.yml run inventree-dev-server invoke update
|
||||
docker compose --project-directory . -f contrib/container/dev-docker-compose.yml run inventree-dev-server invoke backup
|
||||
docker compose --project-directory . -f contrib/container/dev-docker-compose.yml run inventree-dev-server invoke restore
|
||||
docker compose --project-directory . -f contrib/container/dev-docker-compose.yml run inventree-dev-server invoke dev.setup-dev
|
||||
docker compose --project-directory . -f contrib/container/dev-docker-compose.yml up -d
|
||||
docker compose --project-directory . -f contrib/container/dev-docker-compose.yml run inventree-dev-server invoke wait
|
||||
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 invoke wait
|
||||
- name: Check Data Directory
|
||||
# The following file structure should have been created by the docker image
|
||||
run: |
|
||||
@@ -109,90 +103,34 @@ jobs:
|
||||
test -f data/config.yaml
|
||||
test -f data/plugins.txt
|
||||
test -f data/secret_key.txt
|
||||
test -f data/oidc.pem
|
||||
- name: Run Unit Tests
|
||||
run: |
|
||||
echo "GITHUB_TOKEN=${{ secrets.GITHUB_TOKEN }}" >> contrib/container/docker.dev.env
|
||||
docker compose --project-directory . -f contrib/container/dev-docker-compose.yml run --rm inventree-dev-server invoke dev.test --disable-pty --translations
|
||||
|
||||
# Run migration test
|
||||
migration_test:
|
||||
name: Migration Test
|
||||
needs: paths-filter
|
||||
if: needs.paths-filter.outputs.docker == 'true' || github.event_name == 'release' || github.event_name == 'push'
|
||||
permissions:
|
||||
contents: read
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
python_version: "3.11"
|
||||
runs-on: ubuntu-latest # in the future we can try to use alternative runners here
|
||||
|
||||
steps:
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # pin@v5.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Run Migration Tests
|
||||
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: Clean up test folder
|
||||
run: |
|
||||
docker compose --project-directory . -f contrib/container/dev-docker-compose.yml run --rm inventree-dev-server invoke update
|
||||
docker compose --project-directory . -f contrib/container/dev-docker-compose.yml run --rm inventree-dev-server invoke dev.setup-dev
|
||||
docker compose --project-directory . -f contrib/container/dev-docker-compose.yml run --rm inventree-dev-server invoke dev.test --migrations --translations
|
||||
|
||||
# Build and publish
|
||||
publish:
|
||||
name: Publish Docker Image
|
||||
needs: [build, migration_test]
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
id-token: write
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
python_version: "3.11"
|
||||
runs-on: ubuntu-latest # in the future we can try to use alternative runners here
|
||||
|
||||
steps:
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # pin@v5.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Set Up Python ${{ env.python_version }}
|
||||
uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # pin@v6.0.0
|
||||
with:
|
||||
python-version: ${{ env.python_version }}
|
||||
- name: Version Check
|
||||
run: |
|
||||
pip install --require-hashes -r contrib/dev_reqs/requirements.txt
|
||||
python3 .github/scripts/version_check.py
|
||||
echo "git_commit_hash=$(git rev-parse --short HEAD)" >> $GITHUB_ENV
|
||||
echo "git_commit_date=$(git show -s --format=%ci)" >> $GITHUB_ENV
|
||||
rm -rf InvenTree/_testfolder
|
||||
- name: Set up QEMU
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # pin@v3.6.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@e468171a9de216ec08956ac3ada2f0791b6bd435 # pin@v3.11.1
|
||||
uses: docker/setup-buildx-action@f95db51fddba0c2d1ec667646a06c2ce06100226 # pin@v3.0.0
|
||||
- name: Set up cosign
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: sigstore/cosign-installer@faadad0cce49287aee09b3a48701e75088a2c6ad # pin@v4.0.0
|
||||
- name: Check if Dockerhub login is required
|
||||
id: docker_login
|
||||
run: |
|
||||
if [ -z "${{ secrets.DOCKER_USERNAME }}" ]; then
|
||||
echo "skip_dockerhub_login=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "skip_dockerhub_login=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
uses: sigstore/cosign-installer@11086d25041f77fe8fe7b9ea4e48e3b9192b8f19 # pin@v3.1.2
|
||||
- name: Login to Dockerhub
|
||||
if: github.event_name != 'pull_request' && steps.docker_login.outputs.skip_dockerhub_login != 'true'
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # pin@v3.6.0
|
||||
if: github.event_name != 'pull_request'
|
||||
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@5e57cd118135c172c3672efd75eb46360885c0ef # pin@v3.6.0
|
||||
uses: docker/login-action@343f7c4344506bcbf9b4de18042ae17996df046d # pin@v3.0.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
@@ -201,20 +139,18 @@ jobs:
|
||||
- name: Extract Docker metadata
|
||||
if: github.event_name != 'pull_request'
|
||||
id: meta
|
||||
uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # pin@v5.8.0
|
||||
uses: docker/metadata-action@96383f45573cb7f253c731d3b3ab81c87ef81934 # pin@v5.0.0
|
||||
with:
|
||||
images: |
|
||||
inventree/inventree
|
||||
ghcr.io/${{ github.repository }}
|
||||
- uses: depot/setup-action@b0b1ea4f69e92ebf5dea3f8713a1b0c37b2126a5 # pin@v1
|
||||
ghcr.io/inventree/inventree
|
||||
|
||||
- name: Push Docker Images
|
||||
id: push-docker
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: depot/build-push-action@9785b135c3c76c33db102e45be96a25ab55cd507 # pin@v1
|
||||
uses: docker/build-push-action@0565240e2d4ab88bba5387d719585280857ece09 # pin@v5.0.0
|
||||
with:
|
||||
project: jczzbjkk68
|
||||
context: .
|
||||
file: ./contrib/container/Dockerfile
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
sbom: true
|
||||
|
||||
545
.github/workflows/qc_checks.yaml
vendored
545
.github/workflows/qc_checks.yaml
vendored
@@ -4,27 +4,22 @@ name: QC
|
||||
|
||||
on:
|
||||
push:
|
||||
branches-ignore: ["l10*"]
|
||||
branches-ignore: [ 'l10*' ]
|
||||
pull_request:
|
||||
branches-ignore: ["l10*"]
|
||||
branches-ignore: [ 'l10*' ]
|
||||
|
||||
env:
|
||||
python_version: 3.9
|
||||
node_version: 20
|
||||
node_version: 16
|
||||
# The OS version must be set per job
|
||||
server_start_sleep: 60
|
||||
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
INVENTREE_DB_ENGINE: sqlite3
|
||||
INVENTREE_DB_NAME: inventree
|
||||
INVENTREE_MEDIA_ROOT: /home/runner/work/InvenTree/test_inventree_media
|
||||
INVENTREE_STATIC_ROOT: /home/runner/work/InvenTree/test_inventree_static
|
||||
INVENTREE_BACKUP_DIR: /home/runner/work/InvenTree/test_inventree_backup
|
||||
INVENTREE_SITE_URL: http://localhost:8000
|
||||
INVENTREE_DEBUG: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
INVENTREE_MEDIA_ROOT: ../test_inventree_media
|
||||
INVENTREE_STATIC_ROOT: ../test_inventree_static
|
||||
INVENTREE_BACKUP_DIR: ../test_inventree_backup
|
||||
|
||||
jobs:
|
||||
paths-filter:
|
||||
@@ -36,121 +31,98 @@ jobs:
|
||||
migrations: ${{ steps.filter.outputs.migrations }}
|
||||
frontend: ${{ steps.filter.outputs.frontend }}
|
||||
api: ${{ steps.filter.outputs.api }}
|
||||
force: ${{ steps.force.outputs.force }}
|
||||
cicd: ${{ steps.filter.outputs.cicd }}
|
||||
requirements: ${{ steps.filter.outputs.requirements }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # pin@v5.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # pin@v3.0.2
|
||||
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # pin@v4.1.1
|
||||
- uses: dorny/paths-filter@4512585405083f25c027a35db413c2b3b9006d50 # pin@v2.11.1
|
||||
id: filter
|
||||
with:
|
||||
filters: |
|
||||
server:
|
||||
- 'src/backend/InvenTree/**'
|
||||
- 'src/backend/requirements.txt'
|
||||
- 'src/backend/requirements-dev.txt'
|
||||
- 'InvenTree/**'
|
||||
- 'requirements.txt'
|
||||
- 'requirements-dev.txt'
|
||||
migrations:
|
||||
- '**/test_migrations.py'
|
||||
- '**/migrations/**'
|
||||
- '.github/workflows**'
|
||||
- 'src/backend/requirements.txt'
|
||||
api:
|
||||
- 'src/backend/InvenTree/InvenTree/api_version.py'
|
||||
- 'InvenTree/InvenTree/api_version.py'
|
||||
frontend:
|
||||
- 'src/frontend/**'
|
||||
cicd:
|
||||
- '.github/workflows/**'
|
||||
requirements:
|
||||
- 'src/backend/requirements.txt'
|
||||
- 'src/backend/requirements-dev.txt'
|
||||
- 'docs/requirements.txt'
|
||||
- 'contrib/dev_reqs/requirements.txt'
|
||||
- name: Is CI being forced?
|
||||
run: echo "force=true" >> $GITHUB_OUTPUT
|
||||
id: force
|
||||
if: |
|
||||
contains(github.event.pull_request.labels.*.name, 'dependency') ||
|
||||
contains(github.event.pull_request.labels.*.name, 'full-run')
|
||||
|
||||
javascript:
|
||||
name: Style - Classic UI [JS]
|
||||
runs-on: ubuntu-20.04
|
||||
|
||||
needs: [ 'pre-commit' ]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # pin@v4.1.1
|
||||
- name: Environment Setup
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
npm: true
|
||||
install: true
|
||||
- name: Check Templated JS Files
|
||||
run: |
|
||||
cd ci
|
||||
python3 check_js_templates.py
|
||||
- name: Lint Javascript Files
|
||||
run: |
|
||||
python InvenTree/manage.py prerender
|
||||
npx eslint InvenTree/InvenTree/static_i18n/i18n/*.js
|
||||
|
||||
pre-commit:
|
||||
name: Style [pre-commit]
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: ubuntu-20.04
|
||||
needs: paths-filter
|
||||
if: needs.paths-filter.outputs.cicd == 'true' || needs.paths-filter.outputs.server == 'true' || needs.paths-filter.outputs.frontend == 'true' || needs.paths-filter.outputs.requirements == 'true' || needs.paths-filter.outputs.force == 'true'
|
||||
if: needs.paths-filter.outputs.server == 'true' || needs.paths-filter.outputs.frontend == 'true'
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # pin@v5.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # pin@v4.1.1
|
||||
- name: Set up Python ${{ env.python_version }}
|
||||
uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # pin@v6.0.0
|
||||
uses: actions/setup-python@65d7f2d534ac1bc67fcd62888c5f4f3d2cb2b236 # pin@v4.7.1
|
||||
with:
|
||||
python-version: ${{ env.python_version }}
|
||||
cache: "pip"
|
||||
cache: 'pip'
|
||||
- name: Run pre-commit Checks
|
||||
uses: pre-commit/action@2c7b3805fd2a0fd8c1884dcaebf91fc102a13ecd # pin@v3.0.1
|
||||
uses: pre-commit/action@646c83fcd040023954eafda54b4db0192ce70507 # pin@v3.0.0
|
||||
- name: Check Version
|
||||
run: |
|
||||
pip install --require-hashes -r contrib/dev_reqs/requirements.txt
|
||||
python3 .github/scripts/version_check.py
|
||||
|
||||
typecheck:
|
||||
name: Style [Typecheck]
|
||||
runs-on: ubuntu-24.04
|
||||
needs: [paths-filter, pre-commit]
|
||||
if: needs.paths-filter.outputs.server == 'true' || needs.paths-filter.outputs.requirements == 'true' || needs.paths-filter.outputs.force == 'true'
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # pin@v5.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Environment Setup
|
||||
id: setup
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
apt-dependency: gettext poppler-utils
|
||||
dev-install: true
|
||||
update: true
|
||||
- name: Check types
|
||||
run: |
|
||||
ty check --python ${Python_ROOT_DIR}/bin/python3
|
||||
pip install requests
|
||||
python3 ci/version_check.py
|
||||
|
||||
mkdocs:
|
||||
name: Style [Documentation]
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: ubuntu-20.04
|
||||
|
||||
needs: paths-filter
|
||||
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # pin@v5.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # pin@v4.1.1
|
||||
- name: Set up Python ${{ env.python_version }}
|
||||
uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # pin@v6.0.0
|
||||
uses: actions/setup-python@65d7f2d534ac1bc67fcd62888c5f4f3d2cb2b236 # pin@v4.7.1
|
||||
with:
|
||||
python-version: ${{ env.python_version }}
|
||||
- name: Check Config
|
||||
run: |
|
||||
pip install --require-hashes -r contrib/dev_reqs/requirements.txt
|
||||
pip install --require-hashes -r docs/requirements.txt
|
||||
pip install pyyaml
|
||||
pip install -r docs/requirements.txt
|
||||
python docs/ci/check_mkdocs_config.py
|
||||
- name: Check Links
|
||||
uses: gaurav-nelson/github-action-markdown-link-check@5c5dfc0ac2e225883c0e5f03a85311ec2830d368 # pin@v1
|
||||
uses: gaurav-nelson/github-action-markdown-link-check@v1
|
||||
with:
|
||||
folder-path: docs
|
||||
config-file: docs/mlc_config.json
|
||||
check-modified-files-only: "yes"
|
||||
use-quiet-mode: "yes"
|
||||
check-modified-files-only: 'yes'
|
||||
use-quiet-mode: 'yes'
|
||||
|
||||
schema:
|
||||
name: Tests - API Schema Documentation
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: ubuntu-20.04
|
||||
needs: paths-filter
|
||||
if: needs.paths-filter.outputs.server == 'true' || needs.paths-filter.outputs.force == 'true'
|
||||
if: needs.paths-filter.outputs.server == 'true'
|
||||
env:
|
||||
INVENTREE_DB_ENGINE: django.db.backends.sqlite3
|
||||
INVENTREE_DB_NAME: ../inventree_unit_test_db.sqlite3
|
||||
@@ -164,9 +136,7 @@ jobs:
|
||||
version: ${{ steps.version.outputs.version }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # pin@v5.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # pin@v4.1.1
|
||||
- name: Environment Setup
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
@@ -174,181 +144,120 @@ jobs:
|
||||
dev-install: true
|
||||
update: true
|
||||
- name: Export API Documentation
|
||||
run: invoke dev.schema --ignore-warnings --filename src/backend/InvenTree/schema.yml
|
||||
run: invoke schema --ignore-warnings --filename InvenTree/schema.yml
|
||||
- name: Upload schema
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # pin@v5.0.0
|
||||
uses: actions/upload-artifact@a8a3f3ad30e3422c9c7b888a15615d19a852ae32 # pin@v3.1.3
|
||||
with:
|
||||
name: schema.yml
|
||||
path: src/backend/InvenTree/schema.yml
|
||||
path: InvenTree/schema.yml
|
||||
- name: Download public schema
|
||||
env:
|
||||
API: ${{ needs.paths-filter.outputs.api }}
|
||||
if: needs.paths-filter.outputs.api == 'false'
|
||||
run: |
|
||||
pip install --require-hashes -r contrib/dev_reqs/requirements.txt >/dev/null 2>&1
|
||||
version="$(python3 .github/scripts/version_check.py --show-api-version --decrement-api=${API} 2>&1)"
|
||||
echo "API Version: $version"
|
||||
pip install requests >/dev/null 2>&1
|
||||
version="$(python3 ci/version_check.py only_version 2>&1)"
|
||||
echo "Version: $version"
|
||||
url="https://raw.githubusercontent.com/inventree/schema/main/export/${version}/api.yaml"
|
||||
echo "URL: $url"
|
||||
code=$(curl -s -o api.yaml $url --write-out '%{http_code}' --silent)
|
||||
if [ "$code" != "200" ]; then
|
||||
exit 1
|
||||
fi
|
||||
curl -s -o api.yaml $url
|
||||
echo "Downloaded api.yaml"
|
||||
- name: Running OpenAPI Spec diff action
|
||||
id: breaking_changes
|
||||
uses: oasdiff/oasdiff-action/diff@1c611ffb1253a72924624aa4fb662e302b3565d3 # pin@main
|
||||
with:
|
||||
base: "api.yaml"
|
||||
revision: "src/backend/InvenTree/schema.yml"
|
||||
format: "html"
|
||||
- name: Echoing diff to step
|
||||
continue-on-error: true
|
||||
env:
|
||||
DIFF: ${{ steps.breaking_changes.outputs.diff }}
|
||||
run: echo "${DIFF}" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
- name: Check for differences in API Schema
|
||||
if: needs.paths-filter.outputs.api == 'false'
|
||||
run: |
|
||||
diff --color -u src/backend/InvenTree/schema.yml api.yaml
|
||||
diff -u src/backend/InvenTree/schema.yml api.yaml && echo "no difference in API schema " || exit 2
|
||||
diff --color -u InvenTree/schema.yml api.yaml
|
||||
diff -u InvenTree/schema.yml api.yaml && echo "no difference in API schema " || exit 2
|
||||
- name: Check schema - including warnings
|
||||
run: invoke dev.schema
|
||||
run: invoke schema
|
||||
continue-on-error: true
|
||||
- name: Extract version for publishing
|
||||
id: version
|
||||
if: github.ref == 'refs/heads/master' && needs.paths-filter.outputs.api == 'true'
|
||||
run: |
|
||||
pip install --require-hashes -r contrib/dev_reqs/requirements.txt >/dev/null 2>&1
|
||||
version="$(python3 .github/scripts/version_check.py --show-api-version 2>&1)"
|
||||
echo "API Version: $version"
|
||||
pip install requests >/dev/null 2>&1
|
||||
version="$(python3 ci/version_check.py only_version 2>&1)"
|
||||
echo "Version: $version"
|
||||
echo "version=$version" >> "$GITHUB_OUTPUT"
|
||||
- name: Extract settings / tags
|
||||
run: invoke int.export-definitions --basedir docs
|
||||
- name: Upload settings
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # pin@v5.0.0
|
||||
with:
|
||||
name: inventree_settings.json
|
||||
path: docs/generated/inventree_settings.json
|
||||
- name: Upload tags
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # pin@v5.0.0
|
||||
with:
|
||||
name: inventree_tags.yml
|
||||
path: docs/generated/inventree_tags.yml
|
||||
- name: Upload filters
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # pin@v5.0.0
|
||||
with:
|
||||
name: inventree_filters.yml
|
||||
path: docs/generated/inventree_filters.yml
|
||||
|
||||
schema-push:
|
||||
name: Push new schema
|
||||
runs-on: ubuntu-24.04
|
||||
needs: [paths-filter, schema]
|
||||
if: needs.schema.result == 'success' && github.ref == 'refs/heads/master' && needs.paths-filter.outputs.api == 'true' && github.repository_owner == 'inventree'
|
||||
env:
|
||||
version: ${{ needs.schema.outputs.version }}
|
||||
name: Push new schema
|
||||
runs-on: ubuntu-20.04
|
||||
needs: [paths-filter, schema]
|
||||
if: needs.schema.result == 'success' && github.ref == 'refs/heads/master' && needs.paths-filter.outputs.api == 'true'
|
||||
env:
|
||||
version: ${{ needs.schema.outputs.version }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # pin@v5.0.0
|
||||
name: Checkout Code
|
||||
with:
|
||||
repository: inventree/schema
|
||||
token: ${{ secrets.SCHEMA_PAT }}
|
||||
persist-credentials: true
|
||||
- name: Create artifact directory
|
||||
run: mkdir -p artifact
|
||||
- name: Download schema artifact
|
||||
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # pin@v6.0.0
|
||||
with:
|
||||
path: artifact
|
||||
merge-multiple: true
|
||||
- name: Move files to correct location
|
||||
run: |
|
||||
echo "Version: ${version}"
|
||||
echo "before move"
|
||||
ls -la artifact
|
||||
mkdir export/${version}
|
||||
mv artifact/schema.yml export/${version}/api.yaml
|
||||
mv artifact/inventree_settings.json export/${version}/inventree_settings.json
|
||||
mv artifact/inventree_tags.yml export/${version}/inventree_tags.yml
|
||||
mv artifact/inventree_filters.yml export/${version}/inventree_filters.yml
|
||||
echo "after move"
|
||||
ls -la artifact
|
||||
rm -rf artifact
|
||||
- uses: stefanzweifel/git-auto-commit-action@28e16e81777b558cc906c8750092100bbb34c5e3 # pin@v7.0.0
|
||||
name: Commit schema changes
|
||||
with:
|
||||
commit_message: "Update API schema for ${{ env.version }} / ${{ github.sha }}"
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
repository: inventree/schema
|
||||
token: ${{ secrets.SCHEMA_PAT }}
|
||||
- name: Download schema artifact
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: schema.yml
|
||||
- name: Move schema to correct location
|
||||
run: |
|
||||
echo "Version: $version"
|
||||
mkdir export/${version}
|
||||
mv schema.yml export/${version}/api.yaml
|
||||
- uses: stefanzweifel/git-auto-commit-action@v5
|
||||
with:
|
||||
commit_message: "Update API schema for ${version}"
|
||||
|
||||
python:
|
||||
name: Tests - inventree-python
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: ubuntu-20.04
|
||||
|
||||
needs: ["pre-commit", "paths-filter"]
|
||||
if: needs.paths-filter.outputs.server == 'true' || needs.paths-filter.outputs.force == 'true'
|
||||
needs: pre-commit
|
||||
|
||||
env:
|
||||
WRAPPER_NAME: inventree-python
|
||||
wrapper_name: inventree-python
|
||||
INVENTREE_DB_ENGINE: django.db.backends.sqlite3
|
||||
INVENTREE_DB_NAME: ../inventree_unit_test_db.sqlite3
|
||||
INVENTREE_ADMIN_USER: testuser
|
||||
INVENTREE_ADMIN_PASSWORD: testpassword
|
||||
INVENTREE_ADMIN_EMAIL: test@test.com
|
||||
INVENTREE_PYTHON_TEST_SERVER: http://127.0.0.1:12345
|
||||
INVENTREE_PYTHON_TEST_SERVER: http://localhost:12345
|
||||
INVENTREE_PYTHON_TEST_USERNAME: testuser
|
||||
INVENTREE_PYTHON_TEST_PASSWORD: testpassword
|
||||
INVENTREE_SITE_URL: http://127.0.0.1:12345
|
||||
INVENTREE_DEBUG: true
|
||||
INVENTREE_LOG_LEVEL: WARNING
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # pin@v5.0.0
|
||||
with:
|
||||
persist-credentials: true
|
||||
- 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
|
||||
- name: Download Python Code For `${WRAPPER_NAME}`
|
||||
run: git clone --depth 1 https://github.com/inventree/${WRAPPER_NAME} ./${WRAPPER_NAME}
|
||||
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 }}
|
||||
- name: Start InvenTree Server
|
||||
run: |
|
||||
invoke dev.delete-data -f
|
||||
invoke dev.import-fixtures
|
||||
invoke dev.server -a 127.0.0.1:12345 &
|
||||
invoke delete-data -f
|
||||
invoke import-fixtures
|
||||
invoke server -a 127.0.0.1:12345 &
|
||||
invoke wait
|
||||
- name: Run Tests For `${WRAPPER_NAME}`
|
||||
- name: Run Tests For `${{ env.wrapper_name }}`
|
||||
run: |
|
||||
cd ${WRAPPER_NAME}
|
||||
cd ${{ env.wrapper_name }}
|
||||
invoke check-server
|
||||
coverage run -m unittest discover -s test/
|
||||
|
||||
coverage:
|
||||
name: Tests - DB [SQLite] + Coverage ${{ matrix.python_version }}
|
||||
runs-on: ubuntu-24.04
|
||||
name: Tests - DB [SQLite] + Coverage
|
||||
runs-on: ubuntu-20.04
|
||||
|
||||
needs: ["pre-commit", "paths-filter"]
|
||||
if: needs.paths-filter.outputs.server == 'true' || needs.paths-filter.outputs.force == 'true'
|
||||
needs: [ 'pre-commit' ]
|
||||
continue-on-error: true # continue if a step fails so that coverage gets pushed
|
||||
strategy:
|
||||
matrix:
|
||||
python_version: [3.9]
|
||||
# python_version: [3.9, 3.12] # Disabled due to requirement issues
|
||||
|
||||
env:
|
||||
INVENTREE_DB_NAME: ./inventree.sqlite
|
||||
INVENTREE_DB_ENGINE: sqlite3
|
||||
INVENTREE_PLUGINS_ENABLED: true
|
||||
INVENTREE_CONSOLE_LOG: false
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
python_version: ${{ matrix.python_version }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # pin@v5.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # pin@v4.1.1
|
||||
- name: Environment Setup
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
@@ -358,40 +267,28 @@ jobs:
|
||||
- name: Data Export Test
|
||||
uses: ./.github/actions/migration
|
||||
- name: Test Translations
|
||||
run: invoke dev.translate
|
||||
run: invoke translate
|
||||
- name: Check Migration Files
|
||||
run: python3 .github/scripts/check_migration_files.py
|
||||
run: python3 ci/check_migration_files.py
|
||||
- name: Coverage Tests
|
||||
run: invoke dev.test --check --coverage --translations
|
||||
- name: Upload raw coverage to artifacts
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # pin@v5.0.0
|
||||
run: invoke test --coverage
|
||||
- name: Upload Coverage Report
|
||||
uses: coverallsapp/github-action@3dfc5567390f6fa9267c0ee9c251e4c8c3f18949 # pin@v2.2.3
|
||||
with:
|
||||
name: coverage
|
||||
path: .coverage
|
||||
retention-days: 14
|
||||
- name: Upload coverage reports to Codecov
|
||||
uses: codecov/codecov-action@5a1091511ad55cbe89839c7260b706298ca349f7 # pin@v5.5.1
|
||||
if: always()
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
slug: inventree/InvenTree
|
||||
flags: backend
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
postgres:
|
||||
name: Tests - DB [PostgreSQL]
|
||||
runs-on: ubuntu-24.04
|
||||
needs: ["pre-commit", "paths-filter"]
|
||||
if: needs.paths-filter.outputs.server == 'true' || needs.paths-filter.outputs.force == 'true'
|
||||
runs-on: ubuntu-20.04
|
||||
needs: [ 'pre-commit' ]
|
||||
|
||||
env:
|
||||
INVENTREE_DB_ENGINE: django.db.backends.postgresql
|
||||
INVENTREE_DB_USER: inventree
|
||||
INVENTREE_DB_PASSWORD: password
|
||||
INVENTREE_DB_HOST: "127.0.0.1"
|
||||
INVENTREE_DB_HOST: '127.0.0.1'
|
||||
INVENTREE_DB_PORT: 5432
|
||||
INVENTREE_DEBUG: true
|
||||
INVENTREE_LOG_LEVEL: INFO
|
||||
INVENTREE_CONSOLE_LOG: false
|
||||
INVENTREE_DEBUG: info
|
||||
INVENTREE_CACHE_HOST: localhost
|
||||
INVENTREE_PLUGINS_ENABLED: true
|
||||
|
||||
@@ -405,14 +302,12 @@ jobs:
|
||||
- 5432:5432
|
||||
|
||||
redis:
|
||||
image: redis:8
|
||||
image: redis
|
||||
ports:
|
||||
- 6379:6379
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # pin@v5.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # pin@v4.1.1
|
||||
- name: Environment Setup
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
@@ -421,32 +316,29 @@ jobs:
|
||||
dev-install: true
|
||||
update: true
|
||||
- name: Run Tests
|
||||
run: invoke dev.test --check --translations
|
||||
run: invoke test
|
||||
- name: Data Export Test
|
||||
uses: ./.github/actions/migration
|
||||
|
||||
mysql:
|
||||
name: Tests - DB [MySQL]
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: ubuntu-20.04
|
||||
|
||||
needs: ["pre-commit", "paths-filter"]
|
||||
if: needs.paths-filter.outputs.server == 'true' || needs.paths-filter.outputs.force == 'true'
|
||||
needs: [ 'pre-commit' ]
|
||||
|
||||
env:
|
||||
# Database backend configuration
|
||||
INVENTREE_DB_ENGINE: django.db.backends.mysql
|
||||
INVENTREE_DB_USER: root
|
||||
INVENTREE_DB_PASSWORD: password
|
||||
INVENTREE_DB_HOST: "127.0.0.1"
|
||||
INVENTREE_DB_HOST: '127.0.0.1'
|
||||
INVENTREE_DB_PORT: 3306
|
||||
INVENTREE_DEBUG: true
|
||||
INVENTREE_LOG_LEVEL: WARNING
|
||||
INVENTREE_CONSOLE_LOG: false
|
||||
INVENTREE_DEBUG: info
|
||||
INVENTREE_PLUGINS_ENABLED: true
|
||||
|
||||
services:
|
||||
mysql:
|
||||
image: mysql:9
|
||||
image: mysql:latest
|
||||
env:
|
||||
MYSQL_ALLOW_EMPTY_PASSWORD: yes
|
||||
MYSQL_DATABASE: ${{ env.INVENTREE_DB_NAME }}
|
||||
@@ -458,9 +350,7 @@ jobs:
|
||||
- 3306:3306
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # pin@v5.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # pin@v4.1.1
|
||||
- name: Environment Setup
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
@@ -469,7 +359,7 @@ jobs:
|
||||
dev-install: true
|
||||
update: true
|
||||
- name: Run Tests
|
||||
run: invoke dev.test --check --translations
|
||||
run: invoke test
|
||||
- name: Data Export Test
|
||||
uses: ./.github/actions/migration
|
||||
|
||||
@@ -477,17 +367,16 @@ jobs:
|
||||
name: Tests - Migrations [PostgreSQL]
|
||||
runs-on: ubuntu-latest
|
||||
needs: paths-filter
|
||||
if: ${{ (needs.paths-filter.outputs.force == 'true') || (github.ref == 'refs/heads/master' && needs.paths-filter.outputs.migrations == 'true') }}
|
||||
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_HOST: '127.0.0.1'
|
||||
INVENTREE_DB_PORT: 5432
|
||||
INVENTREE_DEBUG: False
|
||||
INVENTREE_LOG_LEVEL: WARNING
|
||||
INVENTREE_DEBUG: info
|
||||
INVENTREE_PLUGINS_ENABLED: false
|
||||
|
||||
services:
|
||||
@@ -500,9 +389,7 @@ jobs:
|
||||
- 5432:5432
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # pin@v5.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # pin@v4.1.1
|
||||
- name: Environment Setup
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
@@ -511,32 +398,22 @@ jobs:
|
||||
dev-install: true
|
||||
update: true
|
||||
- name: Run Tests
|
||||
run: invoke dev.test --check --migrations --report --coverage --translations
|
||||
- name: Upload coverage reports to Codecov
|
||||
uses: codecov/codecov-action@5a1091511ad55cbe89839c7260b706298ca349f7 # pin@v5.5.1
|
||||
if: always()
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
slug: inventree/InvenTree
|
||||
flags: migrations
|
||||
run: invoke test --migrations --report
|
||||
|
||||
migrations-checks:
|
||||
name: Tests - Full Migration [SQLite]
|
||||
runs-on: ubuntu-latest
|
||||
needs: paths-filter
|
||||
if: ${{ (needs.paths-filter.outputs.force == 'true') || (github.ref == 'refs/heads/master' && needs.paths-filter.outputs.migrations == 'true') }}
|
||||
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: true
|
||||
INVENTREE_LOG_LEVEL: WARNING
|
||||
INVENTREE_DEBUG: info
|
||||
INVENTREE_PLUGINS_ENABLED: false
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # pin@v5.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # pin@v4.1.1
|
||||
name: Checkout Code
|
||||
- name: Environment Setup
|
||||
uses: ./.github/actions/setup
|
||||
@@ -545,6 +422,12 @@ jobs:
|
||||
- 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
|
||||
@@ -559,6 +442,13 @@ jobs:
|
||||
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
|
||||
|
||||
- name: 0.13.5 Database
|
||||
run: |
|
||||
rm /home/runner/work/InvenTree/db.sqlite3
|
||||
@@ -566,106 +456,48 @@ jobs:
|
||||
chmod +rw /home/runner/work/InvenTree/db.sqlite3
|
||||
invoke migrate
|
||||
|
||||
- name: 0.16.0 Database
|
||||
run: |
|
||||
rm /home/runner/work/InvenTree/db.sqlite3
|
||||
cp test-db/stable_0.16.0.sqlite3 /home/runner/work/InvenTree/db.sqlite3
|
||||
chmod +rw /home/runner/work/InvenTree/db.sqlite3
|
||||
invoke migrate
|
||||
|
||||
- name: 0.17.0 Database
|
||||
run: |
|
||||
rm /home/runner/work/InvenTree/db.sqlite3
|
||||
cp test-db/stable_0.17.0.sqlite3 /home/runner/work/InvenTree/db.sqlite3
|
||||
chmod +rw /home/runner/work/InvenTree/db.sqlite3
|
||||
invoke migrate
|
||||
|
||||
web_ui:
|
||||
name: Tests - Web UI
|
||||
runs-on: ubuntu-24.04
|
||||
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' || needs.paths-filter.outputs.force == 'true'
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:15
|
||||
env:
|
||||
POSTGRES_DB: inventree
|
||||
POSTGRES_USER: inventree_user
|
||||
POSTGRES_PASSWORD: inventree_password
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd "pg_isready -U testuser"
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
|
||||
needs: [ 'pre-commit', 'paths-filter' ]
|
||||
if: needs.paths-filter.outputs.frontend == 'true'
|
||||
env:
|
||||
INVENTREE_DB_ENGINE: postgresql
|
||||
INVENTREE_DB_NAME: inventree
|
||||
INVENTREE_DB_HOST: "127.0.0.1"
|
||||
INVENTREE_DB_PORT: 5432
|
||||
INVENTREE_DB_USER: inventree_user
|
||||
INVENTREE_DB_PASSWORD: inventree_password
|
||||
INVENTREE_DEBUG: true
|
||||
INVENTREE_DB_ENGINE: sqlite3
|
||||
INVENTREE_DB_NAME: /home/runner/work/InvenTree/db.sqlite3
|
||||
INVENTREE_DEBUG: True
|
||||
INVENTREE_PLUGINS_ENABLED: false
|
||||
VITE_COVERAGE_BUILD: true
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # pin@v5.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # pin@v4.1.1
|
||||
- name: Environment Setup
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
npm: true
|
||||
install: true
|
||||
update: true
|
||||
apt-dependency: postgresql-client libpq-dev
|
||||
pip-dependency: psycopg2
|
||||
- name: Set up test data
|
||||
run: |
|
||||
invoke dev.setup-test -iv
|
||||
invoke int.rebuild-thumbnails
|
||||
run: invoke setup-test -i
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
invoke int.frontend-compile --extract
|
||||
cd src/frontend && npx playwright install --with-deps
|
||||
run: inv frontend-compile
|
||||
- name: Install Playwright Browsers
|
||||
run: cd src/frontend && npx playwright install --with-deps
|
||||
- name: Run Playwright tests
|
||||
id: tests
|
||||
run: cd src/frontend && npx nyc playwright test
|
||||
- uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # pin@v5.0.0
|
||||
if: ${{ !cancelled() && steps.tests.outcome == 'failure' }}
|
||||
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: 14
|
||||
- name: Report coverage
|
||||
run: cd src/frontend && npx nyc report --report-dir ./coverage --temp-dir .nyc_output --reporter=lcov --exclude-after-remap false
|
||||
- name: Upload coverage reports to Codecov
|
||||
uses: codecov/codecov-action@5a1091511ad55cbe89839c7260b706298ca349f7 # pin@v5.5.1
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
slug: inventree/InvenTree
|
||||
flags: web
|
||||
- name: Upload bundler info
|
||||
env:
|
||||
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
||||
run: |
|
||||
cd src/frontend
|
||||
yarn install
|
||||
yarn run build
|
||||
retention-days: 30
|
||||
|
||||
web_ui_build:
|
||||
name: Build - Web UI
|
||||
runs-on: ubuntu-24.04
|
||||
platform_ui_build:
|
||||
name: Build - UI Platform
|
||||
runs-on: ubuntu-20.04
|
||||
timeout-minutes: 60
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # pin@v5.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # pin@v4.1.1
|
||||
- name: Environment Setup
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
@@ -673,39 +505,12 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: cd src/frontend && yarn install
|
||||
- name: Build frontend
|
||||
run: cd src/frontend && yarn run compile && yarn run build
|
||||
- name: Write version file - SHA
|
||||
run: cd src/backend/InvenTree/web/static/web/.vite && echo "$GITHUB_SHA" > sha.txt
|
||||
run: cd src/frontend && npm run build
|
||||
- name: Zip frontend
|
||||
run: |
|
||||
cd src/backend/InvenTree/web/static
|
||||
zip -r frontend-build.zip web/ web/.vite
|
||||
- uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # pin@v5.0.0
|
||||
cd InvenTree/web/static
|
||||
zip -r frontend-build.zip web/
|
||||
- uses: actions/upload-artifact@a8a3f3ad30e3422c9c7b888a15615d19a852ae32 # pin@v3.1.3
|
||||
with:
|
||||
name: frontend-build
|
||||
path: src/backend/InvenTree/web/static/web
|
||||
include-hidden-files: true
|
||||
|
||||
zizmor:
|
||||
name: Security [Zizmor]
|
||||
runs-on: ubuntu-24.04
|
||||
needs: ["pre-commit", "paths-filter"]
|
||||
if: needs.paths-filter.outputs.cicd == 'true' || needs.paths-filter.outputs.force == 'true'
|
||||
|
||||
permissions:
|
||||
security-events: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # pin@v5.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: hynek/setup-cached-uv@757bedc3f972eb7227a1aa657651f15a8527c817 # pin@v2
|
||||
- name: Run zizmor
|
||||
run: uvx zizmor --format sarif . > results.sarif
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Upload SARIF file
|
||||
uses: github/codeql-action/upload-sarif@4e94bd11f71e507f7f87df81788dff88d1dacbfb # pin@v3
|
||||
with:
|
||||
sarif_file: results.sarif
|
||||
category: zizmor
|
||||
path: InvenTree/web/static/web
|
||||
|
||||
136
.github/workflows/release.yaml
vendored
136
.github/workflows/release.yaml
vendored
@@ -1,136 +0,0 @@
|
||||
# Runs on releases
|
||||
|
||||
name: Publish release
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
permissions:
|
||||
contents: read
|
||||
env:
|
||||
python_version: 3.9
|
||||
|
||||
jobs:
|
||||
stable:
|
||||
runs-on: ubuntu-24.04
|
||||
name: Write release to stable branch
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # pin@v5.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Version Check
|
||||
run: |
|
||||
pip install --require-hashes -r contrib/dev_reqs/requirements.txt
|
||||
python3 .github/scripts/version_check.py
|
||||
- name: Push to Stable Branch
|
||||
uses: ad-m/github-push-action@77c5b412c50b723d2a4fbc6d71fb5723bcd439aa # pin@v1.0.0
|
||||
if: env.stable_release == 'true'
|
||||
with:
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
branch: stable
|
||||
force: true
|
||||
|
||||
build:
|
||||
runs-on: ubuntu-24.04
|
||||
name: Build and attest frontend
|
||||
permissions:
|
||||
id-token: write
|
||||
contents: write
|
||||
attestations: write
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # pin@v5.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- 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 compile && npm run build
|
||||
- name: Create SBOM for frontend
|
||||
uses: anchore/sbom-action@8e94d75ddd33f69f691467e42275782e4bfefe84 # pin@v0
|
||||
with:
|
||||
artifact-name: frontend-build.spdx
|
||||
path: src/frontend
|
||||
- name: Write version file - SHA
|
||||
run: cd src/backend/InvenTree/web/static/web/.vite && echo "$GITHUB_SHA" > sha.txt
|
||||
- name: Write version file - TAG
|
||||
run: cd src/backend/InvenTree/web/static/web/.vite && echo "${REF_NAME}" > tag.txt
|
||||
env:
|
||||
REF_NAME: ${{ github.ref_name }}
|
||||
- name: Zip frontend
|
||||
run: |
|
||||
cd src/backend/InvenTree/web/static/web
|
||||
zip -r ../frontend-build.zip * .vite
|
||||
- name: Attest Build Provenance
|
||||
id: attest
|
||||
uses: actions/attest-build-provenance@977bb373ede98d70efdf65b84cb5f73e068dcc2a # pin@v1
|
||||
with:
|
||||
subject-path: "${{ github.workspace }}/src/backend/InvenTree/web/static/frontend-build.zip"
|
||||
|
||||
- name: Upload frontend
|
||||
uses: svenstaro/upload-release-action@81c65b7cd4de9b2570615ce3aad67a41de5b1a13 # pin@2.11.2
|
||||
with:
|
||||
repo_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
file: src/backend/InvenTree/web/static/frontend-build.zip
|
||||
asset_name: frontend-build.zip
|
||||
tag: ${{ github.ref }}
|
||||
overwrite: true
|
||||
- name: Upload Attestation
|
||||
uses: svenstaro/upload-release-action@81c65b7cd4de9b2570615ce3aad67a41de5b1a13 # pin@2.11.2
|
||||
with:
|
||||
repo_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
asset_name: frontend-build.intoto.jsonl
|
||||
file: ${{ steps.attest.outputs.bundle-path}}
|
||||
tag: ${{ github.ref }}
|
||||
overwrite: true
|
||||
|
||||
docs:
|
||||
runs-on: ubuntu-24.04
|
||||
name: Build and publish documentation
|
||||
permissions:
|
||||
contents: write
|
||||
env:
|
||||
INVENTREE_DB_ENGINE: sqlite3
|
||||
INVENTREE_DB_NAME: inventree
|
||||
INVENTREE_MEDIA_ROOT: /home/runner/work/InvenTree/test_inventree_media
|
||||
INVENTREE_STATIC_ROOT: /home/runner/work/InvenTree/test_inventree_static
|
||||
INVENTREE_BACKUP_DIR: /home/runner/work/InvenTree/test_inventree_backup
|
||||
INVENTREE_SITE_URL: http://localhost:8000
|
||||
INVENTREE_DEBUG: true
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # pin@v5.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Environment Setup
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
install: true
|
||||
npm: true
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
pip install --require-hashes -r contrib/dev_reqs/requirements.txt
|
||||
pip install --require-hashes -r docs/requirements.txt
|
||||
- name: Build documentation
|
||||
run: |
|
||||
invoke build-docs --mkdocs
|
||||
- name: Zip build docs
|
||||
run: |
|
||||
cd docs/site
|
||||
zip -r docs-html.zip *
|
||||
- name: Publish documentation
|
||||
uses: svenstaro/upload-release-action@81c65b7cd4de9b2570615ce3aad67a41de5b1a13 # pin@2.11.2
|
||||
with:
|
||||
repo_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
file: docs/site/docs-html.zip
|
||||
asset_name: docs-html.zip
|
||||
tag: ${{ github.ref }}
|
||||
overwrite: true
|
||||
51
.github/workflows/release.yml
vendored
Normal file
51
.github/workflows/release.yml
vendored
Normal file
@@ -0,0 +1,51 @@
|
||||
# Runs on releases
|
||||
|
||||
name: Publish release notes
|
||||
on:
|
||||
release:
|
||||
types: [ published ]
|
||||
|
||||
jobs:
|
||||
|
||||
stable:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
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@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
|
||||
72
.github/workflows/scorecard.yaml
vendored
72
.github/workflows/scorecard.yaml
vendored
@@ -1,72 +0,0 @@
|
||||
# This workflow uses actions that are not certified by GitHub. They are provided
|
||||
# by a third-party and are governed by separate terms of service, privacy
|
||||
# policy, and support documentation.
|
||||
|
||||
name: Scorecard supply-chain security
|
||||
on:
|
||||
# For Branch-Protection check. Only the default branch is supported. See
|
||||
# https://github.com/ossf/scorecard/blob/main/docs/checks.md#branch-protection
|
||||
branch_protection_rule:
|
||||
# To guarantee Maintained check is occasionally updated. See
|
||||
# https://github.com/ossf/scorecard/blob/main/docs/checks.md#maintained
|
||||
schedule:
|
||||
- cron: "32 0 * * 0"
|
||||
push:
|
||||
branches: ["master"]
|
||||
|
||||
# Declare default permissions as read only.
|
||||
permissions: read-all
|
||||
|
||||
jobs:
|
||||
analysis:
|
||||
name: Scorecard analysis
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
# Needed to upload the results to code-scanning dashboard.
|
||||
security-events: write
|
||||
# Needed to publish results and get a badge (see publish_results below).
|
||||
id-token: write
|
||||
# Uncomment the permissions below if installing in a private repository.
|
||||
# contents: read
|
||||
# actions: read
|
||||
|
||||
steps:
|
||||
- name: "Checkout code"
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: "Run analysis"
|
||||
uses: ossf/scorecard-action@4eaacf0543bb3f2c246792bd56e8cdeffafb205a # v2.4.3
|
||||
with:
|
||||
results_file: results.sarif
|
||||
results_format: sarif
|
||||
# (Optional) "write" PAT token. Uncomment the `repo_token` line below if:
|
||||
# - you want to enable the Branch-Protection check on a *public* repository, or
|
||||
# - you are installing Scorecard on a *private* repository
|
||||
# To create the PAT, follow the steps in https://github.com/ossf/scorecard-action#authentication-with-pat.
|
||||
# repo_token: ${{ secrets.SCORECARD_TOKEN }}
|
||||
|
||||
# Public repositories:
|
||||
# - Publish results to OpenSSF REST API for easy access by consumers
|
||||
# - Allows the repository to include the Scorecard badge.
|
||||
# - See https://github.com/ossf/scorecard-action#publishing-results.
|
||||
# For private repositories:
|
||||
# - `publish_results` will always be set to `false`, regardless
|
||||
# of the value entered here.
|
||||
publish_results: true
|
||||
|
||||
# Upload the results as artifacts (optional). Commenting out will disable uploads of run results in SARIF
|
||||
# format to the repository Actions tab.
|
||||
- name: "Upload artifact"
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
with:
|
||||
name: SARIF file
|
||||
path: results.sarif
|
||||
retention-days: 5
|
||||
|
||||
# Upload the results to GitHub's code scanning dashboard.
|
||||
- name: "Upload to code-scanning"
|
||||
uses: github/codeql-action/upload-sarif@4e94bd11f71e507f7f87df81788dff88d1dacbfb # v4.31.0
|
||||
with:
|
||||
sarif_file: results.sarif
|
||||
27
.github/workflows/stale.yaml
vendored
27
.github/workflows/stale.yaml
vendored
@@ -1,27 +0,0 @@
|
||||
# Marks all issues that do not receive activity stale starting 2022
|
||||
name: Mark stale issues and pull requests
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "24 11 * * *"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
stale:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
steps:
|
||||
- uses: actions/stale@5f858e3efba33a5ca4407a664cc011ad407f2008 # pin@v10.1.0
|
||||
with:
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
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"
|
||||
start-date: "2022-01-01"
|
||||
exempt-all-milestones: true
|
||||
25
.github/workflows/stale.yml
vendored
Normal file
25
.github/workflows/stale.yml
vendored
Normal file
@@ -0,0 +1,25 @@
|
||||
# Marks all issues that do not receive activity stale starting 2022
|
||||
name: Mark stale issues and pull requests
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '24 11 * * *'
|
||||
|
||||
jobs:
|
||||
stale:
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
steps:
|
||||
- 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-pr-message: 'This PR seems stale. Please react to show this is still important.'
|
||||
stale-issue-label: 'inactive'
|
||||
stale-pr-label: 'inactive'
|
||||
start-date: '2022-01-01'
|
||||
exempt-all-milestones: true
|
||||
73
.github/workflows/translations.yaml
vendored
73
.github/workflows/translations.yaml
vendored
@@ -1,73 +0,0 @@
|
||||
name: Update Translation Files
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
|
||||
env:
|
||||
python_version: 3.9
|
||||
node_version: 20
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
synchronize-with-crowdin:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
INVENTREE_DB_NAME: "./test_db.sqlite"
|
||||
INVENTREE_DB_ENGINE: django.db.backends.sqlite3
|
||||
INVENTREE_DEBUG: true
|
||||
INVENTREE_LOG_LEVEL: INFO
|
||||
INVENTREE_MEDIA_ROOT: ./media
|
||||
INVENTREE_STATIC_ROOT: ./static
|
||||
INVENTREE_BACKUP_DIR: ./backup
|
||||
INVENTREE_SITE_URL: http://localhost:8000
|
||||
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # pin@v5.0.0
|
||||
with:
|
||||
persist-credentials: true
|
||||
- name: Environment Setup
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
install: true
|
||||
apt-dependency: gettext
|
||||
- name: Make Translations
|
||||
run: invoke dev.translate
|
||||
- name: Remove compiled static files
|
||||
run: rm -rf src/backend/InvenTree/static
|
||||
- name: Remove all local changes that are not *.po files
|
||||
run: |
|
||||
git config --local user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
git config --local user.name "github-actions[bot]"
|
||||
git add src/backend/InvenTree/locale/en/LC_MESSAGES/django.po src/frontend/src/locales/en/messages.po
|
||||
echo "Adding commit (or ignoring if no changes)"
|
||||
git commit -m "add translations" || true
|
||||
echo "Removing all other changes"
|
||||
git reset --hard
|
||||
echo "Resetting to HEAD~"
|
||||
git reset HEAD~ || true
|
||||
- name: crowdin action
|
||||
uses: crowdin/github-action@08713f00a50548bfe39b37e8f44afb53e7a802d4 # pin@v2
|
||||
with:
|
||||
upload_sources: true
|
||||
upload_translations: false
|
||||
download_translations: true
|
||||
localization_branch_name: l10_crowdin
|
||||
create_pull_request: true
|
||||
pull_request_title: 'New Crowdin updates'
|
||||
pull_request_body: 'New Crowdin translations by [Crowdin GH Action](https://github.com/crowdin/github-action)'
|
||||
pull_request_base_branch_name: 'master'
|
||||
pull_request_labels: 'translations'
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
|
||||
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
|
||||
49
.github/workflows/translations.yml
vendored
Normal file
49
.github/workflows/translations.yml
vendored
Normal file
@@ -0,0 +1,49 @@
|
||||
name: Update Translation Files
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
|
||||
env:
|
||||
python_version: 3.9
|
||||
node_version: 16
|
||||
|
||||
jobs:
|
||||
build:
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
INVENTREE_DB_NAME: './test_db.sqlite'
|
||||
INVENTREE_DB_ENGINE: django.db.backends.sqlite3
|
||||
INVENTREE_DEBUG: info
|
||||
INVENTREE_MEDIA_ROOT: ./media
|
||||
INVENTREE_STATIC_ROOT: ./static
|
||||
INVENTREE_BACKUP_DIR: ./backup
|
||||
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # pin@v4.1.1
|
||||
- name: Environment Setup
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
install: true
|
||||
npm: true
|
||||
apt-dependency: gettext
|
||||
- name: Make Translations
|
||||
run: invoke translate
|
||||
- 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 l10_local
|
||||
git add "*.po"
|
||||
git commit -m "updated translation base"
|
||||
- name: Push changes
|
||||
uses: ad-m/github-push-action@d91a481090679876dfc4178fef17f286781251df # pin@v0.8.0
|
||||
with:
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
branch: l10
|
||||
force: true
|
||||
6
.github/workflows/update.yml.disabled
vendored
6
.github/workflows/update.yml.disabled
vendored
@@ -9,11 +9,9 @@ jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # pin@v4.2.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # pin@v4.1.1
|
||||
- name: Setup
|
||||
run: pip install --require-hashes -r requirements-dev.txt
|
||||
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
|
||||
|
||||
25
.gitignore
vendored
25
.gitignore
vendored
@@ -7,7 +7,6 @@ __pycache__/
|
||||
.Python
|
||||
env/
|
||||
inventree-env/
|
||||
.venv/
|
||||
./build/
|
||||
.cache/
|
||||
develop-eggs/
|
||||
@@ -19,6 +18,7 @@ share/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
@@ -31,7 +31,6 @@ var/
|
||||
# Django stuff:
|
||||
*.log
|
||||
local_settings.py
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
*.sqlite3-journal
|
||||
*.backup
|
||||
@@ -46,17 +45,18 @@ inventree_media
|
||||
inventree_static
|
||||
static_i18n
|
||||
|
||||
# Local config files
|
||||
# Local config file
|
||||
config.yaml
|
||||
plugins.txt
|
||||
secret_key.txt
|
||||
oidc.pem
|
||||
|
||||
# Default data file
|
||||
data.json
|
||||
*.json.tmp
|
||||
*.tmp.json
|
||||
|
||||
# Key file
|
||||
secret_key.txt
|
||||
|
||||
# IDE / development files
|
||||
.idea/
|
||||
*.code-workspace
|
||||
@@ -80,17 +80,11 @@ js_tmp/
|
||||
|
||||
# Development files
|
||||
dev/
|
||||
dev-db/
|
||||
data/
|
||||
env/
|
||||
|
||||
# Locale stats file
|
||||
src/backend/InvenTree/InvenTree/locale_stats.json
|
||||
src/backend/InvenTree/InvenTree/licenses.txt
|
||||
|
||||
# Logs
|
||||
src/backend/InvenTree/logs.json
|
||||
src/backend/InvenTree/logs.log
|
||||
locale_stats.json
|
||||
|
||||
# node.js
|
||||
node_modules/
|
||||
@@ -99,7 +93,7 @@ node_modules/
|
||||
maintenance_mode_state.txt
|
||||
|
||||
# plugin dev directory
|
||||
src/backend/InvenTree/plugins/
|
||||
InvenTree/plugins/
|
||||
|
||||
# Compiled translation files
|
||||
*.mo
|
||||
@@ -109,5 +103,8 @@ messages.ts
|
||||
api.yaml
|
||||
|
||||
# web frontend (static files)
|
||||
src/backend/InvenTree/web/static
|
||||
InvenTree/web/static
|
||||
|
||||
# Generated docs files
|
||||
docs/docs/api/*.yml
|
||||
docs/docs/api/schema/*.yml
|
||||
|
||||
18
.pkgr.yml
18
.pkgr.yml
@@ -2,8 +2,10 @@ name: inventree
|
||||
description: Open Source Inventory Management System
|
||||
homepage: https://inventree.org
|
||||
notifications: true
|
||||
buildpack: https://github.com/matmair/null-buildpack#master
|
||||
buildpack: https://github.com/mjmair/heroku-buildpack-python#v216-mjmair
|
||||
env:
|
||||
- STACK=heroku-20
|
||||
- DISABLE_COLLECTSTATIC=1
|
||||
- INVENTREE_DB_ENGINE=sqlite3
|
||||
- INVENTREE_DB_NAME=database.sqlite3
|
||||
- INVENTREE_PLUGINS_ENABLED
|
||||
@@ -12,17 +14,14 @@ env:
|
||||
- INVENTREE_BACKUP_DIR=/opt/inventree/backup
|
||||
- INVENTREE_PLUGIN_FILE=/opt/inventree/plugins.txt
|
||||
- INVENTREE_CONFIG_FILE=/opt/inventree/config.yaml
|
||||
- APP_REPO=inventree/InvenTree
|
||||
before_install: contrib/packager.io/preinstall.sh
|
||||
after_install: contrib/packager.io/postinstall.sh
|
||||
before_remove: contrib/packager.io/preinstall.sh
|
||||
before:
|
||||
- contrib/packager.io/before.sh
|
||||
dependencies:
|
||||
- curl
|
||||
- "python3.9 | python3.10 | python3.11 | python3.12 | python3.13 | python3.14"
|
||||
- "python3.9-venv | python3.10-venv | python3.11-venv | python3.12-venv | python3.13-venv | python3.14-venv"
|
||||
- "python3.9-dev | python3.10-dev | python3.11-dev | python3.12-dev | python3.13-dev | python3.14-dev"
|
||||
- "python3.9 | python3.10 | python3.11"
|
||||
- "python3.9-venv | python3.10-venv | python3.11-venv"
|
||||
- "python3.9-dev | python3.10-dev | python3.11-dev"
|
||||
- python3-pip
|
||||
- python3-cffi
|
||||
- python3-brotli
|
||||
@@ -33,10 +32,7 @@ dependencies:
|
||||
- gettext
|
||||
- nginx
|
||||
- jq
|
||||
- "libffi7 | libffi8"
|
||||
- libffi7
|
||||
targets:
|
||||
ubuntu-20.04: true
|
||||
ubuntu-22.04: true
|
||||
ubuntu-24.04: true
|
||||
debian-11: true
|
||||
debian-12: true
|
||||
|
||||
@@ -2,90 +2,72 @@
|
||||
# See https://pre-commit.com/hooks.html for more hooks
|
||||
exclude: |
|
||||
(?x)^(
|
||||
src/backend/InvenTree/InvenTree/static/.*|
|
||||
src/backend/InvenTree/locale/.*|
|
||||
src/frontend/src/locales/.* |
|
||||
.*/migrations/.* |
|
||||
src/frontend/yarn.lock
|
||||
InvenTree/InvenTree/static/.*|
|
||||
InvenTree/locale/.*|
|
||||
src/frontend/src/locales/.*|
|
||||
.*/migrations/.*
|
||||
)$
|
||||
repos:
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: v5.0.0
|
||||
rev: v4.5.0
|
||||
hooks:
|
||||
- id: trailing-whitespace
|
||||
- id: end-of-file-fixer
|
||||
- id: check-yaml
|
||||
exclude: mkdocs.yml
|
||||
- id: mixed-line-ending
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.11.13
|
||||
rev: v0.2.2
|
||||
hooks:
|
||||
- id: ruff-format
|
||||
args: [--preview]
|
||||
- id: ruff
|
||||
args: [
|
||||
--fix,
|
||||
# --unsafe-fixes,
|
||||
--preview
|
||||
]
|
||||
- repo: https://github.com/astral-sh/uv-pre-commit
|
||||
rev: 0.7.12
|
||||
- repo: https://github.com/matmair/ruff-pre-commit
|
||||
rev: 830893bf46db844d9c99b6c468e285199adf2de6 # uv-018
|
||||
hooks:
|
||||
- id: pip-compile
|
||||
name: pip-compile requirements-dev.in
|
||||
args: [src/backend/requirements-dev.in, -o, src/backend/requirements-dev.txt, --no-strip-extras, --generate-hashes]
|
||||
files: src/backend/requirements-dev\.(in|txt)$
|
||||
args: [requirements-dev.in, -o, requirements-dev.txt, --python-version=3.9]
|
||||
files: ^requirements-dev\.(in|txt)$
|
||||
- id: pip-compile
|
||||
name: pip-compile requirements.txt
|
||||
args: [src/backend/requirements.in, -o, src/backend/requirements.txt, --no-strip-extras, --generate-hashes]
|
||||
files: src/backend/requirements\.(in|txt)$
|
||||
- id: pip-compile
|
||||
name: pip-compile requirements.txt
|
||||
args: [contrib/dev_reqs/requirements.in, -o, contrib/dev_reqs/requirements.txt, --no-strip-extras, --generate-hashes, -b, src/backend/requirements.txt]
|
||||
files: contrib/dev_reqs/requirements\.(in|txt)$
|
||||
- id: pip-compile
|
||||
name: pip-compile requirements.txt
|
||||
args: [docs/requirements.in, -o, docs/requirements.txt, --no-strip-extras, --generate-hashes, -b, src/backend/requirements.txt]
|
||||
files: docs/requirements\.(in|txt)$
|
||||
- id: pip-compile
|
||||
name: pip-compile requirements.txt
|
||||
args: [contrib/container/requirements.in, -o, contrib/container/requirements.txt, --python-version=3.11, --no-strip-extras, --generate-hashes, -b, src/backend/requirements.txt]
|
||||
files: contrib/container/requirements\.(in|txt)$
|
||||
args: [requirements.in, -o, requirements.txt,--python-version=3.9]
|
||||
files: ^requirements\.(in|txt)$
|
||||
- repo: https://github.com/Riverside-Healthcare/djLint
|
||||
rev: v1.36.4
|
||||
rev: v1.34.1
|
||||
hooks:
|
||||
- id: djlint-django
|
||||
- repo: https://github.com/codespell-project/codespell
|
||||
rev: v2.4.1
|
||||
rev: v2.2.6
|
||||
hooks:
|
||||
- id: codespell
|
||||
additional_dependencies:
|
||||
- tomli
|
||||
exclude: >
|
||||
(?x)^(
|
||||
docs/docs/stylesheets/.*|
|
||||
docs/docs/javascripts/.*|
|
||||
docs/docs/webfonts/.* |
|
||||
src/frontend/src/locales/.* |
|
||||
pyproject.toml |
|
||||
src/frontend/vite.config.ts |
|
||||
)$
|
||||
- repo: https://github.com/biomejs/pre-commit
|
||||
rev: v2.0.0-beta.5
|
||||
hooks:
|
||||
- id: biome-check
|
||||
additional_dependencies: ["@biomejs/biome@1.9.4"]
|
||||
files: ^src/frontend/.*\.(js|ts|tsx)$
|
||||
- repo: https://github.com/gitleaks/gitleaks
|
||||
rev: v8.27.2
|
||||
hooks:
|
||||
- id: gitleaks
|
||||
language_version: 1.23.6
|
||||
#- repo: https://github.com/jumanjihouse/pre-commit-hooks
|
||||
# rev: 3.0.0
|
||||
# hooks:
|
||||
# - id: shellcheck
|
||||
- repo: https://github.com/isidentical/teyit
|
||||
rev: 0.4.3
|
||||
hooks:
|
||||
- id: teyit
|
||||
- repo: https://github.com/pre-commit/mirrors-prettier
|
||||
rev: "v4.0.0-alpha.8"
|
||||
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: "v9.0.0-beta.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)$
|
||||
|
||||
5
.vscode/extensions.json
vendored
5
.vscode/extensions.json
vendored
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"biomejs.biome"
|
||||
]
|
||||
}
|
||||
53
.vscode/launch.json
vendored
53
.vscode/launch.json
vendored
@@ -6,60 +6,19 @@
|
||||
"configurations": [
|
||||
{
|
||||
"name": "InvenTree Server",
|
||||
"type": "debugpy",
|
||||
"type": "python",
|
||||
"request": "launch",
|
||||
"program": "${workspaceFolder}/src/backend/InvenTree/manage.py",
|
||||
"args": [
|
||||
"runserver",
|
||||
// "0.0.0.0:8000", // expose server in network (useful for testing with mobile app)
|
||||
// "--noreload" // disable auto-reload
|
||||
],
|
||||
"django": true,
|
||||
"justMyCode": true
|
||||
},
|
||||
{
|
||||
"name": "InvenTree Server - Tests",
|
||||
"type": "debugpy",
|
||||
"request": "launch",
|
||||
"program": "${workspaceFolder}/src/backend/InvenTree/manage.py",
|
||||
"args": [
|
||||
"test",
|
||||
// "part.test_api.PartCategoryAPITest", // run only a specific test
|
||||
],
|
||||
"program": "${workspaceFolder}/InvenTree/manage.py",
|
||||
"args": ["runserver"],
|
||||
"django": true,
|
||||
"justMyCode": true
|
||||
},
|
||||
{
|
||||
"name": "InvenTree Server - 3rd party",
|
||||
"type": "debugpy",
|
||||
"type": "python",
|
||||
"request": "launch",
|
||||
"program": "${workspaceFolder}/src/backend/InvenTree/manage.py",
|
||||
"args": [
|
||||
"runserver"
|
||||
],
|
||||
"django": true,
|
||||
"justMyCode": false
|
||||
},
|
||||
{
|
||||
"name": "InvenTree invoke schema",
|
||||
"type": "debugpy",
|
||||
"request": "launch",
|
||||
"program": "${workspaceFolder}/.venv/lib/python3.9/site-packages/invoke/__main__.py",
|
||||
"cwd": "${workspaceFolder}",
|
||||
"args": [
|
||||
"dev.schema","--ignore-warnings"
|
||||
],
|
||||
"justMyCode": false
|
||||
},
|
||||
{
|
||||
"name": "schema generation",
|
||||
"type": "debugpy",
|
||||
"request": "launch",
|
||||
"program": "${workspaceFolder}/src/backend/InvenTree/manage.py",
|
||||
"args": [
|
||||
"schema",
|
||||
"--file","src/frontend/schema.yml"
|
||||
],
|
||||
"program": "${workspaceFolder}/InvenTree/manage.py",
|
||||
"args": ["runserver"],
|
||||
"django": true,
|
||||
"justMyCode": false
|
||||
},
|
||||
|
||||
8
.vscode/settings.json
vendored
8
.vscode/settings.json
vendored
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"[typescript]": {
|
||||
"editor.defaultFormatter": "biomejs.biome"
|
||||
},
|
||||
"editor.codeActionsOnSave": {
|
||||
"quickfix.biome": "explicit"
|
||||
}
|
||||
}
|
||||
20
.vscode/tasks.json
vendored
20
.vscode/tasks.json
vendored
@@ -9,61 +9,61 @@
|
||||
{
|
||||
"label": "worker",
|
||||
"type": "shell",
|
||||
"command": "invoke worker",
|
||||
"command": "inv worker",
|
||||
"problemMatcher": [],
|
||||
},
|
||||
{
|
||||
"label": "clean-settings",
|
||||
"type": "shell",
|
||||
"command": "invoke int.clean-settings",
|
||||
"command": "inv clean-settings",
|
||||
"problemMatcher": [],
|
||||
},
|
||||
{
|
||||
"label": "delete-data",
|
||||
"type": "shell",
|
||||
"command": "invoke dev.delete-data",
|
||||
"command": "inv delete-data",
|
||||
"problemMatcher": [],
|
||||
},
|
||||
{
|
||||
"label": "migrate",
|
||||
"type": "shell",
|
||||
"command": "invoke migrate",
|
||||
"command": "inv migrate",
|
||||
"problemMatcher": [],
|
||||
},
|
||||
{
|
||||
"label": "server",
|
||||
"type": "shell",
|
||||
"command": "invoke dev.server",
|
||||
"command": "inv server",
|
||||
"problemMatcher": [],
|
||||
},
|
||||
{
|
||||
"label": "setup-dev",
|
||||
"type": "shell",
|
||||
"command": "invoke dev.setup-dev",
|
||||
"command": "inv setup-dev",
|
||||
"problemMatcher": [],
|
||||
},
|
||||
{
|
||||
"label": "setup-test",
|
||||
"type": "shell",
|
||||
"command": "invoke dev.setup-test -i --path dev/inventree-demo-dataset",
|
||||
"command": "inv setup-test -i --path dev/inventree-demo-dataset",
|
||||
"problemMatcher": [],
|
||||
},
|
||||
{
|
||||
"label": "superuser",
|
||||
"type": "shell",
|
||||
"command": "invoke superuser",
|
||||
"command": "inv superuser",
|
||||
"problemMatcher": [],
|
||||
},
|
||||
{
|
||||
"label": "test",
|
||||
"type": "shell",
|
||||
"command": "invoke dev.test",
|
||||
"command": "inv test",
|
||||
"problemMatcher": [],
|
||||
},
|
||||
{
|
||||
"label": "update",
|
||||
"type": "shell",
|
||||
"command": "invoke update",
|
||||
"command": "inv update",
|
||||
"problemMatcher": [],
|
||||
},
|
||||
]
|
||||
|
||||
43
CHANGELOG.md
43
CHANGELOG.md
@@ -1,43 +0,0 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to this project will be documented in this file (starting with 1.0.0).
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## 1.1.0 - 2025-11-02
|
||||
|
||||
### Added
|
||||
|
||||
- Added `order_queryset` report helper function in [#10439](https://github.com/inventree/InvenTree/pull/10439)
|
||||
- Added `SupplierMixin` to import data from suppliers in [#9761](https://github.com/inventree/InvenTree/pull/9761)
|
||||
- Added much more detailed status information for machines to the API endpoint (including backend and frontend changes) in [#10381](https://github.com/inventree/InvenTree/pull/10381)
|
||||
- Added ability to partially complete and partially scrap build outputs in [#10499](https://github.com/inventree/InvenTree/pull/10499)
|
||||
- Added support for Redis ACL user-based authentication in [#10551](https://github.com/inventree/InvenTree/pull/10551)
|
||||
- Expose stock adjustment forms to the UI plugin context in [#10584](https://github.com/inventree/InvenTree/pull/10584)
|
||||
- Allow stock adjustments for "in production" items in [#10600](https://github.com/inventree/InvenTree/pull/10600)
|
||||
- Adds optional shipping address against individual sales order shipments in [#10650](https://github.com/inventree/InvenTree/pull/10650)
|
||||
- Adds UI elements to "check" and "uncheck" sales order shipments in [#10654](https://github.com/inventree/InvenTree/pull/10654)
|
||||
- Allow assigning project codes to order line items in [#10657](https://github.com/inventree/InvenTree/pull/10657)
|
||||
- Added support for webauthn login for the frontend in [#9729](https://github.com/inventree/InvenTree/pull/9729)
|
||||
- Added support for Debian 12, Ubuntu 22.04 and Ubuntu 24.04 in the installer and package in [#10705](https://github.com/inventree/InvenTree/pull/10705)
|
||||
- Support for S3 and SFTP storage backends for media and static files ([#10140](https://github.com/inventree/InvenTree/pull/10140))
|
||||
- Adds hooks for custom UI spotlight actions in [#10720](https://github.com/inventree/InvenTree/pull/10720)
|
||||
- Support uploading attachments against SupplierPart in [#10724](https://github.com/inventree/InvenTree/pull/10724)
|
||||
|
||||
### Changed
|
||||
|
||||
- Changed site URL check to allow protocol mismatches if `INVENTREE_SITE_LAX_PROTOCOL` is set to `True` (default) in [#10454](https://github.com/inventree/InvenTree/pull/10454)
|
||||
- Changed call signature of `get_global_setting` to use `environment_key` instead of `enviroment_key` in [#10557](https://github.com/inventree/InvenTree/pull/10557)
|
||||
|
||||
|
||||
## 1.0.0 - 2025-09-15
|
||||
|
||||
The first "stable" release following semver but not extensively other than the previous releases. The use of 1.0 indicates the stability that users already expect from InvenTree.
|
||||
|
||||
An overarching theme of this release is the complete switch to a new UI framework and paradigm (PUI). The old templating based UI (CUI) is now removed. This makes major improvements in the security and portability of InvenTree possible.
|
||||
|
||||
Our blog holds [a few articles](https://inventree.org/blog/2024/09/23/ui-roadmap) on the topic. This journey started in [March 2022](https://github.com/inventree/InvenTree/issues/2789) and was announced [in 2023](https://inventree.org/blog/2023/08/28/react).
|
||||
|
||||
|
||||
Specific entries to the changelog will be kept for all stable channel minor releases, for changes in 1.0 please refer to the [blog posts](https://inventree.org/blog/2025/09/15/1.0.0) and the [milestone](https://github.com/inventree/InvenTree/milestone/17)
|
||||
@@ -1,128 +0,0 @@
|
||||
# Contributor Covenant Code of Conduct
|
||||
|
||||
## Our Pledge
|
||||
|
||||
We as members, contributors, and leaders pledge to make participation in our
|
||||
community a harassment-free experience for everyone, regardless of age, body
|
||||
size, visible or invisible disability, ethnicity, sex characteristics, gender
|
||||
identity and expression, level of experience, education, socio-economic status,
|
||||
nationality, personal appearance, race, religion, or sexual identity
|
||||
and orientation.
|
||||
|
||||
We pledge to act and interact in ways that contribute to an open, welcoming,
|
||||
diverse, inclusive, and healthy community.
|
||||
|
||||
## Our Standards
|
||||
|
||||
Examples of behavior that contributes to a positive environment for our
|
||||
community include:
|
||||
|
||||
* Demonstrating empathy and kindness toward other people
|
||||
* Being respectful of differing opinions, viewpoints, and experiences
|
||||
* Giving and gracefully accepting constructive feedback
|
||||
* Accepting responsibility and apologizing to those affected by our mistakes,
|
||||
and learning from the experience
|
||||
* Focusing on what is best not just for us as individuals, but for the
|
||||
overall community
|
||||
|
||||
Examples of unacceptable behavior include:
|
||||
|
||||
* The use of sexualized language or imagery, and sexual attention or
|
||||
advances of any kind
|
||||
* Trolling, insulting or derogatory comments, and personal or political attacks
|
||||
* Public or private harassment
|
||||
* Publishing others' private information, such as a physical or email
|
||||
address, without their explicit permission
|
||||
* Other conduct which could reasonably be considered inappropriate in a
|
||||
professional setting
|
||||
|
||||
## Enforcement Responsibilities
|
||||
|
||||
Community leaders are responsible for clarifying and enforcing our standards of
|
||||
acceptable behavior and will take appropriate and fair corrective action in
|
||||
response to any behavior that they deem inappropriate, threatening, offensive,
|
||||
or harmful.
|
||||
|
||||
Community leaders have the right and responsibility to remove, edit, or reject
|
||||
comments, commits, code, wiki edits, issues, and other contributions that are
|
||||
not aligned to this Code of Conduct, and will communicate reasons for moderation
|
||||
decisions when appropriate.
|
||||
|
||||
## Scope
|
||||
|
||||
This Code of Conduct applies within all community spaces, and also applies when
|
||||
an individual is officially representing the community in public spaces.
|
||||
Examples of representing our community include using an official e-mail address,
|
||||
posting via an official social media account, or acting as an appointed
|
||||
representative at an online or offline event.
|
||||
|
||||
## Enforcement
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
||||
reported to the community leaders responsible for enforcement at
|
||||
support AT inventree DOR org.
|
||||
All complaints will be reviewed and investigated promptly and fairly.
|
||||
|
||||
All community leaders are obligated to respect the privacy and security of the
|
||||
reporter of any incident.
|
||||
|
||||
## Enforcement Guidelines
|
||||
|
||||
Community leaders will follow these Community Impact Guidelines in determining
|
||||
the consequences for any action they deem in violation of this Code of Conduct:
|
||||
|
||||
### 1. Correction
|
||||
|
||||
**Community Impact**: Use of inappropriate language or other behavior deemed
|
||||
unprofessional or unwelcome in the community.
|
||||
|
||||
**Consequence**: A private, written warning from community leaders, providing
|
||||
clarity around the nature of the violation and an explanation of why the
|
||||
behavior was inappropriate. A public apology may be requested.
|
||||
|
||||
### 2. Warning
|
||||
|
||||
**Community Impact**: A violation through a single incident or series
|
||||
of actions.
|
||||
|
||||
**Consequence**: A warning with consequences for continued behavior. No
|
||||
interaction with the people involved, including unsolicited interaction with
|
||||
those enforcing the Code of Conduct, for a specified period of time. This
|
||||
includes avoiding interactions in community spaces as well as external channels
|
||||
like social media. Violating these terms may lead to a temporary or
|
||||
permanent ban.
|
||||
|
||||
### 3. Temporary Ban
|
||||
|
||||
**Community Impact**: A serious violation of community standards, including
|
||||
sustained inappropriate behavior.
|
||||
|
||||
**Consequence**: A temporary ban from any sort of interaction or public
|
||||
communication with the community for a specified period of time. No public or
|
||||
private interaction with the people involved, including unsolicited interaction
|
||||
with those enforcing the Code of Conduct, is allowed during this period.
|
||||
Violating these terms may lead to a permanent ban.
|
||||
|
||||
### 4. Permanent Ban
|
||||
|
||||
**Community Impact**: Demonstrating a pattern of violation of community
|
||||
standards, including sustained inappropriate behavior, harassment of an
|
||||
individual, or aggression toward or disparagement of classes of individuals.
|
||||
|
||||
**Consequence**: A permanent ban from any sort of public interaction within
|
||||
the community.
|
||||
|
||||
## Attribution
|
||||
|
||||
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
|
||||
version 2.0, available at
|
||||
https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
|
||||
|
||||
Community Impact Guidelines were inspired by [Mozilla's code of conduct
|
||||
enforcement ladder](https://github.com/mozilla/diversity).
|
||||
|
||||
[homepage]: https://www.contributor-covenant.org
|
||||
|
||||
For answers to common questions about this code of conduct, see the FAQ at
|
||||
https://www.contributor-covenant.org/faq. Translations are available at
|
||||
https://www.contributor-covenant.org/translations.
|
||||
@@ -3,48 +3,4 @@
|
||||
Hi there, thank you for your interest in contributing!
|
||||
Please read our contribution guidelines, before submitting your first pull request to the InvenTree codebase.
|
||||
|
||||
### Project File Structure
|
||||
|
||||
The InvenTree project is split into two main components: frontend and backend. This source is located in the `src` directory. All other files are used for project management, documentation, and testing.
|
||||
|
||||
```bash
|
||||
InvenTree/
|
||||
├─ .devops/ # Files for Azure DevOps
|
||||
├─ .github/ # Files for GitHub
|
||||
│ ├─ actions/ # Reused actions
|
||||
│ ├─ ISSUE_TEMPLATE/ # Templates for issues and pull requests
|
||||
│ ├─ workflows/ # CI/CD flows
|
||||
│ ├─ scripts/ # CI scripts
|
||||
├─ .vscode/ # Settings for Visual Code IDE
|
||||
├─ assets/ # General project assets
|
||||
├─ contrib/ # Files needed for deployments
|
||||
│ ├─ container/ # Files related to building container images
|
||||
│ ├─ installer/ # Files needed to build single-file installer
|
||||
│ ├─ packager.io/ # Files needed for Debian/Ubuntu packages
|
||||
├─ docs/ # Directory for documentation / General helper files
|
||||
│ ├─ ci/ # CI for documentation
|
||||
│ ├─ docs/ # Source for documentation
|
||||
├─ src/ # Source for application
|
||||
│ ├─ backend/ # Directory for backend parts
|
||||
│ │ ├─ InvenTree/ # Source for backend
|
||||
│ │ ├─ requirements.txt # Dependencies for backend
|
||||
│ │ ├─ package.json # Dependencies for backend HTML linting
|
||||
│ ├─ frontend/ # Directory for frontend parts
|
||||
│ │ ├─ src/ # Source for frontend
|
||||
│ │ │ ├─ main.tsx # Entry point for frontend
|
||||
│ │ ├─ tests/ # Tests for frontend
|
||||
│ │ ├─ netlify.toml # Settings for frontend previews (Netlify)
|
||||
│ │ ├─ package.json # Dependencies for frontend
|
||||
│ │ ├─ playwright.config.ts # Settings for frontend tests
|
||||
│ │ ├─ tsconfig.json # Settings for frontend compilation
|
||||
├─ .pkgr.yml # Build definition for Debian/Ubuntu packages
|
||||
├─ .pre-commit-config.yaml # Code formatter/linter configuration
|
||||
├─ CONTRIBUTING.md # Contribution guidelines and overview
|
||||
├─ Procfile # Process definition for Debian/Ubuntu packages
|
||||
├─ README.md # General project information and overview
|
||||
├─ runtime.txt # Python runtime settings for Debian/Ubuntu packages build
|
||||
├─ SECURITY.md # Project security policy
|
||||
├─ tasks.py # Action definitions for development, testing and deployment
|
||||
```
|
||||
|
||||
Refer to our [contribution guidelines](https://docs.inventree.org/en/latest/develop/contributing/) for more information!
|
||||
|
||||
167
Dockerfile
Normal file
167
Dockerfile
Normal file
@@ -0,0 +1,167 @@
|
||||
# The InvenTree dockerfile provides two build targets:
|
||||
#
|
||||
# production:
|
||||
# - Required files are copied into the image
|
||||
# - Runs InvenTree web server under gunicorn
|
||||
#
|
||||
# dev:
|
||||
# - Expects source directories to be loaded as a run-time volume
|
||||
# - Runs InvenTree web server under django development server
|
||||
# - Monitors source files for any changes, and live-reloads server
|
||||
|
||||
ARG base_image=python:3.11-alpine3.18
|
||||
FROM ${base_image} as inventree_base
|
||||
|
||||
# Build arguments for this image
|
||||
ARG commit_tag=""
|
||||
ARG commit_hash=""
|
||||
ARG commit_date=""
|
||||
|
||||
ARG data_dir="data"
|
||||
|
||||
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"
|
||||
|
||||
# InvenTree paths
|
||||
ENV INVENTREE_HOME="/home/inventree"
|
||||
ENV INVENTREE_MNG_DIR="${INVENTREE_HOME}/InvenTree"
|
||||
ENV INVENTREE_DATA_DIR="${INVENTREE_HOME}/${data_dir}"
|
||||
ENV INVENTREE_STATIC_ROOT="${INVENTREE_DATA_DIR}/static"
|
||||
ENV INVENTREE_MEDIA_ROOT="${INVENTREE_DATA_DIR}/media"
|
||||
ENV INVENTREE_BACKUP_DIR="${INVENTREE_DATA_DIR}/backup"
|
||||
ENV INVENTREE_PLUGIN_DIR="${INVENTREE_DATA_DIR}/plugins"
|
||||
|
||||
# InvenTree configuration files
|
||||
ENV INVENTREE_CONFIG_FILE="${INVENTREE_DATA_DIR}/config.yaml"
|
||||
ENV INVENTREE_SECRET_KEY_FILE="${INVENTREE_DATA_DIR}/secret_key.txt"
|
||||
ENV INVENTREE_PLUGIN_FILE="${INVENTREE_DATA_DIR}/plugins.txt"
|
||||
|
||||
# Worker configuration (can be altered by user)
|
||||
ENV INVENTREE_GUNICORN_WORKERS="4"
|
||||
ENV INVENTREE_BACKGROUND_WORKERS="4"
|
||||
|
||||
# Default web server address:port
|
||||
ENV INVENTREE_WEB_ADDR=0.0.0.0
|
||||
ENV INVENTREE_WEB_PORT=8000
|
||||
|
||||
LABEL org.label-schema.schema-version="1.0" \
|
||||
org.label-schema.build-date=${DATE} \
|
||||
org.label-schema.vendor="inventree" \
|
||||
org.label-schema.name="inventree/inventree" \
|
||||
org.label-schema.url="https://hub.docker.com/r/inventree/inventree" \
|
||||
org.label-schema.vcs-url="https://github.com/inventree/InvenTree.git" \
|
||||
org.label-schema.vcs-ref=${commit_tag}
|
||||
|
||||
# Install required system level packages
|
||||
RUN apk add --no-cache \
|
||||
git gettext py-cryptography \
|
||||
# Image format support
|
||||
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 \
|
||||
# Postgres client
|
||||
postgresql13-client \
|
||||
# MySQL / MariaDB client
|
||||
mariadb-client mariadb-connector-c \
|
||||
&& \
|
||||
# fonts
|
||||
apk --update --upgrade --no-cache add fontconfig ttf-freefont font-noto terminus-font && fc-cache -f
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
RUN mkdir -p ${INVENTREE_HOME}
|
||||
WORKDIR ${INVENTREE_HOME}
|
||||
|
||||
COPY ./docker/requirements.txt base_requirements.txt
|
||||
COPY ./requirements.txt ./
|
||||
COPY ./docker/install_build_packages.sh .
|
||||
RUN chmod +x install_build_packages.sh
|
||||
|
||||
# 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 [ `apk --print-arch` = "armv7" ]; then \
|
||||
printf "[global]\nextra-index-url=https://www.piwheels.org/simple\n" > /etc/pip.conf ; \
|
||||
fi
|
||||
|
||||
COPY tasks.py docker/gunicorn.conf.py docker/init.sh ./
|
||||
RUN chmod +x init.sh
|
||||
|
||||
ENTRYPOINT ["/bin/ash", "./init.sh"]
|
||||
|
||||
FROM inventree_base as prebuild
|
||||
|
||||
ENV PATH=/root/.local/bin:$PATH
|
||||
RUN ./install_build_packages.sh --no-cache --virtual .build-deps && \
|
||||
pip install --user -r base_requirements.txt -r requirements.txt --no-cache && \
|
||||
apk --purge del .build-deps
|
||||
|
||||
# Frontend builder image:
|
||||
FROM prebuild 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
|
||||
# - Starts a gunicorn webserver
|
||||
FROM inventree_base as production
|
||||
|
||||
ENV INVENTREE_DEBUG=False
|
||||
|
||||
# As .git directory is not available in production image, we pass the commit information via ENV
|
||||
ENV INVENTREE_COMMIT_HASH="${commit_hash}"
|
||||
ENV INVENTREE_COMMIT_DATE="${commit_date}"
|
||||
|
||||
# use dependencies and compiled wheels from the prebuild image
|
||||
ENV PATH=/root/.local/bin:$PATH
|
||||
COPY --from=prebuild /root/.local /root/.local
|
||||
|
||||
# Copy source code
|
||||
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
|
||||
|
||||
# Vite server (for local frontend development)
|
||||
EXPOSE 5173
|
||||
|
||||
# Install packages required for building python packages
|
||||
RUN ./install_build_packages.sh
|
||||
|
||||
RUN pip install uv --no-cache-dir && pip install -r base_requirements.txt --no-cache
|
||||
|
||||
# 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
|
||||
|
||||
ENV INVENTREE_DEBUG=True
|
||||
|
||||
# Location for python virtual environment
|
||||
# If the INVENTREE_PY_ENV variable is set, the entrypoint script will use it!
|
||||
ENV INVENTREE_PY_ENV="${INVENTREE_DATA_DIR}/env"
|
||||
|
||||
WORKDIR ${INVENTREE_HOME}
|
||||
|
||||
# Entrypoint ensures that we are running in the python virtual environment
|
||||
ENTRYPOINT ["/bin/ash", "./docker/init.sh"]
|
||||
|
||||
# Launch the development server
|
||||
CMD ["invoke", "server", "-a", "${INVENTREE_WEB_ADDR}:${INVENTREE_WEB_PORT}"]
|
||||
113
InvenTree/InvenTree/admin.py
Normal file
113
InvenTree/InvenTree/admin.py
Normal file
@@ -0,0 +1,113 @@
|
||||
"""Admin classes."""
|
||||
|
||||
from django.contrib import admin
|
||||
from django.db.models.fields import CharField
|
||||
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
|
||||
|
||||
|
||||
class InvenTreeResource(ModelResource):
|
||||
"""Custom subclass of the ModelResource class provided by django-import-export".
|
||||
|
||||
Ensures that exported data are escaped to prevent malicious formula injection.
|
||||
Ref: https://owasp.org/www-community/attacks/CSV_Injection
|
||||
"""
|
||||
|
||||
MAX_IMPORT_ROWS = 1000
|
||||
MAX_IMPORT_COLS = 100
|
||||
|
||||
# List of fields which should be converted to empty strings if they are null
|
||||
CONVERT_NULL_FIELDS = []
|
||||
|
||||
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 behavior.
|
||||
|
||||
Specifically, strip illegal leading characters to prevent formula injection
|
||||
"""
|
||||
row = super().export_resource(obj)
|
||||
|
||||
illegal_start_vals = ['@', '=', '+', '-', '@', '\t', '\r', '\n']
|
||||
|
||||
for idx, val in enumerate(row):
|
||||
if type(val) is str:
|
||||
val = val.strip()
|
||||
|
||||
# If the value starts with certain 'suspicious' values, remove it!
|
||||
while len(val) > 0 and val[0] in illegal_start_vals:
|
||||
# Remove the first character
|
||||
val = val[1:]
|
||||
|
||||
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]
|
||||
|
||||
def before_import_row(self, row, row_number=None, **kwargs):
|
||||
"""Run custom code before importing each row.
|
||||
|
||||
- Convert any null fields to empty strings, for fields which do not support null values
|
||||
"""
|
||||
# We can automatically determine which fields might need such a conversion
|
||||
for field in self.Meta.model._meta.fields:
|
||||
if isinstance(field, CharField):
|
||||
if field.blank and not field.null:
|
||||
if field.name not in self.CONVERT_NULL_FIELDS:
|
||||
self.CONVERT_NULL_FIELDS.append(field.name)
|
||||
|
||||
for field in self.CONVERT_NULL_FIELDS:
|
||||
if field in row and row[field] is None:
|
||||
row[field] = ''
|
||||
|
||||
|
||||
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)
|
||||
500
InvenTree/InvenTree/api.py
Normal file
500
InvenTree/InvenTree/api.py
Normal file
@@ -0,0 +1,500 @@
|
||||
"""Main JSON interface views."""
|
||||
|
||||
import sys
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import transaction
|
||||
from django.http import JsonResponse
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from django_q.models import OrmQ
|
||||
from drf_spectacular.utils import OpenApiResponse, extend_schema
|
||||
from rest_framework import permissions, serializers
|
||||
from rest_framework.generics import GenericAPIView
|
||||
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 InvenTree.templatetags.inventree_extras import plugins_info
|
||||
from part.models import Part
|
||||
from plugin.serializers import MetadataSerializer
|
||||
from users.models import ApiToken
|
||||
|
||||
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 VersionViewSerializer(serializers.Serializer):
|
||||
"""Serializer for a single version."""
|
||||
|
||||
class VersionSerializer(serializers.Serializer):
|
||||
"""Serializer for server version."""
|
||||
|
||||
server = serializers.CharField()
|
||||
api = serializers.IntegerField()
|
||||
commit_hash = serializers.CharField()
|
||||
commit_date = serializers.CharField()
|
||||
commit_branch = serializers.CharField()
|
||||
python = serializers.CharField()
|
||||
django = serializers.CharField()
|
||||
|
||||
class LinkSerializer(serializers.Serializer):
|
||||
"""Serializer for all possible links."""
|
||||
|
||||
doc = serializers.URLField()
|
||||
code = serializers.URLField()
|
||||
credit = serializers.URLField()
|
||||
app = serializers.URLField()
|
||||
bug = serializers.URLField()
|
||||
|
||||
dev = serializers.BooleanField()
|
||||
up_to_date = serializers.BooleanField()
|
||||
version = VersionSerializer()
|
||||
links = LinkSerializer()
|
||||
|
||||
|
||||
class VersionView(APIView):
|
||||
"""Simple JSON endpoint for InvenTree version information."""
|
||||
|
||||
permission_classes = [permissions.IsAdminUser]
|
||||
|
||||
@extend_schema(responses={200: OpenApiResponse(response=VersionViewSerializer)})
|
||||
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."""
|
||||
|
||||
serializer_class = VersionSerializer
|
||||
|
||||
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.
|
||||
|
||||
Use to confirm that the server is running, etc.
|
||||
"""
|
||||
|
||||
permission_classes = [permissions.AllowAny]
|
||||
|
||||
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': 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,
|
||||
'plugins_install_disabled': settings.PLUGINS_INSTALL_DISABLED,
|
||||
'active_plugins': plugins_info(),
|
||||
'email_configured': is_email_configured(),
|
||||
'debug_mode': settings.DEBUG,
|
||||
'docker_mode': settings.DOCKER,
|
||||
'default_locale': settings.LANGUAGE_CODE,
|
||||
# Following fields are only available to staff users
|
||||
'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."""
|
||||
from InvenTree.middleware import get_token_from_request
|
||||
|
||||
if token := get_token_from_request(request):
|
||||
# Does the provided token match a valid user?
|
||||
try:
|
||||
token = ApiToken.objects.get(key=token)
|
||||
|
||||
# Check if the token is active and the user is a staff member
|
||||
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 not_found(self, request):
|
||||
"""Return a 404 error."""
|
||||
return JsonResponse(
|
||||
{
|
||||
'detail': _('API endpoint not found'),
|
||||
'url': request.build_absolute_uri(),
|
||||
},
|
||||
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:
|
||||
"""Mixin class for enabling 'bulk delete' operations for various models.
|
||||
|
||||
Bulk delete allows for multiple items to be deleted in a single API query,
|
||||
rather than using multiple API calls to the various detail endpoints.
|
||||
|
||||
This is implemented for two major reasons:
|
||||
- Atomicity (guaranteed that either *all* items are deleted, or *none*)
|
||||
- Speed (single API call and DB query)
|
||||
"""
|
||||
|
||||
def filter_delete_queryset(self, queryset, request):
|
||||
"""Provide custom filtering for the queryset *before* it is deleted."""
|
||||
return queryset
|
||||
|
||||
def delete(self, request, *args, **kwargs):
|
||||
"""Perform a DELETE operation against this list endpoint.
|
||||
|
||||
We expect a list of primary-key (ID) values to be supplied as a JSON object, e.g.
|
||||
{
|
||||
items: [4, 8, 15, 16, 23, 42]
|
||||
}
|
||||
|
||||
"""
|
||||
model = self.serializer_class.Meta.model
|
||||
|
||||
# Extract the items from the request body
|
||||
try:
|
||||
items = request.data.getlist('items', None)
|
||||
except AttributeError:
|
||||
items = request.data.get('items', None)
|
||||
|
||||
# Extract the filters from the request body
|
||||
try:
|
||||
filters = request.data.getlist('filters', None)
|
||||
except AttributeError:
|
||||
filters = request.data.get('filters', None)
|
||||
|
||||
if not items and not filters:
|
||||
raise ValidationError({
|
||||
'non_field_errors': [
|
||||
'List of items or filters must be provided for bulk deletion'
|
||||
]
|
||||
})
|
||||
|
||||
if items and type(items) is not list:
|
||||
raise ValidationError({
|
||||
'items': ["'items' must be supplied as a list object"]
|
||||
})
|
||||
|
||||
if filters and type(filters) is not dict:
|
||||
raise ValidationError({
|
||||
'filters': ["'filters' must be supplied as a dict object"]
|
||||
})
|
||||
|
||||
# Keep track of how many items we deleted
|
||||
n_deleted = 0
|
||||
|
||||
with transaction.atomic():
|
||||
# Start with *all* models and perform basic filtering
|
||||
queryset = model.objects.all()
|
||||
queryset = self.filter_delete_queryset(queryset, request)
|
||||
|
||||
# Filter by provided item ID values
|
||||
if items:
|
||||
queryset = queryset.filter(id__in=items)
|
||||
|
||||
# Filter by provided filters
|
||||
if filters:
|
||||
queryset = queryset.filter(**filters)
|
||||
|
||||
n_deleted = queryset.count()
|
||||
queryset.delete()
|
||||
|
||||
return Response({'success': f'Deleted {n_deleted} items'}, status=204)
|
||||
|
||||
|
||||
class ListCreateDestroyAPIView(BulkDeleteMixin, ListCreateAPI):
|
||||
"""Custom API endpoint which provides BulkDelete functionality in addition to List and Create."""
|
||||
|
||||
...
|
||||
|
||||
|
||||
class APIDownloadMixin:
|
||||
"""Mixin for enabling a LIST endpoint to be downloaded a file.
|
||||
|
||||
To download the data, add the ?export=<fmt> to the query string.
|
||||
|
||||
The implementing class must provided a download_queryset method,
|
||||
e.g.
|
||||
|
||||
def download_queryset(self, queryset, export_format):
|
||||
dataset = StockItemResource().export(queryset=queryset)
|
||||
|
||||
filedata = dataset.export(export_format)
|
||||
|
||||
filename = 'InvenTree_Stocktake_{date}.{fmt}'.format(
|
||||
date=datetime.now().strftime("%d-%b-%Y"),
|
||||
fmt=export_format
|
||||
)
|
||||
|
||||
return DownloadFile(filedata, filename)
|
||||
"""
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
"""Generic handler for a download request."""
|
||||
export_format = request.query_params.get('export', None)
|
||||
|
||||
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)
|
||||
# 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."""
|
||||
raise NotImplementedError('download_queryset method not implemented!')
|
||||
|
||||
|
||||
class AttachmentMixin:
|
||||
"""Mixin for creating attachment objects, and ensuring the user information is saved correctly."""
|
||||
|
||||
permission_classes = [permissions.IsAuthenticated, RolePermission]
|
||||
|
||||
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()
|
||||
attachment.user = self.request.user
|
||||
attachment.save()
|
||||
|
||||
|
||||
class APISearchViewSerializer(serializers.Serializer):
|
||||
"""Serializer for the APISearchView."""
|
||||
|
||||
search = serializers.CharField()
|
||||
search_regex = serializers.BooleanField(default=False, required=False)
|
||||
search_whole = serializers.BooleanField(default=False, required=False)
|
||||
limit = serializers.IntegerField(default=1, required=False)
|
||||
offset = serializers.IntegerField(default=0, required=False)
|
||||
|
||||
|
||||
class APISearchView(GenericAPIView):
|
||||
"""A general-purpose 'search' API endpoint.
|
||||
|
||||
Returns hits against a number of different models simultaneously,
|
||||
to consolidate multiple API requests into a single query.
|
||||
|
||||
Is much more efficient and simplifies code!
|
||||
"""
|
||||
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
serializer_class = APISearchViewSerializer
|
||||
|
||||
def get_result_types(self):
|
||||
"""Construct a list of search types we can return."""
|
||||
import build.api
|
||||
import company.api
|
||||
import order.api
|
||||
import part.api
|
||||
import stock.api
|
||||
|
||||
return {
|
||||
'build': build.api.BuildList,
|
||||
'company': company.api.CompanyList,
|
||||
'manufacturerpart': company.api.ManufacturerPartList,
|
||||
'supplierpart': company.api.SupplierPartList,
|
||||
'part': part.api.PartList,
|
||||
'partcategory': part.api.CategoryList,
|
||||
'purchaseorder': order.api.PurchaseOrderList,
|
||||
'returnorder': order.api.ReturnOrderList,
|
||||
'salesorder': order.api.SalesOrderList,
|
||||
'stockitem': stock.api.StockList,
|
||||
'stocklocation': stock.api.StockLocationList,
|
||||
}
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
"""Perform search query against available models."""
|
||||
data = request.data
|
||||
|
||||
results = {}
|
||||
|
||||
# These parameters are passed through to the individual queries, with optional default values
|
||||
pass_through_params = {
|
||||
'search': '',
|
||||
'search_regex': False,
|
||||
'search_whole': False,
|
||||
'limit': 1,
|
||||
'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:
|
||||
params = data[key]
|
||||
|
||||
for k, v in pass_through_params.items():
|
||||
params[k] = request.data.get(k, v)
|
||||
|
||||
# Enforce json encoding
|
||||
params['format'] = 'json'
|
||||
|
||||
# Ignore if the params are wrong
|
||||
if type(params) is not dict:
|
||||
continue
|
||||
|
||||
view = cls()
|
||||
|
||||
# Override regular query params with specific ones for this search request
|
||||
request._request.GET = params
|
||||
view.request = request
|
||||
view.format_kwarg = 'format'
|
||||
|
||||
# Check permissions and update results dict with particular query
|
||||
model = view.serializer_class.Meta.model
|
||||
app_label = model._meta.app_label
|
||||
model_name = model._meta.model_name
|
||||
table = f'{app_label}_{model_name}'
|
||||
|
||||
try:
|
||||
if users.models.RuleSet.check_table_permission(
|
||||
request.user, table, 'view'
|
||||
):
|
||||
results[key] = view.list(request, *args, **kwargs).data
|
||||
else:
|
||||
results[key] = {
|
||||
'error': _(
|
||||
'User does not have permission to view this model'
|
||||
)
|
||||
}
|
||||
except Exception as exc:
|
||||
results[key] = {'error': str(exc)}
|
||||
|
||||
return Response(results)
|
||||
|
||||
|
||||
class MetadataView(RetrieveUpdateAPI):
|
||||
"""Generic API endpoint for reading and editing metadata for a model."""
|
||||
|
||||
MODEL_REF = 'model'
|
||||
|
||||
def get_model_type(self):
|
||||
"""Return the model type associated with this API instance."""
|
||||
model = self.kwargs.get(self.MODEL_REF, None)
|
||||
|
||||
if model is None:
|
||||
raise ValidationError(
|
||||
f"MetadataView called without '{self.MODEL_REF}' parameter"
|
||||
)
|
||||
|
||||
return model
|
||||
|
||||
def get_permission_model(self):
|
||||
"""Return the 'permission' model associated with this view."""
|
||||
return self.get_model_type()
|
||||
|
||||
def get_queryset(self):
|
||||
"""Return the queryset for this endpoint."""
|
||||
return self.get_model_type().objects.all()
|
||||
|
||||
def get_serializer(self, *args, **kwargs):
|
||||
"""Return MetadataSerializer instance."""
|
||||
# Detect if we are currently generating the OpenAPI schema
|
||||
if 'spectacular' in sys.argv:
|
||||
return MetadataSerializer(Part, *args, **kwargs)
|
||||
return MetadataSerializer(self.get_model_type(), *args, **kwargs)
|
||||
646
InvenTree/InvenTree/api_version.py
Normal file
646
InvenTree/InvenTree/api_version.py
Normal file
@@ -0,0 +1,646 @@
|
||||
"""InvenTree API version information."""
|
||||
|
||||
# InvenTree API version
|
||||
INVENTREE_API_VERSION = 180
|
||||
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
|
||||
|
||||
INVENTREE_API_TEXT = """
|
||||
|
||||
v180 - 2024-3-02 : https://github.com/inventree/InvenTree/pull/6463
|
||||
- Tweaks to API documentation to allow automatic documentation generation
|
||||
|
||||
v179 - 2024-03-01 : https://github.com/inventree/InvenTree/pull/6605
|
||||
- Adds "subcategories" count to PartCategory serializer
|
||||
- Adds "sublocations" count to StockLocation serializer
|
||||
- Adds "image" field to PartBrief serializer
|
||||
- Adds "image" field to CompanyBrief serializer
|
||||
|
||||
v178 - 2024-02-29 : https://github.com/inventree/InvenTree/pull/6604
|
||||
- Adds "external_stock" field to the Part API endpoint
|
||||
- Adds "external_stock" field to the BomItem API endpoint
|
||||
- Adds "external_stock" field to the BuildLine API endpoint
|
||||
- Stock quantites represented in the BuildLine API endpoint are now filtered by Build.source_location
|
||||
|
||||
v177 - 2024-02-27 : https://github.com/inventree/InvenTree/pull/6581
|
||||
- Adds "subcategoies" count to PartCategoryTree serializer
|
||||
- Adds "sublocations" count to StockLocationTree serializer
|
||||
|
||||
v176 - 2024-02-26 : https://github.com/inventree/InvenTree/pull/6535
|
||||
- Adds the field "plugins_install_disabled" to the Server info API endpoint
|
||||
|
||||
v175 - 2024-02-21 : https://github.com/inventree/InvenTree/pull/6538
|
||||
- Adds "parts" count to PartParameterTemplate serializer
|
||||
|
||||
v174 - 2024-02-21 : https://github.com/inventree/InvenTree/pull/6536
|
||||
- Expose PartCategory filters to the API documentation
|
||||
- Expose StockLocation filters to the API documentation
|
||||
|
||||
v173 - 2024-02-20 : https://github.com/inventree/InvenTree/pull/6483
|
||||
- Adds "merge_items" to the PurchaseOrderLine create API endpoint
|
||||
- Adds "auto_pricing" to the PurchaseOrderLine create/update API endpoint
|
||||
|
||||
v172 - 2024-02-20 : https://github.com/inventree/InvenTree/pull/6526
|
||||
- Adds "enabled" field to the PartTestTemplate API endpoint
|
||||
- Adds "enabled" filter to the PartTestTemplate list
|
||||
- Adds "enabled" filter to the StockItemTestResult list
|
||||
|
||||
v171 - 2024-02-19 : https://github.com/inventree/InvenTree/pull/6516
|
||||
- Adds "key" as a filterable parameter to PartTestTemplate list endpoint
|
||||
|
||||
v170 -> 2024-02-19 : https://github.com/inventree/InvenTree/pull/6514
|
||||
- Adds "has_results" filter to the PartTestTemplate list endpoint
|
||||
|
||||
v169 -> 2024-02-14 : https://github.com/inventree/InvenTree/pull/6430
|
||||
- Adds 'key' field to PartTestTemplate API endpoint
|
||||
- Adds annotated 'results' field to PartTestTemplate API endpoint
|
||||
- Adds 'template' field to StockItemTestResult API endpoint
|
||||
|
||||
v168 -> 2024-02-14 : https://github.com/inventree/InvenTree/pull/4824
|
||||
- Adds machine CRUD API endpoints
|
||||
- Adds machine settings API endpoints
|
||||
- Adds machine restart API endpoint
|
||||
- Adds machine types/drivers list API endpoints
|
||||
- Adds machine registry status API endpoint
|
||||
- Adds 'required' field to the global Settings API
|
||||
- Discover sub-sub classes of the StatusCode API
|
||||
|
||||
v167 -> 2024-02-07: https://github.com/inventree/InvenTree/pull/6440
|
||||
- Fixes for OpenAPI schema generation
|
||||
|
||||
v166 -> 2024-02-04 : https://github.com/inventree/InvenTree/pull/6400
|
||||
- Adds package_name to plugin API
|
||||
- Adds mechanism for uninstalling plugins via the API
|
||||
|
||||
v165 -> 2024-01-28 : https://github.com/inventree/InvenTree/pull/6040
|
||||
- Adds supplier_part.name, part.creation_user, part.required_for_sales_order
|
||||
|
||||
v164 -> 2024-01-24 : https://github.com/inventree/InvenTree/pull/6343
|
||||
- Adds "building" quantity to BuildLine API serializer
|
||||
|
||||
v163 -> 2024-01-22 : https://github.com/inventree/InvenTree/pull/6314
|
||||
- Extends API endpoint to expose auth configuration information for signin pages
|
||||
|
||||
v162 -> 2024-01-14 : https://github.com/inventree/InvenTree/pull/6230
|
||||
- Adds API endpoints to provide information on background tasks
|
||||
|
||||
v161 -> 2024-01-13 : https://github.com/inventree/InvenTree/pull/6222
|
||||
- Adds API endpoint for system error information
|
||||
|
||||
v160 -> 2023-12-11 : https://github.com/inventree/InvenTree/pull/6072
|
||||
- Adds API endpoint for allocating stock items against a sales order via barcode scan
|
||||
|
||||
v159 -> 2023-12-08 : https://github.com/inventree/InvenTree/pull/6056
|
||||
- Adds API endpoint for reloading plugin registry
|
||||
|
||||
v158 -> 2023-11-21 : https://github.com/inventree/InvenTree/pull/5953
|
||||
- Adds API endpoint for listing all settings of a particular plugin
|
||||
- Adds API endpoint for registry status (errors)
|
||||
|
||||
v157 -> 2023-12-02 : https://github.com/inventree/InvenTree/pull/6021
|
||||
- Add write-only "existing_image" field to Part API serializer
|
||||
|
||||
v156 -> 2023-11-26 : https://github.com/inventree/InvenTree/pull/5982
|
||||
- Add POST endpoint for report and label creation
|
||||
|
||||
v155 -> 2023-11-24 : https://github.com/inventree/InvenTree/pull/5979
|
||||
- Add "creation_date" field to Part instance serializer
|
||||
|
||||
v154 -> 2023-11-21 : https://github.com/inventree/InvenTree/pull/5944
|
||||
- Adds "responsible" field to the ProjectCode table
|
||||
|
||||
v153 -> 2023-11-21 : https://github.com/inventree/InvenTree/pull/5956
|
||||
- Adds override_min and override_max fields to part pricing API
|
||||
|
||||
v152 -> 2023-11-20 : https://github.com/inventree/InvenTree/pull/5949
|
||||
- Adds barcode support for manufacturerpart model
|
||||
- Adds API endpoint for adding parts to purchase order using barcode scan
|
||||
|
||||
v151 -> 2023-11-13 : https://github.com/inventree/InvenTree/pull/5906
|
||||
- Allow user list API to be filtered by user active status
|
||||
- Allow owner list API to be filtered by user active status
|
||||
|
||||
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
|
||||
|
||||
v122 -> 2023-06-14 : https://github.com/inventree/InvenTree/pull/5034
|
||||
- Adds new BuildLineLabel label type
|
||||
|
||||
v121 -> 2023-06-14 : https://github.com/inventree/InvenTree/pull/4808
|
||||
- Adds "ProjectCode" link to Build model
|
||||
|
||||
v120 -> 2023-06-07 : https://github.com/inventree/InvenTree/pull/4855
|
||||
- Major overhaul of the build order API
|
||||
- Adds new BuildLine model
|
||||
|
||||
v119 -> 2023-06-01 : https://github.com/inventree/InvenTree/pull/4898
|
||||
- Add Metadata to: Part test templates, Part parameters, Part category parameter templates, BOM item substitute, Related Parts, Stock item test result
|
||||
|
||||
v118 -> 2023-06-01 : https://github.com/inventree/InvenTree/pull/4935
|
||||
- Adds extra fields for the PartParameterTemplate model
|
||||
|
||||
v117 -> 2023-05-22 : https://github.com/inventree/InvenTree/pull/4854
|
||||
- Part.units model now supports physical units (e.g. "kg", "m", "mm", etc)
|
||||
- Replaces SupplierPart "pack_size" field with "pack_quantity"
|
||||
- New field supports physical units, and allows for conversion between compatible units
|
||||
|
||||
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
|
||||
- Adds ability to partially scrap a build output
|
||||
|
||||
v114 -> 2023-05-16 : https://github.com/inventree/InvenTree/pull/4825
|
||||
- Adds "delivery_date" to shipments
|
||||
|
||||
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 adding packs
|
||||
|
||||
v111 -> 2023-05-02 : https://github.com/inventree/InvenTree/pull/4367
|
||||
- Adds tags to the Part serializer
|
||||
- Adds tags to the SupplierPart serializer
|
||||
- Adds tags to the ManufacturerPart serializer
|
||||
- Adds tags to the StockItem serializer
|
||||
- Adds tags to the StockLocation serializer
|
||||
|
||||
v110 -> 2023-04-26 : https://github.com/inventree/InvenTree/pull/4698
|
||||
- Adds 'order_currency' field for PurchaseOrder / SalesOrder endpoints
|
||||
|
||||
v109 -> 2023-04-19 : https://github.com/inventree/InvenTree/pull/4636
|
||||
- Adds API endpoints for the "ProjectCode" model
|
||||
|
||||
v108 -> 2023-04-17 : https://github.com/inventree/InvenTree/pull/4615
|
||||
- Adds functionality to upload images for rendering in markdown notes
|
||||
|
||||
v107 -> 2023-04-04 : https://github.com/inventree/InvenTree/pull/4575
|
||||
- Adds barcode support for PurchaseOrder model
|
||||
- Adds barcode support for ReturnOrder model
|
||||
- Adds barcode support for SalesOrder model
|
||||
- Adds barcode support for BuildOrder model
|
||||
|
||||
v106 -> 2023-04-03 : https://github.com/inventree/InvenTree/pull/4566
|
||||
- Adds 'search_regex' parameter to all searchable API endpoints
|
||||
|
||||
v105 -> 2023-03-31 : https://github.com/inventree/InvenTree/pull/4543
|
||||
- Adds API endpoints for status label information on various models
|
||||
|
||||
v104 -> 2023-03-23 : https://github.com/inventree/InvenTree/pull/4488
|
||||
- Adds various endpoints for new "ReturnOrder" models
|
||||
- Adds various endpoints for new "ReturnOrderReport" templates
|
||||
- Exposes API endpoints for "Contact" model
|
||||
|
||||
v103 -> 2023-03-17 : https://github.com/inventree/InvenTree/pull/4410
|
||||
- Add metadata to several more models
|
||||
|
||||
v102 -> 2023-03-18 : https://github.com/inventree/InvenTree/pull/4505
|
||||
- Adds global search API endpoint for consolidated search results
|
||||
|
||||
v101 -> 2023-03-07 : https://github.com/inventree/InvenTree/pull/4462
|
||||
- Adds 'total_in_stock' to Part serializer, and supports API ordering
|
||||
|
||||
v100 -> 2023-03-04 : https://github.com/inventree/InvenTree/pull/4452
|
||||
- Adds bulk delete of PurchaseOrderLineItems to API
|
||||
|
||||
v99 -> 2023-03-03 : https://github.com/inventree/InvenTree/pull/4445
|
||||
- Adds sort by "responsible" to PurchaseOrderAPI
|
||||
|
||||
v98 -> 2023-02-24 : https://github.com/inventree/InvenTree/pull/4408
|
||||
- Adds "responsible" filter to Build API
|
||||
|
||||
v97 -> 2023-02-20 : https://github.com/inventree/InvenTree/pull/4377
|
||||
- Adds "external" attribute to StockLocation model
|
||||
|
||||
v96 -> 2023-02-16 : https://github.com/inventree/InvenTree/pull/4345
|
||||
- Adds stocktake report generation functionality
|
||||
|
||||
v95 -> 2023-02-16 : https://github.com/inventree/InvenTree/pull/4346
|
||||
- Adds "CompanyAttachment" model (and associated API endpoints)
|
||||
|
||||
v94 -> 2023-02-10 : https://github.com/inventree/InvenTree/pull/4327
|
||||
- Adds API endpoints for the "Group" auth model
|
||||
|
||||
v93 -> 2023-02-03 : https://github.com/inventree/InvenTree/pull/4300
|
||||
- Adds extra information to the currency exchange endpoint
|
||||
- Adds API endpoint for manually updating exchange rates
|
||||
|
||||
v92 -> 2023-02-02 : https://github.com/inventree/InvenTree/pull/4293
|
||||
- Adds API endpoint for currency exchange information
|
||||
|
||||
v91 -> 2023-01-31 : https://github.com/inventree/InvenTree/pull/4281
|
||||
- Improves the API endpoint for creating new Part instances
|
||||
|
||||
v90 -> 2023-01-25 : https://github.com/inventree/InvenTree/pull/4186/files
|
||||
- Adds a dedicated endpoint to activate a plugin
|
||||
|
||||
v89 -> 2023-01-25 : https://github.com/inventree/InvenTree/pull/4214
|
||||
- Adds updated field to SupplierPart API
|
||||
- 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
|
||||
|
||||
v87 -> 2023-01-04 : https://github.com/inventree/InvenTree/pull/4067
|
||||
- Add API date filter for stock table on Expiry date
|
||||
|
||||
v86 -> 2022-12-22 : https://github.com/inventree/InvenTree/pull/4069
|
||||
- Adds API endpoints for part stocktake
|
||||
|
||||
v85 -> 2022-12-21 : https://github.com/inventree/InvenTree/pull/3858
|
||||
- Add endpoints serving ICS calendars for purchase and sales orders through API
|
||||
|
||||
v84 -> 2022-12-21: https://github.com/inventree/InvenTree/pull/4083
|
||||
- Add support for listing PO, BO, SO by their reference
|
||||
|
||||
v83 -> 2022-11-19 : https://github.com/inventree/InvenTree/pull/3949
|
||||
- Add support for structural Stock locations
|
||||
|
||||
v82 -> 2022-11-16 : https://github.com/inventree/InvenTree/pull/3931
|
||||
- Add support for structural Part categories
|
||||
|
||||
v81 -> 2022-11-08 : https://github.com/inventree/InvenTree/pull/3710
|
||||
- Adds cached pricing information to Part API
|
||||
- Adds cached pricing information to BomItem API
|
||||
- Allows Part and BomItem list endpoints to be filtered by 'has_pricing'
|
||||
- Remove calculated 'price_string' values from API endpoints
|
||||
- Allows PurchaseOrderLineItem API endpoint to be filtered by 'has_pricing'
|
||||
- Allows SalesOrderLineItem API endpoint to be filtered by 'has_pricing'
|
||||
- Allows SalesOrderLineItem API endpoint to be filtered by 'order_status'
|
||||
- Adds more information to SupplierPriceBreak serializer
|
||||
|
||||
v80 -> 2022-11-07 : https://github.com/inventree/InvenTree/pull/3906
|
||||
- Adds 'barcode_hash' to Part API serializer
|
||||
- Adds 'barcode_hash' to StockLocation API serializer
|
||||
- Adds 'barcode_hash' to SupplierPart API serializer
|
||||
|
||||
v79 -> 2022-11-03 : https://github.com/inventree/InvenTree/pull/3895
|
||||
- Add metadata to Company
|
||||
|
||||
v78 -> 2022-10-25 : https://github.com/inventree/InvenTree/pull/3854
|
||||
- Make PartCategory to be filtered by name and description
|
||||
|
||||
v77 -> 2022-10-12 : https://github.com/inventree/InvenTree/pull/3772
|
||||
- Adds model permission checks for barcode assignment actions
|
||||
|
||||
v76 -> 2022-09-10 : https://github.com/inventree/InvenTree/pull/3640
|
||||
- Refactor of barcode data on the API
|
||||
- StockItem.uid renamed to StockItem.barcode_hash
|
||||
|
||||
v75 -> 2022-09-05 : https://github.com/inventree/InvenTree/pull/3644
|
||||
- Adds "pack_size" attribute to SupplierPart API serializer
|
||||
|
||||
v74 -> 2022-08-28 : https://github.com/inventree/InvenTree/pull/3615
|
||||
- Add confirmation field for completing PurchaseOrder if the order has incomplete lines
|
||||
- Add confirmation field for completing SalesOrder if the order has incomplete lines
|
||||
|
||||
v73 -> 2022-08-24 : https://github.com/inventree/InvenTree/pull/3605
|
||||
- Add 'description' field to PartParameterTemplate model
|
||||
|
||||
v72 -> 2022-08-18 : https://github.com/inventree/InvenTree/pull/3567
|
||||
- Allow PurchaseOrder to be duplicated via the API
|
||||
|
||||
v71 -> 2022-08-18 : https://github.com/inventree/InvenTree/pull/3564
|
||||
- Updates to the "part scheduling" API endpoint
|
||||
|
||||
v70 -> 2022-08-02 : https://github.com/inventree/InvenTree/pull/3451
|
||||
- Adds a 'depth' parameter to the PartCategory list API
|
||||
- Adds a 'depth' parameter to the StockLocation list API
|
||||
|
||||
v69 -> 2022-08-01 : https://github.com/inventree/InvenTree/pull/3443
|
||||
- Updates the PartCategory list API:
|
||||
- Improve query efficiency: O(n) becomes O(1)
|
||||
- Rename 'parts' field to 'part_count'
|
||||
- Updates the StockLocation list API:
|
||||
- Improve query efficiency: O(n) becomes O(1)
|
||||
|
||||
v68 -> 2022-07-27 : https://github.com/inventree/InvenTree/pull/3417
|
||||
- Allows SupplierPart list to be filtered by SKU value
|
||||
- Allows SupplierPart list to be filtered by MPN value
|
||||
|
||||
v67 -> 2022-07-25 : https://github.com/inventree/InvenTree/pull/3395
|
||||
- Adds a 'requirements' endpoint for Part instance
|
||||
- Provides information on outstanding order requirements for a given part
|
||||
|
||||
v66 -> 2022-07-24 : https://github.com/inventree/InvenTree/pull/3393
|
||||
- Part images can now be downloaded from a remote URL via the API
|
||||
- Company images can now be downloaded from a remote URL via the API
|
||||
|
||||
v65 -> 2022-07-15 : https://github.com/inventree/InvenTree/pull/3335
|
||||
- Annotates 'in_stock' quantity to the SupplierPart API
|
||||
|
||||
v64 -> 2022-07-08 : https://github.com/inventree/InvenTree/pull/3310
|
||||
- Annotate 'on_order' quantity to BOM list API
|
||||
- Allow BOM List API endpoint to be filtered by "on_order" parameter
|
||||
|
||||
v63 -> 2022-07-06 : https://github.com/inventree/InvenTree/pull/3301
|
||||
- Allow BOM List API endpoint to be filtered by "available_stock" parameter
|
||||
|
||||
v62 -> 2022-07-05 : https://github.com/inventree/InvenTree/pull/3296
|
||||
- Allows search on BOM List API endpoint
|
||||
- Allows ordering on BOM List API endpoint
|
||||
|
||||
v61 -> 2022-06-12 : https://github.com/inventree/InvenTree/pull/3183
|
||||
- Migrate the "Convert Stock Item" form class to use the API
|
||||
- There is now an API endpoint for converting a stock item to a valid variant
|
||||
|
||||
v60 -> 2022-06-08 : https://github.com/inventree/InvenTree/pull/3148
|
||||
- Add availability data fields to the SupplierPart model
|
||||
|
||||
v59 -> 2022-06-07 : https://github.com/inventree/InvenTree/pull/3154
|
||||
- Adds further improvements to BulkDelete mixin class
|
||||
- Fixes multiple bugs in custom OPTIONS metadata implementation
|
||||
- Adds 'bulk delete' for Notifications
|
||||
|
||||
v58 -> 2022-06-06 : https://github.com/inventree/InvenTree/pull/3146
|
||||
- Adds a BulkDelete API mixin class for fast, safe deletion of multiple objects with a single API request
|
||||
|
||||
v57 -> 2022-06-05 : https://github.com/inventree/InvenTree/pull/3130
|
||||
- Transfer PartCategoryTemplateParameter actions to the API
|
||||
|
||||
v56 -> 2022-06-02 : https://github.com/inventree/InvenTree/pull/3123
|
||||
- Expose the PartParameterTemplate model to use the API
|
||||
|
||||
v55 -> 2022-06-02 : https://github.com/inventree/InvenTree/pull/3120
|
||||
- Converts the 'StockItemReturn' functionality to make use of the API
|
||||
|
||||
v54 -> 2022-06-02 : https://github.com/inventree/InvenTree/pull/3117
|
||||
- Adds 'available_stock' annotation on the SalesOrderLineItem API
|
||||
- Adds (well, fixes) 'overdue' annotation on the SalesOrderLineItem API
|
||||
|
||||
v53 -> 2022-06-01 : https://github.com/inventree/InvenTree/pull/3110
|
||||
- Adds extra search fields to the BuildOrder list API endpoint
|
||||
|
||||
v52 -> 2022-05-31 : https://github.com/inventree/InvenTree/pull/3103
|
||||
- Allow part list API to be searched by supplier SKU
|
||||
|
||||
v51 -> 2022-05-24 : https://github.com/inventree/InvenTree/pull/3058
|
||||
- Adds new fields to the SalesOrderShipment model
|
||||
|
||||
v50 -> 2022-05-18 : https://github.com/inventree/InvenTree/pull/2912
|
||||
- Implement Attachments for manufacturer parts
|
||||
|
||||
v49 -> 2022-05-09 : https://github.com/inventree/InvenTree/pull/2957
|
||||
- Allows filtering of plugin list by 'active' status
|
||||
- Allows filtering of plugin list by 'mixin' support
|
||||
- Adds endpoint to "identify" or "locate" stock items and locations (using plugins)
|
||||
|
||||
v48 -> 2022-05-12 : https://github.com/inventree/InvenTree/pull/2977
|
||||
- Adds "export to file" functionality for PurchaseOrder API endpoint
|
||||
- Adds "export to file" functionality for SalesOrder API endpoint
|
||||
- Adds "export to file" functionality for BuildOrder API endpoint
|
||||
|
||||
v47 -> 2022-05-10 : https://github.com/inventree/InvenTree/pull/2964
|
||||
- Fixes barcode API error response when scanning a StockItem which does not exist
|
||||
- Fixes barcode API error response when scanning a StockLocation which does not exist
|
||||
|
||||
v46 -> 2022-05-09
|
||||
- Fixes read permissions on settings API
|
||||
- Allows non-staff users to read global settings via the API
|
||||
|
||||
v45 -> 2022-05-08 : https://github.com/inventree/InvenTree/pull/2944
|
||||
- Settings are now accessed via the API using their unique key, not their PK
|
||||
- This allows the settings to be accessed without prior knowledge of the PK
|
||||
|
||||
v44 -> 2022-05-04 : https://github.com/inventree/InvenTree/pull/2931
|
||||
- Converting more server-side rendered forms to the API
|
||||
- Exposes more core functionality to API endpoints
|
||||
|
||||
v43 -> 2022-04-26 : https://github.com/inventree/InvenTree/pull/2875
|
||||
- Adds API detail endpoint for PartSalePrice model
|
||||
- Adds API detail endpoint for PartInternalPrice model
|
||||
|
||||
v42 -> 2022-04-26 : https://github.com/inventree/InvenTree/pull/2833
|
||||
- Adds variant stock information to the Part and BomItem serializers
|
||||
|
||||
v41 -> 2022-04-26
|
||||
- Fixes 'variant_of' filter for Part list endpoint
|
||||
|
||||
v40 -> 2022-04-19
|
||||
- Adds ability to filter StockItem list by "tracked" parameter
|
||||
- This checks the serial number or batch code fields
|
||||
|
||||
v39 -> 2022-04-18
|
||||
- Adds ability to filter StockItem list by "has_batch" parameter
|
||||
|
||||
v38 -> 2022-04-14 : https://github.com/inventree/InvenTree/pull/2828
|
||||
- Adds the ability to include stock test results for "installed items"
|
||||
|
||||
v37 -> 2022-04-07 : https://github.com/inventree/InvenTree/pull/2806
|
||||
- Adds extra stock availability information to the BomItem serializer
|
||||
|
||||
v36 -> 2022-04-03
|
||||
- Adds ability to filter part list endpoint by unallocated_stock argument
|
||||
|
||||
v35 -> 2022-04-01 : https://github.com/inventree/InvenTree/pull/2797
|
||||
- Adds stock allocation information to the Part API
|
||||
- Adds calculated field for "unallocated_quantity"
|
||||
|
||||
v34 -> 2022-03-25
|
||||
- Change permissions for "plugin list" API endpoint (now allows any authenticated user)
|
||||
|
||||
v33 -> 2022-03-24
|
||||
- Adds "plugins_enabled" information to root API endpoint
|
||||
|
||||
v32 -> 2022-03-19
|
||||
- Adds "parameters" detail to Part API endpoint (use ¶meters=true)
|
||||
- Adds ability to filter PartParameterTemplate API by Part instance
|
||||
- Adds ability to filter PartParameterTemplate API by PartCategory instance
|
||||
|
||||
v31 -> 2022-03-14
|
||||
- Adds "updated" field to SupplierPriceBreakList and SupplierPriceBreakDetail API endpoints
|
||||
|
||||
v30 -> 2022-03-09
|
||||
- Adds "exclude_location" field to BuildAutoAllocation API endpoint
|
||||
- Allows BuildItem API endpoint to be filtered by BomItem relation
|
||||
|
||||
v29 -> 2022-03-08
|
||||
- Adds "scheduling" endpoint for predicted stock scheduling information
|
||||
|
||||
v28 -> 2022-03-04
|
||||
- Adds an API endpoint for auto allocation of stock items against a build order
|
||||
- Ref: https://github.com/inventree/InvenTree/pull/2713
|
||||
|
||||
v27 -> 2022-02-28
|
||||
- Adds target_date field to individual line items for purchase orders and sales orders
|
||||
|
||||
v26 -> 2022-02-17
|
||||
- Adds API endpoint for uploading a BOM file and extracting data
|
||||
|
||||
v25 -> 2022-02-17
|
||||
- Adds ability to filter "part" list endpoint by "in_bom_for" argument
|
||||
|
||||
v24 -> 2022-02-10
|
||||
- Adds API endpoint for deleting (cancelling) build order outputs
|
||||
|
||||
v23 -> 2022-02-02
|
||||
- Adds API endpoints for managing plugin classes
|
||||
- Adds API endpoints for managing plugin settings
|
||||
|
||||
v22 -> 2021-12-20
|
||||
- Adds API endpoint to "merge" multiple stock items
|
||||
|
||||
v21 -> 2021-12-04
|
||||
- Adds support for multiple "Shipments" against a SalesOrder
|
||||
- Refactors process for stock allocation against a SalesOrder
|
||||
|
||||
v20 -> 2021-12-03
|
||||
- Adds ability to filter POLineItem endpoint by "base_part"
|
||||
- Adds optional "order_detail" to POLineItem list endpoint
|
||||
|
||||
v19 -> 2021-12-02
|
||||
- Adds the ability to filter the StockItem API by "part_tree"
|
||||
- Returns only stock items which match a particular part.tree_id field
|
||||
|
||||
v18 -> 2021-11-15
|
||||
- Adds the ability to filter BomItem API by "uses" field
|
||||
- This returns a list of all BomItems which "use" the specified part
|
||||
- Includes inherited BomItem objects
|
||||
|
||||
v17 -> 2021-11-09
|
||||
- Adds API endpoints for GLOBAL and USER settings objects
|
||||
- Ref: https://github.com/inventree/InvenTree/pull/2275
|
||||
|
||||
v16 -> 2021-10-17
|
||||
- Adds API endpoint for completing build order outputs
|
||||
|
||||
v15 -> 2021-10-06
|
||||
- Adds detail endpoint for SalesOrderAllocation model
|
||||
- Allows use of the API forms interface for adjusting SalesOrderAllocation objects
|
||||
|
||||
v14 -> 2021-10-05
|
||||
- Stock adjustment actions API is improved, using native DRF serializer support
|
||||
- However adjustment actions now only support 'pk' as a lookup field
|
||||
|
||||
v13 -> 2021-10-05
|
||||
- Adds API endpoint to allocate stock items against a BuildOrder
|
||||
- Updates StockItem API with improved filtering against BomItem data
|
||||
|
||||
v12 -> 2021-09-07
|
||||
- Adds API endpoint to receive stock items against a PurchaseOrder
|
||||
|
||||
v11 -> 2021-08-26
|
||||
- Adds "units" field to PartBriefSerializer
|
||||
- This allows units to be introspected from the "part_detail" field in the StockItem serializer
|
||||
|
||||
v10 -> 2021-08-23
|
||||
- Adds "purchase_price_currency" to StockItem serializer
|
||||
- Adds "purchase_price_string" to StockItem serializer
|
||||
- Purchase price is now writable for StockItem serializer
|
||||
|
||||
v9 -> 2021-08-09
|
||||
- Adds "price_string" to part pricing serializers
|
||||
|
||||
v8 -> 2021-07-19
|
||||
- Refactors the API interface for SupplierPart and ManufacturerPart models
|
||||
- ManufacturerPart objects can no longer be created via the SupplierPart API endpoint
|
||||
|
||||
v7 -> 2021-07-03
|
||||
- Introduced the concept of "API forms" in https://github.com/inventree/InvenTree/pull/1716
|
||||
- API OPTIONS endpoints provide comprehensive field metedata
|
||||
- Multiple new API endpoints added for database models
|
||||
|
||||
v6 -> 2021-06-23
|
||||
- Part and Company images can now be directly uploaded via the REST API
|
||||
|
||||
v5 -> 2021-06-21
|
||||
- Adds API interface for manufacturer part parameters
|
||||
|
||||
v4 -> 2021-06-01
|
||||
- BOM items can now accept "variant stock" to be assigned against them
|
||||
- Many slight API tweaks were needed to get this to work properly!
|
||||
|
||||
v3 -> 2021-05-22:
|
||||
- The updated StockItem "history tracking" now uses a different interface
|
||||
|
||||
"""
|
||||
368
InvenTree/InvenTree/apps.py
Normal file
368
InvenTree/InvenTree/apps.py
Normal file
@@ -0,0 +1,368 @@
|
||||
"""AppConfig for InvenTree app."""
|
||||
|
||||
import logging
|
||||
from importlib import import_module
|
||||
from pathlib import Path
|
||||
|
||||
from django.apps import AppConfig, apps
|
||||
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, OperationalError
|
||||
|
||||
import InvenTree.conversion
|
||||
import InvenTree.ready
|
||||
import InvenTree.tasks
|
||||
from InvenTree.config import get_setting
|
||||
|
||||
logger = logging.getLogger('inventree')
|
||||
|
||||
|
||||
class InvenTreeConfig(AppConfig):
|
||||
"""AppConfig for inventree app."""
|
||||
|
||||
name = 'InvenTree'
|
||||
|
||||
def ready(self):
|
||||
"""Run system wide setup init steps.
|
||||
|
||||
Like:
|
||||
- Checking if migrations should be run
|
||||
- Cleaning up tasks
|
||||
- Starting regular tasks
|
||||
- Updating exchange rates
|
||||
- Collecting notification methods
|
||||
- Collecting state transition 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 InvenTree.ready.isPluginRegistryLoaded()
|
||||
or not InvenTree.ready.isInMainThread()
|
||||
):
|
||||
return
|
||||
|
||||
# Skip if running migrations
|
||||
if InvenTree.ready.isRunningMigrations():
|
||||
return
|
||||
|
||||
if InvenTree.ready.canAppAccessDatabase() or settings.TESTING_ENV:
|
||||
self.remove_obsolete_tasks()
|
||||
|
||||
self.collect_tasks()
|
||||
self.start_background_tasks()
|
||||
|
||||
if not InvenTree.ready.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.update_site_url()
|
||||
self.collect_notification_methods()
|
||||
self.collect_state_transition_methods()
|
||||
|
||||
# Ensure the unit registry is loaded
|
||||
InvenTree.conversion.get_unit_registry()
|
||||
|
||||
if InvenTree.ready.canAppAccessDatabase() or settings.TESTING_ENV:
|
||||
self.add_user_on_startup()
|
||||
self.add_user_from_file()
|
||||
|
||||
def remove_obsolete_tasks(self):
|
||||
"""Delete any obsolete scheduled tasks in the database."""
|
||||
obsolete = [
|
||||
'InvenTree.tasks.delete_expired_sessions',
|
||||
'stock.tasks.delete_old_stock_items',
|
||||
]
|
||||
|
||||
try:
|
||||
from django_q.models import Schedule
|
||||
except AppRegistryNotReady: # pragma: no cover
|
||||
return
|
||||
|
||||
# Remove any existing obsolete tasks
|
||||
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__}'
|
||||
|
||||
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))
|
||||
|
||||
self.add_heartbeat()
|
||||
|
||||
logger.info('Started %s scheduled background tasks...', len(tasks))
|
||||
|
||||
def add_heartbeat(self):
|
||||
"""Ensure there is at least one background task in the queue."""
|
||||
import django_q.models
|
||||
|
||||
try:
|
||||
if django_q.models.OrmQ.objects.count() == 0:
|
||||
InvenTree.tasks.offload_task(
|
||||
InvenTree.tasks.heartbeat, force_async=True
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def collect_tasks(self):
|
||||
"""Collect all background tasks."""
|
||||
for app_name, app in apps.app_configs.items():
|
||||
if app_name == 'InvenTree':
|
||||
continue
|
||||
|
||||
if Path(app.path).joinpath('tasks.py').exists():
|
||||
try:
|
||||
import_module(f'{app.module.__package__}.tasks')
|
||||
except Exception as e: # pragma: no cover
|
||||
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.
|
||||
|
||||
Only runs *if*:
|
||||
a) Have not been updated recently (one day or less)
|
||||
b) The base exchange rate has been altered
|
||||
"""
|
||||
try:
|
||||
from djmoney.contrib.exchange.models import ExchangeBackend
|
||||
|
||||
from common.settings import currency_code_default
|
||||
from InvenTree.tasks import update_exchange_rates
|
||||
except AppRegistryNotReady: # pragma: no cover
|
||||
pass
|
||||
|
||||
base_currency = currency_code_default()
|
||||
|
||||
update = False
|
||||
|
||||
try:
|
||||
backend = ExchangeBackend.objects.filter(name='InvenTreeExchange')
|
||||
|
||||
if backend.exists():
|
||||
backend = backend.first()
|
||||
|
||||
last_update = backend.last_update
|
||||
|
||||
if last_update is None:
|
||||
# Never been updated
|
||||
logger.info('Exchange backend has never been updated')
|
||||
update = True
|
||||
|
||||
# Backend currency has changed?
|
||||
if base_currency != backend.base_currency:
|
||||
logger.info(
|
||||
'Base currency changed from %s to %s',
|
||||
backend.base_currency,
|
||||
base_currency,
|
||||
)
|
||||
update = True
|
||||
|
||||
except ExchangeBackend.DoesNotExist:
|
||||
logger.info('Exchange backend not found - updating')
|
||||
update = True
|
||||
|
||||
except Exception:
|
||||
# Some other error - potentially the tables are not ready yet
|
||||
return
|
||||
|
||||
if update:
|
||||
try:
|
||||
update_exchange_rates()
|
||||
except OperationalError:
|
||||
logger.warning('Could not update exchange rates - database not ready')
|
||||
except Exception as e:
|
||||
logger.exception('Error updating exchange rates: %s (%s)', e, type(e))
|
||||
|
||||
def update_site_url(self):
|
||||
"""Update the site URL setting.
|
||||
|
||||
- If a fixed SITE_URL is specified (via configuration), it should override the INVENTREE_BASE_URL setting
|
||||
- If multi-site support is enabled, update the site URL for the current site
|
||||
"""
|
||||
import common.models
|
||||
|
||||
if not InvenTree.ready.canAppAccessDatabase():
|
||||
return
|
||||
|
||||
if InvenTree.ready.isImportingData() or InvenTree.ready.isRunningMigrations():
|
||||
return
|
||||
|
||||
if settings.SITE_URL:
|
||||
try:
|
||||
if (
|
||||
common.models.InvenTreeSetting.get_setting('INVENTREE_BASE_URL')
|
||||
!= settings.SITE_URL
|
||||
):
|
||||
common.models.InvenTreeSetting.set_setting(
|
||||
'INVENTREE_BASE_URL', settings.SITE_URL
|
||||
)
|
||||
logger.info('Updated INVENTREE_SITE_URL to %s', settings.SITE_URL)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# If multi-site support is enabled, update the site URL for the current site
|
||||
try:
|
||||
from django.contrib.sites.models import Site
|
||||
|
||||
site = Site.objects.get_current()
|
||||
site.domain = settings.SITE_URL
|
||||
site.save()
|
||||
|
||||
logger.info('Updated current site URL to %s', settings.SITE_URL)
|
||||
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def add_user_on_startup(self):
|
||||
"""Add a user on startup."""
|
||||
# stop if checks were already created
|
||||
if hasattr(settings, 'USER_ADDED') and settings.USER_ADDED:
|
||||
return
|
||||
|
||||
# get values
|
||||
add_user = get_setting('INVENTREE_ADMIN_USER', 'admin_user')
|
||||
add_email = get_setting('INVENTREE_ADMIN_EMAIL', 'admin_email')
|
||||
add_password = get_setting('INVENTREE_ADMIN_PASSWORD', 'admin_password')
|
||||
add_password_file = get_setting(
|
||||
'INVENTREE_ADMIN_PASSWORD_FILE', 'admin_password_file', None
|
||||
)
|
||||
|
||||
# check if all values are present
|
||||
set_variables = 0
|
||||
|
||||
for tested_var in [add_user, add_email, add_password]:
|
||||
if tested_var:
|
||||
set_variables += 1
|
||||
|
||||
# no variable set -> do not try anything
|
||||
if set_variables == 0:
|
||||
settings.USER_ADDED = True
|
||||
return
|
||||
|
||||
# not all needed variables set
|
||||
if set_variables < 3:
|
||||
settings.USER_ADDED = True
|
||||
|
||||
# if a password file is present, do not warn - will be handled later
|
||||
if add_password_file:
|
||||
return
|
||||
logger.warning(
|
||||
'Not all required settings for adding a user on startup are present:\nINVENTREE_ADMIN_USER, INVENTREE_ADMIN_EMAIL, INVENTREE_ADMIN_PASSWORD'
|
||||
)
|
||||
return
|
||||
|
||||
# good to go -> create user
|
||||
self._create_admin_user(add_user, add_email, add_password)
|
||||
|
||||
# do not try again
|
||||
settings.USER_ADDED = True
|
||||
|
||||
def _create_admin_user(self, add_user, add_email, add_password):
|
||||
user = get_user_model()
|
||||
try:
|
||||
with transaction.atomic():
|
||||
if user.objects.filter(username=add_user).exists():
|
||||
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('User %s was created!', str(new_user))
|
||||
except IntegrityError:
|
||||
logger.warning('The user "%s" could not be created', add_user)
|
||||
|
||||
def add_user_from_file(self):
|
||||
"""Add the superuser from a file."""
|
||||
# stop if checks were already created
|
||||
if hasattr(settings, 'USER_ADDED_FILE') and settings.USER_ADDED_FILE:
|
||||
return
|
||||
|
||||
# get values
|
||||
add_password_file = get_setting(
|
||||
'INVENTREE_ADMIN_PASSWORD_FILE', 'admin_password_file', None
|
||||
)
|
||||
|
||||
# no variable set -> do not try anything
|
||||
if not add_password_file:
|
||||
settings.USER_ADDED_FILE = True
|
||||
return
|
||||
|
||||
# check if file exists
|
||||
add_password_file = Path(str(add_password_file))
|
||||
if not add_password_file.exists():
|
||||
logger.warning('The file "%s" does not exist', add_password_file)
|
||||
settings.USER_ADDED_FILE = True
|
||||
return
|
||||
|
||||
# good to go -> create user
|
||||
self._create_admin_user(
|
||||
get_setting('INVENTREE_ADMIN_USER', 'admin_user', 'admin'),
|
||||
get_setting('INVENTREE_ADMIN_EMAIL', 'admin_email', ''),
|
||||
add_password_file.read_text(encoding='utf-8'),
|
||||
)
|
||||
|
||||
# do not try again
|
||||
settings.USER_ADDED_FILE = True
|
||||
|
||||
def collect_notification_methods(self):
|
||||
"""Collect all notification methods."""
|
||||
from common.notifications import storage
|
||||
|
||||
storage.collect()
|
||||
|
||||
def collect_state_transition_methods(self):
|
||||
"""Collect all state transition methods."""
|
||||
from generic.states import storage
|
||||
|
||||
storage.collect()
|
||||
85
InvenTree/InvenTree/backends.py
Normal file
85
InvenTree/InvenTree/backends.py
Normal file
@@ -0,0 +1,85 @@
|
||||
"""Custom backend implementations."""
|
||||
|
||||
import datetime
|
||||
import logging
|
||||
import time
|
||||
|
||||
from django.db.utils import IntegrityError, OperationalError, ProgrammingError
|
||||
|
||||
from maintenance_mode.backends import AbstractStateBackend
|
||||
|
||||
import common.models
|
||||
|
||||
logger = logging.getLogger('inventree')
|
||||
|
||||
|
||||
class InvenTreeMaintenanceModeBackend(AbstractStateBackend):
|
||||
"""Custom backend for managing state of maintenance mode.
|
||||
|
||||
Stores a timestamp in the database to determine when maintenance mode will elapse.
|
||||
"""
|
||||
|
||||
SETTING_KEY = '_MAINTENANCE_MODE'
|
||||
|
||||
def get_value(self) -> bool:
|
||||
"""Get the current state of the maintenance mode.
|
||||
|
||||
Returns:
|
||||
bool: True if maintenance mode is active, False otherwise.
|
||||
"""
|
||||
try:
|
||||
setting = common.models.InvenTreeSetting.objects.get(key=self.SETTING_KEY)
|
||||
value = str(setting.value).strip()
|
||||
except common.models.InvenTreeSetting.DoesNotExist:
|
||||
# Database is accessible, but setting is not available - assume False
|
||||
return False
|
||||
except (IntegrityError, OperationalError, ProgrammingError):
|
||||
# Database is inaccessible - assume we are not in maintenance mode
|
||||
logger.debug('Failed to read maintenance mode state - assuming True')
|
||||
return True
|
||||
|
||||
# Extract timestamp from string
|
||||
try:
|
||||
# If the timestamp is in the past, we are now *out* of maintenance mode
|
||||
timestamp = datetime.datetime.fromisoformat(value)
|
||||
return timestamp > datetime.datetime.now()
|
||||
except ValueError:
|
||||
# If the value is not a valid timestamp, assume maintenance mode is not active
|
||||
return False
|
||||
|
||||
def set_value(self, value: bool, retries: int = 5, minutes: int = 5):
|
||||
"""Set the state of the maintenance mode.
|
||||
|
||||
Instead of simply writing "true" or "false" to the setting,
|
||||
we write a timestamp to the setting, which is used to determine
|
||||
when maintenance mode will elapse.
|
||||
This ensures that we will always *exit* maintenance mode after a certain time period.
|
||||
"""
|
||||
logger.debug('Setting maintenance mode state: %s', value)
|
||||
|
||||
if value:
|
||||
# Save as isoformat
|
||||
timestamp = datetime.datetime.now() + datetime.timedelta(minutes=minutes)
|
||||
timestamp = timestamp.isoformat()
|
||||
else:
|
||||
# Blank timestamp means maintenance mode is not active
|
||||
timestamp = ''
|
||||
|
||||
while retries > 0:
|
||||
try:
|
||||
common.models.InvenTreeSetting.set_setting(self.SETTING_KEY, timestamp)
|
||||
|
||||
# Read the value back to confirm
|
||||
if self.get_value() == value:
|
||||
break
|
||||
except (IntegrityError, OperationalError, ProgrammingError):
|
||||
# In the database is locked, then
|
||||
logger.debug(
|
||||
'Failed to set maintenance mode state (%s retries left)', retries
|
||||
)
|
||||
time.sleep(0.1)
|
||||
|
||||
retries -= 1
|
||||
|
||||
if retries == 0:
|
||||
logger.warning('Failed to set maintenance mode state')
|
||||
71
InvenTree/InvenTree/ci_render_js.py
Normal file
71
InvenTree/InvenTree/ci_render_js.py
Normal file
@@ -0,0 +1,71 @@
|
||||
"""Pull rendered copies of the templated.
|
||||
|
||||
Only used for testing the js files! - This file is omitted from coverage.
|
||||
"""
|
||||
|
||||
import os # pragma: no cover
|
||||
import pathlib # pragma: no cover
|
||||
|
||||
from InvenTree.unit_test import InvenTreeTestCase # pragma: no cover
|
||||
|
||||
|
||||
class RenderJavascriptFiles(InvenTreeTestCase): # pragma: no cover
|
||||
"""A unit test to "render" javascript files.
|
||||
|
||||
The server renders templated javascript files,
|
||||
we need the fully-rendered files for linting and static tests.
|
||||
"""
|
||||
|
||||
def download_file(self, filename, prefix):
|
||||
"""Function to `download`(copy) a file to a temporary firectory."""
|
||||
url = os.path.join(prefix, filename)
|
||||
|
||||
response = self.client.get(url)
|
||||
|
||||
here = os.path.abspath(os.path.dirname(__file__))
|
||||
|
||||
output_dir = os.path.join(here, '..', '..', 'js_tmp')
|
||||
|
||||
output_dir = os.path.abspath(output_dir)
|
||||
|
||||
if not os.path.exists(output_dir):
|
||||
os.mkdir(output_dir)
|
||||
|
||||
output_file = os.path.join(output_dir, filename)
|
||||
|
||||
with open(output_file, 'wb') as output:
|
||||
output.write(response.content)
|
||||
|
||||
def download_files(self, subdir, prefix):
|
||||
"""Download files in directory."""
|
||||
here = os.path.abspath(os.path.dirname(__file__))
|
||||
|
||||
js_template_dir = os.path.join(here, '..', 'templates', 'js')
|
||||
|
||||
directory = os.path.join(js_template_dir, subdir)
|
||||
|
||||
directory = os.path.abspath(directory)
|
||||
|
||||
js_files = pathlib.Path(directory).rglob('*.js')
|
||||
|
||||
n = 0
|
||||
|
||||
for f in js_files:
|
||||
js = os.path.basename(f)
|
||||
|
||||
self.download_file(js, prefix)
|
||||
|
||||
n += 1
|
||||
|
||||
return n
|
||||
|
||||
def test_render_files(self):
|
||||
"""Look for all javascript files."""
|
||||
n = 0
|
||||
|
||||
print('Rendering javascript files...')
|
||||
|
||||
n += self.download_files('translated', '/js/i18n')
|
||||
n += self.download_files('dynamic', '/js/dynamic')
|
||||
|
||||
print(f'Rendered {n} javascript files.')
|
||||
453
InvenTree/InvenTree/config.py
Normal file
453
InvenTree/InvenTree/config.py
Normal file
@@ -0,0 +1,453 @@
|
||||
"""Helper functions for loading InvenTree configuration options."""
|
||||
|
||||
import datetime
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import random
|
||||
import shutil
|
||||
import string
|
||||
import warnings
|
||||
from pathlib import Path
|
||||
|
||||
from django.core.files.base import ContentFile
|
||||
from django.core.files.storage import Storage
|
||||
|
||||
logger = logging.getLogger('inventree')
|
||||
CONFIG_DATA = None
|
||||
CONFIG_LOOKUPS = {}
|
||||
|
||||
|
||||
def to_list(value, delimiter=','):
|
||||
"""Take a configuration setting and make sure it is a list.
|
||||
|
||||
For example, we might have a configuration setting taken from the .config file,
|
||||
which is already a list.
|
||||
|
||||
However, the same setting may be specified via an environment variable,
|
||||
using a comma delimited string!
|
||||
"""
|
||||
if type(value) in [list, tuple]:
|
||||
return value
|
||||
|
||||
# Otherwise, force string value
|
||||
value = str(value)
|
||||
|
||||
return [x.strip() for x in value.split(delimiter)]
|
||||
|
||||
|
||||
def to_dict(value):
|
||||
"""Take a configuration setting and make sure it is a dict.
|
||||
|
||||
For example, we might have a configuration setting taken from the .config file,
|
||||
which is already an object/dict.
|
||||
|
||||
However, the same setting may be specified via an environment variable,
|
||||
using a valid JSON string!
|
||||
"""
|
||||
if value is None:
|
||||
return {}
|
||||
|
||||
if isinstance(value, dict):
|
||||
return value
|
||||
|
||||
try:
|
||||
return json.loads(value)
|
||||
except Exception as error:
|
||||
logger.exception(
|
||||
"Failed to parse value '%s' as JSON with error %s. Ensure value is a valid JSON string.",
|
||||
value,
|
||||
error,
|
||||
)
|
||||
return {}
|
||||
|
||||
|
||||
def is_true(x):
|
||||
"""Shortcut function to determine if a value "looks" like a boolean."""
|
||||
return str(x).strip().lower() in ['1', 'y', 'yes', 't', 'true', 'on']
|
||||
|
||||
|
||||
def get_base_dir() -> Path:
|
||||
"""Returns the base (top-level) InvenTree directory."""
|
||||
return Path(__file__).parent.parent.resolve()
|
||||
|
||||
|
||||
def ensure_dir(path: Path, storage=None) -> None:
|
||||
"""Ensure that a directory exists.
|
||||
|
||||
If it does not exist, create it.
|
||||
"""
|
||||
if storage and isinstance(storage, Storage):
|
||||
if not storage.exists(str(path)):
|
||||
storage.save(str(path / '.empty'), ContentFile(''))
|
||||
return
|
||||
|
||||
if not path.exists():
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
def get_config_file(create=True) -> Path:
|
||||
"""Returns the path of the InvenTree configuration file.
|
||||
|
||||
Note: It will be created it if does not already exist!
|
||||
"""
|
||||
base_dir = get_base_dir()
|
||||
|
||||
cfg_filename = os.getenv('INVENTREE_CONFIG_FILE')
|
||||
|
||||
if cfg_filename:
|
||||
cfg_filename = Path(cfg_filename.strip()).resolve()
|
||||
else:
|
||||
# Config file is *not* specified - use the default
|
||||
cfg_filename = base_dir.joinpath('config.yaml').resolve()
|
||||
|
||||
if not cfg_filename.exists() and create:
|
||||
print(
|
||||
"InvenTree configuration file 'config.yaml' not found - creating default file"
|
||||
)
|
||||
ensure_dir(cfg_filename.parent)
|
||||
|
||||
cfg_template = base_dir.joinpath('config_template.yaml')
|
||||
shutil.copyfile(cfg_template, cfg_filename)
|
||||
print(f'Created config file {cfg_filename}')
|
||||
|
||||
return cfg_filename
|
||||
|
||||
|
||||
def load_config_data(set_cache: bool = False) -> map:
|
||||
"""Load configuration data from the config file.
|
||||
|
||||
Arguments:
|
||||
set_cache(bool): If True, the configuration data will be cached for future use after load.
|
||||
"""
|
||||
global CONFIG_DATA
|
||||
|
||||
# use cache if populated
|
||||
# skip cache if cache should be set
|
||||
if CONFIG_DATA is not None and not set_cache:
|
||||
return CONFIG_DATA
|
||||
|
||||
import yaml
|
||||
|
||||
cfg_file = get_config_file()
|
||||
|
||||
with open(cfg_file, 'r') as cfg:
|
||||
data = yaml.safe_load(cfg)
|
||||
|
||||
# Set the cache if requested
|
||||
if set_cache:
|
||||
CONFIG_DATA = data
|
||||
|
||||
return data
|
||||
|
||||
|
||||
def do_typecast(value, type, var_name=None):
|
||||
"""Attempt to typecast a value.
|
||||
|
||||
Arguments:
|
||||
value: Value to typecast
|
||||
type: Function to use for typecasting the value e.g. int, float, str, list, dict
|
||||
var_name: Name that should be logged e.g. 'INVENTREE_STATIC_ROOT'. Set if logging is required.
|
||||
|
||||
Returns:
|
||||
Typecasted value or original value if typecasting failed.
|
||||
"""
|
||||
# Force 'list' of strings
|
||||
if type is list:
|
||||
value = to_list(value)
|
||||
|
||||
# Valid JSON string is required
|
||||
elif type is dict:
|
||||
value = to_dict(value)
|
||||
|
||||
elif type is not None:
|
||||
# Try to typecast the value
|
||||
try:
|
||||
val = type(value)
|
||||
return val
|
||||
except Exception as error:
|
||||
if var_name:
|
||||
logger.exception(
|
||||
"Failed to typecast '%s' with value '%s' to type '%s' with error %s",
|
||||
var_name,
|
||||
value,
|
||||
type,
|
||||
error,
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
def get_setting(env_var=None, config_key=None, default_value=None, typecast=None):
|
||||
"""Helper function for retrieving a configuration setting value.
|
||||
|
||||
- First preference is to look for the environment variable
|
||||
- Second preference is to look for the value of the settings file
|
||||
- Third preference is the default value
|
||||
|
||||
Arguments:
|
||||
env_var: Name of the environment variable e.g. 'INVENTREE_STATIC_ROOT'
|
||||
config_key: Key to lookup in the configuration file
|
||||
default_value: Value to return if first two options are not provided
|
||||
typecast: Function to use for typecasting the value e.g. int, float, str, list, dict
|
||||
"""
|
||||
|
||||
def set_metadata(source: str):
|
||||
"""Set lookup metadata for the setting."""
|
||||
key = env_var or config_key
|
||||
CONFIG_LOOKUPS[key] = {
|
||||
'env_var': env_var,
|
||||
'config_key': config_key,
|
||||
'source': source,
|
||||
'accessed': datetime.datetime.now(),
|
||||
}
|
||||
|
||||
# First, try to load from the environment variables
|
||||
if env_var is not None:
|
||||
val = os.getenv(env_var, None)
|
||||
|
||||
if val is not None:
|
||||
set_metadata('env')
|
||||
return do_typecast(val, typecast, var_name=env_var)
|
||||
|
||||
# Next, try to load from configuration file
|
||||
if config_key is not None:
|
||||
cfg_data = load_config_data()
|
||||
|
||||
result = None
|
||||
|
||||
# Hack to allow 'path traversal' in configuration file
|
||||
for key in config_key.strip().split('.'):
|
||||
if type(cfg_data) is not dict or key not in cfg_data:
|
||||
result = None
|
||||
break
|
||||
|
||||
result = cfg_data[key]
|
||||
cfg_data = cfg_data[key]
|
||||
|
||||
if result is not None:
|
||||
set_metadata('yaml')
|
||||
return do_typecast(result, typecast, var_name=env_var)
|
||||
|
||||
# Finally, return the default value
|
||||
set_metadata('default')
|
||||
return do_typecast(default_value, typecast, var_name=env_var)
|
||||
|
||||
|
||||
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:
|
||||
raise FileNotFoundError('INVENTREE_MEDIA_ROOT not specified')
|
||||
|
||||
md = Path(md).resolve()
|
||||
|
||||
if create:
|
||||
md.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
return md
|
||||
|
||||
|
||||
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:
|
||||
raise FileNotFoundError('INVENTREE_STATIC_ROOT not specified')
|
||||
|
||||
sd = Path(sd).resolve()
|
||||
|
||||
if create:
|
||||
sd.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
return sd
|
||||
|
||||
|
||||
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:
|
||||
raise FileNotFoundError('INVENTREE_BACKUP_DIR not specified')
|
||||
|
||||
bd = Path(bd).resolve()
|
||||
|
||||
if create:
|
||||
bd.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
return bd
|
||||
|
||||
|
||||
def get_plugin_file():
|
||||
"""Returns the path of the InvenTree plugins specification 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')
|
||||
|
||||
if not plugin_file:
|
||||
# If not specified, look in the same directory as the configuration file
|
||||
config_dir = get_config_file().parent
|
||||
plugin_file = config_dir.joinpath('plugins.txt')
|
||||
else:
|
||||
# Make sure we are using a modern Path object
|
||||
plugin_file = Path(plugin_file)
|
||||
|
||||
if not plugin_file.exists():
|
||||
logger.warning(
|
||||
'Plugin configuration file does not exist - creating default 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
|
||||
plugin_file.write_text(
|
||||
'# InvenTree Plugins (uses PIP framework to install)\n\n'
|
||||
)
|
||||
|
||||
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.
|
||||
|
||||
Following options are tested, in descending order of preference:
|
||||
|
||||
A) Check for environment variable INVENTREE_SECRET_KEY => Use raw key data
|
||||
B) Check for environment variable INVENTREE_SECRET_KEY_FILE => Load key data from file
|
||||
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
|
||||
return secret_key
|
||||
|
||||
# Look for secret key file
|
||||
if secret_key_file := get_setting('INVENTREE_SECRET_KEY_FILE', 'secret_key_file'):
|
||||
secret_key_file = Path(secret_key_file).resolve()
|
||||
else:
|
||||
# Default location for secret key file
|
||||
secret_key_file = get_base_dir().joinpath('secret_key.txt').resolve()
|
||||
|
||||
if not secret_key_file.exists():
|
||||
logger.info("Generating random key file at '%s'", secret_key_file)
|
||||
ensure_dir(secret_key_file.parent)
|
||||
|
||||
# Create a random key file
|
||||
options = string.digits + string.ascii_letters + string.punctuation
|
||||
key = ''.join([random.choice(options) for i in range(100)])
|
||||
secret_key_file.write_text(key)
|
||||
|
||||
logger.debug("Loading SECRET_KEY from '%s'", secret_key_file)
|
||||
|
||||
key_data = secret_key_file.read_text().strip()
|
||||
|
||||
return key_data
|
||||
|
||||
|
||||
def get_custom_file(
|
||||
env_ref: str, conf_ref: str, log_ref: str, lookup_media: bool = False
|
||||
):
|
||||
"""Returns the checked path to a custom file.
|
||||
|
||||
Set lookup_media to True to also search in the media folder.
|
||||
"""
|
||||
from django.contrib.staticfiles.storage import StaticFilesStorage
|
||||
from django.core.files.storage import default_storage
|
||||
|
||||
value = get_setting(env_ref, conf_ref, None)
|
||||
|
||||
if not value:
|
||||
return None
|
||||
|
||||
static_storage = StaticFilesStorage()
|
||||
|
||||
if static_storage.exists(value):
|
||||
logger.info('Loading %s from %s directory: %s', log_ref, 'static', value)
|
||||
elif lookup_media and default_storage.exists(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(
|
||||
"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
|
||||
83
InvenTree/InvenTree/context.py
Normal file
83
InvenTree/InvenTree/context.py
Normal file
@@ -0,0 +1,83 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
"""Provides extra global data to all templates."""
|
||||
|
||||
import InvenTree.email
|
||||
import InvenTree.status
|
||||
from generic.states import StatusCode
|
||||
from InvenTree.helpers import inheritors
|
||||
from users.models import RuleSet, check_user_role
|
||||
|
||||
|
||||
def health_status(request):
|
||||
"""Provide system health status information to the global context.
|
||||
|
||||
- Not required for AJAX requests
|
||||
- Do not provide if it is already provided to the context
|
||||
"""
|
||||
if request.path.endswith('.js'):
|
||||
# Do not provide to script requests
|
||||
return {} # pragma: no cover
|
||||
|
||||
if hasattr(request, '_inventree_health_status'):
|
||||
# Do not duplicate efforts
|
||||
return {}
|
||||
|
||||
request._inventree_health_status = True
|
||||
|
||||
status = {
|
||||
'django_q_running': InvenTree.status.is_worker_running(),
|
||||
'email_configured': InvenTree.email.is_email_configured(),
|
||||
}
|
||||
|
||||
# The following keys are required to denote system health
|
||||
health_keys = ['django_q_running']
|
||||
|
||||
all_healthy = True
|
||||
|
||||
for k in health_keys:
|
||||
if status[k] is not True:
|
||||
all_healthy = False
|
||||
|
||||
status['system_healthy'] = all_healthy
|
||||
|
||||
status['up_to_date'] = InvenTree.version.isInvenTreeUpToDate()
|
||||
|
||||
return status
|
||||
|
||||
|
||||
def status_codes(request):
|
||||
"""Provide status code enumerations."""
|
||||
if hasattr(request, '_inventree_status_codes'):
|
||||
# Do not duplicate efforts
|
||||
return {}
|
||||
|
||||
request._inventree_status_codes = True
|
||||
return {cls.__name__: cls.template_context() for cls in inheritors(StatusCode)}
|
||||
|
||||
|
||||
def user_roles(request):
|
||||
"""Return a map of the current roles assigned to the user.
|
||||
|
||||
Roles are denoted by their simple names, and then the permission type.
|
||||
|
||||
Permissions can be access as follows:
|
||||
|
||||
- roles.part.view
|
||||
- roles.build.delete
|
||||
|
||||
Each value will return a boolean True / False
|
||||
"""
|
||||
user = request.user
|
||||
|
||||
roles = {}
|
||||
|
||||
for role in RuleSet.get_ruleset_models().keys():
|
||||
permissions = {}
|
||||
|
||||
for perm in ['view', 'add', 'change', 'delete']:
|
||||
permissions[perm] = user.is_superuser or check_user_role(user, role, perm)
|
||||
|
||||
roles[role] = permissions
|
||||
|
||||
return {'roles': roles}
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Helper functions for converting between units."""
|
||||
|
||||
import logging
|
||||
import re
|
||||
from typing import Optional
|
||||
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
@@ -10,9 +10,7 @@ import pint
|
||||
|
||||
_unit_registry = None
|
||||
|
||||
import structlog
|
||||
|
||||
logger = structlog.get_logger('inventree')
|
||||
logger = logging.getLogger('inventree')
|
||||
|
||||
|
||||
def get_unit_registry():
|
||||
@@ -41,9 +39,9 @@ def reload_unit_registry():
|
||||
reg = pint.UnitRegistry(autoconvert_offset_to_baseunit=True)
|
||||
|
||||
# Aliases for temperature units
|
||||
reg.define('@alias degC = Celsius')
|
||||
reg.define('@alias degF = Fahrenheit')
|
||||
reg.define('@alias degK = Kelvin')
|
||||
reg.define('@alias degC = celsius = Celsius')
|
||||
reg.define('@alias degF = fahrenheit = Fahrenheit')
|
||||
reg.define('@alias degK = kelvin = Kelvin')
|
||||
|
||||
# Define some "standard" additional units
|
||||
reg.define('piece = 1')
|
||||
@@ -97,7 +95,7 @@ def from_engineering_notation(value):
|
||||
"""
|
||||
value = str(value).strip()
|
||||
|
||||
pattern = r'(\d+)([a-zA-Z]+)(\d+)(.*)'
|
||||
pattern = f'(\d+)([a-zA-Z]+)(\d+)(.*)'
|
||||
|
||||
if match := re.match(pattern, value):
|
||||
left, prefix, right, suffix = match.groups()
|
||||
@@ -135,7 +133,7 @@ def convert_value(value, unit):
|
||||
return value
|
||||
|
||||
|
||||
def convert_physical_value(value: str, unit: Optional[str] = None, strip_units=True):
|
||||
def convert_physical_value(value: str, unit: str = None, strip_units=True):
|
||||
"""Validate that the provided value is a valid physical quantity.
|
||||
|
||||
Arguments:
|
||||
@@ -155,7 +153,7 @@ def convert_physical_value(value: str, unit: Optional[str] = None, strip_units=T
|
||||
if unit:
|
||||
try:
|
||||
valid = unit in ureg
|
||||
except Exception:
|
||||
except Exception as exc:
|
||||
valid = False
|
||||
|
||||
if not valid:
|
||||
@@ -167,13 +165,6 @@ def convert_physical_value(value: str, unit: Optional[str] = None, strip_units=T
|
||||
value = str(value).strip() if value else ''
|
||||
unit = str(unit).strip() if unit else ''
|
||||
|
||||
# Handle imperial length measurements
|
||||
if value.count("'") == 1 and value.endswith("'"):
|
||||
value = value.replace("'", ' feet')
|
||||
|
||||
if value.count('"') == 1 and value.endswith('"'):
|
||||
value = value.replace('"', ' inches')
|
||||
|
||||
# Error on blank values
|
||||
if not value:
|
||||
raise ValidationError(_('No value provided'))
|
||||
@@ -191,21 +182,22 @@ def convert_physical_value(value: str, unit: Optional[str] = None, strip_units=T
|
||||
attempts.append(f'{value}{unit}')
|
||||
attempts.append(f'{eng}{unit}')
|
||||
|
||||
value: Optional[str] = None
|
||||
value = None
|
||||
|
||||
# Run through the available "attempts", take the first successful result
|
||||
for attempt in attempts:
|
||||
try:
|
||||
value = convert_value(attempt, unit)
|
||||
break
|
||||
except Exception:
|
||||
except Exception as exc:
|
||||
value = None
|
||||
pass
|
||||
|
||||
if value is None:
|
||||
if unit:
|
||||
raise ValidationError(_(f'Could not convert {original} to {unit}'))
|
||||
else:
|
||||
raise ValidationError(_('Invalid quantity provided'))
|
||||
raise ValidationError(_('Invalid quantity supplied'))
|
||||
|
||||
# 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
|
||||
@@ -219,7 +211,7 @@ def convert_physical_value(value: str, unit: Optional[str] = None, strip_units=T
|
||||
|
||||
magnitude = float(ureg.Quantity(magnitude).to_base_units().magnitude)
|
||||
except Exception as exc:
|
||||
raise ValidationError(_('Invalid quantity provided') + f': ({exc})')
|
||||
raise ValidationError(_(f'Invalid quantity supplied ({exc})'))
|
||||
|
||||
if strip_units:
|
||||
return magnitude
|
||||
@@ -247,4 +239,8 @@ def is_dimensionless(value):
|
||||
if value.units == ureg.dimensionless:
|
||||
return True
|
||||
|
||||
return value.to_base_units().units == ureg.dimensionless
|
||||
if value.to_base_units().units == ureg.dimensionless:
|
||||
return True
|
||||
|
||||
# At this point, the value is not dimensionless
|
||||
return False
|
||||
89
InvenTree/InvenTree/email.py
Normal file
89
InvenTree/InvenTree/email.py
Normal file
@@ -0,0 +1,89 @@
|
||||
"""Code for managing email functionality in InvenTree."""
|
||||
|
||||
import logging
|
||||
|
||||
from django.conf import settings
|
||||
from django.core import mail as django_mail
|
||||
|
||||
import InvenTree.ready
|
||||
import InvenTree.tasks
|
||||
|
||||
logger = logging.getLogger('inventree')
|
||||
|
||||
|
||||
def is_email_configured():
|
||||
"""Check if email backend is configured.
|
||||
|
||||
NOTE: This does not check if the configuration is valid!
|
||||
"""
|
||||
configured = True
|
||||
testing = settings.TESTING
|
||||
|
||||
if InvenTree.ready.isInTestMode():
|
||||
return False
|
||||
|
||||
if InvenTree.ready.isImportingData():
|
||||
return False
|
||||
|
||||
if not settings.EMAIL_HOST:
|
||||
configured = False
|
||||
|
||||
# Display warning unless in test mode
|
||||
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 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 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 isinstance(recipients, str):
|
||||
recipients = [recipients]
|
||||
|
||||
import InvenTree.ready
|
||||
import InvenTree.status
|
||||
|
||||
if InvenTree.ready.isImportingData():
|
||||
# If we are importing data, don't send emails
|
||||
return
|
||||
|
||||
if not InvenTree.email.is_email_configured() and not settings.TESTING:
|
||||
# Email is not configured / enabled
|
||||
return
|
||||
|
||||
# If a *from_email* is not specified, ensure that the default is set
|
||||
if not from_email:
|
||||
from_email = settings.DEFAULT_FROM_EMAIL
|
||||
|
||||
# If we still don't have a valid from_email, then we can't send emails
|
||||
if not from_email:
|
||||
if settings.TESTING:
|
||||
from_email = 'from@test.com'
|
||||
else:
|
||||
logger.error('send_email failed: DEFAULT_FROM_EMAIL not specified')
|
||||
return
|
||||
|
||||
InvenTree.tasks.offload_task(
|
||||
django_mail.send_mail,
|
||||
subject,
|
||||
body,
|
||||
from_email,
|
||||
recipients,
|
||||
fail_silently=False,
|
||||
html_message=html_message,
|
||||
)
|
||||
129
InvenTree/InvenTree/exceptions.py
Normal file
129
InvenTree/InvenTree/exceptions.py
Normal file
@@ -0,0 +1,129 @@
|
||||
"""Custom exception handling for the DRF API."""
|
||||
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import logging
|
||||
import sys
|
||||
import traceback
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ValidationError as DjangoValidationError
|
||||
from django.db.utils import IntegrityError, OperationalError
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
import rest_framework.views as drfviews
|
||||
from error_report.models import Error
|
||||
from rest_framework import serializers
|
||||
from rest_framework.exceptions import ValidationError as DRFValidationError
|
||||
from rest_framework.response import Response
|
||||
|
||||
import InvenTree.sentry
|
||||
|
||||
logger = logging.getLogger('inventree')
|
||||
|
||||
|
||||
def log_error(path, error_name=None, error_info=None, error_data=None):
|
||||
"""Log an error to the database.
|
||||
|
||||
- Uses python exception handling to extract error details
|
||||
|
||||
Arguments:
|
||||
path: The 'path' (most likely a URL) associated with this error (optional)
|
||||
|
||||
kwargs:
|
||||
error_name: The name of the error (optional, overrides 'kind')
|
||||
error_info: The error information (optional, overrides 'info')
|
||||
error_data: The error data (optional, overrides 'data')
|
||||
"""
|
||||
kind, info, data = sys.exc_info()
|
||||
|
||||
# Check if the error is on the ignore list
|
||||
if kind in settings.IGNORED_ERRORS:
|
||||
return
|
||||
|
||||
if error_name:
|
||||
kind = error_name
|
||||
else:
|
||||
kind = getattr(kind, '__name__', 'Unknown Error')
|
||||
|
||||
if error_info:
|
||||
info = error_info
|
||||
|
||||
if error_data:
|
||||
data = error_data
|
||||
else:
|
||||
try:
|
||||
data = '\n'.join(traceback.format_exception(kind, info, data))
|
||||
except AttributeError:
|
||||
data = 'No traceback information available'
|
||||
|
||||
# Log error to stderr
|
||||
logger.error(info)
|
||||
|
||||
# Ensure the error information does not exceed field size limits
|
||||
path = path[:200]
|
||||
kind = kind[:128]
|
||||
|
||||
try:
|
||||
Error.objects.create(kind=kind, info=info or '', data=data or '', path=path)
|
||||
except Exception:
|
||||
# Not much we can do if logging the error throws a db exception
|
||||
logger.exception('Failed to log exception to database')
|
||||
|
||||
|
||||
def exception_handler(exc, context):
|
||||
"""Custom exception handler for DRF framework.
|
||||
|
||||
Ref: https://www.django-rest-framework.org/api-guide/exceptions/#custom-exception-handling
|
||||
Catches any errors not natively handled by DRF, and re-throws as an error DRF can handle.
|
||||
|
||||
If sentry error reporting is enabled, we will also provide the original exception to sentry.io
|
||||
"""
|
||||
response = None
|
||||
|
||||
# Pass exception to sentry.io handler
|
||||
try:
|
||||
InvenTree.sentry.report_exception(exc)
|
||||
except Exception:
|
||||
# If sentry.io fails, we don't want to crash the server!
|
||||
pass
|
||||
|
||||
# Catch any django validation error, and re-throw a DRF validation error
|
||||
if isinstance(exc, DjangoValidationError):
|
||||
exc = DRFValidationError(detail=serializers.as_serializer_error(exc))
|
||||
|
||||
# Default to the built-in DRF exception handler
|
||||
response = drfviews.exception_handler(exc, context)
|
||||
|
||||
if response is None:
|
||||
# DRF handler did not provide a default response for this exception
|
||||
|
||||
if settings.TESTING:
|
||||
# If in TESTING mode, re-throw the exception for traceback
|
||||
raise exc
|
||||
elif settings.DEBUG:
|
||||
# If in DEBUG mode, provide error information in the response
|
||||
error_detail = str(exc)
|
||||
else:
|
||||
error_detail = _('Error details can be found in the admin panel')
|
||||
|
||||
response_data = {
|
||||
'error': type(exc).__name__,
|
||||
'error_class': str(type(exc)),
|
||||
'detail': error_detail,
|
||||
'path': context['request'].path,
|
||||
'status_code': 500,
|
||||
}
|
||||
|
||||
response = Response(response_data, status=500)
|
||||
|
||||
log_error(context['request'].path)
|
||||
|
||||
if response is not None:
|
||||
# Convert errors returned under the label '__all__' to 'non_field_errors'
|
||||
if '__all__' in response.data:
|
||||
response.data['non_field_errors'] = response.data['__all__']
|
||||
del response.data['__all__']
|
||||
|
||||
return response
|
||||
@@ -1,15 +1,15 @@
|
||||
"""Custom exchange backend which hooks into the InvenTree plugin system to fetch exchange rates from an external API."""
|
||||
|
||||
import logging
|
||||
|
||||
from django.db.transaction import atomic
|
||||
|
||||
import structlog
|
||||
from djmoney.contrib.exchange.backends.base import SimpleExchangeBackend
|
||||
from djmoney.contrib.exchange.models import ExchangeBackend, Rate
|
||||
|
||||
from common.currency import currency_code_default, currency_codes
|
||||
from common.settings import get_global_setting
|
||||
from common.settings import currency_code_default, currency_codes
|
||||
|
||||
logger = structlog.get_logger('inventree')
|
||||
logger = logging.getLogger('inventree')
|
||||
|
||||
|
||||
class InvenTreeExchange(SimpleExchangeBackend):
|
||||
@@ -20,23 +20,25 @@ class InvenTreeExchange(SimpleExchangeBackend):
|
||||
|
||||
name = 'InvenTreeExchange'
|
||||
|
||||
def get_rates(self, **kwargs) -> dict:
|
||||
def get_rates(self, **kwargs) -> None:
|
||||
"""Set the requested currency codes and get rates."""
|
||||
from plugin import PluginMixinEnum, registry
|
||||
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 = get_global_setting('CURRENCY_UPDATE_PLUGIN', create=False)
|
||||
slug = InvenTreeSetting.get_setting('CURRENCY_UPDATE_PLUGIN', '', create=False)
|
||||
|
||||
plugin = registry.get_plugin(slug, active=True) if slug else None
|
||||
if slug:
|
||||
plugin = registry.get_plugin(slug)
|
||||
else:
|
||||
plugin = None
|
||||
|
||||
if not plugin:
|
||||
# Find the first active currency exchange plugin
|
||||
plugins = registry.with_mixin(
|
||||
PluginMixinEnum.CURRENCY_EXCHANGE, active=True
|
||||
)
|
||||
plugins = registry.with_mixin('currencyexchange', active=True)
|
||||
|
||||
if len(plugins) > 0:
|
||||
plugin = plugins[0]
|
||||
213
InvenTree/InvenTree/fields.py
Normal file
213
InvenTree/InvenTree/fields.py
Normal file
@@ -0,0 +1,213 @@
|
||||
"""Custom fields used in InvenTree."""
|
||||
|
||||
import sys
|
||||
from decimal import Decimal
|
||||
|
||||
from django import forms
|
||||
from django.db import models
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from djmoney.forms.fields import MoneyField
|
||||
from djmoney.models.fields import MoneyField as ModelMoneyField
|
||||
from djmoney.models.validators import MinMoneyValidator
|
||||
from rest_framework.fields import URLField as RestURLField
|
||||
from rest_framework.fields import empty
|
||||
|
||||
import InvenTree.helpers
|
||||
|
||||
from .validators import AllowedURLValidator, allowable_url_schemes
|
||||
|
||||
|
||||
class InvenTreeRestURLField(RestURLField):
|
||||
"""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
|
||||
|
||||
super().__init__(**kwargs)
|
||||
self.validators[-1].schemes = allowable_url_schemes()
|
||||
|
||||
def run_validation(self, data=empty):
|
||||
"""Override default validation behaviour for this field type."""
|
||||
import common.models
|
||||
|
||||
strict_urls = common.models.InvenTreeSetting.get_setting(
|
||||
'INVENTREE_STRICT_URLS', True, cache=False
|
||||
)
|
||||
|
||||
if not strict_urls and data is not empty:
|
||||
if '://' not in data:
|
||||
# Validate as if there were a schema provided
|
||||
data = 'http://' + data
|
||||
|
||||
return super().run_validation(data=data)
|
||||
|
||||
|
||||
class InvenTreeURLField(models.URLField):
|
||||
"""Custom URL field which has custom scheme validators."""
|
||||
|
||||
default_validators = [AllowedURLValidator()]
|
||||
|
||||
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(**kwargs):
|
||||
"""Returns the database settings for MoneyFields."""
|
||||
from common.settings import currency_code_default, currency_code_mappings
|
||||
|
||||
# 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
|
||||
|
||||
|
||||
class InvenTreeModelMoneyField(ModelMoneyField):
|
||||
"""Custom MoneyField for clean migrations while using dynamic currency settings."""
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
"""Overwrite default values and validators."""
|
||||
# detect if creating migration
|
||||
if 'migrate' in sys.argv or 'makemigrations' in sys.argv:
|
||||
# remove currency information for a clean migration
|
||||
kwargs['default_currency'] = ''
|
||||
kwargs['currency_choices'] = []
|
||||
|
||||
kwargs = money_kwargs(**kwargs)
|
||||
|
||||
# Set a minimum value validator
|
||||
validators = kwargs.get('validators', [])
|
||||
|
||||
allow_negative = kwargs.pop('allow_negative', False)
|
||||
|
||||
# If no validators are provided, add some "standard" ones
|
||||
if len(validators) == 0:
|
||||
if not allow_negative:
|
||||
validators.append(MinMoneyValidator(0))
|
||||
|
||||
kwargs['validators'] = validators
|
||||
|
||||
super().__init__(**kwargs)
|
||||
|
||||
def formfield(self, **kwargs):
|
||||
"""Override form class to use own function."""
|
||||
kwargs['form_class'] = InvenTreeMoneyField
|
||||
return super().formfield(**kwargs)
|
||||
|
||||
def to_python(self, value):
|
||||
"""Convert value to python type."""
|
||||
value = super().to_python(value)
|
||||
return round_decimal(value, self.decimal_places)
|
||||
|
||||
def prepare_value(self, value):
|
||||
"""Override the 'prepare_value' method, to remove trailing zeros when displaying.
|
||||
|
||||
Why? It looks nice!
|
||||
"""
|
||||
return round_decimal(value, self.decimal_places, normalize=True)
|
||||
|
||||
|
||||
class InvenTreeMoneyField(MoneyField):
|
||||
"""Custom MoneyField for clean migrations while using dynamic currency settings."""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
"""Override initial values with the real info from database."""
|
||||
kwargs = money_kwargs(**kwargs)
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
|
||||
class DatePickerFormField(forms.DateField):
|
||||
"""Custom date-picker field."""
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
"""Set up custom values."""
|
||||
help_text = kwargs.get('help_text', _('Enter date'))
|
||||
label = kwargs.get('label', None)
|
||||
required = kwargs.get('required', False)
|
||||
initial = kwargs.get('initial', None)
|
||||
|
||||
widget = forms.DateInput(attrs={'type': 'date'})
|
||||
|
||||
forms.DateField.__init__(
|
||||
self,
|
||||
required=required,
|
||||
initial=initial,
|
||||
help_text=help_text,
|
||||
widget=widget,
|
||||
label=label,
|
||||
)
|
||||
|
||||
|
||||
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)
|
||||
|
||||
if normalize:
|
||||
# Remove any trailing zeroes
|
||||
value = InvenTree.helpers.normalize(value)
|
||||
|
||||
return value
|
||||
|
||||
|
||||
class RoundingDecimalFormField(forms.DecimalField):
|
||||
"""Custom FormField that automatically rounds inputs."""
|
||||
|
||||
def to_python(self, value):
|
||||
"""Convert value to python type."""
|
||||
value = super().to_python(value)
|
||||
return round_decimal(value, self.decimal_places)
|
||||
|
||||
def prepare_value(self, value):
|
||||
"""Override the 'prepare_value' method, to remove trailing zeros when displaying.
|
||||
|
||||
Why? It looks nice!
|
||||
"""
|
||||
return round_decimal(value, self.decimal_places, normalize=True)
|
||||
|
||||
|
||||
class RoundingDecimalField(models.DecimalField):
|
||||
"""Custom Field that automatically rounds inputs."""
|
||||
|
||||
def to_python(self, value):
|
||||
"""Convert value to python type."""
|
||||
value = super().to_python(value)
|
||||
return round_decimal(value, self.decimal_places)
|
||||
|
||||
def formfield(self, **kwargs):
|
||||
"""Return a Field instance for this field."""
|
||||
kwargs['form_class'] = RoundingDecimalFormField
|
||||
|
||||
return super().formfield(**kwargs)
|
||||
|
||||
|
||||
class InvenTreeNotesField(models.TextField):
|
||||
"""Custom implementation of a 'notes' field."""
|
||||
|
||||
# Maximum character limit for the various 'notes' fields
|
||||
NOTES_MAX_LENGTH = 50000
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
"""Configure default initial values for this field."""
|
||||
kwargs['max_length'] = self.NOTES_MAX_LENGTH
|
||||
kwargs['verbose_name'] = _('Notes')
|
||||
kwargs['blank'] = True
|
||||
kwargs['null'] = True
|
||||
|
||||
super().__init__(**kwargs)
|
||||
169
InvenTree/InvenTree/filters.py
Normal file
169
InvenTree/InvenTree/filters.py
Normal file
@@ -0,0 +1,169 @@
|
||||
"""General filters for InvenTree."""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from django.conf import settings
|
||||
from django.utils import timezone
|
||||
from django.utils.timezone import make_aware
|
||||
|
||||
from django_filters import rest_framework as rest_filters
|
||||
from rest_framework import filters
|
||||
|
||||
import InvenTree.helpers
|
||||
|
||||
|
||||
class InvenTreeDateFilter(rest_filters.DateFilter):
|
||||
"""Custom DateFilter class which handles timezones correctly."""
|
||||
|
||||
def filter(self, qs, value):
|
||||
"""Override the filter method to handle timezones correctly."""
|
||||
if settings.USE_TZ:
|
||||
if value is not None:
|
||||
tz = timezone.get_current_timezone()
|
||||
value = datetime(value.year, value.month, value.day)
|
||||
value = make_aware(value, tz, True)
|
||||
|
||||
return super().filter(qs, value)
|
||||
|
||||
|
||||
class InvenTreeSearchFilter(filters.SearchFilter):
|
||||
"""Custom search filter which allows adjusting of search terms dynamically."""
|
||||
|
||||
def get_search_fields(self, view, request):
|
||||
"""Return a set of search fields for the request, adjusted based on request params.
|
||||
|
||||
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)
|
||||
|
||||
fields = []
|
||||
|
||||
if search_fields:
|
||||
for field in search_fields:
|
||||
if regex:
|
||||
field = '$' + field
|
||||
|
||||
fields.append(field)
|
||||
|
||||
return fields
|
||||
|
||||
def get_search_terms(self, request):
|
||||
"""Return the search terms for this search request.
|
||||
|
||||
Depending on the request parameters, we may "augment" these somewhat
|
||||
"""
|
||||
whole = InvenTree.helpers.str2bool(
|
||||
request.query_params.get('search_whole', False)
|
||||
)
|
||||
|
||||
terms = []
|
||||
|
||||
search_terms = super().get_search_terms(request)
|
||||
|
||||
if search_terms:
|
||||
for term in search_terms:
|
||||
term = term.strip()
|
||||
|
||||
if not term:
|
||||
# Ignore blank inputs
|
||||
continue
|
||||
|
||||
if whole:
|
||||
# Wrap the search term to enable word-boundary matching
|
||||
term = r'\y' + term + r'\y'
|
||||
|
||||
terms.append(term)
|
||||
|
||||
return terms
|
||||
|
||||
|
||||
class InvenTreeOrderingFilter(filters.OrderingFilter):
|
||||
"""Custom OrderingFilter class which allows aliased filtering of related fields.
|
||||
|
||||
To use, simply specify this filter in the "filter_backends" section.
|
||||
|
||||
filter_backends = [
|
||||
InvenTreeOrderingFilter,
|
||||
]
|
||||
|
||||
Then, specify a ordering_field_aliases attribute:
|
||||
|
||||
ordering_field_alises = {
|
||||
'name': 'part__part__name',
|
||||
'SKU': 'part__SKU',
|
||||
}
|
||||
"""
|
||||
|
||||
def get_ordering(self, request, queryset, view):
|
||||
"""Override ordering for supporting aliases."""
|
||||
ordering = super().get_ordering(request, queryset, view)
|
||||
|
||||
aliases = getattr(view, 'ordering_field_aliases', None)
|
||||
|
||||
# Attempt to map ordering fields based on provided aliases
|
||||
if ordering is not None and aliases is not None:
|
||||
"""Ordering fields should be mapped to separate fields."""
|
||||
|
||||
ordering_initial = ordering
|
||||
ordering = []
|
||||
|
||||
for field in ordering_initial:
|
||||
reverse = field.startswith('-')
|
||||
|
||||
if reverse:
|
||||
field = field[1:]
|
||||
|
||||
# Are aliases defined for this field?
|
||||
if field in aliases:
|
||||
alias = aliases[field]
|
||||
else:
|
||||
alias = field
|
||||
|
||||
"""
|
||||
Potentially, a single field could be "aliased" to multiple field,
|
||||
|
||||
(For example to enforce a particular ordering sequence)
|
||||
|
||||
e.g. to filter first by the integer value...
|
||||
|
||||
ordering_field_aliases = {
|
||||
"reference": ["integer_ref", "reference"]
|
||||
}
|
||||
|
||||
"""
|
||||
|
||||
if type(alias) is str:
|
||||
alias = [alias]
|
||||
elif type(alias) in [list, tuple]:
|
||||
pass
|
||||
else:
|
||||
# Unsupported alias type
|
||||
continue
|
||||
|
||||
for a in alias:
|
||||
if reverse:
|
||||
a = '-' + a
|
||||
|
||||
ordering.append(a)
|
||||
|
||||
return ordering
|
||||
|
||||
|
||||
SEARCH_ORDER_FILTER = [
|
||||
rest_filters.DjangoFilterBackend,
|
||||
InvenTreeSearchFilter,
|
||||
filters.OrderingFilter,
|
||||
]
|
||||
|
||||
SEARCH_ORDER_FILTER_ALIAS = [
|
||||
rest_filters.DjangoFilterBackend,
|
||||
InvenTreeSearchFilter,
|
||||
InvenTreeOrderingFilter,
|
||||
]
|
||||
|
||||
ORDER_FILTER = [rest_filters.DjangoFilterBackend, filters.OrderingFilter]
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import re
|
||||
import string
|
||||
from typing import Optional
|
||||
|
||||
from django.conf import settings
|
||||
from django.utils import translation
|
||||
@@ -70,7 +69,7 @@ def construct_format_regex(fmt_string: str) -> str:
|
||||
for group in string.Formatter().parse(fmt_string):
|
||||
prefix = group[0] # Prefix (literal text appearing before this group)
|
||||
name = group[1] # Name of this format variable
|
||||
_fmt = group[2] # Format specifier e.g :04d
|
||||
format = group[2] # Format specifier e.g :04d
|
||||
|
||||
rep = [
|
||||
'+',
|
||||
@@ -107,24 +106,23 @@ def construct_format_regex(fmt_string: str) -> str:
|
||||
# Add a named capture group for the format entry
|
||||
if name:
|
||||
# Check if integer values are required
|
||||
c = '\\d' if _fmt and _fmt.endswith('d') else '.'
|
||||
if format.endswith('d'):
|
||||
chr = '\d'
|
||||
else:
|
||||
chr = '.'
|
||||
|
||||
# Specify width
|
||||
# TODO: Introspect required width
|
||||
w = '+'
|
||||
|
||||
# replace invalid regex group name '?' with a valid name
|
||||
if name == '?':
|
||||
name = 'wild'
|
||||
|
||||
pattern += f'(?P<{name}>{c}{w})'
|
||||
pattern += f'(?P<{name}>{chr}{w})'
|
||||
|
||||
pattern += '$'
|
||||
|
||||
return pattern
|
||||
|
||||
|
||||
def validate_string(value: str, fmt_string: str) -> bool:
|
||||
def validate_string(value: str, fmt_string: str) -> str:
|
||||
"""Validate that the provided string matches the specified format.
|
||||
|
||||
Args:
|
||||
@@ -162,7 +160,7 @@ def extract_named_group(name: str, value: str, fmt_string: str) -> str:
|
||||
"""
|
||||
info = parse_format_string(fmt_string)
|
||||
|
||||
if name not in info:
|
||||
if name not in info.keys():
|
||||
raise NameError(_(f"Value '{name}' does not appear in pattern format"))
|
||||
|
||||
# Construct a regular expression for matching against the provided format string
|
||||
@@ -184,8 +182,8 @@ def extract_named_group(name: str, value: str, fmt_string: str) -> str:
|
||||
|
||||
def format_money(
|
||||
money: Money,
|
||||
decimal_places: Optional[int] = None,
|
||||
fmt: Optional[str] = None,
|
||||
decimal_places: int = None,
|
||||
format: str = None,
|
||||
include_symbol: bool = True,
|
||||
) -> str:
|
||||
"""Format money object according to the currently set local.
|
||||
@@ -193,8 +191,7 @@ def format_money(
|
||||
Args:
|
||||
money (Money): The money object to format
|
||||
decimal_places (int): Number of decimal places to use
|
||||
fmt (str): Format pattern according LDML / the babel format pattern syntax (https://babel.pocoo.org/en/latest/numbers.html)
|
||||
include_symbol (bool): Whether to include the currency symbol in the formatted output
|
||||
format (str): Format pattern according LDML / the babel format pattern syntax (https://babel.pocoo.org/en/latest/numbers.html)
|
||||
|
||||
Returns:
|
||||
str: The formatted string
|
||||
@@ -202,10 +199,10 @@ def format_money(
|
||||
Raises:
|
||||
ValueError: format string is incorrectly specified
|
||||
"""
|
||||
language = (None) or settings.LANGUAGE_CODE
|
||||
language = None and translation.get_language() or settings.LANGUAGE_CODE
|
||||
locale = Locale.parse(translation.to_locale(language))
|
||||
if fmt:
|
||||
pattern = parse_pattern(fmt)
|
||||
if format:
|
||||
pattern = parse_pattern(format)
|
||||
else:
|
||||
pattern = locale.currency_formats['standard']
|
||||
if decimal_places is not None:
|
||||
395
InvenTree/InvenTree/forms.py
Normal file
395
InvenTree/InvenTree/forms.py
Normal file
@@ -0,0 +1,395 @@
|
||||
"""Helper forms which subclass Django forms to provide additional functionality."""
|
||||
|
||||
import logging
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from django import forms
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import Group, User
|
||||
from django.http import HttpResponseRedirect
|
||||
from django.urls import reverse
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from allauth.account.adapter import DefaultAccountAdapter
|
||||
from allauth.account.forms import LoginForm, SignupForm, set_form_field_order
|
||||
from allauth.core.exceptions import ImmediateHttpResponse
|
||||
from allauth.socialaccount.adapter import DefaultSocialAccountAdapter
|
||||
from allauth_2fa.adapter import OTPAdapter
|
||||
from allauth_2fa.utils import user_has_valid_totp_device
|
||||
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
|
||||
|
||||
import InvenTree.helpers_model
|
||||
import InvenTree.sso
|
||||
from common.models import InvenTreeSetting
|
||||
from InvenTree.exceptions import log_error
|
||||
|
||||
logger = logging.getLogger('inventree')
|
||||
|
||||
|
||||
class HelperForm(forms.ModelForm):
|
||||
"""Provides simple integration of crispy_forms extension."""
|
||||
|
||||
# Custom field decorations can be specified here, per form class
|
||||
field_prefix = {}
|
||||
field_suffix = {}
|
||||
field_placeholder = {}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
"""Setup layout."""
|
||||
super(forms.ModelForm, self).__init__(*args, **kwargs)
|
||||
self.helper = FormHelper()
|
||||
|
||||
self.helper.form_tag = False
|
||||
self.helper.form_show_errors = True
|
||||
|
||||
"""
|
||||
Create a default 'layout' for this form.
|
||||
Ref: https://django-crispy-forms.readthedocs.io/en/latest/layouts.html
|
||||
This is required to do fancy things later (like adding PrependedText, etc).
|
||||
|
||||
Simply create a 'blank' layout for each available field.
|
||||
"""
|
||||
|
||||
self.rebuild_layout()
|
||||
|
||||
def rebuild_layout(self):
|
||||
"""Build crispy layout out of current fields."""
|
||||
layouts = []
|
||||
|
||||
for field in self.fields:
|
||||
prefix = self.field_prefix.get(field, None)
|
||||
suffix = self.field_suffix.get(field, None)
|
||||
placeholder = self.field_placeholder.get(field, '')
|
||||
|
||||
# Look for font-awesome icons
|
||||
if prefix and prefix.startswith('fa-'):
|
||||
prefix = f"<i class='fas {prefix}'/>"
|
||||
|
||||
if suffix and suffix.startswith('fa-'):
|
||||
suffix = f"<i class='fas {suffix}'/>"
|
||||
|
||||
if prefix and suffix:
|
||||
layouts.append(
|
||||
Field(
|
||||
PrependedAppendedText(
|
||||
field,
|
||||
prepended_text=prefix,
|
||||
appended_text=suffix,
|
||||
placeholder=placeholder,
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
elif prefix:
|
||||
layouts.append(
|
||||
Field(PrependedText(field, prefix, placeholder=placeholder))
|
||||
)
|
||||
|
||||
elif suffix:
|
||||
layouts.append(
|
||||
Field(AppendedText(field, suffix, placeholder=placeholder))
|
||||
)
|
||||
|
||||
else:
|
||||
layouts.append(Field(field, placeholder=placeholder))
|
||||
|
||||
self.helper.layout = Layout(*layouts)
|
||||
|
||||
|
||||
class EditUserForm(HelperForm):
|
||||
"""Form for editing user information."""
|
||||
|
||||
class Meta:
|
||||
"""Metaclass options."""
|
||||
|
||||
model = User
|
||||
fields = ['first_name', 'last_name']
|
||||
|
||||
|
||||
class SetPasswordForm(HelperForm):
|
||||
"""Form for setting user password."""
|
||||
|
||||
class Meta:
|
||||
"""Metaclass options."""
|
||||
|
||||
model = User
|
||||
fields = ['enter_password', 'confirm_password', 'old_password']
|
||||
|
||||
enter_password = forms.CharField(
|
||||
max_length=100,
|
||||
min_length=8,
|
||||
required=True,
|
||||
initial='',
|
||||
widget=forms.PasswordInput(attrs={'autocomplete': 'off'}),
|
||||
label=_('Enter password'),
|
||||
help_text=_('Enter new password'),
|
||||
)
|
||||
|
||||
confirm_password = forms.CharField(
|
||||
max_length=100,
|
||||
min_length=8,
|
||||
required=True,
|
||||
initial='',
|
||||
widget=forms.PasswordInput(attrs={'autocomplete': 'off'}),
|
||||
label=_('Confirm password'),
|
||||
help_text=_('Confirm new password'),
|
||||
)
|
||||
|
||||
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."""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
"""Check settings to influence which fields are needed."""
|
||||
kwargs['email_required'] = InvenTreeSetting.get_setting('LOGIN_MAIL_REQUIRED')
|
||||
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# check for two mail fields
|
||||
if InvenTreeSetting.get_setting('LOGIN_SIGNUP_MAIL_TWICE'):
|
||||
self.fields['email2'] = forms.EmailField(
|
||||
label=_('Email (again)'),
|
||||
widget=forms.TextInput(
|
||||
attrs={
|
||||
'type': 'email',
|
||||
'placeholder': _('Email address confirmation'),
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
# check for two password fields
|
||||
if not InvenTreeSetting.get_setting('LOGIN_SIGNUP_PWD_TWICE'):
|
||||
self.fields.pop('password2')
|
||||
|
||||
# reorder fields
|
||||
set_form_field_order(
|
||||
self, ['username', 'email', 'email2', 'password1', 'password2']
|
||||
)
|
||||
|
||||
def clean(self):
|
||||
"""Make sure the supplied emails match if enabled in settings."""
|
||||
cleaned_data = super().clean()
|
||||
|
||||
# check for two mail fields
|
||||
if InvenTreeSetting.get_setting('LOGIN_SIGNUP_MAIL_TWICE'):
|
||||
email = cleaned_data.get('email')
|
||||
email2 = cleaned_data.get('email2')
|
||||
if (email and email2) and email != email2:
|
||||
self.add_error('email2', _('You must type the same email each time.'))
|
||||
|
||||
return cleaned_data
|
||||
|
||||
|
||||
def registration_enabled():
|
||||
"""Determine whether user registration is enabled."""
|
||||
if (
|
||||
InvenTreeSetting.get_setting('LOGIN_ENABLE_REG')
|
||||
or InvenTree.sso.registration_enabled()
|
||||
):
|
||||
if settings.EMAIL_HOST:
|
||||
return True
|
||||
else:
|
||||
logger.error(
|
||||
'Registration cannot be enabled, because EMAIL_HOST is not configured.'
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
class RegistratonMixin:
|
||||
"""Mixin to check if registration should be enabled."""
|
||||
|
||||
def is_open_for_signup(self, request, *args, **kwargs):
|
||||
"""Check if signup is enabled in settings.
|
||||
|
||||
Configure the class variable `REGISTRATION_SETTING` to set which setting should be used, default: `LOGIN_ENABLE_REG`.
|
||||
"""
|
||||
if registration_enabled():
|
||||
return super().is_open_for_signup(request, *args, **kwargs)
|
||||
return False
|
||||
|
||||
def clean_email(self, email):
|
||||
"""Check if the mail is valid to the pattern in LOGIN_SIGNUP_MAIL_RESTRICTION (if enabled in settings)."""
|
||||
mail_restriction = InvenTreeSetting.get_setting(
|
||||
'LOGIN_SIGNUP_MAIL_RESTRICTION', None
|
||||
)
|
||||
if not mail_restriction:
|
||||
return super().clean_email(email)
|
||||
|
||||
split_email = email.split('@')
|
||||
if len(split_email) != 2:
|
||||
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(',')
|
||||
for option in mailoptions:
|
||||
if not option.startswith('@'):
|
||||
log_error('LOGIN_SIGNUP_MAIL_RESTRICTION is not configured correctly')
|
||||
raise forms.ValidationError(
|
||||
_('The provided primary email address is not valid.')
|
||||
)
|
||||
else:
|
||||
if split_email[1] == option[1:]:
|
||||
return super().clean_email(email)
|
||||
|
||||
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):
|
||||
"""Check if a default group is set in settings."""
|
||||
# Create the user
|
||||
user = super().save_user(request, user, form)
|
||||
|
||||
# Check if a default group is set in settings
|
||||
start_group = InvenTreeSetting.get_setting('SIGNUP_GROUP')
|
||||
if start_group:
|
||||
try:
|
||||
group = Group.objects.get(id=start_group)
|
||||
user.groups.add(group)
|
||||
except Group.DoesNotExist:
|
||||
logger.exception(
|
||||
'The setting `SIGNUP_GROUP` contains an non existent group',
|
||||
start_group,
|
||||
)
|
||||
user.save()
|
||||
return user
|
||||
|
||||
|
||||
class CustomUrlMixin:
|
||||
"""Mixin to set urls."""
|
||||
|
||||
def get_email_confirmation_url(self, request, emailconfirmation):
|
||||
"""Custom email confirmation (activation) url."""
|
||||
url = reverse('account_confirm_email', args=[emailconfirmation.key])
|
||||
|
||||
return InvenTree.helpers_model.construct_absolute_url(url)
|
||||
|
||||
|
||||
class CustomAccountAdapter(
|
||||
CustomUrlMixin, RegistratonMixin, OTPAdapter, DefaultAccountAdapter
|
||||
):
|
||||
"""Override of adapter to use dynamic settings."""
|
||||
|
||||
def send_mail(self, template_prefix, email, context):
|
||||
"""Only send mail if backend configured."""
|
||||
if settings.EMAIL_HOST:
|
||||
try:
|
||||
result = super().send_mail(template_prefix, email, context)
|
||||
except Exception:
|
||||
# An exception occurred while attempting to send email
|
||||
# Log it (for admin users) and return silently
|
||||
log_error('account email')
|
||||
result = False
|
||||
|
||||
return result
|
||||
|
||||
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."""
|
||||
|
||||
def is_auto_signup_allowed(self, request, sociallogin):
|
||||
"""Check if auto signup is enabled in settings."""
|
||||
if InvenTreeSetting.get_setting('LOGIN_SIGNUP_SSO_AUTO', True):
|
||||
return super().is_auto_signup_allowed(request, sociallogin)
|
||||
return False
|
||||
|
||||
# from OTPAdapter
|
||||
def has_2fa_enabled(self, user):
|
||||
"""Returns True if the user has 2FA configured."""
|
||||
return user_has_valid_totp_device(user)
|
||||
|
||||
def login(self, request, user):
|
||||
"""Ensure user is send to 2FA before login if enabled."""
|
||||
# Require two-factor authentication if it has been configured.
|
||||
if self.has_2fa_enabled(user):
|
||||
# Cast to string for the case when this is not a JSON serializable
|
||||
# object, e.g. a UUID.
|
||||
request.session['allauth_2fa_user_id'] = str(user.id)
|
||||
|
||||
redirect_url = reverse('two-factor-authenticate')
|
||||
# Add GET parameters to the URL if they exist.
|
||||
if request.GET:
|
||||
redirect_url += '?' + urlencode(request.GET)
|
||||
|
||||
raise ImmediateHttpResponse(response=HttpResponseRedirect(redirect_url))
|
||||
|
||||
# Otherwise defer to the original allauth adapter.
|
||||
return super().login(request, user)
|
||||
|
||||
def authentication_error(
|
||||
self, request, provider_id, error=None, exception=None, extra_context=None
|
||||
):
|
||||
"""Callback method for authentication errors."""
|
||||
if not error:
|
||||
error = request.GET.get('error', None)
|
||||
|
||||
if not exception:
|
||||
exception = request.GET.get('error_description', None)
|
||||
|
||||
path = request.path or 'sso'
|
||||
|
||||
# Log the error to the database
|
||||
log_error(path, error_name=error, error_data=exception)
|
||||
logger.error("SSO error for provider '%s' - check admin error log", provider_id)
|
||||
|
||||
|
||||
# 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.'))
|
||||
922
InvenTree/InvenTree/helpers.py
Normal file
922
InvenTree/InvenTree/helpers.py
Normal file
@@ -0,0 +1,922 @@
|
||||
"""Provides helper functions used throughout the InvenTree project."""
|
||||
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import os.path
|
||||
import re
|
||||
from decimal import Decimal, InvalidOperation
|
||||
from typing import TypeVar
|
||||
from wsgiref.util import FileWrapper
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.staticfiles.storage import StaticFilesStorage
|
||||
from django.core.exceptions import FieldError, ValidationError
|
||||
from django.core.files.storage import default_storage
|
||||
from django.http import StreamingHttpResponse
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
import regex
|
||||
from bleach import clean
|
||||
from djmoney.money import Money
|
||||
from PIL import Image
|
||||
|
||||
import InvenTree.version
|
||||
from common.settings import currency_code_default
|
||||
|
||||
from .settings import MEDIA_URL, STATIC_URL
|
||||
|
||||
logger = logging.getLogger('inventree')
|
||||
|
||||
|
||||
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
|
||||
|
||||
reference = str(reference).strip()
|
||||
|
||||
# Ignore empty string
|
||||
if len(reference) == 0:
|
||||
return 0
|
||||
|
||||
# Look at the start of the string - can it be "integerized"?
|
||||
result = re.match(r'^(\d+)', reference)
|
||||
|
||||
if result and len(result.groups()) == 1:
|
||||
ref = result.groups()[0]
|
||||
try:
|
||||
ref_int = int(ref)
|
||||
except Exception:
|
||||
ref_int = 0
|
||||
else:
|
||||
# Look at the "end" of the string
|
||||
result = re.search(r'(\d+)$', reference)
|
||||
|
||||
if result and len(result.groups()) == 1:
|
||||
ref = result.groups()[0]
|
||||
try:
|
||||
ref_int = int(ref)
|
||||
except Exception:
|
||||
ref_int = 0
|
||||
|
||||
# Ensure that the returned values are within the range that can be stored in an IntegerField
|
||||
# Note: This will result in large values being "clipped"
|
||||
if clip is not None:
|
||||
if ref_int > clip:
|
||||
ref_int = clip
|
||||
elif ref_int < -clip:
|
||||
ref_int = -clip
|
||||
|
||||
if not allow_negative and ref_int < 0:
|
||||
ref_int = abs(ref_int)
|
||||
|
||||
return ref_int
|
||||
|
||||
|
||||
def generateTestKey(test_name: str) -> str:
|
||||
"""Generate a test 'key' for a given test name. This must not have illegal chars as it will be used for dict lookup in a template.
|
||||
|
||||
Tests must be named such that they will have unique keys.
|
||||
"""
|
||||
if test_name is None:
|
||||
test_name = ''
|
||||
|
||||
key = test_name.strip().lower()
|
||||
key = key.replace(' ', '')
|
||||
|
||||
# Remove any characters that cannot be used to represent a variable
|
||||
key = re.sub(r'[^a-zA-Z0-9_]', '', key)
|
||||
|
||||
# If the key starts with a digit, prefix with an underscore
|
||||
if key[0].isdigit():
|
||||
key = '_' + key
|
||||
|
||||
return key
|
||||
|
||||
|
||||
def constructPathString(path, max_chars=250):
|
||||
"""Construct a 'path string' for the given path.
|
||||
|
||||
Arguments:
|
||||
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
|
||||
if len(pathstring) > max_chars:
|
||||
n = int(max_chars / 2 - 2)
|
||||
pathstring = pathstring[:n] + '...' + pathstring[-n:]
|
||||
|
||||
return pathstring
|
||||
|
||||
|
||||
def getMediaUrl(filename):
|
||||
"""Return the qualified access path for the given file, under the media directory."""
|
||||
return os.path.join(MEDIA_URL, str(filename))
|
||||
|
||||
|
||||
def getStaticUrl(filename):
|
||||
"""Return the qualified access path for the given file, under the static media directory."""
|
||||
return os.path.join(STATIC_URL, str(filename))
|
||||
|
||||
|
||||
def TestIfImage(img):
|
||||
"""Test if an image file is indeed an image."""
|
||||
try:
|
||||
Image.open(img).verify()
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def getBlankImage():
|
||||
"""Return the qualified path for the 'blank image' placeholder."""
|
||||
return getStaticUrl('img/blank_image.png')
|
||||
|
||||
|
||||
def getBlankThumbnail():
|
||||
"""Return the qualified path for the 'blank image' thumbnail placeholder."""
|
||||
return getStaticUrl('img/blank_image.thumbnail.png')
|
||||
|
||||
|
||||
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:
|
||||
static_storage = StaticFilesStorage()
|
||||
|
||||
if static_storage.exists(settings.CUSTOM_LOGO):
|
||||
storage = static_storage
|
||||
elif default_storage.exists(settings.CUSTOM_LOGO):
|
||||
storage = default_storage
|
||||
else:
|
||||
storage = None
|
||||
|
||||
if storage is not None:
|
||||
if as_file:
|
||||
return f'file://{storage.path(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}'
|
||||
return getStaticUrl('img/inventree.png')
|
||||
|
||||
|
||||
def getSplashScreen(custom=True):
|
||||
"""Return the InvenTree splash screen, or a custom splash if available."""
|
||||
static_storage = StaticFilesStorage()
|
||||
|
||||
if custom and settings.CUSTOM_SPLASH:
|
||||
if static_storage.exists(settings.CUSTOM_SPLASH):
|
||||
return static_storage.url(settings.CUSTOM_SPLASH)
|
||||
|
||||
# No custom splash screen
|
||||
return static_storage.url('img/inventree_splash.jpg')
|
||||
|
||||
|
||||
def TestIfImageURL(url):
|
||||
"""Test if an image URL (or filename) looks like a valid image format.
|
||||
|
||||
Simply tests the extension against a set of allowed values
|
||||
"""
|
||||
return os.path.splitext(os.path.basename(url))[-1].lower() in [
|
||||
'.jpg',
|
||||
'.jpeg',
|
||||
'.j2k',
|
||||
'.png',
|
||||
'.bmp',
|
||||
'.tif',
|
||||
'.tiff',
|
||||
'.webp',
|
||||
'.gif',
|
||||
]
|
||||
|
||||
|
||||
def str2bool(text, test=True):
|
||||
"""Test if a string 'looks' like a boolean value.
|
||||
|
||||
Args:
|
||||
text: Input text
|
||||
test (default = True): Set which boolean value to look for
|
||||
|
||||
Returns:
|
||||
True if the text looks like the selected boolean value
|
||||
"""
|
||||
if test:
|
||||
return str(text).lower() in ['1', 'y', 'yes', 't', 'true', 'ok', 'on']
|
||||
return str(text).lower() in ['0', 'n', 'no', 'none', 'f', 'false', 'off']
|
||||
|
||||
|
||||
def str2int(text, default=None):
|
||||
"""Convert a string to int if possible.
|
||||
|
||||
Args:
|
||||
text: Int like string
|
||||
default: Return value if str is no int like
|
||||
|
||||
Returns:
|
||||
Converted int value
|
||||
"""
|
||||
try:
|
||||
return int(text)
|
||||
except Exception:
|
||||
return default
|
||||
|
||||
|
||||
def is_bool(text):
|
||||
"""Determine if a string value 'looks' like a boolean."""
|
||||
if str2bool(text, True):
|
||||
return True
|
||||
elif str2bool(text, False):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def isNull(text):
|
||||
"""Test if a string 'looks' like a null value. This is useful for querying the API against a null key.
|
||||
|
||||
Args:
|
||||
text: Input text
|
||||
|
||||
Returns:
|
||||
True if the text looks like a null value
|
||||
"""
|
||||
return str(text).strip().lower() in [
|
||||
'top',
|
||||
'null',
|
||||
'none',
|
||||
'empty',
|
||||
'false',
|
||||
'-1',
|
||||
'',
|
||||
]
|
||||
|
||||
|
||||
def normalize(d):
|
||||
"""Normalize a decimal number, and remove exponential formatting."""
|
||||
if type(d) is not Decimal:
|
||||
d = Decimal(d)
|
||||
|
||||
d = d.normalize()
|
||||
|
||||
# Ref: https://docs.python.org/3/library/decimal.html
|
||||
return d.quantize(Decimal(1)) if d == d.to_integral() else d.normalize()
|
||||
|
||||
|
||||
def increment(value):
|
||||
"""Attempt to increment an integer (or a string that looks like an integer).
|
||||
|
||||
e.g.
|
||||
|
||||
001 -> 002
|
||||
2 -> 3
|
||||
AB01 -> AB02
|
||||
QQQ -> QQQ
|
||||
|
||||
"""
|
||||
value = str(value).strip()
|
||||
|
||||
# Ignore empty strings
|
||||
if value in ['', None]:
|
||||
# Provide a default value if provided with a null input
|
||||
return '1'
|
||||
|
||||
pattern = r'(.*?)(\d+)?$'
|
||||
|
||||
result = re.search(pattern, value)
|
||||
|
||||
# No match!
|
||||
if result is None:
|
||||
return value
|
||||
|
||||
groups = result.groups()
|
||||
|
||||
# If we cannot match the regex, then simply return the provided value
|
||||
if len(groups) != 2:
|
||||
return value
|
||||
|
||||
prefix, number = groups
|
||||
|
||||
# No number extracted? Simply return the prefix (without incrementing!)
|
||||
if not number:
|
||||
return prefix
|
||||
|
||||
# Record the width of the number
|
||||
width = len(number)
|
||||
|
||||
try:
|
||||
number = int(number) + 1
|
||||
number = str(number)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
number = number.zfill(width)
|
||||
|
||||
return prefix + number
|
||||
|
||||
|
||||
def decimal2string(d):
|
||||
"""Format a Decimal number as a string, stripping out any trailing zeroes or decimal points. Essentially make it look like a whole number if it is one.
|
||||
|
||||
Args:
|
||||
d: A python Decimal object
|
||||
|
||||
Returns:
|
||||
A string representation of the input number
|
||||
"""
|
||||
if type(d) is Decimal:
|
||||
d = normalize(d)
|
||||
|
||||
try:
|
||||
# Ensure that the provided string can actually be converted to a float
|
||||
float(d)
|
||||
except ValueError:
|
||||
# Not a number
|
||||
return str(d)
|
||||
|
||||
s = str(d)
|
||||
|
||||
# Return entire number if there is no decimal place
|
||||
if '.' not in s:
|
||||
return s
|
||||
|
||||
return s.rstrip('0').rstrip('.')
|
||||
|
||||
|
||||
def decimal2money(d, currency=None):
|
||||
"""Format a Decimal number as Money.
|
||||
|
||||
Args:
|
||||
d: A python Decimal object
|
||||
currency: Currency of the input amount, defaults to default currency in settings
|
||||
|
||||
Returns:
|
||||
A Money object from the input(s)
|
||||
"""
|
||||
if not currency:
|
||||
currency = currency_code_default()
|
||||
return Money(d, currency)
|
||||
|
||||
|
||||
def WrapWithQuotes(text, quote='"'):
|
||||
"""Wrap the supplied text with quotes.
|
||||
|
||||
Args:
|
||||
text: Input text to wrap
|
||||
quote: Quote character to use for wrapping (default = "")
|
||||
|
||||
Returns:
|
||||
Supplied text wrapped in quote char
|
||||
"""
|
||||
if not text.startswith(quote):
|
||||
text = quote + text
|
||||
|
||||
if not text.endswith(quote):
|
||||
text = text + quote
|
||||
|
||||
return text
|
||||
|
||||
|
||||
def MakeBarcode(cls_name, object_pk: int, object_data=None, **kwargs):
|
||||
"""Generate a string for a barcode. Adds some global InvenTree parameters.
|
||||
|
||||
Args:
|
||||
cls_name: string describing the object type e.g. 'StockItem'
|
||||
object_pk (int): ID (Primary Key) of the object in the database
|
||||
object_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 = {}
|
||||
|
||||
brief = kwargs.get('brief', True)
|
||||
|
||||
data = {}
|
||||
|
||||
if brief:
|
||||
data[cls_name] = object_pk
|
||||
else:
|
||||
data['tool'] = 'InvenTree'
|
||||
data['version'] = InvenTree.version.inventreeVersion()
|
||||
data['instance'] = InvenTree.version.inventreeInstanceName()
|
||||
|
||||
# Ensure PK is included
|
||||
object_data['id'] = object_pk
|
||||
data[cls_name] = object_data
|
||||
|
||||
return str(json.dumps(data, sort_keys=True))
|
||||
|
||||
|
||||
def GetExportFormats():
|
||||
"""Return a list of allowable file formats for exporting data."""
|
||||
return ['csv', 'tsv', 'xls', 'xlsx', 'json', 'yaml']
|
||||
|
||||
|
||||
def DownloadFile(
|
||||
data, filename, content_type='application/text', inline=False
|
||||
) -> StreamingHttpResponse:
|
||||
"""Create a dynamic file for the user to download.
|
||||
|
||||
Args:
|
||||
data: Raw file data (string or bytes)
|
||||
filename: Filename for the file download
|
||||
content_type: Content type for the download
|
||||
inline: Download "inline" or as attachment? (Default = attachment)
|
||||
|
||||
Return:
|
||||
A StreamingHttpResponse object wrapping the supplied data
|
||||
"""
|
||||
filename = WrapWithQuotes(filename)
|
||||
length = len(data)
|
||||
|
||||
if isinstance(data, str):
|
||||
wrapper = FileWrapper(io.StringIO(data))
|
||||
else:
|
||||
wrapper = FileWrapper(io.BytesIO(data))
|
||||
|
||||
response = StreamingHttpResponse(wrapper, content_type=content_type)
|
||||
if isinstance(data, str):
|
||||
length = len(bytes(data, response.charset))
|
||||
response['Content-Length'] = length
|
||||
|
||||
if inline:
|
||||
disposition = f'inline; filename={filename}'
|
||||
else:
|
||||
disposition = f'attachment; filename={filename}'
|
||||
|
||||
response['Content-Disposition'] = disposition
|
||||
return response
|
||||
|
||||
|
||||
def increment_serial_number(serial: str):
|
||||
"""Given a serial number, (attempt to) generate the *next* serial number.
|
||||
|
||||
Note: This method is exposed to custom plugins.
|
||||
|
||||
Arguments:
|
||||
serial: The serial number which should be incremented
|
||||
|
||||
Returns:
|
||||
incremented value, or None if incrementing could not be performed.
|
||||
"""
|
||||
from plugin.registry import registry
|
||||
|
||||
# Ensure we start with a string value
|
||||
if serial is not None:
|
||||
serial = str(serial).strip()
|
||||
|
||||
# First, let any plugins attempt to increment the serial number
|
||||
for plugin in registry.with_mixin('validation'):
|
||||
result = plugin.increment_serial_number(serial)
|
||||
if result is not None:
|
||||
return str(result)
|
||||
|
||||
# If we get to here, no plugins were able to "increment" the provided serial value
|
||||
# Attempt to perform increment according to some basic rules
|
||||
return increment(serial)
|
||||
|
||||
|
||||
def extract_serial_numbers(input_string, expected_quantity: int, starting_value=None):
|
||||
"""Extract a list of serial numbers from a provided input string.
|
||||
|
||||
The input string can be specified using the following concepts:
|
||||
|
||||
- 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 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,
|
||||
allowing custom plugins to determine how serial values are incremented.
|
||||
|
||||
Arguments:
|
||||
input_string: Input string with specified serial numbers (string, or integer)
|
||||
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)
|
||||
|
||||
try:
|
||||
expected_quantity = int(expected_quantity)
|
||||
except ValueError:
|
||||
raise ValidationError([_('Invalid quantity provided')])
|
||||
|
||||
if input_string:
|
||||
input_string = str(input_string).strip()
|
||||
else:
|
||||
input_string = ''
|
||||
|
||||
if len(input_string) == 0:
|
||||
raise ValidationError([_('Empty serial number string')])
|
||||
|
||||
next_value = increment_serial_number(starting_value)
|
||||
|
||||
# Substitute ~ character with latest value
|
||||
while '~' in input_string and next_value:
|
||||
input_string = input_string.replace('~', str(next_value), 1)
|
||||
next_value = increment_serial_number(next_value)
|
||||
|
||||
# Split input string by whitespace or comma (,) characters
|
||||
groups = re.split(r'[\s,]+', input_string)
|
||||
|
||||
serials = []
|
||||
errors = []
|
||||
|
||||
def add_error(error: str):
|
||||
"""Helper function for adding an error message."""
|
||||
if error not in errors:
|
||||
errors.append(error)
|
||||
|
||||
def add_serial(serial):
|
||||
"""Helper function to check for duplicated values."""
|
||||
serial = serial.strip()
|
||||
|
||||
# Ignore blank / empty serials
|
||||
if len(serial) == 0:
|
||||
return
|
||||
|
||||
if serial in serials:
|
||||
add_error(_('Duplicate serial') + f': {serial}')
|
||||
else:
|
||||
serials.append(serial)
|
||||
|
||||
# If the user has supplied the correct number of serials, do not split into groups
|
||||
if len(groups) == expected_quantity:
|
||||
for group in groups:
|
||||
add_serial(group)
|
||||
|
||||
if len(errors) > 0:
|
||||
raise ValidationError(errors)
|
||||
else:
|
||||
return serials
|
||||
|
||||
for group in groups:
|
||||
# Calculate the "remaining" quantity of serial numbers
|
||||
remaining = expected_quantity - len(serials)
|
||||
|
||||
group = group.strip()
|
||||
|
||||
if '-' in group:
|
||||
"""Hyphen indicates a range of values:
|
||||
e.g. 10-20
|
||||
"""
|
||||
items = group.split('-')
|
||||
|
||||
if len(items) == 2:
|
||||
a = items[0]
|
||||
b = items[1]
|
||||
|
||||
if a == b:
|
||||
# Invalid group
|
||||
add_error(_(f'Invalid group range: {group}'))
|
||||
continue
|
||||
|
||||
group_items = []
|
||||
|
||||
count = 0
|
||||
|
||||
a_next = a
|
||||
|
||||
while a_next is not None and a_next not in group_items:
|
||||
group_items.append(a_next)
|
||||
count += 1
|
||||
|
||||
# Progress to the 'next' sequential value
|
||||
a_next = str(increment_serial_number(a_next))
|
||||
|
||||
if a_next == b:
|
||||
# Successfully got to the end of the range
|
||||
group_items.append(b)
|
||||
break
|
||||
|
||||
elif count > remaining:
|
||||
# More than the allowed number of items
|
||||
break
|
||||
|
||||
elif a_next is None:
|
||||
break
|
||||
|
||||
if len(group_items) > remaining:
|
||||
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(_(f'Invalid group range: {group}'))
|
||||
|
||||
else:
|
||||
# In the case of a different number of hyphens, simply add the entire group
|
||||
add_serial(group)
|
||||
|
||||
elif '+' in group:
|
||||
"""Plus character (+) indicates either:
|
||||
- <start>+ - Expected number of serials, beginning at the specified 'start' character
|
||||
- <start>+<num> - Specified number of serials, beginning at the specified 'start' character
|
||||
"""
|
||||
items = group.split('+')
|
||||
|
||||
sequence_items = []
|
||||
counter = 0
|
||||
sequence_count = max(0, expected_quantity - len(serials))
|
||||
|
||||
if len(items) > 2 or len(items) == 0:
|
||||
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(_(f'Invalid group sequence: {group}'))
|
||||
continue
|
||||
|
||||
value = items[0]
|
||||
|
||||
# Keep incrementing up to the specified quantity
|
||||
while (
|
||||
value is not None
|
||||
and value not in sequence_items
|
||||
and counter < sequence_count
|
||||
):
|
||||
sequence_items.append(value)
|
||||
value = increment_serial_number(value)
|
||||
counter += 1
|
||||
|
||||
if len(sequence_items) == sequence_count:
|
||||
for item in sequence_items:
|
||||
add_serial(item)
|
||||
else:
|
||||
add_error(_(f'Invalid group sequence: {group}'))
|
||||
|
||||
else:
|
||||
# At this point, we assume that the 'group' is just a single serial value
|
||||
add_serial(group)
|
||||
|
||||
if len(errors) > 0:
|
||||
raise ValidationError(errors)
|
||||
|
||||
if len(serials) == 0:
|
||||
raise ValidationError([_('No serial numbers found')])
|
||||
|
||||
if len(errors) == 0 and len(serials) != expected_quantity:
|
||||
raise ValidationError([
|
||||
_(
|
||||
f'Number of unique serial numbers ({len(serials)}) must match quantity ({expected_quantity})'
|
||||
)
|
||||
])
|
||||
|
||||
return serials
|
||||
|
||||
|
||||
def validateFilterString(value, model=None):
|
||||
"""Validate that a provided filter string looks like a list of comma-separated key=value pairs.
|
||||
|
||||
These should nominally match to a valid database filter based on the model being filtered.
|
||||
|
||||
e.g. "category=6, IPN=12"
|
||||
e.g. "part__name=widget"
|
||||
|
||||
The ReportTemplate class uses the filter string to work out which items a given report applies to.
|
||||
For example, an acceptance test report template might only apply to stock items with a given IPN,
|
||||
so the string could be set to:
|
||||
|
||||
filters = "IPN = ACME0001"
|
||||
|
||||
Returns a map of key:value pairs
|
||||
"""
|
||||
# Empty results map
|
||||
results = {}
|
||||
|
||||
value = str(value).strip()
|
||||
|
||||
if not value or len(value) == 0:
|
||||
return results
|
||||
|
||||
groups = value.split(',')
|
||||
|
||||
for group in groups:
|
||||
group = group.strip()
|
||||
|
||||
pair = group.split('=')
|
||||
|
||||
if len(pair) != 2:
|
||||
raise ValidationError(f'Invalid group: {group}')
|
||||
|
||||
k, v = pair
|
||||
|
||||
k = k.strip()
|
||||
v = v.strip()
|
||||
|
||||
if not k or not v:
|
||||
raise ValidationError(f'Invalid group: {group}')
|
||||
|
||||
results[k] = v
|
||||
|
||||
# If a model is provided, verify that the provided filters can be used against it
|
||||
if model is not None:
|
||||
try:
|
||||
model.objects.filter(**results)
|
||||
except FieldError as e:
|
||||
raise ValidationError(str(e))
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def clean_decimal(number):
|
||||
"""Clean-up decimal value."""
|
||||
# Check if empty
|
||||
if number is None or number == '' or number == 0:
|
||||
return Decimal(0)
|
||||
|
||||
# Convert to string and remove spaces
|
||||
number = str(number).replace(' ', '')
|
||||
|
||||
# Guess what type of decimal and thousands separators are used
|
||||
count_comma = number.count(',')
|
||||
count_point = number.count('.')
|
||||
|
||||
if count_comma == 1:
|
||||
# Comma is used as decimal separator
|
||||
if count_point > 0:
|
||||
# Points are used as thousands separators: remove them
|
||||
number = number.replace('.', '')
|
||||
# Replace decimal separator with point
|
||||
number = number.replace(',', '.')
|
||||
elif count_point == 1:
|
||||
# Point is used as decimal separator
|
||||
if count_comma > 0:
|
||||
# Commas are used as thousands separators: remove them
|
||||
number = number.replace(',', '')
|
||||
|
||||
# Convert to Decimal type
|
||||
try:
|
||||
clean_number = Decimal(number)
|
||||
except InvalidOperation:
|
||||
# Number cannot be converted to Decimal (eg. a string containing letters)
|
||||
return Decimal(0)
|
||||
|
||||
return (
|
||||
clean_number.quantize(Decimal(1))
|
||||
if clean_number == clean_number.to_integral()
|
||||
else clean_number.normalize()
|
||||
)
|
||||
|
||||
|
||||
def strip_html_tags(value: str, raise_error=True, field_name=None):
|
||||
"""Strip HTML tags from an input string using the bleach library.
|
||||
|
||||
If raise_error is True, a ValidationError will be thrown if HTML tags are detected
|
||||
"""
|
||||
cleaned = clean(value, strip=True, tags=[], attributes=[])
|
||||
|
||||
# Add escaped characters back in
|
||||
replacements = {'>': '>', '<': '<', '&': '&'}
|
||||
|
||||
for o, r in replacements.items():
|
||||
cleaned = cleaned.replace(o, r)
|
||||
|
||||
# If the length changed, it means that HTML tags were removed!
|
||||
if len(cleaned) != len(value) and raise_error:
|
||||
field = field_name or 'non_field_errors'
|
||||
|
||||
raise ValidationError({field: [_('Remove HTML tags from this value')]})
|
||||
|
||||
return cleaned
|
||||
|
||||
|
||||
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:
|
||||
# Remove ASCII control characters
|
||||
# Note that we do not sub out 0x0A (\n) here, it is done separately below
|
||||
cleaned = regex.sub('[\x00-\x09]+', '', cleaned)
|
||||
cleaned = regex.sub('[\x0b-\x1f\x7f]+', '', cleaned)
|
||||
|
||||
if remove_newline:
|
||||
cleaned = regex.sub('[\x0a]+', '', cleaned)
|
||||
|
||||
if remove_unicode:
|
||||
# Remove Unicode control characters
|
||||
if remove_newline:
|
||||
cleaned = regex.sub('[^\P{C}]+', '', cleaned)
|
||||
else:
|
||||
# Use 'negative-lookahead' to exclude newline character
|
||||
cleaned = regex.sub('(?![\x0a])[^\P{C}]+', '', cleaned)
|
||||
|
||||
return cleaned
|
||||
|
||||
|
||||
def hash_barcode(barcode_data):
|
||||
"""Calculate a 'unique' hash for a barcode string.
|
||||
|
||||
This hash is used for comparison / lookup.
|
||||
|
||||
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)
|
||||
|
||||
hash = hashlib.md5(str(barcode_data).encode())
|
||||
|
||||
return str(hash.hexdigest())
|
||||
|
||||
|
||||
def hash_file(filename: str):
|
||||
"""Return the MD5 hash of a file."""
|
||||
return hashlib.md5(open(filename, 'rb').read()).hexdigest()
|
||||
|
||||
|
||||
def get_objectreference(
|
||||
obj, type_ref: str = 'content_type', object_ref: str = 'object_id'
|
||||
):
|
||||
"""Lookup method for the GenericForeignKey fields.
|
||||
|
||||
Attributes:
|
||||
- obj: object that will be resolved
|
||||
- type_ref: field name for the contenttype field in the model
|
||||
- object_ref: field name for the object id in the model
|
||||
|
||||
Example implementation in the serializer:
|
||||
```
|
||||
target = serializers.SerializerMethodField()
|
||||
def get_target(self, obj):
|
||||
return get_objectreference(obj, 'target_content_type', 'target_object_id')
|
||||
```
|
||||
|
||||
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)
|
||||
|
||||
# check if references are set -> return nothing if not
|
||||
if model_cls is None or obj_id is None:
|
||||
return None
|
||||
|
||||
# resolve referenced data into objects
|
||||
model_cls = model_cls.model_class()
|
||||
|
||||
try:
|
||||
item = model_cls.objects.get(id=obj_id)
|
||||
except model_cls.DoesNotExist:
|
||||
return None
|
||||
|
||||
url_fnc = getattr(item, 'get_absolute_url', None)
|
||||
|
||||
# create output
|
||||
ret = {}
|
||||
if url_fnc:
|
||||
ret['link'] = url_fnc()
|
||||
return {'name': str(item), 'model': str(model_cls._meta.verbose_name), **ret}
|
||||
|
||||
|
||||
Inheritors_T = TypeVar('Inheritors_T')
|
||||
|
||||
|
||||
def inheritors(cls: type[Inheritors_T]) -> set[type[Inheritors_T]]:
|
||||
"""Return all classes that are subclasses from the supplied cls."""
|
||||
subcls = set()
|
||||
work = [cls]
|
||||
|
||||
while work:
|
||||
parent = work.pop()
|
||||
for child in parent.__subclasses__():
|
||||
if child not in subcls:
|
||||
subcls.add(child)
|
||||
work.append(child)
|
||||
return subcls
|
||||
|
||||
|
||||
def is_ajax(request):
|
||||
"""Check if the current request is an AJAX request."""
|
||||
return request.headers.get('x-requested-with') == 'XMLHttpRequest'
|
||||
|
||||
|
||||
def pui_url(subpath: str) -> str:
|
||||
"""Return the URL for a PUI subpath."""
|
||||
if not subpath.startswith('/'):
|
||||
subpath = '/' + subpath
|
||||
return f'/{settings.FRONTEND_URL_BASE}{subpath}'
|
||||
106
InvenTree/InvenTree/helpers_mixin.py
Normal file
106
InvenTree/InvenTree/helpers_mixin.py
Normal file
@@ -0,0 +1,106 @@
|
||||
"""Provides helper mixins that are used throughout the InvenTree project."""
|
||||
|
||||
import inspect
|
||||
from pathlib import Path
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
from plugin import registry as plg_registry
|
||||
|
||||
|
||||
class ClassValidationMixin:
|
||||
"""Mixin to validate class attributes and overrides.
|
||||
|
||||
Class attributes:
|
||||
required_attributes: List of class attributes that need to be defined
|
||||
required_overrides: List of functions that need override, a nested list mean either one of them needs an override
|
||||
|
||||
Example:
|
||||
```py
|
||||
class Parent(ClassValidationMixin):
|
||||
NAME: str
|
||||
def test(self):
|
||||
pass
|
||||
|
||||
required_attributes = ["NAME"]
|
||||
required_overrides = [test]
|
||||
|
||||
class MyClass(Parent):
|
||||
pass
|
||||
|
||||
myClass = MyClass()
|
||||
myClass.validate() # raises NotImplementedError
|
||||
```
|
||||
"""
|
||||
|
||||
required_attributes = []
|
||||
required_overrides = []
|
||||
|
||||
@classmethod
|
||||
def validate(cls):
|
||||
"""Validate the class against the required attributes/overrides."""
|
||||
|
||||
def attribute_missing(key):
|
||||
"""Check if attribute is missing."""
|
||||
return not hasattr(cls, key) or getattr(cls, key) == ''
|
||||
|
||||
def override_missing(base_implementation):
|
||||
"""Check if override is missing."""
|
||||
if isinstance(base_implementation, list):
|
||||
return all(override_missing(x) for x in base_implementation)
|
||||
|
||||
return base_implementation == getattr(
|
||||
cls, base_implementation.__name__, None
|
||||
)
|
||||
|
||||
missing_attributes = list(filter(attribute_missing, cls.required_attributes))
|
||||
missing_overrides = list(filter(override_missing, cls.required_overrides))
|
||||
|
||||
errors = []
|
||||
|
||||
if len(missing_attributes) > 0:
|
||||
errors.append(
|
||||
f"did not provide the following attributes: {', '.join(missing_attributes)}"
|
||||
)
|
||||
if len(missing_overrides) > 0:
|
||||
missing_overrides_list = []
|
||||
for base_implementation in missing_overrides:
|
||||
if isinstance(base_implementation, list):
|
||||
missing_overrides_list.append(
|
||||
'one of '
|
||||
+ ' or '.join(attr.__name__ for attr in base_implementation)
|
||||
)
|
||||
else:
|
||||
missing_overrides_list.append(base_implementation.__name__)
|
||||
errors.append(
|
||||
f"did not override the required attributes: {', '.join(missing_overrides_list)}"
|
||||
)
|
||||
|
||||
if len(errors) > 0:
|
||||
raise NotImplementedError(f"'{cls}' " + ' and '.join(errors))
|
||||
|
||||
|
||||
class ClassProviderMixin:
|
||||
"""Mixin to get metadata about a class itself, e.g. the plugin that provided that class."""
|
||||
|
||||
@classmethod
|
||||
def get_provider_file(cls):
|
||||
"""File that contains the Class definition."""
|
||||
return inspect.getfile(cls)
|
||||
|
||||
@classmethod
|
||||
def get_provider_plugin(cls):
|
||||
"""Plugin that contains the Class definition, otherwise None."""
|
||||
for plg in plg_registry.plugins.values():
|
||||
if plg.package_path == cls.__module__:
|
||||
return plg
|
||||
|
||||
@classmethod
|
||||
def get_is_builtin(cls):
|
||||
"""Is this Class build in the Inventree source code?"""
|
||||
try:
|
||||
Path(cls.get_provider_file()).relative_to(settings.BASE_DIR)
|
||||
return True
|
||||
except ValueError:
|
||||
# Path(...).relative_to throws an ValueError if its not relative to the InvenTree source base dir
|
||||
return False
|
||||
364
InvenTree/InvenTree/helpers_model.py
Normal file
364
InvenTree/InvenTree/helpers_model.py
Normal file
@@ -0,0 +1,364 @@
|
||||
"""Provides helper functions used throughout the InvenTree project that access the database."""
|
||||
|
||||
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 requests
|
||||
from djmoney.contrib.exchange.models import convert_money
|
||||
from djmoney.money import Money
|
||||
from PIL import Image
|
||||
|
||||
import common.models
|
||||
import InvenTree
|
||||
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')
|
||||
|
||||
|
||||
def getSetting(key, backup_value=None):
|
||||
"""Shortcut for reading a setting value from the database."""
|
||||
return common.models.InvenTreeSetting.get_setting(key, backup_value=backup_value)
|
||||
|
||||
|
||||
def get_base_url(request=None):
|
||||
"""Return the base URL for the InvenTree server.
|
||||
|
||||
The base URL is determined in the following order of decreasing priority:
|
||||
|
||||
1. If a request object is provided, use the request URL
|
||||
2. Multi-site is enabled, and the current site has a valid URL
|
||||
3. If settings.SITE_URL is set (e.g. in the Django settings), use that
|
||||
4. If the InvenTree setting INVENTREE_BASE_URL is set, use that
|
||||
"""
|
||||
# Check if a request is provided
|
||||
if request:
|
||||
return request.build_absolute_uri('/')
|
||||
|
||||
# Check if multi-site is enabled
|
||||
try:
|
||||
from django.contrib.sites.models import Site
|
||||
|
||||
return Site.objects.get_current().domain
|
||||
except (ImportError, RuntimeError):
|
||||
pass
|
||||
|
||||
# Check if a global site URL is provided
|
||||
if site_url := getattr(settings, 'SITE_URL', None):
|
||||
return site_url
|
||||
|
||||
# Check if a global InvenTree setting is provided
|
||||
try:
|
||||
if site_url := common.models.InvenTreeSetting.get_setting(
|
||||
'INVENTREE_BASE_URL', create=False, cache=False
|
||||
):
|
||||
return site_url
|
||||
except (ProgrammingError, OperationalError):
|
||||
pass
|
||||
|
||||
# No base URL available
|
||||
return ''
|
||||
|
||||
|
||||
def construct_absolute_url(*arg, base_url=None, request=None):
|
||||
"""Construct (or attempt to construct) an absolute URL from a relative URL.
|
||||
|
||||
Args:
|
||||
*arg: The relative URL to construct
|
||||
base_url: The base URL to use for the construction (if not provided, will attempt to determine from settings)
|
||||
request: The request object to use for the construction (optional)
|
||||
"""
|
||||
relative_url = '/'.join(arg)
|
||||
|
||||
if not base_url:
|
||||
base_url = get_base_url(request=request)
|
||||
|
||||
return urljoin(base_url, relative_url)
|
||||
|
||||
|
||||
def download_image_from_url(remote_url, timeout=2.5):
|
||||
"""Download an image file from a remote URL.
|
||||
|
||||
This is a potentially dangerous operation, so we must perform some checks:
|
||||
- The remote URL is available
|
||||
- The Content-Length is provided, and is not too large
|
||||
- The file is a valid image file
|
||||
|
||||
Arguments:
|
||||
remote_url: The remote URL to retrieve image
|
||||
max_size: Maximum allowed image size (default = 1MB)
|
||||
timeout: Connection timeout in seconds (default = 5)
|
||||
|
||||
Returns:
|
||||
An in-memory PIL image file, if the download was successful
|
||||
|
||||
Raises:
|
||||
requests.exceptions.ConnectionError: Connection could not be established
|
||||
requests.exceptions.Timeout: Connection timed out
|
||||
requests.exceptions.HTTPError: Server responded with invalid response code
|
||||
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)
|
||||
|
||||
# Calculate maximum allowable image size (in bytes)
|
||||
max_size = (
|
||||
int(
|
||||
common.models.InvenTreeSetting.get_setting(
|
||||
'INVENTREE_DOWNLOAD_IMAGE_MAX_SIZE'
|
||||
)
|
||||
)
|
||||
* 1024
|
||||
* 1024
|
||||
)
|
||||
|
||||
# Add user specified user-agent to request (if specified)
|
||||
user_agent = common.models.InvenTreeSetting.get_setting(
|
||||
'INVENTREE_DOWNLOAD_FROM_URL_USER_AGENT'
|
||||
)
|
||||
if user_agent:
|
||||
headers = {'User-Agent': user_agent}
|
||||
else:
|
||||
headers = None
|
||||
|
||||
try:
|
||||
response = requests.get(
|
||||
remote_url,
|
||||
timeout=timeout,
|
||||
allow_redirects=True,
|
||||
stream=True,
|
||||
headers=headers,
|
||||
)
|
||||
# Throw an error if anything goes wrong
|
||||
response.raise_for_status()
|
||||
except requests.exceptions.ConnectionError as exc:
|
||||
raise Exception(_('Connection error') + f': {str(exc)}')
|
||||
except requests.exceptions.Timeout as exc:
|
||||
raise exc
|
||||
except requests.exceptions.HTTPError:
|
||||
raise requests.exceptions.HTTPError(
|
||||
_('Server responded with invalid status code') + f': {response.status_code}'
|
||||
)
|
||||
except Exception as exc:
|
||||
raise Exception(_('Exception occurred') + f': {str(exc)}')
|
||||
|
||||
if response.status_code != 200:
|
||||
raise Exception(
|
||||
_('Server responded with invalid status code') + f': {response.status_code}'
|
||||
)
|
||||
|
||||
try:
|
||||
content_length = int(response.headers.get('Content-Length', 0))
|
||||
except ValueError:
|
||||
raise ValueError(_('Server responded with invalid Content-Length value'))
|
||||
|
||||
if content_length > max_size:
|
||||
raise ValueError(_('Image size is too large'))
|
||||
|
||||
# Download the file, ensuring we do not exceed the reported size
|
||||
file = io.BytesIO()
|
||||
|
||||
dl_size = 0
|
||||
chunk_size = 64 * 1024
|
||||
|
||||
for chunk in response.iter_content(chunk_size=chunk_size):
|
||||
dl_size += len(chunk)
|
||||
|
||||
if dl_size > max_size:
|
||||
raise ValueError(_('Image download exceeded maximum size'))
|
||||
|
||||
file.write(chunk)
|
||||
|
||||
if dl_size == 0:
|
||||
raise ValueError(_('Remote server returned empty response'))
|
||||
|
||||
# Now, attempt to convert the downloaded data to a valid image file
|
||||
# img.verify() will throw an exception if the image is not valid
|
||||
try:
|
||||
img = Image.open(file).convert()
|
||||
img.verify()
|
||||
except Exception:
|
||||
raise TypeError(_('Supplied URL is not a valid image file'))
|
||||
|
||||
return img
|
||||
|
||||
|
||||
def render_currency(
|
||||
money,
|
||||
decimal_places=None,
|
||||
currency=None,
|
||||
min_decimal_places=None,
|
||||
max_decimal_places=None,
|
||||
include_symbol=True,
|
||||
):
|
||||
"""Render a currency / Money object to a formatted string (e.g. for reports).
|
||||
|
||||
Arguments:
|
||||
money: The Money instance to be rendered
|
||||
decimal_places: The number of decimal places to render to. If unspecified, uses the PRICING_DECIMAL_PLACES setting.
|
||||
currency: Optionally convert to the specified currency
|
||||
min_decimal_places: The minimum number of decimal places to render to. If unspecified, uses the PRICING_DECIMAL_PLACES_MIN setting.
|
||||
max_decimal_places: The maximum number of decimal places to render to. If unspecified, uses the PRICING_DECIMAL_PLACES setting.
|
||||
include_symbol: If True, include the currency symbol in the output
|
||||
"""
|
||||
if money in [None, '']:
|
||||
return '-'
|
||||
|
||||
if type(money) is not Money:
|
||||
return '-'
|
||||
|
||||
if currency is not None:
|
||||
# Attempt to convert to the provided currency
|
||||
# If cannot be done, leave the original
|
||||
try:
|
||||
money = convert_money(money, currency)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if decimal_places is None:
|
||||
decimal_places = common.models.InvenTreeSetting.get_setting(
|
||||
'PRICING_DECIMAL_PLACES', 6
|
||||
)
|
||||
|
||||
if min_decimal_places is None:
|
||||
min_decimal_places = common.models.InvenTreeSetting.get_setting(
|
||||
'PRICING_DECIMAL_PLACES_MIN', 0
|
||||
)
|
||||
|
||||
if max_decimal_places is None:
|
||||
max_decimal_places = common.models.InvenTreeSetting.get_setting(
|
||||
'PRICING_DECIMAL_PLACES', 6
|
||||
)
|
||||
|
||||
value = Decimal(str(money.amount)).normalize()
|
||||
value = str(value)
|
||||
|
||||
if '.' in value:
|
||||
decimals = len(value.split('.')[-1])
|
||||
|
||||
decimals = max(decimals, min_decimal_places)
|
||||
decimals = min(decimals, decimal_places)
|
||||
|
||||
decimal_places = decimals
|
||||
else:
|
||||
decimal_places = max(decimal_places, 2)
|
||||
|
||||
decimal_places = max(decimal_places, max_decimal_places)
|
||||
|
||||
return format_money(
|
||||
money, decimal_places=decimal_places, include_symbol=include_symbol
|
||||
)
|
||||
|
||||
|
||||
def getModelsWithMixin(mixin_class) -> list:
|
||||
"""Return a list of models that inherit from the given mixin class.
|
||||
|
||||
Args:
|
||||
mixin_class: The mixin class to search for
|
||||
Returns:
|
||||
List of models that inherit from the given mixin class
|
||||
"""
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
|
||||
try:
|
||||
db_models = [
|
||||
x.model_class() for x in ContentType.objects.all() if x is not None
|
||||
]
|
||||
except (OperationalError, ProgrammingError):
|
||||
# Database is likely not yet ready
|
||||
db_models = []
|
||||
|
||||
return [x for x in db_models if x is not None and issubclass(x, mixin_class)]
|
||||
|
||||
|
||||
def notify_responsible(
|
||||
instance,
|
||||
sender,
|
||||
content: NotificationBody = InvenTreeNotificationBodies.NewOrder,
|
||||
exclude=None,
|
||||
):
|
||||
"""Notify all responsible parties of a change in an instance.
|
||||
|
||||
Parses the supplied content with the provided instance and sender and sends a notification to all responsible users,
|
||||
excluding the optional excluded list.
|
||||
|
||||
Args:
|
||||
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.
|
||||
"""
|
||||
import InvenTree.ready
|
||||
|
||||
if InvenTree.ready.isImportingData() or InvenTree.ready.isRunningMigrations():
|
||||
return
|
||||
|
||||
notify_users(
|
||||
[instance.responsible], instance, sender, content=content, exclude=exclude
|
||||
)
|
||||
|
||||
|
||||
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,
|
||||
)
|
||||
@@ -2,22 +2,17 @@
|
||||
|
||||
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
|
||||
- where <language_code> is the code for the new language
|
||||
|
||||
where <language_code> is the code for the new language
|
||||
Additionally, update the following files with the new locale code:
|
||||
|
||||
- /src/frontend/.linguirc file
|
||||
- /src/frontend/src/contexts/LanguageContext.tsx
|
||||
|
||||
(and then run "invoke int.frontend-trans")
|
||||
- /src/frontend/src/context/LanguageContext.tsx
|
||||
"""
|
||||
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
LOCALES = [
|
||||
('ar', _('Arabic')),
|
||||
('bg', _('Bulgarian')),
|
||||
('cs', _('Czech')),
|
||||
('da', _('Danish')),
|
||||
@@ -26,7 +21,6 @@ LOCALES = [
|
||||
('en', _('English')),
|
||||
('es', _('Spanish')),
|
||||
('es-mx', _('Spanish (Mexican)')),
|
||||
('et', _('Estonian')),
|
||||
('fa', _('Farsi / Persian')),
|
||||
('fi', _('Finnish')),
|
||||
('fr', _('French')),
|
||||
@@ -36,14 +30,11 @@ LOCALES = [
|
||||
('it', _('Italian')),
|
||||
('ja', _('Japanese')),
|
||||
('ko', _('Korean')),
|
||||
('lt', _('Lithuanian')),
|
||||
('lv', _('Latvian')),
|
||||
('nl', _('Dutch')),
|
||||
('no', _('Norwegian')),
|
||||
('pl', _('Polish')),
|
||||
('pt', _('Portuguese')),
|
||||
('pt-br', _('Portuguese (Brazilian)')),
|
||||
('ro', _('Romanian')),
|
||||
('ru', _('Russian')),
|
||||
('sk', _('Slovak')),
|
||||
('sl', _('Slovenian')),
|
||||
@@ -51,7 +42,6 @@ LOCALES = [
|
||||
('sv', _('Swedish')),
|
||||
('th', _('Thai')),
|
||||
('tr', _('Turkish')),
|
||||
('uk', _('Ukrainian')),
|
||||
('vi', _('Vietnamese')),
|
||||
('zh-hans', _('Chinese (Simplified)')),
|
||||
('zh-hant', _('Chinese (Traditional)')),
|
||||
@@ -2,20 +2,17 @@
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import User
|
||||
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
|
||||
import structlog
|
||||
from rest_framework import serializers
|
||||
from rest_framework.generics import GenericAPIView
|
||||
from rest_framework.response import Response
|
||||
|
||||
import InvenTree.version
|
||||
from InvenTree.helpers_email import send_email
|
||||
|
||||
logger = structlog.get_logger('inventree')
|
||||
|
||||
|
||||
def send_simple_login_email(user, link):
|
||||
@@ -27,11 +24,11 @@ def send_simple_login_email(user, link):
|
||||
'InvenTree/user_simple_login.txt', context
|
||||
)
|
||||
|
||||
send_email(
|
||||
f'[{site_name}] ' + _('Log in to the app'),
|
||||
send_mail(
|
||||
_(f'[{site_name}] Log in to the app'),
|
||||
email_plaintext_message,
|
||||
[user.email],
|
||||
settings.DEFAULT_FROM_EMAIL,
|
||||
[user.email],
|
||||
)
|
||||
|
||||
|
||||
@@ -58,7 +55,7 @@ class GetSimpleLoginView(GenericAPIView):
|
||||
"""Notify user about link."""
|
||||
user = self.get_user(email)
|
||||
if user is None:
|
||||
logger.warning('User email not found: %s', email)
|
||||
print('user not found:', email)
|
||||
return
|
||||
link = self.create_link(user)
|
||||
send_simple_login_email(user, link)
|
||||
@@ -1,12 +1,12 @@
|
||||
"""Check if there are any pending database migrations, and run them."""
|
||||
|
||||
import logging
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
import structlog
|
||||
|
||||
from InvenTree.tasks import check_for_migrations
|
||||
|
||||
logger = structlog.get_logger('inventree')
|
||||
logger = logging.getLogger('inventree')
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
@@ -1,10 +1,10 @@
|
||||
"""Custom management command to cleanup old settings that are not defined anymore."""
|
||||
|
||||
import logging
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
import structlog
|
||||
|
||||
logger = structlog.get_logger('inventree')
|
||||
logger = logging.getLogger('inventree')
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
66
InvenTree/InvenTree/management/commands/prerender.py
Normal file
66
InvenTree/InvenTree/management/commands/prerender.py
Normal file
@@ -0,0 +1,66 @@
|
||||
"""Custom management command to prerender files."""
|
||||
|
||||
import os
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.http.request import HttpRequest
|
||||
from django.template.loader import render_to_string
|
||||
from django.utils.module_loading import import_string
|
||||
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
|
||||
locale = locale.lower()
|
||||
locale = locale.replace('_', '-')
|
||||
|
||||
target_file = os.path.join(target, locale + '.' + file_name)
|
||||
|
||||
with open(target_file, 'w') as localised_file:
|
||||
with lang_over(locale):
|
||||
rendered = render_to_string(os.path.join(source, file_name), ctx)
|
||||
localised_file.write(rendered)
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
"""Django command to prerender files."""
|
||||
|
||||
def handle(self, *args, **kwargs):
|
||||
"""Django command to prerender files."""
|
||||
# static directories
|
||||
LC_DIR = settings.LOCALE_PATHS[0]
|
||||
SOURCE_DIR = settings.STATICFILES_I18_SRC
|
||||
TARGET_DIR = settings.STATICFILES_I18_TRG
|
||||
|
||||
# ensure static directory exists
|
||||
if not os.path.exists(TARGET_DIR):
|
||||
os.makedirs(TARGET_DIR, exist_ok=True)
|
||||
|
||||
# collect locales
|
||||
locales = {}
|
||||
for locale in os.listdir(LC_DIR):
|
||||
path = os.path.join(LC_DIR, locale)
|
||||
if os.path.exists(path) and os.path.isdir(path):
|
||||
locales[locale] = locale
|
||||
|
||||
# render!
|
||||
request = HttpRequest()
|
||||
ctx = {}
|
||||
processors = tuple(
|
||||
import_string(path) for path in settings.STATFILES_I18_PROCESSORS
|
||||
)
|
||||
for processor in processors:
|
||||
ctx.update(processor(request))
|
||||
|
||||
for file in os.listdir(SOURCE_DIR):
|
||||
path = os.path.join(SOURCE_DIR, file)
|
||||
if os.path.exists(path) and os.path.isfile(path):
|
||||
render_file(file, SOURCE_DIR, TARGET_DIR, locales, ctx)
|
||||
else:
|
||||
raise NotImplementedError(
|
||||
'Using multi-level directories is not implemented at this point'
|
||||
) # TODO multilevel dir if needed
|
||||
print(f'Rendered all files in {SOURCE_DIR}')
|
||||
@@ -3,12 +3,13 @@
|
||||
- This is crucial after importing any fixtures, etc
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
import structlog
|
||||
from maintenance_mode.core import maintenance_mode_on, set_maintenance_mode
|
||||
|
||||
logger = structlog.get_logger('inventree')
|
||||
logger = logging.getLogger('inventree')
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
@@ -3,18 +3,18 @@
|
||||
- May be required after importing a new dataset, for example
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.db.utils import OperationalError, ProgrammingError
|
||||
|
||||
import structlog
|
||||
from PIL import UnidentifiedImageError
|
||||
|
||||
from company.models import Company
|
||||
from part.models import Part
|
||||
|
||||
logger = structlog.get_logger('inventree')
|
||||
logger = logging.getLogger('inventree')
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
@@ -35,7 +35,7 @@ class Command(BaseCommand):
|
||||
img_paths.append(x.path)
|
||||
|
||||
if len(img_paths) > 0:
|
||||
if all(os.path.exists(path) for path in img_paths):
|
||||
if all((os.path.exists(path) for path in img_paths)):
|
||||
# All images exist - skip further work
|
||||
return
|
||||
|
||||
38
InvenTree/InvenTree/management/commands/remove_mfa.py
Normal file
38
InvenTree/InvenTree/management/commands/remove_mfa.py
Normal file
@@ -0,0 +1,38 @@
|
||||
"""Custom management command to remove MFA for a user."""
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
"""Remove MFA for a user."""
|
||||
|
||||
def add_arguments(self, parser):
|
||||
"""Add the arguments."""
|
||||
parser.add_argument('mail', type=str)
|
||||
|
||||
def handle(self, *args, **kwargs):
|
||||
"""Remove MFA for the supplied user (by mail)."""
|
||||
# general settings
|
||||
mail = kwargs.get('mail')
|
||||
if not mail:
|
||||
raise KeyError('A mail is required')
|
||||
user = get_user_model()
|
||||
mfa_user = [
|
||||
*set(
|
||||
user.objects.filter(email=mail)
|
||||
| user.objects.filter(emailaddress__email=mail)
|
||||
)
|
||||
]
|
||||
|
||||
if len(mfa_user) == 0:
|
||||
print('No user with this mail associated')
|
||||
elif len(mfa_user) > 1:
|
||||
print('More than one user found with this mail')
|
||||
else:
|
||||
# and clean out all MFA methods
|
||||
# backup codes
|
||||
mfa_user[0].staticdevice_set.all().delete()
|
||||
# TOTP tokens
|
||||
mfa_user[0].totpdevice_set.all().delete()
|
||||
print(f'Removed all MFA methods for user {str(mfa_user[0])}')
|
||||
@@ -1,12 +1,12 @@
|
||||
"""Check if there are any pending database migrations, and run them."""
|
||||
|
||||
import logging
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
import structlog
|
||||
|
||||
from InvenTree.tasks import check_for_migrations
|
||||
|
||||
logger = structlog.get_logger('inventree')
|
||||
logger = logging.getLogger('inventree')
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
@@ -2,10 +2,9 @@
|
||||
|
||||
import time
|
||||
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.db import connection
|
||||
from django.db.utils import OperationalError
|
||||
from django.db.utils import ImproperlyConfigured, OperationalError
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
293
InvenTree/InvenTree/metadata.py
Normal file
293
InvenTree/InvenTree/metadata.py
Normal file
@@ -0,0 +1,293 @@
|
||||
"""Custom metadata for DRF."""
|
||||
|
||||
import logging
|
||||
|
||||
from rest_framework import serializers
|
||||
from rest_framework.fields import empty
|
||||
from rest_framework.metadata import SimpleMetadata
|
||||
from rest_framework.utils import model_meta
|
||||
|
||||
import common.models
|
||||
import InvenTree.permissions
|
||||
import users.models
|
||||
from InvenTree.helpers import str2bool
|
||||
from InvenTree.serializers import DependentField
|
||||
|
||||
logger = logging.getLogger('inventree')
|
||||
|
||||
|
||||
class InvenTreeMetadata(SimpleMetadata):
|
||||
"""Custom metadata class for the DRF API.
|
||||
|
||||
This custom metadata class imits the available "actions",
|
||||
based on the user's role permissions.
|
||||
|
||||
Thus when a client send an OPTIONS request to an API endpoint,
|
||||
it will only receive a list of actions which it is allowed to perform!
|
||||
|
||||
Additionally, we include some extra information about database models,
|
||||
so we can perform lookup for ForeignKey related fields.
|
||||
"""
|
||||
|
||||
def determine_metadata(self, request, view):
|
||||
"""Overwrite the metadata to adapt to the request user."""
|
||||
self.request = request
|
||||
self.view = view
|
||||
|
||||
metadata = super().determine_metadata(request, view)
|
||||
|
||||
"""
|
||||
Custom context information to pass through to the OPTIONS endpoint,
|
||||
if the "context=True" is supplied to the OPTIONS request
|
||||
|
||||
Serializer class can supply context data by defining a get_context_data() method (no arguments)
|
||||
"""
|
||||
|
||||
context = {}
|
||||
|
||||
if str2bool(request.query_params.get('context', False)):
|
||||
if hasattr(self, 'serializer') and hasattr(
|
||||
self.serializer, 'get_context_data'
|
||||
):
|
||||
context = self.serializer.get_context_data()
|
||||
|
||||
metadata['context'] = context
|
||||
|
||||
user = request.user
|
||||
|
||||
if user is None:
|
||||
# No actions for you!
|
||||
metadata['actions'] = {}
|
||||
return metadata
|
||||
|
||||
try:
|
||||
# Extract the model name associated with the view
|
||||
self.model = InvenTree.permissions.get_model_for_view(view)
|
||||
|
||||
# Construct the 'table name' from the model
|
||||
app_label = self.model._meta.app_label
|
||||
tbl_label = self.model._meta.model_name
|
||||
|
||||
metadata['model'] = tbl_label
|
||||
|
||||
table = f'{app_label}_{tbl_label}'
|
||||
|
||||
actions = metadata.get('actions', None)
|
||||
|
||||
if actions is None:
|
||||
actions = {}
|
||||
|
||||
check = users.models.RuleSet.check_table_permission
|
||||
|
||||
# Map the request method to a permission type
|
||||
rolemap = {
|
||||
'POST': 'add',
|
||||
'PUT': 'change',
|
||||
'PATCH': 'change',
|
||||
'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():
|
||||
result = check(user, table, permission)
|
||||
|
||||
if method in actions and not result:
|
||||
del actions[method]
|
||||
|
||||
# Add a 'DELETE' action if we are allowed to delete
|
||||
if 'DELETE' in view.allowed_methods and check(user, table, 'delete'):
|
||||
actions['DELETE'] = {}
|
||||
|
||||
# Add a 'VIEW' action if we are allowed to view
|
||||
if 'GET' in view.allowed_methods and check(user, table, 'view'):
|
||||
actions['GET'] = {}
|
||||
|
||||
metadata['actions'] = actions
|
||||
|
||||
except AttributeError:
|
||||
# We will assume that if the serializer class does *not* have a Meta
|
||||
# then we don't need a permission
|
||||
pass
|
||||
|
||||
return metadata
|
||||
|
||||
def get_serializer_info(self, serializer):
|
||||
"""Override get_serializer_info so that we can add 'default' values to any fields whose Meta.model specifies a default value."""
|
||||
self.serializer = serializer
|
||||
|
||||
serializer_info = super().get_serializer_info(serializer)
|
||||
|
||||
model_class = None
|
||||
|
||||
# Attributes to copy extra attributes from the model to the field (if they don't exist)
|
||||
extra_attributes = ['help_text', 'max_length']
|
||||
|
||||
try:
|
||||
model_class = serializer.Meta.model
|
||||
|
||||
model_fields = model_meta.get_field_info(model_class)
|
||||
|
||||
model_default_func = getattr(model_class, 'api_defaults', None)
|
||||
|
||||
if model_default_func:
|
||||
model_default_values = model_class.api_defaults(self.request)
|
||||
else:
|
||||
model_default_values = {}
|
||||
|
||||
# Iterate through simple fields
|
||||
for name, field in model_fields.fields.items():
|
||||
if name in serializer_info.keys():
|
||||
if field.has_default():
|
||||
default = field.default
|
||||
|
||||
if callable(default):
|
||||
try:
|
||||
default = default()
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
serializer_info[name]['default'] = default
|
||||
|
||||
elif name in model_default_values:
|
||||
serializer_info[name]['default'] = model_default_values[name]
|
||||
|
||||
for attr in extra_attributes:
|
||||
if attr not in serializer_info[name]:
|
||||
if hasattr(field, attr):
|
||||
serializer_info[name][attr] = getattr(field, attr)
|
||||
|
||||
# Iterate through relations
|
||||
for name, relation in model_fields.relations.items():
|
||||
if name not in serializer_info.keys():
|
||||
# Skip relation not defined in serializer
|
||||
continue
|
||||
|
||||
if relation.reverse:
|
||||
# Ignore reverse relations
|
||||
continue
|
||||
|
||||
# Extract and provide the "limit_choices_to" filters
|
||||
# This is used to automatically filter AJAX requests
|
||||
serializer_info[name]['filters'] = (
|
||||
relation.model_field.get_limit_choices_to()
|
||||
)
|
||||
|
||||
for attr in extra_attributes:
|
||||
if attr not in serializer_info[name] and hasattr(
|
||||
relation.model_field, attr
|
||||
):
|
||||
serializer_info[name][attr] = getattr(
|
||||
relation.model_field, attr
|
||||
)
|
||||
|
||||
if name in model_default_values:
|
||||
serializer_info[name]['default'] = model_default_values[name]
|
||||
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
# Try to extract 'instance' information
|
||||
instance = None
|
||||
|
||||
# Extract extra information if an instance is available
|
||||
if hasattr(serializer, 'instance'):
|
||||
instance = serializer.instance
|
||||
|
||||
if instance is None and model_class is not None:
|
||||
# Attempt to find the instance based on kwargs lookup
|
||||
kwargs = getattr(self.view, 'kwargs', None)
|
||||
|
||||
if kwargs:
|
||||
pk = None
|
||||
|
||||
for field in ['pk', 'id', 'PK', 'ID']:
|
||||
if field in kwargs:
|
||||
pk = kwargs[field]
|
||||
break
|
||||
|
||||
if issubclass(model_class, common.models.BaseInvenTreeSetting):
|
||||
instance = model_class.get_setting_object(**kwargs, create=False)
|
||||
|
||||
elif pk is not None:
|
||||
try:
|
||||
instance = model_class.objects.get(pk=pk)
|
||||
except (ValueError, model_class.DoesNotExist):
|
||||
pass
|
||||
|
||||
if instance is not None:
|
||||
"""If there is an instance associated with this API View, introspect that instance to find any specific API info."""
|
||||
|
||||
if hasattr(instance, 'api_instance_filters'):
|
||||
instance_filters = instance.api_instance_filters()
|
||||
|
||||
for field_name, field_filters in instance_filters.items():
|
||||
if field_name not in serializer_info.keys():
|
||||
# The field might be missing, but is added later on
|
||||
# This function seems to get called multiple times?
|
||||
continue
|
||||
|
||||
if 'instance_filters' not in serializer_info[field_name].keys():
|
||||
serializer_info[field_name]['instance_filters'] = {}
|
||||
|
||||
for key, value in field_filters.items():
|
||||
serializer_info[field_name]['instance_filters'][key] = value
|
||||
|
||||
return serializer_info
|
||||
|
||||
def get_field_info(self, field):
|
||||
"""Given an instance of a serializer field, return a dictionary of metadata about it.
|
||||
|
||||
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)
|
||||
|
||||
# If a default value is specified for the serializer field, add it!
|
||||
if 'default' not in field_info and field.default != empty:
|
||||
field_info['default'] = field.get_default()
|
||||
|
||||
# Force non-nullable fields to read as "required"
|
||||
# (even if there is a default value!)
|
||||
if not field.allow_null and not (
|
||||
hasattr(field, 'allow_blank') and field.allow_blank
|
||||
):
|
||||
field_info['required'] = True
|
||||
|
||||
# Introspect writable related fields
|
||||
if field_info['type'] == 'field' and not field_info['read_only']:
|
||||
# If the field is a PrimaryKeyRelatedField, we can extract the model from the queryset
|
||||
if isinstance(field, serializers.PrimaryKeyRelatedField):
|
||||
model = field.queryset.model
|
||||
else:
|
||||
logger.debug(
|
||||
'Could not extract model for:', field_info.get('label'), '->', field
|
||||
)
|
||||
model = None
|
||||
|
||||
if model:
|
||||
# Mark this field as "related", and point to the URL where we can get the data!
|
||||
field_info['type'] = 'related field'
|
||||
field_info['model'] = model._meta.model_name
|
||||
|
||||
# Special case for 'user' model
|
||||
if field_info['model'] == 'user':
|
||||
field_info['api_url'] = '/api/user/'
|
||||
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'
|
||||
221
InvenTree/InvenTree/middleware.py
Normal file
221
InvenTree/InvenTree/middleware.py
Normal file
@@ -0,0 +1,221 @@
|
||||
"""Middleware for InvenTree."""
|
||||
|
||||
import logging
|
||||
import sys
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.middleware import PersistentRemoteUserMiddleware
|
||||
from django.http import HttpResponse
|
||||
from django.shortcuts import redirect
|
||||
from django.urls import Resolver404, include, path, resolve, reverse_lazy
|
||||
|
||||
from allauth_2fa.middleware import AllauthTwoFactorMiddleware, BaseRequire2FAMiddleware
|
||||
from error_report.middleware import ExceptionProcessor
|
||||
|
||||
from InvenTree.urls import frontendpatterns
|
||||
from users.models import ApiToken
|
||||
|
||||
logger = logging.getLogger('inventree')
|
||||
|
||||
|
||||
def get_token_from_request(request):
|
||||
"""Extract token information from a request object."""
|
||||
auth_keys = ['Authorization', 'authorization']
|
||||
token_keys = ['token', 'bearer']
|
||||
|
||||
for k in auth_keys:
|
||||
if auth_header := request.headers.get(k, None):
|
||||
auth_header = auth_header.strip().lower().split()
|
||||
|
||||
if len(auth_header) > 1:
|
||||
if auth_header[0].strip().lower().replace(':', '') in token_keys:
|
||||
token = auth_header[1]
|
||||
return token
|
||||
|
||||
return None
|
||||
|
||||
|
||||
class AuthRequiredMiddleware(object):
|
||||
"""Check for user to be authenticated."""
|
||||
|
||||
def __init__(self, get_response):
|
||||
"""Save response object."""
|
||||
self.get_response = get_response
|
||||
|
||||
def check_token(self, request) -> bool:
|
||||
"""Check if the user is authenticated via token."""
|
||||
if token := get_token_from_request(request):
|
||||
# Does the provided token match a valid user?
|
||||
try:
|
||||
token = ApiToken.objects.get(key=token)
|
||||
|
||||
if token.active and token.user:
|
||||
# Provide the user information to the request
|
||||
request.user = token.user
|
||||
return True
|
||||
except ApiToken.DoesNotExist:
|
||||
logger.warning('Access denied for unknown token %s', token)
|
||||
|
||||
return False
|
||||
|
||||
def __call__(self, request):
|
||||
"""Check if user needs to be authenticated and is.
|
||||
|
||||
Redirects to login if not authenticated.
|
||||
"""
|
||||
# Code to be executed for each request before
|
||||
# the view (and later middleware) are called.
|
||||
|
||||
assert hasattr(request, 'user')
|
||||
|
||||
# API requests are handled by the DRF library
|
||||
if request.path_info.startswith('/api/'):
|
||||
return self.get_response(request)
|
||||
|
||||
# Is the function exempt from auth requirements?
|
||||
path_func = resolve(request.path).func
|
||||
if getattr(path_func, 'auth_exempt', False) is True:
|
||||
return self.get_response(request)
|
||||
|
||||
if not request.user.is_authenticated:
|
||||
"""
|
||||
Normally, a web-based session would use csrftoken based authentication.
|
||||
|
||||
However when running an external application (e.g. the InvenTree app or Python library),
|
||||
we must validate the user token manually.
|
||||
"""
|
||||
|
||||
authorized = False
|
||||
|
||||
# Allow static files to be accessed without auth
|
||||
# Important for e.g. login page
|
||||
if request.path_info.startswith('/static/'):
|
||||
authorized = True
|
||||
|
||||
# Unauthorized users can access the login page
|
||||
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 self.check_token(request):
|
||||
authorized = True
|
||||
|
||||
# No authorization was found for the request
|
||||
if not authorized:
|
||||
path = request.path_info
|
||||
|
||||
# List of URL endpoints we *do not* want to redirect to
|
||||
urls = [
|
||||
reverse_lazy('account_login'),
|
||||
reverse_lazy('account_logout'),
|
||||
reverse_lazy('admin:login'),
|
||||
reverse_lazy('admin:logout'),
|
||||
]
|
||||
|
||||
# Do not redirect requests to any of these paths
|
||||
paths_ignore = ['/api/', '/js/', '/media/', '/static/']
|
||||
|
||||
if path not in urls and not any(
|
||||
path.startswith(p) for p in paths_ignore
|
||||
):
|
||||
# Save the 'next' parameter to pass through to the login view
|
||||
|
||||
return redirect(
|
||||
f'{reverse_lazy("account_login")}?next={request.path}'
|
||||
)
|
||||
# Return a 401 (Unauthorized) response code for this request
|
||||
return HttpResponse('Unauthorized', status=401)
|
||||
|
||||
response = self.get_response(request)
|
||||
|
||||
return response
|
||||
|
||||
|
||||
url_matcher = path('', include(frontendpatterns))
|
||||
|
||||
|
||||
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:
|
||||
if url_matcher.resolve(request.path[1:]):
|
||||
return InvenTreeSetting.get_setting('LOGIN_ENFORCE_MFA')
|
||||
except Resolver404:
|
||||
pass
|
||||
return False
|
||||
|
||||
|
||||
class CustomAllauthTwoFactorMiddleware(AllauthTwoFactorMiddleware):
|
||||
"""This function ensures only frontend code triggers the MFA auth cycle."""
|
||||
|
||||
def process_request(self, request):
|
||||
"""Check if requested url is forntend and enforce MFA check."""
|
||||
try:
|
||||
if not url_matcher.resolve(request.path[1:]):
|
||||
super().process_request(request)
|
||||
except Resolver404:
|
||||
pass
|
||||
|
||||
|
||||
class InvenTreeRemoteUserMiddleware(PersistentRemoteUserMiddleware):
|
||||
"""Middleware to check if HTTP-header based auth is enabled and to set it up."""
|
||||
|
||||
header = settings.REMOTE_LOGIN_HEADER
|
||||
|
||||
def process_request(self, request):
|
||||
"""Check if proxy login is enabled."""
|
||||
if not settings.REMOTE_LOGIN:
|
||||
return
|
||||
|
||||
return super().process_request(request)
|
||||
|
||||
|
||||
class InvenTreeExceptionProcessor(ExceptionProcessor):
|
||||
"""Custom exception processor that respects blocked errors."""
|
||||
|
||||
def process_exception(self, request, exception):
|
||||
"""Check if kind is ignored before processing."""
|
||||
kind, info, data = sys.exc_info()
|
||||
|
||||
# Check if the error is on the ignore list
|
||||
if kind in settings.IGNORED_ERRORS:
|
||||
return
|
||||
|
||||
import traceback
|
||||
|
||||
from django.views.debug import ExceptionReporter
|
||||
|
||||
from error_report.models import Error
|
||||
from error_report.settings import ERROR_DETAIL_SETTINGS
|
||||
|
||||
# Error reporting is disabled
|
||||
if not ERROR_DETAIL_SETTINGS.get('ERROR_DETAIL_ENABLE', True):
|
||||
return
|
||||
|
||||
path = request.build_absolute_uri()
|
||||
|
||||
# Truncate the path to a reasonable length
|
||||
# Otherwise we get a database error,
|
||||
# because the path field is limited to 200 characters
|
||||
if len(path) > 200:
|
||||
path = path[:195] + '...'
|
||||
|
||||
error = Error.objects.create(
|
||||
kind=kind.__name__,
|
||||
html=ExceptionReporter(request, kind, info, data).get_traceback_html(),
|
||||
path=path,
|
||||
info=info,
|
||||
data='\n'.join(traceback.format_exception(kind, info, data)),
|
||||
)
|
||||
|
||||
error.save()
|
||||
188
InvenTree/InvenTree/mixins.py
Normal file
188
InvenTree/InvenTree/mixins.py
Normal file
@@ -0,0 +1,188 @@
|
||||
"""Mixins for (API) views in the whole project."""
|
||||
|
||||
from django.core.exceptions import FieldDoesNotExist
|
||||
|
||||
from rest_framework import generics, mixins, status
|
||||
from rest_framework.response import Response
|
||||
|
||||
from InvenTree.fields import InvenTreeNotesField
|
||||
from InvenTree.helpers import remove_non_printable_characters, strip_html_tags
|
||||
|
||||
|
||||
class CleanMixin:
|
||||
"""Model mixin class which cleans inputs using the Mozilla bleach tools."""
|
||||
|
||||
# Define a list of field names which will *not* be cleaned
|
||||
SAFE_FIELDS = []
|
||||
|
||||
def create(self, request, *args, **kwargs):
|
||||
"""Override to clean data before processing it."""
|
||||
serializer = self.get_serializer(data=self.clean_data(request.data))
|
||||
serializer.is_valid(raise_exception=True)
|
||||
self.perform_create(serializer)
|
||||
headers = self.get_success_headers(serializer.data)
|
||||
return Response(
|
||||
serializer.data, status=status.HTTP_201_CREATED, headers=headers
|
||||
)
|
||||
|
||||
def update(self, request, *args, **kwargs):
|
||||
"""Override to clean data before processing it."""
|
||||
partial = kwargs.pop('partial', False)
|
||||
instance = self.get_object()
|
||||
serializer = self.get_serializer(
|
||||
instance, data=self.clean_data(request.data), partial=partial
|
||||
)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
self.perform_update(serializer)
|
||||
|
||||
if getattr(instance, '_prefetched_objects_cache', None):
|
||||
# If 'prefetch_related' has been applied to a queryset, we need to
|
||||
# forcibly invalidate the prefetch cache on the instance.
|
||||
instance._prefetched_objects_cache = {}
|
||||
|
||||
return Response(serializer.data)
|
||||
|
||||
def clean_string(self, field: str, data: str) -> str:
|
||||
"""Clean / sanitize a single input string.
|
||||
|
||||
Note that this function will *allow* orphaned <>& characters,
|
||||
which would normally be escaped by bleach.
|
||||
|
||||
Nominally, the only thing that will be "cleaned" will be HTML tags
|
||||
|
||||
Ref: https://github.com/mozilla/bleach/issues/192
|
||||
|
||||
"""
|
||||
cleaned = strip_html_tags(data, field_name=field)
|
||||
|
||||
# By default, newline characters are removed
|
||||
remove_newline = True
|
||||
|
||||
try:
|
||||
if hasattr(self, 'serializer_class'):
|
||||
model = self.serializer_class.Meta.model
|
||||
field = model._meta.get_field(field)
|
||||
|
||||
# The following field types allow newline characters
|
||||
allow_newline = [InvenTreeNotesField]
|
||||
|
||||
for field_type in allow_newline:
|
||||
if issubclass(type(field), field_type):
|
||||
remove_newline = False
|
||||
break
|
||||
|
||||
except AttributeError:
|
||||
pass
|
||||
except FieldDoesNotExist:
|
||||
pass
|
||||
|
||||
cleaned = remove_non_printable_characters(
|
||||
cleaned, remove_newline=remove_newline
|
||||
)
|
||||
|
||||
return cleaned
|
||||
|
||||
def clean_data(self, data: dict) -> dict:
|
||||
"""Clean / sanitize data.
|
||||
|
||||
This uses mozillas bleach under the hood to disable certain html tags by
|
||||
encoding them - this leads to script tags etc. to not work.
|
||||
The results can be longer then the input; might make some character combinations
|
||||
`ugly`. Prevents XSS on the server-level.
|
||||
|
||||
Args:
|
||||
data (dict): Data that should be Sanitized.
|
||||
|
||||
Returns:
|
||||
dict: Provided data Sanitized; still in the same order.
|
||||
"""
|
||||
clean_data = {}
|
||||
|
||||
for k, v in data.items():
|
||||
if k in self.SAFE_FIELDS:
|
||||
ret = v
|
||||
elif isinstance(v, str):
|
||||
ret = self.clean_string(k, v)
|
||||
elif isinstance(v, dict):
|
||||
ret = self.clean_data(v)
|
||||
else:
|
||||
ret = v
|
||||
|
||||
clean_data[k] = ret
|
||||
|
||||
return clean_data
|
||||
|
||||
|
||||
class ListAPI(generics.ListAPIView):
|
||||
"""View for list API."""
|
||||
|
||||
|
||||
class ListCreateAPI(CleanMixin, generics.ListCreateAPIView):
|
||||
"""View for list and create API."""
|
||||
|
||||
|
||||
class CreateAPI(CleanMixin, generics.CreateAPIView):
|
||||
"""View for create API."""
|
||||
|
||||
|
||||
class RetrieveAPI(generics.RetrieveAPIView):
|
||||
"""View for retrieve API."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class RetrieveUpdateAPI(CleanMixin, generics.RetrieveUpdateAPIView):
|
||||
"""View for retrieve and update API."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class CustomDestroyModelMixin:
|
||||
"""This mixin was created pass the kwargs from the API to the models."""
|
||||
|
||||
def destroy(self, request, *args, **kwargs):
|
||||
"""Custom destroy method to pass kwargs."""
|
||||
instance = self.get_object()
|
||||
self.perform_destroy(instance, **kwargs)
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
def perform_destroy(self, instance, **kwargs):
|
||||
"""Custom destroy method to pass kwargs."""
|
||||
instance.delete(**kwargs)
|
||||
|
||||
|
||||
class CustomRetrieveUpdateDestroyAPIView(
|
||||
mixins.RetrieveModelMixin,
|
||||
mixins.UpdateModelMixin,
|
||||
CustomDestroyModelMixin,
|
||||
generics.GenericAPIView,
|
||||
):
|
||||
"""This APIView was created pass the kwargs from the API to the models."""
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
"""Custom get method to pass kwargs."""
|
||||
return self.retrieve(request, *args, **kwargs)
|
||||
|
||||
def put(self, request, *args, **kwargs):
|
||||
"""Custom put method to pass kwargs."""
|
||||
return self.update(request, *args, **kwargs)
|
||||
|
||||
def patch(self, request, *args, **kwargs):
|
||||
"""Custom patch method to pass kwargs."""
|
||||
return self.partial_update(request, *args, **kwargs)
|
||||
|
||||
def delete(self, request, *args, **kwargs):
|
||||
"""Custom delete method to pass kwargs."""
|
||||
return self.destroy(request, *args, **kwargs)
|
||||
|
||||
|
||||
class CustomRetrieveUpdateDestroyAPI(CleanMixin, CustomRetrieveUpdateDestroyAPIView):
|
||||
"""This APIView was created pass the kwargs from the API to the models."""
|
||||
|
||||
|
||||
class RetrieveUpdateDestroyAPI(CleanMixin, generics.RetrieveUpdateDestroyAPIView):
|
||||
"""View for retrieve, update and destroy API."""
|
||||
|
||||
|
||||
class UpdateAPI(CleanMixin, generics.UpdateAPIView):
|
||||
"""View for update API."""
|
||||
1179
InvenTree/InvenTree/models.py
Normal file
1179
InvenTree/InvenTree/models.py
Normal file
File diff suppressed because it is too large
Load Diff
121
InvenTree/InvenTree/permissions.py
Normal file
121
InvenTree/InvenTree/permissions.py
Normal file
@@ -0,0 +1,121 @@
|
||||
"""Permission set for InvenTree."""
|
||||
|
||||
from functools import wraps
|
||||
|
||||
from rest_framework import permissions
|
||||
|
||||
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()
|
||||
|
||||
if hasattr(view, 'serializer_class'):
|
||||
return view.serializer_class.Meta.model
|
||||
|
||||
if hasattr(view, 'get_serializer_class'):
|
||||
return view.get_serializr_class().Meta.model
|
||||
|
||||
raise AttributeError(f'Serializer class not specified for {view.__class__}')
|
||||
|
||||
|
||||
class RolePermission(permissions.BasePermission):
|
||||
"""Role mixin for API endpoints, allowing us to specify the user "role" which is required for certain operations.
|
||||
|
||||
Each endpoint can have one or more of the following actions:
|
||||
- GET
|
||||
- POST
|
||||
- PUT
|
||||
- PATCH
|
||||
- DELETE
|
||||
|
||||
Specify the required "role" using the role_required attribute.
|
||||
|
||||
e.g.
|
||||
|
||||
role_required = "part"
|
||||
|
||||
The RoleMixin class will then determine if the user has the required permission
|
||||
to perform the specified action.
|
||||
|
||||
For example, a DELETE action will be rejected unless the user has the "part.remove" permission
|
||||
"""
|
||||
|
||||
def has_permission(self, request, view):
|
||||
"""Determine if the current user has the specified permissions."""
|
||||
user = request.user
|
||||
|
||||
# Superuser can do it all
|
||||
if user.is_superuser:
|
||||
return True
|
||||
|
||||
# Map the request method to a permission type
|
||||
rolemap = {
|
||||
'GET': 'view',
|
||||
'OPTIONS': 'view',
|
||||
'POST': 'add',
|
||||
'PUT': 'change',
|
||||
'PATCH': 'change',
|
||||
'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
|
||||
if role := getattr(view, 'role_required', None):
|
||||
# If the role is specified as "role.permission", split it
|
||||
if '.' in role:
|
||||
role, permission = role.split('.')
|
||||
|
||||
return users.models.check_user_role(user, role, permission)
|
||||
|
||||
try:
|
||||
# Extract the model name associated with this request
|
||||
model = get_model_for_view(view)
|
||||
|
||||
app_label = model._meta.app_label
|
||||
model_name = model._meta.model_name
|
||||
|
||||
table = f'{app_label}_{model_name}'
|
||||
except AttributeError:
|
||||
# We will assume that if the serializer class does *not* have a Meta,
|
||||
# then we don't need a permission
|
||||
return True
|
||||
|
||||
return users.models.RuleSet.check_table_permission(user, table, permission)
|
||||
|
||||
|
||||
class IsSuperuser(permissions.IsAdminUser):
|
||||
"""Allows access only to superuser users."""
|
||||
|
||||
def has_permission(self, request, view):
|
||||
"""Check if the user is a superuser."""
|
||||
return bool(request.user and request.user.is_superuser)
|
||||
|
||||
|
||||
class IsStaffOrReadOnly(permissions.IsAdminUser):
|
||||
"""Allows read-only access to any user, but write access is restricted to staff users."""
|
||||
|
||||
def has_permission(self, request, view):
|
||||
"""Check if the user is a superuser."""
|
||||
return bool(
|
||||
request.user
|
||||
and request.user.is_staff
|
||||
or request.method in permissions.SAFE_METHODS
|
||||
)
|
||||
|
||||
|
||||
def auth_exempt(view_func):
|
||||
"""Mark a view function as being exempt from auth requirements."""
|
||||
|
||||
def wrapped_view(*args, **kwargs):
|
||||
return view_func(*args, **kwargs)
|
||||
|
||||
wrapped_view.auth_exempt = True
|
||||
return wraps(view_func)(wrapped_view)
|
||||
149
InvenTree/InvenTree/ready.py
Normal file
149
InvenTree/InvenTree/ready.py
Normal file
@@ -0,0 +1,149 @@
|
||||
"""Functions to check if certain parts of InvenTree are ready."""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
|
||||
def isInTestMode():
|
||||
"""Returns True if the database is in testing mode."""
|
||||
return 'test' in sys.argv
|
||||
|
||||
|
||||
def isImportingData():
|
||||
"""Returns True if the database is currently importing (or exporting) data, e.g. 'loaddata' command is performed."""
|
||||
return any((x in sys.argv for x in ['flush', 'loaddata', 'dumpdata']))
|
||||
|
||||
|
||||
def isRunningMigrations():
|
||||
"""Return True if the database is currently running migrations."""
|
||||
return any(
|
||||
(
|
||||
x in sys.argv
|
||||
for x in ['migrate', 'makemigrations', 'showmigrations', 'runmigrations']
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def isRebuildingData():
|
||||
"""Return true if any of the rebuilding commands are being executed."""
|
||||
return any(
|
||||
(
|
||||
x in sys.argv
|
||||
for x in ['prerender', 'rebuild_models', 'rebuild_thumbnails', 'rebuild']
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def isRunningBackup():
|
||||
"""Return true if any of the backup commands are being executed."""
|
||||
return any(
|
||||
(
|
||||
x in sys.argv
|
||||
for x in [
|
||||
'backup',
|
||||
'restore',
|
||||
'dbbackup',
|
||||
'dbresotore',
|
||||
'mediabackup',
|
||||
'mediarestore',
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def isInWorkerThread():
|
||||
"""Returns True if the current thread is a background worker thread."""
|
||||
return 'qcluster' in sys.argv
|
||||
|
||||
|
||||
def isInServerThread():
|
||||
"""Returns True if the current thread is a server thread."""
|
||||
if isInWorkerThread():
|
||||
return False
|
||||
|
||||
if 'runserver' in sys.argv:
|
||||
return True
|
||||
|
||||
if 'gunicorn' in sys.argv[0]:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
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 not isInWorkerThread()
|
||||
|
||||
|
||||
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.
|
||||
|
||||
There are some circumstances where we don't want the ready function in apps.py
|
||||
to touch the database
|
||||
"""
|
||||
# Prevent database access if we are running backups
|
||||
if isRunningBackup():
|
||||
return False
|
||||
|
||||
# Prevent database access if we are importing data
|
||||
if isImportingData():
|
||||
return False
|
||||
|
||||
# Prevent database access if we are rebuilding data
|
||||
if isRebuildingData():
|
||||
return False
|
||||
|
||||
# Prevent database access if we are running migrations
|
||||
if not allow_plugins and isRunningMigrations():
|
||||
return False
|
||||
|
||||
# If any of the following management commands are being executed,
|
||||
# prevent custom "on load" code from running!
|
||||
excluded_commands = [
|
||||
'check',
|
||||
'createsuperuser',
|
||||
'wait_for_db',
|
||||
'makemessages',
|
||||
'compilemessages',
|
||||
]
|
||||
|
||||
if not allow_shell:
|
||||
excluded_commands.append('shell')
|
||||
|
||||
if not allow_test:
|
||||
# Override for testing mode?
|
||||
excluded_commands.append('test')
|
||||
|
||||
if not allow_plugins:
|
||||
excluded_commands.extend(['collectstatic'])
|
||||
|
||||
for cmd in excluded_commands:
|
||||
if cmd in sys.argv:
|
||||
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
|
||||
@@ -188,8 +188,8 @@ ALLOWED_ATTRIBUTES_SVG = [
|
||||
def sanitize_svg(
|
||||
file_data,
|
||||
strip: bool = True,
|
||||
elements: list[str] = ALLOWED_ELEMENTS_SVG,
|
||||
attributes: list[str] = ALLOWED_ATTRIBUTES_SVG,
|
||||
elements: str = ALLOWED_ELEMENTS_SVG,
|
||||
attributes: str = ALLOWED_ATTRIBUTES_SVG,
|
||||
) -> str:
|
||||
"""Sanitize a SVG file.
|
||||
|
||||
73
InvenTree/InvenTree/sentry.py
Normal file
73
InvenTree/InvenTree/sentry.py
Normal file
@@ -0,0 +1,73 @@
|
||||
"""Configuration for Sentry.io error reporting."""
|
||||
|
||||
import logging
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.http import Http404
|
||||
|
||||
import rest_framework.exceptions
|
||||
import sentry_sdk
|
||||
from sentry_sdk.integrations.django import DjangoIntegration
|
||||
|
||||
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'
|
||||
|
||||
|
||||
def sentry_ignore_errors():
|
||||
"""Return a list of error types to ignore.
|
||||
|
||||
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,
|
||||
]
|
||||
|
||||
|
||||
def init_sentry(dsn, sample_rate, tags):
|
||||
"""Initialize sentry.io error reporting."""
|
||||
logger.info('Initializing sentry.io integration')
|
||||
|
||||
sentry_sdk.init(
|
||||
dsn=dsn,
|
||||
integrations=[DjangoIntegration()],
|
||||
traces_sample_rate=sample_rate,
|
||||
send_default_pii=True,
|
||||
ignore_errors=sentry_ignore_errors(),
|
||||
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('Reporting exception to sentry.io: %s', exc)
|
||||
|
||||
try:
|
||||
sentry_sdk.capture_exception(exc)
|
||||
except Exception:
|
||||
logger.warning('Failed to report exception to sentry.io')
|
||||
882
InvenTree/InvenTree/serializers.py
Normal file
882
InvenTree/InvenTree/serializers.py
Normal file
@@ -0,0 +1,882 @@
|
||||
"""Serializers used in various InvenTree apps."""
|
||||
|
||||
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.core.exceptions import ValidationError as DjangoValidationError
|
||||
from django.db import models
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
import tablib
|
||||
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 PermissionDenied, ValidationError
|
||||
from rest_framework.fields import empty
|
||||
from rest_framework.serializers import DecimalField
|
||||
from rest_framework.utils import model_meta
|
||||
from taggit.serializers import TaggitSerializer
|
||||
|
||||
import common.models as common_models
|
||||
from common.settings import currency_code_default, currency_code_mappings
|
||||
from InvenTree.fields import InvenTreeRestURLField, InvenTreeURLField
|
||||
|
||||
|
||||
class EmptySerializer(serializers.Serializer):
|
||||
"""Empty serializer for use in testing."""
|
||||
|
||||
|
||||
class InvenTreeMoneySerializer(MoneyField):
|
||||
"""Custom serializer for 'MoneyField', which ensures that passed values are numerically valid.
|
||||
|
||||
Ref: https://github.com/django-money/django-money/blob/master/djmoney/contrib/django_rest_framework/fields.py
|
||||
"""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
"""Override default values."""
|
||||
kwargs['max_digits'] = kwargs.get('max_digits', 19)
|
||||
self.decimal_places = kwargs['decimal_places'] = kwargs.get('decimal_places', 6)
|
||||
kwargs['required'] = kwargs.get('required', False)
|
||||
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
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
|
||||
if len(str(amount).strip()) == 0:
|
||||
amount = None
|
||||
|
||||
try:
|
||||
if amount is not None and amount is not empty:
|
||||
# Convert to a Decimal instance, and round to maximum allowed decimal places
|
||||
amount = Decimal(amount)
|
||||
amount = round(amount, self.decimal_places)
|
||||
except Exception:
|
||||
raise ValidationError({self.field_name: [_('Must be a valid number')]})
|
||||
|
||||
currency = data.get(
|
||||
get_currency_field_name(self.field_name), self.default_currency
|
||||
)
|
||||
|
||||
if (
|
||||
currency
|
||||
and amount is not None
|
||||
and not isinstance(amount, MONEY_CLASSES)
|
||||
and amount is not empty
|
||||
):
|
||||
return Money(amount, currency)
|
||||
|
||||
return amount
|
||||
|
||||
|
||||
class InvenTreeCurrencySerializer(serializers.ChoiceField):
|
||||
"""Custom serializers for selecting currency option."""
|
||||
|
||||
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
|
||||
)
|
||||
|
||||
if allow_blank:
|
||||
choices = [('', '---------')] + choices
|
||||
|
||||
kwargs['choices'] = choices
|
||||
|
||||
if 'default' not in kwargs and 'required' not in kwargs:
|
||||
kwargs['default'] = '' if allow_blank else currency_code_default
|
||||
|
||||
if 'label' not in kwargs:
|
||||
kwargs['label'] = _('Currency')
|
||||
|
||||
if 'help_text' not in kwargs:
|
||||
kwargs['help_text'] = _('Select currency from available options')
|
||||
|
||||
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 data.get(f, None) is None:
|
||||
if (
|
||||
self.parent
|
||||
and (v := getattr(self.parent.fields[f], 'default', None))
|
||||
is not None
|
||||
):
|
||||
data[f] = v
|
||||
else:
|
||||
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."""
|
||||
|
||||
# Switch out URLField mapping
|
||||
serializer_field_mapping = {
|
||||
**serializers.ModelSerializer.serializer_field_mapping,
|
||||
models.URLField: InvenTreeRestURLField,
|
||||
InvenTreeURLField: InvenTreeRestURLField,
|
||||
}
|
||||
|
||||
def __init__(self, instance=None, data=empty, **kwargs):
|
||||
"""Custom __init__ routine to ensure that *default* values (as specified in the ORM) are used by the DRF serializers, *if* the values are not provided by the user."""
|
||||
# If instance is None, we are creating a new instance
|
||||
if instance is None and data is not empty:
|
||||
if data is None:
|
||||
data = OrderedDict()
|
||||
else:
|
||||
new_data = OrderedDict()
|
||||
new_data.update(data)
|
||||
|
||||
data = new_data
|
||||
|
||||
# Add missing fields which have default values
|
||||
ModelClass = self.Meta.model
|
||||
|
||||
fields = model_meta.get_field_info(ModelClass)
|
||||
|
||||
for field_name, field in fields.fields.items():
|
||||
"""
|
||||
Update the field IF (and ONLY IF):
|
||||
|
||||
- The field has a specified default value
|
||||
- The field does not already have a value set
|
||||
"""
|
||||
if field.has_default() and field_name not in data:
|
||||
value = field.default
|
||||
|
||||
# Account for callable functions
|
||||
if callable(value):
|
||||
try:
|
||||
value = value()
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
data[field_name] = value
|
||||
|
||||
super().__init__(instance, data, **kwargs)
|
||||
|
||||
def get_initial(self):
|
||||
"""Construct initial data for the serializer.
|
||||
|
||||
Use the 'default' values specified by the django model definition
|
||||
"""
|
||||
initials = super().get_initial().copy()
|
||||
|
||||
# Are we creating a new instance?
|
||||
if self.instance is None:
|
||||
ModelClass = self.Meta.model
|
||||
|
||||
fields = model_meta.get_field_info(ModelClass)
|
||||
|
||||
for field_name, field in fields.fields.items():
|
||||
if field.has_default() and field_name not in initials:
|
||||
value = field.default
|
||||
|
||||
# Account for callable functions
|
||||
if callable(value):
|
||||
try:
|
||||
value = value()
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
initials[field_name] = value
|
||||
|
||||
return initials
|
||||
|
||||
def skip_create_fields(self):
|
||||
"""Return a list of 'fields' which should be skipped for model creation.
|
||||
|
||||
This is used to 'bypass' a shortcoming of the DRF framework,
|
||||
which does not allow us to have writeable serializer fields which do not exist on the model.
|
||||
|
||||
Default implementation returns an empty list
|
||||
"""
|
||||
return []
|
||||
|
||||
def save(self, **kwargs):
|
||||
"""Catch any django ValidationError thrown at the moment `save` is called, and re-throw as a DRF ValidationError."""
|
||||
try:
|
||||
super().save(**kwargs)
|
||||
except (ValidationError, DjangoValidationError) as exc:
|
||||
raise ValidationError(detail=serializers.as_serializer_error(exc))
|
||||
|
||||
return self.instance
|
||||
|
||||
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
|
||||
for field in self.skip_create_fields():
|
||||
initial_data.pop(field, None)
|
||||
|
||||
return super().create(initial_data)
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
"""Catch any django ValidationError, and re-throw as a DRF ValidationError."""
|
||||
try:
|
||||
instance = super().update(instance, validated_data)
|
||||
except (ValidationError, DjangoValidationError) as exc:
|
||||
raise ValidationError(detail=serializers.as_serializer_error(exc))
|
||||
|
||||
return instance
|
||||
|
||||
def run_validation(self, data=empty):
|
||||
"""Perform serializer validation.
|
||||
|
||||
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)
|
||||
|
||||
if not hasattr(self, 'instance') or self.instance is None:
|
||||
# No instance exists (we are creating a new one)
|
||||
|
||||
initial_data = data.copy()
|
||||
|
||||
for field in self.skip_create_fields():
|
||||
# Remove any fields we do not wish to provide to the model
|
||||
initial_data.pop(field, None)
|
||||
|
||||
# Create a (RAM only) instance for extra testing
|
||||
instance = self.Meta.model(**initial_data)
|
||||
else:
|
||||
# Instance already exists (we are updating!)
|
||||
instance = self.instance
|
||||
|
||||
# Update instance fields
|
||||
for attr, value in data.items():
|
||||
try:
|
||||
setattr(instance, attr, value)
|
||||
except (ValidationError, DjangoValidationError) as exc:
|
||||
raise ValidationError(detail=serializers.as_serializer_error(exc))
|
||||
|
||||
# Run a 'full_clean' on the model.
|
||||
# Note that by default, DRF does *not* perform full model validation!
|
||||
try:
|
||||
instance.full_clean()
|
||||
except (ValidationError, DjangoValidationError) as exc:
|
||||
data = exc.message_dict
|
||||
|
||||
# Change '__all__' key (django style) to 'non_field_errors' (DRF style)
|
||||
if '__all__' in data:
|
||||
data['non_field_errors'] = data['__all__']
|
||||
del data['__all__']
|
||||
|
||||
raise ValidationError(data)
|
||||
|
||||
return data
|
||||
|
||||
|
||||
class InvenTreeTaggitSerializer(TaggitSerializer):
|
||||
"""Updated from https://github.com/glemmaPaul/django-taggit-serializer."""
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
"""Overridden update method to re-add the tagmanager."""
|
||||
to_be_tagged, validated_data = self._pop_tags(validated_data)
|
||||
|
||||
tag_object = super().update(instance, validated_data)
|
||||
|
||||
for key in to_be_tagged.keys():
|
||||
# re-add the tagmanager
|
||||
new_tagobject = tag_object.__class__.objects.get(id=tag_object.id)
|
||||
setattr(tag_object, key, getattr(new_tagobject, key))
|
||||
|
||||
return self._save_tags(tag_object, to_be_tagged)
|
||||
|
||||
|
||||
class InvenTreeTagModelSerializer(InvenTreeTaggitSerializer, InvenTreeModelSerializer):
|
||||
"""Combination of InvenTreeTaggitSerializer and InvenTreeModelSerializer."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class UserSerializer(InvenTreeModelSerializer):
|
||||
"""Serializer for a User."""
|
||||
|
||||
class Meta:
|
||||
"""Metaclass defines serializer fields."""
|
||||
|
||||
model = User
|
||||
fields = ['pk', 'username', 'first_name', 'last_name', '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."""
|
||||
from InvenTree.helpers_model import get_base_url
|
||||
|
||||
base_url = get_base_url()
|
||||
|
||||
instance = super().create(validated_data)
|
||||
|
||||
# Make sure the user cannot login until they have set a password
|
||||
instance.set_unusable_password()
|
||||
|
||||
message = (
|
||||
_('Your account has been created.')
|
||||
+ '\n\n'
|
||||
+ _('Please use the password reset function to login')
|
||||
)
|
||||
|
||||
if base_url:
|
||||
message += f'\n\nURL: {base_url}'
|
||||
|
||||
# Send the user an onboarding email (from current site)
|
||||
instance.email_user(subject=_('Welcome to InvenTree'), message=message)
|
||||
|
||||
return instance
|
||||
|
||||
|
||||
class InvenTreeAttachmentSerializerField(serializers.FileField):
|
||||
"""Override the DRF native FileField serializer, to remove the leading server path.
|
||||
|
||||
For example, the FileField might supply something like:
|
||||
|
||||
http://127.0.0.1:8000/media/foo/bar.jpg
|
||||
|
||||
Whereas we wish to return:
|
||||
|
||||
/media/foo/bar.jpg
|
||||
|
||||
If the server process is serving the data at 127.0.0.1,
|
||||
but a proxy service (e.g. nginx) is then providing DNS lookup to the outside world,
|
||||
then an attachment which prefixes the "address" of the internal server
|
||||
will not be accessible from the outside world.
|
||||
"""
|
||||
|
||||
def to_representation(self, value):
|
||||
"""To json-serializable type."""
|
||||
if not value:
|
||||
return None
|
||||
|
||||
return os.path.join(str(settings.MEDIA_URL), str(value))
|
||||
|
||||
|
||||
class InvenTreeAttachmentSerializer(InvenTreeModelSerializer):
|
||||
"""Special case of an InvenTreeModelSerializer, which handles an "attachment" model.
|
||||
|
||||
The only real addition here is that we support "renaming" of the attachment file.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def attachment_fields(extra_fields=None):
|
||||
"""Default set of fields for an attachment serializer."""
|
||||
fields = [
|
||||
'pk',
|
||||
'attachment',
|
||||
'filename',
|
||||
'link',
|
||||
'comment',
|
||||
'upload_date',
|
||||
'user',
|
||||
'user_detail',
|
||||
]
|
||||
|
||||
if extra_fields:
|
||||
fields += extra_fields
|
||||
|
||||
return fields
|
||||
|
||||
user_detail = UserSerializer(source='user', read_only=True, many=False)
|
||||
|
||||
attachment = InvenTreeAttachmentSerializerField(required=False, allow_null=False)
|
||||
|
||||
# The 'filename' field must be present in the serializer
|
||||
filename = serializers.CharField(
|
||||
label=_('Filename'), required=False, source='basename', allow_blank=False
|
||||
)
|
||||
|
||||
upload_date = serializers.DateField(read_only=True)
|
||||
|
||||
|
||||
class InvenTreeImageSerializerField(serializers.ImageField):
|
||||
"""Custom image serializer.
|
||||
|
||||
On upload, validate that the file is a valid image file
|
||||
"""
|
||||
|
||||
def to_representation(self, value):
|
||||
"""To json-serializable type."""
|
||||
if not value:
|
||||
return None
|
||||
|
||||
return os.path.join(str(settings.MEDIA_URL), str(value))
|
||||
|
||||
|
||||
class InvenTreeDecimalField(serializers.FloatField):
|
||||
"""Custom serializer for decimal fields.
|
||||
|
||||
Solves the following issues:
|
||||
- The normal DRF DecimalField renders values with trailing zeros
|
||||
- Using a FloatField can result in rounding issues: https://code.djangoproject.com/ticket/30290
|
||||
"""
|
||||
|
||||
def to_internal_value(self, data):
|
||||
"""Convert to python type."""
|
||||
# Convert the value to a string, and then a decimal
|
||||
try:
|
||||
return Decimal(str(data))
|
||||
except Exception:
|
||||
raise serializers.ValidationError(_('Invalid value'))
|
||||
|
||||
|
||||
class DataFileUploadSerializer(serializers.Serializer):
|
||||
"""Generic serializer for uploading a data file, and extracting a dataset.
|
||||
|
||||
- Validates uploaded file
|
||||
- Extracts column names
|
||||
- Extracts data rows
|
||||
"""
|
||||
|
||||
# Implementing class should register a target model (database model) to be used for import
|
||||
TARGET_MODEL = None
|
||||
|
||||
class Meta:
|
||||
"""Metaclass options."""
|
||||
|
||||
fields = ['data_file']
|
||||
|
||||
data_file = serializers.FileField(
|
||||
label=_('Data File'),
|
||||
help_text=_('Select data file for upload'),
|
||||
required=True,
|
||||
allow_empty_file=False,
|
||||
)
|
||||
|
||||
def validate_data_file(self, data_file):
|
||||
"""Perform validation checks on the uploaded data file."""
|
||||
self.filename = data_file.name
|
||||
|
||||
name, ext = os.path.splitext(data_file.name)
|
||||
|
||||
# Remove the leading . from the extension
|
||||
ext = ext[1:]
|
||||
|
||||
accepted_file_types = ['xls', 'xlsx', 'csv', 'tsv', 'xml']
|
||||
|
||||
if ext not in accepted_file_types:
|
||||
raise serializers.ValidationError(_('Unsupported file type'))
|
||||
|
||||
# Impose a 50MB limit on uploaded BOM files
|
||||
max_upload_file_size = 50 * 1024 * 1024
|
||||
|
||||
if data_file.size > max_upload_file_size:
|
||||
raise serializers.ValidationError(_('File is too large'))
|
||||
|
||||
# Read file data into memory (bytes object)
|
||||
try:
|
||||
data = data_file.read()
|
||||
except Exception as e:
|
||||
raise serializers.ValidationError(str(e))
|
||||
|
||||
if ext in ['csv', 'tsv', 'xml']:
|
||||
try:
|
||||
data = data.decode()
|
||||
except Exception as e:
|
||||
raise serializers.ValidationError(str(e))
|
||||
|
||||
# Convert to a tablib dataset (we expect headers)
|
||||
try:
|
||||
self.dataset = tablib.Dataset().load(data, ext, headers=True)
|
||||
except Exception as e:
|
||||
raise serializers.ValidationError(str(e))
|
||||
|
||||
if len(self.dataset.headers) == 0:
|
||||
raise serializers.ValidationError(_('No columns found in file'))
|
||||
|
||||
if len(self.dataset) == 0:
|
||||
raise serializers.ValidationError(_('No data rows found in file'))
|
||||
|
||||
return data_file
|
||||
|
||||
def match_column(self, column_name, field_names, exact=False):
|
||||
"""Attempt to match a column name (from the file) to a field (defined in the model).
|
||||
|
||||
Order of matching is:
|
||||
- Direct match
|
||||
- Case insensitive match
|
||||
- Fuzzy match
|
||||
"""
|
||||
if not column_name:
|
||||
return None
|
||||
|
||||
column_name = str(column_name).strip()
|
||||
|
||||
column_name_lower = column_name.lower()
|
||||
|
||||
if column_name in field_names:
|
||||
return column_name
|
||||
|
||||
for field_name in field_names:
|
||||
if field_name.lower() == column_name_lower:
|
||||
return field_name
|
||||
|
||||
if exact:
|
||||
# Finished available 'exact' matches
|
||||
return None
|
||||
|
||||
# TODO: Fuzzy pattern matching for column names
|
||||
|
||||
# No matches found
|
||||
return None
|
||||
|
||||
def extract_data(self):
|
||||
"""Returns dataset extracted from the file."""
|
||||
# Provide a dict of available import fields for the model
|
||||
model_fields = {}
|
||||
|
||||
# Keep track of columns we have already extracted
|
||||
matched_columns = set()
|
||||
|
||||
if self.TARGET_MODEL:
|
||||
try:
|
||||
model_fields = self.TARGET_MODEL.get_import_fields()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Extract a list of valid model field names
|
||||
model_field_names = list(model_fields.keys())
|
||||
|
||||
# Provide a dict of available columns from the dataset
|
||||
file_columns = {}
|
||||
|
||||
for header in self.dataset.headers:
|
||||
column = {}
|
||||
|
||||
# Attempt to "match" file columns to model fields
|
||||
match = self.match_column(header, model_field_names, exact=True)
|
||||
|
||||
if match is not None and match not in matched_columns:
|
||||
matched_columns.add(match)
|
||||
column['value'] = match
|
||||
else:
|
||||
column['value'] = None
|
||||
|
||||
file_columns[header] = column
|
||||
|
||||
return {
|
||||
'file_fields': file_columns,
|
||||
'model_fields': model_fields,
|
||||
'rows': [row.values() for row in self.dataset.dict],
|
||||
'filename': self.filename,
|
||||
}
|
||||
|
||||
def save(self):
|
||||
"""Empty overwrite for save."""
|
||||
...
|
||||
|
||||
|
||||
class DataFileExtractSerializer(serializers.Serializer):
|
||||
"""Generic serializer for extracting data from an imported dataset.
|
||||
|
||||
- User provides an array of matched headers
|
||||
- User provides an array of raw data rows
|
||||
"""
|
||||
|
||||
# Implementing class should register a target model (database model) to be used for import
|
||||
TARGET_MODEL = None
|
||||
|
||||
class Meta:
|
||||
"""Metaclass options."""
|
||||
|
||||
fields = ['columns', 'rows']
|
||||
|
||||
# Mapping of columns
|
||||
columns = serializers.ListField(child=serializers.CharField(allow_blank=True))
|
||||
|
||||
rows = serializers.ListField(
|
||||
child=serializers.ListField(
|
||||
child=serializers.CharField(allow_blank=True, allow_null=True)
|
||||
)
|
||||
)
|
||||
|
||||
def validate(self, data):
|
||||
"""Clean data."""
|
||||
data = super().validate(data)
|
||||
|
||||
self.columns = data.get('columns', [])
|
||||
self.rows = data.get('rows', [])
|
||||
|
||||
if len(self.rows) == 0:
|
||||
raise serializers.ValidationError(_('No data rows provided'))
|
||||
|
||||
if len(self.columns) == 0:
|
||||
raise serializers.ValidationError(_('No data columns supplied'))
|
||||
|
||||
self.validate_extracted_columns()
|
||||
|
||||
return data
|
||||
|
||||
@property
|
||||
def data(self):
|
||||
"""Returns current data."""
|
||||
if self.TARGET_MODEL:
|
||||
try:
|
||||
model_fields = self.TARGET_MODEL.get_import_fields()
|
||||
except Exception:
|
||||
model_fields = {}
|
||||
|
||||
rows = []
|
||||
|
||||
for row in self.rows:
|
||||
"""Optionally pre-process each row, before sending back to the client."""
|
||||
|
||||
processed_row = self.process_row(self.row_to_dict(row))
|
||||
|
||||
if processed_row:
|
||||
rows.append({'original': row, 'data': processed_row})
|
||||
|
||||
return {'fields': model_fields, 'columns': self.columns, 'rows': rows}
|
||||
|
||||
def process_row(self, row):
|
||||
"""Process a 'row' of data, which is a mapped column:value dict.
|
||||
|
||||
Returns either a mapped column:value dict, or None.
|
||||
|
||||
If the function returns None, the column is ignored!
|
||||
"""
|
||||
# Default implementation simply returns the original row data
|
||||
return row
|
||||
|
||||
def row_to_dict(self, row):
|
||||
"""Convert a "row" to a named data dict."""
|
||||
row_dict = {'errors': {}}
|
||||
|
||||
for idx, value in enumerate(row):
|
||||
if idx < len(self.columns):
|
||||
col = self.columns[idx]
|
||||
|
||||
if col:
|
||||
row_dict[col] = value
|
||||
|
||||
return row_dict
|
||||
|
||||
def validate_extracted_columns(self):
|
||||
"""Perform custom validation of header mapping."""
|
||||
if self.TARGET_MODEL:
|
||||
try:
|
||||
model_fields = self.TARGET_MODEL.get_import_fields()
|
||||
except Exception:
|
||||
model_fields = {}
|
||||
|
||||
cols_seen = set()
|
||||
|
||||
for name, field in model_fields.items():
|
||||
required = field.get('required', False)
|
||||
|
||||
# Check for missing required columns
|
||||
if required:
|
||||
if name not in self.columns:
|
||||
raise serializers.ValidationError(
|
||||
_(f"Missing required column: '{name}'")
|
||||
)
|
||||
|
||||
for col in self.columns:
|
||||
if not col:
|
||||
continue
|
||||
|
||||
# Check for duplicated columns
|
||||
if col in cols_seen:
|
||||
raise serializers.ValidationError(_(f"Duplicate column: '{col}'"))
|
||||
|
||||
cols_seen.add(col)
|
||||
|
||||
def save(self):
|
||||
"""No "save" action for this serializer."""
|
||||
pass
|
||||
|
||||
|
||||
class RemoteImageMixin(metaclass=serializers.SerializerMetaclass):
|
||||
"""Mixin class which allows downloading an 'image' from a remote URL.
|
||||
|
||||
Adds the optional, write-only `remote_image` field to the serializer
|
||||
"""
|
||||
|
||||
def skip_create_fields(self):
|
||||
"""Ensure the 'remote_image' field is skipped when creating a new instance."""
|
||||
return ['remote_image']
|
||||
|
||||
remote_image = serializers.URLField(
|
||||
required=False,
|
||||
allow_blank=False,
|
||||
write_only=True,
|
||||
label=_('Remote Image'),
|
||||
help_text=_('URL of remote image file'),
|
||||
)
|
||||
|
||||
def validate_remote_image(self, url):
|
||||
"""Perform custom validation for the remote image URL.
|
||||
|
||||
- Attempt to download the image and store it against this object instance
|
||||
- Catches and re-throws any errors
|
||||
"""
|
||||
from InvenTree.helpers_model import download_image_from_url
|
||||
|
||||
if not url:
|
||||
return
|
||||
|
||||
if not common_models.InvenTreeSetting.get_setting(
|
||||
'INVENTREE_DOWNLOAD_FROM_URL'
|
||||
):
|
||||
raise ValidationError(
|
||||
_('Downloading images from remote URL is not enabled')
|
||||
)
|
||||
|
||||
try:
|
||||
self.remote_image_file = download_image_from_url(url)
|
||||
except Exception as exc:
|
||||
self.remote_image_file = None
|
||||
raise ValidationError(str(exc))
|
||||
|
||||
return url
|
||||
1215
InvenTree/InvenTree/settings.py
Normal file
1215
InvenTree/InvenTree/settings.py
Normal file
File diff suppressed because it is too large
Load Diff
269
InvenTree/InvenTree/social_auth_urls.py
Normal file
269
InvenTree/InvenTree/social_auth_urls.py
Normal file
@@ -0,0 +1,269 @@
|
||||
"""API endpoints for social authentication with allauth."""
|
||||
|
||||
import logging
|
||||
from importlib import import_module
|
||||
|
||||
from django.urls import NoReverseMatch, include, path, reverse
|
||||
|
||||
from allauth.account.models import EmailAddress
|
||||
from allauth.socialaccount import providers
|
||||
from allauth.socialaccount.providers.oauth2.views import OAuth2Adapter, OAuth2LoginView
|
||||
from drf_spectacular.utils import OpenApiResponse, extend_schema
|
||||
from rest_framework import serializers
|
||||
from rest_framework.exceptions import NotFound
|
||||
from rest_framework.permissions import AllowAny, IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
|
||||
import InvenTree.sso
|
||||
from common.models import InvenTreeSetting
|
||||
from InvenTree.mixins import CreateAPI, ListAPI, ListCreateAPI
|
||||
from InvenTree.serializers import EmptySerializer, 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',
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
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 name, provider in providers.registry.provider_map.items():
|
||||
try:
|
||||
prov_mod = import_module(provider.get_package() + '.views')
|
||||
except ImportError:
|
||||
logger.exception('Could not import authentication provider %s', name)
|
||||
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
|
||||
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 SocialProviderListResponseSerializer(serializers.Serializer):
|
||||
"""Serializer for the SocialProviderListView."""
|
||||
|
||||
class SocialProvider(serializers.Serializer):
|
||||
"""Serializer for the SocialProviderListResponseSerializer."""
|
||||
|
||||
id = serializers.CharField()
|
||||
name = serializers.CharField()
|
||||
configured = serializers.BooleanField()
|
||||
login = serializers.URLField()
|
||||
connect = serializers.URLField()
|
||||
display_name = serializers.CharField()
|
||||
|
||||
sso_enabled = serializers.BooleanField()
|
||||
sso_registration = serializers.BooleanField()
|
||||
mfa_required = serializers.BooleanField()
|
||||
providers = SocialProvider(many=True)
|
||||
registration_enabled = serializers.BooleanField()
|
||||
password_forgotten_enabled = serializers.BooleanField()
|
||||
|
||||
|
||||
class SocialProviderListView(ListAPI):
|
||||
"""List of available social providers."""
|
||||
|
||||
permission_classes = (AllowAny,)
|
||||
serializer_class = EmptySerializer
|
||||
|
||||
@extend_schema(
|
||||
responses={200: OpenApiResponse(response=SocialProviderListResponseSerializer)}
|
||||
)
|
||||
def get(self, request, *args, **kwargs):
|
||||
"""Get the list of providers."""
|
||||
provider_list = []
|
||||
for provider in providers.registry.provider_map.values():
|
||||
provider_data = {
|
||||
'id': provider.id,
|
||||
'name': provider.name,
|
||||
'configured': False,
|
||||
}
|
||||
|
||||
try:
|
||||
provider_data['login'] = request.build_absolute_uri(
|
||||
reverse(f'{provider.id}_api_login')
|
||||
)
|
||||
except NoReverseMatch:
|
||||
provider_data['login'] = None
|
||||
|
||||
try:
|
||||
provider_data['connect'] = request.build_absolute_uri(
|
||||
reverse(f'{provider.id}_api_connect')
|
||||
)
|
||||
except NoReverseMatch:
|
||||
provider_data['connect'] = None
|
||||
|
||||
provider_data['configured'] = InvenTree.sso.check_provider(provider)
|
||||
provider_data['display_name'] = InvenTree.sso.provider_display_name(
|
||||
provider
|
||||
)
|
||||
|
||||
provider_list.append(provider_data)
|
||||
|
||||
data = {
|
||||
'sso_enabled': InvenTree.sso.login_enabled(),
|
||||
'sso_registration': InvenTree.sso.registration_enabled(),
|
||||
'mfa_required': InvenTreeSetting.get_setting('LOGIN_ENFORCE_MFA'),
|
||||
'providers': provider_list,
|
||||
'registration_enabled': InvenTreeSetting.get_setting('LOGIN_ENABLE_REG'),
|
||||
'password_forgotten_enabled': InvenTreeSetting.get_setting(
|
||||
'LOGIN_ENABLE_PWD_FORGOT'
|
||||
),
|
||||
}
|
||||
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()
|
||||
77
InvenTree/InvenTree/sso.py
Normal file
77
InvenTree/InvenTree/sso.py
Normal file
@@ -0,0 +1,77 @@
|
||||
"""Helper functions for Single Sign On functionality."""
|
||||
|
||||
import logging
|
||||
|
||||
from common.models import InvenTreeSetting
|
||||
from InvenTree.helpers import str2bool
|
||||
|
||||
logger = logging.getLogger('inventree')
|
||||
|
||||
|
||||
def get_provider_app(provider):
|
||||
"""Return the SocialApp object for the given provider."""
|
||||
from allauth.socialaccount.models import SocialApp
|
||||
|
||||
try:
|
||||
apps = SocialApp.objects.filter(provider__iexact=provider.id)
|
||||
except SocialApp.DoesNotExist:
|
||||
logger.warning("SSO SocialApp not found for provider '%s'", provider.id)
|
||||
return None
|
||||
|
||||
if apps.count() > 1:
|
||||
logger.warning("Multiple SocialApps found for provider '%s'", provider.id)
|
||||
|
||||
if apps.count() == 0:
|
||||
logger.warning("SSO SocialApp not found for provider '%s'", provider.id)
|
||||
|
||||
return apps.first()
|
||||
|
||||
|
||||
def check_provider(provider, raise_error=False):
|
||||
"""Check if the given provider is correctly configured.
|
||||
|
||||
To be correctly configured, the following must be true:
|
||||
|
||||
- Provider must either have a registered SocialApp
|
||||
- Must have at least one site enabled
|
||||
"""
|
||||
import allauth.app_settings
|
||||
|
||||
# First, check that the provider is enabled
|
||||
app = get_provider_app(provider)
|
||||
|
||||
if not app:
|
||||
return False
|
||||
|
||||
if allauth.app_settings.SITES_ENABLED:
|
||||
# At least one matching site must be specified
|
||||
if not app.sites.exists():
|
||||
logger.error('SocialApp %s has no sites configured', app)
|
||||
return False
|
||||
|
||||
# At this point, we assume that the provider is correctly configured
|
||||
return True
|
||||
|
||||
|
||||
def provider_display_name(provider):
|
||||
"""Return the 'display name' for the given provider."""
|
||||
if app := get_provider_app(provider):
|
||||
return app.name
|
||||
|
||||
# Fallback value if app not found
|
||||
return provider.name
|
||||
|
||||
|
||||
def login_enabled() -> bool:
|
||||
"""Return True if SSO login is enabled."""
|
||||
return str2bool(InvenTreeSetting.get_setting('LOGIN_ENABLE_SSO'))
|
||||
|
||||
|
||||
def registration_enabled() -> bool:
|
||||
"""Return True if SSO registration is enabled."""
|
||||
return str2bool(InvenTreeSetting.get_setting('LOGIN_ENABLE_SSO_REG'))
|
||||
|
||||
|
||||
def auto_registration_enabled() -> bool:
|
||||
"""Return True if SSO auto-registration is enabled."""
|
||||
return str2bool(InvenTreeSetting.get_setting('LOGIN_SIGNUP_SSO_AUTO'))
|
||||
371
InvenTree/InvenTree/static/bootstrap-table/bootstrap-table.css
vendored
Normal file
371
InvenTree/InvenTree/static/bootstrap-table/bootstrap-table.css
vendored
Normal file
@@ -0,0 +1,371 @@
|
||||
/**
|
||||
* @author zhixin wen <wenzhixin2010@gmail.com>
|
||||
* version: 1.18.3
|
||||
* https://github.com/wenzhixin/bootstrap-table/
|
||||
*/
|
||||
.bootstrap-table .fixed-table-toolbar::after {
|
||||
content: "";
|
||||
display: block;
|
||||
clear: both;
|
||||
}
|
||||
|
||||
.bootstrap-table .fixed-table-toolbar .bs-bars,
|
||||
.bootstrap-table .fixed-table-toolbar .search,
|
||||
.bootstrap-table .fixed-table-toolbar .columns {
|
||||
position: relative;
|
||||
margin-top: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.bootstrap-table .fixed-table-toolbar .columns .btn-group > .btn-group {
|
||||
display: inline-block;
|
||||
margin-left: -1px !important;
|
||||
}
|
||||
|
||||
.bootstrap-table .fixed-table-toolbar .columns .btn-group > .btn-group > .btn {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.bootstrap-table .fixed-table-toolbar .columns .btn-group > .btn-group:first-child > .btn {
|
||||
border-top-left-radius: 4px;
|
||||
border-bottom-left-radius: 4px;
|
||||
}
|
||||
|
||||
.bootstrap-table .fixed-table-toolbar .columns .btn-group > .btn-group:last-child > .btn {
|
||||
border-top-right-radius: 4px;
|
||||
border-bottom-right-radius: 4px;
|
||||
}
|
||||
|
||||
.bootstrap-table .fixed-table-toolbar .columns .dropdown-menu {
|
||||
text-align: left;
|
||||
max-height: 300px;
|
||||
overflow: auto;
|
||||
-ms-overflow-style: scrollbar;
|
||||
z-index: 1001;
|
||||
}
|
||||
|
||||
.bootstrap-table .fixed-table-toolbar .columns label {
|
||||
display: block;
|
||||
padding: 3px 20px;
|
||||
clear: both;
|
||||
font-weight: normal;
|
||||
line-height: 1.428571429;
|
||||
}
|
||||
|
||||
.bootstrap-table .fixed-table-toolbar .columns-left {
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
.bootstrap-table .fixed-table-toolbar .columns-right {
|
||||
margin-left: 5px;
|
||||
}
|
||||
|
||||
.bootstrap-table .fixed-table-toolbar .pull-right .dropdown-menu {
|
||||
right: 0;
|
||||
left: auto;
|
||||
}
|
||||
|
||||
.bootstrap-table .fixed-table-container {
|
||||
position: relative;
|
||||
clear: both;
|
||||
}
|
||||
|
||||
.bootstrap-table .fixed-table-container .table {
|
||||
width: 100%;
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
|
||||
.bootstrap-table .fixed-table-container .table th,
|
||||
.bootstrap-table .fixed-table-container .table td {
|
||||
vertical-align: middle;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.bootstrap-table .fixed-table-container .table thead th {
|
||||
vertical-align: bottom;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.bootstrap-table .fixed-table-container .table thead th:focus {
|
||||
outline: 0 solid transparent;
|
||||
}
|
||||
|
||||
.bootstrap-table .fixed-table-container .table thead th.detail {
|
||||
width: 30px;
|
||||
}
|
||||
|
||||
.bootstrap-table .fixed-table-container .table thead th .th-inner {
|
||||
padding: 0.75rem;
|
||||
vertical-align: bottom;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.bootstrap-table .fixed-table-container .table thead th .sortable {
|
||||
cursor: pointer;
|
||||
background-position: right;
|
||||
background-repeat: no-repeat;
|
||||
padding-right: 30px !important;
|
||||
}
|
||||
|
||||
.bootstrap-table .fixed-table-container .table thead th .both {
|
||||
background-image: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABMAAAATCAQAAADYWf5HAAAAkElEQVQoz7X QMQ5AQBCF4dWQSJxC5wwax1Cq1e7BAdxD5SL+Tq/QCM1oNiJidwox0355mXnG/DrEtIQ6azioNZQxI0ykPhTQIwhCR+BmBYtlK7kLJYwWCcJA9M4qdrZrd8pPjZWPtOqdRQy320YSV17OatFC4euts6z39GYMKRPCTKY9UnPQ6P+GtMRfGtPnBCiqhAeJPmkqAAAAAElFTkSuQmCC");
|
||||
}
|
||||
|
||||
.bootstrap-table .fixed-table-container .table thead th .asc {
|
||||
background-image: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABMAAAATCAYAAAByUDbMAAAAZ0lEQVQ4y2NgGLKgquEuFxBPAGI2ahhWCsS/gDibUoO0gPgxEP8H4ttArEyuQYxAPBdqEAxPBImTY5gjEL9DM+wTENuQahAvEO9DMwiGdwAxOymGJQLxTyD+jgWDxCMZRsEoGAVoAADeemwtPcZI2wAAAABJRU5ErkJggg==");
|
||||
}
|
||||
|
||||
.bootstrap-table .fixed-table-container .table thead th .desc {
|
||||
background-image: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABMAAAATCAYAAAByUDbMAAAAZUlEQVQ4y2NgGAWjYBSggaqGu5FA/BOIv2PBIPFEUgxjB+IdQPwfC94HxLykus4GiD+hGfQOiB3J8SojEE9EM2wuSJzcsFMG4ttQgx4DsRalkZENxL+AuJQaMcsGxBOAmGvopk8AVz1sLZgg0bsAAAAASUVORK5CYII= ");
|
||||
}
|
||||
|
||||
.bootstrap-table .fixed-table-container .table tbody tr.selected td {
|
||||
background-color: rgba(0, 0, 0, 0.075);
|
||||
}
|
||||
|
||||
.bootstrap-table .fixed-table-container .table tbody tr.no-records-found td {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.bootstrap-table .fixed-table-container .table tbody tr .card-view {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.bootstrap-table .fixed-table-container .table tbody tr .card-view .card-view-title {
|
||||
font-weight: bold;
|
||||
display: inline-block;
|
||||
min-width: 30%;
|
||||
width: auto !important;
|
||||
text-align: left !important;
|
||||
}
|
||||
|
||||
.bootstrap-table .fixed-table-container .table tbody tr .card-view .card-view-value {
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
.bootstrap-table .fixed-table-container .table .bs-checkbox {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.bootstrap-table .fixed-table-container .table .bs-checkbox label {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.bootstrap-table .fixed-table-container .table .bs-checkbox label input[type="radio"],
|
||||
.bootstrap-table .fixed-table-container .table .bs-checkbox label input[type="checkbox"] {
|
||||
margin: 0 auto !important;
|
||||
}
|
||||
|
||||
.bootstrap-table .fixed-table-container .table.table-sm .th-inner {
|
||||
padding: 0.3rem;
|
||||
}
|
||||
|
||||
.bootstrap-table .fixed-table-container.fixed-height:not(.has-footer) {
|
||||
border-bottom: 1px solid #dee2e6;
|
||||
}
|
||||
|
||||
.bootstrap-table .fixed-table-container.fixed-height.has-card-view {
|
||||
border-top: 1px solid #dee2e6;
|
||||
border-bottom: 1px solid #dee2e6;
|
||||
}
|
||||
|
||||
.bootstrap-table .fixed-table-container.fixed-height .fixed-table-border {
|
||||
border-left: 1px solid #dee2e6;
|
||||
border-right: 1px solid #dee2e6;
|
||||
}
|
||||
|
||||
.bootstrap-table .fixed-table-container.fixed-height .table thead th {
|
||||
border-bottom: 1px solid #dee2e6;
|
||||
}
|
||||
|
||||
.bootstrap-table .fixed-table-container.fixed-height .table-dark thead th {
|
||||
border-bottom: 1px solid #32383e;
|
||||
}
|
||||
|
||||
.bootstrap-table .fixed-table-container .fixed-table-header {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.bootstrap-table .fixed-table-container .fixed-table-body {
|
||||
overflow-x: auto;
|
||||
overflow-y: auto;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.bootstrap-table .fixed-table-container .fixed-table-body .fixed-table-loading {
|
||||
align-items: center;
|
||||
background: #fff;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
width: 100%;
|
||||
z-index: 1000;
|
||||
transition: visibility 0s, opacity 0.15s ease-in-out;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.bootstrap-table .fixed-table-container .fixed-table-body .fixed-table-loading.open {
|
||||
visibility: visible;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.bootstrap-table .fixed-table-container .fixed-table-body .fixed-table-loading .loading-wrap {
|
||||
align-items: baseline;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.bootstrap-table .fixed-table-container .fixed-table-body .fixed-table-loading .loading-wrap .loading-text {
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
.bootstrap-table .fixed-table-container .fixed-table-body .fixed-table-loading .loading-wrap .animation-wrap {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.bootstrap-table .fixed-table-container .fixed-table-body .fixed-table-loading .loading-wrap .animation-dot,
|
||||
.bootstrap-table .fixed-table-container .fixed-table-body .fixed-table-loading .loading-wrap .animation-wrap::after,
|
||||
.bootstrap-table .fixed-table-container .fixed-table-body .fixed-table-loading .loading-wrap .animation-wrap::before {
|
||||
content: "";
|
||||
animation-duration: 1.5s;
|
||||
animation-iteration-count: infinite;
|
||||
animation-name: LOADING;
|
||||
background: #212529;
|
||||
border-radius: 50%;
|
||||
display: block;
|
||||
height: 5px;
|
||||
margin: 0 4px;
|
||||
opacity: 0;
|
||||
width: 5px;
|
||||
}
|
||||
|
||||
.bootstrap-table .fixed-table-container .fixed-table-body .fixed-table-loading .loading-wrap .animation-dot {
|
||||
animation-delay: 0.3s;
|
||||
}
|
||||
|
||||
.bootstrap-table .fixed-table-container .fixed-table-body .fixed-table-loading .loading-wrap .animation-wrap::after {
|
||||
animation-delay: 0.6s;
|
||||
}
|
||||
|
||||
.bootstrap-table .fixed-table-container .fixed-table-body .fixed-table-loading.table-dark {
|
||||
background: #212529;
|
||||
}
|
||||
|
||||
.bootstrap-table .fixed-table-container .fixed-table-body .fixed-table-loading.table-dark .animation-dot,
|
||||
.bootstrap-table .fixed-table-container .fixed-table-body .fixed-table-loading.table-dark .animation-wrap::after,
|
||||
.bootstrap-table .fixed-table-container .fixed-table-body .fixed-table-loading.table-dark .animation-wrap::before {
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.bootstrap-table .fixed-table-container .fixed-table-footer {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.bootstrap-table .fixed-table-pagination::after {
|
||||
content: "";
|
||||
display: block;
|
||||
clear: both;
|
||||
}
|
||||
|
||||
.bootstrap-table .fixed-table-pagination > .pagination-detail,
|
||||
.bootstrap-table .fixed-table-pagination > .pagination {
|
||||
margin-top: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.bootstrap-table .fixed-table-pagination > .pagination-detail .pagination-info {
|
||||
line-height: 34px;
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
.bootstrap-table .fixed-table-pagination > .pagination-detail .page-list {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.bootstrap-table .fixed-table-pagination > .pagination-detail .page-list .btn-group {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.bootstrap-table .fixed-table-pagination > .pagination-detail .page-list .btn-group .dropdown-menu {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.bootstrap-table .fixed-table-pagination > .pagination ul.pagination {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.bootstrap-table .fixed-table-pagination > .pagination ul.pagination li.page-intermediate a {
|
||||
color: #c8c8c8;
|
||||
}
|
||||
|
||||
.bootstrap-table .fixed-table-pagination > .pagination ul.pagination li.page-intermediate a::before {
|
||||
content: '\2B05';
|
||||
}
|
||||
|
||||
.bootstrap-table .fixed-table-pagination > .pagination ul.pagination li.page-intermediate a::after {
|
||||
content: '\27A1';
|
||||
}
|
||||
|
||||
.bootstrap-table .fixed-table-pagination > .pagination ul.pagination li.disabled a {
|
||||
pointer-events: none;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.bootstrap-table.fullscreen {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 1050;
|
||||
width: 100% !important;
|
||||
background: #fff;
|
||||
height: calc(100vh);
|
||||
overflow-y: scroll;
|
||||
}
|
||||
|
||||
.bootstrap-table.bootstrap4 .pagination-lg .page-link, .bootstrap-table.bootstrap5 .pagination-lg .page-link {
|
||||
padding: .5rem 1rem;
|
||||
}
|
||||
|
||||
.bootstrap-table.bootstrap5 .float-left {
|
||||
float: left;
|
||||
}
|
||||
|
||||
.bootstrap-table.bootstrap5 .float-right {
|
||||
float: right;
|
||||
}
|
||||
|
||||
/* calculate scrollbar width */
|
||||
div.fixed-table-scroll-inner {
|
||||
width: 100%;
|
||||
height: 200px;
|
||||
}
|
||||
|
||||
div.fixed-table-scroll-outer {
|
||||
top: 0;
|
||||
left: 0;
|
||||
visibility: hidden;
|
||||
width: 200px;
|
||||
height: 150px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@keyframes LOADING {
|
||||
0% {
|
||||
opacity: 0;
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
7409
InvenTree/InvenTree/static/bootstrap-table/bootstrap-table.js
vendored
Normal file
7409
InvenTree/InvenTree/static/bootstrap-table/bootstrap-table.js
vendored
Normal file
File diff suppressed because it is too large
Load Diff
10
InvenTree/InvenTree/static/bootstrap-table/bootstrap-table.min.css
vendored
Normal file
10
InvenTree/InvenTree/static/bootstrap-table/bootstrap-table.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
10
InvenTree/InvenTree/static/bootstrap-table/bootstrap-table.min.js
vendored
Normal file
10
InvenTree/InvenTree/static/bootstrap-table/bootstrap-table.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1779
InvenTree/InvenTree/static/bootstrap-table/extensions/addrbar/bootstrap-table-addrbar.js
vendored
Normal file
1779
InvenTree/InvenTree/static/bootstrap-table/extensions/addrbar/bootstrap-table-addrbar.js
vendored
Normal file
File diff suppressed because it is too large
Load Diff
10
InvenTree/InvenTree/static/bootstrap-table/extensions/addrbar/bootstrap-table-addrbar.min.js
vendored
Normal file
10
InvenTree/InvenTree/static/bootstrap-table/extensions/addrbar/bootstrap-table-addrbar.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1211
InvenTree/InvenTree/static/bootstrap-table/extensions/auto-refresh/bootstrap-table-auto-refresh.js
vendored
Normal file
1211
InvenTree/InvenTree/static/bootstrap-table/extensions/auto-refresh/bootstrap-table-auto-refresh.js
vendored
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
2546
InvenTree/InvenTree/static/bootstrap-table/extensions/cookie/bootstrap-table-cookie.js
vendored
Normal file
2546
InvenTree/InvenTree/static/bootstrap-table/extensions/cookie/bootstrap-table-cookie.js
vendored
Normal file
File diff suppressed because it is too large
Load Diff
10
InvenTree/InvenTree/static/bootstrap-table/extensions/cookie/bootstrap-table-cookie.min.js
vendored
Normal file
10
InvenTree/InvenTree/static/bootstrap-table/extensions/cookie/bootstrap-table-cookie.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1237
InvenTree/InvenTree/static/bootstrap-table/extensions/copy-rows/bootstrap-table-copy-rows.js
vendored
Normal file
1237
InvenTree/InvenTree/static/bootstrap-table/extensions/copy-rows/bootstrap-table-copy-rows.js
vendored
Normal file
File diff suppressed because it is too large
Load Diff
10
InvenTree/InvenTree/static/bootstrap-table/extensions/copy-rows/bootstrap-table-copy-rows.min.js
vendored
Normal file
10
InvenTree/InvenTree/static/bootstrap-table/extensions/copy-rows/bootstrap-table-copy-rows.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1256
InvenTree/InvenTree/static/bootstrap-table/extensions/custom-view/bootstrap-table-custom-view.js
vendored
Normal file
1256
InvenTree/InvenTree/static/bootstrap-table/extensions/custom-view/bootstrap-table-custom-view.js
vendored
Normal file
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user