Merge branch 'master' of https://github.com/inventree/InvenTree into pr/ChristianSchindler/6305

This commit is contained in:
Matthias Mair 2024-06-27 00:27:59 +02:00
commit 27de080dc6
No known key found for this signature in database
GPG Key ID: A593429DDA23B66A
283 changed files with 110789 additions and 83578 deletions

View File

@ -13,5 +13,5 @@ runs:
invoke export-records -f data.json
python3 ./src/backend/InvenTree/manage.py flush --noinput
invoke migrate
invoke import-records -f data.json
invoke import-records -f data.json
invoke import-records -c -f data.json
invoke import-records -c -f data.json

View File

@ -44,6 +44,11 @@ runs:
with:
python-version: ${{ env.python_version }}
cache: pip
cache-dependency-path: |
src/backend/requirements.txt
src/backend/requirements-dev.txt
contrib/container/requirements.txt
contrib/dev_reqs/requirements.txt
- name: Install Base Python Dependencies
if: ${{ inputs.python == 'true' }}
shell: bash

View File

@ -30,7 +30,7 @@ jobs:
steps:
- name: Checkout Code
uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29 # pin@v4.1.6
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # pin@v4.1.7
- name: Environment Setup
uses: ./.github/actions/setup
with:

View File

@ -39,7 +39,7 @@ jobs:
docker: ${{ steps.filter.outputs.docker }}
steps:
- uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29 # pin@v4.1.6
- uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # pin@v4.1.7
- uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # pin@v3.0.2
id: filter
with:
@ -66,7 +66,7 @@ jobs:
steps:
- name: Check out repo
uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29 # pin@v4.1.6
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # pin@v4.1.7
- name: Set Up Python ${{ env.python_version }}
uses: actions/setup-python@82c7e631bb3cdc910f68e0081d67478d79c6982d # pin@v5.1.0
with:
@ -115,9 +115,10 @@ jobs:
- name: Run Unit Tests
run: |
echo "GITHUB_TOKEN=${{ secrets.GITHUB_TOKEN }}" >> contrib/container/docker.dev.env
docker compose --project-directory . -f contrib/container/dev-docker-compose.yml run inventree-dev-server invoke test --disable-pty
docker compose --project-directory . -f contrib/container/dev-docker-compose.yml run inventree-dev-server invoke test --migrations --disable-pty
docker compose --project-directory . -f contrib/container/dev-docker-compose.yml down
docker compose --project-directory . -f contrib/container/dev-docker-compose.yml run --rm inventree-dev-server invoke test --disable-pty
- name: Run Migration Tests
run: |
docker compose --project-directory . -f contrib/container/dev-docker-compose.yml run --rm inventree-dev-server invoke test --migrations
- name: Clean up test folder
run: |
rm -rf InvenTree/_testfolder
@ -140,14 +141,14 @@ jobs:
fi
- name: Login to Dockerhub
if: github.event_name != 'pull_request' && steps.docker_login.outputs.skip_dockerhub_login != 'true'
uses: docker/login-action@e92390c5fb421da1463c202d546fed0ec5c39f20 # pin@v3.1.0
uses: docker/login-action@0d4c9c5ea7693da7b068278f7b52bda2a190a446 # pin@v3.2.0
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Log into registry ghcr.io
if: github.event_name != 'pull_request'
uses: docker/login-action@e92390c5fb421da1463c202d546fed0ec5c39f20 # pin@v3.1.0
uses: docker/login-action@0d4c9c5ea7693da7b068278f7b52bda2a190a446 # pin@v3.2.0
with:
registry: ghcr.io
username: ${{ github.actor }}
@ -165,7 +166,7 @@ jobs:
- name: Push Docker Images
id: push-docker
if: github.event_name != 'pull_request'
uses: docker/build-push-action@2cdde995de11925a030ce8070c3d77a52ffcf1c0 # pin@v5.3.0
uses: docker/build-push-action@31159d49c0d4756269a0940a750801a1ea5d7003 # pin@v6.1.0
with:
context: .
file: ./contrib/container/Dockerfile

View File

@ -13,8 +13,6 @@ env:
node_version: 18
# The OS version must be set per job
server_start_sleep: 60
requests_version: 2.31.0
pyyaml_version: 6.0.1
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
INVENTREE_DB_ENGINE: sqlite3
@ -40,7 +38,7 @@ jobs:
force: ${{ steps.force.outputs.force }}
steps:
- uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29 # pin@v4.1.6
- uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # pin@v4.1.7
- uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # pin@v3.0.2
id: filter
with:
@ -72,7 +70,7 @@ jobs:
needs: ["pre-commit"]
steps:
- uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29 # pin@v4.1.6
- uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # pin@v4.1.7
- name: Environment Setup
uses: ./.github/actions/setup
with:
@ -94,7 +92,7 @@ jobs:
if: needs.paths-filter.outputs.server == 'true' || needs.paths-filter.outputs.frontend == 'true' || needs.paths-filter.outputs.force == 'true'
steps:
- uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29 # pin@v4.1.6
- uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # pin@v4.1.7
- name: Set up Python ${{ env.python_version }}
uses: actions/setup-python@82c7e631bb3cdc910f68e0081d67478d79c6982d # pin@v5.1.0
with:
@ -115,7 +113,7 @@ jobs:
steps:
- name: Checkout Code
uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29 # pin@v4.1.6
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # pin@v4.1.7
- name: Set up Python ${{ env.python_version }}
uses: actions/setup-python@82c7e631bb3cdc910f68e0081d67478d79c6982d # pin@v5.1.0
with:
@ -151,7 +149,7 @@ jobs:
version: ${{ steps.version.outputs.version }}
steps:
- uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29 # pin@v4.1.6
- uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # pin@v4.1.7
- name: Environment Setup
uses: ./.github/actions/setup
with:
@ -201,7 +199,7 @@ jobs:
version: ${{ needs.schema.outputs.version }}
steps:
- uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29 # v4.1.6
- uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
with:
repository: inventree/schema
token: ${{ secrets.SCHEMA_PAT }}
@ -238,7 +236,7 @@ jobs:
INVENTREE_SITE_URL: http://127.0.0.1:12345
steps:
- uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29 # pin@v4.1.6
- uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # pin@v4.1.7
- name: Environment Setup
uses: ./.github/actions/setup
with:
@ -279,7 +277,7 @@ jobs:
python_version: ${{ matrix.python_version }}
steps:
- uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29 # pin@v4.1.6
- uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # pin@v4.1.7
- name: Environment Setup
uses: ./.github/actions/setup
with:
@ -295,7 +293,7 @@ jobs:
- name: Coverage Tests
run: invoke test --coverage
- name: Upload coverage reports to Codecov
uses: codecov/codecov-action@125fc84a9a348dbcf27191600683ec096ec9021c # pin@v4.4.1
uses: codecov/codecov-action@e28ff129e5465c2c0dcc6f003fc735cb6ae0c673 # pin@v4.5.0
if: always()
with:
token: ${{ secrets.CODECOV_TOKEN }}
@ -333,7 +331,7 @@ jobs:
- 6379:6379
steps:
- uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29 # pin@v4.1.6
- uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # pin@v4.1.7
- name: Environment Setup
uses: ./.github/actions/setup
with:
@ -377,7 +375,7 @@ jobs:
- 3306:3306
steps:
- uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29 # pin@v4.1.6
- uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # pin@v4.1.7
- name: Environment Setup
uses: ./.github/actions/setup
with:
@ -416,7 +414,7 @@ jobs:
- 5432:5432
steps:
- uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29 # pin@v4.1.6
- uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # pin@v4.1.7
- name: Environment Setup
uses: ./.github/actions/setup
with:
@ -427,7 +425,7 @@ jobs:
- name: Run Tests
run: invoke test --migrations --report --coverage
- name: Upload coverage reports to Codecov
uses: codecov/codecov-action@125fc84a9a348dbcf27191600683ec096ec9021c # pin@v4.4.1
uses: codecov/codecov-action@e28ff129e5465c2c0dcc6f003fc735cb6ae0c673 # pin@v4.5.0
if: always()
with:
token: ${{ secrets.CODECOV_TOKEN }}
@ -447,7 +445,7 @@ jobs:
INVENTREE_PLUGINS_ENABLED: false
steps:
- uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29 # pin@v4.1.6
- uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # pin@v4.1.7
name: Checkout Code
- name: Environment Setup
uses: ./.github/actions/setup
@ -504,7 +502,7 @@ jobs:
VITE_COVERAGE: true
steps:
- uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29 # pin@v4.1.6
- uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # pin@v4.1.7
- name: Environment Setup
uses: ./.github/actions/setup
with:
@ -532,7 +530,7 @@ jobs:
if: always()
run: cd src/frontend && npx nyc report --report-dir ./coverage --temp-dir .nyc_output --reporter=lcov --exclude-after-remap false
- name: Upload coverage reports to Codecov
uses: codecov/codecov-action@125fc84a9a348dbcf27191600683ec096ec9021c # pin@v4.4.1
uses: codecov/codecov-action@e28ff129e5465c2c0dcc6f003fc735cb6ae0c673 # pin@v4.5.0
if: always()
with:
token: ${{ secrets.CODECOV_TOKEN }}
@ -545,7 +543,7 @@ jobs:
timeout-minutes: 60
steps:
- uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29 # pin@v4.1.6
- uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # pin@v4.1.7
- name: Environment Setup
uses: ./.github/actions/setup
with:

View File

@ -15,7 +15,7 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
steps:
- name: Checkout Code
uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29 # pin@v4.1.6
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # pin@v4.1.7
- name: Version Check
run: |
pip install --require-hashes -r contrib/dev_reqs/requirements.txt
@ -34,7 +34,7 @@ jobs:
contents: write
pull-requests: write
steps:
- uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29 # pin@v4.1.6
- uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # pin@v4.1.7
- name: Environment Setup
uses: ./.github/actions/setup
with:
@ -46,7 +46,7 @@ jobs:
- name: Zip frontend
run: |
cd src/backend/InvenTree/web/static/web
zip -r ../frontend-build.zip *
zip -r ../frontend-build.zip * .vite
- uses: svenstaro/upload-release-action@04733e069f2d7f7f0b4aebc4fbdbce8613b03ccd # pin@2.9.0
with:
repo_token: ${{ secrets.GITHUB_TOKEN }}

View File

@ -32,7 +32,7 @@ jobs:
steps:
- name: "Checkout code"
uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29 # v4.1.6
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
with:
persist-credentials: false
@ -67,6 +67,6 @@ jobs:
# Upload the results to GitHub's code scanning dashboard.
- name: "Upload to code-scanning"
uses: github/codeql-action/upload-sarif@9fdb3e49720b44c48891d036bb502feb25684276 # v3.25.6
uses: github/codeql-action/upload-sarif@23acc5c183826b7a8a97bce3cecc52db901f8251 # v3.25.10
with:
sarif_file: results.sarif

View File

@ -30,7 +30,7 @@ jobs:
steps:
- name: Checkout Code
uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29 # pin@v4.1.6
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # pin@v4.1.7
- name: Environment Setup
uses: ./.github/actions/setup
with:

View File

@ -31,23 +31,23 @@ repos:
hooks:
- id: pip-compile
name: pip-compile requirements-dev.in
args: [src/backend/requirements-dev.in, -o, src/backend/requirements-dev.txt, --python-version=3.9, --no-strip-extras, --generate-hashes]
args: [src/backend/requirements-dev.in, -o, src/backend/requirements-dev.txt]
files: src/backend/requirements-dev\.(in|txt)$
- id: pip-compile
name: pip-compile requirements.txt
args: [src/backend/requirements.in, -o, src/backend/requirements.txt,--python-version=3.9, --no-strip-extras,--generate-hashes]
args: [src/backend/requirements.in, -o, src/backend/requirements.txt]
files: src/backend/requirements\.(in|txt)$
- id: pip-compile
name: pip-compile requirements.txt
args: [contrib/dev_reqs/requirements.in, -o, contrib/dev_reqs/requirements.txt,--python-version=3.9, --no-strip-extras, --generate-hashes]
args: [contrib/dev_reqs/requirements.in, -o, contrib/dev_reqs/requirements.txt]
files: contrib/dev_reqs/requirements\.(in|txt)$
- id: pip-compile
name: pip-compile requirements.txt
args: [docs/requirements.in, -o, docs/requirements.txt,--python-version=3.9, --no-strip-extras, --generate-hashes]
args: [docs/requirements.in, -o, docs/requirements.txt]
files: docs/requirements\.(in|txt)$
- id: pip-compile
name: pip-compile requirements.txt
args: [contrib/container/requirements.in, -o, contrib/container/requirements.txt,--python-version=3.11, --no-strip-extras, --generate-hashes]
args: [contrib/container/requirements.in, -o, contrib/container/requirements.txt, --python-version=3.11, --no-strip-extras, --generate-hashes]
files: contrib/container/requirements\.(in|txt)$
- repo: https://github.com/Riverside-Healthcare/djLint
rev: v1.34.1
@ -63,6 +63,8 @@ repos:
docs/docs/javascripts/.*|
docs/docs/webfonts/.* |
src/frontend/src/locales/.* |
pyproject.toml |
src/frontend/vite.config.ts |
)$
- repo: https://github.com/pre-commit/mirrors-prettier
rev: "v4.0.0-alpha.8"
@ -73,7 +75,7 @@ repos:
- "prettier@^2.4.1"
- "@trivago/prettier-plugin-sort-imports"
- repo: https://github.com/pre-commit/mirrors-eslint
rev: "v9.1.0"
rev: "v9.4.0"
hooks:
- id: eslint
additional_dependencies:
@ -85,7 +87,7 @@ repos:
- "@typescript-eslint/parser"
files: ^src/frontend/.*\.(js|jsx|ts|tsx)$
- repo: https://github.com/gitleaks/gitleaks
rev: v8.18.2
rev: v8.18.3
hooks:
- id: gitleaks
#- repo: https://github.com/jumanjihouse/pre-commit-hooks

View File

@ -39,7 +39,7 @@ InvenTree/
│ │ ├─ tsconfig.json # Settings for frontend compilation
├─ .pkgr.yml # Build definition for Debian/Ubuntu packages
├─ .pre-commit-config.yaml # Code formatter/linter configuration
├─ CONTRIBUTING.md # Contirbution guidelines and overview
├─ CONTRIBUTING.md # Contribution guidelines and overview
├─ Procfile # Process definition for Debian/Ubuntu packages
├─ README.md # General project information and overview
├─ runtime.txt # Python runtime settings for Debian/Ubuntu packages build

View File

@ -3,6 +3,7 @@ coverage:
project:
default:
target: 82%
patch: off
github_checks:
annotations: true

View File

@ -1,5 +1,5 @@
# This file was autogenerated by uv via the following command:
# uv pip compile contrib/dev_reqs/requirements.in -o contrib/dev_reqs/requirements.txt --python-version=3.9 --no-strip-extras --generate-hashes
# uv pip compile contrib/dev_reqs/requirements.in -o contrib/dev_reqs/requirements.txt
certifi==2024.2.2 \
--hash=sha256:0569859f95fc761b18b45ef421b1290a0f65f147e92a1e5eb3e635f9a5e4e66f \
--hash=sha256:dc383c07b76109f368f6106eee2b593b04a011ea4d55f652c6ca24a754d1cdd1
@ -218,9 +218,9 @@ ruamel-yaml-clib==0.2.8 \
--hash=sha256:f481f16baec5290e45aebdc2a5168ebc6d35189ae6fea7a58787613a25f6e875 \
--hash=sha256:fff3573c2db359f091e1589c3d7c5fc2f86f5bdb6f24252c2d8e539d4e45f412
# via ruamel-yaml
urllib3==2.2.1 \
--hash=sha256:450b20ec296a467077128bff42b73080516e71b56ff59a60a02bef2232c4fa9d \
--hash=sha256:d0570876c61ab9e520d776c38acbbb5b05a776d3f9ff98a5c8fd5162a444cf19
urllib3==2.2.2 \
--hash=sha256:a448b2f64d686155468037e1ace9f2d2199776e17f0a46610480d311f73e3472 \
--hash=sha256:dd505485549a7a552833da5e6063639d0d177c04f23bc3864e41e5dc5f612168
# via requests
xmltodict==0.13.0 \
--hash=sha256:341595a488e3e01a85a9d8911d8912fd922ede5fecc4dce437eb4b6c8d037e56 \

View File

