mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +00:00
Merge branch 'master' of https://github.com/inventree/InvenTree into pr/ChristianSchindler/6305
This commit is contained in:
commit
27de080dc6
4
.github/actions/migration/action.yaml
vendored
4
.github/actions/migration/action.yaml
vendored
@ -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
|
||||
|
5
.github/actions/setup/action.yaml
vendored
5
.github/actions/setup/action.yaml
vendored
@ -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
|
||||
|
2
.github/workflows/check_translations.yaml
vendored
2
.github/workflows/check_translations.yaml
vendored
@ -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:
|
||||
|
17
.github/workflows/docker.yaml
vendored
17
.github/workflows/docker.yaml
vendored
@ -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
|
||||
|
36
.github/workflows/qc_checks.yaml
vendored
36
.github/workflows/qc_checks.yaml
vendored
@ -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:
|
||||
|
6
.github/workflows/release.yaml
vendored
6
.github/workflows/release.yaml
vendored
@ -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 }}
|
||||
|
4
.github/workflows/scorecard.yaml
vendored
4
.github/workflows/scorecard.yaml
vendored
@ -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
|
||||
|
2
.github/workflows/translations.yaml
vendored
2
.github/workflows/translations.yaml
vendored
@ -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:
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -3,6 +3,7 @@ coverage:
|
||||
project:
|
||||
default:
|
||||
target: 82%
|
||||
patch: off
|
||||
|
||||
github_checks:
|
||||
annotations: true
|
||||
|
@ -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 \
|
||||
|
@ -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
|
||||
}
|
||||
|
||||
|
@ -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:
|
||||
|
||||
|
@ -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
|
||||
|
||||
|
@ -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 |
|
||||
|
@ -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.
|
||||
|
||||
|
@ -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.
|
||||
|
||||
|
@ -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
|
||||
|
@ -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"]
|
||||
|
@ -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."""
|
||||
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
|
@ -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
|
||||
|
@ -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):
|
||||
|
@ -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)
|
||||
|
@ -38,6 +38,7 @@ LOCALES = [
|
||||
('pl', _('Polish')),
|
||||
('pt', _('Portuguese')),
|
||||
('pt-br', _('Portuguese (Brazilian)')),
|
||||
('ro', _('Romanian')),
|
||||
('ru', _('Russian')),
|
||||
('sk', _('Slovak')),
|
||||
('sl', _('Slovenian')),
|
||||
|
@ -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()
|
@ -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:
|
||||
|
@ -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
|
||||
|
@ -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',
|
||||
)
|
||||
|
||||
|
@ -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:
|
||||
|
@ -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.
|
||||
|
||||
|
@ -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?
|
||||
|
||||
|
@ -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)
|
||||
|
||||
|
@ -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'))
|
||||
|
@ -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')
|
||||
|
||||
|
@ -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()
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
)
|
||||
|
||||
|
@ -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'),
|
||||
|
@ -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')),
|
||||
|
@ -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',
|
||||
|
@ -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'),
|
||||
),
|
||||
]
|
||||
|
@ -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',
|
||||
),
|
||||
]
|
@ -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.
|
||||
|
||||
|
@ -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',
|
||||
])
|
||||
|
@ -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() {
|
||||
|
@ -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)
|
||||
|
@ -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')
|
||||
|
@ -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)
|
||||
|
@ -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([
|
||||
|
@ -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
|
||||
|
@ -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]
|
||||
|
||||
|
||||
|
43
src/backend/InvenTree/common/migrations/0025_attachment.py
Normal file
43
src/backend/InvenTree/common/migrations/0025_attachment.py
Normal 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',
|
||||
}
|
||||
),
|
||||
]
|
@ -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),
|
||||
]
|
@ -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)
|
||||
|
@ -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()
|
||||
|
@ -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
|
||||
|
210
src/backend/InvenTree/common/test_migrations.py
Normal file
210
src/backend/InvenTree/common/test_migrations.py
Normal 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)
|
@ -1 +0,0 @@
|
||||
"""Unit tests for the views associated with the 'common' app."""
|
@ -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."""
|
||||
|
@ -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
|
||||
|
||||
|
@ -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."""
|
||||
|
||||
|
@ -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([
|
||||
|
@ -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(
|
||||
|
@ -12,6 +12,6 @@ class Migration(migrations.Migration):
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name='company',
|
||||
options={'ordering': ['name']},
|
||||
options={'ordering': ['name'], 'verbose_name': 'Company'},
|
||||
),
|
||||
]
|
||||
|
@ -22,6 +22,7 @@ class Migration(migrations.Migration):
|
||||
],
|
||||
options={
|
||||
'unique_together': {('part', 'manufacturer', 'MPN')},
|
||||
'verbose_name': 'Manufacturer Part',
|
||||
},
|
||||
),
|
||||
]
|
||||
|
@ -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'},
|
||||
),
|
||||
]
|
||||
|
@ -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')),
|
||||
|
@ -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')),
|
||||
|
@ -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',
|
||||
),
|
||||
]
|
@ -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'
|
||||
|
||||
|
@ -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."""
|
||||
|
||||
|
@ -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
|
||||
|
@ -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() {
|
||||
|
@ -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(
|
||||
|
@ -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
Loading…
Reference in New Issue
Block a user