mirror of
https://github.com/inventree/InvenTree.git
synced 2025-12-17 12:25:04 -06:00
Compare commits
298 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a7487ff842 | ||
|
|
5725a9e271 | ||
|
|
fe9a56a5c1 | ||
|
|
39e682cd45 | ||
|
|
a36ab0c004 | ||
|
|
0b45d6f236 | ||
|
|
978e08f3a3 | ||
|
|
85b8157611 | ||
|
|
aaabce9873 | ||
|
|
f5a36ce44e | ||
|
|
6563b4c413 | ||
|
|
abed9fb284 | ||
|
|
09872eec8e | ||
|
|
099b837a4e | ||
|
|
cf977ad29a | ||
|
|
72464c50cc | ||
|
|
942bc5350d | ||
|
|
7876676114 | ||
|
|
ea039645c3 | ||
|
|
b5c7cf0779 | ||
|
|
89d8e47bab | ||
|
|
b8e726d8a4 | ||
|
|
3b238fdbba | ||
|
|
df8c2692a0 | ||
|
|
7391f33a97 | ||
|
|
b1158f7083 | ||
|
|
4969628150 | ||
|
|
57eada1da1 | ||
|
|
f526dcdeec | ||
|
|
aacf35ed47 | ||
|
|
ca986cba01 | ||
|
|
699fb83dd4 | ||
|
|
dd6e225cda | ||
|
|
1f3a49b1ae | ||
|
|
385e7cb478 | ||
|
|
73768bfee1 | ||
|
|
946fe2df29 | ||
|
|
afa7ed873f | ||
|
|
46da332afe | ||
|
|
072b7b3146 | ||
|
|
1d51b2a058 | ||
|
|
08f9bebdf0 | ||
|
|
6d6629f11c | ||
|
|
db88fbda11 | ||
|
|
49c9b5b1aa | ||
|
|
e1a0e79ead | ||
|
|
ab22f2a04d | ||
|
|
8a58bf5ffa | ||
|
|
6730098bac | ||
|
|
93b44ad8e6 | ||
|
|
9b5e828b87 | ||
|
|
cf5d637678 | ||
|
|
feb2acf668 | ||
|
|
0017570dd3 | ||
|
|
4c41a50bb1 | ||
|
|
eab3fdcf2c | ||
|
|
c59eee7359 | ||
|
|
4a5ebf8f01 | ||
|
|
698798fee7 | ||
|
|
2660889879 | ||
|
|
01aaf95a0e | ||
|
|
1d18b487f3 | ||
|
|
7955d1f579 | ||
|
|
c8642bedcd | ||
|
|
752fb97d2f | ||
|
|
a78b26f93a | ||
|
|
53e120cdb3 | ||
|
|
940fa74365 | ||
|
|
8b6abe1505 | ||
|
|
256c9cb751 | ||
|
|
73e0d03abf | ||
|
|
a83a71b3a3 | ||
|
|
0458b5c53a | ||
|
|
5dfc389c06 | ||
|
|
a5c8d86530 | ||
|
|
24b554a8d2 | ||
|
|
fab738cd75 | ||
|
|
603aef5da9 | ||
|
|
198245d0d2 | ||
|
|
3b4e20b54a | ||
|
|
693d24b4b6 | ||
|
|
deffcc2814 | ||
|
|
3001471706 | ||
|
|
7f5e844367 | ||
|
|
66ec82d4d1 | ||
|
|
e2800b19ef | ||
|
|
303305e05f | ||
|
|
b700b44c53 | ||
|
|
09cc654530 | ||
|
|
4c9d4add2c | ||
|
|
13389845b1 | ||
|
|
021a5a4081 | ||
|
|
617ad6c233 | ||
|
|
2e8fb2a14a | ||
|
|
f6420f98c2 | ||
|
|
bf707766b6 | ||
|
|
61d2f452b2 | ||
|
|
31ff3599eb | ||
|
|
51cece9e07 | ||
|
|
62faaf01c5 | ||
|
|
013d206b91 | ||
|
|
2322a98068 | ||
|
|
be6ab14c9b | ||
|
|
8d16abcefb | ||
|
|
a3940cfc41 | ||
|
|
00bb740216 | ||
|
|
c8365ccd0c | ||
|
|
6ba777d363 | ||
|
|
98bddd32d0 | ||
|
|
9117c2234b | ||
|
|
89ad8312ce | ||
|
|
8ca02cb105 | ||
|
|
2f98ed7022 | ||
|
|
9f56ee1023 | ||
|
|
15ab911da6 | ||
|
|
f3a13fc625 | ||
|
|
aebff26ad3 | ||
|
|
d710efb64b | ||
|
|
5d1d8ec889 | ||
|
|
005c8341bf | ||
|
|
280f6241dd | ||
|
|
f1031efa93 | ||
|
|
e807339c55 | ||
|
|
bae1c239e8 | ||
|
|
842d7a93d5 | ||
|
|
a4b4df5ff4 | ||
|
|
81413e02c4 | ||
|
|
d7d3d8aa26 | ||
|
|
192c1ecb21 | ||
|
|
a3150d9cb3 | ||
|
|
f65281c801 | ||
|
|
ba24ff570a | ||
|
|
3ba1d10fc4 | ||
|
|
a4be6bc90b | ||
|
|
58a33c2e67 | ||
|
|
2ed7eefa27 | ||
|
|
d8965c6c2b | ||
|
|
45ec7b9728 | ||
|
|
0c47552199 | ||
|
|
2ca9e0e574 | ||
|
|
21ed4b2081 | ||
|
|
5e2bfaa43a | ||
|
|
b0338e181e | ||
|
|
1d85b70313 | ||
|
|
c0dafe155f | ||
|
|
1df97a7607 | ||
|
|
812b256e08 | ||
|
|
2c3ba6e528 | ||
|
|
936c8ad7fc | ||
|
|
60f2f1ea86 | ||
|
|
11c5ce5f80 | ||
|
|
4d9e92011e | ||
|
|
037654610e | ||
|
|
46a808c064 | ||
|
|
18d9ecd0f4 | ||
|
|
cb0f0e34d9 | ||
|
|
2c58b2fd36 | ||
|
|
e21a5e62b8 | ||
|
|
2c05e3e74d | ||
|
|
685cc1fd77 | ||
|
|
99d122baa9 | ||
|
|
a196f443a1 | ||
|
|
92930d475c | ||
|
|
0808382d06 | ||
|
|
eca2172624 | ||
|
|
3205527ebe | ||
|
|
32331875fe | ||
|
|
95755c5453 | ||
|
|
b842d5ea67 | ||
|
|
637b195a68 | ||
|
|
60f79a0a23 | ||
|
|
0b4a06ae7e | ||
|
|
21dafdee8e | ||
|
|
11f816a787 | ||
|
|
01e2376748 | ||
|
|
4d76708bee | ||
|
|
5dd6f18495 | ||
|
|
717bb07dcf | ||
|
|
433ea4d0de | ||
|
|
8268b9b105 | ||
|
|
fdd4169cd7 | ||
|
|
4079224658 | ||
|
|
4d00c471e1 | ||
|
|
09e99e5f75 | ||
|
|
aa2f5e330a | ||
|
|
91d79dc3ed | ||
|
|
96b7845d84 | ||
|
|
f76059b2b4 | ||
|
|
59cbf17b02 | ||
|
|
5992dcdfda | ||
|
|
63da2ae9f7 | ||
|
|
8dc45e49cd | ||
|
|
9e77b9fc56 | ||
|
|
cb8ae10280 | ||
|
|
61481b4eb0 | ||
|
|
327381357b | ||
|
|
120a710ad4 | ||
|
|
d5caa98936 | ||
|
|
b732b4ceb5 | ||
|
|
4785f465e8 | ||
|
|
f85b378115 | ||
|
|
5c7303fd53 | ||
|
|
98d87c84e3 | ||
|
|
397419f365 | ||
|
|
368f615d71 | ||
|
|
065f3e2404 | ||
|
|
3e0b57f10a | ||
|
|
caa7b84c3e | ||
|
|
e2505433a2 | ||
|
|
1b94a271b6 | ||
|
|
b04053d9b5 | ||
|
|
1d384572ec | ||
|
|
b2ceac2c4a | ||
|
|
634daa2161 | ||
|
|
017ccaa27a | ||
|
|
17057f4266 | ||
|
|
41cef1a190 | ||
|
|
306f36bff8 | ||
|
|
57502a1ad8 | ||
|
|
84f8e33269 | ||
|
|
89dfb6186f | ||
|
|
404113d739 | ||
|
|
4510cf2dd6 | ||
|
|
3b3ce81d11 | ||
|
|
5886415aa7 | ||
|
|
09083d2de1 | ||
|
|
434a00b55f | ||
|
|
1fc22359c7 | ||
|
|
d416e57ee3 | ||
|
|
35d04c0357 | ||
|
|
d7f75d3ab3 | ||
|
|
a0f18d82cb | ||
|
|
1c3d037baf | ||
|
|
7793b3505d | ||
|
|
9920c3fd9c | ||
|
|
c45e66935a | ||
|
|
e7317522a6 | ||
|
|
f5c2591fd4 | ||
|
|
baaa147fd0 | ||
|
|
6da108e031 | ||
|
|
3ff217d229 | ||
|
|
be735e4568 | ||
|
|
52321af962 | ||
|
|
61d613ff34 | ||
|
|
10c3d101e8 | ||
|
|
09fabff551 | ||
|
|
149c4df231 | ||
|
|
d9864fce69 | ||
|
|
20d8c2b4e6 | ||
|
|
6bd95f3b15 | ||
|
|
0b8feb2c4a | ||
|
|
e5e1a09b45 | ||
|
|
21e0679cb9 | ||
|
|
5e99e54bbc | ||
|
|
be856c3682 | ||
|
|
ad4acef459 | ||
|
|
08c4aa4998 | ||
|
|
c5ba632463 | ||
|
|
abee2cee88 | ||
|
|
660a4f8e39 | ||
|
|
f6831558a4 | ||
|
|
f6021c4749 | ||
|
|
8d28fc06be | ||
|
|
e2c3b28640 | ||
|
|
bb860227c8 | ||
|
|
82e98dffb8 | ||
|
|
3975a85742 | ||
|
|
608f96c723 | ||
|
|
5fcab2aec3 | ||
|
|
36d17c082b | ||
|
|
f382d7ef21 | ||
|
|
eaa518852c | ||
|
|
011b5915e1 | ||
|
|
d7bdcd95a6 | ||
|
|
5411cf0878 | ||
|
|
7537fd1278 | ||
|
|
8df207d8e1 | ||
|
|
50cbaff76d | ||
|
|
2ffd2354eb | ||
|
|
20f01e8741 | ||
|
|
ad545bad24 | ||
|
|
9198b52398 | ||
|
|
0f2fd2f678 | ||
|
|
91189fbb77 | ||
|
|
7bc4de6a92 | ||
|
|
c64ff9d569 | ||
|
|
68d1682000 | ||
|
|
a020548c8e | ||
|
|
070e2afcea | ||
|
|
eafd2ac966 | ||
|
|
1b8ad70fb6 | ||
|
|
5cd74c4190 | ||
|
|
2623c22b7e | ||
|
|
9d5522c18c | ||
|
|
b0f6021002 | ||
|
|
ae05c68417 | ||
|
|
3e53b60cac | ||
|
|
f6b9b12745 |
8
.devcontainer/Dockerfile
Normal file → Executable file
8
.devcontainer/Dockerfile
Normal file → Executable file
@@ -4,6 +4,8 @@
|
||||
ARG VARIANT="3.10-bullseye"
|
||||
FROM mcr.microsoft.com/vscode/devcontainers/python:0-${VARIANT}
|
||||
|
||||
ARG WORKSPACE="/workspaces/InvenTree"
|
||||
|
||||
# [Choice] Node.js version: none, lts/*, 16, 14, 12, 10
|
||||
ARG NODE_VERSION="none"
|
||||
RUN if [ "${NODE_VERSION}" != "none" ]; then su vscode -c "umask 0002 && . /usr/local/share/nvm/nvm.sh && nvm install ${NODE_VERSION} 2>&1"; fi
|
||||
@@ -16,7 +18,7 @@ RUN if [ "${NODE_VERSION}" != "none" ]; then su vscode -c "umask 0002 && . /usr/
|
||||
# [Optional] Uncomment this section to install additional OS packages.
|
||||
RUN apt-get update && export DEBIAN_FRONTEND=noninteractive && \
|
||||
apt-get -y install --no-install-recommends \
|
||||
git gcc g++ gettext gnupg libffi-dev \
|
||||
git gcc g++ gettext gnupg2 libffi-dev \
|
||||
# Weasyprint requirements : https://doc.courtbouillon.org/weasyprint/stable/first_steps.html#debian-11
|
||||
poppler-utils libpango-1.0-0 libpangoft2-1.0-0 \
|
||||
# Image format support
|
||||
@@ -42,6 +44,6 @@ RUN pip install --disable-pip-version-check -U -r base_requirements.txt
|
||||
# preserve command history between container starts
|
||||
# Ref: https://code.visualstudio.com/remote/advancedcontainers/persist-bash-history
|
||||
# Folder will be created in 'postCreateCommand' in devcontainer.json as it's not preserved due to the bind mount
|
||||
RUN echo "export PROMPT_COMMAND='history -a' && export HISTFILE=/workspaces/InvenTree/dev/commandhistory/.bash_history" >> "/home/vscode/.bashrc"
|
||||
RUN echo "export PROMPT_COMMAND='history -a' && export HISTFILE=${WORKSPACE}/dev/commandhistory/.bash_history" >> "/home/vscode/.bashrc"
|
||||
|
||||
WORKDIR /workspaces/InvenTree
|
||||
WORKDIR ${WORKSPACE}
|
||||
|
||||
@@ -11,7 +11,8 @@
|
||||
// Use -bullseye variants on local on arm64/Apple Silicon.
|
||||
"VARIANT": "3.10-bullseye",
|
||||
// Options
|
||||
"NODE_VERSION": "lts/*"
|
||||
"NODE_VERSION": "lts/*",
|
||||
"WORKSPACE": "${containerWorkspaceFolder}"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -21,7 +22,7 @@
|
||||
"vscode": {
|
||||
// Set *default* container specific settings.json values on container create.
|
||||
"settings": {
|
||||
"python.defaultInterpreterPath": "/workspaces/InvenTree/dev/venv/bin/python",
|
||||
"python.defaultInterpreterPath": "${containerWorkspaceFolder}/dev/venv/bin/python",
|
||||
"python.linting.enabled": true,
|
||||
"python.linting.pylintEnabled": false,
|
||||
"python.linting.flake8Enabled": true,
|
||||
@@ -40,7 +41,8 @@
|
||||
"extensions": [
|
||||
"ms-python.python",
|
||||
"ms-python.vscode-pylance",
|
||||
"batisteo.vscode-django"
|
||||
"batisteo.vscode-django",
|
||||
"eamodio.gitlens"
|
||||
]
|
||||
}
|
||||
},
|
||||
@@ -54,7 +56,7 @@
|
||||
},
|
||||
|
||||
// Use 'postCreateCommand' to run commands after the container is created.
|
||||
"postCreateCommand": "./.devcontainer/postCreateCommand.sh",
|
||||
"postCreateCommand": "./.devcontainer/postCreateCommand.sh ${containerWorkspaceFolder}",
|
||||
|
||||
// Comment out to connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root.
|
||||
"remoteUser": "vscode",
|
||||
@@ -68,21 +70,22 @@
|
||||
"INVENTREE_DEBUG": "True",
|
||||
"INVENTREE_DEBUG_LEVEL": "INFO",
|
||||
"INVENTREE_DB_ENGINE": "sqlite3",
|
||||
"INVENTREE_DB_NAME": "/workspaces/InvenTree/dev/database.sqlite3",
|
||||
"INVENTREE_MEDIA_ROOT": "/workspaces/InvenTree/dev/media",
|
||||
"INVENTREE_STATIC_ROOT": "/workspaces/InvenTree/dev/static",
|
||||
"INVENTREE_BACKUP_DIR": "/workspaces/InvenTree/dev/backup",
|
||||
"INVENTREE_CONFIG_FILE": "/workspaces/InvenTree/dev/config.yaml",
|
||||
"INVENTREE_SECRET_KEY_FILE": "/workspaces/InvenTree/dev/secret_key.txt",
|
||||
"INVENTREE_PLUGIN_DIR": "/workspaces/InvenTree/dev/plugins",
|
||||
"INVENTREE_PLUGIN_FILE": "/workspaces/InvenTree/dev/plugins.txt",
|
||||
"INVENTREE_DB_NAME": "${containerWorkspaceFolder}/dev/database.sqlite3",
|
||||
"INVENTREE_MEDIA_ROOT": "${containerWorkspaceFolder}/dev/media",
|
||||
"INVENTREE_STATIC_ROOT": "${containerWorkspaceFolder}/dev/static",
|
||||
"INVENTREE_BACKUP_DIR": "${containerWorkspaceFolder}/dev/backup",
|
||||
"INVENTREE_CONFIG_FILE": "${containerWorkspaceFolder}/dev/config.yaml",
|
||||
"INVENTREE_SECRET_KEY_FILE": "${containerWorkspaceFolder}/dev/secret_key.txt",
|
||||
"INVENTREE_PLUGINS_ENABLED": "True",
|
||||
"INVENTREE_PLUGIN_DIR": "${containerWorkspaceFolder}/dev/plugins",
|
||||
"INVENTREE_PLUGIN_FILE": "${containerWorkspaceFolder}/dev/plugins.txt",
|
||||
|
||||
// Python config
|
||||
"PIP_USER": "no",
|
||||
|
||||
// used to load the venv into the PATH and avtivate it
|
||||
// used to load the venv into the PATH and activate it
|
||||
// Ref: https://stackoverflow.com/a/56286534
|
||||
"VIRTUAL_ENV": "/workspaces/InvenTree/dev/venv",
|
||||
"PATH": "/workspaces/InvenTree/dev/venv/bin:${containerEnv:PATH}"
|
||||
"VIRTUAL_ENV": "${containerWorkspaceFolder}/dev/venv",
|
||||
"PATH": "${containerWorkspaceFolder}/dev/venv/bin:${containerEnv:PATH}"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Avoiding Dubious Ownership in Dev Containers for setup commands that use git
|
||||
# Note that the local workspace directory is passed through as the first argument $1
|
||||
git config --global --add safe.directory $1
|
||||
|
||||
# create folders
|
||||
mkdir -p /workspaces/InvenTree/dev/{commandhistory,plugins}
|
||||
cd /workspaces/InvenTree
|
||||
mkdir -p $1/dev/{commandhistory,plugins}
|
||||
cd $1
|
||||
|
||||
# create venv
|
||||
python3 -m venv dev/venv
|
||||
@@ -10,5 +14,10 @@ python3 -m venv dev/venv
|
||||
|
||||
# setup InvenTree server
|
||||
pip install invoke
|
||||
inv update
|
||||
inv setup-dev
|
||||
invoke update
|
||||
invoke setup-dev
|
||||
|
||||
# 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
|
||||
|
||||
3
.djlintrc
Normal file
3
.djlintrc
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"ignore": "D018,H006,H008,H020,H021,H023,H025,H030,H031,T002"
|
||||
}
|
||||
@@ -4,7 +4,7 @@ env:
|
||||
es2021: true
|
||||
jquery: true
|
||||
extends:
|
||||
- google
|
||||
- eslint:recommended
|
||||
parserOptions:
|
||||
ecmaVersion: 12
|
||||
rules:
|
||||
@@ -19,6 +19,8 @@ rules:
|
||||
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
.github/FUNDING.yml
vendored
4
.github/FUNDING.yml
vendored
@@ -1,2 +1,4 @@
|
||||
patreon: inventree
|
||||
github: inventree
|
||||
ko_fi: inventree
|
||||
patreon: inventree
|
||||
custom: [paypal.me/inventree]
|
||||
|
||||
7
.github/ISSUE_TEMPLATE/bug_report.yaml
vendored
7
.github/ISSUE_TEMPLATE/bug_report.yaml
vendored
@@ -52,6 +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: checkboxes
|
||||
id: can-reproduce
|
||||
attributes:
|
||||
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:
|
||||
- label: "I can reproduce this bug on the demo site."
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
|
||||
15
.github/ISSUE_TEMPLATE/documentation.yaml
vendored
Normal file
15
.github/ISSUE_TEMPLATE/documentation.yaml
vendored
Normal file
@@ -0,0 +1,15 @@
|
||||
name: "Documentation"
|
||||
description: "Create an issue to improve the documentation"
|
||||
labels: ["documentation", "triage:not-checked"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Create a new issue regarding the InvenTree documentation
|
||||
- type: textarea
|
||||
id: repro
|
||||
attributes:
|
||||
label: Body of the issue
|
||||
description: Please provide one distinct thing to fix or a clearly defined enhancement
|
||||
validations:
|
||||
required: true
|
||||
2
.github/actions/migration/action.yaml
vendored
2
.github/actions/migration/action.yaml
vendored
@@ -1,5 +1,5 @@
|
||||
name: 'Migration test'
|
||||
description: 'Run migration test sequenze'
|
||||
description: 'Run migration test sequence'
|
||||
author: 'InvenTree'
|
||||
|
||||
runs:
|
||||
|
||||
6
.github/actions/setup/action.yaml
vendored
6
.github/actions/setup/action.yaml
vendored
@@ -1,5 +1,5 @@
|
||||
name: 'Setup Enviroment'
|
||||
description: 'Setup the enviroment for general InvenTree tests'
|
||||
description: 'Setup the environment for general InvenTree tests'
|
||||
author: 'InvenTree'
|
||||
inputs:
|
||||
python:
|
||||
@@ -48,7 +48,7 @@ runs:
|
||||
if: ${{ inputs.python == 'true' }}
|
||||
shell: bash
|
||||
run: |
|
||||
python3 -m pip install pip==23.0.1
|
||||
python3 -m pip install -U pip
|
||||
pip3 install invoke wheel
|
||||
- name: Install Specific Python Dependencies
|
||||
if: ${{ inputs.pip-dependency }}
|
||||
@@ -62,7 +62,7 @@ runs:
|
||||
with:
|
||||
node-version: ${{ env.node_version }}
|
||||
cache: 'npm'
|
||||
- name: Intall npm packages
|
||||
- name: Install npm packages
|
||||
if: ${{ inputs.npm == 'true' }}
|
||||
shell: bash
|
||||
run: npm install
|
||||
|
||||
5
.github/release.yml
vendored
5
.github/release.yml
vendored
@@ -4,6 +4,7 @@ changelog:
|
||||
exclude:
|
||||
labels:
|
||||
- translation
|
||||
- documentation
|
||||
categories:
|
||||
- title: Breaking Changes
|
||||
labels:
|
||||
@@ -15,7 +16,11 @@ changelog:
|
||||
- title: New Features
|
||||
labels:
|
||||
- Semver-Minor
|
||||
- feature
|
||||
- enhancement
|
||||
- title: Experimental Features
|
||||
labels:
|
||||
- experimental
|
||||
- title: Bug Fixes
|
||||
labels:
|
||||
- Semver-Patch
|
||||
|
||||
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@v8.9.3
|
||||
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
|
||||
10
.github/workflows/docker.yaml
vendored
10
.github/workflows/docker.yaml
vendored
@@ -29,9 +29,6 @@ jobs:
|
||||
|
||||
# Build the docker image
|
||||
build:
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event_name }}
|
||||
cancel-in-progress: true
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -63,7 +60,7 @@ jobs:
|
||||
docker-compose run inventree-dev-server invoke update
|
||||
docker-compose run inventree-dev-server invoke setup-dev
|
||||
docker-compose up -d
|
||||
docker-compose run inventree-dev-server pip install --upgrade setuptools
|
||||
docker-compose run inventree-dev-server pip install setuptools==68.1.2
|
||||
docker-compose run inventree-dev-server invoke wait
|
||||
- name: Check Data Directory
|
||||
# The following file structure should have been created by the docker image
|
||||
@@ -81,6 +78,7 @@ jobs:
|
||||
run: |
|
||||
echo "GITHUB_TOKEN=${{ secrets.GITHUB_TOKEN }}" >> docker.dev.env
|
||||
docker-compose run inventree-dev-server invoke test --disable-pty
|
||||
docker-compose run inventree-dev-server invoke test --migrations --disable-pty
|
||||
docker-compose down
|
||||
- name: Set up QEMU
|
||||
if: github.event_name != 'pull_request'
|
||||
@@ -121,8 +119,10 @@ jobs:
|
||||
uses: docker/build-push-action@c56af957549030174b10d6867f20e78cfd7debc5 # pin@v3.2.0
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64,linux/arm/v7
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
sbom: true
|
||||
provenance: false
|
||||
target: production
|
||||
tags: ${{ env.docker_tags }}
|
||||
build-args: |
|
||||
|
||||
180
.github/workflows/qc_checks.yaml
vendored
180
.github/workflows/qc_checks.yaml
vendored
@@ -1,15 +1,12 @@
|
||||
# Checks for each PR / push
|
||||
|
||||
name: QC checks
|
||||
name: QC
|
||||
|
||||
on:
|
||||
push:
|
||||
branches-ignore:
|
||||
- l10*
|
||||
|
||||
branches-ignore: ['l10*']
|
||||
pull_request:
|
||||
branches-ignore:
|
||||
- l10*
|
||||
branches-ignore: ['l10*']
|
||||
|
||||
env:
|
||||
python_version: 3.9
|
||||
@@ -25,13 +22,38 @@ env:
|
||||
INVENTREE_BACKUP_DIR: ../test_inventree_backup
|
||||
|
||||
jobs:
|
||||
paths-filter:
|
||||
name: Filter
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
outputs:
|
||||
server: ${{ steps.filter.outputs.server }}
|
||||
migrations: ${{ steps.filter.outputs.migrations }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@93ea575cb5d8a053eaa0ac8fa3b40d7e05a33cc8 # pin@v3.1.0
|
||||
- uses: dorny/paths-filter@v2
|
||||
id: filter
|
||||
with:
|
||||
filters: |
|
||||
server:
|
||||
- 'InvenTree/**'
|
||||
- 'requirements.txt'
|
||||
- 'requirements-dev.txt'
|
||||
migrations:
|
||||
- '**/migrations/**'
|
||||
- '.github/workflows**'
|
||||
|
||||
pep_style:
|
||||
name: Style [Python]
|
||||
runs-on: ubuntu-20.04
|
||||
|
||||
needs: paths-filter
|
||||
if: needs.paths-filter.outputs.server == 'true'
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@93ea575cb5d8a053eaa0ac8fa3b40d7e05a33cc8 # pin@v3.1.0
|
||||
- name: Enviroment Setup
|
||||
- name: Environment Setup
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
dev-install: true
|
||||
@@ -46,7 +68,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@93ea575cb5d8a053eaa0ac8fa3b40d7e05a33cc8 # pin@v3.1.0
|
||||
- name: Enviroment Setup
|
||||
- name: Environment Setup
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
npm: true
|
||||
@@ -60,22 +82,6 @@ jobs:
|
||||
python InvenTree/manage.py prerender
|
||||
npx eslint InvenTree/InvenTree/static_i18n/i18n/*.js
|
||||
|
||||
html:
|
||||
name: Style [HTML]
|
||||
runs-on: ubuntu-20.04
|
||||
|
||||
needs: pep_style
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@93ea575cb5d8a053eaa0ac8fa3b40d7e05a33cc8 # pin@v3.1.0
|
||||
- name: Enviroment Setup
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
npm: true
|
||||
install: true
|
||||
- name: Check HTML Files
|
||||
run: npx markuplint **/templates/*.html
|
||||
|
||||
pre-commit:
|
||||
name: Style [pre-commit]
|
||||
runs-on: ubuntu-20.04
|
||||
@@ -96,6 +102,28 @@ jobs:
|
||||
pip install requests
|
||||
python3 ci/version_check.py
|
||||
|
||||
mkdocs:
|
||||
name: Style [Documentation]
|
||||
runs-on: ubuntu-20.04
|
||||
|
||||
needs: paths-filter
|
||||
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@93ea575cb5d8a053eaa0ac8fa3b40d7e05a33cc8 # pin@v3.1.0
|
||||
- name: Set up Python ${{ env.python_version }}
|
||||
uses: actions/setup-python@13ae5bb136fac2878aff31522b9efb785519f984 # pin@v4.3.0
|
||||
with:
|
||||
python-version: ${{ env.python_version }}
|
||||
- name: Check Config
|
||||
run: |
|
||||
pip install pyyaml
|
||||
python docs/ci/check_mkdocs_config.py
|
||||
- name: Check Links
|
||||
run: |
|
||||
pip install linkcheckmd requests
|
||||
python -m linkcheckmd docs --recurse
|
||||
|
||||
python:
|
||||
name: Tests - inventree-python
|
||||
runs-on: ubuntu-20.04
|
||||
@@ -115,7 +143,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@93ea575cb5d8a053eaa0ac8fa3b40d7e05a33cc8 # pin@v3.1.0
|
||||
- name: Enviroment Setup
|
||||
- name: Environment Setup
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
apt-dependency: gettext poppler-utils
|
||||
@@ -145,7 +173,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@93ea575cb5d8a053eaa0ac8fa3b40d7e05a33cc8 # pin@v3.1.0
|
||||
- name: Enviroment Setup
|
||||
- name: Environment Setup
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
install: true
|
||||
@@ -156,37 +184,40 @@ jobs:
|
||||
name: Tests - DB [SQLite] + Coverage
|
||||
runs-on: ubuntu-20.04
|
||||
|
||||
needs: [ 'javascript', 'html', 'pre-commit' ]
|
||||
needs: [ 'javascript', 'pre-commit' ]
|
||||
continue-on-error: true # continue if a step fails so that coverage gets pushed
|
||||
|
||||
env:
|
||||
INVENTREE_DB_NAME: ./inventree.sqlite
|
||||
INVENTREE_DB_ENGINE: sqlite3
|
||||
INVENTREE_PLUGINS_ENABLED: true
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@93ea575cb5d8a053eaa0ac8fa3b40d7e05a33cc8 # pin@v3.1.0
|
||||
- name: Enviroment Setup
|
||||
- name: Environment Setup
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
apt-dependency: gettext poppler-utils
|
||||
dev-install: true
|
||||
update: true
|
||||
- name: Coverage Tests
|
||||
run: invoke coverage
|
||||
- name: Data Export Test
|
||||
uses: ./.github/actions/migration
|
||||
- name: Test Translations
|
||||
run: invoke translate
|
||||
- name: Check Migration Files
|
||||
run: python3 ci/check_migration_files.py
|
||||
- name: Coverage Tests
|
||||
run: invoke test --coverage
|
||||
- name: Upload Coverage Report
|
||||
run: coveralls
|
||||
uses: coverallsapp/github-action@v2
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
postgres:
|
||||
name: Tests - DB [PostgreSQL]
|
||||
runs-on: ubuntu-20.04
|
||||
needs: [ 'javascript', 'html', 'pre-commit' ]
|
||||
needs: [ 'javascript', 'pre-commit' ]
|
||||
|
||||
env:
|
||||
INVENTREE_DB_ENGINE: django.db.backends.postgresql
|
||||
@@ -214,7 +245,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@93ea575cb5d8a053eaa0ac8fa3b40d7e05a33cc8 # pin@v3.1.0
|
||||
- name: Enviroment Setup
|
||||
- name: Environment Setup
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
apt-dependency: gettext poppler-utils libpq-dev
|
||||
@@ -230,7 +261,7 @@ jobs:
|
||||
name: Tests - DB [MySQL]
|
||||
runs-on: ubuntu-20.04
|
||||
|
||||
needs: [ 'javascript', 'html', 'pre-commit' ]
|
||||
needs: [ 'javascript', 'pre-commit' ]
|
||||
if: github.event_name == 'push'
|
||||
|
||||
env:
|
||||
@@ -259,7 +290,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@93ea575cb5d8a053eaa0ac8fa3b40d7e05a33cc8 # pin@v3.1.0
|
||||
- name: Enviroment Setup
|
||||
- name: Environment Setup
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
apt-dependency: gettext poppler-utils libmysqlclient-dev
|
||||
@@ -270,3 +301,82 @@ jobs:
|
||||
run: invoke test
|
||||
- name: Data Export Test
|
||||
uses: ./.github/actions/migration
|
||||
|
||||
migration-tests:
|
||||
name: Run Migration Unit Tests
|
||||
runs-on: ubuntu-latest
|
||||
needs: paths-filter
|
||||
if: github.ref == 'refs/heads/master' && needs.paths-filter.outputs.migrations == 'true'
|
||||
|
||||
env:
|
||||
INVENTREE_DB_ENGINE: django.db.backends.postgresql
|
||||
INVENTREE_DB_NAME: inventree
|
||||
INVENTREE_DB_USER: inventree
|
||||
INVENTREE_DB_PASSWORD: password
|
||||
INVENTREE_DB_HOST: '127.0.0.1'
|
||||
INVENTREE_DB_PORT: 5432
|
||||
INVENTREE_DEBUG: info
|
||||
INVENTREE_PLUGINS_ENABLED: false
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:14
|
||||
env:
|
||||
POSTGRES_USER: inventree
|
||||
POSTGRES_PASSWORD: password
|
||||
ports:
|
||||
- 5432:5432
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@93ea575cb5d8a053eaa0ac8fa3b40d7e05a33cc8 # pin@v3.1.0
|
||||
- name: Environment Setup
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
apt-dependency: gettext poppler-utils libpq-dev
|
||||
pip-dependency: psycopg2
|
||||
dev-install: true
|
||||
update: true
|
||||
- name: Run Tests
|
||||
run: invoke test --migrations --report
|
||||
|
||||
migrations-checks:
|
||||
name: Run Database Migrations
|
||||
runs-on: ubuntu-latest
|
||||
needs: paths-filter
|
||||
if: github.ref == 'refs/heads/master' && needs.paths-filter.outputs.migrations == 'true'
|
||||
|
||||
env:
|
||||
INVENTREE_DB_ENGINE: sqlite3
|
||||
INVENTREE_DB_NAME: /home/runner/work/InvenTree/db.sqlite3
|
||||
INVENTREE_DEBUG: info
|
||||
INVENTREE_PLUGINS_ENABLED: false
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
name: Checkout Code
|
||||
- name: Environment Setup
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
install: true
|
||||
- name: Fetch Database
|
||||
run: git clone --depth 1 https://github.com/inventree/test-db ./test-db
|
||||
|
||||
- name: Latest Database
|
||||
run: |
|
||||
cp test-db/latest.sqlite3 /home/runner/work/InvenTree/db.sqlite3
|
||||
chmod +rw /home/runner/work/InvenTree/db.sqlite3
|
||||
invoke migrate
|
||||
|
||||
- name: 0.10.0 Database
|
||||
run: |
|
||||
rm /home/runner/work/InvenTree/db.sqlite3
|
||||
cp test-db/stable_0.10.0.sqlite3 /home/runner/work/InvenTree/db.sqlite3
|
||||
chmod +rw /home/runner/work/InvenTree/db.sqlite3
|
||||
invoke migrate
|
||||
|
||||
- name: 0.11.0 Database
|
||||
run: |
|
||||
rm /home/runner/work/InvenTree/db.sqlite3
|
||||
cp test-db/stable_0.11.0.sqlite3 /home/runner/work/InvenTree/db.sqlite3
|
||||
chmod +rw /home/runner/work/InvenTree/db.sqlite3
|
||||
invoke migrate
|
||||
|
||||
35
.github/workflows/social.yml.disabled
vendored
35
.github/workflows/social.yml.disabled
vendored
@@ -1,35 +0,0 @@
|
||||
# Runs on releases
|
||||
|
||||
name: Publish release notes
|
||||
on:
|
||||
release:
|
||||
types: [ published ]
|
||||
|
||||
jobs:
|
||||
|
||||
tweet:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: Eomm/why-don-t-you-tweet@5936bb1fd0096b1c2bbbb7518746638261bb4dae # pin@v1.0.1
|
||||
with:
|
||||
tweet-message: "InvenTree release ${{ github.event.release.tag_name }} is out
|
||||
now! Release notes: ${{ github.event.release.html_url }} #opensource
|
||||
#inventree"
|
||||
env:
|
||||
TWITTER_CONSUMER_API_KEY: ${{ secrets.TWITTER_CONSUMER_API_KEY }}
|
||||
TWITTER_CONSUMER_API_SECRET: ${{ secrets.TWITTER_CONSUMER_API_SECRET }}
|
||||
TWITTER_ACCESS_TOKEN: ${{ secrets.TWITTER_ACCESS_TOKEN }}
|
||||
TWITTER_ACCESS_TOKEN_SECRET: ${{ secrets.TWITTER_ACCESS_TOKEN_SECRET }}
|
||||
|
||||
reddit:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: bluwy/release-for-reddit-action@4b2d034b5c86a24db24363f1064149a8c2db69b4 # pin@v1.2.0
|
||||
with:
|
||||
username: ${{ secrets.REDDIT_USERNAME }}
|
||||
password: ${{ secrets.REDDIT_PASSWORD }}
|
||||
app-id: ${{ secrets.REDDIT_APP_ID }}
|
||||
app-secret: ${{ secrets.REDDIT_APP_SECRET }}
|
||||
subreddit: InvenTree
|
||||
title: "InvenTree version ${{ github.event.release.tag_name }} released"
|
||||
comment: "${{ github.event.release.body }}"
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -45,9 +45,6 @@ inventree/label.png
|
||||
inventree/my_special*
|
||||
_tests*.txt
|
||||
|
||||
# Sphinx files
|
||||
docs/_build
|
||||
|
||||
# Local static and media file storage (only when running in development mode)
|
||||
inventree_media
|
||||
inventree_static
|
||||
@@ -100,7 +97,7 @@ node_modules/
|
||||
maintenance_mode_state.txt
|
||||
|
||||
# plugin dev directory
|
||||
plugins/
|
||||
InvenTree/plugins/
|
||||
|
||||
# Compiled translation files
|
||||
*.mo
|
||||
|
||||
44
.gitpod.yml
44
.gitpod.yml
@@ -1,44 +0,0 @@
|
||||
tasks:
|
||||
- name: Setup django
|
||||
before: |
|
||||
export INVENTREE_DB_ENGINE='sqlite3'
|
||||
export INVENTREE_DB_NAME='/workspace/InvenTree/dev/database.sqlite3'
|
||||
export INVENTREE_MEDIA_ROOT='/workspace/InvenTree/inventree-data/media'
|
||||
export INVENTREE_STATIC_ROOT='/workspace/InvenTree/dev/static'
|
||||
export INVENTREE_BACKUP_DIR='/workspace/InvenTree/dev/backup'
|
||||
export PIP_USER='no'
|
||||
|
||||
sudo apt install -y gettext
|
||||
python3 -m venv venv
|
||||
source venv/bin/activate
|
||||
pip install invoke pyyaml
|
||||
mkdir dev
|
||||
invoke update
|
||||
invoke setup-test --ignore-update --path inventree-data
|
||||
gp sync-done start_server
|
||||
|
||||
- name: Start server
|
||||
init: gp sync-await start_server
|
||||
command: |
|
||||
gp sync-await start_server
|
||||
export INVENTREE_DB_ENGINE='sqlite3'
|
||||
export INVENTREE_DB_NAME='/workspace/InvenTree/dev/database.sqlite3'
|
||||
export INVENTREE_MEDIA_ROOT='/workspace/InvenTree/inventree-data/media'
|
||||
export INVENTREE_STATIC_ROOT='/workspace/InvenTree/dev/static'
|
||||
export INVENTREE_BACKUP_DIR='/workspace/InvenTree/dev/backup'
|
||||
|
||||
source venv/bin/activate
|
||||
inv server
|
||||
|
||||
ports:
|
||||
- port: 8000
|
||||
onOpen: open-preview
|
||||
|
||||
github:
|
||||
prebuilds:
|
||||
master: true
|
||||
pullRequests: false
|
||||
pullRequestsFromForks: true
|
||||
addBadge: true
|
||||
addLabel: gitpod-ready
|
||||
addCheck: false
|
||||
@@ -15,6 +15,8 @@ env:
|
||||
- INVENTREE_PLUGIN_FILE=/opt/inventree/plugins.txt
|
||||
- INVENTREE_CONFIG_FILE=/opt/inventree/config.yaml
|
||||
after_install: contrib/packager.io/postinstall.sh
|
||||
before:
|
||||
- contrib/packager.io/before.sh
|
||||
dependencies:
|
||||
- curl
|
||||
- python3
|
||||
|
||||
@@ -12,29 +12,45 @@ repos:
|
||||
- id: trailing-whitespace
|
||||
- id: end-of-file-fixer
|
||||
- id: check-yaml
|
||||
- id: check-added-large-files
|
||||
- id: mixed-line-ending
|
||||
- repo: https://github.com/pycqa/flake8
|
||||
rev: '6.0.0'
|
||||
hooks:
|
||||
- id: flake8
|
||||
additional_dependencies: [
|
||||
'flake8-bugbear',
|
||||
'flake8-comprehensions',
|
||||
'flake8-docstrings',
|
||||
'flake8-string-format',
|
||||
'pep8-naming ',
|
||||
'flake8-tidy-imports',
|
||||
'pep8-naming'
|
||||
]
|
||||
- repo: https://github.com/pycqa/isort
|
||||
rev: '5.12.0'
|
||||
hooks:
|
||||
- id: isort
|
||||
- repo: https://github.com/jazzband/pip-tools
|
||||
rev: 6.12.3
|
||||
rev: 6.13.0
|
||||
hooks:
|
||||
- id: pip-compile
|
||||
name: pip-compile requirements-dev.in
|
||||
args: [--generate-hashes, requirements-dev.in, -o, requirements-dev.txt]
|
||||
args: [requirements-dev.in, -o, requirements-dev.txt]
|
||||
files: ^requirements-dev\.(in|txt)$
|
||||
- id: pip-compile
|
||||
name: pip-compile requirements.txt
|
||||
args: [requirements.in, -o, requirements.txt]
|
||||
files: ^requirements\.(in|txt)$
|
||||
- repo: https://github.com/Riverside-Healthcare/djLint
|
||||
rev: v1.30.2
|
||||
hooks:
|
||||
- id: djlint-django
|
||||
- repo: https://github.com/codespell-project/codespell
|
||||
rev: v2.2.4
|
||||
hooks:
|
||||
- id: codespell
|
||||
exclude: >
|
||||
(?x)^(
|
||||
docs/docs/stylesheets/.*|
|
||||
docs/docs/javascripts/.*|
|
||||
docs/docs/webfonts/.*
|
||||
)$
|
||||
|
||||
12
.vscode/tasks.json
vendored
12
.vscode/tasks.json
vendored
@@ -1,52 +1,64 @@
|
||||
{
|
||||
// See https://go.microsoft.com/fwlink/?LinkId=733558
|
||||
// for the documentation about the tasks.json format
|
||||
|
||||
// the problemMatchers should prevent vscode from asking how it should check the output
|
||||
|
||||
"version": "2.0.0",
|
||||
"tasks": [
|
||||
{
|
||||
"label": "clean-settings",
|
||||
"type": "shell",
|
||||
"command": "inv clean-settings",
|
||||
"problemMatcher": [],
|
||||
},
|
||||
{
|
||||
"label": "delete-data",
|
||||
"type": "shell",
|
||||
"command": "inv delete-data",
|
||||
"problemMatcher": [],
|
||||
},
|
||||
{
|
||||
"label": "migrate",
|
||||
"type": "shell",
|
||||
"command": "inv migrate",
|
||||
"problemMatcher": [],
|
||||
},
|
||||
{
|
||||
"label": "server",
|
||||
"type": "shell",
|
||||
"command": "inv server",
|
||||
"problemMatcher": [],
|
||||
},
|
||||
{
|
||||
"label": "setup-dev",
|
||||
"type": "shell",
|
||||
"command": "inv setup-dev",
|
||||
"problemMatcher": [],
|
||||
},
|
||||
{
|
||||
"label": "setup-test",
|
||||
"type": "shell",
|
||||
"command": "inv setup-test --path dev/inventree-demo-dataset",
|
||||
"problemMatcher": [],
|
||||
},
|
||||
{
|
||||
"label": "superuser",
|
||||
"type": "shell",
|
||||
"command": "inv superuser",
|
||||
"problemMatcher": [],
|
||||
},
|
||||
{
|
||||
"label": "test",
|
||||
"type": "shell",
|
||||
"command": "inv test",
|
||||
"problemMatcher": [],
|
||||
},
|
||||
{
|
||||
"label": "update",
|
||||
"type": "shell",
|
||||
"command": "inv update",
|
||||
"problemMatcher": [],
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
Hi there, thank you for your intrest in contributing!
|
||||
Hi there, thank you for your interest in contributing!
|
||||
Please read the contribution guidelines below, before submitting your first pull request to the InvenTree codebase.
|
||||
|
||||
## Quickstart
|
||||
@@ -19,7 +19,7 @@ pip install invoke && invoke setup-dev --tests
|
||||
```bash
|
||||
git clone https://github.com/inventree/InvenTree.git && cd InvenTree
|
||||
docker compose run inventree-dev-server invoke install
|
||||
docker compose run inventree-dev-server invoke setup-test
|
||||
docker compose run inventree-dev-server invoke setup-test --dev
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
@@ -33,7 +33,7 @@ Run the following command to set up all toolsets for development.
|
||||
invoke setup-dev
|
||||
```
|
||||
|
||||
*We recommend you run this command before starting to contribute. This will install and set up `pre-commit` to run some checks before each commit and help reduce the style errors.*
|
||||
*We recommend you run this command before starting to contribute. This will install and set up `pre-commit` to run some checks before each commit and help reduce errors.*
|
||||
|
||||
## Branches and Versioning
|
||||
|
||||
@@ -50,7 +50,7 @@ The HEAD of the "main" or "master" branch of InvenTree represents the current "l
|
||||
- All feature branches are merged into master
|
||||
- All bug fixes are merged into master
|
||||
|
||||
**No pushing to master:** New featues must be submitted as a pull request from a separate branch (one branch per feature).
|
||||
**No pushing to master:** New features must be submitted as a pull request from a separate branch (one branch per feature).
|
||||
|
||||
### Feature Branches
|
||||
|
||||
@@ -70,7 +70,7 @@ The HEAD of the "stable" branch represents the latest stable release code.
|
||||
#### Release Candidate Branches
|
||||
|
||||
- Release candidate branches are made from master, and merged into stable.
|
||||
- RC branches are targetted at a major/minor version e.g. "0.5"
|
||||
- RC branches are targeted at a major/minor version e.g. "0.5"
|
||||
- When a release candidate branch is merged into *stable*, the release is tagged
|
||||
|
||||
#### Bugfix Branches
|
||||
@@ -101,7 +101,7 @@ django-upgrade --target-version 3.2 `find . -name "*.py"`
|
||||
```
|
||||
|
||||
## Credits
|
||||
If you add any new dependencies / libraries, they need to be added to [the docs](https://github.com/inventree/inventree-docs/blob/master/docs/credits.md). Please try to do that as timely as possible.
|
||||
If you add any new dependencies / libraries, they need to be added to [the docs](https://github.com/inventree/inventree/blob/master/docs/docs/credits.md). Please try to do that as timely as possible.
|
||||
|
||||
|
||||
## Migration Files
|
||||
@@ -123,14 +123,44 @@ The InvenTree code base makes use of [GitHub actions](https://github.com/feature
|
||||
|
||||
The various github actions can be found in the `./github/workflows` directory
|
||||
|
||||
### Run tests locally
|
||||
|
||||
To run test locally, use:
|
||||
```
|
||||
invoke test
|
||||
```
|
||||
|
||||
To run only partial tests, for example for a module use:
|
||||
```
|
||||
invoke test --runtest order
|
||||
```
|
||||
|
||||
## Code Style
|
||||
|
||||
Sumbitted Python code is automatically checked against PEP style guidelines. Locally you can run `invoke style` to ensure the style checks will pass, before submitting the PR.
|
||||
Submitted Python code is automatically checked against PEP style guidelines. Locally you can run `invoke style` to ensure the style checks will pass, before submitting the PR.
|
||||
Please write docstrings for each function and class - we follow the [google doc-style](https://google.github.io/styleguide/pyguide.html#38-comments-and-docstrings) for python. Docstrings for general javascript code is encouraged! Docstyles are checked by `invoke style`.
|
||||
|
||||
### Django templates
|
||||
|
||||
Django are checked by [djlint](https://github.com/Riverside-Healthcare/djlint) through pre-commit.
|
||||
|
||||
The following rules out of the [default set](https://djlint.com/docs/linter/) are not applied:
|
||||
```bash
|
||||
D018: (Django) Internal links should use the { % url ... % } pattern
|
||||
H006: Img tag should have height and width attributes
|
||||
H008: Attributes should be double quoted
|
||||
H021: Inline styles should be avoided
|
||||
H023: Do not use entity references
|
||||
H025: Tag seems to be an orphan
|
||||
H030: Consider adding a meta description
|
||||
H031: Consider adding meta keywords
|
||||
T002: Double quotes should be used in tags
|
||||
```
|
||||
|
||||
|
||||
## Documentation
|
||||
|
||||
New features or updates to existing features should be accompanied by user documentation. A PR with associated documentation should link to the matching PR at https://github.com/inventree/inventree-docs/
|
||||
New features or updates to existing features should be accompanied by user documentation.
|
||||
|
||||
## Translations
|
||||
|
||||
@@ -157,16 +187,16 @@ user_facing_string = _('This string will be exposed to the translation engine!')
|
||||
HTML and javascript files are passed through the django templating engine. Translatable strings are implemented as follows:
|
||||
|
||||
```html
|
||||
{% load i18n %}
|
||||
{ % load i18n % }
|
||||
|
||||
<span>{% trans "This string will be translated" %} - this string will not!</span>
|
||||
<span>{ % trans "This string will be translated" % } - this string will not!</span>
|
||||
```
|
||||
|
||||
## Github use
|
||||
### Tags
|
||||
The tags describe issues and PRs in multiple areas:
|
||||
| Area | Name | Description |
|
||||
|---|---|---|
|
||||
| --- | --- | --- |
|
||||
| Triage Labels | | |
|
||||
| | triage:not-checked | Item was not checked by the core team |
|
||||
| | triage:not-approved | Item is not green-light by maintainer |
|
||||
@@ -175,10 +205,13 @@ The tags describe issues and PRs in multiple areas:
|
||||
| | bug | Identifies a bug which needs to be addressed |
|
||||
| | dependency | Relates to a project dependency |
|
||||
| | duplicate | Duplicate of another issue or PR |
|
||||
| | enhancement | This is an suggested enhancement or new feature |
|
||||
| | enhancement | This is an suggested enhancement, extending the functionality of an existing feature |
|
||||
| | experimental | This is a new *experimental* feature which needs to be enabled manually |
|
||||
| | feature | This is a new feature, introducing novel functionality |
|
||||
| | help wanted | Assistance required |
|
||||
| | invalid | This issue or PR is considered invalid |
|
||||
| | inactive | Indicates lack of activity |
|
||||
| | migration | Database migration, requires special attention |
|
||||
| | question | This is a question |
|
||||
| | roadmap | This is a roadmap feature with no immediate plans for implementation |
|
||||
| | security | Relates to a security issue |
|
||||
@@ -197,7 +230,9 @@ The tags describe issues and PRs in multiple areas:
|
||||
| | stock | Stock item management |
|
||||
| | user interface | User interface |
|
||||
| Ecosystem Labels | | |
|
||||
| | backport | Tags that the issue will be backported to a stable branch as a bug-fix |
|
||||
| | demo | Relates to the InvenTree demo server or dataset |
|
||||
| | docker | Docker / docker-compose |
|
||||
| | CI | CI / unit testing ecosystem |
|
||||
| | refactor | Refactoring existing code |
|
||||
| | setup | Relates to the InvenTree setup / installation process |
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
"""Admin classes"""
|
||||
|
||||
from django.contrib import admin
|
||||
from django.http.request import HttpRequest
|
||||
|
||||
from djmoney.contrib.exchange.admin import RateAdmin
|
||||
from djmoney.contrib.exchange.models import Rate
|
||||
from import_export.resources import ModelResource
|
||||
|
||||
|
||||
@@ -31,3 +36,27 @@ class InvenTreeResource(ModelResource):
|
||||
row[idx] = val
|
||||
|
||||
return row
|
||||
|
||||
def get_fields(self, **kwargs):
|
||||
"""Return fields, with some common exclusions"""
|
||||
|
||||
fields = super().get_fields(**kwargs)
|
||||
|
||||
fields_to_exclude = [
|
||||
'metadata',
|
||||
'lft', 'rght', 'tree_id', 'level',
|
||||
]
|
||||
|
||||
return [f for f in fields if f.column_name not in fields_to_exclude]
|
||||
|
||||
|
||||
class CustomRateAdmin(RateAdmin):
|
||||
"""Admin interface for the Rate class"""
|
||||
|
||||
def has_add_permission(self, request: HttpRequest) -> bool:
|
||||
"""Disable the 'add' permission for Rate objects"""
|
||||
return False
|
||||
|
||||
|
||||
admin.site.unregister(Rate)
|
||||
admin.site.register(Rate, CustomRateAdmin)
|
||||
|
||||
@@ -59,14 +59,39 @@ class NotFoundView(AjaxView):
|
||||
|
||||
permission_classes = [permissions.AllowAny]
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
"""Proces an `not found` event on the API."""
|
||||
data = {
|
||||
'details': _('API endpoint not found'),
|
||||
'url': request.build_absolute_uri(),
|
||||
}
|
||||
def not_found(self, request):
|
||||
"""Return a 404 error"""
|
||||
return JsonResponse(
|
||||
{
|
||||
'detail': _('API endpoint not found'),
|
||||
'url': request.build_absolute_uri(),
|
||||
},
|
||||
status=404
|
||||
)
|
||||
|
||||
return JsonResponse(data, status=404)
|
||||
def options(self, request, *args, **kwargs):
|
||||
"""Return 404"""
|
||||
return self.not_found(request)
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
"""Return 404"""
|
||||
return self.not_found(request)
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
"""Return 404"""
|
||||
return self.not_found(request)
|
||||
|
||||
def patch(self, request, *args, **kwargs):
|
||||
"""Return 404"""
|
||||
return self.not_found(request)
|
||||
|
||||
def put(self, request, *args, **kwargs):
|
||||
"""Return 404"""
|
||||
return self.not_found(request)
|
||||
|
||||
def delete(self, request, *args, **kwargs):
|
||||
"""Return 404"""
|
||||
return self.not_found(request)
|
||||
|
||||
|
||||
class BulkDeleteMixin:
|
||||
@@ -306,47 +331,6 @@ class APISearchView(APIView):
|
||||
return Response(results)
|
||||
|
||||
|
||||
class StatusView(APIView):
|
||||
"""Generic API endpoint for discovering information on 'status codes' for a particular model.
|
||||
|
||||
This class should be implemented as a subclass for each type of status.
|
||||
For example, the API endpoint /stock/status/ will have information about
|
||||
all available 'StockStatus' codes
|
||||
"""
|
||||
|
||||
permission_classes = [
|
||||
permissions.IsAuthenticated,
|
||||
]
|
||||
|
||||
# Override status_class for implementing subclass
|
||||
MODEL_REF = 'statusmodel'
|
||||
|
||||
def get_status_model(self, *args, **kwargs):
|
||||
"""Return the StatusCode moedl based on extra parameters passed to the view"""
|
||||
|
||||
status_model = self.kwargs.get(self.MODEL_REF, None)
|
||||
|
||||
if status_model is None:
|
||||
raise ValidationError(f"StatusView view called without '{self.MODEL_REF}' parameter")
|
||||
|
||||
return status_model
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
"""Perform a GET request to learn information about status codes"""
|
||||
|
||||
status_class = self.get_status_model()
|
||||
|
||||
if not status_class:
|
||||
raise NotImplementedError("status_class not defined for this endpoint")
|
||||
|
||||
data = {
|
||||
'class': status_class.__name__,
|
||||
'values': status_class.dict(),
|
||||
}
|
||||
|
||||
return Response(data)
|
||||
|
||||
|
||||
class MetadataView(RetrieveUpdateAPI):
|
||||
"""Generic API endpoint for reading and editing metadata for a model"""
|
||||
|
||||
|
||||
@@ -2,11 +2,79 @@
|
||||
|
||||
|
||||
# InvenTree API version
|
||||
INVENTREE_API_VERSION = 107
|
||||
INVENTREE_API_VERSION = 127
|
||||
|
||||
"""
|
||||
Increment this API version number whenever there is a significant change to the API that any clients need to know about
|
||||
|
||||
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
|
||||
>>>>>>> inventree/master
|
||||
|
||||
v113 -> 2023-05-13 : https://github.com/inventree/InvenTree/pull/4800
|
||||
- Adds API endpoints for scrapping a build output
|
||||
|
||||
v112 -> 2023-05-13: https://github.com/inventree/InvenTree/pull/4741
|
||||
- Adds flag use_pack_size to the stock addition API, which allows addings packs
|
||||
|
||||
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
|
||||
@@ -167,7 +235,7 @@ v64 -> 2022-07-08 : https://github.com/inventree/InvenTree/pull/3310
|
||||
- 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" paramater
|
||||
- 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
|
||||
|
||||
@@ -11,6 +11,7 @@ from django.core.exceptions import AppRegistryNotReady
|
||||
from django.db import transaction
|
||||
from django.db.utils import IntegrityError
|
||||
|
||||
import InvenTree.conversion
|
||||
import InvenTree.tasks
|
||||
from InvenTree.config import get_setting
|
||||
from InvenTree.ready import canAppAccessDatabase, isInTestMode
|
||||
@@ -29,8 +30,8 @@ class InvenTreeConfig(AppConfig):
|
||||
- Checking if migrations should be run
|
||||
- Cleaning up tasks
|
||||
- Starting regular tasks
|
||||
- Updateing exchange rates
|
||||
- Collecting notification mehods
|
||||
- Updating exchange rates
|
||||
- Collecting notification methods
|
||||
- Adding users set in the current environment
|
||||
"""
|
||||
if canAppAccessDatabase() or settings.TESTING_ENV:
|
||||
@@ -46,6 +47,9 @@ class InvenTreeConfig(AppConfig):
|
||||
|
||||
self.collect_notification_methods()
|
||||
|
||||
# Ensure the unit registry is loaded
|
||||
InvenTree.conversion.get_unit_registry()
|
||||
|
||||
if canAppAccessDatabase() or settings.TESTING_ENV:
|
||||
self.add_user_on_startup()
|
||||
|
||||
@@ -80,7 +84,7 @@ class InvenTreeConfig(AppConfig):
|
||||
minutes=task.minutes,
|
||||
)
|
||||
|
||||
# Put at least one task onto the backround worker stack,
|
||||
# Put at least one task onto the background worker stack,
|
||||
# which will be processed as soon as the worker comes online
|
||||
InvenTree.tasks.offload_task(
|
||||
InvenTree.tasks.heartbeat,
|
||||
@@ -122,19 +126,22 @@ class InvenTreeConfig(AppConfig):
|
||||
update = False
|
||||
|
||||
try:
|
||||
backend = ExchangeBackend.objects.get(name='InvenTreeExchange')
|
||||
backend = ExchangeBackend.objects.filter(name='InvenTreeExchange')
|
||||
|
||||
last_update = backend.last_update
|
||||
if backend.exists():
|
||||
backend = backend.first()
|
||||
|
||||
if last_update is None:
|
||||
# Never been updated
|
||||
logger.info("Exchange backend has never been updated")
|
||||
update = True
|
||||
last_update = backend.last_update
|
||||
|
||||
# Backend currency has changed?
|
||||
if base_currency != backend.base_currency:
|
||||
logger.info(f"Base currency changed from {backend.base_currency} to {base_currency}")
|
||||
update = True
|
||||
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(f"Base currency changed from {backend.base_currency} to {base_currency}")
|
||||
update = True
|
||||
|
||||
except (ExchangeBackend.DoesNotExist):
|
||||
logger.info("Exchange backend not found - updating")
|
||||
@@ -148,7 +155,7 @@ class InvenTreeConfig(AppConfig):
|
||||
try:
|
||||
update_exchange_rates()
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating exchange rates: {e}")
|
||||
logger.error(f"Error updating exchange rates: {e} ({type(e)})")
|
||||
|
||||
def add_user_on_startup(self):
|
||||
"""Add a user on startup."""
|
||||
@@ -188,8 +195,8 @@ class InvenTreeConfig(AppConfig):
|
||||
else:
|
||||
new_user = user.objects.create_superuser(add_user, add_email, add_password)
|
||||
logger.info(f'User {str(new_user)} was created!')
|
||||
except IntegrityError as _e:
|
||||
logger.warning(f'The user "{add_user}" could not be created due to the following error:\n{str(_e)}')
|
||||
except IntegrityError:
|
||||
logger.warning(f'The user "{add_user}" could not be created')
|
||||
|
||||
# do not try again
|
||||
settings.USER_ADDED = True
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
"""Pull rendered copies of the templated.
|
||||
|
||||
Only used for testing the js files! - This file is omited from coverage.
|
||||
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.helpers import InvenTreeTestCase # pragma: no cover
|
||||
from InvenTree.unit_test import InvenTreeTestCase # pragma: no cover
|
||||
|
||||
|
||||
class RenderJavascriptFiles(InvenTreeTestCase): # pragma: no cover
|
||||
@@ -17,7 +17,7 @@ class RenderJavascriptFiles(InvenTreeTestCase): # pragma: no cover
|
||||
"""
|
||||
|
||||
def download_file(self, filename, prefix):
|
||||
"""Function to `download`(copy) a file to a temporay firectory."""
|
||||
"""Function to `download`(copy) a file to a temporary firectory."""
|
||||
url = os.path.join(prefix, filename)
|
||||
|
||||
response = self.client.get(url)
|
||||
|
||||
@@ -200,7 +200,7 @@ def get_setting(env_var=None, config_key=None, default_value=None, typecast=None
|
||||
|
||||
|
||||
def get_boolean_setting(env_var=None, config_key=None, default_value=False):
|
||||
"""Helper function for retreiving a boolean configuration setting"""
|
||||
"""Helper function for retrieving a boolean configuration setting"""
|
||||
|
||||
return is_true(get_setting(env_var, config_key, default_value))
|
||||
|
||||
|
||||
@@ -2,11 +2,10 @@
|
||||
|
||||
"""Provides extra global data to all templates."""
|
||||
|
||||
import InvenTree.email
|
||||
import InvenTree.status
|
||||
from InvenTree.status_codes import (BuildStatus, PurchaseOrderStatus,
|
||||
ReturnOrderLineStatus, ReturnOrderStatus,
|
||||
SalesOrderStatus, StockHistoryCode,
|
||||
StockStatus)
|
||||
from generic.states import StatusCode
|
||||
from InvenTree.helpers import inheritors
|
||||
from users.models import RuleSet, check_user_role
|
||||
|
||||
|
||||
@@ -28,7 +27,7 @@ def health_status(request):
|
||||
|
||||
status = {
|
||||
'django_q_running': InvenTree.status.is_worker_running(),
|
||||
'email_configured': InvenTree.status.is_email_configured(),
|
||||
'email_configured': InvenTree.email.is_email_configured(),
|
||||
}
|
||||
|
||||
# The following keys are required to denote system health
|
||||
@@ -56,17 +55,7 @@ def status_codes(request):
|
||||
return {}
|
||||
|
||||
request._inventree_status_codes = True
|
||||
|
||||
return {
|
||||
# Expose the StatusCode classes to the templates
|
||||
'ReturnOrderStatus': ReturnOrderStatus,
|
||||
'ReturnOrderLineStatus': ReturnOrderLineStatus,
|
||||
'SalesOrderStatus': SalesOrderStatus,
|
||||
'PurchaseOrderStatus': PurchaseOrderStatus,
|
||||
'BuildStatus': BuildStatus,
|
||||
'StockStatus': StockStatus,
|
||||
'StockHistoryCode': StockHistoryCode,
|
||||
}
|
||||
return {cls.__name__: cls.template_context() for cls in inheritors(StatusCode)}
|
||||
|
||||
|
||||
def user_roles(request):
|
||||
|
||||
108
InvenTree/InvenTree/conversion.py
Normal file
108
InvenTree/InvenTree/conversion.py
Normal file
@@ -0,0 +1,108 @@
|
||||
"""Helper functions for converting between units."""
|
||||
|
||||
import logging
|
||||
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
import pint
|
||||
|
||||
_unit_registry = None
|
||||
|
||||
|
||||
logger = logging.getLogger('inventree')
|
||||
|
||||
|
||||
def get_unit_registry():
|
||||
"""Return a custom instance of the Pint UnitRegistry."""
|
||||
|
||||
global _unit_registry
|
||||
|
||||
# Cache the unit registry for speedier access
|
||||
if _unit_registry is None:
|
||||
reload_unit_registry()
|
||||
|
||||
return _unit_registry
|
||||
|
||||
|
||||
def reload_unit_registry():
|
||||
"""Reload the unit registry from the database.
|
||||
|
||||
This function is called at startup, and whenever the database is updated.
|
||||
"""
|
||||
|
||||
import time
|
||||
t_start = time.time()
|
||||
|
||||
global _unit_registry
|
||||
|
||||
_unit_registry = pint.UnitRegistry()
|
||||
|
||||
# Define some "standard" additional units
|
||||
_unit_registry.define('piece = 1')
|
||||
_unit_registry.define('each = 1 = ea')
|
||||
_unit_registry.define('dozen = 12 = dz')
|
||||
_unit_registry.define('hundred = 100')
|
||||
_unit_registry.define('thousand = 1000')
|
||||
|
||||
# TODO: Allow for custom units to be defined in the database
|
||||
|
||||
dt = time.time() - t_start
|
||||
logger.debug(f'Loaded unit registry in {dt:.3f}s')
|
||||
|
||||
|
||||
def convert_physical_value(value: str, unit: str = None):
|
||||
"""Validate that the provided value is a valid physical quantity.
|
||||
|
||||
Arguments:
|
||||
value: Value to validate (str)
|
||||
unit: Optional unit to convert to, and validate against
|
||||
|
||||
Raises:
|
||||
ValidationError: If the value is invalid or cannot be converted to the specified unit
|
||||
|
||||
Returns:
|
||||
The converted quantity, in the specified units
|
||||
"""
|
||||
|
||||
# Ensure that the value is a string
|
||||
value = str(value).strip()
|
||||
|
||||
# Error on blank values
|
||||
if not value:
|
||||
raise ValidationError(_('No value provided'))
|
||||
|
||||
ureg = get_unit_registry()
|
||||
error = ''
|
||||
|
||||
try:
|
||||
# Convert to a quantity
|
||||
val = ureg.Quantity(value)
|
||||
|
||||
if unit:
|
||||
|
||||
if val.units == ureg.dimensionless:
|
||||
# If the provided value is dimensionless, assume that the unit is correct
|
||||
val = ureg.Quantity(value, unit)
|
||||
else:
|
||||
# Convert to the provided unit (may raise an exception)
|
||||
val = val.to(unit)
|
||||
|
||||
# At this point we *should* have a valid pint value
|
||||
# To double check, look at the maginitude
|
||||
float(val.magnitude)
|
||||
except (TypeError, ValueError, AttributeError):
|
||||
error = _('Provided value is not a valid number')
|
||||
except (pint.errors.UndefinedUnitError, pint.errors.DefinitionSyntaxError):
|
||||
error = _('Provided value has an invalid unit')
|
||||
except pint.errors.DimensionalityError:
|
||||
error = _('Provided value could not be converted to the specified unit')
|
||||
|
||||
if error:
|
||||
if unit:
|
||||
error += f' ({unit})'
|
||||
|
||||
raise ValidationError(error)
|
||||
|
||||
# Return the converted value
|
||||
return val
|
||||
90
InvenTree/InvenTree/email.py
Normal file
90
InvenTree/InvenTree/email.py
Normal file
@@ -0,0 +1,90 @@
|
||||
"""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.warning("DEFAULT_FROM_EMAIL is not configured")
|
||||
|
||||
return configured
|
||||
|
||||
|
||||
def send_email(subject, body, recipients, from_email=None, html_message=None):
|
||||
"""Send an email with the specified subject and body, to the specified recipients list."""
|
||||
|
||||
if type(recipients) == str:
|
||||
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
|
||||
)
|
||||
@@ -18,6 +18,8 @@ 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')
|
||||
|
||||
|
||||
@@ -32,7 +34,7 @@ def log_error(path):
|
||||
|
||||
kind, info, data = sys.exc_info()
|
||||
|
||||
# Check if the eror is on the ignore list
|
||||
# Check if the error is on the ignore list
|
||||
if kind in settings.IGNORED_ERRORS:
|
||||
return
|
||||
|
||||
@@ -61,18 +63,12 @@ def exception_handler(exc, context):
|
||||
"""
|
||||
response = None
|
||||
|
||||
if settings.SENTRY_ENABLED and settings.SENTRY_DSN and not settings.DEBUG:
|
||||
# Report this exception to sentry.io
|
||||
from sentry_sdk import capture_exception
|
||||
|
||||
# The following types of errors are ignored, they are "expected"
|
||||
do_not_report = [
|
||||
DjangoValidationError,
|
||||
DRFValidationError,
|
||||
]
|
||||
|
||||
if not any([isinstance(exc, err) for err in do_not_report]):
|
||||
capture_exception(exc)
|
||||
# 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):
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
"""Exchangerate backend to use `exchangerate.host` to get rates."""
|
||||
"""Exchangerate backend to use `frankfurter.app` to get rates."""
|
||||
|
||||
import ssl
|
||||
from decimal import Decimal
|
||||
from urllib.error import URLError
|
||||
from urllib.request import urlopen
|
||||
|
||||
from django.db.utils import OperationalError
|
||||
|
||||
import certifi
|
||||
import requests
|
||||
from djmoney.contrib.exchange.backends.base import SimpleExchangeBackend
|
||||
|
||||
from common.settings import currency_code_default, currency_codes
|
||||
@@ -15,19 +14,19 @@ from common.settings import currency_code_default, currency_codes
|
||||
class InvenTreeExchange(SimpleExchangeBackend):
|
||||
"""Backend for automatically updating currency exchange rates.
|
||||
|
||||
Uses the `exchangerate.host` service API
|
||||
Uses the `frankfurter.app` service API
|
||||
"""
|
||||
|
||||
name = "InvenTreeExchange"
|
||||
|
||||
def __init__(self):
|
||||
"""Set API url."""
|
||||
self.url = "https://api.exchangerate.host/latest"
|
||||
self.url = "https://api.frankfurter.app/latest"
|
||||
|
||||
super().__init__()
|
||||
|
||||
def get_params(self):
|
||||
"""Placeholder to set API key. Currently not required by `exchangerate.host`."""
|
||||
"""Placeholder to set API key. Currently not required by `frankfurter.app`."""
|
||||
# No API key is required
|
||||
return {
|
||||
}
|
||||
@@ -40,14 +39,22 @@ class InvenTreeExchange(SimpleExchangeBackend):
|
||||
url = self.get_url(**kwargs)
|
||||
|
||||
try:
|
||||
context = ssl.create_default_context(cafile=certifi.where())
|
||||
response = urlopen(url, timeout=5, context=context)
|
||||
return response.read()
|
||||
response = requests.get(url=url, timeout=5)
|
||||
return response.content
|
||||
except Exception:
|
||||
# Something has gone wrong, but we can just try again next time
|
||||
# Raise a TypeError so the outer function can handle this
|
||||
raise TypeError
|
||||
|
||||
def get_rates(self, **params):
|
||||
"""Intersect the requested currency codes with the available codes."""
|
||||
rates = super().get_rates(**params)
|
||||
|
||||
# Add the base currency to the rates
|
||||
rates[params["base_currency"]] = Decimal("1.0")
|
||||
|
||||
return rates
|
||||
|
||||
def update_rates(self, base_currency=None):
|
||||
"""Set the requested currency codes and get rates."""
|
||||
# Set default - see B008
|
||||
|
||||
@@ -4,7 +4,7 @@ import sys
|
||||
from decimal import Decimal
|
||||
|
||||
from django import forms
|
||||
from django.db import models as models
|
||||
from django.db import models
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from djmoney.forms.fields import MoneyField
|
||||
@@ -19,6 +19,7 @@ from .validators import AllowedURLValidator, allowable_url_schemes
|
||||
|
||||
class InvenTreeRestURLField(RestURLField):
|
||||
"""Custom field for DRF with custom scheme vaildators."""
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
"""Update schemes."""
|
||||
|
||||
@@ -111,6 +112,7 @@ class InvenTreeModelMoneyField(ModelMoneyField):
|
||||
|
||||
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.update(money_kwargs())
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
from django_filters import rest_framework as rest_filters
|
||||
from rest_framework import filters
|
||||
|
||||
from InvenTree.helpers import str2bool
|
||||
import InvenTree.helpers
|
||||
|
||||
|
||||
class InvenTreeSearchFilter(filters.SearchFilter):
|
||||
@@ -13,10 +13,10 @@ class InvenTreeSearchFilter(filters.SearchFilter):
|
||||
"""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 perfomed on 'regex' comparison
|
||||
- search_regex: If True, search is performed on 'regex' comparison
|
||||
"""
|
||||
|
||||
regex = str2bool(request.query_params.get('search_regex', False))
|
||||
regex = InvenTree.helpers.str2bool(request.query_params.get('search_regex', False))
|
||||
|
||||
search_fields = super().get_search_fields(view, request)
|
||||
|
||||
@@ -37,7 +37,7 @@ class InvenTreeSearchFilter(filters.SearchFilter):
|
||||
Depending on the request parameters, we may "augment" these somewhat
|
||||
"""
|
||||
|
||||
whole = str2bool(request.query_params.get('search_whole', False))
|
||||
whole = InvenTree.helpers.str2bool(request.query_params.get('search_whole', False))
|
||||
|
||||
terms = []
|
||||
|
||||
|
||||
@@ -91,7 +91,7 @@ def construct_format_regex(fmt_string: str) -> str:
|
||||
# Add a named capture group for the format entry
|
||||
if name:
|
||||
|
||||
# Check if integer values are requried
|
||||
# Check if integer values are required
|
||||
if format.endswith('d'):
|
||||
chr = '\d'
|
||||
else:
|
||||
|
||||
@@ -12,7 +12,7 @@ from django.urls import reverse
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from allauth.account.adapter import DefaultAccountAdapter
|
||||
from allauth.account.forms import SignupForm, set_form_field_order
|
||||
from allauth.account.forms import LoginForm, SignupForm, set_form_field_order
|
||||
from allauth.exceptions import ImmediateHttpResponse
|
||||
from allauth.socialaccount.adapter import DefaultSocialAccountAdapter
|
||||
from allauth_2fa.adapter import OTPAdapter
|
||||
@@ -21,6 +21,8 @@ from crispy_forms.bootstrap import (AppendedText, PrependedAppendedText,
|
||||
PrependedText)
|
||||
from crispy_forms.helper import FormHelper
|
||||
from crispy_forms.layout import Field, Layout
|
||||
from dj_rest_auth.registration.serializers import RegisterSerializer
|
||||
from rest_framework import serializers
|
||||
|
||||
from common.models import InvenTreeSetting
|
||||
from InvenTree.exceptions import log_error
|
||||
@@ -159,11 +161,30 @@ class SetPasswordForm(HelperForm):
|
||||
old_password = forms.CharField(
|
||||
label=_("Old password"),
|
||||
strip=False,
|
||||
required=False,
|
||||
widget=forms.PasswordInput(attrs={'autocomplete': 'current-password', 'autofocus': True}),
|
||||
)
|
||||
|
||||
|
||||
# override allauth
|
||||
class CustomLoginForm(LoginForm):
|
||||
"""Custom login form to override default allauth behaviour"""
|
||||
|
||||
def login(self, request, redirect_url=None):
|
||||
"""Perform login action.
|
||||
|
||||
First check that:
|
||||
- A valid user has been supplied
|
||||
"""
|
||||
|
||||
if not self.user:
|
||||
# No user supplied - redirect to the login page
|
||||
return HttpResponseRedirect(reverse('account_login'))
|
||||
|
||||
# Now perform default login action
|
||||
return super().login(request, redirect_url)
|
||||
|
||||
|
||||
class CustomSignupForm(SignupForm):
|
||||
"""Override to use dynamic settings."""
|
||||
|
||||
@@ -206,15 +227,20 @@ class CustomSignupForm(SignupForm):
|
||||
return cleaned_data
|
||||
|
||||
|
||||
def registration_enabled():
|
||||
"""Determine whether user registration is enabled."""
|
||||
return settings.EMAIL_HOST and (InvenTreeSetting.get_setting('LOGIN_ENABLE_REG') or InvenTreeSetting.get_setting('LOGIN_ENABLE_SSO_REG'))
|
||||
|
||||
|
||||
class RegistratonMixin:
|
||||
"""Mixin to check if registration should be enabled."""
|
||||
|
||||
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, defualt: `LOGIN_ENABLE_REG`.
|
||||
Configure the class variable `REGISTRATION_SETTING` to set which setting should be used, default: `LOGIN_ENABLE_REG`.
|
||||
"""
|
||||
if settings.EMAIL_HOST and (InvenTreeSetting.get_setting('LOGIN_ENABLE_REG') or InvenTreeSetting.get_setting('LOGIN_ENABLE_SSO_REG')):
|
||||
if registration_enabled():
|
||||
return super().is_open_for_signup(request, *args, **kwargs)
|
||||
return False
|
||||
|
||||
@@ -253,7 +279,7 @@ class RegistratonMixin:
|
||||
group = Group.objects.get(id=start_group)
|
||||
user.groups.add(group)
|
||||
except Group.DoesNotExist:
|
||||
logger.error('The setting `SIGNUP_GROUP` contains an non existant group', start_group)
|
||||
logger.error('The setting `SIGNUP_GROUP` contains an non existent group', start_group)
|
||||
user.save()
|
||||
return user
|
||||
|
||||
@@ -276,7 +302,7 @@ class CustomAccountAdapter(CustomUrlMixin, RegistratonMixin, OTPAdapter, Default
|
||||
try:
|
||||
result = super().send_mail(template_prefix, email, context)
|
||||
except Exception:
|
||||
# An exception ocurred while attempting to send email
|
||||
# An exception occurred while attempting to send email
|
||||
# Log it (for admin users) and return silently
|
||||
log_error('account email')
|
||||
result = False
|
||||
@@ -285,6 +311,15 @@ class CustomAccountAdapter(CustomUrlMixin, RegistratonMixin, OTPAdapter, Default
|
||||
|
||||
return False
|
||||
|
||||
def get_email_confirmation_url(self, request, emailconfirmation):
|
||||
"""Construct the email confirmation url"""
|
||||
|
||||
from InvenTree.helpers_model import construct_absolute_url
|
||||
|
||||
url = super().get_email_confirmation_url(request, emailconfirmation)
|
||||
url = construct_absolute_url(url)
|
||||
return url
|
||||
|
||||
|
||||
class CustomSocialAccountAdapter(CustomUrlMixin, RegistratonMixin, DefaultSocialAccountAdapter):
|
||||
"""Override of adapter to use dynamic settings."""
|
||||
@@ -319,3 +354,20 @@ class CustomSocialAccountAdapter(CustomUrlMixin, RegistratonMixin, DefaultSocial
|
||||
|
||||
# Otherwise defer to the original allauth adapter.
|
||||
return super().login(request, user)
|
||||
|
||||
|
||||
# override dj-rest-auth
|
||||
class CustomRegisterSerializer(RegisterSerializer):
|
||||
"""Override of serializer to use dynamic settings."""
|
||||
email = serializers.EmailField()
|
||||
|
||||
def __init__(self, instance=None, data=..., **kwargs):
|
||||
"""Check settings to influence which fields are needed."""
|
||||
kwargs['email_required'] = InvenTreeSetting.get_setting('LOGIN_MAIL_REQUIRED')
|
||||
super().__init__(instance, data, **kwargs)
|
||||
|
||||
def save(self, request):
|
||||
"""Override to check if registration is open."""
|
||||
if registration_enabled():
|
||||
return super().save(request)
|
||||
raise forms.ValidationError(_('Registration is disabled.'))
|
||||
|
||||
@@ -8,44 +8,28 @@ import os
|
||||
import os.path
|
||||
import re
|
||||
from decimal import Decimal, InvalidOperation
|
||||
from pathlib import Path
|
||||
from wsgiref.util import FileWrapper
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import Permission
|
||||
from django.contrib.staticfiles.storage import StaticFilesStorage
|
||||
from django.core.exceptions import FieldError, ValidationError
|
||||
from django.core.files.storage import default_storage
|
||||
from django.core.validators import URLValidator
|
||||
from django.http import StreamingHttpResponse
|
||||
from django.test import TestCase
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
import moneyed.localization
|
||||
import regex
|
||||
import requests
|
||||
from bleach import clean
|
||||
from djmoney.contrib.exchange.models import convert_money
|
||||
from djmoney.money import Money
|
||||
from PIL import Image
|
||||
|
||||
import InvenTree.version
|
||||
from common.models import InvenTreeSetting
|
||||
from common.notifications import (InvenTreeNotificationBodies,
|
||||
NotificationBody, trigger_notification)
|
||||
from common.settings import currency_code_default
|
||||
|
||||
from .api_tester import ExchangeRateMixin, UserMixin
|
||||
from .settings import MEDIA_URL, STATIC_URL
|
||||
|
||||
logger = logging.getLogger('inventree')
|
||||
|
||||
|
||||
def getSetting(key, backup_value=None):
|
||||
"""Shortcut for reading a setting value from the database."""
|
||||
return InvenTreeSetting.get_setting(key, backup_value=backup_value)
|
||||
|
||||
|
||||
def generateTestKey(test_name):
|
||||
"""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.
|
||||
|
||||
@@ -88,130 +72,6 @@ def getStaticUrl(filename):
|
||||
return os.path.join(STATIC_URL, str(filename))
|
||||
|
||||
|
||||
def construct_absolute_url(*arg):
|
||||
"""Construct (or attempt to construct) an absolute URL from a relative URL.
|
||||
|
||||
This is useful when (for example) sending an email to a user with a link
|
||||
to something in the InvenTree web framework.
|
||||
|
||||
This requires the BASE_URL configuration option to be set!
|
||||
"""
|
||||
base = str(InvenTreeSetting.get_setting('INVENTREE_BASE_URL'))
|
||||
|
||||
url = '/'.join(arg)
|
||||
|
||||
if not base:
|
||||
return url
|
||||
|
||||
# Strip trailing slash from base url
|
||||
if base.endswith('/'):
|
||||
base = base[:-1]
|
||||
|
||||
if url.startswith('/'):
|
||||
url = url[1:]
|
||||
|
||||
url = f"{base}/{url}"
|
||||
|
||||
return 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(InvenTreeSetting.get_setting('INVENTREE_DOWNLOAD_IMAGE_MAX_SIZE')) * 1024 * 1024
|
||||
|
||||
# Add user specified user-agent to request (if specified)
|
||||
user_agent = 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
|
||||
fo = 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"))
|
||||
|
||||
fo.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(fo).convert()
|
||||
img.verify()
|
||||
except Exception:
|
||||
raise TypeError(_("Supplied URL is not a valid image file"))
|
||||
|
||||
return img
|
||||
|
||||
|
||||
def TestIfImage(img):
|
||||
"""Test if an image file is indeed an image."""
|
||||
try:
|
||||
@@ -632,7 +492,7 @@ def extract_serial_numbers(input_string, expected_quantity: int, starting_value=
|
||||
|
||||
serial = serial.strip()
|
||||
|
||||
# Ignore blank / emtpy serials
|
||||
# Ignore blank / empty serials
|
||||
if len(serial) == 0:
|
||||
return
|
||||
|
||||
@@ -823,75 +683,6 @@ def validateFilterString(value, model=None):
|
||||
return results
|
||||
|
||||
|
||||
def addUserPermission(user, permission):
|
||||
"""Shortcut function for adding a certain permission to a user."""
|
||||
perm = Permission.objects.get(codename=permission)
|
||||
user.user_permissions.add(perm)
|
||||
|
||||
|
||||
def addUserPermissions(user, permissions):
|
||||
"""Shortcut function for adding multiple permissions to a user."""
|
||||
for permission in permissions:
|
||||
addUserPermission(user, permission)
|
||||
|
||||
|
||||
def getMigrationFileNames(app):
|
||||
"""Return a list of all migration filenames for provided app."""
|
||||
local_dir = Path(__file__).parent
|
||||
files = local_dir.joinpath('..', app, 'migrations').iterdir()
|
||||
|
||||
# Regex pattern for migration files
|
||||
regex = re.compile(r"^[\d]+_.*\.py$")
|
||||
|
||||
migration_files = []
|
||||
|
||||
for f in files:
|
||||
if regex.match(f.name):
|
||||
migration_files.append(f.name)
|
||||
|
||||
return migration_files
|
||||
|
||||
|
||||
def getOldestMigrationFile(app, exclude_extension=True, ignore_initial=True):
|
||||
"""Return the filename associated with the oldest migration."""
|
||||
oldest_num = -1
|
||||
oldest_file = None
|
||||
|
||||
for f in getMigrationFileNames(app):
|
||||
|
||||
if ignore_initial and f.startswith('0001_initial'):
|
||||
continue
|
||||
|
||||
num = int(f.split('_')[0])
|
||||
|
||||
if oldest_file is None or num < oldest_num:
|
||||
oldest_num = num
|
||||
oldest_file = f
|
||||
|
||||
if exclude_extension:
|
||||
oldest_file = oldest_file.replace('.py', '')
|
||||
|
||||
return oldest_file
|
||||
|
||||
|
||||
def getNewestMigrationFile(app, exclude_extension=True):
|
||||
"""Return the filename associated with the newest migration."""
|
||||
newest_file = None
|
||||
newest_num = -1
|
||||
|
||||
for f in getMigrationFileNames(app):
|
||||
num = int(f.split('_')[0])
|
||||
|
||||
if newest_file is None or num > newest_num:
|
||||
newest_num = num
|
||||
newest_file = f
|
||||
|
||||
if exclude_extension:
|
||||
newest_file = newest_file.replace('.py', '')
|
||||
|
||||
return newest_file
|
||||
|
||||
|
||||
def clean_decimal(number):
|
||||
"""Clean-up decimal value."""
|
||||
# Check if empty
|
||||
@@ -1062,102 +853,3 @@ def inheritors(cls):
|
||||
subcls.add(child)
|
||||
work.append(child)
|
||||
return subcls
|
||||
|
||||
|
||||
class InvenTreeTestCase(ExchangeRateMixin, UserMixin, TestCase):
|
||||
"""Testcase with user setup buildin."""
|
||||
pass
|
||||
|
||||
|
||||
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.
|
||||
"""
|
||||
if instance.responsible is not None:
|
||||
# Setup context for notification parsing
|
||||
content_context = {
|
||||
'instance': str(instance),
|
||||
'verbose_name': sender._meta.verbose_name,
|
||||
'app_label': sender._meta.app_label,
|
||||
'model_name': sender._meta.model_name,
|
||||
}
|
||||
|
||||
# Setup notification context
|
||||
context = {
|
||||
'instance': instance,
|
||||
'name': content.name.format(**content_context),
|
||||
'message': content.message.format(**content_context),
|
||||
'link': InvenTree.helpers.construct_absolute_url(instance.get_absolute_url()),
|
||||
'template': {
|
||||
'html': content.template.format(**content_context),
|
||||
'subject': content.name.format(**content_context),
|
||||
}
|
||||
}
|
||||
|
||||
# Create notification
|
||||
trigger_notification(
|
||||
instance,
|
||||
content.slug.format(**content_context),
|
||||
targets=[instance.responsible],
|
||||
target_exclude=[exclude],
|
||||
context=context,
|
||||
)
|
||||
|
||||
|
||||
def render_currency(money, decimal_places=None, currency=None, include_symbol=True, min_decimal_places=None):
|
||||
"""Render a currency / Money object to a formatted string (e.g. for reports)
|
||||
|
||||
Arguments:
|
||||
money: The Money instance to be rendered
|
||||
decimal_places: The number of decimal places to render to. If unspecified, uses the PRICING_DECIMAL_PLACES setting.
|
||||
currency: Optionally convert to the specified currency
|
||||
include_symbol: Render with the appropriate currency symbol
|
||||
min_decimal_places: The minimum number of decimal places to render to. If unspecified, uses the PRICING_DECIMAL_PLACES_MIN setting.
|
||||
"""
|
||||
|
||||
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 = InvenTreeSetting.get_setting('PRICING_DECIMAL_PLACES', 6)
|
||||
|
||||
if min_decimal_places is None:
|
||||
min_decimal_places = InvenTreeSetting.get_setting('PRICING_DECIMAL_PLACES_MIN', 0)
|
||||
|
||||
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)
|
||||
|
||||
return moneyed.localization.format_money(
|
||||
money,
|
||||
decimal_places=decimal_places,
|
||||
include_symbol=include_symbol,
|
||||
)
|
||||
|
||||
302
InvenTree/InvenTree/helpers_model.py
Normal file
302
InvenTree/InvenTree/helpers_model.py
Normal file
@@ -0,0 +1,302 @@
|
||||
"""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 moneyed.localization
|
||||
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)
|
||||
|
||||
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 construct_absolute_url(*arg, **kwargs):
|
||||
"""Construct (or attempt to construct) an absolute URL from a relative URL.
|
||||
|
||||
This is useful when (for example) sending an email to a user with a link
|
||||
to something in the InvenTree web framework.
|
||||
A URL is constructed in the following order:
|
||||
1. If settings.SITE_URL is set (e.g. in the Django settings), use that
|
||||
2. If the InvenTree setting INVENTREE_BASE_URL is set, use that
|
||||
3. Otherwise, use the current request URL (if available)
|
||||
"""
|
||||
|
||||
relative_url = '/'.join(arg)
|
||||
|
||||
# If a site URL is provided, use that
|
||||
site_url = getattr(settings, 'SITE_URL', None)
|
||||
|
||||
if not site_url:
|
||||
# Otherwise, try to use the InvenTree setting
|
||||
try:
|
||||
site_url = common.models.InvenTreeSetting.get_setting('INVENTREE_BASE_URL', create=False, cache=False)
|
||||
except (ProgrammingError, OperationalError):
|
||||
pass
|
||||
|
||||
if not site_url:
|
||||
# Otherwise, try to use the current request
|
||||
request = kwargs.get('request', None)
|
||||
|
||||
if request:
|
||||
site_url = request.build_absolute_uri('/')
|
||||
|
||||
if not site_url:
|
||||
# No site URL available, return the relative URL
|
||||
return relative_url
|
||||
|
||||
return urljoin(site_url, relative_url)
|
||||
|
||||
|
||||
def get_base_url(**kwargs):
|
||||
"""Return the base URL for the InvenTree server"""
|
||||
return construct_absolute_url('', **kwargs)
|
||||
|
||||
|
||||
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, include_symbol=True, min_decimal_places=None, max_decimal_places=None):
|
||||
"""Render a currency / Money object to a formatted string (e.g. for reports)
|
||||
|
||||
Arguments:
|
||||
money: The Money instance to be rendered
|
||||
decimal_places: The number of decimal places to render to. If unspecified, uses the PRICING_DECIMAL_PLACES setting.
|
||||
currency: Optionally convert to the specified currency
|
||||
include_symbol: Render with the appropriate currency symbol
|
||||
min_decimal_places: The minimum number of decimal places to render to. If unspecified, uses the PRICING_DECIMAL_PLACES_MIN setting.
|
||||
max_decimal_places: The maximum number of decimal places to render to. If unspecified, uses the PRICING_DECIMAL_PLACES setting.
|
||||
"""
|
||||
|
||||
if money in [None, '']:
|
||||
return '-'
|
||||
|
||||
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 moneyed.localization.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
|
||||
|
||||
db_models = [x.model_class() for x in ContentType.objects.all() if x is not None]
|
||||
|
||||
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.
|
||||
"""
|
||||
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,
|
||||
)
|
||||
@@ -23,8 +23,8 @@ def render_file(file_name, source, target, locales, ctx):
|
||||
|
||||
with open(target_file, 'w') as localised_file:
|
||||
with lang_over(locale):
|
||||
renderd = render_to_string(os.path.join(source, file_name), ctx)
|
||||
localised_file.write(renderd)
|
||||
rendered = render_to_string(os.path.join(source, file_name), ctx)
|
||||
localised_file.write(rendered)
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
|
||||
@@ -18,7 +18,7 @@ class Command(BaseCommand):
|
||||
|
||||
while not connected:
|
||||
|
||||
time.sleep(5)
|
||||
time.sleep(2)
|
||||
|
||||
try:
|
||||
connection.ensure_connection()
|
||||
@@ -34,4 +34,4 @@ class Command(BaseCommand):
|
||||
self.stdout.write("Database configuration is not usable")
|
||||
|
||||
if connected:
|
||||
self.stdout.write("Database connection sucessful!")
|
||||
self.stdout.write("Database connection successful!")
|
||||
|
||||
@@ -28,7 +28,7 @@ class InvenTreeMetadata(SimpleMetadata):
|
||||
"""
|
||||
|
||||
def determine_metadata(self, request, view):
|
||||
"""Overwrite the metadata to adapt to hte request user."""
|
||||
"""Overwrite the metadata to adapt to the request user."""
|
||||
self.request = request
|
||||
self.view = view
|
||||
|
||||
@@ -36,7 +36,7 @@ class InvenTreeMetadata(SimpleMetadata):
|
||||
|
||||
"""
|
||||
Custom context information to pass through to the OPTIONS endpoint,
|
||||
if the "context=True" is supplied to the OPTIONS requst
|
||||
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)
|
||||
"""
|
||||
|
||||
@@ -14,7 +14,6 @@ from allauth_2fa.middleware import (AllauthTwoFactorMiddleware,
|
||||
from error_report.middleware import ExceptionProcessor
|
||||
from rest_framework.authtoken.models import Token
|
||||
|
||||
from common.models import InvenTreeSetting
|
||||
from InvenTree.urls import frontendpatterns
|
||||
|
||||
logger = logging.getLogger("inventree")
|
||||
@@ -102,7 +101,7 @@ class AuthRequiredMiddleware(object):
|
||||
'/static/',
|
||||
]
|
||||
|
||||
if path not in urls and not any([path.startswith(p) for p in paths_ignore]):
|
||||
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}')
|
||||
@@ -123,6 +122,9 @@ 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')
|
||||
@@ -158,11 +160,38 @@ class InvenTreeExceptionProcessor(ExceptionProcessor):
|
||||
"""Custom exception processor that respects blocked errors."""
|
||||
|
||||
def process_exception(self, request, exception):
|
||||
"""Check if kind is ignored before procesing."""
|
||||
"""Check if kind is ignored before processing."""
|
||||
kind, info, data = sys.exc_info()
|
||||
|
||||
# Check if the eror is on the ignore list
|
||||
# Check if the error is on the ignore list
|
||||
if kind in settings.IGNORED_ERRORS:
|
||||
return
|
||||
|
||||
return super().process_exception(request, exception)
|
||||
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()
|
||||
|
||||
@@ -125,7 +125,7 @@ class CreateAPI(CleanMixin, generics.CreateAPIView):
|
||||
|
||||
|
||||
class RetrieveAPI(generics.RetrieveAPIView):
|
||||
"""View for retreive API."""
|
||||
"""View for retrieve API."""
|
||||
pass
|
||||
|
||||
|
||||
|
||||
@@ -21,10 +21,10 @@ from error_report.models import Error
|
||||
from mptt.exceptions import InvalidMove
|
||||
from mptt.models import MPTTModel, TreeForeignKey
|
||||
|
||||
import InvenTree.fields
|
||||
import InvenTree.format
|
||||
import InvenTree.helpers
|
||||
from common.models import InvenTreeSetting
|
||||
from InvenTree.fields import InvenTreeURLField
|
||||
import InvenTree.helpers_model
|
||||
from InvenTree.sanitizer import sanitize_svg
|
||||
|
||||
logger = logging.getLogger('inventree')
|
||||
@@ -44,13 +44,88 @@ def rename_attachment(instance, filename):
|
||||
return os.path.join(instance.getSubdir(), filename)
|
||||
|
||||
|
||||
class MetadataMixin(models.Model):
|
||||
"""Model mixin class which adds a JSON metadata field to a model, for use by any (and all) plugins.
|
||||
|
||||
The intent of this mixin is to provide a metadata field on a model instance,
|
||||
for plugins to read / modify as required, to store any extra information.
|
||||
|
||||
The assumptions for models implementing this mixin are:
|
||||
|
||||
- The internal InvenTree business logic will make no use of this field
|
||||
- Multiple plugins may read / write to this metadata field, and not assume they have sole rights
|
||||
"""
|
||||
|
||||
class Meta:
|
||||
"""Meta for MetadataMixin."""
|
||||
abstract = True
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
"""Save the model instance, and perform validation on the metadata field."""
|
||||
self.validate_metadata()
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def clean(self, *args, **kwargs):
|
||||
"""Perform model validation on the metadata field."""
|
||||
super().clean()
|
||||
|
||||
self.validate_metadata()
|
||||
|
||||
def validate_metadata(self):
|
||||
"""Validate the metadata field."""
|
||||
|
||||
# Ensure that the 'metadata' field is a valid dict object
|
||||
if self.metadata is None:
|
||||
self.metadata = {}
|
||||
|
||||
if type(self.metadata) is not dict:
|
||||
raise ValidationError({'metadata': _('Metadata must be a python dict object')})
|
||||
|
||||
metadata = models.JSONField(
|
||||
blank=True, null=True,
|
||||
verbose_name=_('Plugin Metadata'),
|
||||
help_text=_('JSON metadata field, for use by external plugins'),
|
||||
)
|
||||
|
||||
def get_metadata(self, key: str, backup_value=None):
|
||||
"""Finds metadata for this model instance, using the provided key for lookup.
|
||||
|
||||
Args:
|
||||
key: String key for requesting metadata. e.g. if a plugin is accessing the metadata, the plugin slug should be used
|
||||
|
||||
Returns:
|
||||
Python dict object containing requested metadata. If no matching metadata is found, returns None
|
||||
"""
|
||||
if self.metadata is None:
|
||||
return backup_value
|
||||
|
||||
return self.metadata.get(key, backup_value)
|
||||
|
||||
def set_metadata(self, key: str, data, commit: bool = True, overwrite: bool = False):
|
||||
"""Save the provided metadata under the provided key.
|
||||
|
||||
Args:
|
||||
key (str): Key for saving metadata
|
||||
data (Any): Data object to save - must be able to be rendered as a JSON string
|
||||
commit (bool, optional): If true, existing metadata with the provided key will be overwritten. If false, a merge will be attempted. Defaults to True.
|
||||
overwrite (bool): If true, delete existing metadata before adding new value
|
||||
"""
|
||||
if overwrite or self.metadata is None:
|
||||
self.metadata = {}
|
||||
|
||||
self.metadata[key] = data
|
||||
|
||||
if commit:
|
||||
self.save()
|
||||
|
||||
|
||||
class DataImportMixin(object):
|
||||
"""Model mixin class which provides support for 'data import' functionality.
|
||||
|
||||
Models which implement this mixin should provide information on the fields available for import
|
||||
"""
|
||||
|
||||
# Define a map of fields avaialble for import
|
||||
# Define a map of fields available for import
|
||||
IMPORT_FIELDS = {}
|
||||
|
||||
@classmethod
|
||||
@@ -132,6 +207,8 @@ class ReferenceIndexingMixin(models.Model):
|
||||
if cls.REFERENCE_PATTERN_SETTING is None:
|
||||
return ''
|
||||
|
||||
# import at function level to prevent cyclic imports
|
||||
from common.models import InvenTreeSetting
|
||||
return InvenTreeSetting.get_setting(cls.REFERENCE_PATTERN_SETTING, create=False).strip()
|
||||
|
||||
@classmethod
|
||||
@@ -411,7 +488,7 @@ class InvenTreeAttachment(models.Model):
|
||||
blank=True, null=True
|
||||
)
|
||||
|
||||
link = InvenTreeURLField(
|
||||
link = InvenTree.fields.InvenTreeURLField(
|
||||
blank=True, null=True,
|
||||
verbose_name=_('Link'),
|
||||
help_text=_('Link to external URL')
|
||||
@@ -635,12 +712,12 @@ class InvenTreeTree(MPTTModel):
|
||||
available = contents.get_all_objects_for_this_type()
|
||||
|
||||
# List of child IDs
|
||||
childs = self.getUniqueChildren()
|
||||
children = self.getUniqueChildren()
|
||||
|
||||
acceptable = [None]
|
||||
|
||||
for a in available:
|
||||
if a.id not in childs:
|
||||
if a.id not in children:
|
||||
acceptable.append(a)
|
||||
|
||||
return acceptable
|
||||
@@ -652,7 +729,7 @@ class InvenTreeTree(MPTTModel):
|
||||
Returns:
|
||||
List of category names from the top level to the parent of this category
|
||||
"""
|
||||
return [a for a in self.get_ancestors()]
|
||||
return list(self.get_ancestors())
|
||||
|
||||
@property
|
||||
def path(self):
|
||||
@@ -670,6 +747,27 @@ class InvenTreeTree(MPTTModel):
|
||||
return "{path} - {desc}".format(path=self.pathstring, desc=self.description)
|
||||
|
||||
|
||||
class InvenTreeNotesMixin(models.Model):
|
||||
"""A mixin class for adding notes functionality to a model class.
|
||||
|
||||
The following fields are added to any model which implements this mixin:
|
||||
|
||||
- notes : A text field for storing notes
|
||||
"""
|
||||
|
||||
class Meta:
|
||||
"""Metaclass options for this mixin.
|
||||
|
||||
Note: abstract must be true, as this is only a mixin, not a separate table
|
||||
"""
|
||||
abstract = True
|
||||
|
||||
notes = InvenTree.fields.InvenTreeNotesField(
|
||||
verbose_name=_('Notes'),
|
||||
help_text=_('Markdown notes (optional)'),
|
||||
)
|
||||
|
||||
|
||||
class InvenTreeBarcodeMixin(models.Model):
|
||||
"""A mixin class for adding barcode functionality to a model class.
|
||||
|
||||
@@ -793,7 +891,7 @@ def after_error_logged(sender, instance: Error, created: bool, **kwargs):
|
||||
|
||||
users = get_user_model().objects.filter(is_staff=True)
|
||||
|
||||
link = InvenTree.helpers.construct_absolute_url(
|
||||
link = InvenTree.helpers_model.construct_absolute_url(
|
||||
reverse('admin:error_report_error_change', kwargs={'object_id': instance.pk})
|
||||
)
|
||||
|
||||
@@ -809,7 +907,7 @@ def after_error_logged(sender, instance: Error, created: bool, **kwargs):
|
||||
'inventree.error_log',
|
||||
context=context,
|
||||
targets=users,
|
||||
delivery_methods=set([common.notifications.UIMessageNotification]),
|
||||
delivery_methods={common.notifications.UIMessageNotification, },
|
||||
)
|
||||
|
||||
except Exception as exc:
|
||||
|
||||
@@ -92,6 +92,14 @@ class IsSuperuser(permissions.IsAdminUser):
|
||||
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):
|
||||
|
||||
@@ -13,6 +13,11 @@ def isImportingData():
|
||||
return 'loaddata' in sys.argv
|
||||
|
||||
|
||||
def isRunningMigrations():
|
||||
"""Return True if the database is currently running migrations."""
|
||||
return 'migrate' in sys.argv or 'makemigrations' in sys.argv
|
||||
|
||||
|
||||
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.
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@ ALLOWED_ATTRIBUTES_SVG = [
|
||||
]
|
||||
|
||||
|
||||
def sanitize_svg(file_data: str, strip: bool = True, elements: str = ALLOWED_ELEMENTS_SVG, attributes: str = ALLOWED_ATTRIBUTES_SVG) -> str:
|
||||
def sanitize_svg(file_data, strip: bool = True, elements: str = ALLOWED_ELEMENTS_SVG, attributes: str = ALLOWED_ATTRIBUTES_SVG) -> str:
|
||||
"""Sanatize a SVG file.
|
||||
|
||||
Args:
|
||||
@@ -56,6 +56,10 @@ def sanitize_svg(file_data: str, strip: bool = True, elements: str = ALLOWED_ELE
|
||||
str: Sanitzied SVG file.
|
||||
"""
|
||||
|
||||
# Handle byte-encoded data
|
||||
if type(file_data) == bytes:
|
||||
file_data = file_data.decode('utf-8')
|
||||
|
||||
cleaned = clean(
|
||||
file_data,
|
||||
tags=elements,
|
||||
@@ -64,4 +68,5 @@ def sanitize_svg(file_data: str, strip: bool = True, elements: str = ALLOWED_ELE
|
||||
strip_comments=strip,
|
||||
css_sanitizer=CSSSanitizer()
|
||||
)
|
||||
|
||||
return cleaned
|
||||
|
||||
68
InvenTree/InvenTree/sentry.py
Normal file
68
InvenTree/InvenTree/sentry.py
Normal file
@@ -0,0 +1,68 @@
|
||||
"""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
|
||||
|
||||
from InvenTree.version import INVENTREE_SW_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.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_SW_VERSION,
|
||||
)
|
||||
|
||||
for key, val in tags.items():
|
||||
sentry_sdk.set_tag(f'inventree_{key}', val)
|
||||
|
||||
|
||||
def report_exception(exc):
|
||||
"""Report an exception to sentry.io"""
|
||||
|
||||
if settings.SENTRY_ENABLED and settings.SENTRY_DSN:
|
||||
|
||||
if not any(isinstance(exc, e) for e in sentry_ignore_errors()):
|
||||
logger.info(f"Reporting exception to sentry.io: {exc}")
|
||||
|
||||
try:
|
||||
sentry_sdk.capture_exception(exc)
|
||||
except Exception:
|
||||
logger.warning("Failed to report exception to sentry.io")
|
||||
@@ -19,11 +19,12 @@ from rest_framework.exceptions import 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
|
||||
|
||||
from common.models import InvenTreeSetting
|
||||
import common.models as common_models
|
||||
from common.settings import currency_code_default, currency_code_mappings
|
||||
from InvenTree.fields import InvenTreeRestURLField, InvenTreeURLField
|
||||
from InvenTree.helpers import download_image_from_url
|
||||
from InvenTree.helpers_model import download_image_from_url
|
||||
|
||||
|
||||
class InvenTreeMoneySerializer(MoneyField):
|
||||
@@ -33,7 +34,7 @@ class InvenTreeMoneySerializer(MoneyField):
|
||||
"""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
"""Overrite default values."""
|
||||
"""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)
|
||||
@@ -73,10 +74,17 @@ class InvenTreeCurrencySerializer(serializers.ChoiceField):
|
||||
def __init__(self, *args, **kwargs):
|
||||
"""Initialize the currency serializer"""
|
||||
|
||||
kwargs['choices'] = currency_code_mappings()
|
||||
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'] = currency_code_default
|
||||
kwargs['default'] = '' if allow_blank else currency_code_default
|
||||
|
||||
if 'label' not in kwargs:
|
||||
kwargs['label'] = _('Currency')
|
||||
@@ -257,6 +265,28 @@ class InvenTreeModelSerializer(serializers.ModelSerializer):
|
||||
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."""
|
||||
|
||||
@@ -492,7 +522,7 @@ class DataFileUploadSerializer(serializers.Serializer):
|
||||
pass
|
||||
|
||||
# Extract a list of valid model field names
|
||||
model_field_names = [key for key in model_fields.keys()]
|
||||
model_field_names = list(model_fields.keys())
|
||||
|
||||
# Provide a dict of available columns from the dataset
|
||||
file_columns = {}
|
||||
@@ -694,7 +724,7 @@ class RemoteImageMixin(metaclass=serializers.SerializerMetaclass):
|
||||
if not url:
|
||||
return
|
||||
|
||||
if not InvenTreeSetting.get_setting('INVENTREE_DOWNLOAD_FROM_URL'):
|
||||
if not common_models.InvenTreeSetting.get_setting('INVENTREE_DOWNLOAD_FROM_URL'):
|
||||
raise ValidationError(_("Downloading images from remote URL is not enabled"))
|
||||
|
||||
try:
|
||||
|
||||
@@ -17,15 +17,18 @@ from pathlib import Path
|
||||
|
||||
import django.conf.locale
|
||||
import django.core.exceptions
|
||||
from django.core.validators import URLValidator
|
||||
from django.http import Http404
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
import moneyed
|
||||
import sentry_sdk
|
||||
from sentry_sdk.integrations.django import DjangoIntegration
|
||||
from dotenv import load_dotenv
|
||||
|
||||
from InvenTree.config import get_boolean_setting, get_custom_file, get_setting
|
||||
from InvenTree.sentry import default_sentry_dsn, init_sentry
|
||||
from InvenTree.version import inventreeApiVersion
|
||||
|
||||
from . import config
|
||||
from .config import get_boolean_setting, get_custom_file, get_setting
|
||||
|
||||
INVENTREE_NEWS_URL = 'https://inventree.org/news/feed.atom'
|
||||
|
||||
@@ -63,6 +66,12 @@ BASE_DIR = config.get_base_dir()
|
||||
# Load configuration data
|
||||
CONFIG = config.load_config_data(set_cache=True)
|
||||
|
||||
# Load VERSION data if it exists
|
||||
version_file = BASE_DIR.parent.joinpath('VERSION')
|
||||
if version_file.exists():
|
||||
print('load version from file')
|
||||
load_dotenv(version_file)
|
||||
|
||||
# Default action is to run the system in Debug mode
|
||||
# SECURITY WARNING: don't run with debug turned on in production!
|
||||
DEBUG = get_boolean_setting('INVENTREE_DEBUG', 'debug', True)
|
||||
@@ -194,6 +203,7 @@ INSTALLED_APPS = [
|
||||
'stock.apps.StockConfig',
|
||||
'users.apps.UsersConfig',
|
||||
'plugin.apps.PluginAppConfig',
|
||||
'generic',
|
||||
'InvenTree.apps.InvenTreeConfig', # InvenTree app runs last
|
||||
|
||||
# Core django modules
|
||||
@@ -223,6 +233,8 @@ INSTALLED_APPS = [
|
||||
'django_q',
|
||||
'formtools', # Form wizard tools
|
||||
'dbbackup', # Backups - django-dbbackup
|
||||
'taggit', # Tagging
|
||||
'flags', # Flagging - django-flags
|
||||
|
||||
'allauth', # Base app for SSO
|
||||
'allauth.account', # Extend user with accounts
|
||||
@@ -233,6 +245,9 @@ INSTALLED_APPS = [
|
||||
'django_otp.plugins.otp_static', # Backup codes
|
||||
|
||||
'allauth_2fa', # MFA flow for allauth
|
||||
'dj_rest_auth', # Authentication APIs - dj-rest-auth
|
||||
'dj_rest_auth.registration', # Registration APIs - dj-rest-auth'
|
||||
'drf_spectacular', # API documentation
|
||||
|
||||
'django_ical', # For exporting calendars
|
||||
]
|
||||
@@ -326,7 +341,7 @@ TEMPLATES = [
|
||||
'InvenTree.context.user_roles',
|
||||
],
|
||||
'loaders': [(
|
||||
'django.template.loaders.cached.Loader', [
|
||||
'InvenTree.template.InvenTreeTemplateLoader', [
|
||||
'plugin.template.PluginTemplateLoader',
|
||||
'django.template.loaders.filesystem.Loader',
|
||||
'django.template.loaders.app_directories.Loader',
|
||||
@@ -356,7 +371,7 @@ REST_FRAMEWORK = {
|
||||
'rest_framework.permissions.DjangoModelPermissions',
|
||||
'InvenTree.permissions.RolePermission',
|
||||
),
|
||||
'DEFAULT_SCHEMA_CLASS': 'rest_framework.schemas.coreapi.AutoSchema',
|
||||
'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema',
|
||||
'DEFAULT_METADATA_CLASS': 'InvenTree.metadata.InvenTreeMetadata',
|
||||
'DEFAULT_RENDERER_CLASSES': [
|
||||
'rest_framework.renderers.JSONRenderer',
|
||||
@@ -367,6 +382,32 @@ if DEBUG:
|
||||
# Enable browsable API if in DEBUG mode
|
||||
REST_FRAMEWORK['DEFAULT_RENDERER_CLASSES'].append('rest_framework.renderers.BrowsableAPIRenderer')
|
||||
|
||||
# dj-rest-auth
|
||||
# JWT switch
|
||||
USE_JWT = get_boolean_setting('INVENTREE_USE_JWT', 'use_jwt', False)
|
||||
REST_USE_JWT = USE_JWT
|
||||
OLD_PASSWORD_FIELD_ENABLED = True
|
||||
REST_AUTH_REGISTER_SERIALIZERS = {'REGISTER_SERIALIZER': 'InvenTree.forms.CustomRegisterSerializer'}
|
||||
|
||||
# JWT settings - rest_framework_simplejwt
|
||||
if USE_JWT:
|
||||
JWT_AUTH_COOKIE = 'inventree-auth'
|
||||
JWT_AUTH_REFRESH_COOKIE = 'inventree-token'
|
||||
REST_FRAMEWORK['DEFAULT_AUTHENTICATION_CLASSES'] + (
|
||||
'dj_rest_auth.jwt_auth.JWTCookieAuthentication',
|
||||
)
|
||||
INSTALLED_APPS.append('rest_framework_simplejwt')
|
||||
|
||||
# WSGI default setting
|
||||
SPECTACULAR_SETTINGS = {
|
||||
'TITLE': 'InvenTree API',
|
||||
'DESCRIPTION': 'API for InvenTree - the intuitive open source inventory management system',
|
||||
'LICENSE': {'MIT': 'https://github.com/inventree/InvenTree/blob/master/LICENSE'},
|
||||
'EXTERNAL_DOCS': {'docs': 'https://docs.inventree.org', 'web': 'https://inventree.org'},
|
||||
'VERSION': inventreeApiVersion(),
|
||||
'SERVE_INCLUDE_SCHEMA': False,
|
||||
}
|
||||
|
||||
WSGI_APPLICATION = 'InvenTree.wsgi.application'
|
||||
|
||||
"""
|
||||
@@ -467,7 +508,7 @@ if "postgres" in db_engine: # pragma: no cover
|
||||
if "connect_timeout" not in db_options:
|
||||
# The DB server is in the same data center, it should not take very
|
||||
# long to connect to the database server
|
||||
# # seconds, 2 is minium allowed by libpq
|
||||
# # seconds, 2 is minimum allowed by libpq
|
||||
db_options["connect_timeout"] = int(
|
||||
get_setting('INVENTREE_DB_TIMEOUT', 'database.timeout', 2)
|
||||
)
|
||||
@@ -560,31 +601,25 @@ DATABASES = {
|
||||
REMOTE_LOGIN = get_boolean_setting('INVENTREE_REMOTE_LOGIN', 'remote_login_enabled', False)
|
||||
REMOTE_LOGIN_HEADER = get_setting('INVENTREE_REMOTE_LOGIN_HEADER', 'remote_login_header', 'REMOTE_USER')
|
||||
|
||||
LOGIN_REDIRECT_URL = "/index/"
|
||||
|
||||
# sentry.io integration for error reporting
|
||||
SENTRY_ENABLED = get_boolean_setting('INVENTREE_SENTRY_ENABLED', 'sentry_enabled', False)
|
||||
# Default Sentry DSN (can be overriden if user wants custom sentry integration)
|
||||
INVENTREE_DSN = 'https://3928ccdba1d34895abde28031fd00100@o378676.ingest.sentry.io/6494600'
|
||||
SENTRY_DSN = get_setting('INVENTREE_SENTRY_DSN', 'sentry_dsn', INVENTREE_DSN)
|
||||
|
||||
# Default Sentry DSN (can be overridden if user wants custom sentry integration)
|
||||
SENTRY_DSN = get_setting('INVENTREE_SENTRY_DSN', 'sentry_dsn', default_sentry_dsn())
|
||||
SENTRY_SAMPLE_RATE = float(get_setting('INVENTREE_SENTRY_SAMPLE_RATE', 'sentry_sample_rate', 0.1))
|
||||
|
||||
if SENTRY_ENABLED and SENTRY_DSN: # pragma: no cover
|
||||
|
||||
logger.info("Running with sentry.io integration enabled")
|
||||
|
||||
sentry_sdk.init(
|
||||
dsn=SENTRY_DSN,
|
||||
integrations=[DjangoIntegration(), ],
|
||||
traces_sample_rate=1.0 if DEBUG else SENTRY_SAMPLE_RATE,
|
||||
send_default_pii=True
|
||||
)
|
||||
inventree_tags = {
|
||||
'testing': TESTING,
|
||||
'docker': DOCKER,
|
||||
'debug': DEBUG,
|
||||
'remote': REMOTE_LOGIN,
|
||||
}
|
||||
for key, val in inventree_tags.items():
|
||||
sentry_sdk.set_tag(f'inventree_{key}', val)
|
||||
|
||||
init_sentry(SENTRY_DSN, SENTRY_SAMPLE_RATE, inventree_tags)
|
||||
|
||||
# Cache configuration
|
||||
cache_host = get_setting('INVENTREE_CACHE_HOST', 'cache.host', None)
|
||||
@@ -593,7 +628,7 @@ cache_port = get_setting('INVENTREE_CACHE_PORT', 'cache.port', '6379', typecast=
|
||||
if cache_host: # pragma: no cover
|
||||
# We are going to rely upon a possibly non-localhost for our cache,
|
||||
# so don't wait too long for the cache as nothing in the cache should be
|
||||
# irreplacable.
|
||||
# irreplaceable.
|
||||
_cache_options = {
|
||||
"CLIENT_CLASS": "django_redis.client.DefaultClient",
|
||||
"SOCKET_CONNECT_TIMEOUT": int(os.getenv("CACHE_CONNECT_TIMEOUT", "2")),
|
||||
@@ -713,6 +748,7 @@ LANGUAGES = [
|
||||
('es', _('Spanish')),
|
||||
('es-mx', _('Spanish (Mexican)')),
|
||||
('fa', _('Farsi / Persian')),
|
||||
('fi', _('Finnish')),
|
||||
('fr', _('French')),
|
||||
('he', _('Hebrew')),
|
||||
('hu', _('Hungarian')),
|
||||
@@ -723,14 +759,14 @@ LANGUAGES = [
|
||||
('no', _('Norwegian')),
|
||||
('pl', _('Polish')),
|
||||
('pt', _('Portuguese')),
|
||||
('pt-BR', _('Portuguese (Brazilian)')),
|
||||
('pt-br', _('Portuguese (Brazilian)')),
|
||||
('ru', _('Russian')),
|
||||
('sl', _('Slovenian')),
|
||||
('sv', _('Swedish')),
|
||||
('th', _('Thai')),
|
||||
('tr', _('Turkish')),
|
||||
('vi', _('Vietnamese')),
|
||||
('zh-hans', _('Chinese')),
|
||||
('zh-hans', _('Chinese (Simplified)')),
|
||||
]
|
||||
|
||||
# Testing interface translations
|
||||
@@ -759,6 +795,11 @@ CURRENCIES = get_setting(
|
||||
typecast=list,
|
||||
)
|
||||
|
||||
# Ensure that at least one currency value is available
|
||||
if len(CURRENCIES) == 0: # pragma: no cover
|
||||
logger.warning("No currencies selected: Defaulting to USD")
|
||||
CURRENCIES = ['USD']
|
||||
|
||||
# Maximum number of decimal places for currency rendering
|
||||
CURRENCY_DECIMAL_PLACES = 6
|
||||
|
||||
@@ -783,6 +824,10 @@ EMAIL_USE_SSL = get_boolean_setting('INVENTREE_EMAIL_SSL', 'email.ssl', False)
|
||||
|
||||
DEFAULT_FROM_EMAIL = get_setting('INVENTREE_EMAIL_SENDER', 'email.sender', '')
|
||||
|
||||
# If "from" email not specified, default to the username
|
||||
if not DEFAULT_FROM_EMAIL:
|
||||
DEFAULT_FROM_EMAIL = get_setting('INVENTREE_EMAIL_USERNAME', 'email.username', '')
|
||||
|
||||
EMAIL_USE_LOCALTIME = False
|
||||
EMAIL_TIMEOUT = 60
|
||||
|
||||
@@ -832,7 +877,7 @@ ACCOUNT_PREVENT_ENUMERATION = True
|
||||
|
||||
# override forms / adapters
|
||||
ACCOUNT_FORMS = {
|
||||
'login': 'allauth.account.forms.LoginForm',
|
||||
'login': 'InvenTree.forms.CustomLoginForm',
|
||||
'signup': 'InvenTree.forms.CustomSignupForm',
|
||||
'add_email': 'allauth.account.forms.AddEmailForm',
|
||||
'change_password': 'allauth.account.forms.ChangePasswordForm',
|
||||
@@ -899,12 +944,22 @@ PLUGINS_ENABLED = get_boolean_setting('INVENTREE_PLUGINS_ENABLED', 'plugins_enab
|
||||
PLUGIN_FILE = config.get_plugin_file()
|
||||
|
||||
# Plugin test settings
|
||||
PLUGIN_TESTING = get_setting('INVENTREE_PLUGIN_TESTING', 'PLUGIN_TESTING', TESTING) # Are plugins beeing tested?
|
||||
PLUGIN_TESTING = get_setting('INVENTREE_PLUGIN_TESTING', 'PLUGIN_TESTING', TESTING) # Are plugins being tested?
|
||||
PLUGIN_TESTING_SETUP = get_setting('INVENTREE_PLUGIN_TESTING_SETUP', 'PLUGIN_TESTING_SETUP', False) # Load plugins from setup hooks in testing?
|
||||
PLUGIN_TESTING_EVENTS = False # Flag if events are tested right now
|
||||
PLUGIN_RETRY = get_setting('INVENTREE_PLUGIN_RETRY', 'PLUGIN_RETRY', 5) # How often should plugin loading be tried?
|
||||
PLUGIN_FILE_CHECKED = False # Was the plugin file checked?
|
||||
|
||||
# Site URL can be specified statically, or via a run-time setting
|
||||
SITE_URL = get_setting('INVENTREE_SITE_URL', 'site_url', None)
|
||||
|
||||
if SITE_URL:
|
||||
logger.info(f"Site URL: {SITE_URL}")
|
||||
|
||||
# Check that the site URL is valid
|
||||
validator = URLValidator()
|
||||
validator(SITE_URL)
|
||||
|
||||
# User interface customization values
|
||||
CUSTOM_LOGO = get_custom_file('INVENTREE_CUSTOM_LOGO', 'customize.logo', 'custom logo', lookup_media=True)
|
||||
CUSTOM_SPLASH = get_custom_file('INVENTREE_CUSTOM_SPLASH', 'customize.splash', 'custom splash')
|
||||
@@ -915,3 +970,23 @@ if DEBUG:
|
||||
|
||||
logger.info(f"MEDIA_ROOT: '{MEDIA_ROOT}'")
|
||||
logger.info(f"STATIC_ROOT: '{STATIC_ROOT}'")
|
||||
|
||||
# Flags
|
||||
FLAGS = {
|
||||
'EXPERIMENTAL': [
|
||||
{'condition': 'boolean', 'value': DEBUG},
|
||||
{'condition': 'parameter', 'value': 'experimental='},
|
||||
], # Should experimental features be turned on?
|
||||
'NEXT_GEN': [
|
||||
{'condition': 'parameter', 'value': 'ngen='},
|
||||
], # Should next-gen features be turned on?
|
||||
}
|
||||
|
||||
# Get custom flags from environment/yaml
|
||||
CUSTOM_FLAGS = get_setting('INVENTREE_FLAGS', 'flags', None, typecast=dict)
|
||||
if CUSTOM_FLAGS:
|
||||
if not isinstance(CUSTOM_FLAGS, dict):
|
||||
logger.error(f"Invalid custom flags, must be valid dict: {CUSTOM_FLAGS}")
|
||||
else:
|
||||
logger.info(f"Custom flags: {CUSTOM_FLAGS}")
|
||||
FLAGS.update(CUSTOM_FLAGS)
|
||||
|
||||
127
InvenTree/InvenTree/social_auth_urls.py
Normal file
127
InvenTree/InvenTree/social_auth_urls.py
Normal file
@@ -0,0 +1,127 @@
|
||||
"""API endpoints for social authentication with allauth."""
|
||||
import logging
|
||||
from importlib import import_module
|
||||
|
||||
from django.urls import include, path, reverse
|
||||
|
||||
from allauth.socialaccount import providers
|
||||
from allauth.socialaccount.models import SocialApp
|
||||
from allauth.socialaccount.providers.keycloak.views import \
|
||||
KeycloakOAuth2Adapter
|
||||
from allauth.socialaccount.providers.oauth2.views import (OAuth2Adapter,
|
||||
OAuth2LoginView)
|
||||
from rest_framework.generics import ListAPIView
|
||||
from rest_framework.permissions import AllowAny
|
||||
from rest_framework.response import Response
|
||||
|
||||
from common.models import InvenTreeSetting
|
||||
|
||||
logger = logging.getLogger('inventree')
|
||||
|
||||
|
||||
class GenericOAuth2ApiLoginView(OAuth2LoginView):
|
||||
"""Api view to login a user with a social account"""
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
"""Dispatch the regular login view directly."""
|
||||
return self.login(request, *args, **kwargs)
|
||||
|
||||
|
||||
class GenericOAuth2ApiConnectView(GenericOAuth2ApiLoginView):
|
||||
"""Api view to connect a social account to the current user"""
|
||||
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
"""Dispatch the connect request directly."""
|
||||
|
||||
# Override the request method be in connection mode
|
||||
request.GET = request.GET.copy()
|
||||
request.GET['process'] = 'connect'
|
||||
|
||||
# Resume the dispatch
|
||||
return super().dispatch(request, *args, **kwargs)
|
||||
|
||||
|
||||
def handle_oauth2(adapter: OAuth2Adapter):
|
||||
"""Define urls for oauth2 endpoints."""
|
||||
return [
|
||||
path('login/', GenericOAuth2ApiLoginView.adapter_view(adapter), name=f'{provider.id}_api_login'),
|
||||
path('connect/', GenericOAuth2ApiConnectView.adapter_view(adapter), name=f'{provider.id}_api_connect'),
|
||||
]
|
||||
|
||||
|
||||
def handle_keycloak():
|
||||
"""Define urls for keycloak."""
|
||||
return [
|
||||
path('login/', GenericOAuth2ApiLoginView.adapter_view(KeycloakOAuth2Adapter), name='keycloak_api_login'),
|
||||
path('connect/', GenericOAuth2ApiConnectView.adapter_view(KeycloakOAuth2Adapter), name='keycloak_api_connet'),
|
||||
]
|
||||
|
||||
|
||||
legacy = {
|
||||
'twitter': 'twitter_oauth2',
|
||||
'bitbucket': 'bitbucket_oauth2',
|
||||
'linkedin': 'linkedin_oauth2',
|
||||
'vimeo': 'vimeo_oauth2',
|
||||
'openid': 'openid_connect',
|
||||
} # legacy connectors
|
||||
|
||||
|
||||
# Collect urls for all loaded providers
|
||||
social_auth_urlpatterns = []
|
||||
|
||||
provider_urlpatterns = []
|
||||
for provider in providers.registry.get_list():
|
||||
try:
|
||||
prov_mod = import_module(provider.get_package() + ".views")
|
||||
except ImportError:
|
||||
continue
|
||||
|
||||
# Try to extract the adapter class
|
||||
adapters = [cls for cls in prov_mod.__dict__.values() if isinstance(cls, type) and not cls == OAuth2Adapter and issubclass(cls, OAuth2Adapter)]
|
||||
|
||||
# Get urls
|
||||
urls = []
|
||||
if len(adapters) == 1:
|
||||
urls = handle_oauth2(adapter=adapters[0])
|
||||
else:
|
||||
if provider.id in legacy:
|
||||
logger.warning(f'`{provider.id}` is not supported on platform UI. Use `{legacy[provider.id]}` instead.')
|
||||
continue
|
||||
elif provider.id == 'keycloak':
|
||||
urls = handle_keycloak()
|
||||
else:
|
||||
logger.error(f'Found handler that is not yet ready for platform UI: `{provider.id}`. Open an feature request on GitHub if you need it implemented.')
|
||||
continue
|
||||
provider_urlpatterns += [path(f'{provider.id}/', include(urls))]
|
||||
|
||||
|
||||
social_auth_urlpatterns += provider_urlpatterns
|
||||
|
||||
|
||||
class SocialProvierListView(ListAPIView):
|
||||
"""List of available social providers."""
|
||||
permission_classes = (AllowAny,)
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
"""Get the list of providers."""
|
||||
provider_list = []
|
||||
for provider in providers.registry.get_list():
|
||||
provider_data = {
|
||||
'id': provider.id,
|
||||
'name': provider.name,
|
||||
'login': request.build_absolute_uri(reverse(f'{provider.id}_api_login')),
|
||||
'connect': request.build_absolute_uri(reverse(f'{provider.id}_api_connect')),
|
||||
}
|
||||
try:
|
||||
provider_data['display_name'] = provider.get_app(request).name
|
||||
except SocialApp.DoesNotExist:
|
||||
provider_data['display_name'] = provider.name
|
||||
|
||||
provider_list.append(provider_data)
|
||||
|
||||
data = {
|
||||
'sso_enabled': InvenTreeSetting.get_setting('LOGIN_ENABLE_SSO'),
|
||||
'sso_registration': InvenTreeSetting.get_setting('LOGIN_ENABLE_SSO_REG'),
|
||||
'mfa_required': InvenTreeSetting.get_setting('LOGIN_ENFORCE_MFA'),
|
||||
'providers': provider_list
|
||||
}
|
||||
return Response(data)
|
||||
@@ -105,6 +105,10 @@ main {
|
||||
font-size: 110%;
|
||||
}
|
||||
|
||||
.bg-qr-code {
|
||||
background-color: #FFF !important;
|
||||
}
|
||||
|
||||
.qr-code {
|
||||
max-width: 400px;
|
||||
max-height: 400px;
|
||||
@@ -219,8 +223,7 @@ main {
|
||||
}
|
||||
|
||||
.sub-table {
|
||||
margin-left: 45px;
|
||||
margin-right: 45px;
|
||||
margin-left: 60px;
|
||||
}
|
||||
|
||||
.detail-icon .glyphicon {
|
||||
@@ -266,10 +269,6 @@ main {
|
||||
}
|
||||
|
||||
/* Styles for table buttons and filtering */
|
||||
.button-toolbar .btn {
|
||||
margin-left: 1px;
|
||||
margin-right: 1px;
|
||||
}
|
||||
|
||||
.filter-list {
|
||||
display: inline-block;
|
||||
@@ -301,16 +300,14 @@ main {
|
||||
.filter-tag {
|
||||
display: inline-block;
|
||||
*display: inline;
|
||||
zoom: 1;
|
||||
padding-top: 3px;
|
||||
padding-left: 3px;
|
||||
padding-right: 3px;
|
||||
border: 1px solid #aaa;
|
||||
border-radius: 3px;
|
||||
background: #eee;
|
||||
margin: 1px;
|
||||
margin-left: 5px;
|
||||
margin-right: 5px;
|
||||
margin: 5px;
|
||||
padding: 5px;
|
||||
padding-top: 1px;
|
||||
padding-bottom: 1px;
|
||||
color: var(--bs-body-color);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 10px;
|
||||
background: var(--secondary-color);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@@ -321,7 +318,6 @@ main {
|
||||
.filter-input {
|
||||
display: inline-block;
|
||||
*display: inline;
|
||||
zoom: 1;
|
||||
}
|
||||
|
||||
.filter-tag:hover {
|
||||
@@ -1094,4 +1090,10 @@ a {
|
||||
.sso-provider-link a {
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
|
||||
.flex-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
@@ -4,13 +4,13 @@
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
|
||||
from django.conf import settings
|
||||
from django.utils import timezone
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from django_q.models import Success
|
||||
from django_q.monitor import Stat
|
||||
|
||||
import InvenTree.email
|
||||
import InvenTree.ready
|
||||
|
||||
logger = logging.getLogger("inventree")
|
||||
@@ -38,38 +38,14 @@ def is_worker_running(**kwargs):
|
||||
)
|
||||
|
||||
# If any results are returned, then the background worker is running!
|
||||
return results.exists()
|
||||
try:
|
||||
result = results.exists()
|
||||
except Exception:
|
||||
# We may throw an exception if the database is not ready,
|
||||
# or if the django_q table is not yet created (i.e. in CI testing)
|
||||
result = False
|
||||
|
||||
|
||||
def is_email_configured():
|
||||
"""Check if email backend is configured.
|
||||
|
||||
NOTE: This does not check if the configuration is valid!
|
||||
"""
|
||||
configured = True
|
||||
|
||||
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 settings.TESTING: # pragma: no cover
|
||||
logger.debug("EMAIL_HOST is not configured")
|
||||
|
||||
# Display warning unless in test mode
|
||||
if not settings.TESTING: # pragma: no cover
|
||||
logger.debug("EMAIL_HOST_USER is not configured")
|
||||
|
||||
# Display warning unless in test mode
|
||||
if not settings.TESTING: # pragma: no cover
|
||||
logger.debug("EMAIL_HOST_PASSWORD is not configured")
|
||||
|
||||
return configured
|
||||
return result
|
||||
|
||||
|
||||
def check_system_health(**kwargs):
|
||||
@@ -91,7 +67,7 @@ def check_system_health(**kwargs):
|
||||
result = False
|
||||
logger.warning(_("Background worker check failed"))
|
||||
|
||||
if not is_email_configured(): # pragma: no cover
|
||||
if not InvenTree.email.is_email_configured(): # pragma: no cover
|
||||
result = False
|
||||
logger.warning(_("Email backend not configured"))
|
||||
|
||||
|
||||
@@ -2,374 +2,161 @@
|
||||
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
|
||||
class StatusCode:
|
||||
"""Base class for representing a set of StatusCodes.
|
||||
|
||||
This is used to map a set of integer values to text.
|
||||
"""
|
||||
|
||||
colors = {}
|
||||
|
||||
@classmethod
|
||||
def render(cls, key, large=False):
|
||||
"""Render the value as a HTML label."""
|
||||
# If the key cannot be found, pass it back
|
||||
if key not in cls.options.keys():
|
||||
return key
|
||||
|
||||
value = cls.options.get(key, key)
|
||||
color = cls.colors.get(key, 'secondary')
|
||||
|
||||
span_class = f'badge rounded-pill bg-{color}'
|
||||
|
||||
return "<span class='{cl}'>{value}</span>".format(
|
||||
cl=span_class,
|
||||
value=value
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def list(cls):
|
||||
"""Return the StatusCode options as a list of mapped key / value items."""
|
||||
return list(cls.dict().values())
|
||||
|
||||
@classmethod
|
||||
def text(cls, key):
|
||||
"""Text for supplied status code."""
|
||||
return cls.options.get(key, None)
|
||||
|
||||
@classmethod
|
||||
def items(cls):
|
||||
"""All status code items."""
|
||||
return cls.options.items()
|
||||
|
||||
@classmethod
|
||||
def keys(cls):
|
||||
"""All status code keys."""
|
||||
return cls.options.keys()
|
||||
|
||||
@classmethod
|
||||
def labels(cls):
|
||||
"""All status code labels."""
|
||||
return cls.options.values()
|
||||
|
||||
@classmethod
|
||||
def names(cls):
|
||||
"""Return a map of all 'names' of status codes in this class
|
||||
|
||||
Will return a dict object, with the attribute name indexed to the integer value.
|
||||
|
||||
e.g.
|
||||
{
|
||||
'PENDING': 10,
|
||||
'IN_PROGRESS': 20,
|
||||
}
|
||||
"""
|
||||
keys = cls.keys()
|
||||
status_names = {}
|
||||
|
||||
for d in dir(cls):
|
||||
if d.startswith('_'):
|
||||
continue
|
||||
if d != d.upper():
|
||||
continue
|
||||
|
||||
value = getattr(cls, d, None)
|
||||
|
||||
if value is None:
|
||||
continue
|
||||
if callable(value):
|
||||
continue
|
||||
if type(value) != int:
|
||||
continue
|
||||
if value not in keys:
|
||||
continue
|
||||
|
||||
status_names[d] = value
|
||||
|
||||
return status_names
|
||||
|
||||
@classmethod
|
||||
def dict(cls):
|
||||
"""Return a dict representation containing all required information"""
|
||||
values = {}
|
||||
|
||||
for name, value, in cls.names().items():
|
||||
entry = {
|
||||
'key': value,
|
||||
'name': name,
|
||||
'label': cls.label(value),
|
||||
}
|
||||
|
||||
if hasattr(cls, 'colors'):
|
||||
if color := cls.colors.get(value, None):
|
||||
entry['color'] = color
|
||||
|
||||
values[name] = entry
|
||||
|
||||
return values
|
||||
|
||||
@classmethod
|
||||
def label(cls, value):
|
||||
"""Return the status code label associated with the provided value."""
|
||||
return cls.options.get(value, value)
|
||||
|
||||
@classmethod
|
||||
def value(cls, label):
|
||||
"""Return the value associated with the provided label."""
|
||||
for k in cls.options.keys():
|
||||
if cls.options[k].lower() == label.lower():
|
||||
return k
|
||||
|
||||
raise ValueError("Label not found")
|
||||
from generic.states import StatusCode
|
||||
|
||||
|
||||
class PurchaseOrderStatus(StatusCode):
|
||||
"""Defines a set of status codes for a PurchaseOrder."""
|
||||
|
||||
# Order status codes
|
||||
PENDING = 10 # Order is pending (not yet placed)
|
||||
PLACED = 20 # Order has been placed with supplier
|
||||
COMPLETE = 30 # Order has been completed
|
||||
CANCELLED = 40 # Order was cancelled
|
||||
LOST = 50 # Order was lost
|
||||
RETURNED = 60 # Order was returned
|
||||
PENDING = 10, _("Pending"), 'secondary' # Order is pending (not yet placed)
|
||||
PLACED = 20, _("Placed"), 'primary' # Order has been placed with supplier
|
||||
COMPLETE = 30, _("Complete"), 'success' # Order has been completed
|
||||
CANCELLED = 40, _("Cancelled"), 'danger' # Order was cancelled
|
||||
LOST = 50, _("Lost"), 'warning' # Order was lost
|
||||
RETURNED = 60, _("Returned"), 'warning' # Order was returned
|
||||
|
||||
options = {
|
||||
PENDING: _("Pending"),
|
||||
PLACED: _("Placed"),
|
||||
COMPLETE: _("Complete"),
|
||||
CANCELLED: _("Cancelled"),
|
||||
LOST: _("Lost"),
|
||||
RETURNED: _("Returned"),
|
||||
}
|
||||
|
||||
colors = {
|
||||
PENDING: 'secondary',
|
||||
PLACED: 'primary',
|
||||
COMPLETE: 'success',
|
||||
CANCELLED: 'danger',
|
||||
LOST: 'warning',
|
||||
RETURNED: 'warning',
|
||||
}
|
||||
class PurchaseOrderStatusGroups:
|
||||
"""Groups for PurchaseOrderStatus codes."""
|
||||
|
||||
# Open orders
|
||||
OPEN = [
|
||||
PENDING,
|
||||
PLACED,
|
||||
PurchaseOrderStatus.PENDING.value,
|
||||
PurchaseOrderStatus.PLACED.value,
|
||||
]
|
||||
|
||||
# Failed orders
|
||||
FAILED = [
|
||||
CANCELLED,
|
||||
LOST,
|
||||
RETURNED
|
||||
PurchaseOrderStatus.CANCELLED.value,
|
||||
PurchaseOrderStatus.LOST.value,
|
||||
PurchaseOrderStatus.RETURNED.value
|
||||
]
|
||||
|
||||
|
||||
class SalesOrderStatus(StatusCode):
|
||||
"""Defines a set of status codes for a SalesOrder."""
|
||||
|
||||
PENDING = 10 # Order is pending
|
||||
IN_PROGRESS = 15 # Order has been issued, and is in progress
|
||||
SHIPPED = 20 # Order has been shipped to customer
|
||||
CANCELLED = 40 # Order has been cancelled
|
||||
LOST = 50 # Order was lost
|
||||
RETURNED = 60 # Order was returned
|
||||
PENDING = 10, _("Pending"), 'secondary' # Order is pending
|
||||
IN_PROGRESS = 15, _("In Progress"), 'primary' # Order has been issued, and is in progress
|
||||
SHIPPED = 20, _("Shipped"), 'success' # Order has been shipped to customer
|
||||
CANCELLED = 40, _("Cancelled"), 'danger' # Order has been cancelled
|
||||
LOST = 50, _("Lost"), 'warning' # Order was lost
|
||||
RETURNED = 60, _("Returned"), 'warning' # Order was returned
|
||||
|
||||
options = {
|
||||
PENDING: _("Pending"),
|
||||
IN_PROGRESS: _("In Progress"),
|
||||
SHIPPED: _("Shipped"),
|
||||
CANCELLED: _("Cancelled"),
|
||||
LOST: _("Lost"),
|
||||
RETURNED: _("Returned"),
|
||||
}
|
||||
|
||||
colors = {
|
||||
PENDING: 'secondary',
|
||||
IN_PROGRESS: 'primary',
|
||||
SHIPPED: 'success',
|
||||
CANCELLED: 'danger',
|
||||
LOST: 'warning',
|
||||
RETURNED: 'warning',
|
||||
}
|
||||
class SalesOrderStatusGroups:
|
||||
"""Groups for SalesOrderStatus codes."""
|
||||
|
||||
# Open orders
|
||||
OPEN = [
|
||||
PENDING,
|
||||
IN_PROGRESS,
|
||||
SalesOrderStatus.PENDING.value,
|
||||
SalesOrderStatus.IN_PROGRESS.value,
|
||||
]
|
||||
|
||||
# Completed orders
|
||||
COMPLETE = [
|
||||
SHIPPED,
|
||||
SalesOrderStatus.SHIPPED.value,
|
||||
]
|
||||
|
||||
|
||||
class StockStatus(StatusCode):
|
||||
"""Status codes for Stock."""
|
||||
|
||||
OK = 10 # Item is OK
|
||||
ATTENTION = 50 # Item requires attention
|
||||
DAMAGED = 55 # Item is damaged
|
||||
DESTROYED = 60 # Item is destroyed
|
||||
REJECTED = 65 # Item is rejected
|
||||
LOST = 70 # Item has been lost
|
||||
QUARANTINED = 75 # Item has been quarantined and is unavailable
|
||||
RETURNED = 85 # Item has been returned from a customer
|
||||
OK = 10, _("OK"), 'success' # Item is OK
|
||||
ATTENTION = 50, _("Attention needed"), 'warning' # Item requires attention
|
||||
DAMAGED = 55, _("Damaged"), 'warning' # Item is damaged
|
||||
DESTROYED = 60, _("Destroyed"), 'danger' # Item is destroyed
|
||||
REJECTED = 65, _("Rejected"), 'danger' # Item is rejected
|
||||
LOST = 70, _("Lost"), 'dark' # Item has been lost
|
||||
QUARANTINED = 75, _("Quarantined"), 'info' # Item has been quarantined and is unavailable
|
||||
RETURNED = 85, _("Returned"), 'warning' # Item has been returned from a customer
|
||||
|
||||
options = {
|
||||
OK: _("OK"),
|
||||
ATTENTION: _("Attention needed"),
|
||||
DAMAGED: _("Damaged"),
|
||||
DESTROYED: _("Destroyed"),
|
||||
LOST: _("Lost"),
|
||||
REJECTED: _("Rejected"),
|
||||
QUARANTINED: _("Quarantined"),
|
||||
RETURNED: _("Returned"),
|
||||
}
|
||||
|
||||
colors = {
|
||||
OK: 'success',
|
||||
ATTENTION: 'warning',
|
||||
DAMAGED: 'danger',
|
||||
DESTROYED: 'danger',
|
||||
LOST: 'dark',
|
||||
REJECTED: 'danger',
|
||||
QUARANTINED: 'info'
|
||||
}
|
||||
class StockStatusGroups:
|
||||
"""Groups for StockStatus codes."""
|
||||
|
||||
# The following codes correspond to parts that are 'available' or 'in stock'
|
||||
AVAILABLE_CODES = [
|
||||
OK,
|
||||
ATTENTION,
|
||||
DAMAGED,
|
||||
RETURNED,
|
||||
StockStatus.OK.value,
|
||||
StockStatus.ATTENTION.value,
|
||||
StockStatus.DAMAGED.value,
|
||||
StockStatus.RETURNED.value,
|
||||
]
|
||||
|
||||
|
||||
class StockHistoryCode(StatusCode):
|
||||
"""Status codes for StockHistory."""
|
||||
|
||||
LEGACY = 0
|
||||
LEGACY = 0, _('Legacy stock tracking entry')
|
||||
|
||||
CREATED = 1
|
||||
CREATED = 1, _('Stock item created')
|
||||
|
||||
# Manual editing operations
|
||||
EDITED = 5
|
||||
ASSIGNED_SERIAL = 6
|
||||
EDITED = 5, _('Edited stock item')
|
||||
ASSIGNED_SERIAL = 6, _('Assigned serial number')
|
||||
|
||||
# Manual stock operations
|
||||
STOCK_COUNT = 10
|
||||
STOCK_ADD = 11
|
||||
STOCK_REMOVE = 12
|
||||
STOCK_COUNT = 10, _('Stock counted')
|
||||
STOCK_ADD = 11, _('Stock manually added')
|
||||
STOCK_REMOVE = 12, _('Stock manually removed')
|
||||
|
||||
# Location operations
|
||||
STOCK_MOVE = 20
|
||||
STOCK_MOVE = 20, _('Location changed')
|
||||
STOCK_UPDATE = 25, _('Stock updated')
|
||||
|
||||
# Installation operations
|
||||
INSTALLED_INTO_ASSEMBLY = 30
|
||||
REMOVED_FROM_ASSEMBLY = 31
|
||||
INSTALLED_INTO_ASSEMBLY = 30, _('Installed into assembly')
|
||||
REMOVED_FROM_ASSEMBLY = 31, _('Removed from assembly')
|
||||
|
||||
INSTALLED_CHILD_ITEM = 35
|
||||
REMOVED_CHILD_ITEM = 36
|
||||
INSTALLED_CHILD_ITEM = 35, _('Installed component item')
|
||||
REMOVED_CHILD_ITEM = 36, _('Removed component item')
|
||||
|
||||
# Stock splitting operations
|
||||
SPLIT_FROM_PARENT = 40
|
||||
SPLIT_CHILD_ITEM = 42
|
||||
SPLIT_FROM_PARENT = 40, _('Split from parent item')
|
||||
SPLIT_CHILD_ITEM = 42, _('Split child item')
|
||||
|
||||
# Stock merging operations
|
||||
MERGED_STOCK_ITEMS = 45
|
||||
MERGED_STOCK_ITEMS = 45, _('Merged stock items')
|
||||
|
||||
# Convert stock item to variant
|
||||
CONVERTED_TO_VARIANT = 48
|
||||
CONVERTED_TO_VARIANT = 48, _('Converted to variant')
|
||||
|
||||
# Build order codes
|
||||
BUILD_OUTPUT_CREATED = 50
|
||||
BUILD_OUTPUT_COMPLETED = 55
|
||||
BUILD_CONSUMED = 57
|
||||
BUILD_OUTPUT_CREATED = 50, _('Build order output created')
|
||||
BUILD_OUTPUT_COMPLETED = 55, _('Build order output completed')
|
||||
BUILD_OUTPUT_REJECTED = 56, _('Build order output rejected')
|
||||
BUILD_CONSUMED = 57, _('Consumed by build order')
|
||||
|
||||
# Sales order codes
|
||||
SHIPPED_AGAINST_SALES_ORDER = 60
|
||||
SHIPPED_AGAINST_SALES_ORDER = 60, _("Shipped against Sales Order")
|
||||
|
||||
# Purchase order codes
|
||||
RECEIVED_AGAINST_PURCHASE_ORDER = 70
|
||||
RECEIVED_AGAINST_PURCHASE_ORDER = 70, _('Received against Purchase Order')
|
||||
|
||||
# Return order codes
|
||||
RETURNED_AGAINST_RETURN_ORDER = 80
|
||||
RETURNED_AGAINST_RETURN_ORDER = 80, _('Returned against Return Order')
|
||||
|
||||
# Customer actions
|
||||
SENT_TO_CUSTOMER = 100
|
||||
RETURNED_FROM_CUSTOMER = 105
|
||||
|
||||
options = {
|
||||
LEGACY: _('Legacy stock tracking entry'),
|
||||
|
||||
CREATED: _('Stock item created'),
|
||||
|
||||
EDITED: _('Edited stock item'),
|
||||
ASSIGNED_SERIAL: _('Assigned serial number'),
|
||||
|
||||
STOCK_COUNT: _('Stock counted'),
|
||||
STOCK_ADD: _('Stock manually added'),
|
||||
STOCK_REMOVE: _('Stock manually removed'),
|
||||
|
||||
STOCK_MOVE: _('Location changed'),
|
||||
|
||||
INSTALLED_INTO_ASSEMBLY: _('Installed into assembly'),
|
||||
REMOVED_FROM_ASSEMBLY: _('Removed from assembly'),
|
||||
|
||||
INSTALLED_CHILD_ITEM: _('Installed component item'),
|
||||
REMOVED_CHILD_ITEM: _('Removed component item'),
|
||||
|
||||
SPLIT_FROM_PARENT: _('Split from parent item'),
|
||||
SPLIT_CHILD_ITEM: _('Split child item'),
|
||||
|
||||
MERGED_STOCK_ITEMS: _('Merged stock items'),
|
||||
|
||||
CONVERTED_TO_VARIANT: _('Converted to variant'),
|
||||
|
||||
SENT_TO_CUSTOMER: _('Sent to customer'),
|
||||
RETURNED_FROM_CUSTOMER: _('Returned from customer'),
|
||||
|
||||
BUILD_OUTPUT_CREATED: _('Build order output created'),
|
||||
BUILD_OUTPUT_COMPLETED: _('Build order output completed'),
|
||||
BUILD_CONSUMED: _('Consumed by build order'),
|
||||
|
||||
SHIPPED_AGAINST_SALES_ORDER: _("Shipped against Sales Order"),
|
||||
|
||||
RECEIVED_AGAINST_PURCHASE_ORDER: _('Received against Purchase Order'),
|
||||
|
||||
RETURNED_AGAINST_RETURN_ORDER: _('Returned against Return Order'),
|
||||
}
|
||||
SENT_TO_CUSTOMER = 100, _('Sent to customer')
|
||||
RETURNED_FROM_CUSTOMER = 105, _('Returned from customer')
|
||||
|
||||
|
||||
class BuildStatus(StatusCode):
|
||||
"""Build status codes."""
|
||||
|
||||
PENDING = 10 # Build is pending / active
|
||||
PRODUCTION = 20 # BuildOrder is in production
|
||||
CANCELLED = 30 # Build was cancelled
|
||||
COMPLETE = 40 # Build is complete
|
||||
PENDING = 10, _("Pending"), 'secondary' # Build is pending / active
|
||||
PRODUCTION = 20, _("Production"), 'primary' # BuildOrder is in production
|
||||
CANCELLED = 30, _("Cancelled"), 'danger' # Build was cancelled
|
||||
COMPLETE = 40, _("Complete"), 'success' # Build is complete
|
||||
|
||||
options = {
|
||||
PENDING: _("Pending"),
|
||||
PRODUCTION: _("Production"),
|
||||
CANCELLED: _("Cancelled"),
|
||||
COMPLETE: _("Complete"),
|
||||
}
|
||||
|
||||
colors = {
|
||||
PENDING: 'secondary',
|
||||
PRODUCTION: 'primary',
|
||||
COMPLETE: 'success',
|
||||
CANCELLED: 'danger',
|
||||
}
|
||||
class BuildStatusGroups:
|
||||
"""Groups for BuildStatus codes."""
|
||||
|
||||
ACTIVE_CODES = [
|
||||
PENDING,
|
||||
PRODUCTION,
|
||||
BuildStatus.PENDING.value,
|
||||
BuildStatus.PRODUCTION.value,
|
||||
]
|
||||
|
||||
|
||||
@@ -377,68 +164,40 @@ class ReturnOrderStatus(StatusCode):
|
||||
"""Defines a set of status codes for a ReturnOrder"""
|
||||
|
||||
# Order is pending, waiting for receipt of items
|
||||
PENDING = 10
|
||||
PENDING = 10, _("Pending"), 'secondary'
|
||||
|
||||
# Items have been received, and are being inspected
|
||||
IN_PROGRESS = 20
|
||||
IN_PROGRESS = 20, _("In Progress"), 'primary'
|
||||
|
||||
COMPLETE = 30
|
||||
CANCELLED = 40
|
||||
COMPLETE = 30, _("Complete"), 'success'
|
||||
CANCELLED = 40, _("Cancelled"), 'danger'
|
||||
|
||||
|
||||
class ReturnOrderStatusGroups:
|
||||
"""Groups for ReturnOrderStatus codes."""
|
||||
|
||||
OPEN = [
|
||||
PENDING,
|
||||
IN_PROGRESS,
|
||||
ReturnOrderStatus.PENDING.value,
|
||||
ReturnOrderStatus.IN_PROGRESS.value,
|
||||
]
|
||||
|
||||
options = {
|
||||
PENDING: _("Pending"),
|
||||
IN_PROGRESS: _("In Progress"),
|
||||
COMPLETE: _("Complete"),
|
||||
CANCELLED: _("Cancelled"),
|
||||
}
|
||||
|
||||
colors = {
|
||||
PENDING: 'secondary',
|
||||
IN_PROGRESS: 'primary',
|
||||
COMPLETE: 'success',
|
||||
CANCELLED: 'danger',
|
||||
}
|
||||
|
||||
|
||||
class ReturnOrderLineStatus(StatusCode):
|
||||
"""Defines a set of status codes for a ReturnOrderLineItem"""
|
||||
|
||||
PENDING = 10
|
||||
PENDING = 10, _("Pending"), 'secondary'
|
||||
|
||||
# Item is to be returned to customer, no other action
|
||||
RETURN = 20
|
||||
RETURN = 20, _("Return"), 'success'
|
||||
|
||||
# Item is to be repaired, and returned to customer
|
||||
REPAIR = 30
|
||||
REPAIR = 30, _("Repair"), 'primary'
|
||||
|
||||
# Item is to be replaced (new item shipped)
|
||||
REPLACE = 40
|
||||
REPLACE = 40, _("Replace"), 'warning'
|
||||
|
||||
# Item is to be refunded (cannot be repaired)
|
||||
REFUND = 50
|
||||
REFUND = 50, _("Refund"), 'info'
|
||||
|
||||
# Item is rejected
|
||||
REJECT = 60
|
||||
|
||||
options = {
|
||||
PENDING: _('Pending'),
|
||||
RETURN: _('Return'),
|
||||
REPAIR: _('Repair'),
|
||||
REFUND: _('Refund'),
|
||||
REPLACE: _('Replace'),
|
||||
REJECT: _('Reject')
|
||||
}
|
||||
|
||||
colors = {
|
||||
PENDING: 'secondary',
|
||||
RETURN: 'success',
|
||||
REPAIR: 'primary',
|
||||
REFUND: 'info',
|
||||
REPLACE: 'warning',
|
||||
REJECT: 'danger',
|
||||
}
|
||||
REJECT = 60, _("Reject"), 'danger'
|
||||
|
||||
@@ -12,7 +12,6 @@ from datetime import datetime, timedelta
|
||||
from typing import Callable, List
|
||||
|
||||
from django.conf import settings
|
||||
from django.core import mail as django_mail
|
||||
from django.core.exceptions import AppRegistryNotReady
|
||||
from django.core.management import call_command
|
||||
from django.db import DEFAULT_DB_ALIAS, connections
|
||||
@@ -71,7 +70,7 @@ def raise_warning(msg):
|
||||
|
||||
# If testing is running raise a warning that can be asserted
|
||||
if settings.TESTING:
|
||||
warnings.warn(msg)
|
||||
warnings.warn(msg, stacklevel=2)
|
||||
|
||||
|
||||
def check_daily_holdoff(task_name: str, n_days: int = 1) -> bool:
|
||||
@@ -92,13 +91,15 @@ def check_daily_holdoff(task_name: str, n_days: int = 1) -> bool:
|
||||
"""
|
||||
|
||||
from common.models import InvenTreeSetting
|
||||
from InvenTree.ready import isInTestMode
|
||||
|
||||
if n_days <= 0:
|
||||
logger.info(f"Specified interval for task '{task_name}' < 1 - task will not run")
|
||||
return False
|
||||
|
||||
# Sleep a random number of seconds to prevent worker conflict
|
||||
time.sleep(random.randint(1, 5))
|
||||
if not isInTestMode():
|
||||
time.sleep(random.randint(1, 5))
|
||||
|
||||
attempt_key = f'_{task_name}_ATTEMPT'
|
||||
success_key = f'_{task_name}_SUCCESS'
|
||||
@@ -167,6 +168,7 @@ def offload_task(taskname, *args, force_async=False, force_sync=False, **kwargs)
|
||||
If workers are not running or force_sync flag
|
||||
is set then the task is ran synchronously.
|
||||
"""
|
||||
|
||||
try:
|
||||
import importlib
|
||||
|
||||
@@ -186,6 +188,8 @@ def offload_task(taskname, *args, force_async=False, force_sync=False, **kwargs)
|
||||
task.run()
|
||||
except ImportError:
|
||||
raise_warning(f"WARNING: '{taskname}' not started - Function not found")
|
||||
except Exception as exc:
|
||||
raise_warning(f"WARNING: '{taskname}' not started due to {type(exc)}")
|
||||
else:
|
||||
|
||||
if callable(taskname):
|
||||
@@ -249,7 +253,7 @@ class ScheduledTask:
|
||||
|
||||
|
||||
class TaskRegister:
|
||||
"""Registery for periodicall tasks."""
|
||||
"""Registry for periodicall tasks."""
|
||||
task_list: List[ScheduledTask] = []
|
||||
|
||||
def register(self, task, schedule, minutes: int = None):
|
||||
@@ -495,7 +499,7 @@ def check_for_updates():
|
||||
def update_exchange_rates():
|
||||
"""Update currency exchange rates."""
|
||||
try:
|
||||
from djmoney.contrib.exchange.models import ExchangeBackend, Rate
|
||||
from djmoney.contrib.exchange.models import Rate
|
||||
|
||||
from common.settings import currency_code_default, currency_codes
|
||||
from InvenTree.exchange import InvenTreeExchange
|
||||
@@ -507,22 +511,9 @@ def update_exchange_rates():
|
||||
# Other error?
|
||||
return
|
||||
|
||||
# Test to see if the database is ready yet
|
||||
try:
|
||||
backend = ExchangeBackend.objects.get(name='InvenTreeExchange')
|
||||
except ExchangeBackend.DoesNotExist:
|
||||
pass
|
||||
except Exception: # pragma: no cover
|
||||
# Some other error
|
||||
logger.warning("update_exchange_rates: Database not ready")
|
||||
return
|
||||
|
||||
backend = InvenTreeExchange()
|
||||
logger.info(f"Updating exchange rates from {backend.url}")
|
||||
|
||||
base = currency_code_default()
|
||||
|
||||
logger.info(f"Using base currency '{base}'")
|
||||
logger.info(f"Updating exchange rates using base currency '{base}'")
|
||||
|
||||
try:
|
||||
backend.update_rates(base_currency=base)
|
||||
@@ -530,7 +521,7 @@ def update_exchange_rates():
|
||||
# Remove any exchange rates which are not in the provided currencies
|
||||
Rate.objects.filter(backend="InvenTreeExchange").exclude(currency__in=currency_codes()).delete()
|
||||
except Exception as e: # pragma: no cover
|
||||
logger.error(f"Error updating exchange rates: {e}")
|
||||
logger.error(f"Error updating exchange rates: {e} ({type(e)})")
|
||||
|
||||
|
||||
@scheduled_task(ScheduledTask.DAILY)
|
||||
@@ -558,27 +549,11 @@ def run_backup():
|
||||
record_task_success('run_backup')
|
||||
|
||||
|
||||
def send_email(subject, body, recipients, from_email=None, html_message=None):
|
||||
"""Send an email with the specified subject and body, to the specified recipients list."""
|
||||
if type(recipients) == str:
|
||||
recipients = [recipients]
|
||||
|
||||
offload_task(
|
||||
django_mail.send_mail,
|
||||
subject,
|
||||
body,
|
||||
from_email,
|
||||
recipients,
|
||||
fail_silently=False,
|
||||
html_message=html_message
|
||||
)
|
||||
|
||||
|
||||
@scheduled_task(ScheduledTask.DAILY)
|
||||
def check_for_migrations(worker: bool = True):
|
||||
"""Checks if migrations are needed.
|
||||
|
||||
If the setting auto_update is enabled we will start updateing.
|
||||
If the setting auto_update is enabled we will start updating.
|
||||
"""
|
||||
# Test if auto-updates are enabled
|
||||
if not get_setting('INVENTREE_AUTO_UPDATE', 'auto_update'):
|
||||
|
||||
36
InvenTree/InvenTree/template.py
Normal file
36
InvenTree/InvenTree/template.py
Normal file
@@ -0,0 +1,36 @@
|
||||
"""Custom template loader for InvenTree"""
|
||||
|
||||
import os
|
||||
|
||||
from django.conf import settings
|
||||
from django.template.loaders.base import Loader as BaseLoader
|
||||
from django.template.loaders.cached import Loader as CachedLoader
|
||||
|
||||
|
||||
class InvenTreeTemplateLoader(CachedLoader):
|
||||
"""Custom template loader which bypasses cache for PDF export"""
|
||||
|
||||
def get_template(self, template_name, skip=None):
|
||||
"""Return a template object for the given template name.
|
||||
|
||||
Any custom report or label templates will be forced to reload (without cache).
|
||||
This ensures that generated PDF reports / labels are always up-to-date.
|
||||
"""
|
||||
|
||||
# List of template patterns to skip cache for
|
||||
skip_cache_dirs = [
|
||||
os.path.abspath(os.path.join(settings.MEDIA_ROOT, 'report')),
|
||||
os.path.abspath(os.path.join(settings.MEDIA_ROOT, 'label')),
|
||||
'snippets/',
|
||||
]
|
||||
|
||||
# Initially load the template using the cached loader
|
||||
template = CachedLoader.get_template(self, template_name, skip)
|
||||
|
||||
template_path = str(template.name)
|
||||
|
||||
# If the template matches any of the skip patterns, reload it without cache
|
||||
if any(template_path.startswith(d) for d in skip_cache_dirs):
|
||||
template = BaseLoader.get_template(self, template_name, skip)
|
||||
|
||||
return template
|
||||
@@ -6,8 +6,7 @@ from django.urls import reverse
|
||||
|
||||
from rest_framework import status
|
||||
|
||||
from InvenTree.api_tester import InvenTreeAPITestCase
|
||||
from InvenTree.helpers import InvenTreeTestCase
|
||||
from InvenTree.unit_test import InvenTreeAPITestCase, InvenTreeTestCase
|
||||
from users.models import RuleSet, update_group_roles
|
||||
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ from django.urls import reverse
|
||||
from error_report.models import Error
|
||||
|
||||
from InvenTree.exceptions import log_error
|
||||
from InvenTree.helpers import InvenTreeTestCase
|
||||
from InvenTree.unit_test import InvenTreeTestCase
|
||||
|
||||
|
||||
class MiddlewareTests(InvenTreeTestCase):
|
||||
@@ -28,13 +28,13 @@ class MiddlewareTests(InvenTreeTestCase):
|
||||
self.client.logout()
|
||||
|
||||
# check that static files go through
|
||||
# TODO @matmair reenable this check
|
||||
# TODO @matmair re-enable this check
|
||||
# self.check_path('/static/css/inventree.css', 302)
|
||||
|
||||
# check that account things go through
|
||||
self.check_path(reverse('account_login'))
|
||||
|
||||
# logout goes diretly to login
|
||||
# logout goes directly to login
|
||||
self.check_path(reverse('account_logout'))
|
||||
|
||||
# check that frontend code is redirected to login
|
||||
|
||||
@@ -70,11 +70,11 @@ class InvenTreeTaskTests(TestCase):
|
||||
with self.assertWarnsMessage(UserWarning, "WARNING: 'InvenTree' not started - Malformed function path"):
|
||||
InvenTree.tasks.offload_task('InvenTree')
|
||||
|
||||
# Non exsistent app
|
||||
# Non existent app
|
||||
with self.assertWarnsMessage(UserWarning, "WARNING: 'InvenTreeABC.test_tasks.doesnotmatter' not started - No module named 'InvenTreeABC.test_tasks'"):
|
||||
InvenTree.tasks.offload_task('InvenTreeABC.test_tasks.doesnotmatter')
|
||||
|
||||
# Non exsistent function
|
||||
# Non existent function
|
||||
with self.assertWarnsMessage(UserWarning, "WARNING: 'InvenTree.test_tasks.doesnotexsist' not started - No function named 'doesnotexsist'"):
|
||||
InvenTree.tasks.offload_task('InvenTree.test_tasks.doesnotexsist')
|
||||
|
||||
|
||||
@@ -14,18 +14,18 @@ class URLTest(TestCase):
|
||||
# Need fixture data in the database
|
||||
fixtures = [
|
||||
'settings',
|
||||
'build',
|
||||
'company',
|
||||
'manufacturer_part',
|
||||
'price_breaks',
|
||||
'supplier_part',
|
||||
'order',
|
||||
'sales_order',
|
||||
'bom',
|
||||
'category',
|
||||
'params',
|
||||
'part_pricebreaks',
|
||||
'part',
|
||||
'bom',
|
||||
'build',
|
||||
'test_templates',
|
||||
'location',
|
||||
'stock_tests',
|
||||
|
||||
@@ -5,7 +5,7 @@ import os
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.urls import reverse
|
||||
|
||||
from InvenTree.helpers import InvenTreeTestCase
|
||||
from InvenTree.unit_test import InvenTreeTestCase
|
||||
|
||||
|
||||
class ViewTests(InvenTreeTestCase):
|
||||
|
||||
@@ -14,17 +14,19 @@ from django.contrib.sites.models import Site
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.test import TestCase, override_settings
|
||||
|
||||
import requests
|
||||
from djmoney.contrib.exchange.exceptions import MissingRate
|
||||
from djmoney.contrib.exchange.models import Rate, convert_money
|
||||
from djmoney.money import Money
|
||||
|
||||
import InvenTree.conversion
|
||||
import InvenTree.format
|
||||
import InvenTree.helpers
|
||||
import InvenTree.helpers_model
|
||||
import InvenTree.tasks
|
||||
from common.models import InvenTreeSetting
|
||||
from common.settings import currency_codes
|
||||
from InvenTree.sanitizer import sanitize_svg
|
||||
from InvenTree.unit_test import InvenTreeTestCase
|
||||
from part.models import Part, PartCategory
|
||||
from stock.models import StockItem, StockLocation
|
||||
|
||||
@@ -33,6 +35,45 @@ from .tasks import offload_task
|
||||
from .validators import validate_overage
|
||||
|
||||
|
||||
class ConversionTest(TestCase):
|
||||
"""Tests for conversion of physical units"""
|
||||
|
||||
def test_dimensionless_units(self):
|
||||
"""Tests for 'dimensonless' unit quantities"""
|
||||
|
||||
# Test some dimensionless units
|
||||
tests = {
|
||||
'ea': 1,
|
||||
'each': 1,
|
||||
'3 piece': 3,
|
||||
'5 dozen': 60,
|
||||
'3 hundred': 300,
|
||||
'2 thousand': 2000,
|
||||
'12 pieces': 12,
|
||||
}
|
||||
|
||||
for val, expected in tests.items():
|
||||
q = InvenTree.conversion.convert_physical_value(val).to_base_units()
|
||||
self.assertEqual(q.magnitude, expected)
|
||||
|
||||
def test_invalid_values(self):
|
||||
"""Test conversion of invalid inputs"""
|
||||
|
||||
inputs = [
|
||||
'-',
|
||||
';;',
|
||||
'-x',
|
||||
'?',
|
||||
'--',
|
||||
'+',
|
||||
'++',
|
||||
]
|
||||
|
||||
for val in inputs:
|
||||
with self.assertRaises(ValidationError):
|
||||
InvenTree.conversion.convert_physical_value(val)
|
||||
|
||||
|
||||
class ValidatorTest(TestCase):
|
||||
"""Simple tests for custom field validators."""
|
||||
|
||||
@@ -192,6 +233,34 @@ class FormatTest(TestCase):
|
||||
class TestHelpers(TestCase):
|
||||
"""Tests for InvenTree helper functions."""
|
||||
|
||||
def test_absolute_url(self):
|
||||
"""Test helper function for generating an absolute URL"""
|
||||
|
||||
base = "https://demo.inventree.org:12345"
|
||||
|
||||
InvenTreeSetting.set_setting('INVENTREE_BASE_URL', base, change_user=None)
|
||||
|
||||
tests = {
|
||||
"": base,
|
||||
"api/": base + "/api/",
|
||||
"/api/": base + "/api/",
|
||||
"api": base + "/api",
|
||||
"media/label/output/": base + "/media/label/output/",
|
||||
"static/logo.png": base + "/static/logo.png",
|
||||
"https://www.google.com": "https://www.google.com",
|
||||
"https://demo.inventree.org:12345/out.html": "https://demo.inventree.org:12345/out.html",
|
||||
"https://demo.inventree.org/test.html": "https://demo.inventree.org/test.html",
|
||||
"http://www.cwi.nl:80/%7Eguido/Python.html": "http://www.cwi.nl:80/%7Eguido/Python.html",
|
||||
"test.org": base + "/test.org",
|
||||
}
|
||||
|
||||
for url, expected in tests.items():
|
||||
# Test with supplied base URL
|
||||
self.assertEqual(InvenTree.helpers_model.construct_absolute_url(url, site_url=base), expected)
|
||||
|
||||
# Test without supplied base URL
|
||||
self.assertEqual(InvenTree.helpers_model.construct_absolute_url(url), expected)
|
||||
|
||||
def test_image_url(self):
|
||||
"""Test if a filename looks like an image."""
|
||||
for name in ['ape.png', 'bat.GiF', 'apple.WeBP', 'BiTMap.Bmp']:
|
||||
@@ -259,12 +328,12 @@ class TestHelpers(TestCase):
|
||||
"\\invalid-url"
|
||||
]:
|
||||
with self.assertRaises(django_exceptions.ValidationError):
|
||||
helpers.download_image_from_url(url)
|
||||
InvenTree.helpers_model.download_image_from_url(url)
|
||||
|
||||
def dl_helper(url, expected_error, timeout=2.5, retries=3):
|
||||
"""Helper function for unit testing downloads.
|
||||
|
||||
As the httpstat.us service occassionaly refuses a connection,
|
||||
As the httpstat.us service occasionally refuses a connection,
|
||||
we will simply try multiple times
|
||||
"""
|
||||
|
||||
@@ -274,7 +343,7 @@ class TestHelpers(TestCase):
|
||||
while tries < retries:
|
||||
|
||||
try:
|
||||
helpers.download_image_from_url(url, timeout=timeout)
|
||||
InvenTree.helpers_model.download_image_from_url(url, timeout=timeout)
|
||||
break
|
||||
except Exception as exc:
|
||||
if type(exc) is expected_error:
|
||||
@@ -287,10 +356,12 @@ class TestHelpers(TestCase):
|
||||
time.sleep(10 * tries)
|
||||
|
||||
# Attempt to download an image which throws a 404
|
||||
dl_helper("https://httpstat.us/404", requests.exceptions.HTTPError, timeout=10)
|
||||
# TODO: Re-implement this test when we are happier with the external service
|
||||
# dl_helper("https://httpstat.us/404", requests.exceptions.HTTPError, timeout=10)
|
||||
|
||||
# Attempt to download, but timeout
|
||||
dl_helper("https://httpstat.us/200?sleep=5000", requests.exceptions.ReadTimeout, timeout=1)
|
||||
# TODO: Re-implement this test when we are happier with the external service
|
||||
# dl_helper("https://httpstat.us/200?sleep=5000", requests.exceptions.ReadTimeout, timeout=1)
|
||||
|
||||
large_img = "https://github.com/inventree/InvenTree/raw/master/InvenTree/InvenTree/static/img/paper_splash_large.jpg"
|
||||
|
||||
@@ -298,13 +369,27 @@ class TestHelpers(TestCase):
|
||||
|
||||
# Attempt to download an image which is too large
|
||||
with self.assertRaises(ValueError):
|
||||
helpers.download_image_from_url(large_img, timeout=10)
|
||||
InvenTree.helpers_model.download_image_from_url(large_img, timeout=10)
|
||||
|
||||
# Increase allowable download size
|
||||
InvenTreeSetting.set_setting('INVENTREE_DOWNLOAD_IMAGE_MAX_SIZE', 5, change_user=None)
|
||||
|
||||
# Download a valid image (should not throw an error)
|
||||
helpers.download_image_from_url(large_img, timeout=10)
|
||||
InvenTree.helpers_model.download_image_from_url(large_img, timeout=10)
|
||||
|
||||
def test_model_mixin(self):
|
||||
"""Test the getModelsWithMixin function"""
|
||||
|
||||
from InvenTree.models import InvenTreeBarcodeMixin
|
||||
|
||||
models = InvenTree.helpers_model.getModelsWithMixin(InvenTreeBarcodeMixin)
|
||||
|
||||
self.assertIn(Part, models)
|
||||
self.assertIn(StockLocation, models)
|
||||
self.assertIn(StockItem, models)
|
||||
|
||||
self.assertNotIn(PartCategory, models)
|
||||
self.assertNotIn(InvenTreeSetting, models)
|
||||
|
||||
|
||||
class TestQuoteWrap(TestCase):
|
||||
@@ -502,7 +587,7 @@ class TestSerialNumberExtraction(TestCase):
|
||||
self.assertEqual(sn, ['5', '6', '7', '8'])
|
||||
|
||||
def test_failures(self):
|
||||
"""Test wron serial numbers."""
|
||||
"""Test wrong serial numbers."""
|
||||
e = helpers.extract_serial_numbers
|
||||
|
||||
# Test duplicates
|
||||
@@ -660,6 +745,7 @@ class CurrencyTests(TestCase):
|
||||
|
||||
else: # pragma: no cover
|
||||
print("Exchange rate update failed - retrying")
|
||||
print(f'Expected {currency_codes()}, got {[a.currency for a in rates]}')
|
||||
time.sleep(1)
|
||||
|
||||
self.assertTrue(update_successful)
|
||||
@@ -696,7 +782,7 @@ class TestStatus(TestCase):
|
||||
self.assertEqual(ready.isImportingData(), False)
|
||||
|
||||
|
||||
class TestSettings(helpers.InvenTreeTestCase):
|
||||
class TestSettings(InvenTreeTestCase):
|
||||
"""Unit tests for settings."""
|
||||
|
||||
superuser = True
|
||||
@@ -792,7 +878,7 @@ class TestSettings(helpers.InvenTreeTestCase):
|
||||
'inventree/data/config.yaml',
|
||||
]
|
||||
|
||||
self.assertTrue(any([opt in str(config.get_config_file()).lower() for opt in valid]))
|
||||
self.assertTrue(any(opt in str(config.get_config_file()).lower() for opt in valid))
|
||||
|
||||
# with env set
|
||||
with self.in_env_context({'INVENTREE_CONFIG_FILE': 'my_special_conf.yaml'}):
|
||||
@@ -807,7 +893,7 @@ class TestSettings(helpers.InvenTreeTestCase):
|
||||
'inventree/data/plugins.txt',
|
||||
]
|
||||
|
||||
self.assertTrue(any([opt in str(config.get_plugin_file()).lower() for opt in valid]))
|
||||
self.assertTrue(any(opt in str(config.get_plugin_file()).lower() for opt in valid))
|
||||
|
||||
# with env set
|
||||
with self.in_env_context({'INVENTREE_PLUGIN_FILE': 'my_special_plugins.txt'}):
|
||||
@@ -835,7 +921,7 @@ class TestSettings(helpers.InvenTreeTestCase):
|
||||
self.assertEqual(config.get_setting(TEST_ENV_NAME, None, typecast=dict), {})
|
||||
|
||||
|
||||
class TestInstanceName(helpers.InvenTreeTestCase):
|
||||
class TestInstanceName(InvenTreeTestCase):
|
||||
"""Unit tests for instance name."""
|
||||
|
||||
def test_instance_name(self):
|
||||
@@ -863,7 +949,7 @@ class TestInstanceName(helpers.InvenTreeTestCase):
|
||||
self.assertEqual(site_obj.domain, 'http://127.1.2.3')
|
||||
|
||||
|
||||
class TestOffloadTask(helpers.InvenTreeTestCase):
|
||||
class TestOffloadTask(InvenTreeTestCase):
|
||||
"""Tests for offloading tasks to the background worker"""
|
||||
|
||||
fixtures = [
|
||||
@@ -960,7 +1046,7 @@ class TestOffloadTask(helpers.InvenTreeTestCase):
|
||||
self.assertTrue(result)
|
||||
|
||||
|
||||
class BarcodeMixinTest(helpers.InvenTreeTestCase):
|
||||
class BarcodeMixinTest(InvenTreeTestCase):
|
||||
"""Tests for the InvenTreeBarcodeMixin mixin class"""
|
||||
|
||||
def test_barcode_model_type(self):
|
||||
@@ -993,7 +1079,7 @@ class SanitizerTest(TestCase):
|
||||
"""Simple tests for sanitizer functions."""
|
||||
|
||||
def test_svg_sanitizer(self):
|
||||
"""Test that SVGs are sanitized acordingly."""
|
||||
"""Test that SVGs are sanitized accordingly."""
|
||||
valid_string = """<svg xmlns="http://www.w3.org/2000/svg" version="1.1" id="svg2" height="400" width="400">{0}
|
||||
<path id="path1" d="m -151.78571,359.62883 v 112.76373 l 97.068507,-56.04253 V 303.14815 Z" style="fill:#ddbc91;"></path>
|
||||
</svg>"""
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
"""Helper functions for performing API unit tests."""
|
||||
"""Helper functions for unit testing / CI"""
|
||||
|
||||
import csv
|
||||
import io
|
||||
import json
|
||||
import re
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.auth.models import Group
|
||||
from django.contrib.auth.models import Group, Permission
|
||||
from django.db import connections
|
||||
from django.http.response import StreamingHttpResponse
|
||||
from django.test import TestCase
|
||||
from django.test.utils import CaptureQueriesContext
|
||||
|
||||
from djmoney.contrib.exchange.models import ExchangeBackend, Rate
|
||||
from rest_framework.test import APITestCase
|
||||
@@ -15,6 +21,75 @@ from plugin import registry
|
||||
from plugin.models import PluginConfig
|
||||
|
||||
|
||||
def addUserPermission(user, permission):
|
||||
"""Shortcut function for adding a certain permission to a user."""
|
||||
perm = Permission.objects.get(codename=permission)
|
||||
user.user_permissions.add(perm)
|
||||
|
||||
|
||||
def addUserPermissions(user, permissions):
|
||||
"""Shortcut function for adding multiple permissions to a user."""
|
||||
for permission in permissions:
|
||||
addUserPermission(user, permission)
|
||||
|
||||
|
||||
def getMigrationFileNames(app):
|
||||
"""Return a list of all migration filenames for provided app."""
|
||||
local_dir = Path(__file__).parent
|
||||
files = local_dir.joinpath('..', app, 'migrations').iterdir()
|
||||
|
||||
# Regex pattern for migration files
|
||||
regex = re.compile(r"^[\d]+_.*\.py$")
|
||||
|
||||
migration_files = []
|
||||
|
||||
for f in files:
|
||||
if regex.match(f.name):
|
||||
migration_files.append(f.name)
|
||||
|
||||
return migration_files
|
||||
|
||||
|
||||
def getOldestMigrationFile(app, exclude_extension=True, ignore_initial=True):
|
||||
"""Return the filename associated with the oldest migration."""
|
||||
oldest_num = -1
|
||||
oldest_file = None
|
||||
|
||||
for f in getMigrationFileNames(app):
|
||||
|
||||
if ignore_initial and f.startswith('0001_initial'):
|
||||
continue
|
||||
|
||||
num = int(f.split('_')[0])
|
||||
|
||||
if oldest_file is None or num < oldest_num:
|
||||
oldest_num = num
|
||||
oldest_file = f
|
||||
|
||||
if exclude_extension:
|
||||
oldest_file = oldest_file.replace('.py', '')
|
||||
|
||||
return oldest_file
|
||||
|
||||
|
||||
def getNewestMigrationFile(app, exclude_extension=True):
|
||||
"""Return the filename associated with the newest migration."""
|
||||
newest_file = None
|
||||
newest_num = -1
|
||||
|
||||
for f in getMigrationFileNames(app):
|
||||
num = int(f.split('_')[0])
|
||||
|
||||
if newest_file is None or num > newest_num:
|
||||
newest_num = num
|
||||
newest_file = f
|
||||
|
||||
if exclude_extension:
|
||||
newest_file = newest_file.replace('.py', '')
|
||||
|
||||
return newest_file
|
||||
|
||||
|
||||
class UserMixin:
|
||||
"""Mixin to setup a user and login for tests.
|
||||
|
||||
@@ -162,9 +237,38 @@ class ExchangeRateMixin:
|
||||
Rate.objects.bulk_create(items)
|
||||
|
||||
|
||||
class InvenTreeTestCase(ExchangeRateMixin, UserMixin, TestCase):
|
||||
"""Testcase with user setup buildin."""
|
||||
pass
|
||||
|
||||
|
||||
class InvenTreeAPITestCase(ExchangeRateMixin, UserMixin, APITestCase):
|
||||
"""Base class for running InvenTree API tests."""
|
||||
|
||||
@contextmanager
|
||||
def assertNumQueriesLessThan(self, value, using='default', verbose=False, debug=False):
|
||||
"""Context manager to check that the number of queries is less than a certain value.
|
||||
|
||||
Example:
|
||||
with self.assertNumQueriesLessThan(10):
|
||||
# Do some stuff
|
||||
Ref: https://stackoverflow.com/questions/1254170/django-is-there-a-way-to-count-sql-queries-from-an-unit-test/59089020#59089020
|
||||
"""
|
||||
with CaptureQueriesContext(connections[using]) as context:
|
||||
yield # your test will be run here
|
||||
|
||||
if verbose:
|
||||
msg = "\r\n%s" % json.dumps(context.captured_queries, indent=4)
|
||||
else:
|
||||
msg = None
|
||||
|
||||
n = len(context.captured_queries)
|
||||
|
||||
if debug:
|
||||
print(f"Expected less than {value} queries, got {n} queries")
|
||||
|
||||
self.assertLess(n, value, msg=msg)
|
||||
|
||||
def checkResponse(self, url, method, expected_code, response):
|
||||
"""Debug output for an unexpected response"""
|
||||
|
||||
@@ -265,8 +369,7 @@ class InvenTreeAPITestCase(ExchangeRateMixin, UserMixin, APITestCase):
|
||||
"""Download a file from the server, and return an in-memory file."""
|
||||
response = self.client.get(url, data=data, format='json')
|
||||
|
||||
if expected_code is not None:
|
||||
self.assertEqual(response.status_code, expected_code)
|
||||
self.checkResponse(url, 'DOWNLOAD_FILE', expected_code, response)
|
||||
|
||||
# Check that the response is of the correct type
|
||||
if not isinstance(response, StreamingHttpResponse):
|
||||
@@ -284,27 +387,27 @@ class InvenTreeAPITestCase(ExchangeRateMixin, UserMixin, APITestCase):
|
||||
|
||||
if decode:
|
||||
# Decode data and return as StringIO file object
|
||||
fo = io.StringIO()
|
||||
fo.name = fo
|
||||
fo.write(response.getvalue().decode('UTF-8'))
|
||||
file = io.StringIO()
|
||||
file.name = file
|
||||
file.write(response.getvalue().decode('UTF-8'))
|
||||
else:
|
||||
# Return a a BytesIO file object
|
||||
fo = io.BytesIO()
|
||||
fo.name = fn
|
||||
fo.write(response.getvalue())
|
||||
file = io.BytesIO()
|
||||
file.name = fn
|
||||
file.write(response.getvalue())
|
||||
|
||||
fo.seek(0)
|
||||
file.seek(0)
|
||||
|
||||
return fo
|
||||
return file
|
||||
|
||||
def process_csv(self, fo, delimiter=',', required_cols=None, excluded_cols=None, required_rows=None):
|
||||
def process_csv(self, file_object, delimiter=',', required_cols=None, excluded_cols=None, required_rows=None):
|
||||
"""Helper function to process and validate a downloaded csv file."""
|
||||
# Check that the correct object type has been passed
|
||||
self.assertTrue(isinstance(fo, io.StringIO))
|
||||
self.assertTrue(isinstance(file_object, io.StringIO))
|
||||
|
||||
fo.seek(0)
|
||||
file_object.seek(0)
|
||||
|
||||
reader = csv.reader(fo, delimiter=delimiter)
|
||||
reader = csv.reader(file_object, delimiter=delimiter)
|
||||
|
||||
headers = []
|
||||
rows = []
|
||||
@@ -9,7 +9,10 @@ from django.contrib import admin
|
||||
from django.urls import include, path, re_path
|
||||
from django.views.generic.base import RedirectView
|
||||
|
||||
from rest_framework.documentation import include_docs_urls
|
||||
from dj_rest_auth.registration.views import (ConfirmEmailView,
|
||||
SocialAccountDisconnectView,
|
||||
SocialAccountListView)
|
||||
from drf_spectacular.views import SpectacularAPIView, SpectacularRedocView
|
||||
|
||||
from build.api import build_api_urls
|
||||
from build.urls import build_urls
|
||||
@@ -31,6 +34,7 @@ from stock.urls import stock_urls
|
||||
from users.api import user_urls
|
||||
|
||||
from .api import APISearchView, InfoView, NotFoundView
|
||||
from .social_auth_urls import SocialProvierListView, social_auth_urlpatterns
|
||||
from .views import (AboutView, AppearanceSelectView, CustomConnectionsView,
|
||||
CustomEmailView, CustomLoginView,
|
||||
CustomPasswordResetFromKeyView,
|
||||
@@ -62,12 +66,26 @@ apipatterns = [
|
||||
# Plugin endpoints
|
||||
path('', include(plugin_api_urls)),
|
||||
|
||||
# Webhook endpoints
|
||||
# Common endpoints endpoint
|
||||
path('', include(common_api_urls)),
|
||||
|
||||
# OpenAPI Schema
|
||||
re_path('schema/', SpectacularAPIView.as_view(custom_settings={'SCHEMA_PATH_PREFIX': '/api/'}), name='schema'),
|
||||
|
||||
# InvenTree information endpoint
|
||||
path('', InfoView.as_view(), name='api-inventree-info'),
|
||||
|
||||
# Auth API endpoints
|
||||
path('auth/', include([
|
||||
re_path(r'^registration/account-confirm-email/(?P<key>[-:\w]+)/$', ConfirmEmailView.as_view(), name='account_confirm_email'),
|
||||
path('registration/', include('dj_rest_auth.registration.urls')),
|
||||
path('providers/', SocialProvierListView.as_view(), name='social_providers'),
|
||||
path('social/', include(social_auth_urlpatterns)),
|
||||
path('social/', SocialAccountListView.as_view(), name='social_account_list'),
|
||||
path('social/<int:pk>/disconnect/', SocialAccountDisconnectView.as_view(), name='social_account_disconnect'),
|
||||
path('', include('dj_rest_auth.urls')),
|
||||
])),
|
||||
|
||||
# Unknown endpoint
|
||||
re_path(r'^.*$', NotFoundView.as_view(), name='api-404'),
|
||||
]
|
||||
@@ -92,10 +110,11 @@ notifications_urls = [
|
||||
dynamic_javascript_urls = [
|
||||
re_path(r'^calendar.js', DynamicJsView.as_view(template_name='js/dynamic/calendar.js'), name='calendar.js'),
|
||||
re_path(r'^nav.js', DynamicJsView.as_view(template_name='js/dynamic/nav.js'), name='nav.js'),
|
||||
re_path(r'^permissions.js', DynamicJsView.as_view(template_name='js/dynamic/permissions.js'), name='permissions.js'),
|
||||
re_path(r'^settings.js', DynamicJsView.as_view(template_name='js/dynamic/settings.js'), name='settings.js'),
|
||||
]
|
||||
|
||||
# These javascript files are pased through the Django translation layer
|
||||
# These javascript files are passed through the Django translation layer
|
||||
translated_javascript_urls = [
|
||||
re_path(r'^api.js', DynamicJsView.as_view(template_name='js/translated/api.js'), name='api.js'),
|
||||
re_path(r'^attachment.js', DynamicJsView.as_view(template_name='js/translated/attachment.js'), name='attachment.js'),
|
||||
@@ -107,6 +126,7 @@ translated_javascript_urls = [
|
||||
re_path(r'^filters.js', DynamicJsView.as_view(template_name='js/translated/filters.js'), name='filters.js'),
|
||||
re_path(r'^forms.js', DynamicJsView.as_view(template_name='js/translated/forms.js'), name='forms.js'),
|
||||
re_path(r'^helpers.js', DynamicJsView.as_view(template_name='js/translated/helpers.js'), name='helpers.js'),
|
||||
re_path(r'^index.js', DynamicJsView.as_view(template_name='js/translated/index.js'), name='index.js'),
|
||||
re_path(r'^label.js', DynamicJsView.as_view(template_name='js/translated/label.js'), name='label.js'),
|
||||
re_path(r'^model_renderers.js', DynamicJsView.as_view(template_name='js/translated/model_renderers.js'), name='model_renderers.js'),
|
||||
re_path(r'^modals.js', DynamicJsView.as_view(template_name='js/translated/modals.js'), name='modals.js'),
|
||||
@@ -136,7 +156,7 @@ backendpatterns = [
|
||||
re_path(r'^auth/?', auth_request),
|
||||
|
||||
re_path(r'^api/', include(apipatterns)),
|
||||
re_path(r'^api-doc/', include_docs_urls(title='InvenTree API')),
|
||||
re_path(r'^api-doc/', SpectacularRedocView.as_view(url_name='schema'), name='api-doc'),
|
||||
]
|
||||
|
||||
frontendpatterns = [
|
||||
|
||||
@@ -8,9 +8,31 @@ from django.core import validators
|
||||
from django.core.exceptions import FieldDoesNotExist, ValidationError
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
import pint
|
||||
from jinja2 import Template
|
||||
from moneyed import CURRENCIES
|
||||
|
||||
import InvenTree.conversion
|
||||
|
||||
|
||||
def validate_physical_units(unit):
|
||||
"""Ensure that a given unit is a valid physical unit."""
|
||||
|
||||
unit = unit.strip()
|
||||
|
||||
# Ignore blank units
|
||||
if not unit:
|
||||
return
|
||||
|
||||
ureg = InvenTree.conversion.get_unit_registry()
|
||||
|
||||
try:
|
||||
ureg(unit)
|
||||
except AttributeError:
|
||||
raise ValidationError(_('Invalid physical unit'))
|
||||
except pint.errors.UndefinedUnitError:
|
||||
raise ValidationError(_('Invalid physical unit'))
|
||||
|
||||
|
||||
def validate_currency_code(code):
|
||||
"""Check that a given code is a valid currency code."""
|
||||
|
||||
@@ -4,25 +4,41 @@ Provides information on the current InvenTree version
|
||||
"""
|
||||
|
||||
import os
|
||||
import pathlib
|
||||
import platform
|
||||
import re
|
||||
import subprocess
|
||||
from datetime import datetime as dt
|
||||
from datetime import timedelta as td
|
||||
|
||||
import django
|
||||
from django.conf import settings
|
||||
|
||||
import common.models
|
||||
from InvenTree.api_version import INVENTREE_API_VERSION
|
||||
from dulwich.repo import NotGitRepository, Repo
|
||||
|
||||
from .api_version import INVENTREE_API_VERSION
|
||||
|
||||
# InvenTree software version
|
||||
INVENTREE_SW_VERSION = "0.11.0"
|
||||
INVENTREE_SW_VERSION = "0.12.8"
|
||||
|
||||
# Discover git
|
||||
try:
|
||||
main_repo = Repo(pathlib.Path(__file__).parent.parent.parent)
|
||||
main_commit = main_repo[main_repo.head()]
|
||||
except (NotGitRepository, FileNotFoundError):
|
||||
main_commit = None
|
||||
|
||||
|
||||
def inventreeInstanceName():
|
||||
"""Returns the InstanceName settings for the current database."""
|
||||
import common.models
|
||||
|
||||
return common.models.InvenTreeSetting.get_setting("INVENTREE_INSTANCE", "")
|
||||
|
||||
|
||||
def inventreeInstanceTitle():
|
||||
"""Returns the InstanceTitle for the current database."""
|
||||
import common.models
|
||||
|
||||
if common.models.InvenTreeSetting.get_setting("INVENTREE_INSTANCE_TITLE", False):
|
||||
return common.models.InvenTreeSetting.get_setting("INVENTREE_INSTANCE", "")
|
||||
else:
|
||||
@@ -66,6 +82,7 @@ def isInvenTreeUpToDate():
|
||||
|
||||
A background task periodically queries GitHub for latest version, and stores it to the database as "_INVENTREE_LATEST_VERSION"
|
||||
"""
|
||||
import common.models
|
||||
latest = common.models.InvenTreeSetting.get_setting('_INVENTREE_LATEST_VERSION', backup_value=None, create=False)
|
||||
|
||||
# No record for "latest" version - we must assume we are up to date!
|
||||
@@ -97,10 +114,9 @@ def inventreeCommitHash():
|
||||
if commit_hash:
|
||||
return commit_hash
|
||||
|
||||
try:
|
||||
return str(subprocess.check_output('git rev-parse --short HEAD'.split()), 'utf-8').strip()
|
||||
except Exception: # pragma: no cover
|
||||
if main_commit is None:
|
||||
return None
|
||||
return main_commit.sha().hexdigest()[0:7]
|
||||
|
||||
|
||||
def inventreeCommitDate():
|
||||
@@ -111,8 +127,53 @@ def inventreeCommitDate():
|
||||
if commit_date:
|
||||
return commit_date.split(' ')[0]
|
||||
|
||||
try:
|
||||
d = str(subprocess.check_output('git show -s --format=%ci'.split()), 'utf-8').strip()
|
||||
return d.split(' ')[0]
|
||||
except Exception: # pragma: no cover
|
||||
if main_commit is None:
|
||||
return None
|
||||
|
||||
commit_dt = dt.fromtimestamp(main_commit.commit_time) + td(seconds=main_commit.commit_timezone)
|
||||
return str(commit_dt.date())
|
||||
|
||||
|
||||
def inventreeInstaller():
|
||||
"""Returns the installer for the running codebase - if set."""
|
||||
# First look in the environment variables, e.g. if running in docker
|
||||
|
||||
installer = os.environ.get('INVENTREE_PKG_INSTALLER', '')
|
||||
|
||||
if installer:
|
||||
return installer
|
||||
elif settings.DOCKER:
|
||||
return 'DOC'
|
||||
elif main_commit is not None:
|
||||
return 'GIT'
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def inventreeBranch():
|
||||
"""Returns the branch for the running codebase - if set."""
|
||||
# First look in the environment variables, e.g. if running in docker
|
||||
|
||||
branch = os.environ.get('INVENTREE_PKG_BRANCH', '')
|
||||
|
||||
if branch:
|
||||
return branch
|
||||
|
||||
if main_commit is None:
|
||||
return None
|
||||
|
||||
branch = main_repo.refs.follow(b'HEAD')[0][1].decode()
|
||||
return branch.removeprefix('refs/heads/')
|
||||
|
||||
|
||||
def inventreeTarget():
|
||||
"""Returns the target platform for the running codebase - if set."""
|
||||
# First look in the environment variables, e.g. if running in docker
|
||||
|
||||
return os.environ.get('INVENTREE_PKG_TARGET', None)
|
||||
|
||||
|
||||
def inventreePlatform():
|
||||
"""Returns the platform for the instance."""
|
||||
|
||||
return platform.platform(aliased=True)
|
||||
|
||||
@@ -31,8 +31,8 @@ from allauth_2fa.views import TwoFactorRemove
|
||||
from djmoney.contrib.exchange.models import ExchangeBackend, Rate
|
||||
from user_sessions.views import SessionDeleteOtherView, SessionDeleteView
|
||||
|
||||
from common.models import ColorTheme, InvenTreeSetting
|
||||
from common.settings import currency_code_default, currency_codes
|
||||
import common.models as common_models
|
||||
import common.settings as common_settings
|
||||
from part.models import PartCategory
|
||||
from users.models import RuleSet, check_user_role
|
||||
|
||||
@@ -357,7 +357,7 @@ class AjaxUpdateView(AjaxMixin, UpdateView):
|
||||
- Updates model with POST field data
|
||||
- Performs form and object validation
|
||||
- If errors exist, re-render the form
|
||||
- Otherwise, return sucess status
|
||||
- Otherwise, return success status
|
||||
"""
|
||||
self.request = request
|
||||
|
||||
@@ -386,7 +386,7 @@ class AjaxUpdateView(AjaxMixin, UpdateView):
|
||||
|
||||
if valid:
|
||||
|
||||
# Save the updated objec to the database
|
||||
# Save the updated object to the database
|
||||
self.save(self.object, form)
|
||||
|
||||
self.object = self.get_object()
|
||||
@@ -447,8 +447,7 @@ class SetPasswordView(AjaxUpdateView):
|
||||
|
||||
if valid:
|
||||
# Old password must be correct
|
||||
|
||||
if not user.check_password(old_password):
|
||||
if user.has_usable_password() and not user.check_password(old_password):
|
||||
form.add_error('old_password', _('Wrong password provided'))
|
||||
valid = False
|
||||
|
||||
@@ -514,10 +513,10 @@ class SettingsView(TemplateView):
|
||||
"""Add data for template."""
|
||||
ctx = super().get_context_data(**kwargs).copy()
|
||||
|
||||
ctx['settings'] = InvenTreeSetting.objects.all().order_by('key')
|
||||
ctx['settings'] = common_models.InvenTreeSetting.objects.all().order_by('key')
|
||||
|
||||
ctx["base_currency"] = currency_code_default()
|
||||
ctx["currencies"] = currency_codes
|
||||
ctx["base_currency"] = common_settings.currency_code_default()
|
||||
ctx["currencies"] = common_settings.currency_codes
|
||||
|
||||
ctx["rates"] = Rate.objects.filter(backend="InvenTreeExchange")
|
||||
|
||||
@@ -525,8 +524,10 @@ class SettingsView(TemplateView):
|
||||
|
||||
# When were the rates last updated?
|
||||
try:
|
||||
backend = ExchangeBackend.objects.get(name='InvenTreeExchange')
|
||||
ctx["rates_updated"] = backend.last_update
|
||||
backend = ExchangeBackend.objects.filter(name='InvenTreeExchange')
|
||||
if backend.exists():
|
||||
backend = backend.first()
|
||||
ctx["rates_updated"] = backend.last_update
|
||||
except Exception:
|
||||
ctx["rates_updated"] = None
|
||||
|
||||
@@ -620,8 +621,8 @@ class AppearanceSelectView(RedirectView):
|
||||
def get_user_theme(self):
|
||||
"""Get current user color theme."""
|
||||
try:
|
||||
user_theme = ColorTheme.objects.filter(user=self.request.user).get()
|
||||
except ColorTheme.DoesNotExist:
|
||||
user_theme = common_models.ColorTheme.objects.filter(user=self.request.user).get()
|
||||
except common_models.ColorTheme.DoesNotExist:
|
||||
user_theme = None
|
||||
|
||||
return user_theme
|
||||
@@ -635,11 +636,15 @@ class AppearanceSelectView(RedirectView):
|
||||
|
||||
# Create theme entry if user did not select one yet
|
||||
if not user_theme:
|
||||
user_theme = ColorTheme()
|
||||
user_theme = common_models.ColorTheme()
|
||||
user_theme.user = request.user
|
||||
|
||||
user_theme.name = theme
|
||||
user_theme.save()
|
||||
if theme:
|
||||
try:
|
||||
user_theme.name = theme
|
||||
user_theme.save()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return redirect(reverse_lazy('settings'))
|
||||
|
||||
|
||||
@@ -4,9 +4,9 @@ from django.contrib import admin
|
||||
|
||||
from import_export.admin import ImportExportModelAdmin
|
||||
from import_export.fields import Field
|
||||
import import_export.widgets as widgets
|
||||
from import_export import widgets
|
||||
|
||||
from build.models import Build, BuildItem
|
||||
from build.models import Build, BuildLine, BuildItem
|
||||
from InvenTree.admin import InvenTreeResource
|
||||
import part.models
|
||||
|
||||
@@ -87,18 +87,33 @@ class BuildItemAdmin(admin.ModelAdmin):
|
||||
"""Class for managing the BuildItem model via the admin interface"""
|
||||
|
||||
list_display = (
|
||||
'build',
|
||||
'stock_item',
|
||||
'quantity'
|
||||
)
|
||||
|
||||
autocomplete_fields = [
|
||||
'build',
|
||||
'bom_item',
|
||||
'build_line',
|
||||
'stock_item',
|
||||
'install_into',
|
||||
]
|
||||
|
||||
|
||||
class BuildLineAdmin(admin.ModelAdmin):
|
||||
"""Class for managing the BuildLine model via the admin interface"""
|
||||
|
||||
list_display = (
|
||||
'build',
|
||||
'bom_item',
|
||||
'quantity',
|
||||
)
|
||||
|
||||
search_fields = [
|
||||
'build__title',
|
||||
'build__reference',
|
||||
'bom_item__sub_part__name',
|
||||
]
|
||||
|
||||
|
||||
admin.site.register(Build, BuildAdmin)
|
||||
admin.site.register(BuildItem, BuildItemAdmin)
|
||||
admin.site.register(BuildLine, BuildLineAdmin)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""JSON API for the Build app."""
|
||||
|
||||
from django.db.models import F
|
||||
from django.urls import include, path, re_path
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.contrib.auth.models import User
|
||||
@@ -9,14 +10,16 @@ from rest_framework.exceptions import ValidationError
|
||||
from django_filters.rest_framework import DjangoFilterBackend
|
||||
from django_filters import rest_framework as rest_filters
|
||||
|
||||
from InvenTree.api import AttachmentMixin, APIDownloadMixin, ListCreateDestroyAPIView, MetadataView, StatusView
|
||||
from InvenTree.api import AttachmentMixin, APIDownloadMixin, ListCreateDestroyAPIView, MetadataView
|
||||
from generic.states import StatusView
|
||||
from InvenTree.helpers import str2bool, isNull, DownloadFile
|
||||
from InvenTree.status_codes import BuildStatus
|
||||
from InvenTree.status_codes import BuildStatus, BuildStatusGroups
|
||||
from InvenTree.mixins import CreateAPI, RetrieveUpdateDestroyAPI, ListCreateAPI
|
||||
|
||||
import common.models
|
||||
import build.admin
|
||||
import build.serializers
|
||||
from build.models import Build, BuildItem, BuildOrderAttachment
|
||||
from build.models import Build, BuildLine, BuildItem, BuildOrderAttachment
|
||||
import part.models
|
||||
from users.models import Owner
|
||||
from InvenTree.filters import SEARCH_ORDER_FILTER_ALIAS
|
||||
@@ -41,9 +44,9 @@ class BuildFilter(rest_filters.FilterSet):
|
||||
def filter_active(self, queryset, name, value):
|
||||
"""Filter the queryset to either include or exclude orders which are active."""
|
||||
if str2bool(value):
|
||||
return queryset.filter(status__in=BuildStatus.ACTIVE_CODES)
|
||||
return queryset.filter(status__in=BuildStatusGroups.ACTIVE_CODES)
|
||||
else:
|
||||
return queryset.exclude(status__in=BuildStatus.ACTIVE_CODES)
|
||||
return queryset.exclude(status__in=BuildStatusGroups.ACTIVE_CODES)
|
||||
|
||||
overdue = rest_filters.BooleanFilter(label='Build is overdue', method='filter_overdue')
|
||||
|
||||
@@ -87,6 +90,21 @@ class BuildFilter(rest_filters.FilterSet):
|
||||
lookup_expr="iexact"
|
||||
)
|
||||
|
||||
project_code = rest_filters.ModelChoiceFilter(
|
||||
queryset=common.models.ProjectCode.objects.all(),
|
||||
field_name='project_code'
|
||||
)
|
||||
|
||||
has_project_code = rest_filters.BooleanFilter(label='has_project_code', method='filter_has_project_code')
|
||||
|
||||
def filter_has_project_code(self, queryset, name, value):
|
||||
"""Filter by whether or not the order has a project code"""
|
||||
|
||||
if str2bool(value):
|
||||
return queryset.exclude(project_code=None)
|
||||
else:
|
||||
return queryset.filter(project_code=None)
|
||||
|
||||
|
||||
class BuildList(APIDownloadMixin, ListCreateAPI):
|
||||
"""API endpoint for accessing a list of Build objects.
|
||||
@@ -112,11 +130,13 @@ class BuildList(APIDownloadMixin, ListCreateAPI):
|
||||
'completed',
|
||||
'issued_by',
|
||||
'responsible',
|
||||
'project_code',
|
||||
'priority',
|
||||
]
|
||||
|
||||
ordering_field_aliases = {
|
||||
'reference': ['reference_int', 'reference'],
|
||||
'project_code': ['project_code__code'],
|
||||
}
|
||||
|
||||
ordering = '-reference'
|
||||
@@ -127,6 +147,7 @@ class BuildList(APIDownloadMixin, ListCreateAPI):
|
||||
'part__name',
|
||||
'part__IPN',
|
||||
'part__description',
|
||||
'project_code__code',
|
||||
'priority',
|
||||
]
|
||||
|
||||
@@ -250,6 +271,88 @@ class BuildUnallocate(CreateAPI):
|
||||
return ctx
|
||||
|
||||
|
||||
class BuildLineFilter(rest_filters.FilterSet):
|
||||
"""Custom filterset for the BuildLine API endpoint."""
|
||||
|
||||
class Meta:
|
||||
"""Meta information for the BuildLineFilter class."""
|
||||
model = BuildLine
|
||||
fields = [
|
||||
'build',
|
||||
'bom_item',
|
||||
]
|
||||
|
||||
# Fields on related models
|
||||
consumable = rest_filters.BooleanFilter(label=_('Consumable'), field_name='bom_item__consumable')
|
||||
optional = rest_filters.BooleanFilter(label=_('Optional'), field_name='bom_item__optional')
|
||||
tracked = rest_filters.BooleanFilter(label=_('Tracked'), field_name='bom_item__sub_part__trackable')
|
||||
|
||||
allocated = rest_filters.BooleanFilter(label=_('Allocated'), method='filter_allocated')
|
||||
|
||||
def filter_allocated(self, queryset, name, value):
|
||||
"""Filter by whether each BuildLine is fully allocated"""
|
||||
|
||||
if str2bool(value):
|
||||
return queryset.filter(allocated__gte=F('quantity'))
|
||||
else:
|
||||
return queryset.filter(allocated__lt=F('quantity'))
|
||||
|
||||
|
||||
class BuildLineEndpoint:
|
||||
"""Mixin class for BuildLine API endpoints."""
|
||||
|
||||
queryset = BuildLine.objects.all()
|
||||
serializer_class = build.serializers.BuildLineSerializer
|
||||
|
||||
def get_queryset(self):
|
||||
"""Override queryset to select-related and annotate"""
|
||||
queryset = super().get_queryset()
|
||||
|
||||
queryset = queryset.select_related(
|
||||
'build', 'bom_item',
|
||||
)
|
||||
|
||||
queryset = build.serializers.BuildLineSerializer.annotate_queryset(queryset)
|
||||
|
||||
return queryset
|
||||
|
||||
|
||||
class BuildLineList(BuildLineEndpoint, ListCreateAPI):
|
||||
"""API endpoint for accessing a list of BuildLine objects"""
|
||||
|
||||
filterset_class = BuildLineFilter
|
||||
filter_backends = SEARCH_ORDER_FILTER_ALIAS
|
||||
|
||||
ordering_fields = [
|
||||
'part',
|
||||
'allocated',
|
||||
'reference',
|
||||
'quantity',
|
||||
'consumable',
|
||||
'optional',
|
||||
'unit_quantity',
|
||||
'available_stock',
|
||||
]
|
||||
|
||||
ordering_field_aliases = {
|
||||
'part': 'bom_item__sub_part__name',
|
||||
'reference': 'bom_item__reference',
|
||||
'unit_quantity': 'bom_item__quantity',
|
||||
'consumable': 'bom_item__consumable',
|
||||
'optional': 'bom_item__optional',
|
||||
}
|
||||
|
||||
search_fields = [
|
||||
'bom_item__sub_part__name',
|
||||
'bom_item__reference',
|
||||
]
|
||||
|
||||
|
||||
class BuildLineDetail(BuildLineEndpoint, RetrieveUpdateDestroyAPI):
|
||||
"""API endpoint for detail view of a BuildLine object."""
|
||||
pass
|
||||
|
||||
|
||||
class BuildOrderContextMixin:
|
||||
"""Mixin class which adds build order as serializer context variable."""
|
||||
|
||||
@@ -276,6 +379,19 @@ class BuildOutputCreate(BuildOrderContextMixin, CreateAPI):
|
||||
serializer_class = build.serializers.BuildOutputCreateSerializer
|
||||
|
||||
|
||||
class BuildOutputScrap(BuildOrderContextMixin, CreateAPI):
|
||||
"""API endpoint for scrapping build output(s)."""
|
||||
|
||||
queryset = Build.objects.none()
|
||||
serializer_class = build.serializers.BuildOutputScrapSerializer
|
||||
|
||||
def get_serializer_context(self):
|
||||
"""Add extra context information to the endpoint serializer."""
|
||||
ctx = super().get_serializer_context()
|
||||
ctx['to_complete'] = False
|
||||
return ctx
|
||||
|
||||
|
||||
class BuildOutputComplete(BuildOrderContextMixin, CreateAPI):
|
||||
"""API endpoint for completing build outputs."""
|
||||
|
||||
@@ -359,9 +475,8 @@ class BuildItemFilter(rest_filters.FilterSet):
|
||||
"""Metaclass option"""
|
||||
model = BuildItem
|
||||
fields = [
|
||||
'build',
|
||||
'build_line',
|
||||
'stock_item',
|
||||
'bom_item',
|
||||
'install_into',
|
||||
]
|
||||
|
||||
@@ -370,6 +485,11 @@ class BuildItemFilter(rest_filters.FilterSet):
|
||||
field_name='stock_item__part',
|
||||
)
|
||||
|
||||
build = rest_filters.ModelChoiceFilter(
|
||||
queryset=build.models.Build.objects.all(),
|
||||
field_name='build_line__build',
|
||||
)
|
||||
|
||||
tracked = rest_filters.BooleanFilter(label='Tracked', method='filter_tracked')
|
||||
|
||||
def filter_tracked(self, queryset, name, value):
|
||||
@@ -395,10 +515,9 @@ class BuildItemList(ListCreateAPI):
|
||||
try:
|
||||
params = self.request.query_params
|
||||
|
||||
kwargs['part_detail'] = str2bool(params.get('part_detail', False))
|
||||
kwargs['build_detail'] = str2bool(params.get('build_detail', False))
|
||||
kwargs['location_detail'] = str2bool(params.get('location_detail', False))
|
||||
kwargs['stock_detail'] = str2bool(params.get('stock_detail', True))
|
||||
for key in ['part_detail', 'location_detail', 'stock_detail', 'build_detail']:
|
||||
if key in params:
|
||||
kwargs[key] = str2bool(params.get(key, False))
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
@@ -409,9 +528,8 @@ class BuildItemList(ListCreateAPI):
|
||||
queryset = BuildItem.objects.all()
|
||||
|
||||
queryset = queryset.select_related(
|
||||
'bom_item',
|
||||
'bom_item__sub_part',
|
||||
'build',
|
||||
'build_line',
|
||||
'build_line__build',
|
||||
'install_into',
|
||||
'stock_item',
|
||||
'stock_item__location',
|
||||
@@ -421,7 +539,7 @@ class BuildItemList(ListCreateAPI):
|
||||
return queryset
|
||||
|
||||
def filter_queryset(self, queryset):
|
||||
"""Customm query filtering for the BuildItem list."""
|
||||
"""Custom query filtering for the BuildItem list."""
|
||||
queryset = super().filter_queryset(queryset)
|
||||
|
||||
params = self.request.query_params
|
||||
@@ -473,6 +591,12 @@ build_api_urls = [
|
||||
re_path(r'^.*$', BuildAttachmentList.as_view(), name='api-build-attachment-list'),
|
||||
])),
|
||||
|
||||
# Build lines
|
||||
re_path(r'^line/', include([
|
||||
path(r'<int:pk>/', BuildLineDetail.as_view(), name='api-build-line-detail'),
|
||||
re_path(r'^.*$', BuildLineList.as_view(), name='api-build-line-list'),
|
||||
])),
|
||||
|
||||
# Build Items
|
||||
re_path(r'^item/', include([
|
||||
path(r'<int:pk>/', include([
|
||||
@@ -489,6 +613,7 @@ build_api_urls = [
|
||||
re_path(r'^complete/', BuildOutputComplete.as_view(), name='api-build-output-complete'),
|
||||
re_path(r'^create-output/', BuildOutputCreate.as_view(), name='api-build-output-create'),
|
||||
re_path(r'^delete-outputs/', BuildOutputDelete.as_view(), name='api-build-output-delete'),
|
||||
re_path(r'^scrap-outputs/', BuildOutputScrap.as_view(), name='api-build-output-scrap'),
|
||||
re_path(r'^finish/', BuildFinish.as_view(), name='api-build-finish'),
|
||||
re_path(r'^cancel/', BuildCancel.as_view(), name='api-build-cancel'),
|
||||
re_path(r'^unallocate/', BuildUnallocate.as_view(), name='api-build-unallocate'),
|
||||
|
||||
@@ -11,10 +11,6 @@ def update_tree(apps, schema_editor):
|
||||
Build.objects.rebuild()
|
||||
|
||||
|
||||
def nupdate_tree(apps, schema_editor): # pragma: no cover
|
||||
pass
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
atomic = False
|
||||
@@ -53,5 +49,5 @@ class Migration(migrations.Migration):
|
||||
field=models.PositiveIntegerField(db_index=True, default=0, editable=False),
|
||||
preserve_default=False,
|
||||
),
|
||||
migrations.RunPython(update_tree, reverse_code=nupdate_tree),
|
||||
migrations.RunPython(update_tree, reverse_code=migrations.RunPython.noop),
|
||||
]
|
||||
|
||||
@@ -23,13 +23,6 @@ def add_default_reference(apps, schema_editor):
|
||||
print(f"\nUpdated build reference for {count} existing BuildOrder objects")
|
||||
|
||||
|
||||
def reverse_default_reference(apps, schema_editor): # pragma: no cover
|
||||
"""
|
||||
Do nothing! But we need to have a function here so the whole process is reversible.
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
atomic = False
|
||||
@@ -49,7 +42,7 @@ class Migration(migrations.Migration):
|
||||
# Auto-populate the new reference field for any existing build order objects
|
||||
migrations.RunPython(
|
||||
add_default_reference,
|
||||
reverse_code=reverse_default_reference
|
||||
reverse_code=migrations.RunPython.noop
|
||||
),
|
||||
|
||||
# Now that each build has a non-empty, unique reference, update the field requirements!
|
||||
|
||||
@@ -51,14 +51,6 @@ def assign_bom_items(apps, schema_editor):
|
||||
logger.info(f"Assigned BomItem for {count_valid}/{count_total} entries")
|
||||
|
||||
|
||||
def unassign_bom_items(apps, schema_editor): # pragma: no cover
|
||||
"""
|
||||
Reverse migration does not do anything.
|
||||
Function here to preserve ability to reverse migration
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
@@ -66,5 +58,5 @@ class Migration(migrations.Migration):
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(assign_bom_items, reverse_code=unassign_bom_items),
|
||||
migrations.RunPython(assign_bom_items, reverse_code=migrations.RunPython.noop),
|
||||
]
|
||||
|
||||
@@ -31,12 +31,6 @@ def build_refs(apps, schema_editor):
|
||||
build.reference_int = ref
|
||||
build.save()
|
||||
|
||||
def unbuild_refs(apps, schema_editor): # pragma: no cover
|
||||
"""
|
||||
Provided only for reverse migration compatibility
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
@@ -49,6 +43,6 @@ class Migration(migrations.Migration):
|
||||
operations = [
|
||||
migrations.RunPython(
|
||||
build_refs,
|
||||
reverse_code=unbuild_refs
|
||||
reverse_code=migrations.RunPython.noop
|
||||
)
|
||||
]
|
||||
|
||||
@@ -50,11 +50,6 @@ def update_build_reference(apps, schema_editor):
|
||||
print(f"Updated reference field for {n} BuildOrder objects")
|
||||
|
||||
|
||||
def nupdate_build_reference(apps, schema_editor):
|
||||
"""Reverse migration code. Does nothing."""
|
||||
pass
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
@@ -64,6 +59,6 @@ class Migration(migrations.Migration):
|
||||
operations = [
|
||||
migrations.RunPython(
|
||||
update_build_reference,
|
||||
reverse_code=nupdate_build_reference,
|
||||
reverse_code=migrations.RunPython.noop,
|
||||
)
|
||||
]
|
||||
|
||||
19
InvenTree/build/migrations/0042_alter_build_notes.py
Normal file
19
InvenTree/build/migrations/0042_alter_build_notes.py
Normal file
@@ -0,0 +1,19 @@
|
||||
# Generated by Django 3.2.18 on 2023-04-19 00:37
|
||||
|
||||
import InvenTree.fields
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('build', '0041_alter_build_title'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='build',
|
||||
name='notes',
|
||||
field=InvenTree.fields.InvenTreeNotesField(blank=True, help_text='Markdown notes (optional)', max_length=50000, null=True, verbose_name='Notes'),
|
||||
),
|
||||
]
|
||||
28
InvenTree/build/migrations/0043_buildline.py
Normal file
28
InvenTree/build/migrations/0043_buildline.py
Normal file
@@ -0,0 +1,28 @@
|
||||
# Generated by Django 3.2.19 on 2023-05-19 06:04
|
||||
|
||||
import django.core.validators
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('part', '0109_auto_20230517_1048'),
|
||||
('build', '0042_alter_build_notes'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='BuildLine',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('quantity', models.DecimalField(decimal_places=5, default=1, help_text='Required quantity for build order', max_digits=15, validators=[django.core.validators.MinValueValidator(0)], verbose_name='Quantity')),
|
||||
('bom_item', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='build_lines', to='part.bomitem')),
|
||||
('build', models.ForeignKey(help_text='Build object', on_delete=django.db.models.deletion.CASCADE, related_name='build_lines', to='build.build')),
|
||||
],
|
||||
options={
|
||||
'unique_together': {('build', 'bom_item')},
|
||||
},
|
||||
),
|
||||
]
|
||||
97
InvenTree/build/migrations/0044_auto_20230528_1410.py
Normal file
97
InvenTree/build/migrations/0044_auto_20230528_1410.py
Normal file
@@ -0,0 +1,97 @@
|
||||
# Generated by Django 3.2.19 on 2023-05-28 14:10
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def get_bom_items_for_part(part, Part, BomItem):
|
||||
""" Return a list of all BOM items for a given part.
|
||||
|
||||
Note that we cannot use the ORM here (as we are inside a data migration),
|
||||
so we *copy* the logic from the Part class.
|
||||
|
||||
This is a snapshot of the Part.get_bom_items() method as of 2023-05-29
|
||||
"""
|
||||
|
||||
bom_items = set()
|
||||
|
||||
# Get all BOM items which directly reference the part
|
||||
for bom_item in BomItem.objects.filter(part=part):
|
||||
bom_items.add(bom_item)
|
||||
|
||||
# Get all BOM items which are inherited by the part
|
||||
parents = Part.objects.filter(
|
||||
tree_id=part.tree_id,
|
||||
level__lt=part.level,
|
||||
lft__lt=part.lft,
|
||||
rght__gt=part.rght
|
||||
)
|
||||
|
||||
for bom_item in BomItem.objects.filter(part__in=parents, inherited=True):
|
||||
bom_items.add(bom_item)
|
||||
|
||||
return list(bom_items)
|
||||
|
||||
|
||||
def add_lines_to_builds(apps, schema_editor):
|
||||
"""Create BuildOrderLine objects for existing build orders"""
|
||||
|
||||
# Get database models
|
||||
Build = apps.get_model("build", "Build")
|
||||
BuildLine = apps.get_model("build", "BuildLine")
|
||||
|
||||
Part = apps.get_model("part", "Part")
|
||||
BomItem = apps.get_model("part", "BomItem")
|
||||
|
||||
build_lines = []
|
||||
|
||||
builds = Build.objects.all()
|
||||
|
||||
if builds.count() > 0:
|
||||
print(f"Creating BuildOrderLine objects for {builds.count()} existing builds")
|
||||
|
||||
for build in builds:
|
||||
# Create a BuildOrderLine for each BuildItem
|
||||
|
||||
bom_items = get_bom_items_for_part(build.part, Part, BomItem)
|
||||
|
||||
for item in bom_items:
|
||||
build_lines.append(
|
||||
BuildLine(
|
||||
build=build,
|
||||
bom_item=item,
|
||||
quantity=item.quantity * build.quantity,
|
||||
)
|
||||
)
|
||||
|
||||
if len(build_lines) > 0:
|
||||
# Construct the new BuildLine objects
|
||||
BuildLine.objects.bulk_create(build_lines)
|
||||
print(f"Created {len(build_lines)} BuildOrderLine objects for existing builds")
|
||||
|
||||
|
||||
def remove_build_lines(apps, schema_editor):
|
||||
"""Remove BuildOrderLine objects from the database"""
|
||||
|
||||
# Get database models
|
||||
BuildLine = apps.get_model("build", "BuildLine")
|
||||
|
||||
n = BuildLine.objects.all().count()
|
||||
|
||||
BuildLine.objects.all().delete()
|
||||
|
||||
if n > 0:
|
||||
print(f"Removed {n} BuildOrderLine objects")
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('build', '0043_buildline'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(
|
||||
add_lines_to_builds,
|
||||
reverse_code=remove_build_lines,
|
||||
),
|
||||
]
|
||||
19
InvenTree/build/migrations/0045_builditem_build_line.py
Normal file
19
InvenTree/build/migrations/0045_builditem_build_line.py
Normal file
@@ -0,0 +1,19 @@
|
||||
# Generated by Django 3.2.19 on 2023-06-06 10:30
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('build', '0044_auto_20230528_1410'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='builditem',
|
||||
name='build_line',
|
||||
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='allocations', to='build.buildline'),
|
||||
),
|
||||
]
|
||||
95
InvenTree/build/migrations/0046_auto_20230606_1033.py
Normal file
95
InvenTree/build/migrations/0046_auto_20230606_1033.py
Normal file
@@ -0,0 +1,95 @@
|
||||
# Generated by Django 3.2.19 on 2023-06-06 10:33
|
||||
|
||||
import logging
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
logger = logging.getLogger('inventree')
|
||||
|
||||
|
||||
def add_build_line_links(apps, schema_editor):
|
||||
"""Data migration to add links between BuildLine and BuildItem objects.
|
||||
|
||||
Associated model types:
|
||||
Build: A "Build Order"
|
||||
BomItem: An individual line in the BOM for Build.part
|
||||
BuildItem: An individual stock allocation against the Build Order
|
||||
BuildLine: (new model) an individual line in the Build Order
|
||||
|
||||
Goals:
|
||||
- Find all BuildItem objects which are associated with a Build
|
||||
- Link them against the relevant BuildLine object
|
||||
- The BuildLine objects should have been created in 0044_auto_20230528_1410.py
|
||||
"""
|
||||
|
||||
BuildItem = apps.get_model("build", "BuildItem")
|
||||
BuildLine = apps.get_model("build", "BuildLine")
|
||||
|
||||
# Find any existing BuildItem objects
|
||||
build_items = BuildItem.objects.all()
|
||||
|
||||
n_missing = 0
|
||||
|
||||
for item in build_items:
|
||||
|
||||
# Find the relevant BuildLine object
|
||||
line = BuildLine.objects.filter(
|
||||
build=item.build,
|
||||
bom_item=item.bom_item
|
||||
).first()
|
||||
|
||||
if line is None:
|
||||
logger.warning(f"BuildLine does not exist for BuildItem {item.pk}")
|
||||
n_missing += 1
|
||||
|
||||
if item.build is None or item.bom_item is None:
|
||||
continue
|
||||
|
||||
# Create one!
|
||||
line = BuildLine.objects.create(
|
||||
build=item.build,
|
||||
bom_item=item.bom_item,
|
||||
quantity=item.bom_item.quantity * item.build.quantity
|
||||
)
|
||||
|
||||
# Link the BuildItem to the BuildLine
|
||||
# In the next data migration, we remove the 'build' and 'bom_item' fields from BuildItem
|
||||
item.build_line = line
|
||||
item.save()
|
||||
|
||||
if build_items.count() > 0:
|
||||
logger.info(f"add_build_line_links: Updated {build_items.count()} BuildItem objects (added {n_missing})")
|
||||
|
||||
|
||||
def reverse_build_links(apps, schema_editor):
|
||||
"""Reverse data migration from add_build_line_links
|
||||
|
||||
Basically, iterate through each BuildItem and update the links based on the BuildLine
|
||||
"""
|
||||
|
||||
BuildItem = apps.get_model("build", "BuildItem")
|
||||
|
||||
items = BuildItem.objects.all()
|
||||
|
||||
for item in items:
|
||||
item.build = item.build_line.build
|
||||
item.bom_item = item.build_line.bom_item
|
||||
item.save()
|
||||
|
||||
if items.count() > 0:
|
||||
logger.info(f"reverse_build_links: Updated {items.count()} BuildItem objects")
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('build', '0045_builditem_build_line'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(
|
||||
add_build_line_links,
|
||||
reverse_code=reverse_build_links,
|
||||
)
|
||||
]
|
||||
26
InvenTree/build/migrations/0047_auto_20230606_1058.py
Normal file
26
InvenTree/build/migrations/0047_auto_20230606_1058.py
Normal file
@@ -0,0 +1,26 @@
|
||||
# Generated by Django 3.2.19 on 2023-06-06 10:58
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('stock', '0101_stockitemtestresult_metadata'),
|
||||
('build', '0046_auto_20230606_1033'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterUniqueTogether(
|
||||
name='builditem',
|
||||
unique_together={('build_line', 'stock_item', 'install_into')},
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='builditem',
|
||||
name='bom_item',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='builditem',
|
||||
name='build',
|
||||
),
|
||||
]
|
||||
20
InvenTree/build/migrations/0048_build_project_code.py
Normal file
20
InvenTree/build/migrations/0048_build_project_code.py
Normal file
@@ -0,0 +1,20 @@
|
||||
# Generated by Django 3.2.19 on 2023-05-14 09:22
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('common', '0019_projectcode_metadata'),
|
||||
('build', '0047_auto_20230606_1058'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='build',
|
||||
name='project_code',
|
||||
field=models.ForeignKey(blank=True, help_text='Project code for this build order', null=True, on_delete=django.db.models.deletion.SET_NULL, to='common.projectcode', verbose_name='Project Code'),
|
||||
),
|
||||
]
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,8 +4,11 @@ from django.db import transaction
|
||||
from django.core.exceptions import ValidationError as DjangoValidationError
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from django.db.models import Case, When, Value
|
||||
from django.db import models
|
||||
from django.db.models import ExpressionWrapper, F, FloatField
|
||||
from django.db.models import Case, Sum, When, Value
|
||||
from django.db.models import BooleanField
|
||||
from django.db.models.functions import Coalesce
|
||||
|
||||
from rest_framework import serializers
|
||||
from rest_framework.serializers import ValidationError
|
||||
@@ -17,14 +20,15 @@ import InvenTree.helpers
|
||||
from InvenTree.serializers import InvenTreeDecimalField
|
||||
from InvenTree.status_codes import StockStatus
|
||||
|
||||
from stock.models import StockItem, StockLocation
|
||||
from stock.models import generate_batch_code, StockItem, StockLocation
|
||||
from stock.serializers import StockItemSerializerBrief, LocationSerializer
|
||||
|
||||
from part.models import BomItem
|
||||
from part.serializers import PartSerializer, PartBriefSerializer
|
||||
from common.serializers import ProjectCodeSerializer
|
||||
import part.filters
|
||||
from part.serializers import BomItemSerializer, PartSerializer, PartBriefSerializer
|
||||
from users.serializers import OwnerSerializer
|
||||
|
||||
from .models import Build, BuildItem, BuildOrderAttachment
|
||||
from .models import Build, BuildLine, BuildItem, BuildOrderAttachment
|
||||
|
||||
|
||||
class BuildSerializer(InvenTreeModelSerializer):
|
||||
@@ -46,6 +50,8 @@ class BuildSerializer(InvenTreeModelSerializer):
|
||||
'parent',
|
||||
'part',
|
||||
'part_detail',
|
||||
'project_code',
|
||||
'project_code_detail',
|
||||
'overdue',
|
||||
'reference',
|
||||
'sales_order',
|
||||
@@ -87,11 +93,13 @@ class BuildSerializer(InvenTreeModelSerializer):
|
||||
|
||||
barcode_hash = serializers.CharField(read_only=True)
|
||||
|
||||
project_code_detail = ProjectCodeSerializer(source='project_code', many=False, read_only=True)
|
||||
|
||||
@staticmethod
|
||||
def annotate_queryset(queryset):
|
||||
"""Add custom annotations to the BuildSerializer queryset, performing database queries as efficiently as possible.
|
||||
|
||||
The following annoted fields are added:
|
||||
The following annotated fields are added:
|
||||
|
||||
- overdue: True if the build is outstanding *and* the completion date has past
|
||||
"""
|
||||
@@ -170,7 +178,7 @@ class BuildOutputSerializer(serializers.Serializer):
|
||||
if to_complete:
|
||||
|
||||
# The build output must have all tracked parts allocated
|
||||
if not build.is_fully_allocated(output):
|
||||
if not build.is_output_fully_allocated(output):
|
||||
|
||||
# Check if the user has specified that incomplete allocations are ok
|
||||
accept_incomplete = InvenTree.helpers.str2bool(self.context['request'].data.get('accept_incomplete_allocation', False))
|
||||
@@ -181,6 +189,45 @@ class BuildOutputSerializer(serializers.Serializer):
|
||||
return output
|
||||
|
||||
|
||||
class BuildOutputQuantitySerializer(BuildOutputSerializer):
|
||||
"""Serializer for a single build output, with additional quantity field"""
|
||||
|
||||
class Meta:
|
||||
"""Serializer metaclass"""
|
||||
fields = BuildOutputSerializer.Meta.fields + [
|
||||
'quantity',
|
||||
]
|
||||
|
||||
quantity = serializers.DecimalField(
|
||||
max_digits=15,
|
||||
decimal_places=5,
|
||||
min_value=0,
|
||||
required=True,
|
||||
label=_('Quantity'),
|
||||
help_text=_('Enter quantity for build output'),
|
||||
)
|
||||
|
||||
def validate(self, data):
|
||||
"""Validate the serializer data"""
|
||||
|
||||
data = super().validate(data)
|
||||
|
||||
output = data.get('output')
|
||||
quantity = data.get('quantity')
|
||||
|
||||
if quantity <= 0:
|
||||
raise ValidationError({
|
||||
'quantity': _('Quantity must be greater than zero')
|
||||
})
|
||||
|
||||
if quantity > output.quantity:
|
||||
raise ValidationError({
|
||||
'quantity': _("Quantity cannot be greater than the output quantity")
|
||||
})
|
||||
|
||||
return data
|
||||
|
||||
|
||||
class BuildOutputCreateSerializer(serializers.Serializer):
|
||||
"""Serializer for creating a new BuildOutput against a BuildOrder.
|
||||
|
||||
@@ -226,6 +273,7 @@ class BuildOutputCreateSerializer(serializers.Serializer):
|
||||
batch_code = serializers.CharField(
|
||||
required=False,
|
||||
allow_blank=True,
|
||||
default=generate_batch_code,
|
||||
label=_('Batch Code'),
|
||||
help_text=_('Batch code for this build output'),
|
||||
)
|
||||
@@ -302,12 +350,14 @@ class BuildOutputCreateSerializer(serializers.Serializer):
|
||||
auto_allocate = data.get('auto_allocate', False)
|
||||
|
||||
build = self.get_build()
|
||||
user = self.context['request'].user
|
||||
|
||||
build.create_build_output(
|
||||
quantity,
|
||||
serials=self.serials,
|
||||
batch=batch_code,
|
||||
auto_allocate=auto_allocate,
|
||||
user=user,
|
||||
)
|
||||
|
||||
|
||||
@@ -349,6 +399,78 @@ class BuildOutputDeleteSerializer(serializers.Serializer):
|
||||
build.delete_output(output)
|
||||
|
||||
|
||||
class BuildOutputScrapSerializer(serializers.Serializer):
|
||||
"""DRF serializer for scrapping one or more build outputs"""
|
||||
|
||||
class Meta:
|
||||
"""Serializer metaclass"""
|
||||
fields = [
|
||||
'outputs',
|
||||
'location',
|
||||
'notes',
|
||||
]
|
||||
|
||||
outputs = BuildOutputQuantitySerializer(
|
||||
many=True,
|
||||
required=True,
|
||||
)
|
||||
|
||||
location = serializers.PrimaryKeyRelatedField(
|
||||
queryset=StockLocation.objects.all(),
|
||||
many=False,
|
||||
allow_null=False,
|
||||
required=True,
|
||||
label=_('Location'),
|
||||
help_text=_('Stock location for scrapped outputs'),
|
||||
)
|
||||
|
||||
discard_allocations = serializers.BooleanField(
|
||||
required=False,
|
||||
default=False,
|
||||
label=_('Discard Allocations'),
|
||||
help_text=_('Discard any stock allocations for scrapped outputs'),
|
||||
)
|
||||
|
||||
notes = serializers.CharField(
|
||||
label=_('Notes'),
|
||||
help_text=_('Reason for scrapping build output(s)'),
|
||||
required=True,
|
||||
allow_blank=False,
|
||||
)
|
||||
|
||||
def validate(self, data):
|
||||
"""Perform validation on the serializer data"""
|
||||
super().validate(data)
|
||||
outputs = data.get('outputs', [])
|
||||
|
||||
if len(outputs) == 0:
|
||||
raise ValidationError(_("A list of build outputs must be provided"))
|
||||
|
||||
return data
|
||||
|
||||
def save(self):
|
||||
"""Save the serializer to scrap the build outputs"""
|
||||
|
||||
build = self.context['build']
|
||||
request = self.context['request']
|
||||
data = self.validated_data
|
||||
outputs = data.get('outputs', [])
|
||||
|
||||
# Scrap the build outputs
|
||||
with transaction.atomic():
|
||||
for item in outputs:
|
||||
output = item['output']
|
||||
quantity = item['quantity']
|
||||
build.scrap_build_output(
|
||||
output,
|
||||
quantity,
|
||||
data.get('location', None),
|
||||
user=request.user,
|
||||
notes=data.get('notes', ''),
|
||||
discard_allocations=data.get('discard_allocations', False)
|
||||
)
|
||||
|
||||
|
||||
class BuildOutputCompleteSerializer(serializers.Serializer):
|
||||
"""DRF serializer for completing one or more build outputs."""
|
||||
|
||||
@@ -376,8 +498,8 @@ class BuildOutputCompleteSerializer(serializers.Serializer):
|
||||
)
|
||||
|
||||
status = serializers.ChoiceField(
|
||||
choices=list(StockStatus.items()),
|
||||
default=StockStatus.OK,
|
||||
choices=StockStatus.items(),
|
||||
default=StockStatus.OK.value,
|
||||
label=_("Status"),
|
||||
)
|
||||
|
||||
@@ -448,7 +570,7 @@ class BuildCancelSerializer(serializers.Serializer):
|
||||
build = self.context['build']
|
||||
|
||||
return {
|
||||
'has_allocated_stock': build.is_partially_allocated(None),
|
||||
'has_allocated_stock': build.is_partially_allocated(),
|
||||
'incomplete_outputs': build.incomplete_count,
|
||||
'completed_outputs': build.complete_count,
|
||||
}
|
||||
@@ -489,7 +611,7 @@ class OverallocationChoice():
|
||||
TRIM = 'trim'
|
||||
|
||||
OPTIONS = {
|
||||
REJECT: ('Not permitted'),
|
||||
REJECT: _('Not permitted'),
|
||||
ACCEPT: _('Accept as consumed by this build order'),
|
||||
TRIM: _('Deallocate before completing this build order'),
|
||||
}
|
||||
@@ -507,8 +629,8 @@ class BuildCompleteSerializer(serializers.Serializer):
|
||||
build = self.context['build']
|
||||
|
||||
return {
|
||||
'overallocated': build.has_overallocated_parts(),
|
||||
'allocated': build.are_untracked_parts_allocated(),
|
||||
'overallocated': build.is_overallocated(),
|
||||
'allocated': build.are_untracked_parts_allocated,
|
||||
'remaining': build.remaining,
|
||||
'incomplete': build.incomplete_count,
|
||||
}
|
||||
@@ -525,7 +647,7 @@ class BuildCompleteSerializer(serializers.Serializer):
|
||||
"""Check if the 'accept_overallocated' field is required"""
|
||||
build = self.context['build']
|
||||
|
||||
if build.has_overallocated_parts(output=None) and value == OverallocationChoice.REJECT:
|
||||
if build.is_overallocated() and value == OverallocationChoice.REJECT:
|
||||
raise ValidationError(_('Some stock items have been overallocated'))
|
||||
|
||||
return value
|
||||
@@ -541,7 +663,7 @@ class BuildCompleteSerializer(serializers.Serializer):
|
||||
"""Check if the 'accept_unallocated' field is required"""
|
||||
build = self.context['build']
|
||||
|
||||
if not build.are_untracked_parts_allocated() and not value:
|
||||
if not build.are_untracked_parts_allocated and not value:
|
||||
raise ValidationError(_('Required stock has not been fully allocated'))
|
||||
|
||||
return value
|
||||
@@ -592,12 +714,12 @@ class BuildUnallocationSerializer(serializers.Serializer):
|
||||
- bom_item: Filter against a particular BOM line item
|
||||
"""
|
||||
|
||||
bom_item = serializers.PrimaryKeyRelatedField(
|
||||
queryset=BomItem.objects.all(),
|
||||
build_line = serializers.PrimaryKeyRelatedField(
|
||||
queryset=BuildLine.objects.all(),
|
||||
many=False,
|
||||
allow_null=True,
|
||||
required=False,
|
||||
label=_('BOM Item'),
|
||||
label=_('Build Line'),
|
||||
)
|
||||
|
||||
output = serializers.PrimaryKeyRelatedField(
|
||||
@@ -628,8 +750,8 @@ class BuildUnallocationSerializer(serializers.Serializer):
|
||||
|
||||
data = self.validated_data
|
||||
|
||||
build.unallocateStock(
|
||||
bom_item=data['bom_item'],
|
||||
build.deallocate_stock(
|
||||
build_line=data['build_line'],
|
||||
output=data['output']
|
||||
)
|
||||
|
||||
@@ -640,34 +762,34 @@ class BuildAllocationItemSerializer(serializers.Serializer):
|
||||
class Meta:
|
||||
"""Serializer metaclass"""
|
||||
fields = [
|
||||
'bom_item',
|
||||
'build_item',
|
||||
'stock_item',
|
||||
'quantity',
|
||||
'output',
|
||||
]
|
||||
|
||||
bom_item = serializers.PrimaryKeyRelatedField(
|
||||
queryset=BomItem.objects.all(),
|
||||
build_line = serializers.PrimaryKeyRelatedField(
|
||||
queryset=BuildLine.objects.all(),
|
||||
many=False,
|
||||
allow_null=False,
|
||||
required=True,
|
||||
label=_('BOM Item'),
|
||||
label=_('Build Line Item'),
|
||||
)
|
||||
|
||||
def validate_bom_item(self, bom_item):
|
||||
def validate_build_line(self, build_line):
|
||||
"""Check if the parts match"""
|
||||
build = self.context['build']
|
||||
|
||||
# BomItem should point to the same 'part' as the parent build
|
||||
if build.part != bom_item.part:
|
||||
if build.part != build_line.bom_item.part:
|
||||
|
||||
# If not, it may be marked as "inherited" from a parent part
|
||||
if bom_item.inherited and build.part in bom_item.part.get_descendants(include_self=False):
|
||||
if build_line.bom_item.inherited and build.part in build_line.bom_item.part.get_descendants(include_self=False):
|
||||
pass
|
||||
else:
|
||||
raise ValidationError(_("bom_item.part must point to the same part as the build order"))
|
||||
|
||||
return bom_item
|
||||
return build_line
|
||||
|
||||
stock_item = serializers.PrimaryKeyRelatedField(
|
||||
queryset=StockItem.objects.all(),
|
||||
@@ -710,8 +832,7 @@ class BuildAllocationItemSerializer(serializers.Serializer):
|
||||
"""Perform data validation for this item"""
|
||||
super().validate(data)
|
||||
|
||||
build = self.context['build']
|
||||
bom_item = data['bom_item']
|
||||
build_line = data['build_line']
|
||||
stock_item = data['stock_item']
|
||||
quantity = data['quantity']
|
||||
output = data.get('output', None)
|
||||
@@ -733,20 +854,20 @@ class BuildAllocationItemSerializer(serializers.Serializer):
|
||||
})
|
||||
|
||||
# Output *must* be set for trackable parts
|
||||
if output is None and bom_item.sub_part.trackable:
|
||||
if output is None and build_line.bom_item.sub_part.trackable:
|
||||
raise ValidationError({
|
||||
'output': _('Build output must be specified for allocation of tracked parts'),
|
||||
})
|
||||
|
||||
# Output *cannot* be set for un-tracked parts
|
||||
if output is not None and not bom_item.sub_part.trackable:
|
||||
if output is not None and not build_line.bom_item.sub_part.trackable:
|
||||
|
||||
raise ValidationError({
|
||||
'output': _('Build output cannot be specified for allocation of untracked parts'),
|
||||
})
|
||||
|
||||
# Check if this allocation would be unique
|
||||
if BuildItem.objects.filter(build=build, stock_item=stock_item, install_into=output).exists():
|
||||
if BuildItem.objects.filter(build_line=build_line, stock_item=stock_item, install_into=output).exists():
|
||||
raise ValidationError(_('This stock item has already been allocated to this build output'))
|
||||
|
||||
return data
|
||||
@@ -780,24 +901,21 @@ class BuildAllocationSerializer(serializers.Serializer):
|
||||
|
||||
items = data.get('items', [])
|
||||
|
||||
build = self.context['build']
|
||||
|
||||
with transaction.atomic():
|
||||
for item in items:
|
||||
bom_item = item['bom_item']
|
||||
build_line = item['build_line']
|
||||
stock_item = item['stock_item']
|
||||
quantity = item['quantity']
|
||||
output = item.get('output', None)
|
||||
|
||||
# Ignore allocation for consumable BOM items
|
||||
if bom_item.consumable:
|
||||
if build_line.bom_item.consumable:
|
||||
continue
|
||||
|
||||
try:
|
||||
# Create a new BuildItem to allocate stock
|
||||
BuildItem.objects.create(
|
||||
build=build,
|
||||
bom_item=bom_item,
|
||||
build_line=build_line,
|
||||
stock_item=stock_item,
|
||||
quantity=quantity,
|
||||
install_into=output
|
||||
@@ -879,43 +997,37 @@ class BuildItemSerializer(InvenTreeModelSerializer):
|
||||
model = BuildItem
|
||||
fields = [
|
||||
'pk',
|
||||
'bom_part',
|
||||
'build',
|
||||
'build_detail',
|
||||
'build_line',
|
||||
'install_into',
|
||||
'location',
|
||||
'location_detail',
|
||||
'part',
|
||||
'part_detail',
|
||||
'stock_item',
|
||||
'quantity',
|
||||
'location_detail',
|
||||
'part_detail',
|
||||
'stock_item_detail',
|
||||
'quantity'
|
||||
'build_detail',
|
||||
]
|
||||
|
||||
bom_part = serializers.IntegerField(source='bom_item.sub_part.pk', read_only=True)
|
||||
part = serializers.IntegerField(source='stock_item.part.pk', read_only=True)
|
||||
location = serializers.IntegerField(source='stock_item.location.pk', read_only=True)
|
||||
# Annotated fields
|
||||
build = serializers.PrimaryKeyRelatedField(source='build_line.build', many=False, read_only=True)
|
||||
|
||||
# Extra (optional) detail fields
|
||||
part_detail = PartSerializer(source='stock_item.part', many=False, read_only=True)
|
||||
build_detail = BuildSerializer(source='build', many=False, read_only=True)
|
||||
part_detail = PartBriefSerializer(source='stock_item.part', many=False, read_only=True)
|
||||
stock_item_detail = StockItemSerializerBrief(source='stock_item', read_only=True)
|
||||
location_detail = LocationSerializer(source='stock_item.location', read_only=True)
|
||||
build_detail = BuildSerializer(source='build_line.build', many=False, read_only=True)
|
||||
|
||||
quantity = InvenTreeDecimalField()
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
"""Determine which extra details fields should be included"""
|
||||
build_detail = kwargs.pop('build_detail', False)
|
||||
part_detail = kwargs.pop('part_detail', False)
|
||||
location_detail = kwargs.pop('location_detail', False)
|
||||
part_detail = kwargs.pop('part_detail', True)
|
||||
location_detail = kwargs.pop('location_detail', True)
|
||||
stock_detail = kwargs.pop('stock_detail', False)
|
||||
build_detail = kwargs.pop('build_detail', False)
|
||||
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
if not build_detail:
|
||||
self.fields.pop('build_detail')
|
||||
|
||||
if not part_detail:
|
||||
self.fields.pop('part_detail')
|
||||
|
||||
@@ -925,6 +1037,144 @@ class BuildItemSerializer(InvenTreeModelSerializer):
|
||||
if not stock_detail:
|
||||
self.fields.pop('stock_item_detail')
|
||||
|
||||
if not build_detail:
|
||||
self.fields.pop('build_detail')
|
||||
|
||||
|
||||
class BuildLineSerializer(InvenTreeModelSerializer):
|
||||
"""Serializer for a BuildItem object."""
|
||||
|
||||
class Meta:
|
||||
"""Serializer metaclass"""
|
||||
|
||||
model = BuildLine
|
||||
fields = [
|
||||
'pk',
|
||||
'build',
|
||||
'bom_item',
|
||||
'bom_item_detail',
|
||||
'part_detail',
|
||||
'quantity',
|
||||
'allocations',
|
||||
|
||||
# Annotated fields
|
||||
'allocated',
|
||||
'on_order',
|
||||
'available_stock',
|
||||
'available_substitute_stock',
|
||||
'available_variant_stock',
|
||||
]
|
||||
|
||||
read_only_fields = [
|
||||
'build',
|
||||
'bom_item',
|
||||
'allocations',
|
||||
]
|
||||
|
||||
quantity = serializers.FloatField()
|
||||
|
||||
# Foreign key fields
|
||||
bom_item_detail = BomItemSerializer(source='bom_item', many=False, read_only=True)
|
||||
part_detail = PartSerializer(source='bom_item.sub_part', many=False, read_only=True)
|
||||
allocations = BuildItemSerializer(many=True, read_only=True)
|
||||
|
||||
# Annotated (calculated) fields
|
||||
allocated = serializers.FloatField(read_only=True)
|
||||
on_order = serializers.FloatField(read_only=True)
|
||||
available_stock = serializers.FloatField(read_only=True)
|
||||
available_substitute_stock = serializers.FloatField(read_only=True)
|
||||
available_variant_stock = serializers.FloatField(read_only=True)
|
||||
|
||||
@staticmethod
|
||||
def annotate_queryset(queryset):
|
||||
"""Add extra annotations to the queryset:
|
||||
|
||||
- allocated: Total stock quantity allocated against this build line
|
||||
- available: Total stock available for allocation against this build line
|
||||
- on_order: Total stock on order for this build line
|
||||
"""
|
||||
|
||||
# Pre-fetch related fields
|
||||
queryset = queryset.prefetch_related(
|
||||
'bom_item__sub_part__stock_items',
|
||||
'bom_item__sub_part__stock_items__allocations',
|
||||
'bom_item__sub_part__stock_items__sales_order_allocations',
|
||||
|
||||
'bom_item__substitutes',
|
||||
'bom_item__substitutes__part__stock_items',
|
||||
'bom_item__substitutes__part__stock_items__allocations',
|
||||
'bom_item__substitutes__part__stock_items__sales_order_allocations',
|
||||
)
|
||||
|
||||
# Annotate the "allocated" quantity
|
||||
# Difficulty: Easy
|
||||
queryset = queryset.annotate(
|
||||
allocated=Coalesce(
|
||||
Sum('allocations__quantity'), 0,
|
||||
output_field=models.DecimalField()
|
||||
),
|
||||
)
|
||||
|
||||
ref = 'bom_item__sub_part__'
|
||||
|
||||
# Annotate the "on_order" quantity
|
||||
# Difficulty: Medium
|
||||
queryset = queryset.annotate(
|
||||
on_order=part.filters.annotate_on_order_quantity(reference=ref),
|
||||
)
|
||||
|
||||
# Annotate the "available" quantity
|
||||
# TODO: In the future, this should be refactored.
|
||||
# TODO: Note that part.serializers.BomItemSerializer also has a similar annotation
|
||||
queryset = queryset.alias(
|
||||
total_stock=part.filters.annotate_total_stock(reference=ref),
|
||||
allocated_to_sales_orders=part.filters.annotate_sales_order_allocations(reference=ref),
|
||||
allocated_to_build_orders=part.filters.annotate_build_order_allocations(reference=ref),
|
||||
)
|
||||
|
||||
# Calculate 'available_stock' based on previously annotated fields
|
||||
queryset = queryset.annotate(
|
||||
available_stock=ExpressionWrapper(
|
||||
F('total_stock') - F('allocated_to_sales_orders') - F('allocated_to_build_orders'),
|
||||
output_field=models.DecimalField(),
|
||||
)
|
||||
)
|
||||
|
||||
ref = 'bom_item__substitutes__part__'
|
||||
|
||||
# Extract similar information for any 'substitute' parts
|
||||
queryset = queryset.alias(
|
||||
substitute_stock=part.filters.annotate_total_stock(reference=ref),
|
||||
substitute_build_allocations=part.filters.annotate_build_order_allocations(reference=ref),
|
||||
substitute_sales_allocations=part.filters.annotate_sales_order_allocations(reference=ref)
|
||||
)
|
||||
|
||||
# Calculate 'available_substitute_stock' field
|
||||
queryset = queryset.annotate(
|
||||
available_substitute_stock=ExpressionWrapper(
|
||||
F('substitute_stock') - F('substitute_build_allocations') - F('substitute_sales_allocations'),
|
||||
output_field=models.DecimalField(),
|
||||
)
|
||||
)
|
||||
|
||||
# Annotate the queryset with 'available variant stock' information
|
||||
variant_stock_query = part.filters.variant_stock_query(reference='bom_item__sub_part__')
|
||||
|
||||
queryset = queryset.alias(
|
||||
variant_stock_total=part.filters.annotate_variant_quantity(variant_stock_query, reference='quantity'),
|
||||
variant_bo_allocations=part.filters.annotate_variant_quantity(variant_stock_query, reference='sales_order_allocations__quantity'),
|
||||
variant_so_allocations=part.filters.annotate_variant_quantity(variant_stock_query, reference='allocations__quantity'),
|
||||
)
|
||||
|
||||
queryset = queryset.annotate(
|
||||
available_variant_stock=ExpressionWrapper(
|
||||
F('variant_stock_total') - F('variant_bo_allocations') - F('variant_so_allocations'),
|
||||
output_field=FloatField(),
|
||||
)
|
||||
)
|
||||
|
||||
return queryset
|
||||
|
||||
|
||||
class BuildAttachmentSerializer(InvenTreeAttachmentSerializer):
|
||||
"""Serializer for a BuildAttachment."""
|
||||
|
||||
@@ -12,9 +12,10 @@ from allauth.account.models import EmailAddress
|
||||
from plugin.events import trigger_event
|
||||
import common.notifications
|
||||
import build.models
|
||||
import InvenTree.helpers
|
||||
import InvenTree.email
|
||||
import InvenTree.helpers_model
|
||||
import InvenTree.tasks
|
||||
from InvenTree.status_codes import BuildStatus
|
||||
from InvenTree.status_codes import BuildStatusGroups
|
||||
from InvenTree.ready import isImportingData
|
||||
|
||||
import part.models as part_models
|
||||
@@ -23,6 +24,55 @@ import part.models as part_models
|
||||
logger = logging.getLogger('inventree')
|
||||
|
||||
|
||||
def update_build_order_lines(bom_item_pk: int):
|
||||
"""Update all BuildOrderLineItem objects which reference a particular BomItem.
|
||||
|
||||
This task is triggered when a BomItem is created or updated.
|
||||
"""
|
||||
|
||||
logger.info(f"Updating build order lines for BomItem {bom_item_pk}")
|
||||
|
||||
bom_item = part_models.BomItem.objects.filter(pk=bom_item_pk).first()
|
||||
|
||||
# If the BomItem has been deleted, there is nothing to do
|
||||
if not bom_item:
|
||||
return
|
||||
|
||||
assemblies = bom_item.get_assemblies()
|
||||
|
||||
# Find all active builds which reference any of the parts
|
||||
builds = build.models.Build.objects.filter(
|
||||
part__in=list(assemblies),
|
||||
status__in=BuildStatusGroups.ACTIVE_CODES
|
||||
)
|
||||
|
||||
# Iterate through each build, and update the relevant line items
|
||||
for bo in builds:
|
||||
# Try to find a matching build order line
|
||||
line = build.models.BuildLine.objects.filter(
|
||||
build=bo,
|
||||
bom_item=bom_item,
|
||||
).first()
|
||||
|
||||
q = bom_item.get_required_quantity(bo.quantity)
|
||||
|
||||
if line:
|
||||
# Ensure quantity is correct
|
||||
if line.quantity != q:
|
||||
line.quantity = q
|
||||
line.save()
|
||||
else:
|
||||
# Create a new line item
|
||||
build.models.BuildLine.objects.create(
|
||||
build=bo,
|
||||
bom_item=bom_item,
|
||||
quantity=q,
|
||||
)
|
||||
|
||||
if builds.count() > 0:
|
||||
logger.info(f"Updated {builds.count()} build orders for part {bom_item.part}")
|
||||
|
||||
|
||||
def check_build_stock(build: build.models.Build):
|
||||
"""Check the required stock for a newly created build order.
|
||||
|
||||
@@ -64,7 +114,7 @@ def check_build_stock(build: build.models.Build):
|
||||
# There is not sufficient stock for this part
|
||||
|
||||
lines.append({
|
||||
'link': InvenTree.helpers.construct_absolute_url(sub_part.get_absolute_url()),
|
||||
'link': InvenTree.helpers_model.construct_absolute_url(sub_part.get_absolute_url()),
|
||||
'part': sub_part,
|
||||
'in_stock': in_stock,
|
||||
'allocated': allocated,
|
||||
@@ -88,7 +138,7 @@ def check_build_stock(build: build.models.Build):
|
||||
logger.info(f"Notifying users of stock required for build {build.pk}")
|
||||
|
||||
context = {
|
||||
'link': InvenTree.helpers.construct_absolute_url(build.get_absolute_url()),
|
||||
'link': InvenTree.helpers_model.construct_absolute_url(build.get_absolute_url()),
|
||||
'build': build,
|
||||
'part': build.part,
|
||||
'lines': lines,
|
||||
@@ -101,7 +151,7 @@ def check_build_stock(build: build.models.Build):
|
||||
|
||||
recipients = emails.values_list('email', flat=True)
|
||||
|
||||
InvenTree.tasks.send_email(subject, '', recipients, html_message=html_message)
|
||||
InvenTree.email.send_email(subject, '', recipients, html_message=html_message)
|
||||
|
||||
|
||||
def notify_overdue_build_order(bo: build.models.Build):
|
||||
@@ -121,7 +171,7 @@ def notify_overdue_build_order(bo: build.models.Build):
|
||||
'order': bo,
|
||||
'name': name,
|
||||
'message': _(f"Build order {bo} is now overdue"),
|
||||
'link': InvenTree.helpers.construct_absolute_url(
|
||||
'link': InvenTree.helpers_model.construct_absolute_url(
|
||||
bo.get_absolute_url(),
|
||||
),
|
||||
'template': {
|
||||
@@ -157,7 +207,7 @@ def check_overdue_build_orders():
|
||||
|
||||
overdue_orders = build.models.Build.objects.filter(
|
||||
target_date=yesterday,
|
||||
status__in=BuildStatus.ACTIVE_CODES
|
||||
status__in=BuildStatusGroups.ACTIVE_CODES
|
||||
)
|
||||
|
||||
for bo in overdue_orders:
|
||||
|
||||
@@ -2,12 +2,12 @@
|
||||
|
||||
{% load static %}
|
||||
{% load i18n %}
|
||||
{% load status_codes %}
|
||||
{% load generic %}
|
||||
{% load inventree_extras %}
|
||||
|
||||
{% block page_title %}
|
||||
{% inventree_title %} | {% trans "Build Order" %} - {{ build }}
|
||||
{% endblock %}
|
||||
{% endblock page_title %}
|
||||
|
||||
{% block breadcrumbs %}
|
||||
<li class='breadcrumb-item'><a href='{% url "build-index" %}'>{% trans "Build Orders" %}</a></li>
|
||||
@@ -15,7 +15,7 @@
|
||||
{% endblock breadcrumbs %}
|
||||
|
||||
{% block thumbnail %}
|
||||
<img class="part-thumb"
|
||||
<img alt="{% trans "Part thumbnail" %}" class="part-thumb"
|
||||
{% if build.part.image %}
|
||||
src="{{ build.part.image.preview.url }}"
|
||||
{% else %}
|
||||
@@ -25,7 +25,7 @@ src="{% static 'img/blank_image.png' %}"
|
||||
|
||||
{% block heading %}
|
||||
{% trans "Build Order" %} {{ build }}
|
||||
{% endblock %}
|
||||
{% endblock heading %}
|
||||
|
||||
{% block actions %}
|
||||
<!-- Admin Display -->
|
||||
@@ -108,6 +108,7 @@ src="{% static 'img/blank_image.png' %}"
|
||||
<td>{% trans "Build Description" %}</td>
|
||||
<td>{{ build.title }}</td>
|
||||
</tr>
|
||||
{% include "project_code_data.html" with instance=build %}
|
||||
{% include "barcode_data.html" with instance=build %}
|
||||
</table>
|
||||
|
||||
@@ -117,12 +118,6 @@ src="{% static 'img/blank_image.png' %}"
|
||||
{% trans "No build outputs have been created for this build order" %}<br>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if build.parent %}
|
||||
<div class='alert alert-block alert-info'>
|
||||
{% object_link 'build-detail' build.parent.id build.parent as link %}
|
||||
{% blocktrans %}This Build Order is a child of Build Order {{link}}{% endblocktrans %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if build.active %}
|
||||
{% if build.can_complete %}
|
||||
@@ -147,7 +142,7 @@ src="{% static 'img/blank_image.png' %}"
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% endblock details %}
|
||||
|
||||
{% block details_right %}
|
||||
<table class='table table-striped table-condensed'>
|
||||
@@ -156,7 +151,7 @@ src="{% static 'img/blank_image.png' %}"
|
||||
<td><span class='fas fa-info'></span></td>
|
||||
<td>{% trans "Status" %}</td>
|
||||
<td>
|
||||
{% build_status_label build.status %}
|
||||
{% status_label 'build' build.status %}
|
||||
</td>
|
||||
</tr>
|
||||
{% if build.target_date %}
|
||||
@@ -180,7 +175,7 @@ src="{% static 'img/blank_image.png' %}"
|
||||
{% else %}
|
||||
<span class='fa fa-times-circle icon-red'></span>
|
||||
{% endif %}
|
||||
<td>{% trans "Completed" %}</td>
|
||||
<td>{% trans "Completed Outputs" %}</td>
|
||||
<td>{% progress_bar build.completed build.quantity id='build-completed' max_width='150px' %}</td>
|
||||
</tr>
|
||||
{% if build.parent %}
|
||||
@@ -219,11 +214,11 @@ src="{% static 'img/blank_image.png' %}"
|
||||
</tr>
|
||||
{% endif %}
|
||||
</table>
|
||||
{% endblock %}
|
||||
{% endblock details_right %}
|
||||
|
||||
{% block page_data %}
|
||||
<h3>
|
||||
{% build_status_label build.status large=True %}
|
||||
{% status_label 'build' build.status large=True %}
|
||||
{% if build.is_overdue %}
|
||||
<span class='badge rounded-pill bg-danger'>{% trans "Overdue" %}</span>
|
||||
{% endif %}
|
||||
@@ -231,8 +226,7 @@ src="{% static 'img/blank_image.png' %}"
|
||||
<hr>
|
||||
<p>{{ build.title }}</p>
|
||||
|
||||
|
||||
{% endblock %}
|
||||
{% endblock page_data %}
|
||||
|
||||
{% block js_ready %}
|
||||
|
||||
@@ -288,7 +282,7 @@ src="{% static 'img/blank_image.png' %}"
|
||||
$('#show-qr-code').click(function() {
|
||||
showQRDialog(
|
||||
'{% trans "Build Order QR Code" %}',
|
||||
'{"build": {{ build.pk }}}'
|
||||
'{"build": {{ build.pk }} }'
|
||||
);
|
||||
});
|
||||
|
||||
@@ -312,4 +306,4 @@ src="{% static 'img/blank_image.png' %}"
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{% endblock %}
|
||||
{% endblock js_ready %}
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
{% load static %}
|
||||
{% load i18n %}
|
||||
{% load inventree_extras %}
|
||||
{% load status_codes %}
|
||||
{% load generic %}
|
||||
|
||||
{% block sidebar %}
|
||||
{% include "build/sidebar.html" %}
|
||||
{% endblock %}
|
||||
{% endblock sidebar %}
|
||||
|
||||
{% block page_content %}
|
||||
|
||||
@@ -22,12 +22,12 @@
|
||||
<tr>
|
||||
<td><span class='fas fa-info'></span></td>
|
||||
<td>{% trans "Description" %}</td>
|
||||
<td>{{ build.title }}{% include "clip.html"%}</td>
|
||||
<td>{{ build.title }}{% include "clip.html" %}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><span class='fas fa-shapes'></span></td>
|
||||
<td>{% trans "Part" %}</td>
|
||||
<td><a href="{% url 'part-detail' build.part.id %}?display=build-orders">{{ build.part.full_name }}</a>{% include "clip.html"%}</td>
|
||||
<td><a href="{% url 'part-detail' build.part.id %}?display=build-orders">{{ build.part.full_name }}</a>{% include "clip.html" %}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td></td>
|
||||
@@ -38,7 +38,7 @@
|
||||
<td>{% trans "Stock Source" %}</td>
|
||||
<td>
|
||||
{% if build.take_from %}
|
||||
<a href="{% url 'stock-location-detail' build.take_from.id %}">{{ build.take_from }}</a>{% include "clip.html"%}
|
||||
<a href="{% url 'stock-location-detail' build.take_from.id %}">{{ build.take_from }}</a>{% include "clip.html" %}
|
||||
{% else %}
|
||||
<em>{% trans "Stock can be taken from any available location." %}</em>
|
||||
{% endif %}
|
||||
@@ -51,7 +51,7 @@
|
||||
{% if build.destination %}
|
||||
<a href="{% url 'stock-location-detail' build.destination.id %}">
|
||||
{{ build.destination }}
|
||||
</a>{% include "clip.html"%}
|
||||
</a>{% include "clip.html" %}
|
||||
{% else %}
|
||||
<em>{% trans "Destination location not specified" %}</em>
|
||||
{% endif %}
|
||||
@@ -60,14 +60,14 @@
|
||||
<tr>
|
||||
<td><span class='fas fa-info'></span></td>
|
||||
<td>{% trans "Status" %}</td>
|
||||
<td>{% build_status_label build.status %}</td>
|
||||
<td>{% status_label 'build' build.status %}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><span class='fas fa-check-circle'></span></td>
|
||||
<td>{% trans "Completed" %}</td>
|
||||
<td>{% trans "Completed Outputs" %}</td>
|
||||
<td>{% progress_bar build.completed build.quantity id='build-completed-2' max_width='150px' %}</td>
|
||||
</tr>
|
||||
{% if build.active and has_untracked_bom_items %}
|
||||
{% if build.active %}
|
||||
<tr>
|
||||
<td><span class='fas fa-list'></span></td>
|
||||
<td>{% trans "Allocated Parts" %}</td>
|
||||
@@ -78,14 +78,14 @@
|
||||
<tr>
|
||||
<td><span class='fas fa-layer-group'></span></td>
|
||||
<td>{% trans "Batch" %}</td>
|
||||
<td>{{ build.batch }}{% include "clip.html"%}</td>
|
||||
<td>{{ build.batch }}{% include "clip.html" %}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% if build.parent %}
|
||||
<tr>
|
||||
<td><span class='fas fa-sitemap'></span></td>
|
||||
<td>{% trans "Parent Build" %}</td>
|
||||
<td><a href="{% url 'build-detail' build.parent.id %}">{{ build.parent }}</a>{% include "clip.html"%}</td>
|
||||
<td><a href="{% url 'build-detail' build.parent.id %}">{{ build.parent }}</a>{% include "clip.html" %}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% if build.priority != 0 %}
|
||||
@@ -99,14 +99,14 @@
|
||||
<tr>
|
||||
<td><span class='fas fa-dolly'></span></td>
|
||||
<td>{% trans "Sales Order" %}</td>
|
||||
<td><a href="{% url 'so-detail' build.sales_order.id %}">{{ build.sales_order }}</a>{% include "clip.html"%}</td>
|
||||
<td><a href="{% url 'so-detail' build.sales_order.id %}">{{ build.sales_order }}</a>{% include "clip.html" %}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% if build.link %}
|
||||
<tr>
|
||||
<td><span class='fas fa-link'></span></td>
|
||||
<td>{% trans "External Link" %}</td>
|
||||
<td><a href="{{ build.link }}">{{ build.link }}</a>{% include "clip.html"%}</td>
|
||||
<td>{% include 'clip_link.html' with link=build.link %}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% if build.issued_by %}
|
||||
@@ -165,9 +165,7 @@
|
||||
</div>
|
||||
<div class='panel-content'>
|
||||
<div id='child-button-toolbar'>
|
||||
<div class='button-toolbar container-fluid float-right'>
|
||||
{% include "filter_list.html" with id='sub-build' %}
|
||||
</div>
|
||||
{% include "filter_list.html" with id='sub-build' %}
|
||||
</div>
|
||||
<table class='table table-striped table-condensed' id='sub-build-table' data-toolbar='#child-button-toolbar'></table>
|
||||
</div>
|
||||
@@ -179,9 +177,9 @@
|
||||
<h4>{% trans "Allocate Stock to Build" %}</h4>
|
||||
{% include "spacer.html" %}
|
||||
<div class='btn-group' role='group'>
|
||||
{% if roles.build.add and build.active and has_untracked_bom_items %}
|
||||
<button class='btn btn-danger' type='button' id='btn-unallocate' title='{% trans "Unallocate stock" %}'>
|
||||
<span class='fas fa-minus-circle'></span> {% trans "Unallocate Stock" %}
|
||||
{% if roles.build.add and build.active %}
|
||||
<button class='btn btn-danger' type='button' id='btn-unallocate' title='{% trans "Deallocate stock" %}'>
|
||||
<span class='fas fa-minus-circle'></span> {% trans "Deallocate Stock" %}
|
||||
</button>
|
||||
<button class='btn btn-primary' type='button' id='btn-auto-allocate' title='{% trans "Automatically allocate stock to build" %}'>
|
||||
<span class='fas fa-magic'></span> {% trans "Auto Allocate" %}
|
||||
@@ -199,34 +197,10 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class='panel-content'>
|
||||
{% if has_untracked_bom_items %}
|
||||
{% if build.active %}
|
||||
{% if build.are_untracked_parts_allocated %}
|
||||
<div class='alert alert-block alert-success'>
|
||||
{% trans "Untracked stock has been fully allocated for this Build Order" %}
|
||||
<div id='build-lines-toolbar'>
|
||||
{% include "filter_list.html" with id='buildlines' %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class='alert alert-block alert-danger'>
|
||||
{% trans "Untracked stock has not been fully allocated for this Build Order" %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
<div id='unallocated-toolbar'>
|
||||
<div class='button-toolbar container-fluid' style='float: right;'>
|
||||
<div class='btn-group'>
|
||||
<button id='allocate-selected-items' class='btn btn-success' title='{% trans "Allocate selected items" %}'>
|
||||
<span class='fas fa-sign-in-alt'></span>
|
||||
</button>
|
||||
{% include "filter_list.html" with id='builditems' %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<table class='table table-striped table-condensed' id='allocation-table-untracked' data-toolbar='#unallocated-toolbar'></table>
|
||||
{% else %}
|
||||
<div class='alert alert-block alert-info'>
|
||||
{% trans "This Build Order does not have any associated untracked BOM items" %}
|
||||
</div>
|
||||
{% endif %}
|
||||
<table class='table table-striped table-condensed' id='build-lines-table' data-toolbar='#build-lines-toolbar'></table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -246,37 +220,24 @@
|
||||
</div>
|
||||
<div class='panel-content'>
|
||||
<div id='build-output-toolbar'>
|
||||
<div class='button-toolbar container-fluid'>
|
||||
{% if build.active %}
|
||||
<div class='btn-group'>
|
||||
|
||||
<!-- Build output actions -->
|
||||
<div class='btn-group'>
|
||||
<button id='output-options' class='btn btn-primary dropdown-toggle' type='button' data-bs-toggle='dropdown' title='{% trans "Output Actions" %}'>
|
||||
<span class='fas fa-tools'></span> <span class='caret'></span>
|
||||
</button>
|
||||
<ul class='dropdown-menu'>
|
||||
{% if roles.build.add %}
|
||||
<li><a class='dropdown-item' href='#' id='multi-output-complete' title='{% trans "Complete selected build outputs" %}'>
|
||||
<span class='fas fa-check-circle icon-green'></span> {% trans "Complete outputs" %}
|
||||
</a></li>
|
||||
{% endif %}
|
||||
{% if roles.build.delete %}
|
||||
<li><a class='dropdown-item' href='#' id='multi-output-delete' title='{% trans "Delete selected build outputs" %}'>
|
||||
<span class='fas fa-trash-alt icon-red'></span> {% trans "Delete outputs" %}
|
||||
</a></li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</div>
|
||||
{% include "filter_list.html" with id='incompletebuilditems' %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% include "filter_list.html" with id='incompletebuilditems' %}
|
||||
</div>
|
||||
<table class='table table-striped table-condensed' id='build-output-table' data-toolbar='#build-output-toolbar'></table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class='panel panel-hidden' id='panel-consumed'>
|
||||
<div class='panel-heading'>
|
||||
<h4>
|
||||
{% trans "Consumed Stock" %}
|
||||
</h4>
|
||||
</div>
|
||||
|
||||
<div class='panel-content'>
|
||||
{% include "stock_table.html" with read_only=True prefix="consumed-" %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class='panel panel-hidden' id='panel-completed'>
|
||||
<div class='panel-heading'>
|
||||
<h4>
|
||||
@@ -285,7 +246,7 @@
|
||||
</div>
|
||||
|
||||
<div class='panel-content'>
|
||||
{% include "stock_table.html" with read_only=True prefix="build-" %}
|
||||
{% include "stock_table.html" with prefix="build-" %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -319,25 +280,32 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{% endblock %}
|
||||
{% endblock page_content %}
|
||||
|
||||
{% block js_ready %}
|
||||
{{ block.super }}
|
||||
|
||||
onPanelLoad('consumed', function() {
|
||||
loadStockTable($('#consumed-stock-table'), {
|
||||
filterTarget: '#filter-list-consumed-stock',
|
||||
params: {
|
||||
location_detail: true,
|
||||
part_detail: true,
|
||||
consumed_by: {{ build.pk }},
|
||||
in_stock: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
onPanelLoad('completed', function() {
|
||||
loadStockTable($("#build-stock-table"), {
|
||||
filterTarget: '#filter-list-build-stock',
|
||||
params: {
|
||||
location_detail: true,
|
||||
part_detail: true,
|
||||
build: {{ build.id }},
|
||||
is_building: false,
|
||||
},
|
||||
groupByField: 'location',
|
||||
buttons: [
|
||||
'#stock-options',
|
||||
],
|
||||
url: "{% url 'api-stock-list' %}",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -400,38 +368,15 @@ onPanelLoad('outputs', function() {
|
||||
{% endif %}
|
||||
});
|
||||
|
||||
{% if build.active and has_untracked_bom_items %}
|
||||
|
||||
function loadUntrackedStockTable() {
|
||||
|
||||
var build_info = {
|
||||
pk: {{ build.pk }},
|
||||
part: {{ build.part.pk }},
|
||||
quantity: {{ build.quantity }},
|
||||
{% if build.take_from %}
|
||||
source_location: {{ build.take_from.pk }},
|
||||
{% endif %}
|
||||
tracked_parts: false,
|
||||
};
|
||||
|
||||
$('#allocation-table-untracked').bootstrapTable('destroy');
|
||||
|
||||
// Load allocation table for un-tracked parts
|
||||
loadBuildOutputAllocationTable(
|
||||
build_info,
|
||||
null,
|
||||
{
|
||||
search: true,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
onPanelLoad('allocate', function() {
|
||||
loadUntrackedStockTable();
|
||||
// Load the table of line items for this build order
|
||||
loadBuildLineTable(
|
||||
"#build-lines-table",
|
||||
{{ build.pk }},
|
||||
{}
|
||||
);
|
||||
});
|
||||
|
||||
{% endif %}
|
||||
|
||||
$('#btn-create-output').click(function() {
|
||||
|
||||
createBuildOutput(
|
||||
@@ -453,70 +398,62 @@ $("#btn-auto-allocate").on('click', function() {
|
||||
{% if build.take_from %}
|
||||
location: {{ build.take_from.pk }},
|
||||
{% endif %}
|
||||
onSuccess: loadUntrackedStockTable,
|
||||
onSuccess: function() {
|
||||
$('#build-lines-table').bootstrapTable('refresh');
|
||||
},
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
$("#btn-allocate").on('click', function() {
|
||||
function allocateSelectedLines() {
|
||||
|
||||
var bom_items = $("#allocation-table-untracked").bootstrapTable("getData");
|
||||
let data = getTableData('#build-lines-table');
|
||||
|
||||
var incomplete_bom_items = [];
|
||||
let unallocated_lines = [];
|
||||
|
||||
bom_items.forEach(function(bom_item) {
|
||||
if (bom_item.required > bom_item.allocated) {
|
||||
incomplete_bom_items.push(bom_item);
|
||||
data.forEach(function(line) {
|
||||
if (line.allocated < line.quantity) {
|
||||
unallocated_lines.push(line);
|
||||
}
|
||||
});
|
||||
|
||||
if (incomplete_bom_items.length == 0) {
|
||||
if (unallocated_lines.length == 0) {
|
||||
showAlertDialog(
|
||||
'{% trans "Allocation Complete" %}',
|
||||
'{% trans "All untracked stock items have been allocated" %}',
|
||||
'{% trans "All lines have been fully allocated" %}',
|
||||
);
|
||||
} else {
|
||||
|
||||
allocateStockToBuild(
|
||||
{{ build.pk }},
|
||||
{{ build.part.pk }},
|
||||
incomplete_bom_items,
|
||||
unallocated_lines,
|
||||
{
|
||||
{% if build.take_from %}
|
||||
source_location: {{ build.take_from.pk }},
|
||||
{% endif %}
|
||||
success: loadUntrackedStockTable,
|
||||
success: function() {
|
||||
$('#build-lines-table').bootstrapTable('refresh');
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
$('#btn-unallocate').on('click', function() {
|
||||
unallocateStock({{ build.id }}, {
|
||||
deallocateStock({{ build.id }}, {
|
||||
table: '#allocation-table-untracked',
|
||||
onSuccess: loadUntrackedStockTable,
|
||||
onSuccess: function() {
|
||||
$('#build-lines-table').bootstrapTable('refresh');
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
$('#allocate-selected-items').click(function() {
|
||||
|
||||
var bom_items = getTableData('#allocation-table-untracked');
|
||||
|
||||
allocateStockToBuild(
|
||||
{{ build.pk }},
|
||||
{{ build.part.pk }},
|
||||
bom_items,
|
||||
{
|
||||
{% if build.take_from %}
|
||||
source_location: {{ build.take_from.pk }},
|
||||
{% endif %}
|
||||
success: loadUntrackedStockTable,
|
||||
}
|
||||
);
|
||||
$("#btn-allocate").on('click', function() {
|
||||
allocateSelectedLines();
|
||||
});
|
||||
|
||||
{% endif %}
|
||||
|
||||
enableSidebar('buildorder');
|
||||
|
||||
{% endblock %}
|
||||
{% endblock js_ready %}
|
||||
|
||||
@@ -6,11 +6,11 @@
|
||||
|
||||
{% block page_title %}
|
||||
{% inventree_title %} | {% trans "Build Orders" %}
|
||||
{% endblock %}
|
||||
{% endblock page_title %}
|
||||
|
||||
{% block heading %}
|
||||
{% trans "Build Orders" %}
|
||||
{% endblock %}
|
||||
{% endblock heading %}
|
||||
|
||||
{% block actions %}
|
||||
{% if roles.build.add %}
|
||||
@@ -18,24 +18,20 @@
|
||||
<span class='fas fa-tools'></span> {% trans "New Build Order" %}
|
||||
</button>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
{% endblock actions %}
|
||||
|
||||
{% block page_info %}
|
||||
|
||||
<div class='panel-content'>
|
||||
<div id='button-toolbar'>
|
||||
<div class='button-toolbar container-fluid' style='float: right;'>
|
||||
<div class='btn-group' role='group'>
|
||||
{% include "filter_list.html" with id="build" %}
|
||||
</div>
|
||||
</div>
|
||||
{% include "filter_list.html" with id="build" %}
|
||||
</div>
|
||||
|
||||
<table class='table table-striped table-condensed' id='build-table' data-toolbar='#button-toolbar'>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
{% endblock page_info %}
|
||||
|
||||
{% block js_ready %}
|
||||
{{ block.super }}
|
||||
@@ -48,4 +44,4 @@ loadBuildTable($("#build-table"), {
|
||||
locale: '{{ request.LANGUAGE_CODE }}',
|
||||
});
|
||||
|
||||
{% endblock %}
|
||||
{% endblock js_ready %}
|
||||
|
||||
@@ -4,16 +4,16 @@
|
||||
|
||||
{% trans "Build Order Details" as text %}
|
||||
{% include "sidebar_item.html" with label='details' text=text icon="fa-info-circle" %}
|
||||
{% if build.active %}
|
||||
{% if build.is_active %}
|
||||
{% trans "Allocate Stock" as text %}
|
||||
{% include "sidebar_item.html" with label='allocate' text=text icon="fa-tasks" %}
|
||||
{% endif %}
|
||||
{% if not build.is_complete %}
|
||||
{% trans "Incomplete Outputs" as text %}
|
||||
{% include "sidebar_item.html" with label='outputs' text=text icon="fa-tools" %}
|
||||
{% endif %}
|
||||
{% trans "Completed Outputs" as text %}
|
||||
{% include "sidebar_item.html" with label='completed' text=text icon="fa-boxes" %}
|
||||
{% trans "Consumed Stock" as text %}
|
||||
{% include "sidebar_item.html" with label='consumed' text=text icon="fa-list" %}
|
||||
{% trans "Child Build Orders" as text %}
|
||||
{% include "sidebar_item.html" with label='children' text=text icon="fa-sitemap" %}
|
||||
{% trans "Attachments" as text %}
|
||||
|
||||
@@ -10,8 +10,8 @@ from part.models import Part
|
||||
from build.models import Build, BuildItem
|
||||
from stock.models import StockItem
|
||||
|
||||
from InvenTree.status_codes import BuildStatus
|
||||
from InvenTree.api_tester import InvenTreeAPITestCase
|
||||
from InvenTree.status_codes import BuildStatus, StockStatus
|
||||
from InvenTree.unit_test import InvenTreeAPITestCase
|
||||
|
||||
|
||||
class TestBuildAPI(InvenTreeAPITestCase):
|
||||
@@ -298,7 +298,7 @@ class BuildTest(BuildAPITest):
|
||||
expected_code=400,
|
||||
)
|
||||
|
||||
bo.status = BuildStatus.CANCELLED
|
||||
bo.status = BuildStatus.CANCELLED.value
|
||||
bo.save()
|
||||
|
||||
# Now, we should be able to delete
|
||||
@@ -541,10 +541,10 @@ class BuildTest(BuildAPITest):
|
||||
{
|
||||
'export': 'csv',
|
||||
}
|
||||
) as fo:
|
||||
) as file:
|
||||
|
||||
data = self.process_csv(
|
||||
fo,
|
||||
file,
|
||||
required_cols=required_cols,
|
||||
excluded_cols=excluded_cols,
|
||||
required_rows=Build.objects.count()
|
||||
@@ -582,6 +582,9 @@ class BuildAllocationTest(BuildAPITest):
|
||||
|
||||
self.build = Build.objects.get(pk=1)
|
||||
|
||||
# Regenerate BuildLine objects
|
||||
self.build.create_build_line_items()
|
||||
|
||||
# Record number of build items which exist at the start of each test
|
||||
self.n = BuildItem.objects.count()
|
||||
|
||||
@@ -593,7 +596,7 @@ class BuildAllocationTest(BuildAPITest):
|
||||
self.assertEqual(self.build.part.bom_items.count(), 4)
|
||||
|
||||
# No items yet allocated to this build
|
||||
self.assertEqual(self.build.allocated_stock.count(), 0)
|
||||
self.assertEqual(BuildItem.objects.filter(build_line__build=self.build).count(), 0)
|
||||
|
||||
def test_get(self):
|
||||
"""A GET request to the endpoint should return an error."""
|
||||
@@ -634,7 +637,7 @@ class BuildAllocationTest(BuildAPITest):
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"bom_item": 1, # M2x4 LPHS
|
||||
"build_line": 1, # M2x4 LPHS
|
||||
"stock_item": 2, # 5,000 screws available
|
||||
}
|
||||
]
|
||||
@@ -658,7 +661,7 @@ class BuildAllocationTest(BuildAPITest):
|
||||
expected_code=400
|
||||
).data
|
||||
|
||||
self.assertIn("This field is required", str(data["items"][0]["bom_item"]))
|
||||
self.assertIn("This field is required", str(data["items"][0]["build_line"]))
|
||||
|
||||
# Missing stock_item
|
||||
data = self.post(
|
||||
@@ -666,7 +669,7 @@ class BuildAllocationTest(BuildAPITest):
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"bom_item": 1,
|
||||
"build_line": 1,
|
||||
"quantity": 5000,
|
||||
}
|
||||
]
|
||||
@@ -681,12 +684,25 @@ class BuildAllocationTest(BuildAPITest):
|
||||
|
||||
def test_invalid_bom_item(self):
|
||||
"""Test by passing an invalid BOM item."""
|
||||
|
||||
# Find the right (in this case, wrong) BuildLine instance
|
||||
|
||||
si = StockItem.objects.get(pk=11)
|
||||
lines = self.build.build_lines.all()
|
||||
|
||||
wrong_line = None
|
||||
|
||||
for line in lines:
|
||||
if line.bom_item.sub_part.pk != si.pk:
|
||||
wrong_line = line
|
||||
break
|
||||
|
||||
data = self.post(
|
||||
self.url,
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"bom_item": 5,
|
||||
"build_line": wrong_line.pk,
|
||||
"stock_item": 11,
|
||||
"quantity": 500,
|
||||
}
|
||||
@@ -695,19 +711,31 @@ class BuildAllocationTest(BuildAPITest):
|
||||
expected_code=400
|
||||
).data
|
||||
|
||||
self.assertIn('must point to the same part', str(data))
|
||||
self.assertIn('Selected stock item does not match BOM line', str(data))
|
||||
|
||||
def test_valid_data(self):
|
||||
"""Test with valid data.
|
||||
|
||||
This should result in creation of a new BuildItem object
|
||||
"""
|
||||
|
||||
# Find the correct BuildLine
|
||||
si = StockItem.objects.get(pk=2)
|
||||
|
||||
right_line = None
|
||||
|
||||
for line in self.build.build_lines.all():
|
||||
|
||||
if line.bom_item.sub_part.pk == si.part.pk:
|
||||
right_line = line
|
||||
break
|
||||
|
||||
self.post(
|
||||
self.url,
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"bom_item": 1,
|
||||
"build_line": right_line.pk,
|
||||
"stock_item": 2,
|
||||
"quantity": 5000,
|
||||
}
|
||||
@@ -749,16 +777,22 @@ class BuildOverallocationTest(BuildAPITest):
|
||||
cls.state = {}
|
||||
cls.allocation = {}
|
||||
|
||||
for i, bi in enumerate(cls.build.part.bom_items.all()):
|
||||
rq = cls.build.required_quantity(bi, None) + i + 1
|
||||
si = StockItem.objects.filter(part=bi.sub_part, quantity__gte=rq).first()
|
||||
items_to_create = []
|
||||
|
||||
cls.state[bi.sub_part] = (si, si.quantity, rq)
|
||||
BuildItem.objects.create(
|
||||
build=cls.build,
|
||||
for idx, build_line in enumerate(cls.build.build_lines.all()):
|
||||
required = build_line.quantity + idx + 1
|
||||
sub_part = build_line.bom_item.sub_part
|
||||
si = StockItem.objects.filter(part=sub_part, quantity__gte=required).first()
|
||||
|
||||
cls.state[sub_part] = (si, si.quantity, required)
|
||||
|
||||
items_to_create.append(BuildItem(
|
||||
build_line=build_line,
|
||||
stock_item=si,
|
||||
quantity=rq,
|
||||
)
|
||||
quantity=required,
|
||||
))
|
||||
|
||||
BuildItem.objects.bulk_create(items_to_create)
|
||||
|
||||
# create and complete outputs
|
||||
cls.build.create_build_output(cls.build.quantity)
|
||||
@@ -822,9 +856,10 @@ class BuildOverallocationTest(BuildAPITest):
|
||||
self.assertTrue(self.build.is_complete)
|
||||
|
||||
# Check stock items have reduced only by bom requirement (overallocation trimmed)
|
||||
for bi in self.build.part.bom_items.all():
|
||||
si, oq, _ = self.state[bi.sub_part]
|
||||
rq = self.build.required_quantity(bi, None)
|
||||
for line in self.build.build_lines.all():
|
||||
|
||||
si, oq, _ = self.state[line.bom_item.sub_part]
|
||||
rq = line.quantity
|
||||
si.refresh_from_db()
|
||||
self.assertEqual(si.quantity, oq - rq)
|
||||
|
||||
@@ -843,7 +878,7 @@ class BuildListTest(BuildAPITest):
|
||||
builds = self.get(self.url, data={'active': True})
|
||||
self.assertEqual(len(builds.data), 1)
|
||||
|
||||
builds = self.get(self.url, data={'status': BuildStatus.COMPLETE})
|
||||
builds = self.get(self.url, data={'status': BuildStatus.COMPLETE.value})
|
||||
self.assertEqual(len(builds.data), 4)
|
||||
|
||||
builds = self.get(self.url, data={'overdue': False})
|
||||
@@ -863,7 +898,7 @@ class BuildListTest(BuildAPITest):
|
||||
reference="BO-0006",
|
||||
quantity=10,
|
||||
title='Just some thing',
|
||||
status=BuildStatus.PRODUCTION,
|
||||
status=BuildStatus.PRODUCTION.value,
|
||||
target_date=in_the_past
|
||||
)
|
||||
|
||||
@@ -924,3 +959,130 @@ class BuildListTest(BuildAPITest):
|
||||
builds = response.data
|
||||
|
||||
self.assertEqual(len(builds), 20)
|
||||
|
||||
|
||||
class BuildOutputScrapTest(BuildAPITest):
|
||||
"""Unit tests for scrapping build outputs"""
|
||||
|
||||
def scrap(self, build_id, data, expected_code=None):
|
||||
"""Helper method to POST to the scrap API"""
|
||||
|
||||
url = reverse('api-build-output-scrap', kwargs={'pk': build_id})
|
||||
|
||||
response = self.post(url, data, expected_code=expected_code)
|
||||
|
||||
return response.data
|
||||
|
||||
def test_invalid_scraps(self):
|
||||
"""Test that invalid scrap attempts are rejected"""
|
||||
|
||||
# Test with missing required fields
|
||||
response = self.scrap(1, {}, expected_code=400)
|
||||
|
||||
for field in ['outputs', 'location', 'notes']:
|
||||
self.assertIn('This field is required', str(response[field]))
|
||||
|
||||
# Scrap with no outputs specified
|
||||
response = self.scrap(
|
||||
1,
|
||||
{
|
||||
'outputs': [],
|
||||
'location': 1,
|
||||
'notes': 'Should fail',
|
||||
}
|
||||
)
|
||||
|
||||
self.assertIn('A list of build outputs must be provided', str(response))
|
||||
|
||||
# Scrap with an invalid output ID
|
||||
response = self.scrap(
|
||||
1,
|
||||
{
|
||||
'outputs': [
|
||||
{
|
||||
'output': 9999,
|
||||
}
|
||||
],
|
||||
'location': 1,
|
||||
'notes': 'Should fail',
|
||||
},
|
||||
expected_code=400
|
||||
)
|
||||
|
||||
self.assertIn('object does not exist', str(response['outputs']))
|
||||
|
||||
# Create a build output, for a different build
|
||||
build = Build.objects.get(pk=2)
|
||||
output = StockItem.objects.create(
|
||||
part=build.part,
|
||||
quantity=10,
|
||||
batch='BATCH-TEST',
|
||||
is_building=True,
|
||||
build=build,
|
||||
)
|
||||
|
||||
response = self.scrap(
|
||||
1,
|
||||
{
|
||||
'outputs': [
|
||||
{
|
||||
'output': output.pk,
|
||||
},
|
||||
],
|
||||
'location': 1,
|
||||
'notes': 'Should fail',
|
||||
},
|
||||
expected_code=400
|
||||
)
|
||||
|
||||
self.assertIn("Build output does not match the parent build", str(response['outputs']))
|
||||
|
||||
def test_valid_scraps(self):
|
||||
"""Test that valid scrap attempts succeed"""
|
||||
|
||||
# Create a build output
|
||||
build = Build.objects.get(pk=1)
|
||||
|
||||
for _ in range(3):
|
||||
build.create_build_output(2)
|
||||
|
||||
outputs = build.build_outputs.all()
|
||||
|
||||
self.assertEqual(outputs.count(), 3)
|
||||
self.assertEqual(StockItem.objects.filter(build=build).count(), 3)
|
||||
|
||||
for output in outputs:
|
||||
self.assertEqual(output.status, StockStatus.OK)
|
||||
self.assertTrue(output.is_building)
|
||||
|
||||
# Scrap all three outputs
|
||||
self.scrap(
|
||||
1,
|
||||
{
|
||||
'outputs': [
|
||||
{
|
||||
'output': outputs[0].pk,
|
||||
'quantity': outputs[0].quantity,
|
||||
},
|
||||
{
|
||||
'output': outputs[1].pk,
|
||||
'quantity': outputs[1].quantity,
|
||||
},
|
||||
{
|
||||
'output': outputs[2].pk,
|
||||
'quantity': outputs[2].quantity,
|
||||
},
|
||||
],
|
||||
'location': 1,
|
||||
'notes': 'Should succeed',
|
||||
},
|
||||
expected_code=201
|
||||
)
|
||||
|
||||
# There should still be three outputs associated with this build
|
||||
self.assertEqual(StockItem.objects.filter(build=build).count(), 3)
|
||||
|
||||
for output in outputs:
|
||||
output.refresh_from_db()
|
||||
self.assertEqual(output.status, StockStatus.REJECTED)
|
||||
self.assertFalse(output.is_building)
|
||||
|
||||
@@ -13,7 +13,7 @@ from InvenTree import status_codes as status
|
||||
|
||||
import common.models
|
||||
import build.tasks
|
||||
from build.models import Build, BuildItem, generate_next_build_reference
|
||||
from build.models import Build, BuildItem, BuildLine, generate_next_build_reference
|
||||
from part.models import Part, BomItem, BomItemSubstitute
|
||||
from stock.models import StockItem
|
||||
from users.models import Owner
|
||||
@@ -107,6 +107,11 @@ class BuildTestBase(TestCase):
|
||||
issued_by=get_user_model().objects.get(pk=1),
|
||||
)
|
||||
|
||||
# Create some BuildLine items we can use later on
|
||||
cls.line_1 = BuildLine.objects.get(build=cls.build, bom_item=cls.bom_item_1)
|
||||
cls.line_2 = BuildLine.objects.get(build=cls.build, bom_item=cls.bom_item_2)
|
||||
cls.line_3 = BuildLine.objects.get(build=cls.build, bom_item=cls.bom_item_3)
|
||||
|
||||
# Create some build output (StockItem) objects
|
||||
cls.output_1 = StockItem.objects.create(
|
||||
part=cls.assembly,
|
||||
@@ -141,6 +146,7 @@ class BuildTest(BuildTestBase):
|
||||
def test_ref_int(self):
|
||||
"""Test the "integer reference" field used for natural sorting"""
|
||||
|
||||
# Set build reference to new value
|
||||
common.models.InvenTreeSetting.set_setting('BUILDORDER_REFERENCE_PATTERN', 'BO-{ref}-???', change_user=None)
|
||||
|
||||
refs = {
|
||||
@@ -163,6 +169,9 @@ class BuildTest(BuildTestBase):
|
||||
build.save()
|
||||
self.assertEqual(build.reference_int, ref_int)
|
||||
|
||||
# Set build reference back to default value
|
||||
common.models.InvenTreeSetting.set_setting('BUILDORDER_REFERENCE_PATTERN', 'BO-{ref:04d}', change_user=None)
|
||||
|
||||
def test_ref_validation(self):
|
||||
"""Test that the reference field validation works as expected"""
|
||||
|
||||
@@ -209,6 +218,9 @@ class BuildTest(BuildTestBase):
|
||||
title='Valid reference',
|
||||
)
|
||||
|
||||
# Set build reference back to default value
|
||||
common.models.InvenTreeSetting.set_setting('BUILDORDER_REFERENCE_PATTERN', 'BO-{ref:04d}', change_user=None)
|
||||
|
||||
def test_next_ref(self):
|
||||
"""Test that the next reference is automatically generated"""
|
||||
|
||||
@@ -233,6 +245,9 @@ class BuildTest(BuildTestBase):
|
||||
self.assertEqual(build.reference, 'XYZ-000988')
|
||||
self.assertEqual(build.reference_int, 988)
|
||||
|
||||
# Set build reference back to default value
|
||||
common.models.InvenTreeSetting.set_setting('BUILDORDER_REFERENCE_PATTERN', 'BO-{ref:04d}', change_user=None)
|
||||
|
||||
def test_init(self):
|
||||
"""Perform some basic tests before we start the ball rolling"""
|
||||
|
||||
@@ -248,13 +263,10 @@ class BuildTest(BuildTestBase):
|
||||
for output in self.build.get_build_outputs().all():
|
||||
self.assertFalse(self.build.is_fully_allocated(output))
|
||||
|
||||
self.assertFalse(self.build.is_bom_item_allocated(self.bom_item_1, self.output_1))
|
||||
self.assertFalse(self.build.is_bom_item_allocated(self.bom_item_2, self.output_2))
|
||||
self.assertFalse(self.line_1.is_fully_allocated())
|
||||
self.assertFalse(self.line_2.is_overallocated())
|
||||
|
||||
self.assertEqual(self.build.unallocated_quantity(self.bom_item_1, self.output_1), 15)
|
||||
self.assertEqual(self.build.unallocated_quantity(self.bom_item_1, self.output_2), 35)
|
||||
self.assertEqual(self.build.unallocated_quantity(self.bom_item_2, self.output_1), 9)
|
||||
self.assertEqual(self.build.unallocated_quantity(self.bom_item_2, self.output_2), 21)
|
||||
self.assertEqual(self.line_1.allocated_quantity(), 0)
|
||||
|
||||
self.assertFalse(self.build.is_complete)
|
||||
|
||||
@@ -264,25 +276,25 @@ class BuildTest(BuildTestBase):
|
||||
stock = StockItem.objects.create(part=self.assembly, quantity=99)
|
||||
|
||||
# Create a BuiltItem which points to an invalid StockItem
|
||||
b = BuildItem(stock_item=stock, build=self.build, quantity=10)
|
||||
b = BuildItem(stock_item=stock, build_line=self.line_2, quantity=10)
|
||||
|
||||
with self.assertRaises(ValidationError):
|
||||
b.save()
|
||||
|
||||
# Create a BuildItem which has too much stock assigned
|
||||
b = BuildItem(stock_item=self.stock_1_1, build=self.build, quantity=9999999)
|
||||
b = BuildItem(stock_item=self.stock_1_1, build_line=self.line_1, quantity=9999999)
|
||||
|
||||
with self.assertRaises(ValidationError):
|
||||
b.clean()
|
||||
|
||||
# Negative stock? Not on my watch!
|
||||
b = BuildItem(stock_item=self.stock_1_1, build=self.build, quantity=-99)
|
||||
b = BuildItem(stock_item=self.stock_1_1, build_line=self.line_1, quantity=-99)
|
||||
|
||||
with self.assertRaises(ValidationError):
|
||||
b.clean()
|
||||
|
||||
# Ok, what about we make one that does *not* fail?
|
||||
b = BuildItem(stock_item=self.stock_1_2, build=self.build, install_into=self.output_1, quantity=10)
|
||||
b = BuildItem(stock_item=self.stock_1_2, build_line=self.line_1, install_into=self.output_1, quantity=10)
|
||||
b.save()
|
||||
|
||||
def test_duplicate_bom_line(self):
|
||||
@@ -302,13 +314,24 @@ class BuildTest(BuildTestBase):
|
||||
allocations: Map of {StockItem: quantity}
|
||||
"""
|
||||
|
||||
items_to_create = []
|
||||
|
||||
for item, quantity in allocations.items():
|
||||
BuildItem.objects.create(
|
||||
|
||||
# Find an appropriate BuildLine to allocate against
|
||||
line = BuildLine.objects.filter(
|
||||
build=self.build,
|
||||
bom_item__sub_part=item.part
|
||||
).first()
|
||||
|
||||
items_to_create.append(BuildItem(
|
||||
build_line=line,
|
||||
stock_item=item,
|
||||
quantity=quantity,
|
||||
install_into=output
|
||||
)
|
||||
))
|
||||
|
||||
BuildItem.objects.bulk_create(items_to_create)
|
||||
|
||||
def test_partial_allocation(self):
|
||||
"""Test partial allocation of stock"""
|
||||
@@ -321,7 +344,7 @@ class BuildTest(BuildTestBase):
|
||||
}
|
||||
)
|
||||
|
||||
self.assertTrue(self.build.is_fully_allocated(self.output_1))
|
||||
self.assertTrue(self.build.is_output_fully_allocated(self.output_1))
|
||||
|
||||
# Partially allocate tracked stock against build output 2
|
||||
self.allocate_stock(
|
||||
@@ -331,7 +354,7 @@ class BuildTest(BuildTestBase):
|
||||
}
|
||||
)
|
||||
|
||||
self.assertFalse(self.build.is_fully_allocated(self.output_2))
|
||||
self.assertFalse(self.build.is_output_fully_allocated(self.output_2))
|
||||
|
||||
# Partially allocate untracked stock against build
|
||||
self.allocate_stock(
|
||||
@@ -342,11 +365,12 @@ class BuildTest(BuildTestBase):
|
||||
}
|
||||
)
|
||||
|
||||
self.assertFalse(self.build.is_fully_allocated(None))
|
||||
self.assertFalse(self.build.is_output_fully_allocated(None))
|
||||
|
||||
unallocated = self.build.unallocated_bom_items(None)
|
||||
# Find lines which are *not* fully allocated
|
||||
unallocated = self.build.unallocated_lines()
|
||||
|
||||
self.assertEqual(len(unallocated), 2)
|
||||
self.assertEqual(len(unallocated), 3)
|
||||
|
||||
self.allocate_stock(
|
||||
None,
|
||||
@@ -357,17 +381,17 @@ class BuildTest(BuildTestBase):
|
||||
|
||||
self.assertFalse(self.build.is_fully_allocated(None))
|
||||
|
||||
unallocated = self.build.unallocated_bom_items(None)
|
||||
|
||||
self.assertEqual(len(unallocated), 1)
|
||||
|
||||
self.build.unallocateStock()
|
||||
|
||||
unallocated = self.build.unallocated_bom_items(None)
|
||||
unallocated = self.build.unallocated_lines()
|
||||
|
||||
self.assertEqual(len(unallocated), 2)
|
||||
|
||||
self.assertFalse(self.build.are_untracked_parts_allocated())
|
||||
self.build.deallocate_stock()
|
||||
|
||||
unallocated = self.build.unallocated_lines(None)
|
||||
|
||||
self.assertEqual(len(unallocated), 3)
|
||||
|
||||
self.assertFalse(self.build.is_fully_allocated(tracked=False))
|
||||
|
||||
self.stock_2_1.quantity = 500
|
||||
self.stock_2_1.save()
|
||||
@@ -381,7 +405,7 @@ class BuildTest(BuildTestBase):
|
||||
}
|
||||
)
|
||||
|
||||
self.assertTrue(self.build.are_untracked_parts_allocated())
|
||||
self.assertTrue(self.build.is_fully_allocated(tracked=False))
|
||||
|
||||
def test_overallocation_and_trim(self):
|
||||
"""Test overallocation of stock and trim function"""
|
||||
@@ -424,24 +448,40 @@ class BuildTest(BuildTestBase):
|
||||
extra_2_2: 4, # 35
|
||||
}
|
||||
)
|
||||
self.assertTrue(self.build.has_overallocated_parts(None))
|
||||
|
||||
self.assertTrue(self.build.is_overallocated())
|
||||
|
||||
self.build.trim_allocated_stock()
|
||||
self.assertFalse(self.build.has_overallocated_parts(None))
|
||||
self.assertFalse(self.build.is_overallocated())
|
||||
|
||||
self.build.complete_build_output(self.output_1, None)
|
||||
self.build.complete_build_output(self.output_2, None)
|
||||
self.assertTrue(self.build.can_complete)
|
||||
|
||||
n = StockItem.objects.filter(consumed_by=self.build).count()
|
||||
|
||||
self.build.complete_build(None)
|
||||
|
||||
self.assertEqual(self.build.status, status.BuildStatus.COMPLETE)
|
||||
|
||||
# Check stock items are in expected state.
|
||||
self.assertEqual(StockItem.objects.get(pk=self.stock_1_2.pk).quantity, 53)
|
||||
self.assertEqual(StockItem.objects.filter(part=self.sub_part_2).aggregate(Sum('quantity'))['quantity__sum'], 5)
|
||||
|
||||
# Total stock quantity has not been decreased
|
||||
items = StockItem.objects.filter(part=self.sub_part_2)
|
||||
self.assertEqual(items.aggregate(Sum('quantity'))['quantity__sum'], 35)
|
||||
|
||||
# However, the "available" stock quantity has been decreased
|
||||
self.assertEqual(items.filter(consumed_by=None).aggregate(Sum('quantity'))['quantity__sum'], 5)
|
||||
|
||||
# And the "consumed_by" quantity has been increased
|
||||
self.assertEqual(items.filter(consumed_by=self.build).aggregate(Sum('quantity'))['quantity__sum'], 30)
|
||||
|
||||
self.assertEqual(StockItem.objects.get(pk=self.stock_3_1.pk).quantity, 980)
|
||||
|
||||
# Check that the "consumed_by" item count has increased
|
||||
self.assertEqual(StockItem.objects.filter(consumed_by=self.build).count(), n + 8)
|
||||
|
||||
def test_cancel(self):
|
||||
"""Test cancellation of the build"""
|
||||
|
||||
@@ -510,15 +550,12 @@ class BuildTest(BuildTestBase):
|
||||
self.assertEqual(BuildItem.objects.count(), 0)
|
||||
|
||||
# New stock items should have been created!
|
||||
self.assertEqual(StockItem.objects.count(), 10)
|
||||
self.assertEqual(StockItem.objects.count(), 13)
|
||||
|
||||
# This stock item has been depleted!
|
||||
with self.assertRaises(StockItem.DoesNotExist):
|
||||
StockItem.objects.get(pk=self.stock_1_1.pk)
|
||||
|
||||
# This stock item has also been depleted
|
||||
with self.assertRaises(StockItem.DoesNotExist):
|
||||
StockItem.objects.get(pk=self.stock_2_1.pk)
|
||||
# This stock item has been marked as "consumed"
|
||||
item = StockItem.objects.get(pk=self.stock_1_1.pk)
|
||||
self.assertIsNotNone(item.consumed_by)
|
||||
self.assertFalse(item.in_stock)
|
||||
|
||||
# And 10 new stock items created for the build output
|
||||
outputs = StockItem.objects.filter(build=self.build)
|
||||
@@ -574,12 +611,12 @@ class BuildTest(BuildTestBase):
|
||||
"""Unit tests for the metadata field."""
|
||||
|
||||
# Make sure a BuildItem exists before trying to run this test
|
||||
b = BuildItem(stock_item=self.stock_1_2, build=self.build, install_into=self.output_1, quantity=10)
|
||||
b = BuildItem(stock_item=self.stock_1_2, build_line=self.line_1, install_into=self.output_1, quantity=10)
|
||||
b.save()
|
||||
|
||||
for model in [Build, BuildItem]:
|
||||
p = model.objects.first()
|
||||
self.assertIsNone(p.metadata)
|
||||
self.assertEqual(len(p.metadata.keys()), 0)
|
||||
|
||||
self.assertIsNone(p.get_metadata('test'))
|
||||
self.assertEqual(p.get_metadata('test', backup_value=123), 123)
|
||||
@@ -631,7 +668,7 @@ class AutoAllocationTests(BuildTestBase):
|
||||
# No build item allocations have been made against the build
|
||||
self.assertEqual(self.build.allocated_stock.count(), 0)
|
||||
|
||||
self.assertFalse(self.build.are_untracked_parts_allocated())
|
||||
self.assertFalse(self.build.is_fully_allocated(tracked=False))
|
||||
|
||||
# Stock is not interchangeable, nothing will happen
|
||||
self.build.auto_allocate_stock(
|
||||
@@ -639,15 +676,15 @@ class AutoAllocationTests(BuildTestBase):
|
||||
substitutes=False,
|
||||
)
|
||||
|
||||
self.assertFalse(self.build.are_untracked_parts_allocated())
|
||||
self.assertFalse(self.build.is_fully_allocated(tracked=False))
|
||||
|
||||
self.assertEqual(self.build.allocated_stock.count(), 0)
|
||||
|
||||
self.assertFalse(self.build.is_bom_item_allocated(self.bom_item_1))
|
||||
self.assertFalse(self.build.is_bom_item_allocated(self.bom_item_2))
|
||||
self.assertFalse(self.line_1.is_fully_allocated())
|
||||
self.assertFalse(self.line_2.is_fully_allocated())
|
||||
|
||||
self.assertEqual(self.build.unallocated_quantity(self.bom_item_1), 50)
|
||||
self.assertEqual(self.build.unallocated_quantity(self.bom_item_2), 30)
|
||||
self.assertEqual(self.line_1.unallocated_quantity(), 50)
|
||||
self.assertEqual(self.line_2.unallocated_quantity(), 30)
|
||||
|
||||
# This time we expect stock to be allocated!
|
||||
self.build.auto_allocate_stock(
|
||||
@@ -656,28 +693,27 @@ class AutoAllocationTests(BuildTestBase):
|
||||
optional_items=True,
|
||||
)
|
||||
|
||||
self.assertFalse(self.build.are_untracked_parts_allocated())
|
||||
self.assertFalse(self.build.is_fully_allocated(tracked=False))
|
||||
|
||||
self.assertEqual(self.build.allocated_stock.count(), 7)
|
||||
|
||||
self.assertTrue(self.build.is_bom_item_allocated(self.bom_item_1))
|
||||
self.assertFalse(self.build.is_bom_item_allocated(self.bom_item_2))
|
||||
self.assertTrue(self.line_1.is_fully_allocated())
|
||||
self.assertFalse(self.line_2.is_fully_allocated())
|
||||
|
||||
self.assertEqual(self.build.unallocated_quantity(self.bom_item_1), 0)
|
||||
self.assertEqual(self.build.unallocated_quantity(self.bom_item_2), 5)
|
||||
self.assertEqual(self.line_1.unallocated_quantity(), 0)
|
||||
self.assertEqual(self.line_2.unallocated_quantity(), 5)
|
||||
|
||||
# This time, allow substitue parts to be used!
|
||||
# This time, allow substitute parts to be used!
|
||||
self.build.auto_allocate_stock(
|
||||
interchangeable=True,
|
||||
substitutes=True,
|
||||
)
|
||||
|
||||
# self.assertEqual(self.build.allocated_stock.count(), 8)
|
||||
self.assertEqual(self.build.unallocated_quantity(self.bom_item_1), 0)
|
||||
self.assertEqual(self.build.unallocated_quantity(self.bom_item_2), 5.0)
|
||||
self.assertEqual(self.line_1.unallocated_quantity(), 0)
|
||||
self.assertEqual(self.line_2.unallocated_quantity(), 5)
|
||||
|
||||
self.assertTrue(self.build.is_bom_item_allocated(self.bom_item_1))
|
||||
self.assertFalse(self.build.is_bom_item_allocated(self.bom_item_2))
|
||||
self.assertTrue(self.line_1.is_fully_allocated())
|
||||
self.assertFalse(self.line_2.is_fully_allocated())
|
||||
|
||||
def test_fully_auto(self):
|
||||
"""We should be able to auto-allocate against a build in a single go"""
|
||||
@@ -688,7 +724,7 @@ class AutoAllocationTests(BuildTestBase):
|
||||
optional_items=True,
|
||||
)
|
||||
|
||||
self.assertTrue(self.build.are_untracked_parts_allocated())
|
||||
self.assertTrue(self.build.is_fully_allocated(tracked=False))
|
||||
|
||||
self.assertEqual(self.build.unallocated_quantity(self.bom_item_1), 0)
|
||||
self.assertEqual(self.build.unallocated_quantity(self.bom_item_2), 0)
|
||||
self.assertEqual(self.line_1.unallocated_quantity(), 0)
|
||||
self.assertEqual(self.line_2.unallocated_quantity(), 0)
|
||||
|
||||
@@ -2,14 +2,14 @@
|
||||
|
||||
from django_test_migrations.contrib.unittest_case import MigratorTestCase
|
||||
|
||||
from InvenTree import helpers
|
||||
from InvenTree import unit_test
|
||||
|
||||
|
||||
class TestForwardMigrations(MigratorTestCase):
|
||||
"""Test entire schema migration sequence for the build app."""
|
||||
|
||||
migrate_from = ('build', helpers.getOldestMigrationFile('build'))
|
||||
migrate_to = ('build', helpers.getNewestMigrationFile('build'))
|
||||
migrate_from = ('build', unit_test.getOldestMigrationFile('build'))
|
||||
migrate_to = ('build', unit_test.getNewestMigrationFile('build'))
|
||||
|
||||
def prepare(self):
|
||||
"""Create initial data!"""
|
||||
@@ -19,22 +19,15 @@ class TestForwardMigrations(MigratorTestCase):
|
||||
name='Widget',
|
||||
description='Buildable Part',
|
||||
active=True,
|
||||
level=0, lft=0, rght=0, tree_id=0,
|
||||
)
|
||||
|
||||
with self.assertRaises(TypeError):
|
||||
# Cannot set the 'assembly' field as it hasn't been added to the db schema
|
||||
Part.objects.create(
|
||||
name='Blorb',
|
||||
description='ABCDE',
|
||||
assembly=True
|
||||
)
|
||||
|
||||
Build = self.old_state.apps.get_model('build', 'build')
|
||||
|
||||
Build.objects.create(
|
||||
part=buildable_part,
|
||||
title='A build of some stuff',
|
||||
quantity=50
|
||||
quantity=50,
|
||||
)
|
||||
|
||||
def test_items_exist(self):
|
||||
@@ -58,7 +51,7 @@ class TestForwardMigrations(MigratorTestCase):
|
||||
class TestReferenceMigration(MigratorTestCase):
|
||||
"""Test custom migration which adds 'reference' field to Build model."""
|
||||
|
||||
migrate_from = ('build', helpers.getOldestMigrationFile('build'))
|
||||
migrate_from = ('build', unit_test.getOldestMigrationFile('build'))
|
||||
migrate_to = ('build', '0018_build_reference')
|
||||
|
||||
def prepare(self):
|
||||
@@ -67,7 +60,8 @@ class TestReferenceMigration(MigratorTestCase):
|
||||
|
||||
part = Part.objects.create(
|
||||
name='Part',
|
||||
description='A test part'
|
||||
description='A test part',
|
||||
level=0, lft=0, rght=0, tree_id=0,
|
||||
)
|
||||
|
||||
Build = self.old_state.apps.get_model('build', 'build')
|
||||
@@ -113,7 +107,7 @@ class TestReferencePatternMigration(MigratorTestCase):
|
||||
"""
|
||||
|
||||
migrate_from = ('build', '0019_auto_20201019_1302')
|
||||
migrate_to = ('build', helpers.getNewestMigrationFile('build'))
|
||||
migrate_to = ('build', unit_test.getNewestMigrationFile('build'))
|
||||
|
||||
def prepare(self):
|
||||
"""Create some initial data prior to migration"""
|
||||
@@ -158,3 +152,139 @@ class TestReferencePatternMigration(MigratorTestCase):
|
||||
pattern = Setting.objects.get(key='BUILDORDER_REFERENCE_PATTERN')
|
||||
|
||||
self.assertEqual(pattern.value, 'BuildOrder-{ref:04d}')
|
||||
|
||||
|
||||
class TestBuildLineCreation(MigratorTestCase):
|
||||
"""Test that build lines are correctly created for existing builds.
|
||||
|
||||
Ref: https://github.com/inventree/InvenTree/pull/4855
|
||||
|
||||
This PR added the 'BuildLine' model, which acts as a link between a Build and a BomItem.
|
||||
|
||||
- Migration 0044 creates BuildLine objects for existing builds.
|
||||
- Migration 0046 links any existing BuildItem objects to corresponding BuildLine
|
||||
"""
|
||||
|
||||
migrate_from = ('build', '0041_alter_build_title')
|
||||
migrate_to = ('build', '0047_auto_20230606_1058')
|
||||
|
||||
def prepare(self):
|
||||
"""Create data to work with"""
|
||||
|
||||
# Model references
|
||||
Part = self.old_state.apps.get_model('part', 'part')
|
||||
BomItem = self.old_state.apps.get_model('part', 'bomitem')
|
||||
Build = self.old_state.apps.get_model('build', 'build')
|
||||
BuildItem = self.old_state.apps.get_model('build', 'builditem')
|
||||
StockItem = self.old_state.apps.get_model('stock', 'stockitem')
|
||||
|
||||
# The "BuildLine" model does not exist yet
|
||||
with self.assertRaises(LookupError):
|
||||
self.old_state.apps.get_model('build', 'buildline')
|
||||
|
||||
# Create a part
|
||||
assembly = Part.objects.create(
|
||||
name='Assembly',
|
||||
description='An assembly',
|
||||
assembly=True,
|
||||
level=0, lft=0, rght=0, tree_id=0,
|
||||
)
|
||||
|
||||
# Create components
|
||||
for idx in range(1, 11):
|
||||
part = Part.objects.create(
|
||||
name=f"Part {idx}",
|
||||
description=f"Part {idx}",
|
||||
level=0, lft=0, rght=0, tree_id=0,
|
||||
)
|
||||
|
||||
# Create plentiful stock
|
||||
StockItem.objects.create(
|
||||
part=part,
|
||||
quantity=1000,
|
||||
level=0, lft=0, rght=0, tree_id=0,
|
||||
)
|
||||
|
||||
# Create a BOM item
|
||||
BomItem.objects.create(
|
||||
part=assembly,
|
||||
sub_part=part,
|
||||
quantity=idx,
|
||||
reference=f"REF-{idx}",
|
||||
)
|
||||
|
||||
# Create some builds
|
||||
for idx in range(1, 4):
|
||||
build = Build.objects.create(
|
||||
part=assembly,
|
||||
title=f"Build {idx}",
|
||||
quantity=idx * 10,
|
||||
reference=f"REF-{idx}",
|
||||
level=0, lft=0, rght=0, tree_id=0,
|
||||
)
|
||||
|
||||
# Allocate stock to the build
|
||||
for bom_item in BomItem.objects.all():
|
||||
stock_item = StockItem.objects.get(part=bom_item.sub_part)
|
||||
BuildItem.objects.create(
|
||||
build=build,
|
||||
bom_item=bom_item,
|
||||
stock_item=stock_item,
|
||||
quantity=bom_item.quantity,
|
||||
)
|
||||
|
||||
def test_build_line_creation(self):
|
||||
"""Test that the BuildLine objects have been created correctly"""
|
||||
|
||||
Build = self.new_state.apps.get_model('build', 'build')
|
||||
BomItem = self.new_state.apps.get_model('part', 'bomitem')
|
||||
BuildLine = self.new_state.apps.get_model('build', 'buildline')
|
||||
BuildItem = self.new_state.apps.get_model('build', 'builditem')
|
||||
StockItem = self.new_state.apps.get_model('stock', 'stockitem')
|
||||
|
||||
# There should be 3x builds
|
||||
self.assertEqual(Build.objects.count(), 3)
|
||||
|
||||
# 10x BOMItem objects
|
||||
self.assertEqual(BomItem.objects.count(), 10)
|
||||
|
||||
# 10x StockItem objects
|
||||
self.assertEqual(StockItem.objects.count(), 10)
|
||||
|
||||
# And 30x BuildLine items (1 for each BomItem for each Build)
|
||||
self.assertEqual(BuildLine.objects.count(), 30)
|
||||
|
||||
# And 30x BuildItem objects (1 for each BomItem for each Build)
|
||||
self.assertEqual(BuildItem.objects.count(), 30)
|
||||
|
||||
# Check that each BuildItem has been linked to a BuildLine
|
||||
for item in BuildItem.objects.all():
|
||||
self.assertIsNotNone(item.build_line)
|
||||
self.assertEqual(
|
||||
item.stock_item.part,
|
||||
item.build_line.bom_item.sub_part,
|
||||
)
|
||||
|
||||
item = BuildItem.objects.first()
|
||||
|
||||
# Check that the "build" field has been removed
|
||||
with self.assertRaises(AttributeError):
|
||||
item.build
|
||||
|
||||
# Check that the "bom_item" field has been removed
|
||||
with self.assertRaises(AttributeError):
|
||||
item.bom_item
|
||||
|
||||
# Check that each BuildLine is correctly configured
|
||||
for line in BuildLine.objects.all():
|
||||
# Check that the quantity is correct
|
||||
self.assertEqual(
|
||||
line.quantity,
|
||||
line.build.quantity * line.bom_item.quantity,
|
||||
)
|
||||
|
||||
# Check that the linked parts are correct
|
||||
self.assertEqual(
|
||||
line.build.part,
|
||||
line.bom_item.part,
|
||||
)
|
||||
|
||||
@@ -4,7 +4,7 @@ from django.urls import reverse
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from InvenTree.helpers import InvenTreeTestCase
|
||||
from InvenTree.unit_test import InvenTreeTestCase
|
||||
|
||||
from .models import Build
|
||||
from stock.models import StockItem
|
||||
|
||||
@@ -39,7 +39,5 @@ class BuildDetail(InvenTreeRoleMixin, InvenTreePluginViewMixin, DetailView):
|
||||
part = build.part
|
||||
|
||||
ctx['part'] = part
|
||||
ctx['has_tracked_bom_items'] = build.has_tracked_bom_items()
|
||||
ctx['has_untracked_bom_items'] = build.has_untracked_bom_items()
|
||||
|
||||
return ctx
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import json
|
||||
|
||||
from django.conf import settings
|
||||
from django.http.response import HttpResponse
|
||||
from django.urls import include, path, re_path
|
||||
from django.utils.decorators import method_decorator
|
||||
@@ -17,13 +18,13 @@ from rest_framework.views import APIView
|
||||
|
||||
import common.models
|
||||
import common.serializers
|
||||
from InvenTree.api import BulkDeleteMixin
|
||||
from InvenTree.api import BulkDeleteMixin, MetadataView
|
||||
from InvenTree.config import CONFIG_LOOKUPS
|
||||
from InvenTree.filters import ORDER_FILTER, SEARCH_ORDER_FILTER
|
||||
from InvenTree.helpers import inheritors
|
||||
from InvenTree.mixins import (ListAPI, RetrieveAPI, RetrieveUpdateAPI,
|
||||
RetrieveUpdateDestroyAPI)
|
||||
from InvenTree.permissions import IsSuperuser
|
||||
from InvenTree.mixins import (ListAPI, ListCreateAPI, RetrieveAPI,
|
||||
RetrieveUpdateAPI, RetrieveUpdateDestroyAPI)
|
||||
from InvenTree.permissions import IsStaffOrReadOnly, IsSuperuser
|
||||
from plugin.models import NotificationUserSetting
|
||||
from plugin.serializers import NotificationUserSettingSerializer
|
||||
|
||||
@@ -45,7 +46,7 @@ class WebhookView(CsrfExemptMixin, APIView):
|
||||
run_async = False
|
||||
|
||||
def post(self, request, endpoint, *args, **kwargs):
|
||||
"""Process incomming webhook."""
|
||||
"""Process incoming webhook."""
|
||||
# get webhook definition
|
||||
self._get_webhook(endpoint, request, *args, **kwargs)
|
||||
|
||||
@@ -121,8 +122,13 @@ class CurrencyExchangeView(APIView):
|
||||
|
||||
# Information on last update
|
||||
try:
|
||||
backend = ExchangeBackend.objects.get(name='InvenTreeExchange')
|
||||
updated = backend.last_update
|
||||
backend = ExchangeBackend.objects.filter(name='InvenTreeExchange')
|
||||
|
||||
if backend.exists():
|
||||
backend = backend.first()
|
||||
updated = backend.last_update
|
||||
else:
|
||||
updated = None
|
||||
except Exception:
|
||||
updated = None
|
||||
|
||||
@@ -164,7 +170,7 @@ class CurrencyRefreshView(APIView):
|
||||
class SettingsList(ListAPI):
|
||||
"""Generic ListView for settings.
|
||||
|
||||
This is inheritted by all list views for settings.
|
||||
This is inherited by all list views for settings.
|
||||
"""
|
||||
|
||||
filter_backends = SEARCH_ORDER_FILTER
|
||||
@@ -440,6 +446,69 @@ class ConfigDetail(RetrieveAPI):
|
||||
return {key: value}
|
||||
|
||||
|
||||
class NotesImageList(ListCreateAPI):
|
||||
"""List view for all notes images."""
|
||||
|
||||
queryset = common.models.NotesImage.objects.all()
|
||||
serializer_class = common.serializers.NotesImageSerializer
|
||||
permission_classes = [permissions.IsAuthenticated, ]
|
||||
|
||||
def perform_create(self, serializer):
|
||||
"""Create (upload) a new notes image"""
|
||||
image = serializer.save()
|
||||
image.user = self.request.user
|
||||
image.save()
|
||||
|
||||
|
||||
class ProjectCodeList(ListCreateAPI):
|
||||
"""List view for all project codes."""
|
||||
|
||||
queryset = common.models.ProjectCode.objects.all()
|
||||
serializer_class = common.serializers.ProjectCodeSerializer
|
||||
permission_classes = [permissions.IsAuthenticated, IsStaffOrReadOnly]
|
||||
filter_backends = SEARCH_ORDER_FILTER
|
||||
|
||||
ordering_fields = [
|
||||
'code',
|
||||
]
|
||||
|
||||
search_fields = [
|
||||
'code',
|
||||
'description',
|
||||
]
|
||||
|
||||
|
||||
class ProjectCodeDetail(RetrieveUpdateDestroyAPI):
|
||||
"""Detail view for a particular project code"""
|
||||
|
||||
queryset = common.models.ProjectCode.objects.all()
|
||||
serializer_class = common.serializers.ProjectCodeSerializer
|
||||
permission_classes = [permissions.IsAuthenticated, IsStaffOrReadOnly]
|
||||
|
||||
|
||||
class FlagList(ListAPI):
|
||||
"""List view for feature flags."""
|
||||
|
||||
queryset = settings.FLAGS
|
||||
serializer_class = common.serializers.FlagSerializer
|
||||
permission_classes = [permissions.AllowAny, ]
|
||||
|
||||
|
||||
class FlagDetail(RetrieveAPI):
|
||||
"""Detail view for an individual feature flag."""
|
||||
|
||||
serializer_class = common.serializers.FlagSerializer
|
||||
permission_classes = [permissions.AllowAny, ]
|
||||
|
||||
def get_object(self):
|
||||
"""Attempt to find a config object with the provided key."""
|
||||
key = self.kwargs['key']
|
||||
value = settings.FLAGS.get(key, None)
|
||||
if not value:
|
||||
raise NotFound()
|
||||
return {key: value}
|
||||
|
||||
|
||||
settings_api_urls = [
|
||||
# User settings
|
||||
re_path(r'^user/', include([
|
||||
@@ -456,7 +525,7 @@ settings_api_urls = [
|
||||
path(r'<int:pk>/', NotificationUserSettingsDetail.as_view(), name='api-notification-setting-detail'),
|
||||
|
||||
# Notification Settings List
|
||||
re_path(r'^.*$', NotificationUserSettingsList.as_view(), name='api-notifcation-setting-list'),
|
||||
re_path(r'^.*$', NotificationUserSettingsList.as_view(), name='api-notification-setting-list'),
|
||||
])),
|
||||
|
||||
# Global settings
|
||||
@@ -473,6 +542,18 @@ common_api_urls = [
|
||||
# Webhooks
|
||||
path('webhook/<slug:endpoint>/', WebhookView.as_view(), name='api-webhook'),
|
||||
|
||||
# Uploaded images for notes
|
||||
re_path(r'^notes-image-upload/', NotesImageList.as_view(), name='api-notes-image-list'),
|
||||
|
||||
# Project codes
|
||||
re_path(r'^project-code/', include([
|
||||
path(r'<int:pk>/', include([
|
||||
re_path(r'^metadata/', MetadataView.as_view(), {'model': common.models.ProjectCode}, name='api-project-code-metadata'),
|
||||
re_path(r'^.*$', ProjectCodeDetail.as_view(), name='api-project-code-detail'),
|
||||
])),
|
||||
re_path(r'^.*$', ProjectCodeList.as_view(), name='api-project-code-list'),
|
||||
])),
|
||||
|
||||
# Currencies
|
||||
re_path(r'^currency/', include([
|
||||
re_path(r'^exchange/', CurrencyExchangeView.as_view(), name='api-currency-exchange'),
|
||||
@@ -500,6 +581,11 @@ common_api_urls = [
|
||||
re_path(r'^.*$', NewsFeedEntryList.as_view(), name='api-news-list'),
|
||||
])),
|
||||
|
||||
# Flags
|
||||
path('flags/', include([
|
||||
path('<str:key>/', FlagDetail.as_view(), name='api-flag-detail'),
|
||||
re_path(r'^.*$', FlagList.as_view(), name='api-flag-list'),
|
||||
])),
|
||||
]
|
||||
|
||||
admin_api_urls = [
|
||||
|
||||
@@ -59,7 +59,8 @@ class FileManager:
|
||||
# Reset stream position to beginning of file
|
||||
file.seek(0)
|
||||
else:
|
||||
raise ValidationError(_(f'Unsupported file format: {ext.upper()}'))
|
||||
fmt = ext.upper()
|
||||
raise ValidationError(_(f'Unsupported file format: {fmt}'))
|
||||
except UnicodeEncodeError:
|
||||
raise ValidationError(_('Error reading file (invalid encoding)'))
|
||||
|
||||
@@ -83,7 +84,7 @@ class FileManager:
|
||||
self.HEADERS = self.REQUIRED_HEADERS + self.ITEM_MATCH_HEADERS + self.OPTIONAL_MATCH_HEADERS + self.OPTIONAL_HEADERS
|
||||
|
||||
def setup(self):
|
||||
"""Setup headers should be overriden in usage to set the Different Headers."""
|
||||
"""Setup headers should be overridden in usage to set the Different Headers."""
|
||||
if not self.name:
|
||||
return
|
||||
|
||||
@@ -180,7 +181,7 @@ class FileManager:
|
||||
|
||||
for i in range(self.row_count()):
|
||||
|
||||
data = [item for item in self.get_row_data(i)]
|
||||
data = list(self.get_row_data(i))
|
||||
|
||||
# Is the row completely empty? Skip!
|
||||
empty = True
|
||||
|
||||
@@ -46,7 +46,7 @@ class MatchFieldForm(forms.Form):
|
||||
"""Step 2 of FileManagementFormView."""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
"""Setup filemanager and check columsn."""
|
||||
"""Setup filemanager and check columns."""
|
||||
# Get FileManager
|
||||
file_manager = None
|
||||
if 'file_manager' in kwargs:
|
||||
@@ -106,7 +106,7 @@ class MatchItemForm(forms.Form):
|
||||
# Set field name
|
||||
field_name = col_guess.lower() + '-' + str(row['index'])
|
||||
|
||||
# check if field def was overriden
|
||||
# check if field def was overridden
|
||||
overriden_field = self.get_special_field(col_guess, row, file_manager)
|
||||
if overriden_field:
|
||||
self.fields[field_name] = overriden_field
|
||||
@@ -174,5 +174,5 @@ class MatchItemForm(forms.Form):
|
||||
)
|
||||
|
||||
def get_special_field(self, col_guess, row, file_manager):
|
||||
"""Function to be overriden in inherited forms to add specific form settings."""
|
||||
"""Function to be overridden in inherited forms to add specific form settings."""
|
||||
return None
|
||||
|
||||
@@ -4,15 +4,40 @@ import django.core.validators
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class CreateModelOrSkip(migrations.CreateModel):
|
||||
"""Custom migration operation to create a model if it does not already exist.
|
||||
|
||||
- If the model already exists, the migration is skipped
|
||||
- This class has been added to deal with some errors being thrown in CI tests
|
||||
- The 'common_currency' table doesn't exist anymore anyway!
|
||||
- In the future, these migrations will be squashed
|
||||
"""
|
||||
|
||||
def database_forwards(self, app_label, schema_editor, from_state, to_state) -> None:
|
||||
"""Forwards migration *attempts* to create the model, but will fail gracefully if it already exists"""
|
||||
|
||||
try:
|
||||
super().database_forwards(app_label, schema_editor, from_state, to_state)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def state_forwards(self, app_label, state) -> None:
|
||||
try:
|
||||
super().state_forwards(app_label, state)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
atomic = False
|
||||
|
||||
dependencies = [
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
CreateModelOrSkip(
|
||||
name='Currency',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
|
||||
@@ -8,6 +8,16 @@ def set_default_currency(apps, schema_editor):
|
||||
""" migrate the currency setting from config.yml to db """
|
||||
# get value from settings-file
|
||||
base_currency = get_setting('INVENTREE_BASE_CURRENCY', 'base_currency', 'USD')
|
||||
|
||||
from common.settings import currency_codes
|
||||
|
||||
# check if value is valid
|
||||
if base_currency not in currency_codes():
|
||||
if len (currency_codes()) > 0:
|
||||
base_currency = currency_codes()[0]
|
||||
else:
|
||||
base_currency = 'USD'
|
||||
|
||||
# write to database
|
||||
InvenTreeSetting.set_setting('INVENTREE_DEFAULT_CURRENCY', base_currency, None, create=True)
|
||||
|
||||
|
||||
27
InvenTree/common/migrations/0017_notesimage.py
Normal file
27
InvenTree/common/migrations/0017_notesimage.py
Normal file
@@ -0,0 +1,27 @@
|
||||
# Generated by Django 3.2.18 on 2023-04-17 05:55
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
import common.models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
('common', '0016_alter_notificationentry_updated'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='NotesImage',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('image', models.ImageField(help_text='Image file', upload_to=common.models.rename_notes_image, verbose_name='Image')),
|
||||
('date', models.DateTimeField(auto_now_add=True)),
|
||||
('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
),
|
||||
]
|
||||
21
InvenTree/common/migrations/0018_projectcode.py
Normal file
21
InvenTree/common/migrations/0018_projectcode.py
Normal file
@@ -0,0 +1,21 @@
|
||||
# Generated by Django 3.2.18 on 2023-04-19 02:06
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('common', '0017_notesimage'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='ProjectCode',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('code', models.CharField(help_text='Unique project code', max_length=50, unique=True, verbose_name='Project Code')),
|
||||
('description', models.CharField(blank=True, help_text='Project description', max_length=200, verbose_name='Description')),
|
||||
],
|
||||
),
|
||||
]
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user