@ -2,6 +2,8 @@
#
# packager.io postinstall script functions
#
Color_Off='\033[0m'
On_Red='\033[41m'
function detect_docker() {
if [ -n "$(grep docker </proc/1/cgroup)" ]; then
@ -50,9 +52,19 @@ function detect_python() {
echo "# Python environment already present"
# Extract earliest python version initialised from /opt/inventree/env/lib
SETUP_PYTHON=$(ls -1 ${APP_HOME}/env/bin/python* | sort | head -n 1)
echo "# Found earliest version: ${SETUP_PYTHON}"
echo "# Found earlier used version: ${SETUP_PYTHON}"
else
echo "# No python environment found - using ${SETUP_PYTHON}"
echo "# No python environment found - using environment variable: ${SETUP_PYTHON}"
fi
# Ensure python can be executed - abort if not
if [ -z "$(which ${SETUP_PYTHON})" ]; then
echo "${On_Red}"
echo "# Python ${SETUP_PYTHON} not found - aborting!"
echo "# Please ensure python can be executed with the command '$SETUP_PYTHON' by the current user '$USER'."
echo "# If you are using a different python version, please set the environment variable SETUP_PYTHON to the correct command - eg. 'python3.10'."
echo "${Color_Off}"
exit 1
fi
}

View File

@ -5,7 +5,7 @@ title: Part Scheduling
## Part Scheduling
The *Scheduling* tab provides an overview of the *predicted* future availabile quantity of a particular part.
The *Scheduling* tab provides an overview of the *predicted* future available quantity of a particular part.
The *Scheduling* tab displays a chart of estimated future part stock levels. It begins at the current date, with the current stock level. It then projects into the "future", taking information from:

View File

@ -61,7 +61,7 @@ Also, dedicated settings sections were added for:
For Category section, read [Category Parameter Templates](#category-parameter-templates)
Other section allows to set the prefix of build, puchase and sales orders.
Other section allows to set the prefix of build, purchase and sales orders.
### Category Parameter Templates

View File

@ -29,5 +29,5 @@ title: Release 0.4.0
| Pull Request | Description |
| --- | --- |
| [#1823](https://github.com/inventree/InvenTree/pull/1823) | Fixes link formatting issues for ManufacturerParts |
| [#1824](https://github.com/inventree/InvenTree/pull/1824) | Selects correct curreny code when creating a new PurchaseOrderLineItem |
| [#1824](https://github.com/inventree/InvenTree/pull/1824) | Selects correct currency code when creating a new PurchaseOrderLineItem |
| [#1867](https://github.com/inventree/InvenTree/pull/1867) | Fixes long-running bug when deleting sequential items via the API |

View File

@ -12,8 +12,25 @@ The stock ownership feature is disabled by default, and must be enabled via the
{% include 'img.html' %}
{% endwith %}
### Background
The stock item ownership function does not change or the influence the access rights that have been set
in admin panel. It just hides buttons for edit, add and delete actions. This means that the user who
should have access by ownership needs to have stock item write access set in the admin panel. By
this he also gets write access to all other items, except the item has a different owner.
Example
* Stock item 1111 has an owner called Daniel
* Stock item 2222 has an owner called Peter
* Stock item 3333 has no owner
Set stock item access for Daniel and Peter to V,A,E,D in the admin panel. Now Daniel can edit
1111 but not 2222. Peter can edit 2222 but not 1111. Both can edit 3333.
!!! warning "Existing Stock Locations and Items"
Enabling the ownership feature will automatically remove the edit permissions to all users for stock locations and items which **do not have** any owner set. Only a user with admin permissions will be able to set the owner for those locations and items.
Items with no user set can be edited by everyone who has the access rights. An owner has
to be added to every stock item in order to use the ownership control.
### Owner: Group vs User
@ -46,6 +63,12 @@ Setting the owner of stock location will automatically:
* Set the owner of all children locations to the same owner.
* Set the owner of all stock items at this location to the same owner.
The inherited ownership changes with stock transfer. If a stock item is transferred from a location
owned by Daniel to a location owned by Peter the owner of the stock item and the access right will
also change from Daniel to Peter. This is only valid for the inherited ownership. If a stock item
is directly owned by Peter the ownership and the access right will stay with Peter even when the item
is moved to a location owned by Daniel.
!!! info
If the owner of a children location or a stock item is a subset of the specified owner (eg. a user linked to the specified group), the owner won't be updated.

View File

@ -20,7 +20,7 @@ The *Stock Item* detail view shows information regarding the particular stock it
**Quantity** - How many items are in stock?
**Supplier** - If this part was purcahsed from a *Supplier*, which *Supplier* did it come from?
**Supplier** - If this part was purchased from a *Supplier*, which *Supplier* did it come from?
**Supplier Part** - Link to the particular *Supplier Part*, if appropriate.

View File

@ -1,5 +1,5 @@
# This file was autogenerated by uv via the following command:
# uv pip compile docs/requirements.in -o docs/requirements.txt --python-version=3.9 --no-strip-extras --generate-hashes
# uv pip compile docs/requirements.in -o docs/requirements.txt
anyio==4.3.0 \
--hash=sha256:048e05d0f6caeed70d731f3db756d35dcc1f35747c8c403364a8332c630441b8 \
--hash=sha256:f75253795a87df48568485fd18cdd2a3fa5c4f7c5be8e5e36637733fce06fed6

View File

@ -69,6 +69,11 @@ indent-style = "space"
skip-magic-trailing-comma = true
line-ending = "auto"
[tool.uv.pip]
python-version = "3.9"
no-strip-extras=true
generate-hashes=true
[tool.coverage.run]
source = ["src/backend/InvenTree", "InvenTree"]
@ -80,3 +85,6 @@ src_paths=["src/backend/InvenTree", ]
skip_glob ="*/migrations/*.py"
known_django="django"
sections=["FUTURE","STDLIB","DJANGO","THIRDPARTY","FIRSTPARTY","LOCALFOLDER"]
[tool.codespell]
ignore-words-list = ["assertIn","SME"]

View File

@ -419,22 +419,6 @@ class APIDownloadMixin:
raise NotImplementedError('download_queryset method not implemented!')
class AttachmentMixin:
"""Mixin for creating attachment objects, and ensuring the user information is saved correctly."""
permission_classes = [permissions.IsAuthenticated, RolePermission]
filter_backends = SEARCH_ORDER_FILTER
search_fields = ['attachment', 'comment', 'link']
def perform_create(self, serializer):
"""Save the user information when a file is uploaded."""
attachment = serializer.save()
attachment.user = self.request.user
attachment.save()
class APISearchViewSerializer(serializers.Serializer):
"""Serializer for the APISearchView."""

View File

@ -1,11 +1,29 @@
"""InvenTree API version information."""
# InvenTree API version
INVENTREE_API_VERSION = 205
INVENTREE_API_VERSION = 210
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
INVENTREE_API_TEXT = """
v210 - 2024-06-26 : https://github.com/inventree/InvenTree/pull/7518
- Adds translateable text to User API fields
v209 - 2024-06-26 : https://github.com/inventree/InvenTree/pull/7514
- Add "top_level" filter to PartCategory API endpoint
- Add "top_level" filter to StockLocation API endpoint
v208 - 2024-06-19 : https://github.com/inventree/InvenTree/pull/7479
- Adds documentation for the user roles API endpoint (no functional changes)
v207 - 2024-06-09 : https://github.com/inventree/InvenTree/pull/7420
- Moves all "Attachment" models into a single table
- All "Attachment" operations are now performed at /api/attachment/
- Add permissions information to /api/user/roles/ endpoint
v206 - 2024-06-08 : https://github.com/inventree/InvenTree/pull/7417
- Adds "choices" field to the PartTestTemplate model
v205 - 2024-06-03 : https://github.com/inventree/InvenTree/pull/7284
- Added model_type and model_id fields to the "NotesImage" serializer
@ -108,7 +126,7 @@ v178 - 2024-02-29 : https://github.com/inventree/InvenTree/pull/6604
- Adds "external_stock" field to the Part API endpoint
- Adds "external_stock" field to the BomItem API endpoint
- Adds "external_stock" field to the BuildLine API endpoint
- Stock quantites represented in the BuildLine API endpoint are now filtered by Build.source_location
- Stock quantities represented in the BuildLine API endpoint are now filtered by Build.source_location
v177 - 2024-02-27 : https://github.com/inventree/InvenTree/pull/6581
- Adds "subcategoies" count to PartCategoryTree serializer

View File

@ -14,6 +14,7 @@ from django.db.utils import IntegrityError, OperationalError
import InvenTree.conversion
import InvenTree.ready
import InvenTree.tasks
from common.settings import get_global_setting, set_global_setting
from InvenTree.config import get_setting
logger = logging.getLogger('inventree')
@ -238,8 +239,6 @@ class InvenTreeConfig(AppConfig):
- If a fixed SITE_URL is specified (via configuration), it should override the INVENTREE_BASE_URL setting
- If multi-site support is enabled, update the site URL for the current site
"""
import common.models
if not InvenTree.ready.canAppAccessDatabase():
return
@ -248,13 +247,8 @@ class InvenTreeConfig(AppConfig):
if settings.SITE_URL:
try:
if (
common.models.InvenTreeSetting.get_setting('INVENTREE_BASE_URL')
!= settings.SITE_URL
):
common.models.InvenTreeSetting.set_setting(
'INVENTREE_BASE_URL', settings.SITE_URL
)
if get_global_setting('INVENTREE_BASE_URL') != settings.SITE_URL:
set_global_setting('INVENTREE_BASE_URL', settings.SITE_URL)
logger.info('Updated INVENTREE_SITE_URL to %s', settings.SITE_URL)
except Exception:
pass

View File

@ -8,6 +8,7 @@ from djmoney.contrib.exchange.backends.base import SimpleExchangeBackend
from djmoney.contrib.exchange.models import ExchangeBackend, Rate
from common.currency import currency_code_default, currency_codes
from common.settings import get_global_setting
logger = logging.getLogger('inventree')
@ -22,14 +23,13 @@ class InvenTreeExchange(SimpleExchangeBackend):
def get_rates(self, **kwargs) -> dict:
"""Set the requested currency codes and get rates."""
from common.models import InvenTreeSetting
from plugin import registry
base_currency = kwargs.get('base_currency', currency_code_default())
symbols = kwargs.get('symbols', currency_codes())
# Find the selected exchange rate plugin
slug = InvenTreeSetting.get_setting('CURRENCY_UPDATE_PLUGIN', '', create=False)
slug = get_global_setting('CURRENCY_UPDATE_PLUGIN', create=False)
if slug:
plugin = registry.get_plugin(slug)

View File

@ -14,6 +14,7 @@ from rest_framework.fields import URLField as RestURLField
from rest_framework.fields import empty
import InvenTree.helpers
from common.settings import get_global_setting
from .validators import AllowedURLValidator, allowable_url_schemes
@ -32,11 +33,7 @@ class InvenTreeRestURLField(RestURLField):
def run_validation(self, data=empty):
"""Override default validation behaviour for this field type."""
import common.models
strict_urls = common.models.InvenTreeSetting.get_setting(
'INVENTREE_STRICT_URLS', True, cache=False
)
strict_urls = get_global_setting('INVENTREE_STRICT_URLS', cache=False)
if not strict_urls and data is not empty and '://' not in data:
# Validate as if there were a schema provided

View File

@ -24,7 +24,7 @@ from rest_framework import serializers
import InvenTree.helpers_model
import InvenTree.sso
from common.models import InvenTreeSetting
from common.settings import get_global_setting
from InvenTree.exceptions import log_error
logger = logging.getLogger('inventree')
@ -172,12 +172,12 @@ class CustomSignupForm(SignupForm):
def __init__(self, *args, **kwargs):
"""Check settings to influence which fields are needed."""
kwargs['email_required'] = InvenTreeSetting.get_setting('LOGIN_MAIL_REQUIRED')
kwargs['email_required'] = get_global_setting('LOGIN_MAIL_REQUIRED')
super().__init__(*args, **kwargs)
# check for two mail fields
if InvenTreeSetting.get_setting('LOGIN_SIGNUP_MAIL_TWICE'):
if get_global_setting('LOGIN_SIGNUP_MAIL_TWICE'):
self.fields['email2'] = forms.EmailField(
label=_('Email (again)'),
widget=forms.TextInput(
@ -189,7 +189,7 @@ class CustomSignupForm(SignupForm):
)
# check for two password fields
if not InvenTreeSetting.get_setting('LOGIN_SIGNUP_PWD_TWICE'):
if not get_global_setting('LOGIN_SIGNUP_PWD_TWICE'):
self.fields.pop('password2')
# reorder fields
@ -202,7 +202,7 @@ class CustomSignupForm(SignupForm):
cleaned_data = super().clean()
# check for two mail fields
if InvenTreeSetting.get_setting('LOGIN_SIGNUP_MAIL_TWICE'):
if get_global_setting('LOGIN_SIGNUP_MAIL_TWICE'):
email = cleaned_data.get('email')
email2 = cleaned_data.get('email2')
if (email and email2) and email != email2:
@ -213,10 +213,7 @@ class CustomSignupForm(SignupForm):
def registration_enabled():
"""Determine whether user registration is enabled."""
if (
InvenTreeSetting.get_setting('LOGIN_ENABLE_REG')
or InvenTree.sso.registration_enabled()
):
if get_global_setting('LOGIN_ENABLE_REG') or InvenTree.sso.registration_enabled():
if settings.EMAIL_HOST:
return True
else:
@ -240,9 +237,7 @@ class RegistratonMixin:
def clean_email(self, email):
"""Check if the mail is valid to the pattern in LOGIN_SIGNUP_MAIL_RESTRICTION (if enabled in settings)."""
mail_restriction = InvenTreeSetting.get_setting(
'LOGIN_SIGNUP_MAIL_RESTRICTION', None
)
mail_restriction = get_global_setting('LOGIN_SIGNUP_MAIL_RESTRICTION', None)
if not mail_restriction:
return super().clean_email(email)
@ -273,7 +268,7 @@ class RegistratonMixin:
user = super().save_user(request, user, form)
# Check if a default group is set in settings
start_group = InvenTreeSetting.get_setting('SIGNUP_GROUP')
start_group = get_global_setting('SIGNUP_GROUP')
if start_group:
try:
group = Group.objects.get(id=start_group)
@ -333,7 +328,7 @@ class CustomSocialAccountAdapter(
def is_auto_signup_allowed(self, request, sociallogin):
"""Check if auto signup is enabled in settings."""
if InvenTreeSetting.get_setting('LOGIN_SIGNUP_SSO_AUTO', True):
if get_global_setting('LOGIN_SIGNUP_SSO_AUTO', True):
return super().is_auto_signup_allowed(request, sociallogin)
return False
@ -385,7 +380,7 @@ class CustomRegisterSerializer(RegisterSerializer):
def __init__(self, instance=None, data=..., **kwargs):
"""Check settings to influence which fields are needed."""
kwargs['email_required'] = InvenTreeSetting.get_setting('LOGIN_MAIL_REQUIRED')
kwargs['email_required'] = get_global_setting('LOGIN_MAIL_REQUIRED')
super().__init__(instance, data, **kwargs)
def save(self, request):

View File

@ -15,7 +15,6 @@ 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
@ -24,16 +23,12 @@ from common.notifications import (
NotificationBody,
trigger_notification,
)
from common.settings import get_global_setting
from InvenTree.format import format_money
logger = logging.getLogger('inventree')
def getSetting(key, backup_value=None):
"""Shortcut for reading a setting value from the database."""
return common.models.InvenTreeSetting.get_setting(key, backup_value=backup_value)
def get_base_url(request=None):
"""Return the base URL for the InvenTree server.
@ -44,6 +39,8 @@ def get_base_url(request=None):
3. If settings.SITE_URL is set (e.g. in the Django settings), use that
4. If the InvenTree setting INVENTREE_BASE_URL is set, use that
"""
import common.models
# Check if a request is provided
if request:
return request.build_absolute_uri('/')
@ -62,9 +59,7 @@ def get_base_url(request=None):
# Check if a global InvenTree setting is provided
try:
if site_url := common.models.InvenTreeSetting.get_setting(
'INVENTREE_BASE_URL', create=False
):
if site_url := get_global_setting('INVENTREE_BASE_URL', create=False):
return site_url
except (ProgrammingError, OperationalError):
pass
@ -112,25 +107,20 @@ def download_image_from_url(remote_url, timeout=2.5):
ValueError: Server responded with invalid 'Content-Length' value
TypeError: Response is not a valid image
"""
import common.models
# 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
int(get_global_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'
)
user_agent = get_global_setting('INVENTREE_DOWNLOAD_FROM_URL_USER_AGENT')
if user_agent:
headers = {'User-Agent': user_agent}
else:
@ -216,6 +206,8 @@ def render_currency(
max_decimal_places: The maximum number of decimal places to render to. If unspecified, uses the PRICING_DECIMAL_PLACES setting.
include_symbol: If True, include the currency symbol in the output
"""
import common.models
if money in [None, '']:
return '-'
@ -231,19 +223,13 @@ def render_currency(
pass
if decimal_places is None:
decimal_places = common.models.InvenTreeSetting.get_setting(
'PRICING_DECIMAL_PLACES', 6
)
decimal_places = get_global_setting('PRICING_DECIMAL_PLACES', 6)
if min_decimal_places is None:
min_decimal_places = common.models.InvenTreeSetting.get_setting(
'PRICING_DECIMAL_PLACES_MIN', 0
)
min_decimal_places = get_global_setting('PRICING_DECIMAL_PLACES_MIN', 0)
if max_decimal_places is None:
max_decimal_places = common.models.InvenTreeSetting.get_setting(
'PRICING_DECIMAL_PLACES', 6
)
max_decimal_places = get_global_setting('PRICING_DECIMAL_PLACES', 6)
value = Decimal(str(money.amount)).normalize()
value = str(value)

View File

@ -38,6 +38,7 @@ LOCALES = [
('pl', _('Polish')),
('pt', _('Portuguese')),
('pt-br', _('Portuguese (Brazilian)')),
('ro', _('Romanian')),
('ru', _('Russian')),
('sk', _('Slovak')),
('sl', _('Slovenian')),

View File

@ -0,0 +1,13 @@
"""Management command to collect plugin static files."""
from django.core.management import BaseCommand
class Command(BaseCommand):
"""Collect static files for all installed plugins."""
def handle(self, *args, **kwargs):
"""Run the management command."""
from plugin.staticfiles import collect_plugins_static_files
collect_plugins_static_files()

View File

@ -115,9 +115,14 @@ class InvenTreeMetadata(SimpleMetadata):
return metadata
def override_value(self, field_name, field_value, model_value):
def override_value(self, field_name: str, field_key: str, field_value, model_value):
"""Override a value on the serializer with a matching value for the model.
Often, the serializer field will point to an underlying model field,
which contains extra information (which is translated already).
Rather than duplicating this information in the serializer, we can extract it from the model.
This is used to override the serializer values with model values,
if (and *only* if) the model value should take precedence.
@ -125,6 +130,12 @@ class InvenTreeMetadata(SimpleMetadata):
- field_value is None
- model_value is callable, and field_value is not (this indicates that the model value is translated)
- model_value is not a string, and field_value is a string (this indicates that the model value is translated)
Arguments:
- field_name: The name of the field
- field_key: The property key to override
- field_value: The value of the field (if available)
- model_value: The equivalent value of the model (if available)
"""
if model_value and not field_value:
return model_value
@ -132,10 +143,15 @@ class InvenTreeMetadata(SimpleMetadata):
if field_value and not model_value:
return field_value
# Callable values will be evaluated later
if callable(model_value) and not callable(field_value):
return model_value
if type(model_value) is not str and type(field_value) is str:
if callable(field_value) and not callable(model_value):
return field_value
# Prioritize translated text over raw string values
if type(field_value) is str and type(model_value) is not str:
return model_value
return field_value
@ -197,10 +213,12 @@ class InvenTreeMetadata(SimpleMetadata):
serializer_info[name]['default'] = model_default_values[name]
for field_key, model_key in extra_attributes.items():
field_value = serializer_info[name].get(field_key, None)
field_value = getattr(serializer.fields[name], field_key, None)
model_value = getattr(field, model_key, None)
if value := self.override_value(name, field_value, model_value):
if value := self.override_value(
name, field_key, field_value, model_value
):
serializer_info[name][field_key] = value
# Iterate through relations
@ -220,10 +238,12 @@ class InvenTreeMetadata(SimpleMetadata):
)
for field_key, model_key in extra_attributes.items():
field_value = serializer_info[name].get(field_key, None)
field_value = getattr(serializer.fields[name], field_key, None)
model_value = getattr(relation.model_field, model_key, None)
if value := self.override_value(name, field_value, model_value):
if value := self.override_value(
name, field_key, field_value, model_value
):
serializer_info[name][field_key] = value
if name in model_default_values:

View File

@ -12,6 +12,7 @@ from django.urls import Resolver404, include, path, resolve, reverse_lazy
from allauth_2fa.middleware import AllauthTwoFactorMiddleware, BaseRequire2FAMiddleware
from error_report.middleware import ExceptionProcessor
from common.settings import get_global_setting
from InvenTree.urls import frontendpatterns
from users.models import ApiToken
@ -153,11 +154,9 @@ class Check2FAMiddleware(BaseRequire2FAMiddleware):
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')
return get_global_setting('LOGIN_ENFORCE_MFA')
except Resolver404:
pass
return False

View File

@ -1,9 +1,7 @@
"""Generic models which provide extra functionality over base Django model types."""
import logging
import os
from datetime import datetime
from io import BytesIO
from django.conf import settings
from django.contrib.auth import get_user_model
@ -20,11 +18,11 @@ from error_report.models import Error
from mptt.exceptions import InvalidMove
from mptt.models import MPTTModel, TreeForeignKey
import common.settings
import InvenTree.fields
import InvenTree.format
import InvenTree.helpers
import InvenTree.helpers_model
from InvenTree.sanitizer import sanitize_svg
logger = logging.getLogger('inventree')
@ -304,10 +302,7 @@ 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(
return common.settings.get_global_setting(
cls.REFERENCE_PATTERN_SETTING, create=False
).strip()
@ -503,207 +498,71 @@ class InvenTreeMetadataModel(MetadataMixin, InvenTreeModel):
abstract = True
def rename_attachment(instance, filename):
"""Function for renaming an attachment file. The subdirectory for the uploaded file is determined by the implementing class.
Args:
instance: Instance of a PartAttachment object
filename: name of uploaded file
Returns:
path to store file, format: '<subdir>/<id>/filename'
"""
# Construct a path to store a file attachment for a given model type
return os.path.join(instance.getSubdir(), filename)
class InvenTreeAttachment(InvenTreeModel):
class InvenTreeAttachmentMixin:
"""Provides an abstracted class for managing file attachments.
An attachment can be either an uploaded file, or an external URL
Links the implementing model to the common.models.Attachment table,
and provides the following methods:
Attributes:
attachment: Upload file
link: External URL
comment: String descriptor for the attachment
user: User associated with file upload
upload_date: Date the file was uploaded
- attachments: Return a queryset containing all attachments for this model
"""
class Meta:
"""Metaclass options. Abstract ensures no database table is created."""
def delete(self):
"""Handle the deletion of a model instance.
abstract = True
def getSubdir(self):
"""Return the subdirectory under which attachments should be stored.
Note: Re-implement this for each subclass of InvenTreeAttachment
Before deleting the model instance, delete any associated attachments.
"""
return 'attachments'
def save(self, *args, **kwargs):
"""Provide better validation error."""
# Either 'attachment' or 'link' must be specified!
if not self.attachment and not self.link:
raise ValidationError({
'attachment': _('Missing file'),
'link': _('Missing external link'),
})
if self.attachment and self.attachment.name.lower().endswith('.svg'):
self.attachment.file.file = self.clean_svg(self.attachment)
super().save(*args, **kwargs)
def clean_svg(self, field):
"""Sanitize SVG file before saving."""
cleaned = sanitize_svg(field.file.read())
return BytesIO(bytes(cleaned, 'utf8'))
def __str__(self):
"""Human name for attachment."""
if self.attachment is not None:
return os.path.basename(self.attachment.name)
return str(self.link)
attachment = models.FileField(
upload_to=rename_attachment,
verbose_name=_('Attachment'),
help_text=_('Select file to attach'),
blank=True,
null=True,
)
link = InvenTree.fields.InvenTreeURLField(
blank=True,
null=True,
verbose_name=_('Link'),
help_text=_('Link to external URL'),
)
comment = models.CharField(
blank=True,
max_length=100,
verbose_name=_('Comment'),
help_text=_('File comment'),
)
user = models.ForeignKey(
User,
on_delete=models.SET_NULL,
blank=True,
null=True,
verbose_name=_('User'),
help_text=_('User'),
)
upload_date = models.DateField(
auto_now_add=True, null=True, blank=True, verbose_name=_('upload date')
)
self.attachments.all().delete()
super().delete()
@property
def basename(self):
"""Base name/path for attachment."""
if self.attachment:
return os.path.basename(self.attachment.name)
return None
def attachments(self):
"""Return a queryset containing all attachments for this model."""
return self.attachments_for_model().filter(model_id=self.pk)
@basename.setter
def basename(self, fn):
"""Function to rename the attachment file.
@classmethod
def check_attachment_permission(cls, permission, user) -> bool:
"""Check if the user has permission to perform the specified action on the attachment.
- Filename cannot be empty
- Filename cannot contain illegal characters
- Filename must specify an extension
- Filename cannot match an existing file
The default implementation runs a permission check against *this* model class,
but this can be overridden in the implementing class if required.
Arguments:
permission: The permission to check (add / change / view / delete)
user: The user to check against
Returns:
bool: True if the user has permission, False otherwise
"""
fn = fn.strip()
perm = f'{cls._meta.app_label}.{permission}_{cls._meta.model_name}'
return user.has_perm(perm)
if len(fn) == 0:
raise ValidationError(_('Filename must not be empty'))
def attachments_for_model(self):
"""Return all attachments for this model class."""
from common.models import Attachment
attachment_dir = settings.MEDIA_ROOT.joinpath(self.getSubdir())
old_file = settings.MEDIA_ROOT.joinpath(self.attachment.name)
new_file = settings.MEDIA_ROOT.joinpath(self.getSubdir(), fn).resolve()
model_type = self.__class__.__name__.lower()
# Check that there are no directory tricks going on...
if new_file.parent != attachment_dir:
logger.error(
"Attempted to rename attachment outside valid directory: '%s'", new_file
)
raise ValidationError(_('Invalid attachment directory'))
return Attachment.objects.filter(model_type=model_type)
# Ignore further checks if the filename is not actually being renamed
if new_file == old_file:
return
def create_attachment(self, attachment=None, link=None, comment='', **kwargs):
"""Create an attachment / link for this model."""
from common.models import Attachment
forbidden = [
"'",
'"',
'#',
'@',
'!',
'&',
'^',
'<',
'>',
':',
';',
'/',
'\\',
'|',
'?',
'*',
'%',
'~',
'`',
]
kwargs['attachment'] = attachment
kwargs['link'] = link
kwargs['comment'] = comment
kwargs['model_type'] = self.__class__.__name__.lower()
kwargs['model_id'] = self.pk
for c in forbidden:
if c in fn:
raise ValidationError(_(f"Filename contains illegal character '{c}'"))
if len(fn.split('.')) < 2:
raise ValidationError(_('Filename missing extension'))
if not old_file.exists():
logger.error(
"Trying to rename attachment '%s' which does not exist", old_file
)
return
if new_file.exists():
raise ValidationError(_('Attachment with this filename already exists'))
try:
os.rename(old_file, new_file)
self.attachment.name = os.path.join(self.getSubdir(), fn)
self.save()
except Exception:
raise ValidationError(_('Error renaming file'))
def fully_qualified_url(self):
"""Return a 'fully qualified' URL for this attachment.
- If the attachment is a link to an external resource, return the link
- If the attachment is an uploaded file, return the fully qualified media URL
"""
if self.link:
return self.link
if self.attachment:
media_url = InvenTree.helpers.getMediaUrl(self.attachment.url)
return InvenTree.helpers_model.construct_absolute_url(media_url)
return ''
Attachment.objects.create(**kwargs)
class InvenTreeTree(MetadataMixin, PluginValidationMixin, MPTTModel):
"""Provides an abstracted self-referencing tree model for data categories.
- Each Category has one parent Category, which can be blank (for a top-level Category).
- Each Category can have zero-or-more child Categor(y/ies)
- Each Category can have zero-or-more child Category(y/ies)
Attributes:
name: brief name
@ -920,7 +779,7 @@ class InvenTreeTree(MetadataMixin, PluginValidationMixin, MPTTModel):
on_delete=models.DO_NOTHING,
blank=True,
null=True,
verbose_name=_('parent'),
verbose_name='parent',
related_name='children',
)

View File

@ -125,7 +125,7 @@ def canAppAccessDatabase(
excluded_commands.append('test')
if not allow_plugins:
excluded_commands.extend(['collectstatic'])
excluded_commands.extend(['collectstatic', 'collectplugins'])
for cmd in excluded_commands:
if cmd in sys.argv:

View File

@ -404,6 +404,17 @@ class UserSerializer(InvenTreeModelSerializer):
read_only_fields = ['username']
username = serializers.CharField(label=_('Username'), help_text=_('Username'))
first_name = serializers.CharField(
label=_('First Name'), help_text=_('First name of the user')
)
last_name = serializers.CharField(
label=_('Last Name'), help_text=_('Last name of the user')
)
email = serializers.EmailField(
label=_('Email'), help_text=_('Email address of the user')
)
class ExendedUserSerializer(UserSerializer):
"""Serializer for a User with a bit more info."""
@ -424,6 +435,16 @@ class ExendedUserSerializer(UserSerializer):
read_only_fields = UserSerializer.Meta.read_only_fields + ['groups']
is_staff = serializers.BooleanField(
label=_('Staff'), help_text=_('Does this user have staff permissions')
)
is_superuser = serializers.BooleanField(
label=_('Superuser'), help_text=_('Is this user a superuser')
)
is_active = serializers.BooleanField(
label=_('Active'), help_text=_('Is this user account active')
)
def validate(self, attrs):
"""Expanded validation for changing user role."""
# Check if is_staff or is_superuser is in attrs
@ -509,43 +530,6 @@ class InvenTreeAttachmentSerializerField(serializers.FileField):
return os.path.join(str(settings.MEDIA_URL), str(value))
class InvenTreeAttachmentSerializer(InvenTreeModelSerializer):
"""Special case of an InvenTreeModelSerializer, which handles an "attachment" model.
The only real addition here is that we support "renaming" of the attachment file.
"""
@staticmethod
def attachment_fields(extra_fields=None):
"""Default set of fields for an attachment serializer."""
fields = [
'pk',
'attachment',
'filename',
'link',
'comment',
'upload_date',
'user',
'user_detail',
]
if extra_fields:
fields += extra_fields
return fields
user_detail = UserSerializer(source='user', read_only=True, many=False)
attachment = InvenTreeAttachmentSerializerField(required=False, allow_null=False)
# The 'filename' field must be present in the serializer
filename = serializers.CharField(
label=_('Filename'), required=False, source='basename', allow_blank=False
)
upload_date = serializers.DateField(read_only=True)
class InvenTreeImageSerializerField(serializers.ImageField):
"""Custom image serializer.

View File

@ -296,6 +296,7 @@ ADMIN_SHELL_IMPORT_MODELS = False
if (
DEBUG
and INVENTREE_ADMIN_ENABLED
and not TESTING
and get_boolean_setting('INVENTREE_DEBUG_SHELL', 'debug_shell', False)
): # noqa
try:
@ -1272,7 +1273,7 @@ PLUGIN_TESTING_SETUP = get_setting(
) # 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
'INVENTREE_PLUGIN_RETRY', 'PLUGIN_RETRY', 3, typecast=int
) # How often should plugin loading be tried?
PLUGIN_FILE_CHECKED = False # Was the plugin file checked?

View File

@ -15,7 +15,7 @@ from rest_framework.permissions import AllowAny, IsAuthenticated
from rest_framework.response import Response
import InvenTree.sso
from common.models import InvenTreeSetting
from common.settings import get_global_setting
from InvenTree.mixins import CreateAPI, ListAPI, ListCreateAPI
from InvenTree.serializers import EmptySerializer, InvenTreeModelSerializer
@ -177,12 +177,10 @@ class SocialProviderListView(ListAPI):
data = {
'sso_enabled': InvenTree.sso.login_enabled(),
'sso_registration': InvenTree.sso.registration_enabled(),
'mfa_required': InvenTreeSetting.get_setting('LOGIN_ENFORCE_MFA'),
'mfa_required': get_global_setting('LOGIN_ENFORCE_MFA'),
'providers': provider_list,
'registration_enabled': InvenTreeSetting.get_setting('LOGIN_ENABLE_REG'),
'password_forgotten_enabled': InvenTreeSetting.get_setting(
'LOGIN_ENABLE_PWD_FORGOT'
),
'registration_enabled': get_global_setting('LOGIN_ENABLE_REG'),
'password_forgotten_enabled': get_global_setting('LOGIN_ENABLE_PWD_FORGOT'),
}
return Response(data)

View File

@ -2,7 +2,7 @@
import logging
from common.models import InvenTreeSetting
from common.settings import get_global_setting
from InvenTree.helpers import str2bool
logger = logging.getLogger('inventree')
@ -64,14 +64,14 @@ def provider_display_name(provider):
def login_enabled() -> bool:
"""Return True if SSO login is enabled."""
return str2bool(InvenTreeSetting.get_setting('LOGIN_ENABLE_SSO'))
return str2bool(get_global_setting('LOGIN_ENABLE_SSO'))
def registration_enabled() -> bool:
"""Return True if SSO registration is enabled."""
return str2bool(InvenTreeSetting.get_setting('LOGIN_ENABLE_SSO_REG'))
return str2bool(get_global_setting('LOGIN_ENABLE_SSO_REG'))
def auto_registration_enabled() -> bool:
"""Return True if SSO auto-registration is enabled."""
return str2bool(InvenTreeSetting.get_setting('LOGIN_SIGNUP_SSO_AUTO'))
return str2bool(get_global_setting('LOGIN_SIGNUP_SSO_AUTO'))

View File

@ -26,6 +26,7 @@ from maintenance_mode.core import (
set_maintenance_mode,
)
from common.settings import get_global_setting, set_global_setting
from InvenTree.config import get_setting
from plugin import registry
@ -90,7 +91,6 @@ def check_daily_holdoff(task_name: str, n_days: int = 1) -> bool:
Note that this function creates some *hidden* global settings (designated with the _ prefix),
which are used to keep a running track of when the particular task was was last run.
"""
from common.models import InvenTreeSetting
from InvenTree.ready import isInTestMode
if n_days <= 0:
@ -107,7 +107,7 @@ def check_daily_holdoff(task_name: str, n_days: int = 1) -> bool:
success_key = f'_{task_name}_SUCCESS'
# Check for recent success information
last_success = InvenTreeSetting.get_setting(success_key, '', cache=False)
last_success = get_global_setting(success_key, '', cache=False)
if last_success:
try:
@ -125,7 +125,7 @@ def check_daily_holdoff(task_name: str, n_days: int = 1) -> bool:
return False
# Check for any information we have about this task
last_attempt = InvenTreeSetting.get_setting(attempt_key, '', cache=False)
last_attempt = get_global_setting(attempt_key, '', cache=False)
if last_attempt:
try:
@ -152,22 +152,14 @@ def check_daily_holdoff(task_name: str, n_days: int = 1) -> bool:
def record_task_attempt(task_name: str):
"""Record that a multi-day task has been attempted *now*."""
from common.models import InvenTreeSetting
logger.info("Logging task attempt for '%s'", task_name)
InvenTreeSetting.set_setting(
f'_{task_name}_ATTEMPT', datetime.now().isoformat(), None
)
set_global_setting(f'_{task_name}_ATTEMPT', datetime.now().isoformat(), None)
def record_task_success(task_name: str):
"""Record that a multi-day task was successful *now*."""
from common.models import InvenTreeSetting
InvenTreeSetting.set_setting(
f'_{task_name}_SUCCESS', datetime.now().isoformat(), None
)
set_global_setting(f'_{task_name}_SUCCESS', datetime.now().isoformat(), None)
def offload_task(
@ -380,9 +372,7 @@ def delete_successful_tasks():
try:
from django_q.models import Success
from common.models import InvenTreeSetting
days = InvenTreeSetting.get_setting('INVENTREE_DELETE_TASKS_DAYS', 30)
days = get_global_setting('INVENTREE_DELETE_TASKS_DAYS', 30)
threshold = timezone.now() - timedelta(days=days)
# Delete successful tasks
@ -404,9 +394,7 @@ def delete_failed_tasks():
try:
from django_q.models import Failure
from common.models import InvenTreeSetting
days = InvenTreeSetting.get_setting('INVENTREE_DELETE_TASKS_DAYS', 30)
days = get_global_setting('INVENTREE_DELETE_TASKS_DAYS', 30)
threshold = timezone.now() - timedelta(days=days)
# Delete failed tasks
@ -426,9 +414,7 @@ def delete_old_error_logs():
try:
from error_report.models import Error
from common.models import InvenTreeSetting
days = InvenTreeSetting.get_setting('INVENTREE_DELETE_ERRORS_DAYS', 30)
days = get_global_setting('INVENTREE_DELETE_ERRORS_DAYS', 30)
threshold = timezone.now() - timedelta(days=days)
errors = Error.objects.filter(when__lte=threshold)
@ -448,13 +434,9 @@ def delete_old_error_logs():
def delete_old_notifications():
"""Delete old notification logs."""
try:
from common.models import (
InvenTreeSetting,
NotificationEntry,
NotificationMessage,
)
from common.models import NotificationEntry, NotificationMessage
days = InvenTreeSetting.get_setting('INVENTREE_DELETE_NOTIFICATIONS_DAYS', 30)
days = get_global_setting('INVENTREE_DELETE_NOTIFICATIONS_DAYS', 30)
threshold = timezone.now() - timedelta(days=days)
items = NotificationEntry.objects.filter(updated__lte=threshold)
@ -479,7 +461,6 @@ def delete_old_notifications():
def check_for_updates():
"""Check if there is an update for InvenTree."""
try:
import common.models
from common.notifications import trigger_superuser_notification
except AppRegistryNotReady: # pragma: no cover
# Apps not yet loaded!
@ -487,9 +468,7 @@ def check_for_updates():
return
interval = int(
common.models.InvenTreeSetting.get_setting(
'INVENTREE_UPDATE_CHECK_INTERVAL', 7, cache=False
)
get_global_setting('INVENTREE_UPDATE_CHECK_INTERVAL', 7, cache=False)
)
# Check if we should check for updates *today*
@ -538,7 +517,7 @@ def check_for_updates():
logger.info("Latest InvenTree version: '%s'", tag)
# Save the version to the database
common.models.InvenTreeSetting.set_setting('_INVENTREE_LATEST_VERSION', tag, None)
set_global_setting('_INVENTREE_LATEST_VERSION', tag, None)
# Record that this task was successful
record_task_success('check_for_updates')
@ -572,7 +551,6 @@ def update_exchange_rates(force: bool = False):
from djmoney.contrib.exchange.models import Rate
from common.currency import currency_code_default, currency_codes
from common.models import InvenTreeSetting
from InvenTree.exchange import InvenTreeExchange
except AppRegistryNotReady: # pragma: no cover
# Apps not yet loaded!
@ -585,9 +563,7 @@ def update_exchange_rates(force: bool = False):
return
if not force:
interval = int(
InvenTreeSetting.get_setting('CURRENCY_UPDATE_INTERVAL', 1, cache=False)
)
interval = int(get_global_setting('CURRENCY_UPDATE_INTERVAL', 1, cache=False))
if not check_daily_holdoff('update_exchange_rates', interval):
logger.info('Skipping exchange rate update (interval not reached)')
@ -617,15 +593,11 @@ def update_exchange_rates(force: bool = False):
@scheduled_task(ScheduledTask.DAILY)
def run_backup():
"""Run the backup command."""
from common.models import InvenTreeSetting
if not InvenTreeSetting.get_setting('INVENTREE_BACKUP_ENABLE', False, cache=False):
if not get_global_setting('INVENTREE_BACKUP_ENABLE', False, cache=False):
# Backups are not enabled - exit early
return
interval = int(
InvenTreeSetting.get_setting('INVENTREE_BACKUP_DAYS', 1, cache=False)
)
interval = int(get_global_setting('INVENTREE_BACKUP_DAYS', 1, cache=False))
# Check if should run this task *today*
if not check_daily_holdoff('run_backup', interval):
@ -655,13 +627,12 @@ def check_for_migrations(force: bool = False, reload_registry: bool = True):
If the setting auto_update is enabled we will start updating.
"""
from common.models import InvenTreeSetting
from plugin import registry
def set_pending_migrations(n: int):
"""Helper function to inform the user about pending migrations."""
logger.info('There are %s pending migrations', n)
InvenTreeSetting.set_setting('_PENDING_MIGRATIONS', n, None)
set_global_setting('_PENDING_MIGRATIONS', n, None)
logger.info('Checking for pending database migrations')

View File

@ -17,6 +17,7 @@ import InvenTree.helpers
import InvenTree.helpers_model
import plugin.models
from common.currency import currency_code_default
from common.settings import get_global_setting
from InvenTree import settings, version
from plugin import registry
from plugin.plugin import InvenTreePlugin
@ -135,7 +136,7 @@ def inventree_in_debug_mode(*args, **kwargs):
@register.simple_tag()
def inventree_show_about(user, *args, **kwargs):
"""Return True if the about modal should be shown."""
if common.models.InvenTreeSetting.get_setting('INVENTREE_RESTRICT_ABOUT'):
if get_global_setting('INVENTREE_RESTRICT_ABOUT'):
# Return False if the user is not a superuser, or no user information is provided
if not user or not user.is_superuser:
return False
@ -373,7 +374,7 @@ def settings_value(key, *args, **kwargs):
return common.models.InvenTreeUserSetting.get_setting(key)
return common.models.InvenTreeUserSetting.get_setting(key, user=kwargs['user'])
return common.models.InvenTreeSetting.get_setting(key)
return get_global_setting(key)
@register.simple_tag()

View File

@ -122,6 +122,7 @@ class InvenTreeTaskTests(TestCase):
def test_task_check_for_updates(self):
"""Test the task check_for_updates."""
# Check that setting should be empty
InvenTreeSetting.set_setting('_INVENTREE_LATEST_VERSION', '')
self.assertEqual(InvenTreeSetting.get_setting('_INVENTREE_LATEST_VERSION'), '')
# Get new version

View File

@ -152,6 +152,17 @@ class UserMixin:
"""Lougout current user."""
self.client.logout()
@classmethod
def clearRoles(cls):
"""Remove all user roles from the registered user."""
for ruleset in cls.group.rule_sets.all():
ruleset.can_view = False
ruleset.can_change = False
ruleset.can_delete = False
ruleset.can_add = False
ruleset.save()
@classmethod
def assignRole(cls, role=None, assign_all: bool = False, group=None):
"""Set the user roles for the registered user.
@ -229,7 +240,7 @@ class ExchangeRateMixin:
class InvenTreeTestCase(ExchangeRateMixin, UserMixin, TestCase):
"""Testcase with user setup buildin."""
"""Testcase with user setup build in."""
pass
@ -267,7 +278,7 @@ class InvenTreeAPITestCase(ExchangeRateMixin, UserMixin, APITestCase):
f'Query count exceeded at {url}: Expected < {value} queries, got {n}'
) # pragma: no cover
if verbose:
if verbose or n >= value:
msg = '\r\n%s' % json.dumps(
context.captured_queries, indent=4
) # pragma: no cover
@ -296,7 +307,7 @@ class InvenTreeAPITestCase(ExchangeRateMixin, UserMixin, APITestCase):
if hasattr(response, 'content'):
print('content:', response.content)
self.assertEqual(expected_code, response.status_code)
self.assertEqual(response.status_code, expected_code)
def getActions(self, url):
"""Return a dict of the 'actions' available at a given endpoint.
@ -314,17 +325,17 @@ class InvenTreeAPITestCase(ExchangeRateMixin, UserMixin, APITestCase):
if data is None:
data = {}
expected_code = kwargs.pop('expected_code', None)
kwargs['format'] = kwargs.get('format', 'json')
max_queries = kwargs.get('max_query_count', self.MAX_QUERY_COUNT)
max_query_time = kwargs.get('max_query_time', self.MAX_QUERY_TIME)
expected_code = kwargs.pop('expected_code', None)
max_queries = kwargs.pop('max_query_count', self.MAX_QUERY_COUNT)
max_query_time = kwargs.pop('max_query_time', self.MAX_QUERY_TIME)
t1 = time.time()
with self.assertNumQueriesLessThan(max_queries, url=url):
response = method(url, data, **kwargs)
t2 = time.time()
dt = t2 - t1

View File

@ -13,6 +13,7 @@ from jinja2 import Template
from moneyed import CURRENCIES
import InvenTree.conversion
from common.settings import get_global_setting
def validate_physical_units(unit):
@ -63,14 +64,10 @@ class AllowedURLValidator(validators.URLValidator):
def __call__(self, value):
"""Validate the URL."""
import common.models
self.schemes = allowable_url_schemes()
# Determine if 'strict' URL validation is required (i.e. if the URL must have a schema prefix)
strict_urls = common.models.InvenTreeSetting.get_setting(
'INVENTREE_STRICT_URLS', True, cache=False
)
strict_urls = get_global_setting('INVENTREE_STRICT_URLS', cache=False)
if not strict_urls:
# Allow URLs which do not have a provided schema

View File

@ -3,6 +3,7 @@
Provides information on the current InvenTree version
"""
import logging
import os
import pathlib
import platform
@ -14,18 +15,29 @@ from datetime import timedelta as td
import django
from django.conf import settings
from dulwich.repo import NotGitRepository, Repo
from .api_version import INVENTREE_API_TEXT, INVENTREE_API_VERSION
# InvenTree software version
INVENTREE_SW_VERSION = '0.16.0 dev'
logger = logging.getLogger('inventree')
# Discover git
try:
from dulwich.repo import Repo
main_repo = Repo(pathlib.Path(__file__).parent.parent.parent.parent.parent)
main_commit = main_repo[main_repo.head()]
except (NotGitRepository, FileNotFoundError):
except (ImportError, ModuleNotFoundError):
logger.warning(
'Warning: Dulwich module not found, git information will not be available.'
)
main_repo = None
main_commit = None
except Exception:
main_repo = None
main_commit = None
@ -51,17 +63,18 @@ def checkMinPythonVersion():
def inventreeInstanceName():
"""Returns the InstanceName settings for the current database."""
import common.models
from common.settings import get_global_setting
return common.models.InvenTreeSetting.get_setting('INVENTREE_INSTANCE', '')
return get_global_setting('INVENTREE_INSTANCE')
def inventreeInstanceTitle():
"""Returns the InstanceTitle for the current database."""
import common.models
from common.settings import get_global_setting
if get_global_setting('INVENTREE_INSTANCE_TITLE'):
return get_global_setting('INVENTREE_INSTANCE')
if common.models.InvenTreeSetting.get_setting('INVENTREE_INSTANCE_TITLE', False):
return common.models.InvenTreeSetting.get_setting('INVENTREE_INSTANCE', '')
return 'InvenTree'
@ -122,9 +135,9 @@ def isInvenTreeUpToDate():
A background task periodically queries GitHub for latest version, and stores it to the database as "_INVENTREE_LATEST_VERSION"
"""
import common.models
from common.settings import get_global_setting
latest = common.models.InvenTreeSetting.get_setting(
latest = get_global_setting(
'_INVENTREE_LATEST_VERSION', backup_value=None, create=False
)

View File

@ -11,7 +11,7 @@ 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
from InvenTree.api import APIDownloadMixin, MetadataView
from generic.states.api import StatusView
from InvenTree.helpers import str2bool, isNull, DownloadFile
from build.status_codes import BuildStatus, BuildStatusGroups
@ -20,7 +20,7 @@ from InvenTree.mixins import CreateAPI, RetrieveUpdateDestroyAPI, ListCreateAPI
import common.models
import build.admin
import build.serializers
from build.models import Build, BuildLine, BuildItem, BuildOrderAttachment
from build.models import Build, BuildLine, BuildItem
import part.models
from users.models import Owner
from InvenTree.filters import SEARCH_ORDER_FILTER_ALIAS
@ -380,6 +380,8 @@ class BuildLineList(BuildLineEndpoint, ListCreateAPI):
search_fields = [
'bom_item__sub_part__name',
'bom_item__sub_part__IPN',
'bom_item__sub_part__description',
'bom_item__reference',
]
@ -612,32 +614,8 @@ class BuildItemList(ListCreateAPI):
]
class BuildAttachmentList(AttachmentMixin, ListCreateDestroyAPIView):
"""API endpoint for listing (and creating) BuildOrderAttachment objects."""
queryset = BuildOrderAttachment.objects.all()
serializer_class = build.serializers.BuildAttachmentSerializer
filterset_fields = [
'build',
]
class BuildAttachmentDetail(AttachmentMixin, RetrieveUpdateDestroyAPI):
"""Detail endpoint for a BuildOrderAttachment object."""
queryset = BuildOrderAttachment.objects.all()
serializer_class = build.serializers.BuildAttachmentSerializer
build_api_urls = [
# Attachments
path('attachment/', include([
path('<int:pk>/', BuildAttachmentDetail.as_view(), name='api-build-attachment-detail'),
path('', BuildAttachmentList.as_view(), name='api-build-attachment-list'),
])),
# Build lines
path('line/', include([
path('<int:pk>/', BuildLineDetail.as_view(), name='api-build-line-detail'),

View File

@ -18,7 +18,7 @@ class Migration(migrations.Migration):
name='BuildOrderAttachment',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('attachment', models.FileField(help_text='Select file to attach', upload_to=InvenTree.models.rename_attachment)),
('attachment', models.FileField(help_text='Select file to attach', upload_to='attachments')),
('comment', models.CharField(blank=True, help_text='File comment', max_length=100)),
('upload_date', models.DateField(auto_now_add=True, null=True)),
('build', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='attachments', to='build.Build')),

View File

@ -65,7 +65,7 @@ class Migration(migrations.Migration):
migrations.AlterField(
model_name='buildorderattachment',
name='attachment',
field=models.FileField(help_text='Select file to attach', upload_to=InvenTree.models.rename_attachment, verbose_name='Attachment'),
field=models.FileField(help_text='Select file to attach', upload_to='attachments', verbose_name='Attachment'),
),
migrations.AlterField(
model_name='buildorderattachment',

View File

@ -20,6 +20,6 @@ class Migration(migrations.Migration):
migrations.AlterField(
model_name='buildorderattachment',
name='attachment',
field=models.FileField(blank=True, help_text='Select file to attach', null=True, upload_to=InvenTree.models.rename_attachment, verbose_name='Attachment'),
field=models.FileField(blank=True, help_text='Select file to attach', null=True, upload_to='attachments', verbose_name='Attachment'),
),
]

View File

@ -0,0 +1,21 @@
# Generated by Django 4.2.12 on 2024-06-09 09:02
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('build', '0050_auto_20240508_0138'),
('common', '0026_auto_20240608_1238'),
('company', '0069_company_active'),
('order', '0099_alter_salesorder_status'),
('part', '0123_parttesttemplate_choices'),
('stock', '0110_alter_stockitemtestresult_finished_datetime_and_more')
]
operations = [
migrations.DeleteModel(
name='BuildOrderAttachment',
),
]

View File

@ -36,6 +36,7 @@ import InvenTree.tasks
import common.models
from common.notifications import trigger_notification, InvenTreeNotificationBodies
from common.settings import get_global_setting
from plugin.events import trigger_event
import part.models
@ -49,6 +50,7 @@ logger = logging.getLogger('inventree')
class Build(
report.mixins.InvenTreeReportMixin,
InvenTree.models.InvenTreeAttachmentMixin,
InvenTree.models.InvenTreeBarcodeMixin,
InvenTree.models.InvenTreeNotesMixin,
InvenTree.models.MetadataMixin,
@ -136,7 +138,7 @@ class Build(
super().clean()
if common.models.InvenTreeSetting.get_setting('BUILDORDER_REQUIRE_RESPONSIBLE'):
if get_global_setting('BUILDORDER_REQUIRE_RESPONSIBLE'):
if not self.responsible:
raise ValidationError({
'responsible': _('Responsible user or group must be specified')
@ -1321,16 +1323,6 @@ def after_save_build(sender, instance: Build, created: bool, **kwargs):
instance.update_build_line_items()
class BuildOrderAttachment(InvenTree.models.InvenTreeAttachment):
"""Model for storing file attachments against a BuildOrder object."""
def getSubdir(self):
"""Return the media file subdirectory for storing BuildOrder attachments"""
return os.path.join('bo_files', str(self.build.id))
build = models.ForeignKey(Build, on_delete=models.CASCADE, related_name='attachments')
class BuildLine(report.mixins.InvenTreeReportMixin, InvenTree.models.InvenTreeModel):
"""A BuildLine object links a BOMItem to a Build.

View File

@ -13,8 +13,7 @@ from django.db.models.functions import Coalesce
from rest_framework import serializers
from rest_framework.serializers import ValidationError
from InvenTree.serializers import InvenTreeModelSerializer, InvenTreeAttachmentSerializer
from InvenTree.serializers import UserSerializer
from InvenTree.serializers import InvenTreeModelSerializer, UserSerializer
import InvenTree.helpers
from InvenTree.serializers import InvenTreeDecimalField, NotesFieldMixin
@ -30,7 +29,7 @@ import part.filters
from part.serializers import BomItemSerializer, PartSerializer, PartBriefSerializer
from users.serializers import OwnerSerializer
from .models import Build, BuildLine, BuildItem, BuildOrderAttachment
from .models import Build, BuildLine, BuildItem
class BuildSerializer(NotesFieldMixin, InvenTreeModelSerializer):
@ -1311,15 +1310,3 @@ class BuildLineSerializer(InvenTreeModelSerializer):
)
return queryset
class BuildAttachmentSerializer(InvenTreeAttachmentSerializer):
"""Serializer for a BuildAttachment."""
class Meta:
"""Serializer metaclass"""
model = BuildOrderAttachment
fields = InvenTreeAttachmentSerializer.attachment_fields([
'build',
])

View File

@ -326,18 +326,7 @@ onPanelLoad('children', function() {
});
onPanelLoad('attachments', function() {
loadAttachmentTable('{% url "api-build-attachment-list" %}', {
filters: {
build: {{ build.pk }},
},
fields: {
build: {
value: {{ build.pk }},
hidden: true,
}
}
});
loadAttachmentTable('build', {{ build.pk }});
});
onPanelLoad('notes', function() {

View File

@ -12,6 +12,7 @@ from django.db.models import Sum
from InvenTree import status_codes as status
import common.models
from common.settings import set_global_setting
import build.tasks
from build.models import Build, BuildItem, BuildLine, generate_next_build_reference
from part.models import Part, BomItem, BomItemSubstitute, PartTestTemplate
@ -215,7 +216,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)
set_global_setting('BUILDORDER_REFERENCE_PATTERN', 'BO-{ref}-???', change_user=None)
refs = {
'BO-123-456': 123,
@ -238,7 +239,7 @@ class BuildTest(BuildTestBase):
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)
set_global_setting('BUILDORDER_REFERENCE_PATTERN', 'BO-{ref:04d}', change_user=None)
def test_ref_validation(self):
"""Test that the reference field validation works as expected"""
@ -271,7 +272,7 @@ class BuildTest(BuildTestBase):
)
# Try a new validator pattern
common.models.InvenTreeSetting.set_setting('BUILDORDER_REFERENCE_PATTERN', '{ref}-BO', change_user=None)
set_global_setting('BUILDORDER_REFERENCE_PATTERN', '{ref}-BO', change_user=None)
for ref in [
'1234-BO',
@ -285,11 +286,11 @@ class BuildTest(BuildTestBase):
)
# Set build reference back to default value
common.models.InvenTreeSetting.set_setting('BUILDORDER_REFERENCE_PATTERN', 'BO-{ref:04d}', change_user=None)
set_global_setting('BUILDORDER_REFERENCE_PATTERN', 'BO-{ref:04d}', change_user=None)
def test_next_ref(self):
"""Test that the next reference is automatically generated"""
common.models.InvenTreeSetting.set_setting('BUILDORDER_REFERENCE_PATTERN', 'XYZ-{ref:06d}', change_user=None)
set_global_setting('BUILDORDER_REFERENCE_PATTERN', 'XYZ-{ref:06d}', change_user=None)
build = Build.objects.create(
part=self.assembly,
@ -311,7 +312,7 @@ class BuildTest(BuildTestBase):
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)
set_global_setting('BUILDORDER_REFERENCE_PATTERN', 'BO-{ref:04d}', change_user=None)
def test_init(self):
"""Perform some basic tests before we start the ball rolling"""
@ -647,7 +648,7 @@ class BuildTest(BuildTestBase):
"""Test the prevention completion when a required test is missing feature"""
# with required tests incompleted the save should fail
common.models.InvenTreeSetting.set_setting('PREVENT_BUILD_COMPLETION_HAVING_INCOMPLETED_TESTS', True, change_user=None)
set_global_setting('PREVENT_BUILD_COMPLETION_HAVING_INCOMPLETED_TESTS', True, change_user=None)
with self.assertRaises(ValidationError):
self.build_w_tests_trackable.complete_build_output(self.stockitem_with_required_test, None)

View File

@ -19,7 +19,6 @@ class TestForwardMigrations(MigratorTestCase):
name='Widget',
description='Buildable Part',
active=True,
level=0, lft=0, rght=0, tree_id=0,
)
Build = self.old_state.apps.get_model('build', 'build')
@ -61,7 +60,6 @@ class TestReferenceMigration(MigratorTestCase):
part = Part.objects.create(
name='Part',
description='A test part',
level=0, lft=0, rght=0, tree_id=0,
)
Build = self.old_state.apps.get_model('build', 'build')

View File

@ -5,6 +5,34 @@ from django.contrib import admin
from import_export.admin import ImportExportModelAdmin
import common.models
import common.validators
@admin.register(common.models.Attachment)
class AttachmentAdmin(admin.ModelAdmin):
"""Admin interface for Attachment objects."""
def formfield_for_dbfield(self, db_field, request, **kwargs):
"""Provide custom choices for 'model_type' field."""
if db_field.name == 'model_type':
db_field.choices = common.validators.attachment_model_options()
return super().formfield_for_dbfield(db_field, request, **kwargs)
list_display = (
'model_type',
'model_id',
'attachment',
'link',
'upload_user',
'upload_date',
)
list_filter = ['model_type', 'upload_user']
readonly_fields = ['file_size', 'upload_date', 'upload_user']
search_fields = ('content_type', 'comment')
@admin.register(common.models.ProjectCode)
@ -16,6 +44,7 @@ class ProjectCodeAdmin(ImportExportModelAdmin):
search_fields = ('code', 'description')
@admin.register(common.models.InvenTreeSetting)
class SettingsAdmin(ImportExportModelAdmin):
"""Admin settings for InvenTreeSetting."""
@ -28,6 +57,7 @@ class SettingsAdmin(ImportExportModelAdmin):
return []
@admin.register(common.models.InvenTreeUserSetting)
class UserSettingsAdmin(ImportExportModelAdmin):
"""Admin settings for InvenTreeUserSetting."""
@ -40,18 +70,21 @@ class UserSettingsAdmin(ImportExportModelAdmin):
return []
@admin.register(common.models.WebhookEndpoint)
class WebhookAdmin(ImportExportModelAdmin):
"""Admin settings for Webhook."""
list_display = ('endpoint_id', 'name', 'active', 'user')
@admin.register(common.models.NotificationEntry)
class NotificationEntryAdmin(admin.ModelAdmin):
"""Admin settings for NotificationEntry."""
list_display = ('key', 'uid', 'updated')
@admin.register(common.models.NotificationMessage)
class NotificationMessageAdmin(admin.ModelAdmin):
"""Admin settings for NotificationMessage."""
@ -70,16 +103,11 @@ class NotificationMessageAdmin(admin.ModelAdmin):
search_fields = ('name', 'category', 'message')
@admin.register(common.models.NewsFeedEntry)
class NewsFeedEntryAdmin(admin.ModelAdmin):
"""Admin settings for NewsFeedEntry."""
list_display = ('title', 'author', 'published', 'summary')
admin.site.register(common.models.InvenTreeSetting, SettingsAdmin)
admin.site.register(common.models.InvenTreeUserSetting, UserSettingsAdmin)
admin.site.register(common.models.WebhookEndpoint, WebhookAdmin)
admin.site.register(common.models.WebhookMessage, ImportExportModelAdmin)
admin.site.register(common.models.NotificationEntry, NotificationEntryAdmin)
admin.site.register(common.models.NotificationMessage, NotificationMessageAdmin)
admin.site.register(common.models.NewsFeedEntry, NewsFeedEntryAdmin)

View File

@ -4,24 +4,28 @@ import json
from django.conf import settings
from django.contrib.contenttypes.models import ContentType
from django.db.models import Q
from django.http.response import HttpResponse
from django.urls import include, path, re_path
from django.utils.decorators import method_decorator
from django.utils.translation import gettext_lazy as _
from django.views.decorators.csrf import csrf_exempt
import django_q.models
from django_filters import rest_framework as rest_filters
from django_q.tasks import async_task
from djmoney.contrib.exchange.models import ExchangeBackend, Rate
from drf_spectacular.utils import OpenApiResponse, extend_schema
from error_report.models import Error
from rest_framework import permissions, serializers
from rest_framework.exceptions import NotAcceptable, NotFound
from rest_framework.exceptions import NotAcceptable, NotFound, PermissionDenied
from rest_framework.permissions import IsAdminUser
from rest_framework.response import Response
from rest_framework.views import APIView
import common.models
import common.serializers
from common.settings import get_global_setting
from generic.states.api import AllStatusViews, StatusView
from InvenTree.api import BulkDeleteMixin, MetadataView
from InvenTree.config import CONFIG_LOOKUPS
@ -149,7 +153,7 @@ class CurrencyExchangeView(APIView):
updated = None
response = {
'base_currency': common.models.InvenTreeSetting.get_setting(
'base_currency': get_global_setting(
'INVENTREE_DEFAULT_CURRENCY', backup_value='USD'
),
'exchange_rates': {},
@ -673,6 +677,71 @@ class ContentTypeModelDetail(ContentTypeDetail):
raise NotFound()
class AttachmentFilter(rest_filters.FilterSet):
"""Filterset for the AttachmentList API endpoint."""
class Meta:
"""Metaclass options."""
model = common.models.Attachment
fields = ['model_type', 'model_id', 'upload_user']
is_link = rest_filters.BooleanFilter(label=_('Is Link'), method='filter_is_link')
def filter_is_link(self, queryset, name, value):
"""Filter attachments based on whether they are a link or not."""
if value:
return queryset.exclude(link=None).exclude(link='')
return queryset.filter(Q(link=None) | Q(link='')).distinct()
is_file = rest_filters.BooleanFilter(label=_('Is File'), method='filter_is_file')
def filter_is_file(self, queryset, name, value):
"""Filter attachments based on whether they are a file or not."""
if value:
return queryset.exclude(attachment=None).exclude(attachment='')
return queryset.filter(Q(attachment=None) | Q(attachment='')).distinct()
class AttachmentList(ListCreateAPI):
"""List API endpoint for Attachment objects."""
queryset = common.models.Attachment.objects.all()
serializer_class = common.serializers.AttachmentSerializer
permission_classes = [permissions.IsAuthenticated]
filter_backends = SEARCH_ORDER_FILTER
filterset_class = AttachmentFilter
ordering_fields = ['model_id', 'model_type', 'upload_date', 'file_size']
search_fields = ['comment', 'model_id', 'model_type']
def perform_create(self, serializer):
"""Save the user information when a file is uploaded."""
attachment = serializer.save()
attachment.upload_user = self.request.user
attachment.save()
class AttachmentDetail(RetrieveUpdateDestroyAPI):
"""Detail API endpoint for Attachment objects."""
queryset = common.models.Attachment.objects.all()
serializer_class = common.serializers.AttachmentSerializer
permission_classes = [permissions.IsAuthenticated]
def destroy(self, request, *args, **kwargs):
"""Check user permissions before deleting an attachment."""
attachment = self.get_object()
if not attachment.check_permission('delete', request.user):
raise PermissionDenied(
_('User does not have permission to delete this attachment')
)
return super().destroy(request, *args, **kwargs)
settings_api_urls = [
# User settings
path(
@ -741,6 +810,25 @@ common_api_urls = [
path('', BackgroundTaskOverview.as_view(), name='api-task-overview'),
]),
),
# Attachments
path(
'attachment/',
include([
path(
'<int:pk>/',
include([
path(
'metadata/',
MetadataView.as_view(),
{'model': common.models.Attachment},
name='api-attachment-metadata',
),
path('', AttachmentDetail.as_view(), name='api-attachment-detail'),
]),
),
path('', AttachmentList.as_view(), name='api-attachment-list'),
]),
),
path(
'error-report/',
include([

View File

@ -5,6 +5,7 @@ import logging
from django.apps import AppConfig
import InvenTree.ready
from common.settings import get_global_setting, set_global_setting
logger = logging.getLogger('inventree')
@ -27,16 +28,12 @@ class CommonConfig(AppConfig):
def clear_restart_flag(self):
"""Clear the SERVER_RESTART_REQUIRED setting."""
try:
import common.models
if common.models.InvenTreeSetting.get_setting(
if get_global_setting(
'SERVER_RESTART_REQUIRED', backup_value=False, create=False, cache=False
):
logger.info('Clearing SERVER_RESTART_REQUIRED flag')
if not InvenTree.ready.isImportingData():
common.models.InvenTreeSetting.set_setting(
'SERVER_RESTART_REQUIRED', False, None
)
set_global_setting('SERVER_RESTART_REQUIRED', False, None)
except Exception:
pass

View File

@ -17,7 +17,7 @@ logger = logging.getLogger('inventree')
def currency_code_default():
"""Returns the default currency code (or USD if not specified)."""
from common.models import InvenTreeSetting
from common.settings import get_global_setting
try:
cached_value = cache.get('currency_code_default', '')
@ -28,9 +28,7 @@ def currency_code_default():
return cached_value
try:
code = InvenTreeSetting.get_setting(
'INVENTREE_DEFAULT_CURRENCY', backup_value='', create=True, cache=True
)
code = get_global_setting('INVENTREE_DEFAULT_CURRENCY', create=True, cache=True)
except Exception: # pragma: no cover
# Database may not yet be ready, no need to throw an error here
code = ''
@ -59,9 +57,9 @@ def currency_codes_default_list() -> str:
def currency_codes() -> list:
"""Returns the current currency codes."""
from common.models import InvenTreeSetting
from common.settings import get_global_setting
codes = InvenTreeSetting.get_setting('CURRENCY_CODES', '', create=False).strip()
codes = get_global_setting('CURRENCY_CODES', create=False).strip()
if not codes:
codes = currency_codes_default_list()
@ -150,6 +148,9 @@ def currency_exchange_plugins() -> list:
except Exception:
plugs = []
if len(plugs) == 0:
return None
return [('', _('No plugin'))] + [(plug.slug, plug.human_name) for plug in plugs]

View File

@ -0,0 +1,43 @@
# Generated by Django 4.2.12 on 2024-06-08 12:37
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import taggit.managers
import common.models
import common.validators
import InvenTree.fields
import InvenTree.models
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('contenttypes', '0002_remove_content_type_name'),
('common', '0024_notesimage_model_id_notesimage_model_type'),
]
operations = [
migrations.CreateModel(
name='Attachment',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('model_id', models.PositiveIntegerField()),
('attachment', models.FileField(blank=True, help_text='Select file to attach', null=True, upload_to=common.models.rename_attachment, verbose_name='Attachment')),
('link', InvenTree.fields.InvenTreeURLField(blank=True, help_text='Link to external URL', null=True, verbose_name='Link')),
('comment', models.CharField(blank=True, help_text='Attachment comment', max_length=250, verbose_name='Comment')),
('upload_date', models.DateField(auto_now_add=True, help_text='Date the file was uploaded', null=True, verbose_name='Upload date')),
('file_size', models.PositiveIntegerField(default=0, help_text='File size in bytes', verbose_name='File size')),
('model_type', models.CharField(help_text='Target model type for this image', max_length=100, validators=[common.validators.validate_attachment_model_type])),
('upload_user', models.ForeignKey(blank=True, help_text='User', null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, verbose_name='User')),
('metadata', models.JSONField(blank=True, help_text='JSON metadata field, for use by external plugins', null=True, verbose_name='Plugin Metadata')),
('tags', taggit.managers.TaggableManager(blank=True, help_text='A comma-separated list of tags.', through='taggit.TaggedItem', to='taggit.Tag', verbose_name='Tags'))
],
bases=(InvenTree.models.PluginValidationMixin, models.Model),
options={
'verbose_name': 'Attachment',
}
),
]

View File

@ -0,0 +1,122 @@
# Generated by Django 4.2.12 on 2024-06-08 12:38
from django.db import migrations
from django.core.files.storage import default_storage
def get_legacy_models():
"""Return a set of legacy attachment models."""
# Legacy attachment types to convert:
# app_label, table name, target model, model ref
return [
('build', 'BuildOrderAttachment', 'build', 'build'),
('company', 'CompanyAttachment', 'company', 'company'),
('company', 'ManufacturerPartAttachment', 'manufacturerpart', 'manufacturer_part'),
('order', 'PurchaseOrderAttachment', 'purchaseorder', 'order'),
('order', 'SalesOrderAttachment', 'salesorder', 'order'),
('order', 'ReturnOrderAttachment', 'returnorder', 'order'),
('part', 'PartAttachment', 'part', 'part'),
('stock', 'StockItemAttachment', 'stockitem', 'stock_item')
]
def update_attachments(apps, schema_editor):
"""Migrate any existing attachment models to the new attachment table."""
Attachment = apps.get_model('common', 'attachment')
N = 0
for app, model, target_model, model_ref in get_legacy_models():
LegacyAttachmentModel = apps.get_model(app, model)
if LegacyAttachmentModel.objects.count() == 0:
continue
to_create = []
for attachment in LegacyAttachmentModel.objects.all():
# Find the size of the file (if exists)
if attachment.attachment and default_storage.exists(attachment.attachment.name):
try:
file_size = default_storage.size(attachment.attachment.name)
except NotImplementedError:
file_size = 0
else:
file_size = 0
to_create.append(
Attachment(
model_type=target_model,
model_id=getattr(attachment, model_ref).pk,
attachment=attachment.attachment,
link=attachment.link,
comment=attachment.comment,
upload_date=attachment.upload_date,
upload_user=attachment.user,
file_size=file_size
)
)
if len(to_create) > 0:
print(f"Migrating {len(to_create)} attachments for the legacy '{model}' model.")
Attachment.objects.bulk_create(to_create)
N += len(to_create)
# Check the correct number of Attachment objects has been created
assert(N == Attachment.objects.count())
def reverse_attachments(apps, schema_editor):
"""Reverse data migration, and map new Attachment model back to legacy models."""
Attachment = apps.get_model('common', 'attachment')
N = 0
for app, model, target_model, model_ref in get_legacy_models():
LegacyAttachmentModel = apps.get_model(app, model)
to_create = []
for attachment in Attachment.objects.filter(model_type=target_model):
TargetModel = apps.get_model(app, target_model)
data = {
'attachment': attachment.attachment,
'link': attachment.link,
'comment': attachment.comment,
'upload_date': attachment.upload_date,
'user': attachment.upload_user,
model_ref: TargetModel.objects.get(pk=attachment.model_id)
}
to_create.append(LegacyAttachmentModel(**data))
if len(to_create) > 0:
print(f"Reversing {len(to_create)} attachments for the legacy '{model}' model.")
LegacyAttachmentModel.objects.bulk_create(to_create)
N += len(to_create)
# Check the correct number of LegacyAttachmentModel objects has been created
assert(N == Attachment.objects.count())
class Migration(migrations.Migration):
dependencies = [
('build', '0050_auto_20240508_0138'),
('common', '0025_attachment'),
('company', '0069_company_active'),
('order', '0099_alter_salesorder_status'),
('part', '0123_parttesttemplate_choices'),
('stock', '0110_alter_stockitemtestresult_finished_datetime_and_more')
]
operations = [
migrations.RunPython(update_attachments, reverse_code=reverse_attachments),
]

View File

@ -12,6 +12,7 @@ import os
import uuid
from datetime import timedelta, timezone
from enum import Enum
from io import BytesIO
from secrets import compare_digest
from typing import Any, Callable, TypedDict, Union
@ -23,6 +24,7 @@ from django.contrib.contenttypes.models import ContentType
from django.contrib.humanize.templatetags.humanize import naturaltime
from django.core.cache import cache
from django.core.exceptions import ValidationError
from django.core.files.storage import default_storage
from django.core.validators import MaxValueValidator, MinValueValidator, URLValidator
from django.db import models, transaction
from django.db.models.signals import post_delete, post_save
@ -35,6 +37,7 @@ from django.utils.translation import gettext_lazy as _
from djmoney.contrib.exchange.exceptions import MissingRate
from djmoney.contrib.exchange.models import convert_money
from rest_framework.exceptions import PermissionDenied
from taggit.managers import TaggableManager
import build.validators
import common.currency
@ -48,6 +51,7 @@ import InvenTree.validators
import order.validators
import report.helpers
import users.models
from InvenTree.sanitizer import sanitize_svg
from plugin import registry
logger = logging.getLogger('inventree')
@ -549,25 +553,25 @@ class BaseInvenTreeSetting(models.Model):
"""
key = str(key).strip().upper()
filters = {
'key__iexact': key,
# Optionally filter by other keys
**cls.get_filters(**kwargs),
}
# Unless otherwise specified, attempt to create the setting
create = kwargs.pop('create', True)
# Specify if cache lookup should be performed
do_cache = kwargs.pop('cache', django_settings.GLOBAL_CACHE_ENABLED)
# Prevent saving to the database during data import
if InvenTree.ready.isImportingData():
create = False
do_cache = False
filters = {
'key__iexact': key,
# Optionally filter by other keys
**cls.get_filters(**kwargs),
}
# Prevent saving to the database during migrations
if InvenTree.ready.isRunningMigrations():
# Prevent saving to the database during certain operations
if (
InvenTree.ready.isImportingData()
or InvenTree.ready.isRunningMigrations()
or InvenTree.ready.isRebuildingData()
or InvenTree.ready.isRunningBackup()
):
create = False
do_cache = False
@ -594,33 +598,21 @@ class BaseInvenTreeSetting(models.Model):
setting = None
# Setting does not exist! (Try to create it)
if not setting:
# Prevent creation of new settings objects when importing data
if (
InvenTree.ready.isImportingData()
or not InvenTree.ready.canAppAccessDatabase(
allow_test=True, allow_shell=True
)
):
create = False
if not setting and create:
# Attempt to create a new settings object
default_value = cls.get_setting_default(key, **kwargs)
setting = cls(key=key, value=default_value, **kwargs)
if create:
# Attempt to create a new settings object
default_value = cls.get_setting_default(key, **kwargs)
setting = cls(key=key, value=default_value, **kwargs)
try:
# Wrap this statement in "atomic", so it can be rolled back if it fails
with transaction.atomic():
setting.save(**kwargs)
except (IntegrityError, OperationalError, ProgrammingError):
# It might be the case that the database isn't created yet
pass
except ValidationError:
# The setting failed validation - might be due to duplicate keys
pass
try:
# Wrap this statement in "atomic", so it can be rolled back if it fails
with transaction.atomic():
setting.save(**kwargs)
except (IntegrityError, OperationalError, ProgrammingError):
# It might be the case that the database isn't created yet
pass
except ValidationError:
# The setting failed validation - might be due to duplicate keys
pass
if setting and do_cache:
# Cache this setting object
@ -694,6 +686,15 @@ class BaseInvenTreeSetting(models.Model):
if change_user is not None and not change_user.is_staff:
return
# Do not write to the database under certain conditions
if (
InvenTree.ready.isImportingData()
or InvenTree.ready.isRunningMigrations()
or InvenTree.ready.isRebuildingData()
or InvenTree.ready.isRunningBackup()
):
return
attempts = int(kwargs.get('attempts', 3))
filters = {
@ -3062,3 +3063,184 @@ def after_custom_unit_updated(sender, instance, **kwargs):
from InvenTree.conversion import reload_unit_registry
reload_unit_registry()
def rename_attachment(instance, filename):
"""Callback function to rename an uploaded attachment file.
Arguments:
- instance: The Attachment instance
- filename: The original filename of the uploaded file
Returns:
- The new filename for the uploaded file, e.g. 'attachments/<model_type>/<model_id>/<filename>'
"""
# Remove any illegal characters from the filename
illegal_chars = '\'"\\`~#|!@#$%^&*()[]{}<>?;:+=,'
for c in illegal_chars:
filename = filename.replace(c, '')
filename = os.path.basename(filename)
# Generate a new filename for the attachment
return os.path.join(
'attachments', str(instance.model_type), str(instance.model_id), filename
)
class Attachment(InvenTree.models.MetadataMixin, InvenTree.models.InvenTreeModel):
"""Class which represents an uploaded file attachment.
An attachment can be either an uploaded file, or an external URL.
Attributes:
attachment: The uploaded file
url: An external URL
comment: A comment or description for the attachment
user: The user who uploaded the attachment
upload_date: The date the attachment was uploaded
file_size: The size of the uploaded file
metadata: Arbitrary metadata for the attachment (inherit from MetadataMixin)
tags: Tags for the attachment
"""
class Meta:
"""Metaclass options."""
verbose_name = _('Attachment')
def save(self, *args, **kwargs):
"""Custom 'save' method for the Attachment model.
- Record the file size of the uploaded attachment (if applicable)
- Ensure that the 'content_type' and 'object_id' fields are set
- Run extra validations
"""
# Either 'attachment' or 'link' must be specified!
if not self.attachment and not self.link:
raise ValidationError({
'attachment': _('Missing file'),
'link': _('Missing external link'),
})
if self.attachment:
if self.attachment.name.lower().endswith('.svg'):
self.attachment.file.file = self.clean_svg(self.attachment)
else:
self.file_size = 0
super().save(*args, **kwargs)
# Update file size
if self.file_size == 0 and self.attachment:
# Get file size
if default_storage.exists(self.attachment.name):
try:
self.file_size = default_storage.size(self.attachment.name)
except Exception:
pass
if self.file_size != 0:
super().save()
def clean_svg(self, field):
"""Sanitize SVG file before saving."""
cleaned = sanitize_svg(field.file.read())
return BytesIO(bytes(cleaned, 'utf8'))
def __str__(self):
"""Human name for attachment."""
if self.attachment is not None:
return os.path.basename(self.attachment.name)
return str(self.link)
model_type = models.CharField(
max_length=100,
validators=[common.validators.validate_attachment_model_type],
help_text=_('Target model type for this image'),
)
model_id = models.PositiveIntegerField()
attachment = models.FileField(
upload_to=rename_attachment,
verbose_name=_('Attachment'),
help_text=_('Select file to attach'),
blank=True,
null=True,
)
link = InvenTree.fields.InvenTreeURLField(
blank=True,
null=True,
verbose_name=_('Link'),
help_text=_('Link to external URL'),
)
comment = models.CharField(
blank=True,
max_length=250,
verbose_name=_('Comment'),
help_text=_('Attachment comment'),
)
upload_user = models.ForeignKey(
User,
on_delete=models.SET_NULL,
blank=True,
null=True,
verbose_name=_('User'),
help_text=_('User'),
)
upload_date = models.DateField(
auto_now_add=True,
null=True,
blank=True,
verbose_name=_('Upload date'),
help_text=_('Date the file was uploaded'),
)
file_size = models.PositiveIntegerField(
default=0, verbose_name=_('File size'), help_text=_('File size in bytes')
)
tags = TaggableManager(blank=True)
@property
def basename(self):
"""Base name/path for attachment."""
if self.attachment:
return os.path.basename(self.attachment.name)
return None
def fully_qualified_url(self):
"""Return a 'fully qualified' URL for this attachment.
- If the attachment is a link to an external resource, return the link
- If the attachment is an uploaded file, return the fully qualified media URL
"""
if self.link:
return self.link
if self.attachment:
import InvenTree.helpers_model
media_url = InvenTree.helpers.getMediaUrl(self.attachment.url)
return InvenTree.helpers_model.construct_absolute_url(media_url)
return ''
def check_permission(self, permission, user):
"""Check if the user has the required permission for this attachment."""
from InvenTree.models import InvenTreeAttachmentMixin
model_class = common.validators.attachment_model_class_from_label(
self.model_type
)
if not issubclass(model_class, InvenTreeAttachmentMixin):
raise ValidationError(_('Invalid model type specified for attachment'))
return model_class.check_attachment_permission(permission, user)

View File

@ -9,13 +9,18 @@ import django_q.models
from error_report.models import Error
from flags.state import flag_state
from rest_framework import serializers
from rest_framework.exceptions import PermissionDenied
from taggit.serializers import TagListSerializerField
import common.models as common_models
import common.validators
from InvenTree.helpers import get_objectreference
from InvenTree.helpers_model import construct_absolute_url
from InvenTree.serializers import (
InvenTreeAttachmentSerializerField,
InvenTreeImageSerializerField,
InvenTreeModelSerializer,
UserSerializer,
)
from plugin import registry as plugin_registry
from users.serializers import OwnerSerializer
@ -474,3 +479,85 @@ class FailedTaskSerializer(InvenTreeModelSerializer):
pk = serializers.CharField(source='id', read_only=True)
result = serializers.CharField()
class AttachmentSerializer(InvenTreeModelSerializer):
"""Serializer class for the Attachment model."""
class Meta:
"""Serializer metaclass."""
model = common_models.Attachment
fields = [
'pk',
'attachment',
'filename',
'link',
'comment',
'upload_date',
'upload_user',
'user_detail',
'file_size',
'model_type',
'model_id',
'tags',
]
read_only_fields = ['pk', 'file_size', 'upload_date', 'upload_user', 'filename']
def __init__(self, *args, **kwargs):
"""Override the model_type field to provide dynamic choices."""
super().__init__(*args, **kwargs)
if len(self.fields['model_type'].choices) == 0:
self.fields[
'model_type'
].choices = common.validators.attachment_model_options()
tags = TagListSerializerField(required=False)
user_detail = UserSerializer(source='upload_user', read_only=True, many=False)
attachment = InvenTreeAttachmentSerializerField(required=False, allow_null=True)
# The 'filename' field must be present in the serializer
filename = serializers.CharField(
label=_('Filename'), required=False, source='basename', allow_blank=False
)
upload_date = serializers.DateField(read_only=True)
# Note: The choices are overridden at run-time on class initialization
model_type = serializers.ChoiceField(
label=_('Model Type'),
choices=common.validators.attachment_model_options(),
required=True,
allow_blank=False,
allow_null=False,
)
def save(self):
"""Override the save method to handle the model_type field."""
from InvenTree.models import InvenTreeAttachmentMixin
model_type = self.validated_data.get('model_type', None)
# Ensure that the user has permission to attach files to the specified model
user = self.context.get('request').user
target_model_class = common.validators.attachment_model_class_from_label(
model_type
)
if not issubclass(target_model_class, InvenTreeAttachmentMixin):
raise PermissionDenied(_('Invalid model type specified for attachment'))
# Check that the user has the required permissions to attach files to the target model
if not target_model_class.check_attachment_permission('change', user):
raise PermissionDenied(
_(
'User does not have permission to create or edit attachments for this model'
)
)
return super().save()

View File

@ -1,6 +1,47 @@
"""User-configurable settings for the common app."""
def get_global_setting(key, backup_value=None, **kwargs):
"""Return the value of a global setting using the provided key."""
from common.models import InvenTreeSetting
if backup_value is not None:
kwargs['backup_value'] = backup_value
return InvenTreeSetting.get_setting(key, **kwargs)
def set_global_setting(key, value, change_user=None, create=True, **kwargs):
"""Set the value of a global setting using the provided key."""
from common.models import InvenTreeSetting
kwargs['change_user'] = change_user
kwargs['create'] = create
return InvenTreeSetting.set_setting(key, value, **kwargs)
def get_user_setting(key, user, backup_value=None, **kwargs):
"""Return the value of a user-specific setting using the provided key."""
from common.models import InvenTreeUserSetting
kwargs['user'] = user
if backup_value is not None:
kwargs['backup_value'] = backup_value
return InvenTreeUserSetting.get_setting(key, **kwargs)
def set_user_setting(key, value, user, **kwargs):
"""Set the value of a user-specific setting using the provided key."""
from common.models import InvenTreeUserSetting
kwargs['user'] = user
return InvenTreeUserSetting.set_setting(key, value, **kwargs)
def stock_expiry_enabled():
"""Returns True if the stock expiry feature is enabled."""
from common.models import InvenTreeSetting

View File

@ -0,0 +1,210 @@
"""Data migration unit tests for the 'common' app."""
import io
from django.core.files.base import ContentFile
from django_test_migrations.contrib.unittest_case import MigratorTestCase
from InvenTree import unit_test
def get_legacy_models():
"""Return a set of legacy attachment models."""
# Legacy attachment types to convert:
# app_label, table name, target model, model ref
return [
('build', 'BuildOrderAttachment', 'build', 'build'),
('company', 'CompanyAttachment', 'company', 'company'),
(
'company',
'ManufacturerPartAttachment',
'manufacturerpart',
'manufacturer_part',
),
('order', 'PurchaseOrderAttachment', 'purchaseorder', 'order'),
('order', 'SalesOrderAttachment', 'salesorder', 'order'),
('order', 'ReturnOrderAttachment', 'returnorder', 'order'),
('part', 'PartAttachment', 'part', 'part'),
('stock', 'StockItemAttachment', 'stockitem', 'stock_item'),
]
def generate_attachment():
"""Generate a file attachment object for test upload."""
file_object = io.StringIO('Some dummy data')
file_object.seek(0)
return ContentFile(file_object.getvalue(), 'test.txt')
class TestForwardMigrations(MigratorTestCase):
"""Test entire schema migration sequence for the common app."""
migrate_from = ('common', '0024_notesimage_model_id_notesimage_model_type')
migrate_to = ('common', unit_test.getNewestMigrationFile('common'))
def prepare(self):
"""Create initial data.
Legacy attachment model types are:
- BuildOrderAttachment
- CompanyAttachment
- ManufacturerPartAttachment
- PurchaseOrderAttachment
- SalesOrderAttachment
- ReturnOrderAttachment
- PartAttachment
- StockItemAttachment
"""
# Dummy MPPT data
tree = {'tree_id': 0, 'level': 0, 'lft': 0, 'rght': 0}
# BuildOrderAttachment
Part = self.old_state.apps.get_model('part', 'Part')
Build = self.old_state.apps.get_model('build', 'Build')
part = Part.objects.create(
name='Test Part',
description='Test Part Description',
active=True,
assembly=True,
purchaseable=True,
**tree,
)
build = Build.objects.create(part=part, title='Test Build', quantity=10, **tree)
PartAttachment = self.old_state.apps.get_model('part', 'PartAttachment')
PartAttachment.objects.create(
part=part, attachment=generate_attachment(), comment='Test file attachment'
)
PartAttachment.objects.create(
part=part, link='http://example.com', comment='Test link attachment'
)
self.assertEqual(PartAttachment.objects.count(), 2)
BuildOrderAttachment = self.old_state.apps.get_model(
'build', 'BuildOrderAttachment'
)
BuildOrderAttachment.objects.create(
build=build, link='http://example.com', comment='Test comment'
)
BuildOrderAttachment.objects.create(
build=build, attachment=generate_attachment(), comment='a test file'
)
self.assertEqual(BuildOrderAttachment.objects.count(), 2)
StockItem = self.old_state.apps.get_model('stock', 'StockItem')
StockItemAttachment = self.old_state.apps.get_model(
'stock', 'StockItemAttachment'
)
item = StockItem.objects.create(part=part, quantity=10, **tree)
StockItemAttachment.objects.create(
stock_item=item,
attachment=generate_attachment(),
comment='Test file attachment',
)
StockItemAttachment.objects.create(
stock_item=item, link='http://example.com', comment='Test link attachment'
)
self.assertEqual(StockItemAttachment.objects.count(), 2)
Company = self.old_state.apps.get_model('company', 'Company')
CompanyAttachment = self.old_state.apps.get_model(
'company', 'CompanyAttachment'
)
company = Company.objects.create(
name='Test Company',
description='Test Company Description',
is_customer=True,
is_manufacturer=True,
is_supplier=True,
)
CompanyAttachment.objects.create(
company=company,
attachment=generate_attachment(),
comment='Test file attachment',
)
CompanyAttachment.objects.create(
company=company, link='http://example.com', comment='Test link attachment'
)
self.assertEqual(CompanyAttachment.objects.count(), 2)
PurchaseOrder = self.old_state.apps.get_model('order', 'PurchaseOrder')
PurchaseOrderAttachment = self.old_state.apps.get_model(
'order', 'PurchaseOrderAttachment'
)
po = PurchaseOrder.objects.create(
reference='PO-12345',
supplier=company,
description='Test Purchase Order Description',
)
PurchaseOrderAttachment.objects.create(
order=po, attachment=generate_attachment(), comment='Test file attachment'
)
PurchaseOrderAttachment.objects.create(
order=po, link='http://example.com', comment='Test link attachment'
)
self.assertEqual(PurchaseOrderAttachment.objects.count(), 2)
SalesOrder = self.old_state.apps.get_model('order', 'SalesOrder')
SalesOrderAttachment = self.old_state.apps.get_model(
'order', 'SalesOrderAttachment'
)
so = SalesOrder.objects.create(
reference='SO-12345',
customer=company,
description='Test Sales Order Description',
)
SalesOrderAttachment.objects.create(
order=so, attachment=generate_attachment(), comment='Test file attachment'
)
SalesOrderAttachment.objects.create(
order=so, link='http://example.com', comment='Test link attachment'
)
self.assertEqual(SalesOrderAttachment.objects.count(), 2)
ReturnOrder = self.old_state.apps.get_model('order', 'ReturnOrder')
ReturnOrderAttachment = self.old_state.apps.get_model(
'order', 'ReturnOrderAttachment'
)
ro = ReturnOrder.objects.create(
reference='RO-12345',
customer=company,
description='Test Return Order Description',
)
ReturnOrderAttachment.objects.create(
order=ro, attachment=generate_attachment(), comment='Test file attachment'
)
ReturnOrderAttachment.objects.create(
order=ro, link='http://example.com', comment='Test link attachment'
)
self.assertEqual(ReturnOrderAttachment.objects.count(), 2)
def test_items_exist(self):
"""Test to ensure that the attachments are correctly migrated."""
Attachment = self.new_state.apps.get_model('common', 'Attachment')
self.assertEqual(Attachment.objects.count(), 14)
for model in [
'build',
'company',
'purchaseorder',
'returnorder',
'salesorder',
'part',
'stockitem',
]:
self.assertEqual(Attachment.objects.filter(model_type=model).count(), 2)

View File

@ -1 +0,0 @@
"""Unit tests for the views associated with the 'common' app."""

View File

@ -11,6 +11,8 @@ from django.contrib.auth import get_user_model
from django.contrib.contenttypes.models import ContentType
from django.core.cache import cache
from django.core.exceptions import ValidationError
from django.core.files.base import ContentFile
from django.core.files.storage import default_storage
from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import Client, TestCase
from django.test.utils import override_settings
@ -18,13 +20,16 @@ from django.urls import reverse
import PIL
from common.settings import get_global_setting, set_global_setting
from InvenTree.helpers import str2bool
from InvenTree.unit_test import InvenTreeAPITestCase, InvenTreeTestCase, PluginMixin
from part.models import Part
from plugin import registry
from plugin.models import NotificationUserSetting
from .api import WebhookView
from .models import (
Attachment,
ColorTheme,
CustomUnit,
InvenTreeSetting,
@ -40,6 +45,131 @@ from .models import (
CONTENT_TYPE_JSON = 'application/json'
class AttachmentTest(InvenTreeAPITestCase):
"""Unit tests for the 'Attachment' model."""
fixtures = ['part', 'category', 'location']
def generate_file(self, fn: str):
"""Generate an attachment file object."""
file_object = io.StringIO('Some dummy data')
file_object.seek(0)
return ContentFile(file_object.getvalue(), fn)
def test_filename_validation(self):
"""Test that the filename validation works as expected.
The django file-upload mechanism should sanitize filenames correctly.
"""
part = Part.objects.first()
filenames = {
'test.txt': 'test.txt',
'r####at.mp4': 'rat.mp4',
'../../../win32.dll': 'win32.dll',
'ABC!@#$%^&&&&&&&)-XYZ-(**&&&\\/QqQ.sqlite': 'QqQ.sqlite',
'/var/log/inventree.log': 'inventree.log',
'c:\\Users\\admin\\passwd.txt': 'cUsersadminpasswd.txt',
'8&&&8.txt': '88.txt',
}
for fn, expected in filenames.items():
attachment = Attachment.objects.create(
attachment=self.generate_file(fn),
comment=f'Testing filename: {fn}',
model_type='part',
model_id=part.pk,
)
expected_path = f'attachments/part/{part.pk}/{expected}'
self.assertEqual(attachment.attachment.name, expected_path)
self.assertEqual(attachment.file_size, 15)
self.assertEqual(part.attachments.count(), len(filenames.keys()))
# Delete any attachments after the test is completed
for attachment in part.attachments.all():
path = attachment.attachment.name
attachment.delete()
# Remove uploaded files to prevent them sticking around
if default_storage.exists(path):
default_storage.delete(path)
self.assertEqual(
Attachment.objects.filter(model_type='part', model_id=part.pk).count(), 0
)
def test_mixin(self):
"""Test that the mixin class works as expected."""
part = Part.objects.first()
self.assertEqual(part.attachments.count(), 0)
part.create_attachment(
attachment=self.generate_file('test.txt'), comment='Hello world'
)
self.assertEqual(part.attachments.count(), 1)
attachment = part.attachments.first()
self.assertEqual(attachment.comment, 'Hello world')
self.assertIn(f'attachments/part/{part.pk}/test', attachment.attachment.name)
def test_upload_via_api(self):
"""Test that we can upload attachments via the API."""
part = Part.objects.first()
url = reverse('api-attachment-list')
data = {
'model_type': 'part',
'model_id': part.pk,
'link': 'https://www.google.com',
'comment': 'Some appropriate comment',
}
# Start without appropriate permissions
# User must have 'part.change' to upload an attachment against a Part instance
self.logout()
self.user.is_staff = False
self.user.is_superuser = False
self.user.save()
self.clearRoles()
# Check without login (401)
response = self.post(url, data, expected_code=401)
self.login()
response = self.post(url, data, expected_code=403)
self.assertIn(
'User does not have permission to create or edit attachments for this model',
str(response.data['detail']),
)
# Add the required permission
self.assignRole('part.change')
# Upload should now work!
response = self.post(url, data, expected_code=201)
# Try to delete the attachment via API (should fail)
attachment = part.attachments.first()
url = reverse('api-attachment-detail', kwargs={'pk': attachment.pk})
response = self.delete(url, expected_code=403)
self.assertIn(
'User does not have permission to delete this attachment',
str(response.data['detail']),
)
# Assign 'delete' permission to 'part' model
self.assignRole('part.delete')
response = self.delete(url, expected_code=204)
class SettingsTest(InvenTreeTestCase):
"""Tests for the 'settings' model."""
@ -273,13 +403,19 @@ class SettingsTest(InvenTreeTestCase):
print(f"run_settings_check failed for user setting '{key}'")
raise exc
@override_settings(SITE_URL=None)
@override_settings(SITE_URL=None, PLUGIN_TESTING=True, PLUGIN_TESTING_SETUP=True)
def test_defaults(self):
"""Populate the settings with default values."""
N = len(InvenTreeSetting.SETTINGS.keys())
for key in InvenTreeSetting.SETTINGS.keys():
value = InvenTreeSetting.get_setting_default(key)
InvenTreeSetting.set_setting(key, value, self.user)
try:
InvenTreeSetting.set_setting(key, value, change_user=self.user)
except Exception as exc:
print(f"test_defaults: Failed to set default value for setting '{key}'")
raise exc
self.assertEqual(value, InvenTreeSetting.get_setting(key))
@ -287,11 +423,6 @@ class SettingsTest(InvenTreeTestCase):
setting = InvenTreeSetting.get_setting_object(key)
if setting.is_bool():
if setting.default_value in ['', None]:
raise ValueError(
f'Default value for boolean setting {key} not provided'
) # pragma: no cover
if setting.default_value not in [True, False]:
raise ValueError(
f'Non-boolean default value specified for {key}'
@ -975,17 +1106,13 @@ class CommonTest(InvenTreeAPITestCase):
from plugin import registry
# set flag true
common.models.InvenTreeSetting.set_setting(
'SERVER_RESTART_REQUIRED', True, None
)
set_global_setting('SERVER_RESTART_REQUIRED', True, None)
# reload the app
registry.reload_plugins()
# now it should be false again
self.assertFalse(
common.models.InvenTreeSetting.get_setting('SERVER_RESTART_REQUIRED')
)
self.assertFalse(get_global_setting('SERVER_RESTART_REQUIRED'))
def test_config_api(self):
"""Test config URLs."""

View File

@ -5,7 +5,45 @@ import re
from django.core.exceptions import ValidationError
from django.utils.translation import gettext_lazy as _
import InvenTree.helpers_model
from common.settings import get_global_setting
def attachment_model_types():
"""Return a list of valid attachment model choices."""
import InvenTree.models
return list(
InvenTree.helpers_model.getModelsWithMixin(
InvenTree.models.InvenTreeAttachmentMixin
)
)
def attachment_model_options():
"""Return a list of options for models which support attachments."""
return [
(model.__name__.lower(), model._meta.verbose_name)
for model in attachment_model_types()
]
def attachment_model_class_from_label(label: str):
"""Return the model class for the given label."""
if not label:
raise ValidationError(_('No attachment model type provided'))
for model in attachment_model_types():
if model.__name__.lower() == label.lower():
return model
raise ValidationError(_('Invalid attachment model type') + f": '{label}'")
def validate_attachment_model_type(value):
"""Ensure that the provided attachment model is valid."""
model_names = [el[0] for el in attachment_model_options()]
if value not in model_names:
raise ValidationError(f'Model type does not support attachments')
def validate_notes_model_type(value):
@ -13,6 +51,7 @@ def validate_notes_model_type(value):
The provided value must map to a model which implements the 'InvenTreeNotesMixin'.
"""
import InvenTree.helpers_model
import InvenTree.models
if not value:
@ -31,11 +70,9 @@ def validate_notes_model_type(value):
def validate_decimal_places_min(value):
"""Validator for PRICING_DECIMAL_PLACES_MIN setting."""
from common.models import InvenTreeSetting
try:
value = int(value)
places_max = int(InvenTreeSetting.get_setting('PRICING_DECIMAL_PLACES'))
places_max = int(get_global_setting('PRICING_DECIMAL_PLACES', create=False))
except Exception:
return
@ -45,11 +82,9 @@ def validate_decimal_places_min(value):
def validate_decimal_places_max(value):
"""Validator for PRICING_DECIMAL_PLACES_MAX setting."""
from common.models import InvenTreeSetting
try:
value = int(value)
places_min = int(InvenTreeSetting.get_setting('PRICING_DECIMAL_PLACES_MIN'))
places_min = int(get_global_setting('PRICING_DECIMAL_PLACES_MIN', create=False))
except Exception:
return

View File

@ -14,7 +14,6 @@ from .models import (
Company,
Contact,
ManufacturerPart,
ManufacturerPartAttachment,
ManufacturerPartParameter,
SupplierPart,
SupplierPriceBreak,
@ -120,15 +119,6 @@ class ManufacturerPartAdmin(ImportExportModelAdmin):
autocomplete_fields = ('part', 'manufacturer')
@admin.register(ManufacturerPartAttachment)
class ManufacturerPartAttachmentAdmin(ImportExportModelAdmin):
"""Admin class for ManufacturerPartAttachment model."""
list_display = ('manufacturer_part', 'attachment', 'comment')
autocomplete_fields = ('manufacturer_part',)
class ManufacturerPartParameterResource(InvenTreeResource):
"""Class for managing ManufacturerPartParameter data import/export."""

View File

@ -7,7 +7,7 @@ from django.utils.translation import gettext_lazy as _
from django_filters import rest_framework as rest_filters
import part.models
from InvenTree.api import AttachmentMixin, ListCreateDestroyAPIView, MetadataView
from InvenTree.api import ListCreateDestroyAPIView, MetadataView
from InvenTree.filters import (
ORDER_FILTER,
SEARCH_ORDER_FILTER,
@ -19,20 +19,16 @@ from InvenTree.mixins import ListCreateAPI, RetrieveUpdateDestroyAPI
from .models import (
Address,
Company,
CompanyAttachment,
Contact,
ManufacturerPart,
ManufacturerPartAttachment,
ManufacturerPartParameter,
SupplierPart,
SupplierPriceBreak,
)
from .serializers import (
AddressSerializer,
CompanyAttachmentSerializer,
CompanySerializer,
ContactSerializer,
ManufacturerPartAttachmentSerializer,
ManufacturerPartParameterSerializer,
ManufacturerPartSerializer,
SupplierPartSerializer,
@ -88,22 +84,6 @@ class CompanyDetail(RetrieveUpdateDestroyAPI):
return queryset
class CompanyAttachmentList(AttachmentMixin, ListCreateDestroyAPIView):
"""API endpoint for listing, creating and bulk deleting a CompanyAttachment."""
queryset = CompanyAttachment.objects.all()
serializer_class = CompanyAttachmentSerializer
filterset_fields = ['company']
class CompanyAttachmentDetail(AttachmentMixin, RetrieveUpdateDestroyAPI):
"""Detail endpoint for CompanyAttachment model."""
queryset = CompanyAttachment.objects.all()
serializer_class = CompanyAttachmentSerializer
class ContactList(ListCreateDestroyAPIView):
"""API endpoint for list view of Company model."""
@ -227,22 +207,6 @@ class ManufacturerPartDetail(RetrieveUpdateDestroyAPI):
serializer_class = ManufacturerPartSerializer
class ManufacturerPartAttachmentList(AttachmentMixin, ListCreateDestroyAPIView):
"""API endpoint for listing, creating and bulk deleting a ManufacturerPartAttachment (file upload)."""
queryset = ManufacturerPartAttachment.objects.all()
serializer_class = ManufacturerPartAttachmentSerializer
filterset_fields = ['manufacturer_part']
class ManufacturerPartAttachmentDetail(AttachmentMixin, RetrieveUpdateDestroyAPI):
"""Detail endpooint for ManufacturerPartAttachment model."""
queryset = ManufacturerPartAttachment.objects.all()
serializer_class = ManufacturerPartAttachmentSerializer
class ManufacturerPartParameterFilter(rest_filters.FilterSet):
"""Custom filterset for the ManufacturerPartParameterList API endpoint."""
@ -509,22 +473,6 @@ class SupplierPriceBreakDetail(RetrieveUpdateDestroyAPI):
manufacturer_part_api_urls = [
# Base URL for ManufacturerPartAttachment API endpoints
path(
'attachment/',
include([
path(
'<int:pk>/',
ManufacturerPartAttachmentDetail.as_view(),
name='api-manufacturer-part-attachment-detail',
),
path(
'',
ManufacturerPartAttachmentList.as_view(),
name='api-manufacturer-part-attachment-list',
),
]),
),
path(
'parameter/',
include([
@ -611,19 +559,6 @@ company_api_urls = [
path('', CompanyDetail.as_view(), name='api-company-detail'),
]),
),
path(
'attachment/',
include([
path(
'<int:pk>/',
CompanyAttachmentDetail.as_view(),
name='api-company-attachment-detail',
),
path(
'', CompanyAttachmentList.as_view(), name='api-company-attachment-list'
),
]),
),
path(
'contact/',
include([

View File

@ -31,6 +31,9 @@ class Migration(migrations.Migration):
('is_customer', models.BooleanField(default=False, help_text='Do you sell items to this company?')),
('is_supplier', models.BooleanField(default=True, help_text='Do you purchase items from this company?')),
],
options={
'verbose_name': 'Company',
}
),
migrations.CreateModel(
name='Contact',
@ -60,6 +63,7 @@ class Migration(migrations.Migration):
],
options={
'db_table': 'part_supplierpart',
'verbose_name': 'Supplier Part',
},
),
migrations.CreateModel(

View File

@ -12,6 +12,6 @@ class Migration(migrations.Migration):
operations = [
migrations.AlterModelOptions(
name='company',
options={'ordering': ['name']},
options={'ordering': ['name'], 'verbose_name': 'Company'},
),
]

View File

@ -22,6 +22,7 @@ class Migration(migrations.Migration):
],
options={
'unique_together': {('part', 'manufacturer', 'MPN')},
'verbose_name': 'Manufacturer Part',
},
),
]

View File

@ -12,6 +12,6 @@ class Migration(migrations.Migration):
operations = [
migrations.AlterModelOptions(
name='company',
options={'ordering': ['name'], 'verbose_name_plural': 'Companies'},
options={'ordering': ['name'], 'verbose_name': 'Company', 'verbose_name_plural': 'Companies'},
),
]

View File

@ -19,7 +19,7 @@ class Migration(migrations.Migration):
name='ManufacturerPartAttachment',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('attachment', models.FileField(blank=True, help_text='Select file to attach', null=True, upload_to=InvenTree.models.rename_attachment, verbose_name='Attachment')),
('attachment', models.FileField(blank=True, help_text='Select file to attach', null=True, upload_to='attachments', verbose_name='Attachment')),
('link', InvenTree.fields.InvenTreeURLField(blank=True, help_text='Link to external URL', null=True, verbose_name='Link')),
('comment', models.CharField(blank=True, help_text='File comment', max_length=100, verbose_name='Comment')),
('upload_date', models.DateField(auto_now_add=True, null=True, verbose_name='upload date')),

View File

@ -19,7 +19,7 @@ class Migration(migrations.Migration):
name='CompanyAttachment',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('attachment', models.FileField(blank=True, help_text='Select file to attach', null=True, upload_to=InvenTree.models.rename_attachment, verbose_name='Attachment')),
('attachment', models.FileField(blank=True, help_text='Select file to attach', null=True, upload_to='attachments', verbose_name='Attachment')),
('link', InvenTree.fields.InvenTreeURLField(blank=True, help_text='Link to external URL', null=True, verbose_name='Link')),
('comment', models.CharField(blank=True, help_text='File comment', max_length=100, verbose_name='Comment')),
('upload_date', models.DateField(auto_now_add=True, null=True, verbose_name='upload date')),

View File

@ -0,0 +1,24 @@
# Generated by Django 4.2.12 on 2024-06-09 09:02
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('build', '0050_auto_20240508_0138'),
('common', '0026_auto_20240608_1238'),
('company', '0069_company_active'),
('order', '0099_alter_salesorder_status'),
('part', '0123_parttesttemplate_choices'),
('stock', '0110_alter_stockitemtestresult_finished_datetime_and_more')
]
operations = [
migrations.DeleteModel(
name='CompanyAttachment',
),
migrations.DeleteModel(
name='ManufacturerPartAttachment',
),
]

View File

@ -60,7 +60,9 @@ def rename_company_image(instance, filename):
class Company(
InvenTree.models.InvenTreeNotesMixin, InvenTree.models.InvenTreeMetadataModel
InvenTree.models.InvenTreeAttachmentMixin,
InvenTree.models.InvenTreeNotesMixin,
InvenTree.models.InvenTreeMetadataModel,
):
"""A Company object represents an external company.
@ -95,7 +97,8 @@ class Company(
constraints = [
UniqueConstraint(fields=['name', 'email'], name='unique_name_email_pair')
]
verbose_name_plural = 'Companies'
verbose_name = _('Company')
verbose_name_plural = _('Companies')
@staticmethod
def get_api_url():
@ -255,26 +258,6 @@ class Company(
).distinct()
class CompanyAttachment(InvenTree.models.InvenTreeAttachment):
"""Model for storing file or URL attachments against a Company object."""
@staticmethod
def get_api_url():
"""Return the API URL associated with this model."""
return reverse('api-company-attachment-list')
def getSubdir(self):
"""Return the subdirectory where these attachments are uploaded."""
return os.path.join('company_files', str(self.company.pk))
company = models.ForeignKey(
Company,
on_delete=models.CASCADE,
verbose_name=_('Company'),
related_name='attachments',
)
class Contact(InvenTree.models.InvenTreeMetadataModel):
"""A Contact represents a person who works at a particular company. A Company may have zero or more associated Contact objects.
@ -460,7 +443,9 @@ class Address(InvenTree.models.InvenTreeModel):
class ManufacturerPart(
InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models.InvenTreeMetadataModel
InvenTree.models.InvenTreeAttachmentMixin,
InvenTree.models.InvenTreeBarcodeMixin,
InvenTree.models.InvenTreeMetadataModel,
):
"""Represents a unique part as provided by a Manufacturer Each ManufacturerPart is identified by a MPN (Manufacturer Part Number) Each ManufacturerPart is also linked to a Part object. A Part may be available from multiple manufacturers.
@ -475,6 +460,7 @@ class ManufacturerPart(
class Meta:
"""Metaclass defines extra model options."""
verbose_name = _('Manufacturer Part')
unique_together = ('part', 'manufacturer', 'MPN')
@staticmethod
@ -563,26 +549,6 @@ class ManufacturerPart(
return s
class ManufacturerPartAttachment(InvenTree.models.InvenTreeAttachment):
"""Model for storing file attachments against a ManufacturerPart object."""
@staticmethod
def get_api_url():
"""Return the API URL associated with the ManufacturerPartAttachment model."""
return reverse('api-manufacturer-part-attachment-list')
def getSubdir(self):
"""Return the subdirectory where attachment files for the ManufacturerPart model are located."""
return os.path.join('manufacturer_part_files', str(self.manufacturer_part.id))
manufacturer_part = models.ForeignKey(
ManufacturerPart,
on_delete=models.CASCADE,
verbose_name=_('Manufacturer Part'),
related_name='attachments',
)
class ManufacturerPartParameter(InvenTree.models.InvenTreeModel):
"""A ManufacturerPartParameter represents a key:value parameter for a MnaufacturerPart.
@ -679,6 +645,8 @@ class SupplierPart(
unique_together = ('part', 'supplier', 'SKU')
verbose_name = _('Supplier Part')
# This model was moved from the 'Part' app
db_table = 'part_supplierpart'

View File

@ -11,7 +11,6 @@ from taggit.serializers import TagListSerializerField
import part.filters
from InvenTree.serializers import (
InvenTreeAttachmentSerializer,
InvenTreeCurrencySerializer,
InvenTreeDecimalField,
InvenTreeImageSerializerField,
@ -26,10 +25,8 @@ from part.serializers import PartBriefSerializer
from .models import (
Address,
Company,
CompanyAttachment,
Contact,
ManufacturerPart,
ManufacturerPartAttachment,
ManufacturerPartParameter,
SupplierPart,
SupplierPriceBreak,
@ -186,17 +183,6 @@ class CompanySerializer(NotesFieldMixin, RemoteImageMixin, InvenTreeModelSeriali
return self.instance
class CompanyAttachmentSerializer(InvenTreeAttachmentSerializer):
"""Serializer for the CompanyAttachment class."""
class Meta:
"""Metaclass defines serializer options."""
model = CompanyAttachment
fields = InvenTreeAttachmentSerializer.attachment_fields(['company'])
class ContactSerializer(InvenTreeModelSerializer):
"""Serializer class for the Contact model."""
@ -260,17 +246,6 @@ class ManufacturerPartSerializer(InvenTreeTagModelSerializer):
)
class ManufacturerPartAttachmentSerializer(InvenTreeAttachmentSerializer):
"""Serializer for the ManufacturerPartAttachment class."""
class Meta:
"""Metaclass options."""
model = ManufacturerPartAttachment
fields = InvenTreeAttachmentSerializer.attachment_fields(['manufacturer_part'])
class ManufacturerPartParameterSerializer(InvenTreeModelSerializer):
"""Serializer for the ManufacturerPartParameter model."""

View File

@ -244,17 +244,7 @@
{{ block.super }}
onPanelLoad("attachments", function() {
loadAttachmentTable('{% url "api-company-attachment-list" %}', {
filters: {
company: {{ company.pk }},
},
fields: {
company: {
value: {{ company.pk }},
hidden: true
}
}
});
loadAttachmentTable('company', {{ company.pk }});
});
// Callback function when the 'contacts' panel is loaded

View File

@ -177,17 +177,7 @@ src="{% static 'img/blank_image.png' %}"
{{ block.super }}
onPanelLoad("attachments", function() {
loadAttachmentTable('{% url "api-manufacturer-part-attachment-list" %}', {
filters: {
manufacturer_part: {{ part.pk }},
},
fields: {
manufacturer_part: {
value: {{ part.pk }},
hidden: true
}
}
});
loadAttachmentTable('manufacturerpart', {{ part.pk }});
});
$('#parameter-create').click(function() {

View File

@ -45,14 +45,7 @@ class TestManufacturerField(MigratorTestCase):
SupplierPart = self.old_state.apps.get_model('company', 'supplierpart')
# Create an initial part
part = Part.objects.create(
name='Screw',
description='A single screw',
level=0,
tree_id=0,
lft=0,
rght=0,
)
part = Part.objects.create(name='Screw', description='A single screw')
# Create a company to act as the supplier
supplier = Company.objects.create(

View File

@ -26,7 +26,7 @@ database:
# Set debug to False to run in production mode, or use the environment variable INVENTREE_DEBUG
debug: True
# Set to False to disable the admin interfac, or use the environment variable INVENTREE_ADMIN_ENABLED
# Set to False to disable the admin interface, or use the environment variable INVENTREE_ADMIN_ENABLED
#admin_enabled: True
# Set the admin URL, or use the environment variable INVENTREE_ADMIN_URL

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

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