mirror of
https://github.com/inventree/InvenTree.git
synced 2025-12-19 05:11:41 -06:00
Compare commits
368 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0b5557395b | ||
|
|
6c410521a1 | ||
|
|
9d88d38bf8 | ||
|
|
4531030551 | ||
|
|
aced0e73c7 | ||
|
|
61eba2f7fc | ||
|
|
0926992b4f | ||
|
|
d8e1e18f4d | ||
|
|
b7247284a6 | ||
|
|
1502a07b81 | ||
|
|
eb108edb60 | ||
|
|
270c0ea85d | ||
|
|
83002a5d51 | ||
|
|
9eb559bec5 | ||
|
|
69473b9bff | ||
|
|
c07aef7f75 | ||
|
|
cbb94d2ff7 | ||
|
|
75054f870e | ||
|
|
124db01b63 | ||
|
|
2be78f5d4c | ||
|
|
d3da262687 | ||
|
|
2b4723cc32 | ||
|
|
09ef85ce9d | ||
|
|
d4529ec1c4 | ||
|
|
f8edcf8a0d | ||
|
|
6f00c662a1 | ||
|
|
cfae92e22b | ||
|
|
3e6429cb13 | ||
|
|
3a8cce9af3 | ||
|
|
aa41e3e17d | ||
|
|
1bf72ee335 | ||
|
|
73bcacc2d8 | ||
|
|
79a643ccf5 | ||
|
|
623d0366fc | ||
|
|
1e6c6c678f | ||
|
|
42a73576da | ||
|
|
a8d22dac62 | ||
|
|
75a249da95 | ||
|
|
71425d3bc5 | ||
|
|
79dc66e840 | ||
|
|
d5034ece51 | ||
|
|
04318c6d70 | ||
|
|
96efb0eb28 | ||
|
|
3eae70e745 | ||
|
|
f902b79d79 | ||
|
|
12a4c22a9b | ||
|
|
a9d490b716 | ||
|
|
d243ff6b37 | ||
|
|
fb5a94a778 | ||
|
|
72f83771f1 | ||
|
|
8a06eaa40d | ||
|
|
85c9bc1b81 | ||
|
|
471d009e84 | ||
|
|
029808a986 | ||
|
|
effd547260 | ||
|
|
7c9ad3f406 | ||
|
|
4cf0339393 | ||
|
|
a2ff3e3474 | ||
|
|
c2f85b0447 | ||
|
|
8f07efa4e3 | ||
|
|
b490c5d035 | ||
|
|
f9449da576 | ||
|
|
34e95ab70c | ||
|
|
5f9236d280 | ||
|
|
44fe5721e0 | ||
|
|
f6f3815f31 | ||
|
|
78bcbe271a | ||
|
|
2e8d3b6424 | ||
|
|
c9021fe991 | ||
|
|
0e1b647e7b | ||
|
|
b74d365529 | ||
|
|
3da5505b58 | ||
|
|
5a168abbfe | ||
|
|
8f626d305e | ||
|
|
5d9e273559 | ||
|
|
91b6f98f95 | ||
|
|
2f1db486a0 | ||
|
|
823f84e46a | ||
|
|
178715ce61 | ||
|
|
e787c853e5 | ||
|
|
5e54b0f5cf | ||
|
|
9086c8a3bf | ||
|
|
e011faa9b7 | ||
|
|
e6bd91c9e2 | ||
|
|
1372343bd5 | ||
|
|
8eb571bddf | ||
|
|
c7e1ac5648 | ||
|
|
6bf3cc9e01 | ||
|
|
6bf4140e5a | ||
|
|
afddf12339 | ||
|
|
6a1bb0a806 | ||
|
|
97e1bc0a67 | ||
|
|
cbddda6640 | ||
|
|
7491cda313 | ||
|
|
b5a5f5b409 | ||
|
|
f8d1ee8805 | ||
|
|
47a93bc4cb | ||
|
|
3381945e14 | ||
|
|
0bb2507dd6 | ||
|
|
1074300ba0 | ||
|
|
ccd35fc4b4 | ||
|
|
c0691c3e9b | ||
|
|
63ade51c6c | ||
|
|
3926276fd1 | ||
|
|
f39928368e | ||
|
|
2db6af2af6 | ||
|
|
71cac6e269 | ||
|
|
ed304f571a | ||
|
|
b2264940a3 | ||
|
|
14aead038e | ||
|
|
d4d9263131 | ||
|
|
9c38d67b52 | ||
|
|
4a3ca4638c | ||
|
|
d91531720b | ||
|
|
5e0e364b6c | ||
|
|
da63ec5351 | ||
|
|
6412cf1c87 | ||
|
|
734985faa9 | ||
|
|
52b2b9582d | ||
|
|
7b4d3a3c07 | ||
|
|
32eaf48c12 | ||
|
|
76fe535ef9 | ||
|
|
bd65a42410 | ||
|
|
fd66e8b136 | ||
|
|
547dcc6e80 | ||
|
|
58ddc47065 | ||
|
|
530b90042a | ||
|
|
2c053eae4c | ||
|
|
ef64d1e61d | ||
|
|
adcb211572 | ||
|
|
efd14fca64 | ||
|
|
20c455384e | ||
|
|
c68220a597 | ||
|
|
cd7724d490 | ||
|
|
f67210b20f | ||
|
|
1854da380b | ||
|
|
0547e1c03b | ||
|
|
446bc06c1b | ||
|
|
2de6fcbfa4 | ||
|
|
698b946403 | ||
|
|
45ca8d0e93 | ||
|
|
bb69e38c1a | ||
|
|
94574b37ae | ||
|
|
00c4519d28 | ||
|
|
2436b1f2c9 | ||
|
|
a8b858c824 | ||
|
|
8d3b9e2ca4 | ||
|
|
be41be3981 | ||
|
|
8e7e36089b | ||
|
|
47ba0599eb | ||
|
|
db858b3cfc | ||
|
|
148600a9c4 | ||
|
|
839c29117d | ||
|
|
d446f8ddd1 | ||
|
|
08a1a6cf43 | ||
|
|
76ab38a06b | ||
|
|
8c8b25a0d2 | ||
|
|
38b9655ad9 | ||
|
|
9e56bf90c5 | ||
|
|
2f2e5862a9 | ||
|
|
b9e81c3c0e | ||
|
|
b9f9b26ca5 | ||
|
|
7683cc1aaa | ||
|
|
ff6b127f1b | ||
|
|
8b227ce297 | ||
|
|
286cf9b102 | ||
|
|
24d36e0b66 | ||
|
|
251ec7a02f | ||
|
|
61f8b982ce | ||
|
|
1f881dd041 | ||
|
|
42b400e619 | ||
|
|
601aff8283 | ||
|
|
58bfc80f79 | ||
|
|
2746396d11 | ||
|
|
6017cad6b3 | ||
|
|
1a7b6e2613 | ||
|
|
ab57fd3b76 | ||
|
|
c0a0ca4588 | ||
|
|
3f257279ee | ||
|
|
731ec25b24 | ||
|
|
53c9475e6d | ||
|
|
9ccff64679 | ||
|
|
de3395ed26 | ||
|
|
16433f49c6 | ||
|
|
73e032e1d0 | ||
|
|
82b6c48946 | ||
|
|
566c3af39e | ||
|
|
5d141e3568 | ||
|
|
83cd24961d | ||
|
|
737a378515 | ||
|
|
f71ebc20ec | ||
|
|
ac9753e72c | ||
|
|
dc94376f6d | ||
|
|
c846e2e65a | ||
|
|
608f47837f | ||
|
|
2f6ee330de | ||
|
|
c66dddc03f | ||
|
|
48cbd3be97 | ||
|
|
6b99808c52 | ||
|
|
61d14a0eda | ||
|
|
5aea35f8fa | ||
|
|
3cc0530419 | ||
|
|
cef75aabc5 | ||
|
|
09693d0d09 | ||
|
|
70703f8588 | ||
|
|
67a4c5a9a2 | ||
|
|
db8d93e2e9 | ||
|
|
af52f0eace | ||
|
|
631e41e22a | ||
|
|
f8d29b7b3b | ||
|
|
9e4218d02f | ||
|
|
d09483f30c | ||
|
|
d1a42f55a2 | ||
|
|
bdd5fa96e7 | ||
|
|
4f87c848a5 | ||
|
|
d20c3bb733 | ||
|
|
ae72224ece | ||
|
|
fd43f8dc64 | ||
|
|
6b32142725 | ||
|
|
01e6635032 | ||
|
|
88a021f165 | ||
|
|
b16f85de65 | ||
|
|
0b1f22c7fd | ||
|
|
9c8817d73b | ||
|
|
811f9333e8 | ||
|
|
a4d098194b | ||
|
|
e78455085f | ||
|
|
865436c42a | ||
|
|
db994fd908 | ||
|
|
83f8afe113 | ||
|
|
e7ed4c4eab | ||
|
|
39b2c5f943 | ||
|
|
3ddbb6a6cd | ||
|
|
fd01e23245 | ||
|
|
1a288168b7 | ||
|
|
f288b906ad | ||
|
|
58c30f48d5 | ||
|
|
bfbdd72306 | ||
|
|
0b78f3d931 | ||
|
|
32cfe1b954 | ||
|
|
67f06d6e5d | ||
|
|
408b9d5e5b | ||
|
|
50adb2ac61 | ||
|
|
49bb5634da | ||
|
|
e6dfb7da52 | ||
|
|
cbb887ae79 | ||
|
|
709bfb1bd2 | ||
|
|
217097c9d3 | ||
|
|
19059ea4cf | ||
|
|
d64dd68403 | ||
|
|
bd87f4c733 | ||
|
|
19c03e1472 | ||
|
|
cffe2ba84b | ||
|
|
7a4b90649a | ||
|
|
88c1bc79d7 | ||
|
|
a3ab70b05d | ||
|
|
c558a04162 | ||
|
|
1ffd3a0070 | ||
|
|
e0a0bfdadb | ||
|
|
c85d737446 | ||
|
|
5a5e76e0a6 | ||
|
|
fd4f33d45b | ||
|
|
4abd8587ab | ||
|
|
afd2dacfc7 | ||
|
|
3a0c68bf5c | ||
|
|
df0ab2359f | ||
|
|
e28dde7f7b | ||
|
|
08ffbee8ed | ||
|
|
ce64feb79d | ||
|
|
e3f49b8996 | ||
|
|
8f610d826f | ||
|
|
e897864396 | ||
|
|
edbbfff1af | ||
|
|
8fd666e662 | ||
|
|
f25c83226f | ||
|
|
b7718d9c6c | ||
|
|
283663633a | ||
|
|
c9464fd393 | ||
|
|
84aea1d587 | ||
|
|
57289fe141 | ||
|
|
9c91ba4692 | ||
|
|
4e7243b999 | ||
|
|
e3a5a56371 | ||
|
|
db47629867 | ||
|
|
8b310d8e47 | ||
|
|
be30933bfa | ||
|
|
9a710ca28f | ||
|
|
47a1143570 | ||
|
|
5f19f534fc | ||
|
|
e2e870858d | ||
|
|
15678f789c | ||
|
|
45edb7e802 | ||
|
|
5b7d35e6f7 | ||
|
|
3900f9b1b6 | ||
|
|
8619af9f09 | ||
|
|
85474516a3 | ||
|
|
6946abae13 | ||
|
|
5e48009241 | ||
|
|
64a57128bc | ||
|
|
d39bd88440 | ||
|
|
7b81a470b9 | ||
|
|
247cbe0154 | ||
|
|
2de879d2ba | ||
|
|
b17a50bd51 | ||
|
|
9918860820 | ||
|
|
c6e154f996 | ||
|
|
c1aed51de1 | ||
|
|
24823adc6d | ||
|
|
6ea846ce45 | ||
|
|
f6dd710d6e | ||
|
|
de85d61451 | ||
|
|
5ff18a0a3a | ||
|
|
82faccc62f | ||
|
|
59e98bc22d | ||
|
|
acb0b2c10a | ||
|
|
429f9d0a13 | ||
|
|
89c7c87f1e | ||
|
|
b152f7041b | ||
|
|
700effcee7 | ||
|
|
18b559fee7 | ||
|
|
9d404afec0 | ||
|
|
51616c8aca | ||
|
|
ef4dbda223 | ||
|
|
006dd10a79 | ||
|
|
5b8eb1c530 | ||
|
|
bfb0cb3b47 | ||
|
|
ed028aed62 | ||
|
|
c07f217416 | ||
|
|
47c98db8a1 | ||
|
|
4925f24ca9 | ||
|
|
18defcff16 | ||
|
|
3cf5aec289 | ||
|
|
1532a0c3a1 | ||
|
|
5949ccd74f | ||
|
|
f1ba20c3da | ||
|
|
eb6310c774 | ||
|
|
9d321f4833 | ||
|
|
e1ba0a9a99 | ||
|
|
8e2a2c59bf | ||
|
|
7ccd339b5c | ||
|
|
fa95759a00 | ||
|
|
23e19614a5 | ||
|
|
3897166185 | ||
|
|
7800664f4b | ||
|
|
448c3cc6f5 | ||
|
|
5a6a12604e | ||
|
|
33e176e4e7 | ||
|
|
9b0595d232 | ||
|
|
7f05485954 | ||
|
|
727fd38978 | ||
|
|
d559d92f58 | ||
|
|
39d44ce32f | ||
|
|
5b68d82fa3 | ||
|
|
660fed9196 | ||
|
|
6f63b43c1c | ||
|
|
ca626ead6c | ||
|
|
fb096bd65b | ||
|
|
a00756ec3a | ||
|
|
af0c72d338 | ||
|
|
5ae5b9c0d4 | ||
|
|
48cd227f06 | ||
|
|
ae3a0133eb | ||
|
|
45b3c68930 | ||
|
|
7bec3ff5dd | ||
|
|
9ea3193ffb | ||
|
|
8061669c70 | ||
|
|
ade1d36397 | ||
|
|
a4257ad9df |
5
.gitattributes
vendored
5
.gitattributes
vendored
@@ -4,3 +4,8 @@
|
||||
*.md text
|
||||
*.html text
|
||||
*.txt text
|
||||
*.yml text
|
||||
*.yaml text
|
||||
*.conf text
|
||||
*.sh text
|
||||
*.js text
|
||||
48
.github/workflows/coverage.yaml
vendored
Normal file
48
.github/workflows/coverage.yaml
vendored
Normal file
@@ -0,0 +1,48 @@
|
||||
# Perform CI checks, and calculate code coverage
|
||||
|
||||
name: SQLite
|
||||
|
||||
on: ["push", "pull_request"]
|
||||
|
||||
jobs:
|
||||
|
||||
# Run tests on SQLite database
|
||||
# These tests are used for code coverage analysis
|
||||
coverage:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
INVENTREE_DB_NAME: './test_db.sqlite'
|
||||
INVENTREE_DB_ENGINE: django.db.backends.sqlite3
|
||||
INVENTREE_DEBUG: info
|
||||
INVENTREE_MEDIA_ROOT: ./media
|
||||
INVENTREE_STATIC_ROOT: ./static
|
||||
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@v2
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v2
|
||||
with:
|
||||
python-version: 3.7
|
||||
- name: Install Dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
pip3 install invoke
|
||||
invoke install
|
||||
- name: Coverage Tests
|
||||
run: |
|
||||
invoke coverage
|
||||
- name: Data Import Export
|
||||
run: |
|
||||
invoke migrate
|
||||
invoke import-fixtures
|
||||
invoke export-records -f data.json
|
||||
rm test_db.sqlite
|
||||
invoke migrate
|
||||
invoke import-records -f data.json
|
||||
- name: Check Migration Files
|
||||
run: python3 ci/check_migration_files.py
|
||||
- name: Upload Coverage Report
|
||||
run: coveralls
|
||||
16
.github/workflows/docker_build.yaml
vendored
Normal file
16
.github/workflows/docker_build.yaml
vendored
Normal file
@@ -0,0 +1,16 @@
|
||||
# Test that the docker file builds correctly
|
||||
|
||||
name: Docker
|
||||
|
||||
on: ["push", "pull_request"]
|
||||
|
||||
jobs:
|
||||
|
||||
docker:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Build Docker Image
|
||||
run: cd docker && docker build . --tag inventree:$(date +%s)
|
||||
|
||||
23
.github/workflows/docker_publish.yaml
vendored
Normal file
23
.github/workflows/docker_publish.yaml
vendored
Normal file
@@ -0,0 +1,23 @@
|
||||
# Publish docker images to dockerhub
|
||||
|
||||
name: Docker Publish
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
jobs:
|
||||
publish_image:
|
||||
name: Push InvenTree web server image to dockerhub
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@v2
|
||||
- name: Push to Docker Hub
|
||||
uses: docker/build-push-action@v1
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
repository: inventree/inventree
|
||||
tag_with_ref: true
|
||||
dockerfile: docker/Dockerfile
|
||||
51
.github/workflows/mysql.yaml
vendored
Normal file
51
.github/workflows/mysql.yaml
vendored
Normal file
@@ -0,0 +1,51 @@
|
||||
# MySQL Unit Testing
|
||||
|
||||
name: MySQL
|
||||
|
||||
on: ["push", "pull_request"]
|
||||
|
||||
jobs:
|
||||
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
env:
|
||||
# Database backend configuration
|
||||
INVENTREE_DB_ENGINE: django.db.backends.mysql
|
||||
INVENTREE_DB_NAME: inventree
|
||||
INVENTREE_DB_USER: root
|
||||
INVENTREE_DB_PASSWORD: password
|
||||
INVENTREE_DB_HOST: '127.0.0.1'
|
||||
INVENTREE_DB_PORT: 3306
|
||||
INVENTREE_DEBUG: info
|
||||
INVENTREE_MEDIA_ROOT: ./media
|
||||
INVENTREE_STATIC_ROOT: ./static
|
||||
|
||||
services:
|
||||
mysql:
|
||||
image: mysql:latest
|
||||
env:
|
||||
MYSQL_ALLOW_EMPTY_PASSWORD: yes
|
||||
MYSQL_DATABASE: inventree
|
||||
MYSQL_USER: inventree
|
||||
MYSQL_PASSWORD: password
|
||||
MYSQL_ROOT_PASSWORD: password
|
||||
options: --health-cmd="mysqladmin ping" --health-interval=5s --health-timeout=2s --health-retries=3
|
||||
ports:
|
||||
- 3306:3306
|
||||
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@v2
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v2
|
||||
with:
|
||||
python-version: 3.7
|
||||
- name: Install Dependencies
|
||||
run: |
|
||||
sudo apt-get install mysql-server libmysqlclient-dev
|
||||
pip3 install invoke
|
||||
pip3 install mysqlclient
|
||||
invoke install
|
||||
- name: Run Tests
|
||||
run: invoke test
|
||||
47
.github/workflows/postgresql.yaml
vendored
Normal file
47
.github/workflows/postgresql.yaml
vendored
Normal file
@@ -0,0 +1,47 @@
|
||||
# PostgreSQL Unit Testing
|
||||
|
||||
name: PostgreSQL
|
||||
|
||||
on: ["push", "pull_request"]
|
||||
|
||||
jobs:
|
||||
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
env:
|
||||
# Database backend configuration
|
||||
INVENTREE_DB_ENGINE: django.db.backends.postgresql
|
||||
INVENTREE_DB_NAME: inventree
|
||||
INVENTREE_DB_USER: inventree
|
||||
INVENTREE_DB_PASSWORD: password
|
||||
INVENTREE_DB_HOST: '127.0.0.1'
|
||||
INVENTREE_DB_PORT: 5432
|
||||
INVENTREE_DEBUG: info
|
||||
INVENTREE_MEDIA_ROOT: ./media
|
||||
INVENTREE_STATIC_ROOT: ./static
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres
|
||||
env:
|
||||
POSTGRES_USER: inventree
|
||||
POSTGRES_PASSWORD: password
|
||||
ports:
|
||||
- 5432:5432
|
||||
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@v2
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v2
|
||||
with:
|
||||
python-version: 3.7
|
||||
- name: Install Dependencies
|
||||
run: |
|
||||
sudo apt-get install libpq-dev
|
||||
pip3 install invoke
|
||||
pip3 install psycopg2
|
||||
invoke install
|
||||
- name: Run Tests
|
||||
run: invoke test
|
||||
27
.github/workflows/style.yaml
vendored
Normal file
27
.github/workflows/style.yaml
vendored
Normal file
@@ -0,0 +1,27 @@
|
||||
name: Style Checks
|
||||
|
||||
on: ["push", "pull_request"]
|
||||
|
||||
jobs:
|
||||
style:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
strategy:
|
||||
max-parallel: 4
|
||||
matrix:
|
||||
python-version: [3.7]
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v2
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v2
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
- name: Install deps
|
||||
run: |
|
||||
pip install flake8==3.8.3
|
||||
pip install pep8-naming==0.11.1
|
||||
- name: flake8
|
||||
run: |
|
||||
flake8 InvenTree
|
||||
53
.travis.yml
53
.travis.yml
@@ -1,53 +0,0 @@
|
||||
dist: xenial
|
||||
|
||||
services:
|
||||
- mysql
|
||||
- postgresql
|
||||
|
||||
language: python
|
||||
python:
|
||||
- 3.6
|
||||
- 3.7
|
||||
|
||||
addons:
|
||||
apt-packages:
|
||||
- sqlite3
|
||||
|
||||
before_install:
|
||||
- sudo apt-get update
|
||||
- sudo apt-get install gettext
|
||||
- sudo apt-get install mysql-server libmysqlclient-dev
|
||||
- sudo apt-get install libpq-dev
|
||||
- pip3 install invoke
|
||||
- pip3 install mysqlclient
|
||||
- pip3 install psycopg2
|
||||
- invoke install
|
||||
- invoke migrate
|
||||
- cd InvenTree && python3 manage.py createsuperuser --username InvenTreeAdmin --email admin@inventree.com --noinput && cd ..
|
||||
- psql -c 'create database inventree_test_db;' -U postgres
|
||||
- mysql -e 'CREATE DATABASE inventree_test_db;'
|
||||
|
||||
script:
|
||||
- cd InvenTree && python3 manage.py makemigrations && cd ..
|
||||
- python3 ci/check_migration_files.py
|
||||
# Run unit testing / code coverage tests
|
||||
- invoke coverage
|
||||
# Run unit test for SQL database backend
|
||||
- cd InvenTree && python3 manage.py test --settings=InvenTree.ci_mysql && cd ..
|
||||
# Run unit test for PostgreSQL database backend
|
||||
- cd InvenTree && python3 manage.py test --settings=InvenTree.ci_postgresql && cd ..
|
||||
- invoke translate
|
||||
- invoke style
|
||||
# Create an empty database and fill it with test data
|
||||
- rm inventree_default_db.sqlite3
|
||||
- invoke migrate
|
||||
- invoke import-fixtures
|
||||
# Export database records
|
||||
- invoke export-records -f data.json
|
||||
# Create a new empty database and import the saved data
|
||||
- rm inventree_default_db.sqlite3
|
||||
- invoke migrate
|
||||
- invoke import-records -f data.json
|
||||
|
||||
after_success:
|
||||
- coveralls
|
||||
@@ -7,7 +7,7 @@ from __future__ import unicode_literals
|
||||
|
||||
import logging
|
||||
|
||||
from django.utils.translation import ugettext as _
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from django.http import JsonResponse
|
||||
|
||||
from django_filters.rest_framework import DjangoFilterBackend
|
||||
@@ -19,11 +19,12 @@ from rest_framework.views import APIView
|
||||
|
||||
from .views import AjaxView
|
||||
from .version import inventreeVersion, inventreeApiVersion, inventreeInstanceName
|
||||
from .status import is_worker_running
|
||||
|
||||
from plugins import plugins as inventree_plugins
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = logging.getLogger("inventree")
|
||||
|
||||
|
||||
logger.info("Loading action plugins...")
|
||||
@@ -44,6 +45,7 @@ class InfoView(AjaxView):
|
||||
'version': inventreeVersion(),
|
||||
'instance': inventreeInstanceName(),
|
||||
'apiVersion': inventreeApiVersion(),
|
||||
'worker_running': is_worker_running(),
|
||||
}
|
||||
|
||||
return JsonResponse(data)
|
||||
|
||||
44
InvenTree/InvenTree/apps.py
Normal file
44
InvenTree/InvenTree/apps.py
Normal file
@@ -0,0 +1,44 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
import logging
|
||||
|
||||
from django.apps import AppConfig
|
||||
from django.core.exceptions import AppRegistryNotReady
|
||||
|
||||
import InvenTree.tasks
|
||||
|
||||
|
||||
logger = logging.getLogger("inventree")
|
||||
|
||||
|
||||
class InvenTreeConfig(AppConfig):
|
||||
name = 'InvenTree'
|
||||
|
||||
def ready(self):
|
||||
|
||||
self.start_background_tasks()
|
||||
|
||||
def start_background_tasks(self):
|
||||
|
||||
try:
|
||||
from django_q.models import Schedule
|
||||
except (AppRegistryNotReady):
|
||||
return
|
||||
|
||||
logger.info("Starting background tasks...")
|
||||
|
||||
InvenTree.tasks.schedule_task(
|
||||
'InvenTree.tasks.delete_successful_tasks',
|
||||
schedule_type=Schedule.DAILY,
|
||||
)
|
||||
|
||||
InvenTree.tasks.schedule_task(
|
||||
'InvenTree.tasks.check_for_updates',
|
||||
schedule_type=Schedule.DAILY
|
||||
)
|
||||
|
||||
InvenTree.tasks.schedule_task(
|
||||
'InvenTree.tasks.heartbeat',
|
||||
schedule_type=Schedule.MINUTES,
|
||||
minutes=15
|
||||
)
|
||||
@@ -1,18 +0,0 @@
|
||||
"""
|
||||
Configuration file for running tests against a MySQL database.
|
||||
"""
|
||||
|
||||
from InvenTree.settings import *
|
||||
|
||||
# Override the 'test' database
|
||||
if 'test' in sys.argv:
|
||||
print('InvenTree: Running tests - Using MySQL test database')
|
||||
|
||||
DATABASES['default'] = {
|
||||
# Ensure mysql backend is being used
|
||||
'ENGINE': 'django.db.backends.mysql',
|
||||
'NAME': 'inventree_test_db',
|
||||
'USER': 'travis',
|
||||
'PASSWORD': '',
|
||||
'HOST': '127.0.0.1'
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
"""
|
||||
Configuration file for running tests against a MySQL database.
|
||||
"""
|
||||
|
||||
from InvenTree.settings import *
|
||||
|
||||
# Override the 'test' database
|
||||
if 'test' in sys.argv:
|
||||
print('InvenTree: Running tests - Using PostGreSQL test database')
|
||||
|
||||
DATABASES['default'] = {
|
||||
# Ensure postgresql backend is being used
|
||||
'ENGINE': 'django.db.backends.postgresql',
|
||||
'NAME': 'inventree_test_db',
|
||||
'USER': 'postgres',
|
||||
'PASSWORD': '',
|
||||
}
|
||||
@@ -30,10 +30,23 @@ def health_status(request):
|
||||
|
||||
request._inventree_health_status = True
|
||||
|
||||
return {
|
||||
"system_healthy": InvenTree.status.check_system_health(),
|
||||
status = {
|
||||
'django_q_running': InvenTree.status.is_worker_running(),
|
||||
'email_configured': InvenTree.status.is_email_configured(),
|
||||
}
|
||||
|
||||
all_healthy = True
|
||||
|
||||
for k in status.keys():
|
||||
if status[k] is not True:
|
||||
all_healthy = False
|
||||
|
||||
status['system_healthy'] = all_healthy
|
||||
|
||||
status['up_to_date'] = InvenTree.version.isInvenTreeUpToDate()
|
||||
|
||||
return status
|
||||
|
||||
|
||||
def status_codes(request):
|
||||
"""
|
||||
|
||||
@@ -5,7 +5,7 @@ from __future__ import unicode_literals
|
||||
|
||||
from .validators import allowable_url_schemes
|
||||
|
||||
from django.utils.translation import ugettext as _
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
|
||||
from django.forms.fields import URLField as FormURLField
|
||||
from django.db import models as models
|
||||
@@ -42,6 +42,7 @@ class DatePickerFormField(forms.DateField):
|
||||
def __init__(self, **kwargs):
|
||||
|
||||
help_text = kwargs.get('help_text', _('Enter date'))
|
||||
label = kwargs.get('label', None)
|
||||
required = kwargs.get('required', False)
|
||||
initial = kwargs.get('initial', None)
|
||||
|
||||
@@ -56,7 +57,8 @@ class DatePickerFormField(forms.DateField):
|
||||
required=required,
|
||||
initial=initial,
|
||||
help_text=help_text,
|
||||
widget=widget
|
||||
widget=widget,
|
||||
label=label
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ Helper forms which subclass Django forms to provide additional functionality
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.utils.translation import ugettext as _
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from django import forms
|
||||
from crispy_forms.helper import FormHelper
|
||||
from crispy_forms.layout import Layout, Field
|
||||
@@ -123,6 +123,7 @@ class DeleteForm(forms.Form):
|
||||
confirm_delete = forms.BooleanField(
|
||||
required=False,
|
||||
initial=False,
|
||||
label=_('Confirm delete'),
|
||||
help_text=_('Confirm item deletion')
|
||||
)
|
||||
|
||||
@@ -155,6 +156,7 @@ class SetPasswordForm(HelperForm):
|
||||
required=True,
|
||||
initial='',
|
||||
widget=forms.PasswordInput(attrs={'autocomplete': 'off'}),
|
||||
label=_('Enter password'),
|
||||
help_text=_('Enter new password'))
|
||||
|
||||
confirm_password = forms.CharField(max_length=100,
|
||||
@@ -162,6 +164,7 @@ class SetPasswordForm(HelperForm):
|
||||
required=True,
|
||||
initial='',
|
||||
widget=forms.PasswordInput(attrs={'autocomplete': 'off'}),
|
||||
label=_('Confirm password'),
|
||||
help_text=_('Confirm new password'))
|
||||
|
||||
class Meta:
|
||||
|
||||
@@ -13,7 +13,7 @@ from decimal import Decimal
|
||||
from wsgiref.util import FileWrapper
|
||||
from django.http import StreamingHttpResponse
|
||||
from django.core.exceptions import ValidationError, FieldError
|
||||
from django.utils.translation import ugettext as _
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
|
||||
from django.contrib.auth.models import Permission
|
||||
|
||||
@@ -280,11 +280,25 @@ def MakeBarcode(object_name, object_pk, object_data={}, **kwargs):
|
||||
json string of the supplied data plus some other data
|
||||
"""
|
||||
|
||||
url = kwargs.get('url', False)
|
||||
brief = kwargs.get('brief', True)
|
||||
|
||||
data = {}
|
||||
|
||||
if brief:
|
||||
if url:
|
||||
request = object_data.get('request', None)
|
||||
item_url = object_data.get('item_url', None)
|
||||
absolute_url = None
|
||||
|
||||
if request and item_url:
|
||||
absolute_url = request.build_absolute_uri(item_url)
|
||||
# Return URL (No JSON)
|
||||
return absolute_url
|
||||
|
||||
if item_url:
|
||||
# Return URL (No JSON)
|
||||
return item_url
|
||||
elif brief:
|
||||
data[object_name] = object_pk
|
||||
else:
|
||||
data['tool'] = 'InvenTree'
|
||||
@@ -382,17 +396,17 @@ def extract_serial_numbers(serials, expected_quantity):
|
||||
if a < b:
|
||||
for n in range(a, b + 1):
|
||||
if n in numbers:
|
||||
errors.append(_('Duplicate serial: {n}'.format(n=n)))
|
||||
errors.append(_('Duplicate serial: {n}').format(n=n))
|
||||
else:
|
||||
numbers.append(n)
|
||||
else:
|
||||
errors.append(_("Invalid group: {g}".format(g=group)))
|
||||
errors.append(_("Invalid group: {g}").format(g=group))
|
||||
|
||||
except ValueError:
|
||||
errors.append(_("Invalid group: {g}".format(g=group)))
|
||||
errors.append(_("Invalid group: {g}").format(g=group))
|
||||
continue
|
||||
else:
|
||||
errors.append(_("Invalid group: {g}".format(g=group)))
|
||||
errors.append(_("Invalid group: {g}").format(g=group))
|
||||
continue
|
||||
|
||||
else:
|
||||
@@ -409,7 +423,7 @@ def extract_serial_numbers(serials, expected_quantity):
|
||||
|
||||
# The number of extracted serial numbers must match the expected quantity
|
||||
if not expected_quantity == len(numbers):
|
||||
raise ValidationError([_("Number of unique serial number ({s}) must match quantity ({q})".format(s=len(numbers), q=expected_quantity))])
|
||||
raise ValidationError([_("Number of unique serial number ({s}) must match quantity ({q})").format(s=len(numbers), q=expected_quantity)])
|
||||
|
||||
return numbers
|
||||
|
||||
|
||||
42
InvenTree/InvenTree/management/commands/wait_for_db.py
Normal file
42
InvenTree/InvenTree/management/commands/wait_for_db.py
Normal file
@@ -0,0 +1,42 @@
|
||||
"""
|
||||
Custom management command, wait for the database to be ready!
|
||||
"""
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
from django.db import connection
|
||||
from django.db.utils import OperationalError, ImproperlyConfigured
|
||||
|
||||
import time
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
"""
|
||||
django command to pause execution until the database is ready
|
||||
"""
|
||||
|
||||
def handle(self, *args, **kwargs):
|
||||
|
||||
self.stdout.write("Waiting for database...")
|
||||
|
||||
connected = False
|
||||
|
||||
while not connected:
|
||||
|
||||
time.sleep(5)
|
||||
|
||||
try:
|
||||
connection.ensure_connection()
|
||||
|
||||
connected = True
|
||||
|
||||
except OperationalError as e:
|
||||
self.stdout.write(f"Could not connect to database: {e}")
|
||||
except ImproperlyConfigured as e:
|
||||
self.stdout.write(f"Improperly configured: {e}")
|
||||
else:
|
||||
if not connection.is_usable():
|
||||
self.stdout.write("Database configuration is not usable")
|
||||
|
||||
if connected:
|
||||
self.stdout.write("Database connection sucessful!")
|
||||
@@ -8,7 +8,7 @@ import operator
|
||||
|
||||
from rest_framework.authtoken.models import Token
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = logging.getLogger("inventree")
|
||||
|
||||
|
||||
class AuthRequiredMiddleware(object):
|
||||
@@ -52,6 +52,10 @@ class AuthRequiredMiddleware(object):
|
||||
if request.path_info.startswith('/static/'):
|
||||
authorized = True
|
||||
|
||||
# Unauthorized users can access the login page
|
||||
elif request.path_info.startswith('/accounts/'):
|
||||
authorized = True
|
||||
|
||||
elif 'Authorization' in request.headers.keys():
|
||||
auth = request.headers['Authorization'].strip()
|
||||
|
||||
|
||||
@@ -56,19 +56,20 @@ class InvenTreeAttachment(models.Model):
|
||||
def __str__(self):
|
||||
return os.path.basename(self.attachment.name)
|
||||
|
||||
attachment = models.FileField(upload_to=rename_attachment,
|
||||
attachment = models.FileField(upload_to=rename_attachment, verbose_name=_('Attachment'),
|
||||
help_text=_('Select file to attach'))
|
||||
|
||||
comment = models.CharField(blank=True, max_length=100, help_text=_('File comment'))
|
||||
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)
|
||||
upload_date = models.DateField(auto_now_add=True, null=True, blank=True, verbose_name=_('upload date'))
|
||||
|
||||
@property
|
||||
def basename(self):
|
||||
@@ -103,12 +104,14 @@ class InvenTreeTree(MPTTModel):
|
||||
blank=False,
|
||||
max_length=100,
|
||||
validators=[validate_tree_name],
|
||||
verbose_name=_("Name"),
|
||||
help_text=_("Name"),
|
||||
)
|
||||
|
||||
description = models.CharField(
|
||||
blank=True,
|
||||
max_length=250,
|
||||
verbose_name=_("Description"),
|
||||
help_text=_("Description (optional)")
|
||||
)
|
||||
|
||||
@@ -117,6 +120,7 @@ class InvenTreeTree(MPTTModel):
|
||||
on_delete=models.DO_NOTHING,
|
||||
blank=True,
|
||||
null=True,
|
||||
verbose_name=_("parent"),
|
||||
related_name='children')
|
||||
|
||||
@property
|
||||
|
||||
@@ -13,6 +13,9 @@ database setup in this file.
|
||||
|
||||
import logging
|
||||
import os
|
||||
import random
|
||||
import string
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
from datetime import datetime
|
||||
@@ -46,14 +49,31 @@ def get_setting(environment_var, backup_val, default_value=None):
|
||||
return default_value
|
||||
|
||||
|
||||
# Determine if we are running in "test" mode e.g. "manage.py test"
|
||||
TESTING = 'test' in sys.argv
|
||||
|
||||
# Build paths inside the project like this: os.path.join(BASE_DIR, ...)
|
||||
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
cfg_filename = os.path.join(BASE_DIR, 'config.yaml')
|
||||
# Specify where the "config file" is located.
|
||||
# By default, this is 'config.yaml'
|
||||
|
||||
cfg_filename = os.getenv('INVENTREE_CONFIG_FILE')
|
||||
|
||||
if cfg_filename:
|
||||
cfg_filename = cfg_filename.strip()
|
||||
cfg_filename = os.path.abspath(cfg_filename)
|
||||
|
||||
else:
|
||||
# Config file is *not* specified - use the default
|
||||
cfg_filename = os.path.join(BASE_DIR, 'config.yaml')
|
||||
|
||||
if not os.path.exists(cfg_filename):
|
||||
print("Error: config.yaml not found")
|
||||
sys.exit(-1)
|
||||
print("InvenTree configuration file 'config.yaml' not found - creating default file")
|
||||
|
||||
cfg_template = os.path.join(BASE_DIR, "config_template.yaml")
|
||||
shutil.copyfile(cfg_template, cfg_filename)
|
||||
print(f"Created config file {cfg_filename}")
|
||||
|
||||
with open(cfg_filename, 'r') as cfg:
|
||||
CONFIG = yaml.safe_load(cfg)
|
||||
@@ -94,7 +114,18 @@ LOGGING = {
|
||||
}
|
||||
|
||||
# Get a logger instance for this setup file
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = logging.getLogger("inventree")
|
||||
|
||||
"""
|
||||
Specify a secret key to be used by django.
|
||||
|
||||
Following options are tested, in descending order of preference:
|
||||
|
||||
A) Check for environment variable INVENTREE_SECRET_KEY => Use raw key data
|
||||
B) Check for environment variable INVENTREE_SECRET_KEY_FILE => Load key data from file
|
||||
C) Look for default key file "secret_key.txt"
|
||||
d) Create "secret_key.txt" if it does not exist
|
||||
"""
|
||||
|
||||
if os.getenv("INVENTREE_SECRET_KEY"):
|
||||
# Secret key passed in directly
|
||||
@@ -105,15 +136,22 @@ else:
|
||||
key_file = os.getenv("INVENTREE_SECRET_KEY_FILE")
|
||||
|
||||
if key_file:
|
||||
if os.path.isfile(key_file):
|
||||
logger.info("SECRET_KEY loaded by INVENTREE_SECRET_KEY_FILE")
|
||||
else:
|
||||
logger.error(f"Secret key file {key_file} not found")
|
||||
exit(-1)
|
||||
key_file = os.path.abspath(key_file)
|
||||
else:
|
||||
# default secret key location
|
||||
key_file = os.path.join(BASE_DIR, "secret_key.txt")
|
||||
logger.info(f"SECRET_KEY loaded from {key_file}")
|
||||
key_file = os.path.abspath(key_file)
|
||||
|
||||
if not os.path.exists(key_file):
|
||||
logger.info(f"Generating random key file at '{key_file}'")
|
||||
# Create a random key file
|
||||
with open(key_file, 'w') as f:
|
||||
options = string.digits + string.ascii_letters + string.punctuation
|
||||
key = ''.join([random.choice(options) for i in range(100)])
|
||||
f.write(key)
|
||||
|
||||
logger.info(f"Loading SECRET_KEY from '{key_file}'")
|
||||
|
||||
try:
|
||||
SECRET_KEY = open(key_file, "r").read().strip()
|
||||
except Exception:
|
||||
@@ -144,7 +182,7 @@ STATIC_URL = '/static/'
|
||||
STATIC_ROOT = os.path.abspath(
|
||||
get_setting(
|
||||
'INVENTREE_STATIC_ROOT',
|
||||
CONFIG.get('static_root', os.path.join(BASE_DIR, 'static'))
|
||||
CONFIG.get('static_root', '/home/inventree/static')
|
||||
)
|
||||
)
|
||||
|
||||
@@ -162,7 +200,7 @@ MEDIA_URL = '/media/'
|
||||
MEDIA_ROOT = os.path.abspath(
|
||||
get_setting(
|
||||
'INVENTREE_MEDIA_ROOT',
|
||||
CONFIG.get('media_root', os.path.join(BASE_DIR, 'media'))
|
||||
CONFIG.get('media_root', '/home/inventree/data/media')
|
||||
)
|
||||
)
|
||||
|
||||
@@ -194,6 +232,7 @@ INSTALLED_APPS = [
|
||||
'report.apps.ReportConfig',
|
||||
'stock.apps.StockConfig',
|
||||
'users.apps.UsersConfig',
|
||||
'InvenTree.apps.InvenTreeConfig', # InvenTree app runs last
|
||||
|
||||
# Third part add-ons
|
||||
'django_filters', # Extended filter functionality
|
||||
@@ -211,6 +250,7 @@ INSTALLED_APPS = [
|
||||
'djmoney', # django-money integration
|
||||
'djmoney.contrib.exchange', # django-money exchange rates
|
||||
'error_report', # Error reporting in the admin interface
|
||||
'django_q',
|
||||
]
|
||||
|
||||
MIDDLEWARE = CONFIG.get('middleware', [
|
||||
@@ -285,6 +325,18 @@ REST_FRAMEWORK = {
|
||||
|
||||
WSGI_APPLICATION = 'InvenTree.wsgi.application'
|
||||
|
||||
# django-q configuration
|
||||
Q_CLUSTER = {
|
||||
'name': 'InvenTree',
|
||||
'workers': 4,
|
||||
'timeout': 90,
|
||||
'retry': 120,
|
||||
'queue_limit': 50,
|
||||
'bulk': 10,
|
||||
'orm': 'default',
|
||||
'sync': False,
|
||||
}
|
||||
|
||||
# Markdownx configuration
|
||||
# Ref: https://neutronx.github.io/django-markdownx/customization/
|
||||
MARKDOWNX_MEDIA_PATH = datetime.now().strftime('markdownx/%Y/%m/%d')
|
||||
@@ -319,93 +371,75 @@ MARKDOWNIFY_BLEACH = False
|
||||
DATABASES = {}
|
||||
|
||||
"""
|
||||
When running unit tests, enforce usage of sqlite3 database,
|
||||
so that the tests can be run in RAM without any setup requirements
|
||||
Configure the database backend based on the user-specified values.
|
||||
|
||||
- Primarily this configuration happens in the config.yaml file
|
||||
- However there may be reason to configure the DB via environmental variables
|
||||
- The following code lets the user "mix and match" database configuration
|
||||
"""
|
||||
if 'test' in sys.argv:
|
||||
logger.info('InvenTree: Running tests - Using sqlite3 memory database')
|
||||
DATABASES['default'] = {
|
||||
# Ensure sqlite3 backend is being used
|
||||
'ENGINE': 'django.db.backends.sqlite3',
|
||||
# Doesn't matter what the database is called, it is executed in RAM
|
||||
'NAME': 'ram_test_db.sqlite3',
|
||||
}
|
||||
|
||||
# Database backend selection
|
||||
else:
|
||||
"""
|
||||
Configure the database backend based on the user-specified values.
|
||||
|
||||
- Primarily this configuration happens in the config.yaml file
|
||||
- However there may be reason to configure the DB via environmental variables
|
||||
- The following code lets the user "mix and match" database configuration
|
||||
"""
|
||||
logger.info("Configuring database backend:")
|
||||
|
||||
logger.info("Configuring database backend:")
|
||||
# Extract database configuration from the config.yaml file
|
||||
db_config = CONFIG.get('database', {})
|
||||
|
||||
# Extract database configuration from the config.yaml file
|
||||
db_config = CONFIG.get('database', {})
|
||||
if not db_config:
|
||||
db_config = {}
|
||||
|
||||
# If a particular database option is not specified in the config file,
|
||||
# look for it in the environmental variables
|
||||
# e.g. INVENTREE_DB_NAME / INVENTREE_DB_USER / etc
|
||||
# Environment variables take preference over config file!
|
||||
|
||||
db_keys = ['ENGINE', 'NAME', 'USER', 'PASSWORD', 'HOST', 'PORT']
|
||||
db_keys = ['ENGINE', 'NAME', 'USER', 'PASSWORD', 'HOST', 'PORT']
|
||||
|
||||
for key in db_keys:
|
||||
if key not in db_config:
|
||||
logger.debug(f" - Missing {key} value: Looking for environment variable INVENTREE_DB_{key}")
|
||||
env_key = f'INVENTREE_DB_{key}'
|
||||
env_var = os.environ.get(env_key, None)
|
||||
for key in db_keys:
|
||||
# First, check the environment variables
|
||||
env_key = f"INVENTREE_DB_{key}"
|
||||
env_var = os.environ.get(env_key, None)
|
||||
|
||||
if env_var is not None:
|
||||
logger.info(f'Using environment variable INVENTREE_DB_{key}')
|
||||
db_config[key] = env_var
|
||||
else:
|
||||
logger.debug(f' INVENTREE_DB_{key} not found in environment variables')
|
||||
if env_var:
|
||||
# Override configuration value
|
||||
db_config[key] = env_var
|
||||
|
||||
# Check that required database configuration options are specified
|
||||
reqiured_keys = ['ENGINE', 'NAME']
|
||||
# Check that required database configuration options are specified
|
||||
reqiured_keys = ['ENGINE', 'NAME']
|
||||
|
||||
for key in reqiured_keys:
|
||||
if key not in db_config:
|
||||
error_msg = f'Missing required database configuration value {key} in config.yaml'
|
||||
logger.error(error_msg)
|
||||
for key in reqiured_keys:
|
||||
if key not in db_config:
|
||||
error_msg = f'Missing required database configuration value {key}'
|
||||
logger.error(error_msg)
|
||||
|
||||
print('Error: ' + error_msg)
|
||||
sys.exit(-1)
|
||||
print('Error: ' + error_msg)
|
||||
sys.exit(-1)
|
||||
|
||||
"""
|
||||
Special considerations for the database 'ENGINE' setting.
|
||||
It can be specified in config.yaml (or envvar) as either (for example):
|
||||
- sqlite3
|
||||
- django.db.backends.sqlite3
|
||||
- django.db.backends.postgresql
|
||||
"""
|
||||
"""
|
||||
Special considerations for the database 'ENGINE' setting.
|
||||
It can be specified in config.yaml (or envvar) as either (for example):
|
||||
- sqlite3
|
||||
- django.db.backends.sqlite3
|
||||
- django.db.backends.postgresql
|
||||
"""
|
||||
|
||||
db_engine = db_config['ENGINE']
|
||||
db_engine = db_config['ENGINE']
|
||||
|
||||
if db_engine.lower() in ['sqlite3', 'postgresql', 'mysql']:
|
||||
# Prepend the required python module string
|
||||
db_engine = f'django.db.backends.{db_engine.lower()}'
|
||||
db_config['ENGINE'] = db_engine
|
||||
if db_engine.lower() in ['sqlite3', 'postgresql', 'mysql']:
|
||||
# Prepend the required python module string
|
||||
db_engine = f'django.db.backends.{db_engine.lower()}'
|
||||
db_config['ENGINE'] = db_engine
|
||||
|
||||
db_name = db_config['NAME']
|
||||
db_name = db_config['NAME']
|
||||
db_host = db_config.get('HOST', "''")
|
||||
|
||||
logger.info(f"Database ENGINE: '{db_engine}'")
|
||||
logger.info(f"Database NAME: '{db_name}'")
|
||||
print("InvenTree Database Configuration")
|
||||
print("================================")
|
||||
print(f"ENGINE: {db_engine}")
|
||||
print(f"NAME: {db_name}")
|
||||
print(f"HOST: {db_host}")
|
||||
|
||||
DATABASES['default'] = db_config
|
||||
DATABASES['default'] = db_config
|
||||
|
||||
CACHES = {
|
||||
'default': {
|
||||
'BACKEND': 'django.core.cache.backends.locmem.LocMemCache',
|
||||
},
|
||||
'qr-code': {
|
||||
'BACKEND': 'django.core.cache.backends.locmem.LocMemCache',
|
||||
'LOCATION': 'qr-code-cache',
|
||||
'TIMEOUT': 3600
|
||||
}
|
||||
}
|
||||
|
||||
# Password validation
|
||||
@@ -460,17 +494,68 @@ CURRENCIES = CONFIG.get(
|
||||
# TODO - Allow live web-based backends in the future
|
||||
EXCHANGE_BACKEND = 'InvenTree.exchange.InvenTreeManualExchangeBackend'
|
||||
|
||||
# Extract email settings from the config file
|
||||
email_config = CONFIG.get('email', {})
|
||||
|
||||
EMAIL_BACKEND = get_setting(
|
||||
'django.core.mail.backends.smtp.EmailBackend',
|
||||
email_config.get('backend', '')
|
||||
)
|
||||
|
||||
# Email backend settings
|
||||
EMAIL_HOST = get_setting(
|
||||
'INVENTREE_EMAIL_HOST',
|
||||
email_config.get('host', '')
|
||||
)
|
||||
|
||||
EMAIL_PORT = get_setting(
|
||||
'INVENTREE_EMAIL_PORT',
|
||||
email_config.get('port', 25)
|
||||
)
|
||||
|
||||
EMAIL_HOST_USER = get_setting(
|
||||
'INVENTREE_EMAIL_USERNAME',
|
||||
email_config.get('username', ''),
|
||||
)
|
||||
|
||||
EMAIL_HOST_PASSWORD = get_setting(
|
||||
'INVENTREE_EMAIL_PASSWORD',
|
||||
email_config.get('password', ''),
|
||||
)
|
||||
|
||||
EMAIL_SUBJECT_PREFIX = '[InvenTree] '
|
||||
|
||||
EMAIL_USE_LOCALTIME = False
|
||||
|
||||
EMAIL_USE_TLS = get_setting(
|
||||
'INVENTREE_EMAIL_TLS',
|
||||
email_config.get('tls', False),
|
||||
)
|
||||
|
||||
EMAIL_USE_SSL = get_setting(
|
||||
'INVENTREE_EMAIL_SSL',
|
||||
email_config.get('ssl', False),
|
||||
)
|
||||
|
||||
EMAIL_TIMEOUT = 60
|
||||
|
||||
LOCALE_PATHS = (
|
||||
os.path.join(BASE_DIR, 'locale/'),
|
||||
)
|
||||
|
||||
TIME_ZONE = CONFIG.get('timezone', 'UTC')
|
||||
TIME_ZONE = get_setting(
|
||||
'INVENTREE_TIMEZONE',
|
||||
CONFIG.get('timezone', 'UTC')
|
||||
)
|
||||
|
||||
USE_I18N = True
|
||||
|
||||
USE_L10N = True
|
||||
|
||||
USE_TZ = True
|
||||
# Do not use native timezone support in "test" mode
|
||||
# It generates a *lot* of cruft in the logs
|
||||
if not TESTING:
|
||||
USE_TZ = True
|
||||
|
||||
DATE_INPUT_FORMATS = [
|
||||
"%Y-%m-%d",
|
||||
|
||||
@@ -185,6 +185,10 @@
|
||||
color: #c55;
|
||||
}
|
||||
|
||||
.icon-orange {
|
||||
color: #fcba03;
|
||||
}
|
||||
|
||||
.icon-green {
|
||||
color: #43bb43;
|
||||
}
|
||||
@@ -586,6 +590,8 @@
|
||||
|
||||
.breadcrump {
|
||||
margin-bottom: 5px;
|
||||
margin-left: 5px;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.inventree-body {
|
||||
@@ -624,6 +630,53 @@
|
||||
z-index: 11000;
|
||||
}
|
||||
|
||||
.modal-close {
|
||||
position: absolute;
|
||||
top: 15px;
|
||||
right: 35px;
|
||||
color: #f1f1f1;
|
||||
font-size: 40px;
|
||||
font-weight: bold;
|
||||
transition: 0.25s;
|
||||
}
|
||||
|
||||
.modal-close:hover,
|
||||
.modal-close:focus {
|
||||
color: #bbb;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.modal-image-content {
|
||||
margin: auto;
|
||||
display: block;
|
||||
width: 80%;
|
||||
max-width: 700px;
|
||||
text-align: center;
|
||||
color: #ccc;
|
||||
padding: 10px 0;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 700px){
|
||||
.modal-image-content {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.modal-image {
|
||||
display: none;
|
||||
position: fixed;
|
||||
z-index: 10000;
|
||||
padding-top: 100px;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
background-color: rgb(0,0,0); /* Fallback color */
|
||||
background-color: rgba(0,0,0,0.85); /* Black w/ opacity */
|
||||
}
|
||||
|
||||
.js-modal-form .checkbox {
|
||||
margin-left: 0px;
|
||||
}
|
||||
|
||||
@@ -1,13 +1,73 @@
|
||||
"""
|
||||
Provides system status functionality checks.
|
||||
"""
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from django.utils.translation import ugettext as _
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from django_q.models import Success
|
||||
from django_q.monitor import Stat
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = logging.getLogger("inventree")
|
||||
|
||||
|
||||
def is_worker_running(**kwargs):
|
||||
"""
|
||||
Return True if the background worker process is oprational
|
||||
"""
|
||||
|
||||
clusters = Stat.get_all()
|
||||
|
||||
if len(clusters) > 0:
|
||||
# TODO - Introspect on any cluster information
|
||||
return True
|
||||
|
||||
"""
|
||||
Sometimes Stat.get_all() returns [].
|
||||
In this case we have the 'heartbeat' task running every 15 minutes.
|
||||
Check to see if we have a result within the last 20 minutes
|
||||
"""
|
||||
|
||||
now = datetime.now()
|
||||
past = now - timedelta(minutes=20)
|
||||
|
||||
results = Success.objects.filter(
|
||||
func='InvenTree.tasks.heartbeat',
|
||||
started__gte=past
|
||||
)
|
||||
|
||||
# If any results are returned, then the background worker is running!
|
||||
return results.exists()
|
||||
|
||||
|
||||
def is_email_configured():
|
||||
"""
|
||||
Check if email backend is configured.
|
||||
|
||||
NOTE: This does not check if the configuration is valid!
|
||||
"""
|
||||
|
||||
configured = True
|
||||
|
||||
if not settings.EMAIL_HOST:
|
||||
logger.warning("EMAIL_HOST is not configured")
|
||||
configured = False
|
||||
|
||||
if not settings.EMAIL_HOST_USER:
|
||||
logger.warning("EMAIL_HOST_USER is not configured")
|
||||
configured = False
|
||||
|
||||
if not settings.EMAIL_HOST_PASSWORD:
|
||||
logger.warning("EMAIL_HOST_PASSWORD is not configured")
|
||||
configured = False
|
||||
|
||||
return configured
|
||||
|
||||
|
||||
def check_system_health(**kwargs):
|
||||
@@ -19,21 +79,15 @@ def check_system_health(**kwargs):
|
||||
|
||||
result = True
|
||||
|
||||
if not check_celery_worker(**kwargs):
|
||||
if not is_worker_running(**kwargs):
|
||||
result = False
|
||||
logger.warning(_("Celery worker check failed"))
|
||||
logger.warning(_("Background worker check failed"))
|
||||
|
||||
if not is_email_configured():
|
||||
result = False
|
||||
logger.warning(_("Email backend not configured"))
|
||||
|
||||
if not result:
|
||||
logger.warning(_("InvenTree system health checks failed"))
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def check_celery_worker(**kwargs):
|
||||
"""
|
||||
Check that a celery worker is running.
|
||||
"""
|
||||
|
||||
# TODO - Checks that the configured celery worker thing is running
|
||||
|
||||
return True
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from django.utils.translation import ugettext as _
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
|
||||
|
||||
class StatusCode:
|
||||
|
||||
178
InvenTree/InvenTree/tasks.py
Normal file
178
InvenTree/InvenTree/tasks.py
Normal file
@@ -0,0 +1,178 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import re
|
||||
import json
|
||||
import requests
|
||||
import logging
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from django.core.exceptions import AppRegistryNotReady
|
||||
from django.db.utils import OperationalError, ProgrammingError
|
||||
|
||||
|
||||
logger = logging.getLogger("inventree")
|
||||
|
||||
|
||||
def schedule_task(taskname, **kwargs):
|
||||
"""
|
||||
Create a scheduled task.
|
||||
If the task has already been scheduled, ignore!
|
||||
"""
|
||||
|
||||
# If unspecified, repeat indefinitely
|
||||
repeats = kwargs.pop('repeats', -1)
|
||||
kwargs['repeats'] = repeats
|
||||
|
||||
try:
|
||||
from django_q.models import Schedule
|
||||
except (AppRegistryNotReady):
|
||||
logger.warning("Could not start background tasks - App registry not ready")
|
||||
return
|
||||
|
||||
try:
|
||||
# If this task is already scheduled, don't schedule it again
|
||||
# Instead, update the scheduling parameters
|
||||
if Schedule.objects.filter(func=taskname).exists():
|
||||
logger.info(f"Scheduled task '{taskname}' already exists - updating!")
|
||||
|
||||
Schedule.objects.filter(func=taskname).update(**kwargs)
|
||||
else:
|
||||
logger.info(f"Creating scheduled task '{taskname}'")
|
||||
|
||||
Schedule.objects.create(
|
||||
name=taskname,
|
||||
func=taskname,
|
||||
**kwargs
|
||||
)
|
||||
except (OperationalError, ProgrammingError):
|
||||
# Required if the DB is not ready yet
|
||||
pass
|
||||
|
||||
|
||||
def offload_task(taskname, *args, **kwargs):
|
||||
"""
|
||||
Create an AsyncTask.
|
||||
This is different to a 'scheduled' task,
|
||||
in that it only runs once!
|
||||
"""
|
||||
|
||||
try:
|
||||
from django_q.tasks import AsyncTask
|
||||
except (AppRegistryNotReady):
|
||||
logger.warning("Could not offload task - app registry not ready")
|
||||
return
|
||||
|
||||
task = AsyncTask(taskname, *args, **kwargs)
|
||||
|
||||
task.run()
|
||||
|
||||
|
||||
def heartbeat():
|
||||
"""
|
||||
Simple task which runs at 5 minute intervals,
|
||||
so we can determine that the background worker
|
||||
is actually running.
|
||||
|
||||
(There is probably a less "hacky" way of achieving this)?
|
||||
"""
|
||||
|
||||
try:
|
||||
from django_q.models import Success
|
||||
logger.warning("Could not perform heartbeat task - App registry not ready")
|
||||
except AppRegistryNotReady:
|
||||
return
|
||||
|
||||
threshold = datetime.now() - timedelta(minutes=30)
|
||||
|
||||
# Delete heartbeat results more than half an hour old,
|
||||
# otherwise they just create extra noise
|
||||
heartbeats = Success.objects.filter(
|
||||
func='InvenTree.tasks.heartbeat',
|
||||
started__lte=threshold
|
||||
)
|
||||
|
||||
heartbeats.delete()
|
||||
|
||||
|
||||
def delete_successful_tasks():
|
||||
"""
|
||||
Delete successful task logs
|
||||
which are more than a month old.
|
||||
"""
|
||||
|
||||
try:
|
||||
from django_q.models import Success
|
||||
except AppRegistryNotReady:
|
||||
logger.warning("Could not perform 'delete_successful_tasks' - App registry not ready")
|
||||
return
|
||||
|
||||
threshold = datetime.now() - timedelta(days=30)
|
||||
|
||||
results = Success.objects.filter(
|
||||
started__lte=threshold
|
||||
)
|
||||
|
||||
results.delete()
|
||||
|
||||
|
||||
def check_for_updates():
|
||||
"""
|
||||
Check if there is an update for InvenTree
|
||||
"""
|
||||
|
||||
try:
|
||||
import common.models
|
||||
except AppRegistryNotReady:
|
||||
# Apps not yet loaded!
|
||||
return
|
||||
|
||||
response = requests.get('https://api.github.com/repos/inventree/inventree/releases/latest')
|
||||
|
||||
if not response.status_code == 200:
|
||||
raise ValueError(f'Unexpected status code from GitHub API: {response.status_code}')
|
||||
|
||||
data = json.loads(response.text)
|
||||
|
||||
tag = data.get('tag_name', None)
|
||||
|
||||
if not tag:
|
||||
raise ValueError("'tag_name' missing from GitHub response")
|
||||
|
||||
match = re.match(r"^.*(\d+)\.(\d+)\.(\d+).*$", tag)
|
||||
|
||||
if not len(match.groups()) == 3:
|
||||
logger.warning(f"Version '{tag}' did not match expected pattern")
|
||||
return
|
||||
|
||||
latest_version = [int(x) for x in match.groups()]
|
||||
|
||||
if not len(latest_version) == 3:
|
||||
raise ValueError(f"Version '{tag}' is not correct format")
|
||||
|
||||
logger.info(f"Latest InvenTree version: '{tag}'")
|
||||
|
||||
# Save the version to the database
|
||||
common.models.InvenTreeSetting.set_setting(
|
||||
'INVENTREE_LATEST_VERSION',
|
||||
tag,
|
||||
None
|
||||
)
|
||||
|
||||
|
||||
def send_email(subject, body, recipients, from_email=None):
|
||||
"""
|
||||
Send an email with the specified subject and body,
|
||||
to the specified recipients list.
|
||||
"""
|
||||
|
||||
if type(recipients) == str:
|
||||
recipients = [recipients]
|
||||
|
||||
offload_task(
|
||||
'django.core.mail.send_mail',
|
||||
subject, body,
|
||||
from_email,
|
||||
recipients,
|
||||
)
|
||||
43
InvenTree/InvenTree/test_tasks.py
Normal file
43
InvenTree/InvenTree/test_tasks.py
Normal file
@@ -0,0 +1,43 @@
|
||||
"""
|
||||
Unit tests for task management
|
||||
"""
|
||||
|
||||
from django.test import TestCase
|
||||
from django_q.models import Schedule
|
||||
|
||||
import InvenTree.tasks
|
||||
|
||||
|
||||
class ScheduledTaskTests(TestCase):
|
||||
"""
|
||||
Unit tests for scheduled tasks
|
||||
"""
|
||||
|
||||
def get_tasks(self, name):
|
||||
|
||||
return Schedule.objects.filter(func=name)
|
||||
|
||||
def test_add_task(self):
|
||||
"""
|
||||
Ensure that duplicate tasks cannot be added.
|
||||
"""
|
||||
|
||||
task = 'InvenTree.tasks.heartbeat'
|
||||
|
||||
self.assertEqual(self.get_tasks(task).count(), 0)
|
||||
|
||||
InvenTree.tasks.schedule_task(task, schedule_type=Schedule.MINUTES, minutes=10)
|
||||
|
||||
self.assertEqual(self.get_tasks(task).count(), 1)
|
||||
|
||||
t = Schedule.objects.get(func=task)
|
||||
|
||||
self.assertEqual(t.minutes, 10)
|
||||
|
||||
# Attempt to schedule the same task again
|
||||
InvenTree.tasks.schedule_task(task, schedule_type=Schedule.MINUTES, minutes=5)
|
||||
self.assertEqual(self.get_tasks(task).count(), 1)
|
||||
|
||||
# But the 'minutes' should have been updated
|
||||
t = Schedule.objects.get(func=task)
|
||||
self.assertEqual(t.minutes, 5)
|
||||
@@ -7,6 +7,7 @@ from django.core.exceptions import ValidationError
|
||||
|
||||
from .validators import validate_overage, validate_part_name
|
||||
from . import helpers
|
||||
from . import version
|
||||
|
||||
from mptt.exceptions import InvalidMove
|
||||
|
||||
@@ -269,3 +270,33 @@ class TestSerialNumberExtraction(TestCase):
|
||||
|
||||
with self.assertRaises(ValidationError):
|
||||
e("10, a, 7-70j", 4)
|
||||
|
||||
|
||||
class TestVersionNumber(TestCase):
|
||||
"""
|
||||
Unit tests for version number functions
|
||||
"""
|
||||
|
||||
def test_tuple(self):
|
||||
|
||||
v = version.inventreeVersionTuple()
|
||||
self.assertEqual(len(v), 3)
|
||||
|
||||
s = '.'.join([str(i) for i in v])
|
||||
|
||||
self.assertTrue(s in version.inventreeVersion())
|
||||
|
||||
def test_comparison(self):
|
||||
"""
|
||||
Test direct comparison of version numbers
|
||||
"""
|
||||
|
||||
v_a = version.inventreeVersionTuple('1.2.0')
|
||||
v_b = version.inventreeVersionTuple('1.2.3')
|
||||
v_c = version.inventreeVersionTuple('1.2.4')
|
||||
v_d = version.inventreeVersionTuple('2.0.0')
|
||||
|
||||
self.assertTrue(v_b > v_a)
|
||||
self.assertTrue(v_c > v_b)
|
||||
self.assertTrue(v_d > v_c)
|
||||
self.assertTrue(v_d > v_a)
|
||||
|
||||
@@ -11,6 +11,7 @@ from django.contrib import admin
|
||||
from django.contrib.auth import views as auth_views
|
||||
|
||||
from company.urls import company_urls
|
||||
from company.urls import manufacturer_part_urls
|
||||
from company.urls import supplier_part_urls
|
||||
from company.urls import price_break_urls
|
||||
|
||||
@@ -110,10 +111,12 @@ dynamic_javascript_urls = [
|
||||
url(r'^stock.js', DynamicJsView.as_view(template_name='js/stock.js'), name='stock.js'),
|
||||
url(r'^tables.js', DynamicJsView.as_view(template_name='js/tables.js'), name='tables.js'),
|
||||
url(r'^table_filters.js', DynamicJsView.as_view(template_name='js/table_filters.js'), name='table_filters.js'),
|
||||
url(r'^filters.js', DynamicJsView.as_view(template_name='js/filters.js'), name='filters.js'),
|
||||
]
|
||||
|
||||
urlpatterns = [
|
||||
url(r'^part/', include(part_urls)),
|
||||
url(r'^manufacturer-part/', include(manufacturer_part_urls)),
|
||||
url(r'^supplier-part/', include(supplier_part_urls)),
|
||||
url(r'^price-break/', include(price_break_urls)),
|
||||
|
||||
@@ -132,7 +135,7 @@ urlpatterns = [
|
||||
url(r'^auth/', include('rest_framework.urls', namespace='rest_framework')),
|
||||
|
||||
url(r'^login/?', auth_views.LoginView.as_view(), name='login'),
|
||||
url(r'^logout/', auth_views.LogoutView.as_view(template_name='registration/logout.html'), name='logout'),
|
||||
url(r'^logout/', auth_views.LogoutView.as_view(template_name='registration/logged_out.html'), name='logout'),
|
||||
|
||||
url(r'^settings/', include(settings_urls)),
|
||||
|
||||
@@ -142,6 +145,7 @@ urlpatterns = [
|
||||
url(r'^admin/error_log/', include('error_report.urls')),
|
||||
url(r'^admin/shell/', include('django_admin_shell.urls')),
|
||||
url(r'^admin/', admin.site.urls, name='inventree-admin'),
|
||||
url(r'accounts/', include('django.contrib.auth.urls')),
|
||||
|
||||
url(r'^index/', IndexView.as_view(), name='index'),
|
||||
url(r'^search/', SearchView.as_view(), name='search'),
|
||||
|
||||
@@ -60,7 +60,7 @@ def validate_part_ipn(value):
|
||||
match = re.search(pattern, value)
|
||||
|
||||
if match is None:
|
||||
raise ValidationError(_('IPN must match regex pattern') + " '{pat}'".format(pat=pattern))
|
||||
raise ValidationError(_('IPN must match regex pattern {pat}').format(pat=pattern))
|
||||
|
||||
|
||||
def validate_build_order_reference(value):
|
||||
|
||||
@@ -4,10 +4,11 @@ Provides information on the current InvenTree version
|
||||
|
||||
import subprocess
|
||||
import django
|
||||
import re
|
||||
|
||||
import common.models
|
||||
|
||||
INVENTREE_SW_VERSION = "0.1.7"
|
||||
INVENTREE_SW_VERSION = "0.2.1"
|
||||
|
||||
# Increment this number whenever there is a significant change to the API that any clients need to know about
|
||||
INVENTREE_API_VERSION = 2
|
||||
@@ -23,6 +24,38 @@ def inventreeVersion():
|
||||
return INVENTREE_SW_VERSION
|
||||
|
||||
|
||||
def inventreeVersionTuple(version=None):
|
||||
""" Return the InvenTree version string as (maj, min, sub) tuple """
|
||||
|
||||
if version is None:
|
||||
version = INVENTREE_SW_VERSION
|
||||
|
||||
match = re.match(r"^.*(\d+)\.(\d+)\.(\d+).*$", str(version))
|
||||
|
||||
return [int(g) for g in match.groups()]
|
||||
|
||||
|
||||
def isInvenTreeUpToDate():
|
||||
"""
|
||||
Test if the InvenTree instance is "up to date" with the latest version.
|
||||
|
||||
A background task periodically queries GitHub for latest version,
|
||||
and stores it to the database as INVENTREE_LATEST_VERSION
|
||||
"""
|
||||
|
||||
latest = common.models.InvenTreeSetting.get_setting('INVENTREE_LATEST_VERSION', None)
|
||||
|
||||
# No record for "latest" version - we must assume we are up to date!
|
||||
if not latest:
|
||||
return True
|
||||
|
||||
# Extract "tuple" version (Python can directly compare version tuples)
|
||||
latest_version = inventreeVersionTuple(latest)
|
||||
inventree_version = inventreeVersionTuple()
|
||||
|
||||
return inventree_version >= latest_version
|
||||
|
||||
|
||||
def inventreeApiVersion():
|
||||
return INVENTREE_API_VERSION
|
||||
|
||||
@@ -37,7 +70,7 @@ def inventreeCommitHash():
|
||||
|
||||
try:
|
||||
return str(subprocess.check_output('git rev-parse --short HEAD'.split()), 'utf-8').strip()
|
||||
except FileNotFoundError:
|
||||
except:
|
||||
return None
|
||||
|
||||
|
||||
@@ -47,5 +80,5 @@ def inventreeCommitDate():
|
||||
try:
|
||||
d = str(subprocess.check_output('git show -s --format=%ci'.split()), 'utf-8').strip()
|
||||
return d.split(' ')[0]
|
||||
except FileNotFoundError:
|
||||
except:
|
||||
return None
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
from django.urls import reverse
|
||||
from django.conf.urls import url
|
||||
from django.utils.translation import ugettext as _
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
|
||||
from rest_framework.exceptions import ValidationError
|
||||
from rest_framework import permissions
|
||||
|
||||
@@ -12,7 +12,7 @@ from stock.serializers import StockItemSerializer, LocationSerializer
|
||||
from part.serializers import PartSerializer
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = logging.getLogger('inventree')
|
||||
|
||||
|
||||
def hash_barcode(barcode_data):
|
||||
|
||||
@@ -5,7 +5,7 @@ Django Forms for interacting with Build objects
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.utils.translation import ugettext as _
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from django import forms
|
||||
|
||||
from InvenTree.forms import HelperForm
|
||||
@@ -36,11 +36,13 @@ class EditBuildForm(HelperForm):
|
||||
}
|
||||
|
||||
target_date = DatePickerFormField(
|
||||
label=_('Target Date'),
|
||||
help_text=_('Target date for build completion. Build will be overdue after this date.')
|
||||
)
|
||||
|
||||
quantity = RoundingDecimalFormField(
|
||||
max_digits=10, decimal_places=5,
|
||||
label=_('Quantity'),
|
||||
help_text=_('Number of items to build')
|
||||
)
|
||||
|
||||
@@ -87,7 +89,7 @@ class BuildOutputCreateForm(HelperForm):
|
||||
)
|
||||
|
||||
serial_numbers = forms.CharField(
|
||||
label=_('Serial numbers'),
|
||||
label=_('Serial Numbers'),
|
||||
required=False,
|
||||
help_text=_('Enter serial numbers for build outputs'),
|
||||
)
|
||||
@@ -95,7 +97,7 @@ class BuildOutputCreateForm(HelperForm):
|
||||
confirm = forms.BooleanField(
|
||||
required=True,
|
||||
label=_('Confirm'),
|
||||
help_text=_('Confirm creation of build outut'),
|
||||
help_text=_('Confirm creation of build output'),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
@@ -115,6 +117,7 @@ class BuildOutputDeleteForm(HelperForm):
|
||||
|
||||
confirm = forms.BooleanField(
|
||||
required=False,
|
||||
label=_('Confirm'),
|
||||
help_text=_('Confirm deletion of build output')
|
||||
)
|
||||
|
||||
@@ -136,7 +139,7 @@ class UnallocateBuildForm(HelperForm):
|
||||
Form for auto-de-allocation of stock from a build
|
||||
"""
|
||||
|
||||
confirm = forms.BooleanField(required=False, help_text=_('Confirm unallocation of stock'))
|
||||
confirm = forms.BooleanField(required=False, label=_('Confirm'), help_text=_('Confirm unallocation of stock'))
|
||||
|
||||
output_id = forms.IntegerField(
|
||||
required=False,
|
||||
@@ -160,7 +163,7 @@ class UnallocateBuildForm(HelperForm):
|
||||
class AutoAllocateForm(HelperForm):
|
||||
""" Form for auto-allocation of stock to a build """
|
||||
|
||||
confirm = forms.BooleanField(required=True, help_text=_('Confirm stock allocation'))
|
||||
confirm = forms.BooleanField(required=True, label=_('Confirm'), help_text=_('Confirm stock allocation'))
|
||||
|
||||
# Keep track of which build output we are interested in
|
||||
output = forms.ModelChoiceField(
|
||||
@@ -207,15 +210,17 @@ class CompleteBuildOutputForm(HelperForm):
|
||||
|
||||
location = forms.ModelChoiceField(
|
||||
queryset=StockLocation.objects.all(),
|
||||
label=_('Location'),
|
||||
help_text=_('Location of completed parts'),
|
||||
)
|
||||
|
||||
confirm_incomplete = forms.BooleanField(
|
||||
required=False,
|
||||
label=_('Confirm incomplete'),
|
||||
help_text=_("Confirm completion with incomplete stock allocation")
|
||||
)
|
||||
|
||||
confirm = forms.BooleanField(required=True, help_text=_('Confirm build completion'))
|
||||
confirm = forms.BooleanField(required=True, label=_('Confirm'), help_text=_('Confirm build completion'))
|
||||
|
||||
output = forms.ModelChoiceField(
|
||||
queryset=StockItem.objects.all(), # Queryset is narrowed in the view
|
||||
@@ -235,7 +240,7 @@ class CompleteBuildOutputForm(HelperForm):
|
||||
class CancelBuildForm(HelperForm):
|
||||
""" Form for cancelling a build """
|
||||
|
||||
confirm_cancel = forms.BooleanField(required=False, help_text=_('Confirm build cancellation'))
|
||||
confirm_cancel = forms.BooleanField(required=False, label=_('Confirm cancel'), help_text=_('Confirm build cancellation'))
|
||||
|
||||
class Meta:
|
||||
model = Build
|
||||
@@ -249,7 +254,7 @@ class EditBuildItemForm(HelperForm):
|
||||
Form for creating (or editing) a BuildItem object.
|
||||
"""
|
||||
|
||||
quantity = RoundingDecimalFormField(max_digits=10, decimal_places=5, help_text=_('Select quantity of stock to allocate'))
|
||||
quantity = RoundingDecimalFormField(max_digits=10, decimal_places=5, label=_('Quantity'), help_text=_('Select quantity of stock to allocate'))
|
||||
|
||||
part_id = forms.IntegerField(required=False, widget=forms.HiddenInput())
|
||||
|
||||
|
||||
85
InvenTree/build/migrations/0027_auto_20210404_2016.py
Normal file
85
InvenTree/build/migrations/0027_auto_20210404_2016.py
Normal file
@@ -0,0 +1,85 @@
|
||||
# Generated by Django 3.0.7 on 2021-04-04 20:16
|
||||
|
||||
import InvenTree.models
|
||||
from django.conf import settings
|
||||
import django.core.validators
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('stock', '0058_stockitem_packaging'),
|
||||
('users', '0005_owner_model'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
('build', '0026_auto_20210216_1539'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='build',
|
||||
name='completed_by',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='builds_completed', to=settings.AUTH_USER_MODEL, verbose_name='completed by'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='build',
|
||||
name='completion_date',
|
||||
field=models.DateField(blank=True, null=True, verbose_name='Completion Date'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='build',
|
||||
name='creation_date',
|
||||
field=models.DateField(auto_now_add=True, verbose_name='Creation Date'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='build',
|
||||
name='issued_by',
|
||||
field=models.ForeignKey(blank=True, help_text='User who issued this build order', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='builds_issued', to=settings.AUTH_USER_MODEL, verbose_name='Issued by'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='build',
|
||||
name='responsible',
|
||||
field=models.ForeignKey(blank=True, help_text='User responsible for this build order', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='builds_responsible', to='users.Owner', verbose_name='Responsible'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='builditem',
|
||||
name='build',
|
||||
field=models.ForeignKey(help_text='Build to allocate parts', on_delete=django.db.models.deletion.CASCADE, related_name='allocated_stock', to='build.Build', verbose_name='Build'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='builditem',
|
||||
name='install_into',
|
||||
field=models.ForeignKey(blank=True, help_text='Destination stock item', limit_choices_to={'is_building': True}, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='items_to_install', to='stock.StockItem', verbose_name='Install into'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='builditem',
|
||||
name='quantity',
|
||||
field=models.DecimalField(decimal_places=5, default=1, help_text='Stock quantity to allocate to build', max_digits=15, validators=[django.core.validators.MinValueValidator(0)], verbose_name='Quantity'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='builditem',
|
||||
name='stock_item',
|
||||
field=models.ForeignKey(help_text='Source stock item', limit_choices_to={'belongs_to': None, 'sales_order': None}, on_delete=django.db.models.deletion.CASCADE, related_name='allocations', to='stock.StockItem', verbose_name='Stock Item'),
|
||||
),
|
||||
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'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='buildorderattachment',
|
||||
name='comment',
|
||||
field=models.CharField(blank=True, help_text='File comment', max_length=100, verbose_name='Comment'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='buildorderattachment',
|
||||
name='upload_date',
|
||||
field=models.DateField(auto_now_add=True, null=True, verbose_name='upload date'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='buildorderattachment',
|
||||
name='user',
|
||||
field=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'),
|
||||
),
|
||||
]
|
||||
@@ -9,7 +9,7 @@ import os
|
||||
from datetime import datetime
|
||||
|
||||
from django.contrib.auth.models import User
|
||||
from django.utils.translation import ugettext as _
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
from django.urls import reverse
|
||||
@@ -216,7 +216,7 @@ class Build(MPTTModel):
|
||||
help_text=_('Batch code for this build output')
|
||||
)
|
||||
|
||||
creation_date = models.DateField(auto_now_add=True, editable=False)
|
||||
creation_date = models.DateField(auto_now_add=True, editable=False, verbose_name=_('Creation Date'))
|
||||
|
||||
target_date = models.DateField(
|
||||
null=True, blank=True,
|
||||
@@ -224,12 +224,13 @@ class Build(MPTTModel):
|
||||
help_text=_('Target date for build completion. Build will be overdue after this date.')
|
||||
)
|
||||
|
||||
completion_date = models.DateField(null=True, blank=True)
|
||||
completion_date = models.DateField(null=True, blank=True, verbose_name=_('Completion Date'))
|
||||
|
||||
completed_by = models.ForeignKey(
|
||||
User,
|
||||
on_delete=models.SET_NULL,
|
||||
blank=True, null=True,
|
||||
verbose_name=_('completed by'),
|
||||
related_name='builds_completed'
|
||||
)
|
||||
|
||||
@@ -237,6 +238,7 @@ class Build(MPTTModel):
|
||||
User,
|
||||
on_delete=models.SET_NULL,
|
||||
blank=True, null=True,
|
||||
verbose_name=_('Issued by'),
|
||||
help_text=_('User who issued this build order'),
|
||||
related_name='builds_issued',
|
||||
)
|
||||
@@ -245,6 +247,7 @@ class Build(MPTTModel):
|
||||
UserModels.Owner,
|
||||
on_delete=models.SET_NULL,
|
||||
blank=True, null=True,
|
||||
verbose_name=_('Responsible'),
|
||||
help_text=_('User responsible for this build order'),
|
||||
related_name='builds_responsible',
|
||||
)
|
||||
@@ -1017,14 +1020,14 @@ class BuildItem(models.Model):
|
||||
try:
|
||||
# Allocated part must be in the BOM for the master part
|
||||
if self.stock_item.part not in self.build.part.getRequiredParts(recursive=False):
|
||||
errors['stock_item'] = [_("Selected stock item not found in BOM for part '{p}'".format(p=self.build.part.full_name))]
|
||||
errors['stock_item'] = [_("Selected stock item not found in BOM for part '{p}'").format(p=self.build.part.full_name)]
|
||||
|
||||
# Allocated quantity cannot exceed available stock quantity
|
||||
if self.quantity > self.stock_item.quantity:
|
||||
errors['quantity'] = [_("Allocated quantity ({n}) must not exceed available quantity ({q})".format(
|
||||
errors['quantity'] = [_("Allocated quantity ({n}) must not exceed available quantity ({q})").format(
|
||||
n=normalize(self.quantity),
|
||||
q=normalize(self.stock_item.quantity)
|
||||
))]
|
||||
)]
|
||||
|
||||
# Allocated quantity cannot cause the stock item to be over-allocated
|
||||
if self.stock_item.quantity - self.stock_item.allocation_count() + self.quantity < self.quantity:
|
||||
@@ -1076,6 +1079,7 @@ class BuildItem(models.Model):
|
||||
Build,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='allocated_stock',
|
||||
verbose_name=_('Build'),
|
||||
help_text=_('Build to allocate parts')
|
||||
)
|
||||
|
||||
@@ -1083,6 +1087,7 @@ class BuildItem(models.Model):
|
||||
'stock.StockItem',
|
||||
on_delete=models.CASCADE,
|
||||
related_name='allocations',
|
||||
verbose_name=_('Stock Item'),
|
||||
help_text=_('Source stock item'),
|
||||
limit_choices_to={
|
||||
'sales_order': None,
|
||||
@@ -1095,6 +1100,7 @@ class BuildItem(models.Model):
|
||||
max_digits=15,
|
||||
default=1,
|
||||
validators=[MinValueValidator(0)],
|
||||
verbose_name=_('Quantity'),
|
||||
help_text=_('Stock quantity to allocate to build')
|
||||
)
|
||||
|
||||
@@ -1103,6 +1109,7 @@ class BuildItem(models.Model):
|
||||
on_delete=models.SET_NULL,
|
||||
blank=True, null=True,
|
||||
related_name='items_to_install',
|
||||
verbose_name=_('Install into'),
|
||||
help_text=_('Destination stock item'),
|
||||
limit_choices_to={
|
||||
'is_building': True,
|
||||
|
||||
@@ -164,7 +164,7 @@ src="{% static 'img/blank_image.png' %}"
|
||||
launchModalForm("{% url 'build-cancel' build.id %}",
|
||||
{
|
||||
reload: true,
|
||||
submit_text: "Cancel Build",
|
||||
submit_text: '{% trans "Cancel Build" %}',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -173,7 +173,7 @@ src="{% static 'img/blank_image.png' %}"
|
||||
"{% url 'build-complete' build.id %}",
|
||||
{
|
||||
reload: true,
|
||||
submit_text: "Complete Build",
|
||||
submit_text: '{% trans "Complete Build" %}',
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
@@ -130,6 +130,7 @@ InvenTree | {% trans "Build Orders" %}
|
||||
initialView: 'dayGridMonth',
|
||||
nowIndicator: true,
|
||||
aspectRatio: 2.5,
|
||||
locale: '{{request.LANGUAGE_CODE}}',
|
||||
datesSet: function() {
|
||||
loadOrderEvents(calendar);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,9 @@
|
||||
|
||||
{% block heading %}
|
||||
{% trans "Build Notes" %}
|
||||
{% if roles.build.change and not editing %}
|
||||
<button title='{% trans "Edit notes" %}' class='btn btn-default' id='edit-notes'><span class='fas fa-edit'></span></button>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block details %}
|
||||
@@ -20,14 +23,13 @@
|
||||
|
||||
{{ form }}
|
||||
<hr>
|
||||
<input type="submit" value='{% trans "Save" %}'/>
|
||||
<button type="submit" class='btn btn-default'>{% trans "Save" %}</button>
|
||||
|
||||
</form>
|
||||
|
||||
{{ form.media }}
|
||||
|
||||
{% else %}
|
||||
<button title='{% trans "Edit notes" %}' class='btn btn-default action-button' id='edit-notes'><span class='fas fa-edit'></span></button>
|
||||
|
||||
{{ build.notes | markdownify }}
|
||||
{% endif %}
|
||||
|
||||
@@ -5,7 +5,7 @@ Django views for interacting with Build objects
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.utils.translation import ugettext as _
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.views.generic import DetailView, ListView, UpdateView
|
||||
from django.forms import HiddenInput
|
||||
|
||||
@@ -17,7 +17,7 @@ from djmoney.models.fields import MoneyField
|
||||
from djmoney.contrib.exchange.models import convert_money
|
||||
from djmoney.contrib.exchange.exceptions import MissingRate
|
||||
|
||||
from django.utils.translation import ugettext as _
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from django.core.validators import MinValueValidator, URLValidator
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
@@ -78,6 +78,13 @@ class InvenTreeSetting(models.Model):
|
||||
'choices': djmoney.settings.CURRENCY_CHOICES,
|
||||
},
|
||||
|
||||
'INVENTREE_DOWNLOAD_FROM_URL': {
|
||||
'name': _('Download from URL'),
|
||||
'description': _('Allow download of remote images and files from external URL'),
|
||||
'validator': bool,
|
||||
'default': False,
|
||||
},
|
||||
|
||||
'BARCODE_ENABLE': {
|
||||
'name': _('Barcode Support'),
|
||||
'description': _('Enable barcode scanner support'),
|
||||
@@ -97,6 +104,13 @@ class InvenTreeSetting(models.Model):
|
||||
'validator': bool,
|
||||
},
|
||||
|
||||
'PART_ALLOW_EDIT_IPN': {
|
||||
'name': _('Allow Editing IPN'),
|
||||
'description': _('Allow changing the IPN value while editing a part'),
|
||||
'default': True,
|
||||
'validator': bool,
|
||||
},
|
||||
|
||||
'PART_COPY_BOM': {
|
||||
'name': _('Copy Part BOM Data'),
|
||||
'description': _('Copy BOM data by default when duplicating a part'),
|
||||
@@ -486,7 +500,7 @@ class InvenTreeSetting(models.Model):
|
||||
create: If True, create a new setting if the specified key does not exist.
|
||||
"""
|
||||
|
||||
if not user.is_staff:
|
||||
if user is not None and not user.is_staff:
|
||||
return
|
||||
|
||||
try:
|
||||
|
||||
@@ -5,7 +5,7 @@ Django views for interacting with common models
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.utils.translation import ugettext as _
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from django.forms import CheckboxInput, Select
|
||||
|
||||
from InvenTree.views import AjaxUpdateView
|
||||
|
||||
@@ -15,9 +15,11 @@ from django.db.models import Q
|
||||
from InvenTree.helpers import str2bool
|
||||
|
||||
from .models import Company
|
||||
from .models import ManufacturerPart
|
||||
from .models import SupplierPart, SupplierPriceBreak
|
||||
|
||||
from .serializers import CompanySerializer
|
||||
from .serializers import ManufacturerPartSerializer
|
||||
from .serializers import SupplierPartSerializer, SupplierPriceBreakSerializer
|
||||
|
||||
|
||||
@@ -80,8 +82,105 @@ class CompanyDetail(generics.RetrieveUpdateDestroyAPIView):
|
||||
queryset = CompanySerializer.annotate_queryset(queryset)
|
||||
|
||||
return queryset
|
||||
|
||||
|
||||
class ManufacturerPartList(generics.ListCreateAPIView):
|
||||
""" API endpoint for list view of ManufacturerPart object
|
||||
|
||||
- GET: Return list of ManufacturerPart objects
|
||||
- POST: Create a new ManufacturerPart object
|
||||
"""
|
||||
|
||||
queryset = ManufacturerPart.objects.all().prefetch_related(
|
||||
'part',
|
||||
'manufacturer',
|
||||
'supplier_parts',
|
||||
)
|
||||
|
||||
serializer_class = ManufacturerPartSerializer
|
||||
|
||||
def get_serializer(self, *args, **kwargs):
|
||||
|
||||
# Do we wish to include extra detail?
|
||||
try:
|
||||
kwargs['part_detail'] = str2bool(self.request.query_params.get('part_detail', None))
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
try:
|
||||
kwargs['manufacturer_detail'] = str2bool(self.request.query_params.get('manufacturer_detail', None))
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
try:
|
||||
kwargs['pretty'] = str2bool(self.request.query_params.get('pretty', None))
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
kwargs['context'] = self.get_serializer_context()
|
||||
|
||||
return self.serializer_class(*args, **kwargs)
|
||||
|
||||
def filter_queryset(self, queryset):
|
||||
"""
|
||||
Custom filtering for the queryset.
|
||||
"""
|
||||
|
||||
queryset = super().filter_queryset(queryset)
|
||||
|
||||
params = self.request.query_params
|
||||
|
||||
# Filter by manufacturer
|
||||
manufacturer = params.get('company', None)
|
||||
|
||||
if manufacturer is not None:
|
||||
queryset = queryset.filter(manufacturer=manufacturer)
|
||||
|
||||
# Filter by parent part?
|
||||
part = params.get('part', None)
|
||||
|
||||
if part is not None:
|
||||
queryset = queryset.filter(part=part)
|
||||
|
||||
# Filter by 'active' status of the part?
|
||||
active = params.get('active', None)
|
||||
|
||||
if active is not None:
|
||||
active = str2bool(active)
|
||||
queryset = queryset.filter(part__active=active)
|
||||
|
||||
return queryset
|
||||
|
||||
filter_backends = [
|
||||
DjangoFilterBackend,
|
||||
filters.SearchFilter,
|
||||
filters.OrderingFilter,
|
||||
]
|
||||
|
||||
filter_fields = [
|
||||
]
|
||||
|
||||
search_fields = [
|
||||
'manufacturer__name',
|
||||
'description',
|
||||
'MPN',
|
||||
'part__name',
|
||||
'part__description',
|
||||
]
|
||||
|
||||
|
||||
class ManufacturerPartDetail(generics.RetrieveUpdateDestroyAPIView):
|
||||
""" API endpoint for detail view of ManufacturerPart object
|
||||
|
||||
- GET: Retrieve detail view
|
||||
- PATCH: Update object
|
||||
- DELETE: Delete object
|
||||
"""
|
||||
|
||||
queryset = ManufacturerPart.objects.all()
|
||||
serializer_class = ManufacturerPartSerializer
|
||||
|
||||
|
||||
class SupplierPartList(generics.ListCreateAPIView):
|
||||
""" API endpoint for list view of SupplierPart object
|
||||
|
||||
@@ -92,7 +191,7 @@ class SupplierPartList(generics.ListCreateAPIView):
|
||||
queryset = SupplierPart.objects.all().prefetch_related(
|
||||
'part',
|
||||
'supplier',
|
||||
'manufacturer'
|
||||
'manufacturer_part__manufacturer',
|
||||
)
|
||||
|
||||
def get_queryset(self):
|
||||
@@ -114,7 +213,7 @@ class SupplierPartList(generics.ListCreateAPIView):
|
||||
manufacturer = params.get('manufacturer', None)
|
||||
|
||||
if manufacturer is not None:
|
||||
queryset = queryset.filter(manufacturer=manufacturer)
|
||||
queryset = queryset.filter(manufacturer_part__manufacturer=manufacturer)
|
||||
|
||||
# Filter by supplier
|
||||
supplier = params.get('supplier', None)
|
||||
@@ -126,7 +225,7 @@ class SupplierPartList(generics.ListCreateAPIView):
|
||||
company = params.get('company', None)
|
||||
|
||||
if company is not None:
|
||||
queryset = queryset.filter(Q(manufacturer=company) | Q(supplier=company))
|
||||
queryset = queryset.filter(Q(manufacturer_part__manufacturer=company) | Q(supplier=company))
|
||||
|
||||
# Filter by parent part?
|
||||
part = params.get('part', None)
|
||||
@@ -134,6 +233,12 @@ class SupplierPartList(generics.ListCreateAPIView):
|
||||
if part is not None:
|
||||
queryset = queryset.filter(part=part)
|
||||
|
||||
# Filter by manufacturer part?
|
||||
manufacturer_part = params.get('manufacturer_part', None)
|
||||
|
||||
if manufacturer_part is not None:
|
||||
queryset = queryset.filter(manufacturer_part=manufacturer_part)
|
||||
|
||||
# Filter by 'active' status of the part?
|
||||
active = params.get('active', None)
|
||||
|
||||
@@ -184,9 +289,9 @@ class SupplierPartList(generics.ListCreateAPIView):
|
||||
search_fields = [
|
||||
'SKU',
|
||||
'supplier__name',
|
||||
'manufacturer__name',
|
||||
'manufacturer_part__manufacturer__name',
|
||||
'description',
|
||||
'MPN',
|
||||
'manufacturer_part__MPN',
|
||||
'part__name',
|
||||
'part__description',
|
||||
]
|
||||
@@ -197,7 +302,7 @@ class SupplierPartDetail(generics.RetrieveUpdateDestroyAPIView):
|
||||
|
||||
- GET: Retrieve detail view
|
||||
- PATCH: Update object
|
||||
- DELETE: Delete objec
|
||||
- DELETE: Delete object
|
||||
"""
|
||||
|
||||
queryset = SupplierPart.objects.all()
|
||||
@@ -226,6 +331,15 @@ class SupplierPriceBreakList(generics.ListCreateAPIView):
|
||||
]
|
||||
|
||||
|
||||
manufacturer_part_api_urls = [
|
||||
|
||||
url(r'^(?P<pk>\d+)/?', ManufacturerPartDetail.as_view(), name='api-manufacturer-part-detail'),
|
||||
|
||||
# Catch anything else
|
||||
url(r'^.*$', ManufacturerPartList.as_view(), name='api-manufacturer-part-list'),
|
||||
]
|
||||
|
||||
|
||||
supplier_part_api_urls = [
|
||||
|
||||
url(r'^(?P<pk>\d+)/?', SupplierPartDetail.as_view(), name='api-supplier-part-detail'),
|
||||
@@ -236,7 +350,8 @@ supplier_part_api_urls = [
|
||||
|
||||
|
||||
company_api_urls = [
|
||||
|
||||
url(r'^part/manufacturer/', include(manufacturer_part_api_urls)),
|
||||
|
||||
url(r'^part/', include(supplier_part_api_urls)),
|
||||
|
||||
url(r'^price-break/', SupplierPriceBreakList.as_view(), name='api-part-supplier-price'),
|
||||
|
||||
@@ -7,8 +7,9 @@ from django.apps import AppConfig
|
||||
from django.db.utils import OperationalError, ProgrammingError
|
||||
from django.conf import settings
|
||||
|
||||
from PIL import UnidentifiedImageError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = logging.getLogger("inventree")
|
||||
|
||||
|
||||
class CompanyConfig(AppConfig):
|
||||
@@ -38,9 +39,11 @@ class CompanyConfig(AppConfig):
|
||||
try:
|
||||
company.image.render_variations(replace=False)
|
||||
except FileNotFoundError:
|
||||
logger.warning("Image file missing")
|
||||
logger.warning(f"Image file '{company.image}' missing")
|
||||
company.image = None
|
||||
company.save()
|
||||
except UnidentifiedImageError:
|
||||
logger.warning(f"Image file '{company.image}' is invalid")
|
||||
except (OperationalError, ProgrammingError):
|
||||
# Getting here probably meant the database was in test mode
|
||||
pass
|
||||
|
||||
@@ -31,3 +31,17 @@
|
||||
name: Another customer!
|
||||
description: Yet another company
|
||||
is_customer: True
|
||||
|
||||
- model: company.company
|
||||
pk: 6
|
||||
fields:
|
||||
name: A manufacturer
|
||||
description: A company that makes parts!
|
||||
is_manufacturer: True
|
||||
|
||||
- model: company.company
|
||||
pk: 7
|
||||
fields:
|
||||
name: Another manufacturer
|
||||
description: They build things and sell it to us
|
||||
is_manufacturer: True
|
||||
|
||||
39
InvenTree/company/fixtures/manufacturer_part.yaml
Normal file
39
InvenTree/company/fixtures/manufacturer_part.yaml
Normal file
@@ -0,0 +1,39 @@
|
||||
# Manufacturer Parts
|
||||
|
||||
- model: company.manufacturerpart
|
||||
pk: 1
|
||||
fields:
|
||||
part: 5
|
||||
manufacturer: 6
|
||||
MPN: 'MPN123'
|
||||
|
||||
- model: company.manufacturerpart
|
||||
pk: 2
|
||||
fields:
|
||||
part: 3
|
||||
manufacturer: 7
|
||||
MPN: 'MPN456'
|
||||
|
||||
- model: company.manufacturerpart
|
||||
pk: 3
|
||||
fields:
|
||||
part: 5
|
||||
manufacturer: 7
|
||||
MPN: 'MPN789'
|
||||
|
||||
# Supplier parts linked to Manufacturer parts
|
||||
- model: company.supplierpart
|
||||
pk: 10
|
||||
fields:
|
||||
part: 3
|
||||
manufacturer_part: 2
|
||||
supplier: 2
|
||||
SKU: 'MPN456-APPEL'
|
||||
|
||||
- model: company.supplierpart
|
||||
pk: 11
|
||||
fields:
|
||||
part: 3
|
||||
manufacturer_part: 2
|
||||
supplier: 3
|
||||
SKU: 'MPN456-ZERG'
|
||||
@@ -8,7 +8,7 @@ from __future__ import unicode_literals
|
||||
from InvenTree.forms import HelperForm
|
||||
from InvenTree.fields import RoundingDecimalFormField
|
||||
|
||||
from django.utils.translation import ugettext as _
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
import django.forms
|
||||
|
||||
import djmoney.settings
|
||||
@@ -17,6 +17,7 @@ from djmoney.forms.fields import MoneyField
|
||||
import common.settings
|
||||
|
||||
from .models import Company
|
||||
from .models import ManufacturerPart
|
||||
from .models import SupplierPart
|
||||
from .models import SupplierPriceBreak
|
||||
|
||||
@@ -34,6 +35,7 @@ class EditCompanyForm(HelperForm):
|
||||
|
||||
currency = django.forms.ChoiceField(
|
||||
required=False,
|
||||
label=_('Currency'),
|
||||
help_text=_('Default currency used for this company'),
|
||||
choices=[('', '----------')] + djmoney.settings.CURRENCY_CHOICES,
|
||||
initial=common.settings.currency_code_default,
|
||||
@@ -66,12 +68,48 @@ class CompanyImageForm(HelperForm):
|
||||
]
|
||||
|
||||
|
||||
class CompanyImageDownloadForm(HelperForm):
|
||||
"""
|
||||
Form for downloading an image from a URL
|
||||
"""
|
||||
|
||||
url = django.forms.URLField(
|
||||
label=_('URL'),
|
||||
help_text=_('Image URL'),
|
||||
required=True
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Company
|
||||
fields = [
|
||||
'url',
|
||||
]
|
||||
|
||||
|
||||
class EditManufacturerPartForm(HelperForm):
|
||||
""" Form for editing a ManufacturerPart object """
|
||||
|
||||
field_prefix = {
|
||||
'link': 'fa-link',
|
||||
'MPN': 'fa-hashtag',
|
||||
}
|
||||
|
||||
class Meta:
|
||||
model = ManufacturerPart
|
||||
fields = [
|
||||
'part',
|
||||
'manufacturer',
|
||||
'MPN',
|
||||
'description',
|
||||
'link',
|
||||
]
|
||||
|
||||
|
||||
class EditSupplierPartForm(HelperForm):
|
||||
""" Form for editing a SupplierPart object """
|
||||
|
||||
field_prefix = {
|
||||
'link': 'fa-link',
|
||||
'MPN': 'fa-hashtag',
|
||||
'SKU': 'fa-hashtag',
|
||||
'note': 'fa-pencil-alt',
|
||||
}
|
||||
@@ -85,15 +123,28 @@ class EditSupplierPartForm(HelperForm):
|
||||
required=False,
|
||||
)
|
||||
|
||||
manufacturer = django.forms.ChoiceField(
|
||||
required=False,
|
||||
help_text=_('Select manufacturer'),
|
||||
choices=[],
|
||||
)
|
||||
|
||||
MPN = django.forms.CharField(
|
||||
required=False,
|
||||
help_text=_('Manufacturer Part Number'),
|
||||
max_length=100,
|
||||
label=_('MPN'),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = SupplierPart
|
||||
fields = [
|
||||
'part',
|
||||
'supplier',
|
||||
'SKU',
|
||||
'description',
|
||||
'manufacturer',
|
||||
'MPN',
|
||||
'description',
|
||||
'link',
|
||||
'note',
|
||||
'single_pricing',
|
||||
@@ -102,6 +153,19 @@ class EditSupplierPartForm(HelperForm):
|
||||
'packaging',
|
||||
]
|
||||
|
||||
def get_manufacturer_choices(self):
|
||||
""" Returns tuples for all manufacturers """
|
||||
empty_choice = [('', '----------')]
|
||||
|
||||
manufacturers = [(manufacturer.id, manufacturer.name) for manufacturer in Company.objects.filter(is_manufacturer=True)]
|
||||
|
||||
return empty_choice + manufacturers
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self.fields['manufacturer'].choices = self.get_manufacturer_choices()
|
||||
|
||||
|
||||
class EditPriceBreakForm(HelperForm):
|
||||
""" Form for creating / editing a supplier price break """
|
||||
|
||||
69
InvenTree/company/migrations/0032_auto_20210403_1837.py
Normal file
69
InvenTree/company/migrations/0032_auto_20210403_1837.py
Normal file
@@ -0,0 +1,69 @@
|
||||
# Generated by Django 3.0.7 on 2021-04-03 18:37
|
||||
|
||||
import InvenTree.fields
|
||||
import company.models
|
||||
import django.core.validators
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import markdownx.models
|
||||
import stdimage.models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('company', '0031_auto_20210103_2215'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='company',
|
||||
name='image',
|
||||
field=stdimage.models.StdImageField(blank=True, null=True, upload_to=company.models.rename_company_image, verbose_name='Image'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='company',
|
||||
name='is_customer',
|
||||
field=models.BooleanField(default=False, help_text='Do you sell items to this company?', verbose_name='is customer'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='company',
|
||||
name='is_manufacturer',
|
||||
field=models.BooleanField(default=False, help_text='Does this company manufacture parts?', verbose_name='is manufacturer'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='company',
|
||||
name='is_supplier',
|
||||
field=models.BooleanField(default=True, help_text='Do you purchase items from this company?', verbose_name='is supplier'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='company',
|
||||
name='link',
|
||||
field=InvenTree.fields.InvenTreeURLField(blank=True, help_text='Link to external company information', verbose_name='Link'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='company',
|
||||
name='notes',
|
||||
field=markdownx.models.MarkdownxField(blank=True, verbose_name='Notes'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='supplierpart',
|
||||
name='base_cost',
|
||||
field=models.DecimalField(decimal_places=3, default=0, help_text='Minimum charge (e.g. stocking fee)', max_digits=10, validators=[django.core.validators.MinValueValidator(0)], verbose_name='base cost'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='supplierpart',
|
||||
name='multiple',
|
||||
field=models.PositiveIntegerField(default=1, help_text='Order multiple', validators=[django.core.validators.MinValueValidator(1)], verbose_name='multiple'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='supplierpart',
|
||||
name='packaging',
|
||||
field=models.CharField(blank=True, help_text='Part packaging', max_length=50, null=True, verbose_name='Packaging'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='supplierpricebreak',
|
||||
name='part',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='pricebreaks', to='company.SupplierPart', verbose_name='Part'),
|
||||
),
|
||||
]
|
||||
18
InvenTree/company/migrations/0033_auto_20210410_1528.py
Normal file
18
InvenTree/company/migrations/0033_auto_20210410_1528.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 3.0.7 on 2021-04-10 05:28
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('company', '0032_auto_20210403_1837'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='company',
|
||||
name='description',
|
||||
field=models.CharField(blank=True, help_text='Description of the company', max_length=500, verbose_name='Company description'),
|
||||
),
|
||||
]
|
||||
27
InvenTree/company/migrations/0034_manufacturerpart.py
Normal file
27
InvenTree/company/migrations/0034_manufacturerpart.py
Normal file
@@ -0,0 +1,27 @@
|
||||
import InvenTree.fields
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('company', '0033_auto_20210410_1528'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='ManufacturerPart',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('MPN', models.CharField(help_text='Manufacturer Part Number', max_length=100, null=True, verbose_name='MPN')),
|
||||
('link', InvenTree.fields.InvenTreeURLField(blank=True, help_text='URL for external manufacturer part link', null=True, verbose_name='Link')),
|
||||
('description', models.CharField(blank=True, help_text='Manufacturer part description', max_length=250, null=True, verbose_name='Description')),
|
||||
('manufacturer', models.ForeignKey(help_text='Select manufacturer', limit_choices_to={'is_manufacturer': True}, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='manufactured_parts', to='company.Company', verbose_name='Manufacturer')),
|
||||
('part', models.ForeignKey(help_text='Select part', limit_choices_to={'purchaseable': True}, on_delete=django.db.models.deletion.CASCADE, related_name='manufacturer_parts', to='part.Part', verbose_name='Base Part')),
|
||||
],
|
||||
options={
|
||||
'unique_together': {('part', 'manufacturer', 'MPN')},
|
||||
},
|
||||
),
|
||||
]
|
||||
18
InvenTree/company/migrations/0035_supplierpart_update_1.py
Normal file
18
InvenTree/company/migrations/0035_supplierpart_update_1.py
Normal file
@@ -0,0 +1,18 @@
|
||||
import InvenTree.fields
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('company', '0034_manufacturerpart'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='supplierpart',
|
||||
name='manufacturer_part',
|
||||
field=models.ForeignKey(blank=True, help_text='Select manufacturer part', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='supplier_parts', to='company.ManufacturerPart', verbose_name='Manufacturer Part'),
|
||||
),
|
||||
]
|
||||
110
InvenTree/company/migrations/0036_supplierpart_update_2.py
Normal file
110
InvenTree/company/migrations/0036_supplierpart_update_2.py
Normal file
@@ -0,0 +1,110 @@
|
||||
import InvenTree.fields
|
||||
from django.db import migrations, models, transaction
|
||||
import django.db.models.deletion
|
||||
from django.db.utils import IntegrityError
|
||||
|
||||
def supplierpart_make_manufacturer_parts(apps, schema_editor):
|
||||
Part = apps.get_model('part', 'Part')
|
||||
ManufacturerPart = apps.get_model('company', 'ManufacturerPart')
|
||||
SupplierPart = apps.get_model('company', 'SupplierPart')
|
||||
|
||||
supplier_parts = SupplierPart.objects.all()
|
||||
|
||||
if supplier_parts:
|
||||
print(f'\nCreating ManufacturerPart Objects\n{"-"*10}')
|
||||
for supplier_part in supplier_parts:
|
||||
print(f'{supplier_part.supplier.name[:15].ljust(15)} | {supplier_part.SKU[:15].ljust(15)}\t', end='')
|
||||
|
||||
if supplier_part.manufacturer_part:
|
||||
print(f'[ERROR: MANUFACTURER PART ALREADY EXISTS]')
|
||||
continue
|
||||
|
||||
part = supplier_part.part
|
||||
if not part:
|
||||
print(f'[ERROR: SUPPLIER PART IS NOT CONNECTED TO PART]')
|
||||
continue
|
||||
|
||||
manufacturer = supplier_part.manufacturer
|
||||
MPN = supplier_part.MPN
|
||||
link = supplier_part.link
|
||||
description = supplier_part.description
|
||||
|
||||
if manufacturer or MPN:
|
||||
print(f' | {part.name[:15].ljust(15)}', end='')
|
||||
|
||||
try:
|
||||
print(f' | {manufacturer.name[:15].ljust(15)}', end='')
|
||||
except AttributeError:
|
||||
print(f' | {"EMPTY MANUF".ljust(15)}', end='')
|
||||
|
||||
try:
|
||||
print(f' | {MPN[:15].ljust(15)}', end='')
|
||||
except TypeError:
|
||||
print(f' | {"EMPTY MPN".ljust(15)}', end='')
|
||||
|
||||
print('\t', end='')
|
||||
|
||||
# Create ManufacturerPart
|
||||
manufacturer_part = ManufacturerPart(part=part, manufacturer=manufacturer, MPN=MPN, description=description, link=link)
|
||||
created = False
|
||||
try:
|
||||
with transaction.atomic():
|
||||
manufacturer_part.save()
|
||||
created = True
|
||||
except IntegrityError:
|
||||
manufacturer_part = ManufacturerPart.objects.get(part=part, manufacturer=manufacturer, MPN=MPN)
|
||||
|
||||
# Link it to SupplierPart
|
||||
supplier_part.manufacturer_part = manufacturer_part
|
||||
supplier_part.save()
|
||||
|
||||
if created:
|
||||
print(f'[SUCCESS: MANUFACTURER PART CREATED]')
|
||||
else:
|
||||
print(f'[IGNORED: MANUFACTURER PART ALREADY EXISTS]')
|
||||
else:
|
||||
print(f'[IGNORED: MISSING MANUFACTURER DATA]')
|
||||
|
||||
print(f'{"-"*10}\nDone\n')
|
||||
|
||||
def supplierpart_populate_manufacturer_info(apps, schema_editor):
|
||||
Part = apps.get_model('part', 'Part')
|
||||
ManufacturerPart = apps.get_model('company', 'ManufacturerPart')
|
||||
SupplierPart = apps.get_model('company', 'SupplierPart')
|
||||
|
||||
supplier_parts = SupplierPart.objects.all()
|
||||
|
||||
if supplier_parts:
|
||||
print(f'\nSupplierPart: Populating Manufacturer Information\n{"-"*10}')
|
||||
for supplier_part in supplier_parts:
|
||||
print(f'{supplier_part.supplier.name[:15].ljust(15)} | {supplier_part.SKU[:15].ljust(15)}\t', end='')
|
||||
|
||||
manufacturer_part = supplier_part.manufacturer_part
|
||||
|
||||
if manufacturer_part:
|
||||
if manufacturer_part.manufacturer:
|
||||
supplier_part.manufacturer = manufacturer_part.manufacturer
|
||||
|
||||
if manufacturer_part.MPN:
|
||||
supplier_part.MPN = manufacturer_part.MPN
|
||||
|
||||
supplier_part.save()
|
||||
|
||||
print(f'[SUCCESS: UPDATED MANUFACTURER INFO]')
|
||||
else:
|
||||
print(f'[IGNORED: NO MANUFACTURER PART]')
|
||||
|
||||
print(f'{"-"*10}\nDone\n')
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('company', '0035_supplierpart_update_1'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
# Make new ManufacturerPart with SupplierPart "manufacturer" and "MPN"
|
||||
# fields, then link it to the new SupplierPart "manufacturer_part" field
|
||||
migrations.RunPython(supplierpart_make_manufacturer_parts, reverse_code=supplierpart_populate_manufacturer_info),
|
||||
]
|
||||
21
InvenTree/company/migrations/0037_supplierpart_update_3.py
Normal file
21
InvenTree/company/migrations/0037_supplierpart_update_3.py
Normal file
@@ -0,0 +1,21 @@
|
||||
import InvenTree.fields
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('company', '0036_supplierpart_update_2'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='supplierpart',
|
||||
name='MPN',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='supplierpart',
|
||||
name='manufacturer',
|
||||
),
|
||||
]
|
||||
@@ -9,9 +9,11 @@ import os
|
||||
|
||||
import math
|
||||
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from django.core.validators import MinValueValidator
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import models
|
||||
from django.db.utils import IntegrityError
|
||||
from django.db.models import Sum, Q, UniqueConstraint
|
||||
|
||||
from django.apps import apps
|
||||
@@ -95,7 +97,12 @@ class Company(models.Model):
|
||||
help_text=_('Company name'),
|
||||
verbose_name=_('Company name'))
|
||||
|
||||
description = models.CharField(max_length=500, verbose_name=_('Company description'), help_text=_('Description of the company'))
|
||||
description = models.CharField(
|
||||
max_length=500,
|
||||
verbose_name=_('Company description'),
|
||||
help_text=_('Description of the company'),
|
||||
blank=True,
|
||||
)
|
||||
|
||||
website = models.URLField(blank=True, verbose_name=_('Website'), help_text=_('Company website URL'))
|
||||
|
||||
@@ -114,7 +121,7 @@ class Company(models.Model):
|
||||
verbose_name=_('Contact'),
|
||||
blank=True, help_text=_('Point of contact'))
|
||||
|
||||
link = InvenTreeURLField(blank=True, help_text=_('Link to external company information'))
|
||||
link = InvenTreeURLField(blank=True, verbose_name=_('Link'), help_text=_('Link to external company information'))
|
||||
|
||||
image = StdImageField(
|
||||
upload_to=rename_company_image,
|
||||
@@ -122,15 +129,16 @@ class Company(models.Model):
|
||||
blank=True,
|
||||
variations={'thumbnail': (128, 128)},
|
||||
delete_orphans=True,
|
||||
verbose_name=_('Image'),
|
||||
)
|
||||
|
||||
notes = MarkdownxField(blank=True)
|
||||
notes = MarkdownxField(blank=True, verbose_name=_('Notes'))
|
||||
|
||||
is_customer = models.BooleanField(default=False, help_text=_('Do you sell items to this company?'))
|
||||
is_customer = models.BooleanField(default=False, verbose_name=_('is customer'), help_text=_('Do you sell items to this company?'))
|
||||
|
||||
is_supplier = models.BooleanField(default=True, help_text=_('Do you purchase items from this company?'))
|
||||
is_supplier = models.BooleanField(default=True, verbose_name=_('is supplier'), help_text=_('Do you purchase items from this company?'))
|
||||
|
||||
is_manufacturer = models.BooleanField(default=False, help_text=_('Does this company manufacture parts?'))
|
||||
is_manufacturer = models.BooleanField(default=False, verbose_name=_('is manufacturer'), help_text=_('Does this company manufacture parts?'))
|
||||
|
||||
currency = models.CharField(
|
||||
max_length=3,
|
||||
@@ -202,7 +210,7 @@ class Company(models.Model):
|
||||
@property
|
||||
def parts(self):
|
||||
""" Return SupplierPart objects which are supplied or manufactured by this company """
|
||||
return SupplierPart.objects.filter(Q(supplier=self.id) | Q(manufacturer=self.id))
|
||||
return SupplierPart.objects.filter(Q(supplier=self.id) | Q(manufacturer_part__manufacturer=self.id))
|
||||
|
||||
@property
|
||||
def part_count(self):
|
||||
@@ -217,7 +225,7 @@ class Company(models.Model):
|
||||
def stock_items(self):
|
||||
""" Return a list of all stock items supplied or manufactured by this company """
|
||||
stock = apps.get_model('stock', 'StockItem')
|
||||
return stock.objects.filter(Q(supplier_part__supplier=self.id) | Q(supplier_part__manufacturer=self.id)).all()
|
||||
return stock.objects.filter(Q(supplier_part__supplier=self.id) | Q(supplier_part__manufacturer_part__manufacturer=self.id)).all()
|
||||
|
||||
@property
|
||||
def stock_count(self):
|
||||
@@ -278,19 +286,106 @@ class Contact(models.Model):
|
||||
on_delete=models.CASCADE)
|
||||
|
||||
|
||||
class SupplierPart(models.Model):
|
||||
""" Represents a unique part as provided by a Supplier
|
||||
Each SupplierPart is identified by a MPN (Manufacturer Part Number)
|
||||
Each SupplierPart is also linked to a Part object.
|
||||
A Part may be available from multiple suppliers
|
||||
class ManufacturerPart(models.Model):
|
||||
""" Represents a unique part as provided by a Manufacturer
|
||||
Each ManufacturerPart is identified by a MPN (Manufacturer Part Number)
|
||||
Each ManufacturerPart is also linked to a Part object.
|
||||
A Part may be available from multiple manufacturers
|
||||
|
||||
Attributes:
|
||||
part: Link to the master Part
|
||||
manufacturer: Company that manufactures the ManufacturerPart
|
||||
MPN: Manufacture part number
|
||||
link: Link to external website for this manufacturer part
|
||||
description: Descriptive notes field
|
||||
"""
|
||||
|
||||
class Meta:
|
||||
unique_together = ('part', 'manufacturer', 'MPN')
|
||||
|
||||
part = models.ForeignKey('part.Part', on_delete=models.CASCADE,
|
||||
related_name='manufacturer_parts',
|
||||
verbose_name=_('Base Part'),
|
||||
limit_choices_to={
|
||||
'purchaseable': True,
|
||||
},
|
||||
help_text=_('Select part'),
|
||||
)
|
||||
|
||||
manufacturer = models.ForeignKey(
|
||||
Company,
|
||||
on_delete=models.CASCADE,
|
||||
null=True,
|
||||
related_name='manufactured_parts',
|
||||
limit_choices_to={
|
||||
'is_manufacturer': True
|
||||
},
|
||||
verbose_name=_('Manufacturer'),
|
||||
help_text=_('Select manufacturer'),
|
||||
)
|
||||
|
||||
MPN = models.CharField(
|
||||
null=True,
|
||||
max_length=100,
|
||||
verbose_name=_('MPN'),
|
||||
help_text=_('Manufacturer Part Number')
|
||||
)
|
||||
|
||||
link = InvenTreeURLField(
|
||||
blank=True, null=True,
|
||||
verbose_name=_('Link'),
|
||||
help_text=_('URL for external manufacturer part link')
|
||||
)
|
||||
|
||||
description = models.CharField(
|
||||
max_length=250, blank=True, null=True,
|
||||
verbose_name=_('Description'),
|
||||
help_text=_('Manufacturer part description')
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def create(cls, part, manufacturer, mpn, description, link=None):
|
||||
""" Check if ManufacturerPart instance does not already exist
|
||||
then create it
|
||||
"""
|
||||
|
||||
manufacturer_part = None
|
||||
|
||||
try:
|
||||
manufacturer_part = ManufacturerPart.objects.get(part=part, manufacturer=manufacturer, MPN=mpn)
|
||||
except ManufacturerPart.DoesNotExist:
|
||||
pass
|
||||
|
||||
if not manufacturer_part:
|
||||
manufacturer_part = ManufacturerPart(part=part, manufacturer=manufacturer, MPN=mpn, description=description, link=link)
|
||||
manufacturer_part.save()
|
||||
|
||||
return manufacturer_part
|
||||
|
||||
def __str__(self):
|
||||
s = ''
|
||||
|
||||
if self.manufacturer:
|
||||
s += f'{self.manufacturer.name}'
|
||||
s += ' | '
|
||||
|
||||
s += f'{self.MPN}'
|
||||
|
||||
return s
|
||||
|
||||
|
||||
class SupplierPart(models.Model):
|
||||
""" Represents a unique part as provided by a Supplier
|
||||
Each SupplierPart is identified by a SKU (Supplier Part Number)
|
||||
Each SupplierPart is also linked to a Part or ManufacturerPart object.
|
||||
A Part may be available from multiple suppliers
|
||||
|
||||
Attributes:
|
||||
part: Link to the master Part (Obsolete)
|
||||
source_item: The sourcing item linked to this SupplierPart instance
|
||||
supplier: Company that supplies this SupplierPart object
|
||||
SKU: Stock keeping unit (supplier part number)
|
||||
manufacturer: Company that manufactures the SupplierPart (leave blank if it is the sample as the Supplier!)
|
||||
MPN: Manufacture part number
|
||||
link: Link to external website for this part
|
||||
link: Link to external website for this supplier part
|
||||
description: Descriptive notes field
|
||||
note: Longer form note field
|
||||
base_cost: Base charge added to order independent of quantity e.g. "Reeling Fee"
|
||||
@@ -302,6 +397,57 @@ class SupplierPart(models.Model):
|
||||
def get_absolute_url(self):
|
||||
return reverse('supplier-part-detail', kwargs={'pk': self.id})
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
""" Overriding save method to process the linked ManufacturerPart
|
||||
"""
|
||||
|
||||
if 'manufacturer' in kwargs:
|
||||
manufacturer_id = kwargs.pop('manufacturer')
|
||||
|
||||
try:
|
||||
manufacturer = Company.objects.get(pk=int(manufacturer_id))
|
||||
except (ValueError, Company.DoesNotExist):
|
||||
manufacturer = None
|
||||
else:
|
||||
manufacturer = None
|
||||
if 'MPN' in kwargs:
|
||||
MPN = kwargs.pop('MPN')
|
||||
else:
|
||||
MPN = None
|
||||
|
||||
if manufacturer or MPN:
|
||||
if not self.manufacturer_part:
|
||||
# Create ManufacturerPart
|
||||
manufacturer_part = ManufacturerPart.create(part=self.part,
|
||||
manufacturer=manufacturer,
|
||||
mpn=MPN,
|
||||
description=self.description)
|
||||
self.manufacturer_part = manufacturer_part
|
||||
else:
|
||||
# Update ManufacturerPart (if ID exists)
|
||||
try:
|
||||
manufacturer_part_id = self.manufacturer_part.id
|
||||
except AttributeError:
|
||||
manufacturer_part_id = None
|
||||
|
||||
if manufacturer_part_id:
|
||||
try:
|
||||
(manufacturer_part, created) = ManufacturerPart.objects.update_or_create(part=self.part,
|
||||
manufacturer=manufacturer,
|
||||
MPN=MPN)
|
||||
except IntegrityError:
|
||||
manufacturer_part = None
|
||||
raise ValidationError(f'ManufacturerPart linked to {self.part} from manufacturer {manufacturer.name}'
|
||||
f'with part number {MPN} already exists!')
|
||||
|
||||
if manufacturer_part:
|
||||
self.manufacturer_part = manufacturer_part
|
||||
|
||||
self.clean()
|
||||
self.validate_unique()
|
||||
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
class Meta:
|
||||
unique_together = ('part', 'supplier', 'SKU')
|
||||
|
||||
@@ -330,23 +476,12 @@ class SupplierPart(models.Model):
|
||||
help_text=_('Supplier stock keeping unit')
|
||||
)
|
||||
|
||||
manufacturer = models.ForeignKey(
|
||||
Company,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name='manufactured_parts',
|
||||
limit_choices_to={
|
||||
'is_manufacturer': True
|
||||
},
|
||||
verbose_name=_('Manufacturer'),
|
||||
help_text=_('Select manufacturer'),
|
||||
null=True, blank=True
|
||||
)
|
||||
|
||||
MPN = models.CharField(
|
||||
max_length=100, blank=True, null=True,
|
||||
verbose_name=_('MPN'),
|
||||
help_text=_('Manufacturer part number')
|
||||
)
|
||||
manufacturer_part = models.ForeignKey(ManufacturerPart, on_delete=models.CASCADE,
|
||||
blank=True, null=True,
|
||||
related_name='supplier_parts',
|
||||
verbose_name=_('Manufacturer Part'),
|
||||
help_text=_('Select manufacturer part'),
|
||||
)
|
||||
|
||||
link = InvenTreeURLField(
|
||||
blank=True, null=True,
|
||||
@@ -366,11 +501,11 @@ class SupplierPart(models.Model):
|
||||
help_text=_('Notes')
|
||||
)
|
||||
|
||||
base_cost = models.DecimalField(max_digits=10, decimal_places=3, default=0, validators=[MinValueValidator(0)], help_text=_('Minimum charge (e.g. stocking fee)'))
|
||||
base_cost = models.DecimalField(max_digits=10, decimal_places=3, default=0, validators=[MinValueValidator(0)], verbose_name=_('base cost'), help_text=_('Minimum charge (e.g. stocking fee)'))
|
||||
|
||||
packaging = models.CharField(max_length=50, blank=True, null=True, help_text=_('Part packaging'))
|
||||
packaging = models.CharField(max_length=50, blank=True, null=True, verbose_name=_('Packaging'), help_text=_('Part packaging'))
|
||||
|
||||
multiple = models.PositiveIntegerField(default=1, validators=[MinValueValidator(1)], help_text=('Order multiple'))
|
||||
multiple = models.PositiveIntegerField(default=1, validators=[MinValueValidator(1)], verbose_name=_('multiple'), help_text=_('Order multiple'))
|
||||
|
||||
# TODO - Reimplement lead-time as a charfield with special validation (pattern matching).
|
||||
# lead_time = models.DurationField(blank=True, null=True)
|
||||
@@ -383,10 +518,11 @@ class SupplierPart(models.Model):
|
||||
|
||||
items = []
|
||||
|
||||
if self.manufacturer:
|
||||
items.append(self.manufacturer.name)
|
||||
if self.MPN:
|
||||
items.append(self.MPN)
|
||||
if self.manufacturer_part:
|
||||
if self.manufacturer_part.manufacturer:
|
||||
items.append(self.manufacturer_part.manufacturer.name)
|
||||
if self.manufacturer_part.MPN:
|
||||
items.append(self.manufacturer_part.MPN)
|
||||
|
||||
return ' | '.join(items)
|
||||
|
||||
@@ -530,7 +666,7 @@ class SupplierPriceBreak(common.models.PriceBreak):
|
||||
currency: Reference to the currency of this pricebreak (leave empty for base currency)
|
||||
"""
|
||||
|
||||
part = models.ForeignKey(SupplierPart, on_delete=models.CASCADE, related_name='pricebreaks')
|
||||
part = models.ForeignKey(SupplierPart, on_delete=models.CASCADE, related_name='pricebreaks', verbose_name=_('Part'),)
|
||||
|
||||
class Meta:
|
||||
unique_together = ("part", "quantity")
|
||||
|
||||
@@ -7,6 +7,7 @@ from rest_framework import serializers
|
||||
from sql_util.utils import SubqueryCount
|
||||
|
||||
from .models import Company
|
||||
from .models import ManufacturerPart
|
||||
from .models import SupplierPart, SupplierPriceBreak
|
||||
|
||||
from InvenTree.serializers import InvenTreeModelSerializer
|
||||
@@ -80,6 +81,49 @@ class CompanySerializer(InvenTreeModelSerializer):
|
||||
]
|
||||
|
||||
|
||||
class ManufacturerPartSerializer(InvenTreeModelSerializer):
|
||||
""" Serializer for ManufacturerPart object """
|
||||
|
||||
part_detail = PartBriefSerializer(source='part', many=False, read_only=True)
|
||||
|
||||
manufacturer_detail = CompanyBriefSerializer(source='manufacturer', many=False, read_only=True)
|
||||
|
||||
pretty_name = serializers.CharField(read_only=True)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
|
||||
part_detail = kwargs.pop('part_detail', False)
|
||||
manufacturer_detail = kwargs.pop('manufacturer_detail', False)
|
||||
prettify = kwargs.pop('pretty', False)
|
||||
|
||||
super(ManufacturerPartSerializer, self).__init__(*args, **kwargs)
|
||||
|
||||
if part_detail is not True:
|
||||
self.fields.pop('part_detail')
|
||||
|
||||
if manufacturer_detail is not True:
|
||||
self.fields.pop('manufacturer_detail')
|
||||
|
||||
if prettify is not True:
|
||||
self.fields.pop('pretty_name')
|
||||
|
||||
manufacturer = serializers.PrimaryKeyRelatedField(queryset=Company.objects.filter(is_manufacturer=True))
|
||||
|
||||
class Meta:
|
||||
model = ManufacturerPart
|
||||
fields = [
|
||||
'pk',
|
||||
'part',
|
||||
'part_detail',
|
||||
'pretty_name',
|
||||
'manufacturer',
|
||||
'manufacturer_detail',
|
||||
'description',
|
||||
'MPN',
|
||||
'link',
|
||||
]
|
||||
|
||||
|
||||
class SupplierPartSerializer(InvenTreeModelSerializer):
|
||||
""" Serializer for SupplierPart object """
|
||||
|
||||
@@ -87,7 +131,7 @@ class SupplierPartSerializer(InvenTreeModelSerializer):
|
||||
|
||||
supplier_detail = CompanyBriefSerializer(source='supplier', many=False, read_only=True)
|
||||
|
||||
manufacturer_detail = CompanyBriefSerializer(source='manufacturer', many=False, read_only=True)
|
||||
manufacturer_detail = CompanyBriefSerializer(source='manufacturer_part.manufacturer', many=False, read_only=True)
|
||||
|
||||
pretty_name = serializers.CharField(read_only=True)
|
||||
|
||||
@@ -113,8 +157,12 @@ class SupplierPartSerializer(InvenTreeModelSerializer):
|
||||
self.fields.pop('pretty_name')
|
||||
|
||||
supplier = serializers.PrimaryKeyRelatedField(queryset=Company.objects.filter(is_supplier=True))
|
||||
|
||||
manufacturer = serializers.PrimaryKeyRelatedField(source='manufacturer_part.manufacturer', read_only=True)
|
||||
|
||||
MPN = serializers.StringRelatedField(source='manufacturer_part.MPN')
|
||||
|
||||
manufacturer = serializers.PrimaryKeyRelatedField(queryset=Company.objects.filter(is_manufacturer=True))
|
||||
manufacturer_part = ManufacturerPartSerializer(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = SupplierPart
|
||||
@@ -127,12 +175,31 @@ class SupplierPartSerializer(InvenTreeModelSerializer):
|
||||
'supplier_detail',
|
||||
'SKU',
|
||||
'manufacturer',
|
||||
'manufacturer_detail',
|
||||
'description',
|
||||
'MPN',
|
||||
'manufacturer_detail',
|
||||
'manufacturer_part',
|
||||
'description',
|
||||
'link',
|
||||
]
|
||||
|
||||
def create(self, validated_data):
|
||||
""" Extract manufacturer data and process ManufacturerPart """
|
||||
|
||||
# Create SupplierPart
|
||||
supplier_part = super().create(validated_data)
|
||||
|
||||
# Get ManufacturerPart raw data (unvalidated)
|
||||
manufacturer_id = self.initial_data.get('manufacturer', None)
|
||||
MPN = self.initial_data.get('MPN', None)
|
||||
|
||||
if manufacturer_id or MPN:
|
||||
kwargs = {'manufacturer': manufacturer_id,
|
||||
'MPN': MPN,
|
||||
}
|
||||
supplier_part.save(**kwargs)
|
||||
|
||||
return supplier_part
|
||||
|
||||
|
||||
class SupplierPriceBreakSerializer(InvenTreeModelSerializer):
|
||||
""" Serializer for SupplierPriceBreak object """
|
||||
|
||||
@@ -2,19 +2,32 @@
|
||||
|
||||
{% load static %}
|
||||
{% load i18n %}
|
||||
{% load inventree_extras %}
|
||||
|
||||
|
||||
{% block page_title %}
|
||||
InvenTree | {% trans "Company" %} - {{ company.name }}
|
||||
{% endblock %}
|
||||
|
||||
|
||||
{% block thumbnail %}
|
||||
<div class='dropzone' id='company-thumb'>
|
||||
<img class="part-thumb"
|
||||
{% settings_value "INVENTREE_DOWNLOAD_FROM_URL" as allow_download %}
|
||||
|
||||
<div class='dropzone part-thumb-container' id='company-thumb'>
|
||||
<img class="part-thumb" id='company-image'
|
||||
{% if company.image %}
|
||||
src="{{ company.image.url }}"
|
||||
{% else %}
|
||||
src="{% static 'img/blank_image.png' %}"
|
||||
{% endif %}/>
|
||||
<div class='btn-row part-thumb-overlay'>
|
||||
<div class='btn-group'>
|
||||
<button type='button' class='btn btn-default btn-glyph' title='{% trans "Upload new image" %}' id='company-image-upload'><span class='fas fa-file-upload'></span></button>
|
||||
{% if allow_download %}
|
||||
<button type='button' class='btn btn-default btn-glyph' title="{% trans 'Download image from URL' %}" id='company-image-url'><span class='fas fa-cloud-download-alt'></span></button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -30,17 +43,17 @@ InvenTree | {% trans "Company" %} - {{ company.name }}
|
||||
<p>{{ company.description }}</p>
|
||||
<div class='btn-group action-buttons'>
|
||||
{% if company.is_supplier and roles.purchase_order.add %}
|
||||
<button type='button' class='btn btn-default' id='company-order-2' title='Create purchase order'>
|
||||
<button type='button' class='btn btn-default' id='company-order-2' title='{% trans "Create Purchase Order" %}'>
|
||||
<span class='fas fa-shopping-cart'/>
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if perms.company.change_company %}
|
||||
<button type='button' class='btn btn-default' id='company-edit' title='Edit company information'>
|
||||
<button type='button' class='btn btn-default' id='company-edit' title='{% trans "Edit company information" %}'>
|
||||
<span class='fas fa-edit icon-green'/>
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if perms.company.delete_company %}
|
||||
<button type='button' class='btn btn-default' id='company-delete' title='Delete company'>
|
||||
<button type='button' class='btn btn-default' id='company-delete' title='{% trans "Delete Company" %}'>
|
||||
<span class='fas fa-trash-alt icon-red'/>
|
||||
</button>
|
||||
{% endif %}
|
||||
@@ -135,7 +148,13 @@ InvenTree | {% trans "Company" %} - {{ company.name }}
|
||||
}
|
||||
);
|
||||
|
||||
$("#company-thumb").click(function() {
|
||||
{% if company.image %}
|
||||
$('#company-image').click(function() {
|
||||
showModalImage('{{ company.image.url }}');
|
||||
});
|
||||
{% endif %}
|
||||
|
||||
$("#company-image-upload").click(function() {
|
||||
launchModalForm(
|
||||
"{% url 'company-image' company.id %}",
|
||||
{
|
||||
@@ -144,4 +163,17 @@ InvenTree | {% trans "Company" %} - {{ company.name }}
|
||||
);
|
||||
});
|
||||
|
||||
{% settings_value "INVENTREE_DOWNLOAD_FROM_URL" as allow_download %}
|
||||
|
||||
{% if allow_download %}
|
||||
$('#company-image-url').click(function() {
|
||||
launchModalForm(
|
||||
'{% url "company-image-download" company.id %}',
|
||||
{
|
||||
reload: true,
|
||||
}
|
||||
)
|
||||
});
|
||||
{% endif %}
|
||||
|
||||
{% endblock %}
|
||||
@@ -1,14 +1,16 @@
|
||||
{% extends "modal_delete_form.html" %}
|
||||
|
||||
{% load i18n %}
|
||||
|
||||
{% block pre_form_content %}
|
||||
|
||||
Are you sure you want to delete company '{{ company.name }}'?
|
||||
{% blocktrans with company.name as name %}Are you sure you want to delete company '{{ name }}'?{% endblocktrans %}
|
||||
|
||||
<br>
|
||||
|
||||
{% if company.supplied_part_count > 0 %}
|
||||
<p>There are {{ company.supplied_part_count }} parts sourced from this company.<br>
|
||||
If this supplier is deleted, these supplier part entries will also be deleted.</p>
|
||||
<p>{% blocktrans with company.supplied_part_count as count %}There are {{ count }} parts sourced from this company.<br>
|
||||
If this supplier is deleted, these supplier part entries will also be deleted.{% endblocktrans %}</p>
|
||||
<ul class='list-group'>
|
||||
{% for part in company.parts.all %}
|
||||
<li class='list-group-item'><b>{{ part.SKU }}</b> - <i>{{ part.part.full_name }}</i></li>
|
||||
|
||||
@@ -21,11 +21,13 @@
|
||||
<td>{% trans "Company Name" %}</td>
|
||||
<td>{{ company.name }}</td>
|
||||
</tr>
|
||||
{% if company.description %}
|
||||
<tr>
|
||||
<td><span class='fas fa-info'></span></td>
|
||||
<td>{% trans "Description" %}</td>
|
||||
<td>{{ company.description }}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
<tr>
|
||||
<td><span class='fas fa-globe'></span></td>
|
||||
<td>{% trans "Website" %}</td>
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
{% extends "company/company_base.html" %}
|
||||
{% load static %}
|
||||
{% load i18n %}
|
||||
{% load inventree_extras %}
|
||||
|
||||
{% block menubar %}
|
||||
{% include 'company/navbar.html' with tab='manufacturer_parts' %}
|
||||
{% endblock %}
|
||||
|
||||
{% block heading %}
|
||||
{% trans "Manufacturer Parts" %}
|
||||
{% endblock %}
|
||||
|
||||
|
||||
{% block details %}
|
||||
|
||||
{% if roles.purchase_order.change %}
|
||||
<div id='button-toolbar'>
|
||||
<div class='button-toolbar container-fluid'>
|
||||
<div class='btn-group role='group'>
|
||||
{% if roles.purchase_order.add %}
|
||||
<button class="btn btn-success" id='manufacturer-part-create' title='{% trans "Create new manufacturer part" %}'>
|
||||
<span class='fas fa-plus-circle'></span> {% trans "New Manufacturer Part" %}
|
||||
</button>
|
||||
{% endif %}
|
||||
<div class='btn-group'>
|
||||
<div class="dropdown" style="float: right;">
|
||||
<button class="btn btn-primary dropdown-toggle" type="button" data-toggle="dropdown">{% trans "Options" %}
|
||||
<span class="caret"></span>
|
||||
</button>
|
||||
<ul class="dropdown-menu">
|
||||
{% if roles.purchase_order.add %}
|
||||
<li><a href='#' id='multi-part-order' title='{% trans "Order parts" %}'>{% trans "Order Parts" %}</a></li>
|
||||
{% endif %}
|
||||
{% if roles.purchase_order.delete %}
|
||||
<li><a href='#' id='multi-part-delete' title='{% trans "Delete parts" %}'>{% trans "Delete Parts" %}</a></li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class='filter-list' id='filter-list-supplier-part'>
|
||||
<!-- Empty div (will be filled out with available BOM filters) -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<table class='table table-striped table-condensed' id='part-table' data-toolbar='#button-toolbar'>
|
||||
</table>
|
||||
|
||||
{% endblock %}
|
||||
{% block js_ready %}
|
||||
{{ block.super }}
|
||||
|
||||
$("#manufacturer-part-create").click(function () {
|
||||
launchModalForm(
|
||||
"{% url 'manufacturer-part-create' %}",
|
||||
{
|
||||
data: {
|
||||
manufacturer: {{ company.id }},
|
||||
},
|
||||
reload: true,
|
||||
secondary: [
|
||||
{
|
||||
field: 'part',
|
||||
label: '{% trans "New Part" %}',
|
||||
title: '{% trans "Create new Part" %}',
|
||||
url: "{% url 'part-create' %}"
|
||||
},
|
||||
{
|
||||
field: 'manufacturer',
|
||||
label: '{% trans "New Manufacturer" %}',
|
||||
title: '{% trans "Create new Manufacturer" %}',
|
||||
url: "{% url 'manufacturer-create' %}",
|
||||
},
|
||||
]
|
||||
});
|
||||
});
|
||||
|
||||
loadManufacturerPartTable(
|
||||
"#part-table",
|
||||
"{% url 'api-manufacturer-part-list' %}",
|
||||
{
|
||||
params: {
|
||||
part_detail: true,
|
||||
manufacturer_detail: true,
|
||||
company: {{ company.id }},
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
$("#multi-part-delete").click(function() {
|
||||
var selections = $("#part-table").bootstrapTable("getSelections");
|
||||
|
||||
var parts = [];
|
||||
|
||||
selections.forEach(function(item) {
|
||||
parts.push(item.pk);
|
||||
});
|
||||
|
||||
var url = "{% url 'manufacturer-part-delete' %}"
|
||||
|
||||
launchModalForm(url, {
|
||||
data: {
|
||||
parts: parts,
|
||||
},
|
||||
reload: true,
|
||||
});
|
||||
});
|
||||
|
||||
$("#multi-part-order").click(function() {
|
||||
var selections = $("#part-table").bootstrapTable("getSelections");
|
||||
|
||||
var parts = [];
|
||||
|
||||
selections.forEach(function(item) {
|
||||
parts.push(item.part);
|
||||
});
|
||||
|
||||
launchModalForm("/order/purchase-order/order-parts/", {
|
||||
data: {
|
||||
parts: parts,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
{% endblock %}
|
||||
@@ -1,9 +1,10 @@
|
||||
{% extends "company/company_base.html" %}
|
||||
{% load static %}
|
||||
{% load i18n %}
|
||||
{% load inventree_extras %}
|
||||
|
||||
{% block menubar %}
|
||||
{% include 'company/navbar.html' with tab='parts' %}
|
||||
{% include 'company/navbar.html' with tab='supplier_parts' %}
|
||||
{% endblock %}
|
||||
|
||||
{% block heading %}
|
||||
@@ -15,11 +16,11 @@
|
||||
{% if roles.purchase_order.change %}
|
||||
<div id='button-toolbar'>
|
||||
<div class='button-toolbar container-fluid'>
|
||||
<div class='btn-group role='group'>
|
||||
<div class='btn-group' role='group'>
|
||||
{% if roles.purchase_order.add %}
|
||||
<button class="btn btn-success" id='part-create' title='{% trans "Create new supplier part" %}'>
|
||||
<span class='fas fa-plus-circle'></span> {% trans "New Supplier Part" %}
|
||||
</button>
|
||||
<button class="btn btn-success" id='supplier-part-create' title='{% trans "Create new supplier part" %}'>
|
||||
<span class='fas fa-plus-circle'></span> {% trans "New Supplier Part" %}
|
||||
</button>
|
||||
{% endif %}
|
||||
<div class='btn-group'>
|
||||
<div class="dropdown" style="float: right;">
|
||||
@@ -51,13 +52,12 @@
|
||||
{% block js_ready %}
|
||||
{{ block.super }}
|
||||
|
||||
$("#part-create").click(function () {
|
||||
$("#supplier-part-create").click(function () {
|
||||
launchModalForm(
|
||||
"{% url 'supplier-part-create' %}",
|
||||
{
|
||||
data: {
|
||||
{% if company.is_supplier %}supplier: {{ company.id }},{% endif %}
|
||||
{% if company.is_manufacturer %}manufacturer: {{ company.id }},{% endif %}
|
||||
supplier: {{ company.id }},
|
||||
},
|
||||
reload: true,
|
||||
secondary: [
|
||||
@@ -73,12 +73,6 @@
|
||||
title: "{% trans 'Create new Supplier' %}",
|
||||
url: "{% url 'supplier-create' %}",
|
||||
},
|
||||
{
|
||||
field: 'manufacturer',
|
||||
label: '{% trans "New Manufacturer" %}',
|
||||
title: '{% trans "Create new Manufacturer" %}',
|
||||
url: "{% url 'manufacturer-create' %}",
|
||||
},
|
||||
]
|
||||
});
|
||||
});
|
||||
@@ -105,7 +99,9 @@
|
||||
parts.push(item.pk);
|
||||
});
|
||||
|
||||
launchModalForm("{% url 'supplier-part-delete' %}", {
|
||||
var url = "{% url 'supplier-part-delete' %}"
|
||||
|
||||
launchModalForm(url, {
|
||||
data: {
|
||||
parts: parts,
|
||||
},
|
||||
133
InvenTree/company/templates/company/manufacturer_part_base.html
Normal file
133
InvenTree/company/templates/company/manufacturer_part_base.html
Normal file
@@ -0,0 +1,133 @@
|
||||
{% extends "two_column.html" %}
|
||||
{% load static %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block page_title %}
|
||||
InvenTree | {% trans "Manufacturer Part" %}
|
||||
{% endblock %}
|
||||
|
||||
{% block thumbnail %}
|
||||
<img class='part-thumb'
|
||||
{% if part.part.image %}
|
||||
src='{{ part.part.image.url }}'
|
||||
{% else %}
|
||||
src="{% static 'img/blank_image.png' %}"
|
||||
{% endif %}/>
|
||||
{% endblock %}
|
||||
|
||||
{% block page_data %}
|
||||
<h3>{% trans "Manufacturer Part" %}</h3>
|
||||
<hr>
|
||||
<h4>
|
||||
{{ part.part.full_name }}
|
||||
{% if user.is_staff and perms.company.change_company %}
|
||||
<a href="{% url 'admin:company_supplierpart_change' part.pk %}">
|
||||
<span title='{% trans "Admin view" %}' class='fas fa-user-shield'></span>
|
||||
</a>
|
||||
{% endif %}
|
||||
</h4>
|
||||
<p>{{ part.manufacturer.name }} - {{ part.MPN }}</p>
|
||||
|
||||
{% if roles.purchase_order.change %}
|
||||
<div class='btn-row'>
|
||||
<div class='btn-group action-buttons' role='group'>
|
||||
{% comment "for later" %}
|
||||
{% if roles.purchase_order.add %}
|
||||
<button type='button' class='btn btn-default btn-glyph' id='order-part' title='{% trans "Order part" %}'>
|
||||
<span class='fas fa-shopping-cart'></span>
|
||||
</button>
|
||||
{% endif %}
|
||||
{% endcomment %}
|
||||
<button type='button' class='btn btn-default btn-glyph' id='edit-part' title='{% trans "Edit manufacturer part" %}'>
|
||||
<span class='fas fa-edit icon-green'/>
|
||||
</button>
|
||||
{% if roles.purchase_order.delete %}
|
||||
<button type='button' class='btn btn-default btn-glyph' id='delete-part' title='{% trans "Delete manufacturer part" %}'>
|
||||
<span class='fas fa-trash-alt icon-red'/>
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block page_details %}
|
||||
|
||||
<h4>{% trans "Manufacturer Part Details" %}</h4>
|
||||
<table class="table table-striped table-condensed">
|
||||
<col width='25'>
|
||||
<tr>
|
||||
<td><span class='fas fa-shapes'></span></td>
|
||||
<td>{% trans "Internal Part" %}</td>
|
||||
<td>
|
||||
{% if part.part %}
|
||||
<a href="{% url 'part-manufacturers' part.part.id %}">{{ part.part.full_name }}</a>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% if part.description %}
|
||||
<tr>
|
||||
<td></td>
|
||||
<td>{% trans "Description" %}</td>
|
||||
<td>{{ part.description }}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% if part.link %}
|
||||
<tr>
|
||||
<td><span class='fas fa-link'></span></td>
|
||||
<td>{% trans "External Link" %}</td>
|
||||
<td><a href="{{ part.link }}">{{ part.link }}</a></td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
<tr>
|
||||
<td><span class='fas fa-industry'></span></td>
|
||||
<td>{% trans "Manufacturer" %}</td>
|
||||
<td><a href="{% url 'company-detail-manufacturer-parts' part.manufacturer.id %}">{{ part.manufacturer.name }}</a></td></tr>
|
||||
<tr>
|
||||
<td><span class='fas fa-hashtag'></span></td>
|
||||
<td>{% trans "MPN" %}</td>
|
||||
<td>{{ part.MPN }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
{% endblock %}
|
||||
|
||||
{% block js_ready %}
|
||||
{{ block.super }}
|
||||
|
||||
enableNavbar({
|
||||
label: 'manufacturer-part',
|
||||
toggleId: '#manufacturer-part-menu-toggle'
|
||||
})
|
||||
|
||||
$('#order-part, #order-part2').click(function() {
|
||||
launchModalForm(
|
||||
"{% url 'order-parts' %}",
|
||||
{
|
||||
data: {
|
||||
part: {{ part.part.id }},
|
||||
},
|
||||
reload: true,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
$('#edit-part').click(function () {
|
||||
launchModalForm(
|
||||
"{% url 'manufacturer-part-edit' part.id %}",
|
||||
{
|
||||
reload: true
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
$('#delete-part').click(function() {
|
||||
launchModalForm(
|
||||
"{% url 'manufacturer-part-delete' %}?part={{ part.id }}",
|
||||
{
|
||||
redirect: "{% url 'company-detail-manufacturer-parts' part.manufacturer.id %}"
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,17 @@
|
||||
{% extends "modal_form.html" %}
|
||||
|
||||
{% load i18n %}
|
||||
|
||||
{% block pre_form_content %}
|
||||
{{ block.super }}
|
||||
|
||||
{% if part %}
|
||||
<div class='alert alert-block alert-info'>
|
||||
{% include "hover_image.html" with image=part.image %}
|
||||
{{ part.full_name}}
|
||||
<br>
|
||||
<i>{{ part.description }}</i>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,47 @@
|
||||
{% extends "modal_delete_form.html" %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block pre_form_content %}
|
||||
<div class='alert alert-block alert-warning'>
|
||||
{% trans "Are you sure you want to delete the following Manufacturer Parts?" %}
|
||||
</div>
|
||||
{% for part in parts %}
|
||||
|
||||
{% endfor %}
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block form_data %}
|
||||
|
||||
{% for part in parts %}
|
||||
<table class='table table-striped table-condensed'>
|
||||
<tr>
|
||||
<input type='hidden' name='manufacturer-part-{{ part.id}}' value='manufacturer-part-{{ part.id }}'/>
|
||||
|
||||
<td>
|
||||
{% include "hover_image.html" with image=part.part.image %}
|
||||
{{ part.part.full_name }}
|
||||
</td>
|
||||
<td>
|
||||
{% include "hover_image.html" with image=part.manufacturer.image %}
|
||||
{{ part.manufacturer.name }}
|
||||
</td>
|
||||
<td>
|
||||
{{ part.MPN }}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
{% if part.supplier_parts.all|length > 0 %}
|
||||
<div class='alert alert-block alert-danger'>
|
||||
<p>There are {{ part.supplier_parts.all|length }} suppliers defined for this manufacturer part. If you delete it, the following supplier parts will also be deleted:
|
||||
</p>
|
||||
<ul class='list-group' style='margin-top:10px'>
|
||||
{% for spart in part.supplier_parts.all %}
|
||||
<li class='list-group-item'>{{ spart.supplier.name }} - {{ spart.SKU }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,38 @@
|
||||
{% extends "company/manufacturer_part_base.html" %}
|
||||
{% load static %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block menubar %}
|
||||
{% include "company/manufacturer_part_navbar.html" with tab='details' %}
|
||||
{% endblock %}
|
||||
|
||||
{% block heading %}
|
||||
{% trans "Manufacturer Part Details" %}
|
||||
{% endblock %}
|
||||
|
||||
|
||||
{% block details %}
|
||||
|
||||
<table class="table table-striped table-condensed">
|
||||
<tr>
|
||||
<td>{% trans "Internal Part" %}</td>
|
||||
<td>
|
||||
{% if part.part %}
|
||||
<a href="{% url 'part-manufacturers' part.part.id %}">{{ part.part.full_name }}</a>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr><td>{% trans "Manufacturer" %}</td><td><a href="{% url 'company-detail-manufacturer-parts' part.manufacturer.id %}">{{ part.manufacturer.name }}</a></td></tr>
|
||||
<tr><td>{% trans "MPN" %}</td><td>{{ part.MPN }}</tr></tr>
|
||||
{% if part.link %}
|
||||
<tr><td>{% trans "External Link" %}</td><td><a href="{{ part.link }}">{{ part.link }}</a></td></tr>
|
||||
{% endif %}
|
||||
</table>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block js_ready %}
|
||||
{{ block.super }}
|
||||
|
||||
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,34 @@
|
||||
{% load i18n %}
|
||||
|
||||
<ul class='list-group'>
|
||||
|
||||
<li class='list-group-item'>
|
||||
<a href='#' id='manufacturer-part-menu-toggle'>
|
||||
<span class='menu-tab-icon fas fa-expand-arrows-alt'></span>
|
||||
</a>
|
||||
</li>
|
||||
|
||||
<li class='list-group-item {% if tab == "suppliers" %}active{% endif %}' title='{% trans "Supplier Parts" %}'>
|
||||
<a href='{% url "manufacturer-part-suppliers" part.id %}'>
|
||||
<span class='fas fa-building'></span>
|
||||
{% trans "Suppliers" %}
|
||||
</a>
|
||||
</li>
|
||||
|
||||
{% comment "for later" %}
|
||||
<li class='list-group-item {% if tab == "stock" %}active{% endif %}' title='{% trans "Manufacturer Part Stock" %}'>
|
||||
<a href='{% url "manufacturer-part-stock" part.id %}'>
|
||||
<span class='fas fa-boxes'></span>
|
||||
{% trans "Stock" %}
|
||||
</a>
|
||||
</li>
|
||||
|
||||
<li class='list-group-item {% if tab == "orders" %}active{% endif %}' title='{% trans "Manufacturer Part Orders" %}'>
|
||||
<a href='{% url "manufacturer-part-orders" part.id %}'>
|
||||
<span class='fas fa-shopping-cart'></span>
|
||||
{% trans "Orders" %}
|
||||
</a>
|
||||
</li>
|
||||
{% endcomment %}
|
||||
|
||||
</ul>
|
||||
@@ -0,0 +1,89 @@
|
||||
{% extends "company/manufacturer_part_base.html" %}
|
||||
{% load static %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block menubar %}
|
||||
{% include "company/manufacturer_part_navbar.html" with tab='suppliers' %}
|
||||
{% endblock %}
|
||||
|
||||
{% block heading %}
|
||||
{% trans "Supplier Parts" %}
|
||||
{% endblock %}
|
||||
|
||||
{% block details %}
|
||||
<div id='button-toolbar'>
|
||||
<div class='btn-group'>
|
||||
<button class="btn btn-success" id='supplier-create'>
|
||||
<span class='fas fa-plus-circle'></span> {% trans "New Supplier Part" %}
|
||||
</button>
|
||||
<div id='opt-dropdown' class="btn-group">
|
||||
<button id='supplier-part-options' class="btn btn-primary dropdown-toggle" type="button" data-toggle="dropdown">{% trans "Options" %}<span class="caret"></span></button>
|
||||
<ul class="dropdown-menu">
|
||||
<li><a href='#' id='supplier-part-delete' title='{% trans "Delete supplier parts" %}'>{% trans "Delete" %}</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<table class="table table-striped table-condensed" id='supplier-table' data-toolbar='#button-toolbar'>
|
||||
</table>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block js_ready %}
|
||||
{{ block.super }}
|
||||
|
||||
$('#supplier-create').click(function () {
|
||||
launchModalForm(
|
||||
"{% url 'supplier-part-create' %}",
|
||||
{
|
||||
reload: true,
|
||||
data: {
|
||||
manufacturer_part: {{ part.id }}
|
||||
},
|
||||
secondary: [
|
||||
{
|
||||
field: 'supplier',
|
||||
label: '{% trans "New Supplier" %}',
|
||||
title: '{% trans "Create new supplier" %}',
|
||||
url: "{% url 'supplier-create' %}"
|
||||
},
|
||||
]
|
||||
});
|
||||
});
|
||||
|
||||
$("#supplier-part-delete").click(function() {
|
||||
|
||||
var selections = $("#supplier-table").bootstrapTable("getSelections");
|
||||
|
||||
var parts = [];
|
||||
|
||||
selections.forEach(function(item) {
|
||||
parts.push(item.pk);
|
||||
});
|
||||
|
||||
launchModalForm("{% url 'supplier-part-delete' %}", {
|
||||
data: {
|
||||
parts: parts,
|
||||
},
|
||||
reload: true,
|
||||
});
|
||||
});
|
||||
|
||||
loadSupplierPartTable(
|
||||
"#supplier-table",
|
||||
"{% url 'api-supplier-part-list' %}",
|
||||
{
|
||||
params: {
|
||||
part: {{ part.part.id }},
|
||||
manufacturer_part: {{ part.id }},
|
||||
part_detail: false,
|
||||
supplier_detail: true,
|
||||
manufacturer_detail: false,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
linkButtonsToSelection($("#supplier-table"), ['#supplier-part-options'])
|
||||
|
||||
{% endblock %}
|
||||
@@ -16,14 +16,25 @@
|
||||
</a>
|
||||
</li>
|
||||
|
||||
{% if company.is_supplier or company.is_manufacturer %}
|
||||
<li class='list-group-item {% if tab == "parts" %}active{% endif %}' title='{% trans "Supplied Parts" %}'>
|
||||
<a href='{% url "company-detail-parts" company.id %}'>
|
||||
<span class='fas fa-shapes'></span>
|
||||
{% trans "Parts" %}
|
||||
{% if company.is_manufacturer %}
|
||||
<li class='list-group-item {% if tab == "manufacturer_parts" %}active{% endif %}' title='{% trans "Manufactured Parts" %}'>
|
||||
<a href='{% url "company-detail-manufacturer-parts" company.id %}'>
|
||||
<span class='fas fa-industry'></span>
|
||||
{% trans "Manufactured Parts" %}
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
|
||||
{% if company.is_supplier or company.is_manufacturer %}
|
||||
<li class='list-group-item {% if tab == "supplier_parts" %}active{% endif %}' title='{% trans "Supplied Parts" %}'>
|
||||
<a href='{% url "company-detail-supplier-parts" company.id %}'>
|
||||
<span class='fas fa-building'></span>
|
||||
{% trans "Supplied Parts" %}
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
|
||||
{% if company.is_manufacturer or company.is_supplier %}
|
||||
<li class='list-group-item {% if tab == "stock" %}active{% endif %}' title='{% trans "Stock Items" %}'>
|
||||
<a href='{% url "company-detail-stock" company.id %}'>
|
||||
<span class='fas fa-boxes'></span>
|
||||
|
||||
@@ -9,6 +9,9 @@
|
||||
|
||||
{% block heading %}
|
||||
{% trans "Company Notes" %}
|
||||
{% if not editing %}
|
||||
<button title='{% trans "Edit notes" %}' class='btn btn-default' id='edit-notes'><span class='fas fa-edit'></span></button>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block details %}
|
||||
@@ -18,7 +21,7 @@
|
||||
|
||||
{{ form }}
|
||||
<hr>
|
||||
<input type="submit" value='{% trans "Save" %}'/>
|
||||
<button type="submit" class='btn btn-default'>{% trans "Save" %}</button>
|
||||
|
||||
</form>
|
||||
|
||||
@@ -26,7 +29,6 @@
|
||||
|
||||
{% else %}
|
||||
|
||||
<button title='{% trans "Edit notes" %}' class='btn btn-default action-button' id='edit-notes'><span class='fas fa-edit'></span></button>
|
||||
{{ company.notes | markdownify }}
|
||||
{% endif %}
|
||||
|
||||
|
||||
@@ -81,23 +81,24 @@ src="{% static 'img/blank_image.png' %}"
|
||||
<tr>
|
||||
<td><span class='fas fa-building'></span></td>
|
||||
<td>{% trans "Supplier" %}</td>
|
||||
<td><a href="{% url 'company-detail-parts' part.supplier.id %}">{{ part.supplier.name }}</a></td></tr>
|
||||
<td><a href="{% url 'company-detail-supplier-parts' part.supplier.id %}">{{ part.supplier.name }}</a></td></tr>
|
||||
<tr>
|
||||
<td><span class='fas fa-hashtag'></span></td>
|
||||
<td>{% trans "SKU" %}</td>
|
||||
<td>{{ part.SKU }}</tr>
|
||||
</tr>
|
||||
{% if part.manufacturer %}
|
||||
{% if part.manufacturer_part.manufacturer %}
|
||||
<tr>
|
||||
<td><span class='fas fa-industry'></span></td>
|
||||
<td>{% trans "Manufacturer" %}</td>
|
||||
<td><a href="{% url 'company-detail-parts' part.manufacturer.id %}">{{ part.manufacturer.name }}</a></td></tr>
|
||||
<td><a href="{% url 'company-detail-manufacturer-parts' part.manufacturer_part.manufacturer.id %}">{{ part.manufacturer_part.manufacturer.name }}</a></td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% if part.MPN %}
|
||||
{% if part.manufacturer_part.MPN %}
|
||||
<tr>
|
||||
<td><span class='fas fa-hashtag'></span></td>
|
||||
<td>{% trans "MPN" %}</td>
|
||||
<td>{{ part.MPN }}</td>
|
||||
<td><a href="{% url 'manufacturer-part-detail' part.manufacturer_part.id %}">{{ part.manufacturer_part.MPN }}</a></td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% if part.packaging %}
|
||||
@@ -150,7 +151,7 @@ $('#delete-part').click(function() {
|
||||
launchModalForm(
|
||||
"{% url 'supplier-part-delete' %}?part={{ part.id }}",
|
||||
{
|
||||
redirect: "{% url 'company-detail-parts' part.supplier.id %}"
|
||||
redirect: "{% url 'company-detail-supplier-parts' part.supplier.id %}"
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
@@ -13,13 +13,16 @@
|
||||
<tr>
|
||||
<input type='hidden' name='supplier-part-{{ part.id}}' value='supplier-part-{{ part.id }}'/>
|
||||
|
||||
<td>
|
||||
{% include "hover_image.html" with image=part.part.image %}
|
||||
{{ part.part.full_name }}
|
||||
</td>
|
||||
<td>
|
||||
{% include "hover_image.html" with image=part.supplier.image %}
|
||||
{{ part.supplier.name }}
|
||||
</td>
|
||||
<td>
|
||||
{% include "hover_image.html" with image=part.part.image %}
|
||||
{{ part.part.full_name }}
|
||||
{{ part.SKU }}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
@@ -3,7 +3,7 @@
|
||||
{% load i18n %}
|
||||
|
||||
{% block menubar %}
|
||||
{% include "company/part_navbar.html" with tab='details' %}
|
||||
{% include "company/supplier_part_navbar.html" with tab='details' %}
|
||||
{% endblock %}
|
||||
|
||||
{% block heading %}
|
||||
@@ -22,7 +22,7 @@
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr><td>{% trans "Supplier" %}</td><td><a href="{% url 'company-detail-parts' part.supplier.id %}">{{ part.supplier.name }}</a></td></tr>
|
||||
<tr><td>{% trans "Supplier" %}</td><td><a href="{% url 'company-detail-supplier-parts' part.supplier.id %}">{{ part.supplier.name }}</a></td></tr>
|
||||
<tr><td>{% trans "SKU" %}</td><td>{{ part.SKU }}</tr></tr>
|
||||
{% if part.link %}
|
||||
<tr><td>{% trans "External Link" %}</td><td><a href="{{ part.link }}">{{ part.link }}</a></td></tr>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
{% load i18n %}
|
||||
{% load inventree_extras %}
|
||||
|
||||
<ul class='list-group'>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
{% load i18n %}
|
||||
|
||||
{% block menubar %}
|
||||
{% include "company/part_navbar.html" with tab='orders' %}
|
||||
{% include "company/supplier_part_navbar.html" with tab='orders' %}
|
||||
{% endblock %}
|
||||
|
||||
{% block heading %}
|
||||
@@ -14,7 +14,7 @@
|
||||
{% if roles.purchase_order.add %}
|
||||
<div id='button-bar'>
|
||||
<div class='btn-group'>
|
||||
<button class='btn btn-primary' type='button' id='order-part2' title='Order part'>
|
||||
<button class='btn btn-primary' type='button' id='order-part2' title='{% trans "Order part" %}'>
|
||||
<span class='fas fa-shopping-cart'></span> {% trans "Order Part" %}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
{% load inventree_extras %}
|
||||
|
||||
{% block menubar %}
|
||||
{% include "company/part_navbar.html" with tab='pricing' %}
|
||||
{% include "company/supplier_part_navbar.html" with tab='pricing' %}
|
||||
{% endblock %}
|
||||
|
||||
{% block heading %}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
{% load i18n %}
|
||||
|
||||
{% block menubar %}
|
||||
{% include "company/part_navbar.html" with tab='stock' %}
|
||||
{% include "company/supplier_part_navbar.html" with tab='stock' %}
|
||||
{% endblock %}
|
||||
|
||||
{% block heading %}
|
||||
@@ -22,7 +22,7 @@
|
||||
params: {
|
||||
supplier_part: {{ part.id }},
|
||||
location_detail: true,
|
||||
part_detail: true,
|
||||
part_detail: false,
|
||||
},
|
||||
groupByField: 'location',
|
||||
buttons: ['#stock-options'],
|
||||
|
||||
@@ -27,7 +27,7 @@ class CompanyTest(InvenTreeAPITestCase):
|
||||
def test_company_list(self):
|
||||
url = reverse('api-company-list')
|
||||
|
||||
# There should be two companies
|
||||
# There should be three companies
|
||||
response = self.get(url)
|
||||
self.assertEqual(len(response.data), 3)
|
||||
|
||||
@@ -62,3 +62,90 @@ class CompanyTest(InvenTreeAPITestCase):
|
||||
data = {'search': 'cup'}
|
||||
response = self.get(url, data)
|
||||
self.assertEqual(len(response.data), 2)
|
||||
|
||||
|
||||
class ManufacturerTest(InvenTreeAPITestCase):
|
||||
"""
|
||||
Series of tests for the Manufacturer DRF API
|
||||
"""
|
||||
|
||||
fixtures = [
|
||||
'category',
|
||||
'part',
|
||||
'location',
|
||||
'company',
|
||||
'manufacturer_part',
|
||||
]
|
||||
|
||||
roles = [
|
||||
'part.add',
|
||||
'part.change',
|
||||
]
|
||||
|
||||
def test_manufacturer_part_list(self):
|
||||
url = reverse('api-manufacturer-part-list')
|
||||
|
||||
# There should be three manufacturer parts
|
||||
response = self.get(url)
|
||||
self.assertEqual(len(response.data), 3)
|
||||
|
||||
# Create manufacturer part
|
||||
data = {
|
||||
'part': 1,
|
||||
'manufacturer': 7,
|
||||
'MPN': 'MPN_TEST',
|
||||
}
|
||||
response = self.client.post(url, data, format='json')
|
||||
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
|
||||
self.assertEqual(response.data['MPN'], 'MPN_TEST')
|
||||
|
||||
# Filter by manufacturer
|
||||
data = {'company': 7}
|
||||
response = self.get(url, data)
|
||||
self.assertEqual(len(response.data), 3)
|
||||
|
||||
# Filter by part
|
||||
data = {'part': 5}
|
||||
response = self.get(url, data)
|
||||
self.assertEqual(len(response.data), 2)
|
||||
|
||||
def test_manufacturer_part_detail(self):
|
||||
url = reverse('api-manufacturer-part-detail', kwargs={'pk': 1})
|
||||
|
||||
response = self.get(url)
|
||||
self.assertEqual(response.data['MPN'], 'MPN123')
|
||||
|
||||
# Change the MPN
|
||||
data = {
|
||||
'MPN': 'MPN-TEST-123',
|
||||
}
|
||||
response = self.client.patch(url, data, format='json')
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(response.data['MPN'], 'MPN-TEST-123')
|
||||
|
||||
def test_manufacturer_part_search(self):
|
||||
# Test search functionality in manufacturer list
|
||||
url = reverse('api-manufacturer-part-list')
|
||||
data = {'search': 'MPN'}
|
||||
response = self.get(url, data)
|
||||
self.assertEqual(len(response.data), 3)
|
||||
|
||||
def test_supplier_part_create(self):
|
||||
url = reverse('api-supplier-part-list')
|
||||
|
||||
# Create supplier part
|
||||
data = {
|
||||
'part': 1,
|
||||
'supplier': 1,
|
||||
'SKU': 'SKU_TEST',
|
||||
'manufacturer': 7,
|
||||
'MPN': 'PART_NUMBER',
|
||||
}
|
||||
response = self.client.post(url, data, format='json')
|
||||
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
|
||||
|
||||
# Check manufacturer part
|
||||
manufacturer_part_id = int(response.data['manufacturer_part']['pk'])
|
||||
url = reverse('api-manufacturer-part-detail', kwargs={'pk': manufacturer_part_id})
|
||||
response = self.get(url)
|
||||
self.assertEqual(response.data['MPN'], 'PART_NUMBER')
|
||||
|
||||
@@ -79,7 +79,7 @@ class TestManufacturerField(MigratorTestCase):
|
||||
part=part,
|
||||
supplier=supplier,
|
||||
SKU='SCREW.002',
|
||||
manufacturer_name='Zero Corp'
|
||||
manufacturer_name='Zero Corp',
|
||||
)
|
||||
|
||||
self.assertEqual(Company.objects.count(), 1)
|
||||
@@ -107,6 +107,136 @@ class TestManufacturerField(MigratorTestCase):
|
||||
self.assertEqual(part.manufacturer.name, 'ACME')
|
||||
|
||||
|
||||
class TestManufacturerPart(MigratorTestCase):
|
||||
"""
|
||||
Tests for migration 0034-0037 which added and transitioned to the ManufacturerPart model
|
||||
"""
|
||||
|
||||
migrate_from = ('company', '0033_auto_20210410_1528')
|
||||
migrate_to = ('company', '0037_supplierpart_update_3')
|
||||
|
||||
def prepare(self):
|
||||
"""
|
||||
Prepare the database by adding some test data 'before' the change:
|
||||
|
||||
- Part object
|
||||
- Company object (supplier)
|
||||
- SupplierPart object
|
||||
"""
|
||||
|
||||
Part = self.old_state.apps.get_model('part', 'part')
|
||||
Company = self.old_state.apps.get_model('company', 'company')
|
||||
SupplierPart = self.old_state.apps.get_model('company', 'supplierpart')
|
||||
|
||||
# Create an initial part
|
||||
part = Part.objects.create(
|
||||
name='CAP CER 0.1UF 10V X5R 0402',
|
||||
description='CAP CER 0.1UF 10V X5R 0402',
|
||||
purchaseable=True,
|
||||
level=0,
|
||||
tree_id=0,
|
||||
lft=0,
|
||||
rght=0,
|
||||
)
|
||||
|
||||
# Create a manufacturer
|
||||
manufacturer = Company.objects.create(
|
||||
name='Murata',
|
||||
description='Makes capacitors',
|
||||
is_manufacturer=True,
|
||||
is_supplier=False,
|
||||
is_customer=False,
|
||||
)
|
||||
|
||||
# Create suppliers
|
||||
supplier_1 = Company.objects.create(
|
||||
name='Digi-Key',
|
||||
description='A supplier of components',
|
||||
is_manufacturer=False,
|
||||
is_supplier=True,
|
||||
is_customer=False,
|
||||
)
|
||||
|
||||
supplier_2 = Company.objects.create(
|
||||
name='Mouser',
|
||||
description='We sell components',
|
||||
is_manufacturer=False,
|
||||
is_supplier=True,
|
||||
is_customer=False,
|
||||
)
|
||||
|
||||
# Add some SupplierPart objects
|
||||
SupplierPart.objects.create(
|
||||
part=part,
|
||||
supplier=supplier_1,
|
||||
SKU='DK-MUR-CAP-123456-ND',
|
||||
manufacturer=manufacturer,
|
||||
MPN='MUR-CAP-123456',
|
||||
)
|
||||
|
||||
SupplierPart.objects.create(
|
||||
part=part,
|
||||
supplier=supplier_1,
|
||||
SKU='DK-MUR-CAP-987654-ND',
|
||||
manufacturer=manufacturer,
|
||||
MPN='MUR-CAP-987654',
|
||||
)
|
||||
|
||||
SupplierPart.objects.create(
|
||||
part=part,
|
||||
supplier=supplier_2,
|
||||
SKU='CAP-CER-01UF',
|
||||
manufacturer=manufacturer,
|
||||
MPN='MUR-CAP-123456',
|
||||
)
|
||||
|
||||
# No MPN
|
||||
SupplierPart.objects.create(
|
||||
part=part,
|
||||
supplier=supplier_2,
|
||||
SKU='CAP-CER-01UF-1',
|
||||
manufacturer=manufacturer,
|
||||
)
|
||||
|
||||
# No Manufacturer
|
||||
SupplierPart.objects.create(
|
||||
part=part,
|
||||
supplier=supplier_2,
|
||||
SKU='CAP-CER-01UF-2',
|
||||
MPN='MUR-CAP-123456',
|
||||
)
|
||||
|
||||
# No Manufacturer data
|
||||
SupplierPart.objects.create(
|
||||
part=part,
|
||||
supplier=supplier_2,
|
||||
SKU='CAP-CER-01UF-3',
|
||||
)
|
||||
|
||||
def test_manufacturer_part_objects(self):
|
||||
"""
|
||||
Test that the new companies have been created successfully
|
||||
"""
|
||||
|
||||
# Check on the SupplierPart objects
|
||||
SupplierPart = self.new_state.apps.get_model('company', 'supplierpart')
|
||||
|
||||
supplier_parts = SupplierPart.objects.all()
|
||||
self.assertEqual(supplier_parts.count(), 6)
|
||||
|
||||
supplier_parts = SupplierPart.objects.filter(supplier__name='Mouser')
|
||||
self.assertEqual(supplier_parts.count(), 4)
|
||||
|
||||
# Check on the ManufacturerPart objects
|
||||
ManufacturerPart = self.new_state.apps.get_model('company', 'manufacturerpart')
|
||||
|
||||
manufacturer_parts = ManufacturerPart.objects.all()
|
||||
self.assertEqual(manufacturer_parts.count(), 4)
|
||||
|
||||
manufacturer_part = manufacturer_parts.first()
|
||||
self.assertEqual(manufacturer_part.MPN, 'MUR-CAP-123456')
|
||||
|
||||
|
||||
class TestCurrencyMigration(MigratorTestCase):
|
||||
"""
|
||||
Tests for upgrade from basic currency support to django-money
|
||||
|
||||
@@ -10,6 +10,7 @@ from django.urls import reverse
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.auth.models import Group
|
||||
|
||||
from .models import ManufacturerPart
|
||||
from .models import SupplierPart
|
||||
|
||||
|
||||
@@ -20,6 +21,7 @@ class CompanyViewTestBase(TestCase):
|
||||
'part',
|
||||
'location',
|
||||
'company',
|
||||
'manufacturer_part',
|
||||
'supplier_part',
|
||||
]
|
||||
|
||||
@@ -200,3 +202,105 @@ class CompanyViewTest(CompanyViewTestBase):
|
||||
|
||||
response = self.client.get(reverse('customer-create'), HTTP_X_REQUESTED_WITH='XMLHttpRequest')
|
||||
self.assertContains(response, 'Create new Customer')
|
||||
|
||||
|
||||
class ManufacturerPartViewTests(CompanyViewTestBase):
|
||||
"""
|
||||
Tests for the ManufacturerPart views.
|
||||
"""
|
||||
|
||||
def test_manufacturer_part_create(self):
|
||||
"""
|
||||
Test the ManufacturerPartCreate view.
|
||||
"""
|
||||
|
||||
url = reverse('manufacturer-part-create')
|
||||
|
||||
# First check that we can GET the form
|
||||
response = self.client.get(url, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
# How many manufaturer parts are already in the database?
|
||||
n = ManufacturerPart.objects.all().count()
|
||||
|
||||
data = {
|
||||
'part': 1,
|
||||
'manufacturer': 6,
|
||||
}
|
||||
|
||||
# MPN is required! (form should fail)
|
||||
(response, errors) = self.post(url, data, valid=False)
|
||||
|
||||
self.assertIsNotNone(errors.get('MPN', None))
|
||||
|
||||
data['MPN'] = 'TEST-ME-123'
|
||||
|
||||
(response, errors) = self.post(url, data, valid=True)
|
||||
|
||||
# Check that the ManufacturerPart was created!
|
||||
self.assertEqual(n + 1, ManufacturerPart.objects.all().count())
|
||||
|
||||
# Try to create duplicate ManufacturerPart
|
||||
(response, errors) = self.post(url, data, valid=False)
|
||||
|
||||
self.assertIsNotNone(errors.get('__all__', None))
|
||||
|
||||
# Check that the ManufacturerPart count stayed the same
|
||||
self.assertEqual(n + 1, ManufacturerPart.objects.all().count())
|
||||
|
||||
def test_supplier_part_create(self):
|
||||
"""
|
||||
Test that the SupplierPartCreate view creates Manufacturer Part.
|
||||
"""
|
||||
|
||||
url = reverse('supplier-part-create')
|
||||
|
||||
# First check that we can GET the form
|
||||
response = self.client.get(url, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
# How many manufacturer parts are already in the database?
|
||||
n = ManufacturerPart.objects.all().count()
|
||||
|
||||
data = {
|
||||
'part': 1,
|
||||
'supplier': 1,
|
||||
'SKU': 'SKU_TEST',
|
||||
'manufacturer': 6,
|
||||
'MPN': 'MPN_TEST',
|
||||
}
|
||||
|
||||
(response, errors) = self.post(url, data, valid=True)
|
||||
|
||||
# Check that the ManufacturerPart was created!
|
||||
self.assertEqual(n + 1, ManufacturerPart.objects.all().count())
|
||||
|
||||
def test_manufacturer_part_delete(self):
|
||||
"""
|
||||
Test the ManufacturerPartDelete view
|
||||
"""
|
||||
|
||||
url = reverse('manufacturer-part-delete')
|
||||
|
||||
# Get form using 'part' argument
|
||||
response = self.client.get(url, {'part': '2'}, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
# POST to delete manufacturer part
|
||||
n = ManufacturerPart.objects.count()
|
||||
m = SupplierPart.objects.count()
|
||||
|
||||
response = self.client.post(
|
||||
url,
|
||||
{
|
||||
'manufacturer-part-2': 'manufacturer-part-2',
|
||||
'confirm_delete': True
|
||||
},
|
||||
HTTP_X_REQUESTED_WITH='XMLHttpRequest')
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
# Check that the ManufacturerPart was deleted
|
||||
self.assertEqual(n - 1, ManufacturerPart.objects.count())
|
||||
# Check that the SupplierParts were deleted
|
||||
self.assertEqual(m - 2, SupplierPart.objects.count())
|
||||
|
||||
@@ -6,7 +6,7 @@ from django.core.exceptions import ValidationError
|
||||
|
||||
import os
|
||||
|
||||
from .models import Company, Contact, SupplierPart
|
||||
from .models import Company, Contact, ManufacturerPart, SupplierPart
|
||||
from .models import rename_company_image
|
||||
from part.models import Part
|
||||
|
||||
@@ -22,6 +22,7 @@ class CompanySimpleTest(TestCase):
|
||||
'part',
|
||||
'location',
|
||||
'bom',
|
||||
'manufacturer_part',
|
||||
'supplier_part',
|
||||
'price_breaks',
|
||||
]
|
||||
@@ -74,10 +75,10 @@ class CompanySimpleTest(TestCase):
|
||||
self.assertEqual(acme.supplied_part_count, 4)
|
||||
|
||||
self.assertTrue(appel.has_parts)
|
||||
self.assertEqual(appel.supplied_part_count, 2)
|
||||
self.assertEqual(appel.supplied_part_count, 3)
|
||||
|
||||
self.assertTrue(zerg.has_parts)
|
||||
self.assertEqual(zerg.supplied_part_count, 1)
|
||||
self.assertEqual(zerg.supplied_part_count, 2)
|
||||
|
||||
def test_price_breaks(self):
|
||||
|
||||
@@ -166,3 +167,53 @@ class ContactSimpleTest(TestCase):
|
||||
# Remove the parent company
|
||||
Company.objects.get(pk=self.c.pk).delete()
|
||||
self.assertEqual(Contact.objects.count(), 0)
|
||||
|
||||
|
||||
class ManufacturerPartSimpleTest(TestCase):
|
||||
|
||||
fixtures = [
|
||||
'category',
|
||||
'company',
|
||||
'location',
|
||||
'part',
|
||||
'manufacturer_part',
|
||||
]
|
||||
|
||||
def setUp(self):
|
||||
# Create a manufacturer part
|
||||
self.part = Part.objects.get(pk=1)
|
||||
manufacturer = Company.objects.get(pk=1)
|
||||
|
||||
self.mp = ManufacturerPart.create(
|
||||
part=self.part,
|
||||
manufacturer=manufacturer,
|
||||
mpn='PART_NUMBER',
|
||||
description='THIS IS A MANUFACTURER PART',
|
||||
)
|
||||
|
||||
# Create a supplier part
|
||||
supplier = Company.objects.get(pk=5)
|
||||
supplier_part = SupplierPart.objects.create(
|
||||
part=self.part,
|
||||
supplier=supplier,
|
||||
SKU='SKU_TEST',
|
||||
)
|
||||
|
||||
kwargs = {
|
||||
'manufacturer': manufacturer.id,
|
||||
'MPN': 'MPN_TEST',
|
||||
}
|
||||
supplier_part.save(**kwargs)
|
||||
|
||||
def test_exists(self):
|
||||
self.assertEqual(ManufacturerPart.objects.count(), 5)
|
||||
|
||||
# Check that manufacturer part was created from supplier part creation
|
||||
manufacturer_parts = ManufacturerPart.objects.filter(manufacturer=1)
|
||||
self.assertEqual(manufacturer_parts.count(), 2)
|
||||
|
||||
def test_delete(self):
|
||||
# Remove a part
|
||||
Part.objects.get(pk=self.part.id).delete()
|
||||
# Check that ManufacturerPart was deleted
|
||||
self.assertEqual(ManufacturerPart.objects.count(), 3)
|
||||
|
||||
@@ -13,7 +13,8 @@ company_detail_urls = [
|
||||
|
||||
# url(r'orders/?', views.CompanyDetail.as_view(template_name='company/orders.html'), name='company-detail-orders'),
|
||||
|
||||
url(r'^parts/', views.CompanyDetail.as_view(template_name='company/detail_part.html'), name='company-detail-parts'),
|
||||
url(r'^supplier-parts/', views.CompanyDetail.as_view(template_name='company/detail_supplier_part.html'), name='company-detail-supplier-parts'),
|
||||
url(r'^manufacturer-parts/', views.CompanyDetail.as_view(template_name='company/detail_manufacturer_part.html'), name='company-detail-manufacturer-parts'),
|
||||
url(r'^stock/', views.CompanyDetail.as_view(template_name='company/detail_stock.html'), name='company-detail-stock'),
|
||||
url(r'^purchase-orders/', views.CompanyDetail.as_view(template_name='company/purchase_orders.html'), name='company-detail-purchase-orders'),
|
||||
url(r'^assigned-stock/', views.CompanyDetail.as_view(template_name='company/assigned_stock.html'), name='company-detail-assigned-stock'),
|
||||
@@ -21,6 +22,7 @@ company_detail_urls = [
|
||||
url(r'^notes/', views.CompanyNotes.as_view(), name='company-notes'),
|
||||
|
||||
url(r'^thumbnail/', views.CompanyImage.as_view(), name='company-image'),
|
||||
url(r'^thumb-download/', views.CompanyImageDownloadFromURL.as_view(), name='company-image-download'),
|
||||
|
||||
# Any other URL
|
||||
url(r'^.*$', views.CompanyDetail.as_view(), name='company-detail'),
|
||||
@@ -51,9 +53,26 @@ price_break_urls = [
|
||||
url(r'^(?P<pk>\d+)/delete/', views.PriceBreakDelete.as_view(), name='price-break-delete'),
|
||||
]
|
||||
|
||||
manufacturer_part_detail_urls = [
|
||||
url(r'^edit/?', views.ManufacturerPartEdit.as_view(), name='manufacturer-part-edit'),
|
||||
|
||||
url(r'^suppliers/', views.ManufacturerPartDetail.as_view(template_name='company/manufacturer_part_suppliers.html'), name='manufacturer-part-suppliers'),
|
||||
|
||||
url('^.*$', views.ManufacturerPartDetail.as_view(template_name='company/manufacturer_part_suppliers.html'), name='manufacturer-part-detail'),
|
||||
]
|
||||
|
||||
manufacturer_part_urls = [
|
||||
url(r'^new/?', views.ManufacturerPartCreate.as_view(), name='manufacturer-part-create'),
|
||||
|
||||
url(r'delete/', views.ManufacturerPartDelete.as_view(), name='manufacturer-part-delete'),
|
||||
|
||||
url(r'^(?P<pk>\d+)/', include(manufacturer_part_detail_urls)),
|
||||
]
|
||||
|
||||
supplier_part_detail_urls = [
|
||||
url(r'^edit/?', views.SupplierPartEdit.as_view(), name='supplier-part-edit'),
|
||||
|
||||
url(r'^manufacturers/', views.SupplierPartDetail.as_view(template_name='company/supplier_part_manufacturers.html'), name='supplier-part-manufacturers'),
|
||||
url(r'^pricing/', views.SupplierPartDetail.as_view(template_name='company/supplier_part_pricing.html'), name='supplier-part-pricing'),
|
||||
url(r'^orders/', views.SupplierPartDetail.as_view(template_name='company/supplier_part_orders.html'), name='supplier-part-orders'),
|
||||
url(r'^stock/', views.SupplierPartDetail.as_view(template_name='company/supplier_part_stock.html'), name='supplier-part-stock'),
|
||||
|
||||
@@ -6,19 +6,25 @@ Django views for interacting with Company app
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.utils.translation import ugettext as _
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from django.views.generic import DetailView, ListView, UpdateView
|
||||
|
||||
from django.urls import reverse
|
||||
from django.forms import HiddenInput
|
||||
from django.core.files.base import ContentFile
|
||||
|
||||
from moneyed import CURRENCIES
|
||||
|
||||
from PIL import Image
|
||||
import requests
|
||||
import io
|
||||
|
||||
from InvenTree.views import AjaxCreateView, AjaxUpdateView, AjaxDeleteView
|
||||
from InvenTree.helpers import str2bool
|
||||
from InvenTree.views import InvenTreeRoleMixin
|
||||
|
||||
from .models import Company
|
||||
from .models import ManufacturerPart
|
||||
from .models import SupplierPart
|
||||
from .models import SupplierPriceBreak
|
||||
|
||||
@@ -26,8 +32,10 @@ from part.models import Part
|
||||
|
||||
from .forms import EditCompanyForm
|
||||
from .forms import CompanyImageForm
|
||||
from .forms import EditManufacturerPartForm
|
||||
from .forms import EditSupplierPartForm
|
||||
from .forms import EditPriceBreakForm
|
||||
from .forms import CompanyImageDownloadForm
|
||||
|
||||
import common.models
|
||||
import common.settings
|
||||
@@ -150,6 +158,84 @@ class CompanyDetail(DetailView):
|
||||
return ctx
|
||||
|
||||
|
||||
class CompanyImageDownloadFromURL(AjaxUpdateView):
|
||||
"""
|
||||
View for downloading an image from a provided URL
|
||||
"""
|
||||
|
||||
model = Company
|
||||
ajax_template_name = 'image_download.html'
|
||||
form_class = CompanyImageDownloadForm
|
||||
ajax_form_title = _('Download Image')
|
||||
|
||||
def validate(self, company, form):
|
||||
"""
|
||||
Validate that the image data are correct
|
||||
"""
|
||||
# First ensure that the normal validation routines pass
|
||||
if not form.is_valid():
|
||||
return
|
||||
|
||||
# We can now extract a valid URL from the form data
|
||||
url = form.cleaned_data.get('url', None)
|
||||
|
||||
# Download the file
|
||||
response = requests.get(url, stream=True)
|
||||
|
||||
# Look at response header, reject if too large
|
||||
content_length = response.headers.get('Content-Length', '0')
|
||||
|
||||
try:
|
||||
content_length = int(content_length)
|
||||
except (ValueError):
|
||||
# If we cannot extract meaningful length, just assume it's "small enough"
|
||||
content_length = 0
|
||||
|
||||
# TODO: Factor this out into a configurable setting
|
||||
MAX_IMG_LENGTH = 10 * 1024 * 1024
|
||||
|
||||
if content_length > MAX_IMG_LENGTH:
|
||||
form.add_error('url', _('Image size exceeds maximum allowable size for download'))
|
||||
return
|
||||
|
||||
self.response = response
|
||||
|
||||
# Check for valid response code
|
||||
if not response.status_code == 200:
|
||||
form.add_error('url', f"{_('Invalid response')}: {response.status_code}")
|
||||
return
|
||||
|
||||
response.raw.decode_content = True
|
||||
|
||||
try:
|
||||
self.image = Image.open(response.raw).convert()
|
||||
self.image.verify()
|
||||
except:
|
||||
form.add_error('url', _("Supplied URL is not a valid image file"))
|
||||
return
|
||||
|
||||
def save(self, company, form, **kwargs):
|
||||
"""
|
||||
Save the downloaded image to the company
|
||||
"""
|
||||
fmt = self.image.format
|
||||
|
||||
if not fmt:
|
||||
fmt = 'PNG'
|
||||
|
||||
buffer = io.BytesIO()
|
||||
|
||||
self.image.save(buffer, format=fmt)
|
||||
|
||||
# Construct a simplified name for the image
|
||||
filename = f"company_{company.pk}_image.{fmt.lower()}"
|
||||
|
||||
company.image.save(
|
||||
filename,
|
||||
ContentFile(buffer.getvalue()),
|
||||
)
|
||||
|
||||
|
||||
class CompanyImage(AjaxUpdateView):
|
||||
""" View for uploading an image for the Company """
|
||||
model = Company
|
||||
@@ -247,6 +333,177 @@ class CompanyDelete(AjaxDeleteView):
|
||||
}
|
||||
|
||||
|
||||
class ManufacturerPartDetail(DetailView):
|
||||
""" Detail view for ManufacturerPart """
|
||||
model = ManufacturerPart
|
||||
template_name = 'company/manufacturer_part_detail.html'
|
||||
context_object_name = 'part'
|
||||
queryset = ManufacturerPart.objects.all()
|
||||
permission_required = 'purchase_order.view'
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
ctx = super().get_context_data(**kwargs)
|
||||
|
||||
return ctx
|
||||
|
||||
|
||||
class ManufacturerPartEdit(AjaxUpdateView):
|
||||
""" Update view for editing ManufacturerPart """
|
||||
|
||||
model = ManufacturerPart
|
||||
context_object_name = 'part'
|
||||
form_class = EditManufacturerPartForm
|
||||
ajax_template_name = 'modal_form.html'
|
||||
ajax_form_title = _('Edit Manufacturer Part')
|
||||
|
||||
|
||||
class ManufacturerPartCreate(AjaxCreateView):
|
||||
""" Create view for making new ManufacturerPart """
|
||||
|
||||
model = ManufacturerPart
|
||||
form_class = EditManufacturerPartForm
|
||||
ajax_template_name = 'company/manufacturer_part_create.html'
|
||||
ajax_form_title = _('Create New Manufacturer Part')
|
||||
context_object_name = 'part'
|
||||
|
||||
def get_context_data(self):
|
||||
"""
|
||||
Supply context data to the form
|
||||
"""
|
||||
|
||||
ctx = super().get_context_data()
|
||||
|
||||
# Add 'part' object
|
||||
form = self.get_form()
|
||||
|
||||
part = form['part'].value()
|
||||
|
||||
try:
|
||||
part = Part.objects.get(pk=part)
|
||||
except (ValueError, Part.DoesNotExist):
|
||||
part = None
|
||||
|
||||
ctx['part'] = part
|
||||
|
||||
return ctx
|
||||
|
||||
def get_form(self):
|
||||
""" Create Form instance to create a new ManufacturerPart object.
|
||||
Hide some fields if they are not appropriate in context
|
||||
"""
|
||||
form = super(AjaxCreateView, self).get_form()
|
||||
|
||||
if form.initial.get('part', None):
|
||||
# Hide the part field
|
||||
form.fields['part'].widget = HiddenInput()
|
||||
|
||||
return form
|
||||
|
||||
def get_initial(self):
|
||||
""" Provide initial data for new ManufacturerPart:
|
||||
|
||||
- If 'manufacturer_id' provided, pre-fill manufacturer field
|
||||
- If 'part_id' provided, pre-fill part field
|
||||
"""
|
||||
initials = super(ManufacturerPartCreate, self).get_initial().copy()
|
||||
|
||||
manufacturer_id = self.get_param('manufacturer')
|
||||
part_id = self.get_param('part')
|
||||
|
||||
if manufacturer_id:
|
||||
try:
|
||||
initials['manufacturer'] = Company.objects.get(pk=manufacturer_id)
|
||||
except (ValueError, Company.DoesNotExist):
|
||||
pass
|
||||
|
||||
if part_id:
|
||||
try:
|
||||
initials['part'] = Part.objects.get(pk=part_id)
|
||||
except (ValueError, Part.DoesNotExist):
|
||||
pass
|
||||
|
||||
return initials
|
||||
|
||||
|
||||
class ManufacturerPartDelete(AjaxDeleteView):
|
||||
""" Delete view for removing a ManufacturerPart.
|
||||
|
||||
ManufacturerParts can be deleted using a variety of 'selectors'.
|
||||
|
||||
- ?part=<pk> -> Delete a single ManufacturerPart object
|
||||
- ?parts=[] -> Delete a list of ManufacturerPart objects
|
||||
|
||||
"""
|
||||
|
||||
success_url = '/manufacturer/'
|
||||
ajax_template_name = 'company/manufacturer_part_delete.html'
|
||||
ajax_form_title = _('Delete Manufacturer Part')
|
||||
|
||||
role_required = 'purchase_order.delete'
|
||||
|
||||
parts = []
|
||||
|
||||
def get_context_data(self):
|
||||
ctx = {}
|
||||
|
||||
ctx['parts'] = self.parts
|
||||
|
||||
return ctx
|
||||
|
||||
def get_parts(self):
|
||||
""" Determine which ManufacturerPart object(s) the user wishes to delete.
|
||||
"""
|
||||
|
||||
self.parts = []
|
||||
|
||||
# User passes a single ManufacturerPart ID
|
||||
if 'part' in self.request.GET:
|
||||
try:
|
||||
self.parts.append(ManufacturerPart.objects.get(pk=self.request.GET.get('part')))
|
||||
except (ValueError, ManufacturerPart.DoesNotExist):
|
||||
pass
|
||||
|
||||
elif 'parts[]' in self.request.GET:
|
||||
|
||||
part_id_list = self.request.GET.getlist('parts[]')
|
||||
|
||||
self.parts = ManufacturerPart.objects.filter(id__in=part_id_list)
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
self.request = request
|
||||
self.get_parts()
|
||||
|
||||
return self.renderJsonResponse(request, form=self.get_form())
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
""" Handle the POST action for deleting ManufacturerPart object.
|
||||
"""
|
||||
|
||||
self.request = request
|
||||
self.parts = []
|
||||
|
||||
for item in self.request.POST:
|
||||
if item.startswith('manufacturer-part-'):
|
||||
pk = item.replace('manufacturer-part-', '')
|
||||
|
||||
try:
|
||||
self.parts.append(ManufacturerPart.objects.get(pk=pk))
|
||||
except (ValueError, ManufacturerPart.DoesNotExist):
|
||||
pass
|
||||
|
||||
confirm = str2bool(self.request.POST.get('confirm_delete', False))
|
||||
|
||||
data = {
|
||||
'form_valid': confirm,
|
||||
}
|
||||
|
||||
if confirm:
|
||||
for part in self.parts:
|
||||
part.delete()
|
||||
|
||||
return self.renderJsonResponse(self.request, data=data, form=self.get_form())
|
||||
|
||||
|
||||
class SupplierPartDetail(DetailView):
|
||||
""" Detail view for SupplierPart """
|
||||
model = SupplierPart
|
||||
@@ -270,11 +527,25 @@ class SupplierPartEdit(AjaxUpdateView):
|
||||
ajax_template_name = 'modal_form.html'
|
||||
ajax_form_title = _('Edit Supplier Part')
|
||||
|
||||
def save(self, supplier_part, form, **kwargs):
|
||||
""" Process ManufacturerPart data """
|
||||
|
||||
manufacturer = form.cleaned_data.get('manufacturer', None)
|
||||
MPN = form.cleaned_data.get('MPN', None)
|
||||
kwargs = {'manufacturer': manufacturer,
|
||||
'MPN': MPN,
|
||||
}
|
||||
supplier_part.save(**kwargs)
|
||||
|
||||
def get_form(self):
|
||||
form = super().get_form()
|
||||
|
||||
supplier_part = self.get_object()
|
||||
|
||||
# Hide Manufacturer fields
|
||||
form.fields['manufacturer'].widget = HiddenInput()
|
||||
form.fields['MPN'].widget = HiddenInput()
|
||||
|
||||
# It appears that hiding a MoneyField fails validation
|
||||
# Therefore the idea to set the value before hiding
|
||||
if form.is_valid():
|
||||
@@ -284,6 +555,19 @@ class SupplierPartEdit(AjaxUpdateView):
|
||||
|
||||
return form
|
||||
|
||||
def get_initial(self):
|
||||
""" Fetch data from ManufacturerPart """
|
||||
|
||||
initials = super(SupplierPartEdit, self).get_initial().copy()
|
||||
|
||||
supplier_part = self.get_object()
|
||||
|
||||
if supplier_part.manufacturer_part:
|
||||
initials['manufacturer'] = supplier_part.manufacturer_part.manufacturer.id
|
||||
initials['MPN'] = supplier_part.manufacturer_part.MPN
|
||||
|
||||
return initials
|
||||
|
||||
|
||||
class SupplierPartCreate(AjaxCreateView):
|
||||
""" Create view for making new SupplierPart """
|
||||
@@ -331,6 +615,14 @@ class SupplierPartCreate(AjaxCreateView):
|
||||
# Save the supplier part object
|
||||
supplier_part = super().save(form)
|
||||
|
||||
# Process manufacturer data
|
||||
manufacturer = form.cleaned_data.get('manufacturer', None)
|
||||
MPN = form.cleaned_data.get('MPN', None)
|
||||
kwargs = {'manufacturer': manufacturer,
|
||||
'MPN': MPN,
|
||||
}
|
||||
supplier_part.save(**kwargs)
|
||||
|
||||
single_pricing = form.cleaned_data.get('single_pricing', None)
|
||||
|
||||
if single_pricing:
|
||||
@@ -349,6 +641,12 @@ class SupplierPartCreate(AjaxCreateView):
|
||||
# Hide the part field
|
||||
form.fields['part'].widget = HiddenInput()
|
||||
|
||||
if form.initial.get('manufacturer', None):
|
||||
# Hide the manufacturer field
|
||||
form.fields['manufacturer'].widget = HiddenInput()
|
||||
# Hide the MPN field
|
||||
form.fields['MPN'].widget = HiddenInput()
|
||||
|
||||
return form
|
||||
|
||||
def get_initial(self):
|
||||
@@ -362,6 +660,7 @@ class SupplierPartCreate(AjaxCreateView):
|
||||
manufacturer_id = self.get_param('manufacturer')
|
||||
supplier_id = self.get_param('supplier')
|
||||
part_id = self.get_param('part')
|
||||
manufacturer_part_id = self.get_param('manufacturer_part')
|
||||
|
||||
supplier = None
|
||||
|
||||
@@ -377,6 +676,16 @@ class SupplierPartCreate(AjaxCreateView):
|
||||
initials['manufacturer'] = Company.objects.get(pk=manufacturer_id)
|
||||
except (ValueError, Company.DoesNotExist):
|
||||
pass
|
||||
|
||||
if manufacturer_part_id:
|
||||
try:
|
||||
# Get ManufacturerPart instance information
|
||||
manufacturer_part_obj = ManufacturerPart.objects.get(pk=manufacturer_part_id)
|
||||
initials['part'] = Part.objects.get(pk=manufacturer_part_obj.part.id)
|
||||
initials['manufacturer'] = manufacturer_part_obj.manufacturer.id
|
||||
initials['MPN'] = manufacturer_part_obj.MPN
|
||||
except (ValueError, ManufacturerPart.DoesNotExist, Part.DoesNotExist, Company.DoesNotExist):
|
||||
pass
|
||||
|
||||
if part_id:
|
||||
try:
|
||||
@@ -409,7 +718,7 @@ class SupplierPartDelete(AjaxDeleteView):
|
||||
"""
|
||||
|
||||
success_url = '/supplier/'
|
||||
ajax_template_name = 'company/partdelete.html'
|
||||
ajax_template_name = 'company/supplier_part_delete.html'
|
||||
ajax_form_title = _('Delete Supplier Part')
|
||||
|
||||
role_required = 'purchase_order.delete'
|
||||
|
||||
@@ -7,11 +7,9 @@
|
||||
# with the prefix INVENTREE_DB_
|
||||
# e.g INVENTREE_DB_NAME / INVENTREE_DB_USER / INVENTREE_DB_PASSWORD
|
||||
database:
|
||||
# Default configuration - sqlite filesystem database
|
||||
ENGINE: sqlite3
|
||||
NAME: '../inventree_default_db.sqlite3'
|
||||
|
||||
# For more complex database installations, further parameters are required
|
||||
# Uncomment (and edit) one of the database configurations below,
|
||||
# or specify database options using environment variables
|
||||
|
||||
# Refer to the django documentation for full list of options
|
||||
|
||||
# --- Available options: ---
|
||||
@@ -27,14 +25,22 @@ database:
|
||||
|
||||
# --- Example Configuration - sqlite3 ---
|
||||
# ENGINE: sqlite3
|
||||
# NAME: '/path/to/database.sqlite3'
|
||||
# NAME: '/home/inventree/database.sqlite3'
|
||||
|
||||
# --- Example Configuration - MySQL ---
|
||||
#ENGINE: django.db.backends.mysql
|
||||
#ENGINE: mysql
|
||||
#NAME: inventree
|
||||
#USER: inventree_username
|
||||
#USER: inventree
|
||||
#PASSWORD: inventree_password
|
||||
#HOST: '127.0.0.1'
|
||||
#HOST: 'localhost'
|
||||
#PORT: '3306'
|
||||
|
||||
# --- Example Configuration - Postgresql ---
|
||||
#ENGINE: postgresql
|
||||
#NAME: inventree
|
||||
#USER: inventree
|
||||
#PASSWORD: inventree_password
|
||||
#HOST: 'localhost'
|
||||
#PORT: '5432'
|
||||
|
||||
# Select default system language (default is 'en-us')
|
||||
@@ -43,6 +49,7 @@ language: en-us
|
||||
# System time-zone (default is UTC)
|
||||
# Reference: https://en.wikipedia.org/wiki/List_of_tz_database_time_zones
|
||||
# Select an option from the "TZ database name" column
|
||||
# Use the environment variable INVENTREE_TIMEZONE
|
||||
timezone: UTC
|
||||
|
||||
# List of currencies supported by default.
|
||||
@@ -56,7 +63,33 @@ currencies:
|
||||
- NZD
|
||||
- USD
|
||||
|
||||
# Email backend configuration
|
||||
# Ref: https://docs.djangoproject.com/en/dev/topics/email/
|
||||
# Available options:
|
||||
# host: Email server host address
|
||||
# port: Email port
|
||||
# username: Account username
|
||||
# password: Account password
|
||||
# prefix: Email subject prefix
|
||||
# tls: Enable TLS support
|
||||
# ssl: Enable SSL support
|
||||
|
||||
# Alternatively, these options can all be set using environment variables,
|
||||
# with the INVENTREE_EMAIL_ prefix:
|
||||
# e.g. INVENTREE_EMAIL_HOST / INVENTREE_EMAIL_PORT / INVENTREE_EMAIL_USERNAME
|
||||
# Refer to the InvenTree documentation for more information
|
||||
|
||||
email:
|
||||
# backend: 'django.core.mail.backends.smtp.EmailBackend'
|
||||
host: ''
|
||||
port: 25
|
||||
username: ''
|
||||
password: ''
|
||||
tls: False
|
||||
ssl: False
|
||||
|
||||
# Set debug to False to run in production mode
|
||||
# Use the environment variable INVENTREE_DEBUG
|
||||
debug: True
|
||||
|
||||
# Set debug_toolbar to True to enable a debugging toolbar for InvenTree
|
||||
@@ -65,6 +98,7 @@ debug: True
|
||||
debug_toolbar: False
|
||||
|
||||
# Configure the system logging level
|
||||
# Use environment variable INVENTREE_LOG_LEVEL
|
||||
# Options: DEBUG / INFO / WARNING / ERROR / CRITICAL
|
||||
log_level: WARNING
|
||||
|
||||
@@ -86,13 +120,14 @@ cors:
|
||||
# - https://sub.example.com
|
||||
|
||||
# MEDIA_ROOT is the local filesystem location for storing uploaded files
|
||||
# By default, it is stored in a directory named 'inventree_media' local to the InvenTree directory
|
||||
# This should be changed for a production installation
|
||||
media_root: '../inventree_media'
|
||||
# By default, it is stored under /home/inventree/data/media
|
||||
# Use environment variable INVENTREE_MEDIA_ROOT
|
||||
media_root: '/home/inventree/data/media'
|
||||
|
||||
# STATIC_ROOT is the local filesystem location for storing static files
|
||||
# By default it is stored in a directory named 'inventree_static' local to the InvenTree directory
|
||||
static_root: '../inventree_static'
|
||||
# By default, it is stored under /home/inventree
|
||||
# Use environment variable INVENTREE_STATIC_ROOT
|
||||
static_root: '/home/inventree/static'
|
||||
|
||||
# Optional URL schemes to allow in URL fields
|
||||
# By default, only the following schemes are allowed: ['http', 'https', 'ftp', 'ftps']
|
||||
@@ -105,7 +140,8 @@ static_root: '../inventree_static'
|
||||
# Backup options
|
||||
# Set the backup_dir parameter to store backup files in a specific location
|
||||
# If unspecified, the local user's temp directory will be used
|
||||
#backup_dir: '/home/inventree/backup/'
|
||||
# Use environment variable INVENTREE_BACKUP_DIR
|
||||
backup_dir: '/home/inventree/data/backup/'
|
||||
|
||||
# Permit custom authentication backends
|
||||
#authentication_backends:
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.utils.translation import ugettext as _
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from django.conf.urls import url, include
|
||||
from django.core.exceptions import ValidationError, FieldError
|
||||
from django.http import HttpResponse
|
||||
|
||||
@@ -7,7 +7,7 @@ from django.apps import AppConfig
|
||||
from django.conf import settings
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = logging.getLogger("inventree")
|
||||
|
||||
|
||||
def hashFile(filename):
|
||||
|
||||
@@ -32,7 +32,7 @@ except OSError as err:
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = logging.getLogger("inventree")
|
||||
|
||||
|
||||
def rename_label(instance, filename):
|
||||
@@ -126,7 +126,7 @@ class LabelTemplate(models.Model):
|
||||
|
||||
width = models.FloatField(
|
||||
default=50,
|
||||
verbose_name=('Width [mm]'),
|
||||
verbose_name=_('Width [mm]'),
|
||||
help_text=_('Label width, specified in mm'),
|
||||
validators=[MinValueValidator(2)]
|
||||
)
|
||||
@@ -253,10 +253,12 @@ class StockItemLabel(LabelTemplate):
|
||||
'part': stock_item.part,
|
||||
'name': stock_item.part.full_name,
|
||||
'ipn': stock_item.part.IPN,
|
||||
'revision': stock_item.part.revision,
|
||||
'quantity': normalize(stock_item.quantity),
|
||||
'serial': stock_item.serial,
|
||||
'uid': stock_item.uid,
|
||||
'qr_data': stock_item.format_barcode(brief=True),
|
||||
'qr_url': stock_item.format_barcode(url=True, request=request),
|
||||
'tests': stock_item.testResultMap()
|
||||
}
|
||||
|
||||
|
||||
Binary file not shown.
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
@@ -6,7 +6,7 @@ Django Forms for interacting with Order objects
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django import forms
|
||||
from django.utils.translation import ugettext as _
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
|
||||
from mptt.fields import TreeNodeChoiceField
|
||||
|
||||
@@ -14,6 +14,8 @@ from InvenTree.forms import HelperForm
|
||||
from InvenTree.fields import RoundingDecimalFormField
|
||||
from InvenTree.fields import DatePickerFormField
|
||||
|
||||
import part.models
|
||||
|
||||
from stock.models import StockLocation
|
||||
from .models import PurchaseOrder, PurchaseOrderLineItem, PurchaseOrderAttachment
|
||||
from .models import SalesOrder, SalesOrderLineItem, SalesOrderAttachment
|
||||
@@ -22,7 +24,7 @@ from .models import SalesOrderAllocation
|
||||
|
||||
class IssuePurchaseOrderForm(HelperForm):
|
||||
|
||||
confirm = forms.BooleanField(required=True, initial=False, help_text=_('Place order'))
|
||||
confirm = forms.BooleanField(required=True, initial=False, label=_('Confirm'), help_text=_('Place order'))
|
||||
|
||||
class Meta:
|
||||
model = PurchaseOrder
|
||||
@@ -33,7 +35,7 @@ class IssuePurchaseOrderForm(HelperForm):
|
||||
|
||||
class CompletePurchaseOrderForm(HelperForm):
|
||||
|
||||
confirm = forms.BooleanField(required=True, help_text=_("Mark order as complete"))
|
||||
confirm = forms.BooleanField(required=True, label=_('Confirm'), help_text=_("Mark order as complete"))
|
||||
|
||||
class Meta:
|
||||
model = PurchaseOrder
|
||||
@@ -44,7 +46,7 @@ class CompletePurchaseOrderForm(HelperForm):
|
||||
|
||||
class CancelPurchaseOrderForm(HelperForm):
|
||||
|
||||
confirm = forms.BooleanField(required=True, help_text=_('Cancel order'))
|
||||
confirm = forms.BooleanField(required=True, label=_('Confirm'), help_text=_('Cancel order'))
|
||||
|
||||
class Meta:
|
||||
model = PurchaseOrder
|
||||
@@ -55,7 +57,7 @@ class CancelPurchaseOrderForm(HelperForm):
|
||||
|
||||
class CancelSalesOrderForm(HelperForm):
|
||||
|
||||
confirm = forms.BooleanField(required=True, help_text=_('Cancel order'))
|
||||
confirm = forms.BooleanField(required=True, label=_('Confirm'), help_text=_('Cancel order'))
|
||||
|
||||
class Meta:
|
||||
model = SalesOrder
|
||||
@@ -66,7 +68,7 @@ class CancelSalesOrderForm(HelperForm):
|
||||
|
||||
class ShipSalesOrderForm(HelperForm):
|
||||
|
||||
confirm = forms.BooleanField(required=True, help_text=_('Ship order'))
|
||||
confirm = forms.BooleanField(required=True, label=_('Confirm'), help_text=_('Ship order'))
|
||||
|
||||
class Meta:
|
||||
model = SalesOrder
|
||||
@@ -77,7 +79,7 @@ class ShipSalesOrderForm(HelperForm):
|
||||
|
||||
class ReceivePurchaseOrderForm(HelperForm):
|
||||
|
||||
location = TreeNodeChoiceField(queryset=StockLocation.objects.all(), required=True, help_text=_('Receive parts to this location'))
|
||||
location = TreeNodeChoiceField(queryset=StockLocation.objects.all(), required=True, label=_('Location'), help_text=_('Receive parts to this location'))
|
||||
|
||||
class Meta:
|
||||
model = PurchaseOrder
|
||||
@@ -104,6 +106,7 @@ class EditPurchaseOrderForm(HelperForm):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
target_date = DatePickerFormField(
|
||||
label=_('Target Date'),
|
||||
help_text=_('Target date for order delivery. Order will be overdue after this date.'),
|
||||
)
|
||||
|
||||
@@ -116,6 +119,7 @@ class EditPurchaseOrderForm(HelperForm):
|
||||
'description',
|
||||
'target_date',
|
||||
'link',
|
||||
'responsible',
|
||||
]
|
||||
|
||||
|
||||
@@ -137,6 +141,7 @@ class EditSalesOrderForm(HelperForm):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
target_date = DatePickerFormField(
|
||||
label=_('Target Date'),
|
||||
help_text=_('Target date for order completion. Order will be overdue after this date.'),
|
||||
)
|
||||
|
||||
@@ -148,7 +153,8 @@ class EditSalesOrderForm(HelperForm):
|
||||
'customer_reference',
|
||||
'description',
|
||||
'target_date',
|
||||
'link'
|
||||
'link',
|
||||
'responsible',
|
||||
]
|
||||
|
||||
|
||||
@@ -179,7 +185,7 @@ class EditSalesOrderAttachmentForm(HelperForm):
|
||||
class EditPurchaseOrderLineItemForm(HelperForm):
|
||||
""" Form for editing a PurchaseOrderLineItem object """
|
||||
|
||||
quantity = RoundingDecimalFormField(max_digits=10, decimal_places=5)
|
||||
quantity = RoundingDecimalFormField(max_digits=10, decimal_places=5, label=_('Quantity'))
|
||||
|
||||
class Meta:
|
||||
model = PurchaseOrderLineItem
|
||||
@@ -196,7 +202,7 @@ class EditPurchaseOrderLineItemForm(HelperForm):
|
||||
class EditSalesOrderLineItemForm(HelperForm):
|
||||
""" Form for editing a SalesOrderLineItem object """
|
||||
|
||||
quantity = RoundingDecimalFormField(max_digits=10, decimal_places=5)
|
||||
quantity = RoundingDecimalFormField(max_digits=10, decimal_places=5, label=_('Quantity'))
|
||||
|
||||
class Meta:
|
||||
model = SalesOrderLineItem
|
||||
@@ -209,9 +215,67 @@ class EditSalesOrderLineItemForm(HelperForm):
|
||||
]
|
||||
|
||||
|
||||
class EditSalesOrderAllocationForm(HelperForm):
|
||||
class AllocateSerialsToSalesOrderForm(forms.Form):
|
||||
"""
|
||||
Form for assigning stock to a sales order,
|
||||
by serial number lookup
|
||||
"""
|
||||
|
||||
quantity = RoundingDecimalFormField(max_digits=10, decimal_places=5)
|
||||
line = forms.ModelChoiceField(
|
||||
queryset=SalesOrderLineItem.objects.all(),
|
||||
)
|
||||
|
||||
part = forms.ModelChoiceField(
|
||||
queryset=part.models.Part.objects.all(),
|
||||
)
|
||||
|
||||
serials = forms.CharField(
|
||||
label=_("Serial Numbers"),
|
||||
required=True,
|
||||
help_text=_('Enter stock item serial numbers'),
|
||||
)
|
||||
|
||||
quantity = forms.IntegerField(
|
||||
label=_('Quantity'),
|
||||
required=True,
|
||||
help_text=_('Enter quantity of stock items'),
|
||||
initial=1,
|
||||
min_value=1
|
||||
)
|
||||
|
||||
class Meta:
|
||||
|
||||
fields = [
|
||||
'line',
|
||||
'part',
|
||||
'serials',
|
||||
'quantity',
|
||||
]
|
||||
|
||||
|
||||
class CreateSalesOrderAllocationForm(HelperForm):
|
||||
"""
|
||||
Form for creating a SalesOrderAllocation item.
|
||||
"""
|
||||
|
||||
quantity = RoundingDecimalFormField(max_digits=10, decimal_places=5, label=_('Quantity'))
|
||||
|
||||
class Meta:
|
||||
model = SalesOrderAllocation
|
||||
|
||||
fields = [
|
||||
'line',
|
||||
'item',
|
||||
'quantity',
|
||||
]
|
||||
|
||||
|
||||
class EditSalesOrderAllocationForm(HelperForm):
|
||||
"""
|
||||
Form for editing a SalesOrderAllocation item
|
||||
"""
|
||||
|
||||
quantity = RoundingDecimalFormField(max_digits=10, decimal_places=5, label=_('Quantity'))
|
||||
|
||||
class Meta:
|
||||
model = SalesOrderAllocation
|
||||
|
||||
25
InvenTree/order/migrations/0042_auto_20210310_1619.py
Normal file
25
InvenTree/order/migrations/0042_auto_20210310_1619.py
Normal file
@@ -0,0 +1,25 @@
|
||||
# Generated by Django 3.0.7 on 2021-03-10 05:19
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('users', '0005_owner_model'),
|
||||
('order', '0041_auto_20210114_1728'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='purchaseorder',
|
||||
name='responsible',
|
||||
field=models.ForeignKey(blank=True, help_text='User or group responsible for this order', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='users.Owner', verbose_name='Responsible'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='salesorder',
|
||||
name='responsible',
|
||||
field=models.ForeignKey(blank=True, help_text='User or group responsible for this order', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='users.Owner', verbose_name='Responsible'),
|
||||
),
|
||||
]
|
||||
17
InvenTree/order/migrations/0043_auto_20210330_0013.py
Normal file
17
InvenTree/order/migrations/0043_auto_20210330_0013.py
Normal file
@@ -0,0 +1,17 @@
|
||||
# Generated by Django 3.0.7 on 2021-03-29 13:13
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('order', '0042_auto_20210310_1619'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterUniqueTogether(
|
||||
name='salesorderlineitem',
|
||||
unique_together=set(),
|
||||
),
|
||||
]
|
||||
233
InvenTree/order/migrations/0044_auto_20210404_2016.py
Normal file
233
InvenTree/order/migrations/0044_auto_20210404_2016.py
Normal file
@@ -0,0 +1,233 @@
|
||||
# Generated by Django 3.0.7 on 2021-04-04 20:16
|
||||
|
||||
import InvenTree.fields
|
||||
import InvenTree.models
|
||||
from django.conf import settings
|
||||
import django.core.validators
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import markdownx.models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('company', '0032_auto_20210403_1837'),
|
||||
('part', '0063_bomitem_inherited'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
('stock', '0058_stockitem_packaging'),
|
||||
('order', '0043_auto_20210330_0013'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='purchaseorder',
|
||||
name='created_by',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL, verbose_name='Created By'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='purchaseorder',
|
||||
name='creation_date',
|
||||
field=models.DateField(blank=True, null=True, verbose_name='Creation Date'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='purchaseorder',
|
||||
name='description',
|
||||
field=models.CharField(help_text='Order description', max_length=250, verbose_name='Description'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='purchaseorder',
|
||||
name='link',
|
||||
field=models.URLField(blank=True, help_text='Link to external page', verbose_name='Link'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='purchaseorder',
|
||||
name='notes',
|
||||
field=markdownx.models.MarkdownxField(blank=True, help_text='Order notes', verbose_name='Notes'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='purchaseorder',
|
||||
name='received_by',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL, verbose_name='received by'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='purchaseorder',
|
||||
name='reference',
|
||||
field=models.CharField(help_text='Order reference', max_length=64, unique=True, verbose_name='Reference'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='purchaseorder',
|
||||
name='supplier',
|
||||
field=models.ForeignKey(help_text='Company from which the items are being ordered', limit_choices_to={'is_supplier': True}, on_delete=django.db.models.deletion.CASCADE, related_name='purchase_orders', to='company.Company', verbose_name='Supplier'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='purchaseorder',
|
||||
name='supplier_reference',
|
||||
field=models.CharField(blank=True, help_text='Supplier order reference code', max_length=64, verbose_name='Supplier Reference'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='purchaseorderattachment',
|
||||
name='attachment',
|
||||
field=models.FileField(help_text='Select file to attach', upload_to=InvenTree.models.rename_attachment, verbose_name='Attachment'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='purchaseorderattachment',
|
||||
name='comment',
|
||||
field=models.CharField(blank=True, help_text='File comment', max_length=100, verbose_name='Comment'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='purchaseorderattachment',
|
||||
name='upload_date',
|
||||
field=models.DateField(auto_now_add=True, null=True, verbose_name='upload date'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='purchaseorderattachment',
|
||||
name='user',
|
||||
field=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'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='purchaseorderlineitem',
|
||||
name='notes',
|
||||
field=models.CharField(blank=True, help_text='Line item notes', max_length=500, verbose_name='Notes'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='purchaseorderlineitem',
|
||||
name='order',
|
||||
field=models.ForeignKey(help_text='Purchase Order', on_delete=django.db.models.deletion.CASCADE, related_name='lines', to='order.PurchaseOrder', verbose_name='Order'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='purchaseorderlineitem',
|
||||
name='part',
|
||||
field=models.ForeignKey(blank=True, help_text='Supplier part', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='purchase_order_line_items', to='company.SupplierPart', verbose_name='Part'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='purchaseorderlineitem',
|
||||
name='quantity',
|
||||
field=InvenTree.fields.RoundingDecimalField(decimal_places=5, default=1, help_text='Item quantity', max_digits=15, validators=[django.core.validators.MinValueValidator(0)], verbose_name='Quantity'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='purchaseorderlineitem',
|
||||
name='received',
|
||||
field=models.DecimalField(decimal_places=5, default=0, help_text='Number of items received', max_digits=15, verbose_name='Received'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='purchaseorderlineitem',
|
||||
name='reference',
|
||||
field=models.CharField(blank=True, help_text='Line item reference', max_length=100, verbose_name='Reference'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='salesorder',
|
||||
name='created_by',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL, verbose_name='Created By'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='salesorder',
|
||||
name='creation_date',
|
||||
field=models.DateField(blank=True, null=True, verbose_name='Creation Date'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='salesorder',
|
||||
name='customer',
|
||||
field=models.ForeignKey(help_text='Company to which the items are being sold', limit_choices_to={'is_customer': True}, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='sales_orders', to='company.Company', verbose_name='Customer'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='salesorder',
|
||||
name='customer_reference',
|
||||
field=models.CharField(blank=True, help_text='Customer order reference code', max_length=64, verbose_name='Customer Reference '),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='salesorder',
|
||||
name='description',
|
||||
field=models.CharField(help_text='Order description', max_length=250, verbose_name='Description'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='salesorder',
|
||||
name='link',
|
||||
field=models.URLField(blank=True, help_text='Link to external page', verbose_name='Link'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='salesorder',
|
||||
name='notes',
|
||||
field=markdownx.models.MarkdownxField(blank=True, help_text='Order notes', verbose_name='Notes'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='salesorder',
|
||||
name='reference',
|
||||
field=models.CharField(help_text='Order reference', max_length=64, unique=True, verbose_name='Reference'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='salesorder',
|
||||
name='shipment_date',
|
||||
field=models.DateField(blank=True, null=True, verbose_name='Shipment Date'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='salesorder',
|
||||
name='shipped_by',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL, verbose_name='shipped by'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='salesorder',
|
||||
name='status',
|
||||
field=models.PositiveIntegerField(choices=[(10, 'Pending'), (20, 'Shipped'), (40, 'Cancelled'), (50, 'Lost'), (60, 'Returned')], default=10, help_text='Purchase order status', verbose_name='Status'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='salesorderallocation',
|
||||
name='item',
|
||||
field=models.ForeignKey(help_text='Select stock item to allocate', limit_choices_to={'belongs_to': None, 'part__salable': True, 'sales_order': None}, on_delete=django.db.models.deletion.CASCADE, related_name='sales_order_allocations', to='stock.StockItem', verbose_name='Item'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='salesorderallocation',
|
||||
name='line',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='allocations', to='order.SalesOrderLineItem', verbose_name='Line'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='salesorderallocation',
|
||||
name='quantity',
|
||||
field=InvenTree.fields.RoundingDecimalField(decimal_places=5, default=1, help_text='Enter stock allocation quantity', max_digits=15, validators=[django.core.validators.MinValueValidator(0)], verbose_name='Quantity'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='salesorderattachment',
|
||||
name='attachment',
|
||||
field=models.FileField(help_text='Select file to attach', upload_to=InvenTree.models.rename_attachment, verbose_name='Attachment'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='salesorderattachment',
|
||||
name='comment',
|
||||
field=models.CharField(blank=True, help_text='File comment', max_length=100, verbose_name='Comment'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='salesorderattachment',
|
||||
name='upload_date',
|
||||
field=models.DateField(auto_now_add=True, null=True, verbose_name='upload date'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='salesorderattachment',
|
||||
name='user',
|
||||
field=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'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='salesorderlineitem',
|
||||
name='notes',
|
||||
field=models.CharField(blank=True, help_text='Line item notes', max_length=500, verbose_name='Notes'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='salesorderlineitem',
|
||||
name='order',
|
||||
field=models.ForeignKey(help_text='Sales Order', on_delete=django.db.models.deletion.CASCADE, related_name='lines', to='order.SalesOrder', verbose_name='Order'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='salesorderlineitem',
|
||||
name='part',
|
||||
field=models.ForeignKey(help_text='Part', limit_choices_to={'salable': True}, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='sales_order_line_items', to='part.Part', verbose_name='Part'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='salesorderlineitem',
|
||||
name='quantity',
|
||||
field=InvenTree.fields.RoundingDecimalField(decimal_places=5, default=1, help_text='Item quantity', max_digits=15, validators=[django.core.validators.MinValueValidator(0)], verbose_name='Quantity'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='salesorderlineitem',
|
||||
name='reference',
|
||||
field=models.CharField(blank=True, help_text='Line item reference', max_length=100, verbose_name='Reference'),
|
||||
),
|
||||
]
|
||||
@@ -15,12 +15,13 @@ from django.core.validators import MinValueValidator
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.contrib.auth.models import User
|
||||
from django.urls import reverse
|
||||
from django.utils.translation import ugettext as _
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
|
||||
from markdownx.models import MarkdownxField
|
||||
|
||||
from djmoney.models.fields import MoneyField
|
||||
|
||||
from users import models as UserModels
|
||||
from part import models as PartModels
|
||||
from stock import models as stock_models
|
||||
from company.models import Company, SupplierPart
|
||||
@@ -46,7 +47,7 @@ class Order(models.Model):
|
||||
created_by: User who created this order (automatically captured)
|
||||
issue_date: Date the order was issued
|
||||
complete_date: Date the order was completed
|
||||
|
||||
responsible: User (or group) responsible for managing the order
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
@@ -95,21 +96,31 @@ class Order(models.Model):
|
||||
class Meta:
|
||||
abstract = True
|
||||
|
||||
reference = models.CharField(unique=True, max_length=64, blank=False, help_text=_('Order reference'))
|
||||
reference = models.CharField(unique=True, max_length=64, blank=False, verbose_name=_('Reference'), help_text=_('Order reference'))
|
||||
|
||||
description = models.CharField(max_length=250, help_text=_('Order description'))
|
||||
description = models.CharField(max_length=250, verbose_name=_('Description'), help_text=_('Order description'))
|
||||
|
||||
link = models.URLField(blank=True, help_text=_('Link to external page'))
|
||||
link = models.URLField(blank=True, verbose_name=_('Link'), help_text=_('Link to external page'))
|
||||
|
||||
creation_date = models.DateField(blank=True, null=True)
|
||||
creation_date = models.DateField(blank=True, null=True, verbose_name=_('Creation Date'))
|
||||
|
||||
created_by = models.ForeignKey(User,
|
||||
on_delete=models.SET_NULL,
|
||||
blank=True, null=True,
|
||||
related_name='+'
|
||||
related_name='+',
|
||||
verbose_name=_('Created By')
|
||||
)
|
||||
|
||||
notes = MarkdownxField(blank=True, help_text=_('Order notes'))
|
||||
responsible = models.ForeignKey(
|
||||
UserModels.Owner,
|
||||
on_delete=models.SET_NULL,
|
||||
blank=True, null=True,
|
||||
help_text=_('User or group responsible for this order'),
|
||||
verbose_name=_('Responsible'),
|
||||
related_name='+',
|
||||
)
|
||||
|
||||
notes = MarkdownxField(blank=True, verbose_name=_('Notes'), help_text=_('Order notes'))
|
||||
|
||||
|
||||
class PurchaseOrder(Order):
|
||||
@@ -176,16 +187,18 @@ class PurchaseOrder(Order):
|
||||
'is_supplier': True,
|
||||
},
|
||||
related_name='purchase_orders',
|
||||
verbose_name=_('Supplier'),
|
||||
help_text=_('Company from which the items are being ordered')
|
||||
)
|
||||
|
||||
supplier_reference = models.CharField(max_length=64, blank=True, help_text=_("Supplier order reference code"))
|
||||
supplier_reference = models.CharField(max_length=64, blank=True, verbose_name=_('Supplier Reference'), help_text=_("Supplier order reference code"))
|
||||
|
||||
received_by = models.ForeignKey(
|
||||
User,
|
||||
on_delete=models.SET_NULL,
|
||||
blank=True, null=True,
|
||||
related_name='+'
|
||||
related_name='+',
|
||||
verbose_name=_('received by')
|
||||
)
|
||||
|
||||
issue_date = models.DateField(
|
||||
@@ -424,13 +437,14 @@ class SalesOrder(Order):
|
||||
null=True,
|
||||
limit_choices_to={'is_customer': True},
|
||||
related_name='sales_orders',
|
||||
verbose_name=_('Customer'),
|
||||
help_text=_("Company to which the items are being sold"),
|
||||
)
|
||||
|
||||
status = models.PositiveIntegerField(default=SalesOrderStatus.PENDING, choices=SalesOrderStatus.items(),
|
||||
help_text=_('Purchase order status'))
|
||||
verbose_name=_('Status'), help_text=_('Purchase order status'))
|
||||
|
||||
customer_reference = models.CharField(max_length=64, blank=True, help_text=_("Customer order reference code"))
|
||||
customer_reference = models.CharField(max_length=64, blank=True, verbose_name=_('Customer Reference '), help_text=_("Customer order reference code"))
|
||||
|
||||
target_date = models.DateField(
|
||||
null=True, blank=True,
|
||||
@@ -438,13 +452,14 @@ class SalesOrder(Order):
|
||||
help_text=_('Target date for order completion. Order will be overdue after this date.')
|
||||
)
|
||||
|
||||
shipment_date = models.DateField(blank=True, null=True)
|
||||
shipment_date = models.DateField(blank=True, null=True, verbose_name=_('Shipment Date'))
|
||||
|
||||
shipped_by = models.ForeignKey(
|
||||
User,
|
||||
on_delete=models.SET_NULL,
|
||||
blank=True, null=True,
|
||||
related_name='+'
|
||||
related_name='+',
|
||||
verbose_name=_('shipped by')
|
||||
)
|
||||
|
||||
@property
|
||||
@@ -575,11 +590,11 @@ class OrderLineItem(models.Model):
|
||||
class Meta:
|
||||
abstract = True
|
||||
|
||||
quantity = RoundingDecimalField(max_digits=15, decimal_places=5, validators=[MinValueValidator(0)], default=1, help_text=_('Item quantity'))
|
||||
quantity = RoundingDecimalField(max_digits=15, decimal_places=5, validators=[MinValueValidator(0)], default=1, verbose_name=_('Quantity'), help_text=_('Item quantity'))
|
||||
|
||||
reference = models.CharField(max_length=100, blank=True, help_text=_('Line item reference'))
|
||||
reference = models.CharField(max_length=100, blank=True, verbose_name=_('Reference'), help_text=_('Line item reference'))
|
||||
|
||||
notes = models.CharField(max_length=500, blank=True, help_text=_('Line item notes'))
|
||||
notes = models.CharField(max_length=500, blank=True, verbose_name=_('Notes'), help_text=_('Line item notes'))
|
||||
|
||||
|
||||
class PurchaseOrderLineItem(OrderLineItem):
|
||||
@@ -605,6 +620,7 @@ class PurchaseOrderLineItem(OrderLineItem):
|
||||
order = models.ForeignKey(
|
||||
PurchaseOrder, on_delete=models.CASCADE,
|
||||
related_name='lines',
|
||||
verbose_name=_('Order'),
|
||||
help_text=_('Purchase Order')
|
||||
)
|
||||
|
||||
@@ -618,10 +634,11 @@ class PurchaseOrderLineItem(OrderLineItem):
|
||||
SupplierPart, on_delete=models.SET_NULL,
|
||||
blank=True, null=True,
|
||||
related_name='purchase_order_line_items',
|
||||
verbose_name=_('Part'),
|
||||
help_text=_("Supplier part"),
|
||||
)
|
||||
|
||||
received = models.DecimalField(decimal_places=5, max_digits=15, default=0, help_text=_('Number of items received'))
|
||||
received = models.DecimalField(decimal_places=5, max_digits=15, default=0, verbose_name=_('Received'), help_text=_('Number of items received'))
|
||||
|
||||
purchase_price = MoneyField(
|
||||
max_digits=19,
|
||||
@@ -647,13 +664,12 @@ class SalesOrderLineItem(OrderLineItem):
|
||||
part: Link to a Part object (may be null)
|
||||
"""
|
||||
|
||||
order = models.ForeignKey(SalesOrder, on_delete=models.CASCADE, related_name='lines', help_text=_('Sales Order'))
|
||||
order = models.ForeignKey(SalesOrder, on_delete=models.CASCADE, related_name='lines', verbose_name=_('Order'), help_text=_('Sales Order'))
|
||||
|
||||
part = models.ForeignKey('part.Part', on_delete=models.SET_NULL, related_name='sales_order_line_items', null=True, help_text=_('Part'), limit_choices_to={'salable': True})
|
||||
part = models.ForeignKey('part.Part', on_delete=models.SET_NULL, related_name='sales_order_line_items', null=True, verbose_name=_('Part'), help_text=_('Part'), limit_choices_to={'salable': True})
|
||||
|
||||
class Meta:
|
||||
unique_together = [
|
||||
('order', 'part'),
|
||||
]
|
||||
|
||||
def fulfilled_quantity(self):
|
||||
@@ -722,6 +738,12 @@ class SalesOrderAllocation(models.Model):
|
||||
|
||||
errors = {}
|
||||
|
||||
try:
|
||||
if not self.item:
|
||||
raise ValidationError({'item': _('Stock item has not been assigned')})
|
||||
except stock_models.StockItem.DoesNotExist:
|
||||
raise ValidationError({'item': _('Stock item has not been assigned')})
|
||||
|
||||
try:
|
||||
if not self.line.part == self.item.part:
|
||||
errors['item'] = _('Cannot allocate stock item to a line with a different part')
|
||||
@@ -744,7 +766,7 @@ class SalesOrderAllocation(models.Model):
|
||||
if len(errors) > 0:
|
||||
raise ValidationError(errors)
|
||||
|
||||
line = models.ForeignKey(SalesOrderLineItem, on_delete=models.CASCADE, related_name='allocations')
|
||||
line = models.ForeignKey(SalesOrderLineItem, on_delete=models.CASCADE, verbose_name=_('Line'), related_name='allocations')
|
||||
|
||||
item = models.ForeignKey(
|
||||
'stock.StockItem',
|
||||
@@ -755,10 +777,11 @@ class SalesOrderAllocation(models.Model):
|
||||
'belongs_to': None,
|
||||
'sales_order': None,
|
||||
},
|
||||
verbose_name=_('Item'),
|
||||
help_text=_('Select stock item to allocate')
|
||||
)
|
||||
|
||||
quantity = RoundingDecimalField(max_digits=15, decimal_places=5, validators=[MinValueValidator(0)], default=1, help_text=_('Enter stock allocation quantity'))
|
||||
quantity = RoundingDecimalField(max_digits=15, decimal_places=5, validators=[MinValueValidator(0)], default=1, verbose_name=_('Quantity'), help_text=_('Enter stock allocation quantity'))
|
||||
|
||||
def get_serial(self):
|
||||
return self.item.serial
|
||||
|
||||
@@ -35,7 +35,10 @@ src="{% static 'img/blank_image.png' %}"
|
||||
<hr>
|
||||
<p>{{ order.description }}</p>
|
||||
<div class='btn-row'>
|
||||
<div class='btn-group action-buttons'>
|
||||
<div class='btn-group action-buttons' role='group'>
|
||||
<button type='button' class='btn btn-default' id='print-order-report' title='{% trans "Print" %}'>
|
||||
<span class='fas fa-print'></span>
|
||||
</button>
|
||||
{% if roles.purchase_order.change %}
|
||||
<button type='button' class='btn btn-default' id='edit-order' title='{% trans "Edit order information" %}'>
|
||||
<span class='fas fa-edit icon-green'></span>
|
||||
@@ -129,6 +132,13 @@ src="{% static 'img/blank_image.png' %}"
|
||||
<td>{{ order.complete_date }}<span class='badge'>{{ order.received_by }}</span></td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% if order.responsible %}
|
||||
<tr>
|
||||
<td><span class='fas fa-users'></span></td>
|
||||
<td>{% trans "Responsible" %}</td>
|
||||
<td>{{ order.responsible }}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
</table>
|
||||
{% endblock %}
|
||||
|
||||
@@ -149,6 +159,10 @@ $("#place-order").click(function() {
|
||||
});
|
||||
{% endif %}
|
||||
|
||||
$('#print-order-report').click(function() {
|
||||
printPurchaseOrderReports([{{ order.pk }}]);
|
||||
});
|
||||
|
||||
$("#edit-order").click(function() {
|
||||
launchModalForm("{% url 'po-edit' order.id %}",
|
||||
{
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
{% extends "modal_form.html" %}
|
||||
|
||||
{% load i18n %}
|
||||
|
||||
{% block pre_form_content %}
|
||||
|
||||
Mark this order as complete?
|
||||
{% trans 'Mark this order as complete?' %}
|
||||
{% if not order.is_complete %}
|
||||
<div class='alert alert-warning alert-block'>
|
||||
This order has line items which have not been marked as received.
|
||||
Marking this order as complete will remove these line items.
|
||||
{%trans 'This order has line items which have not been marked as received.
|
||||
Marking this order as complete will remove these line items.' %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
{% extends "modal_form.html" %}
|
||||
|
||||
{% load i18n %}
|
||||
|
||||
{% block pre_form_content %}
|
||||
|
||||
After placing this purchase order, line items will no longer be editable.
|
||||
{% trans 'After placing this purchase order, line items will no longer be editable.' %}
|
||||
|
||||
{% endblock %}
|
||||
@@ -11,6 +11,9 @@
|
||||
|
||||
{% block heading %}
|
||||
{% trans "Order Notes" %}
|
||||
{% if roles.purchase_order.change and not editing %}
|
||||
<button title='{% trans "Edit notes" %}' class='btn btn-default' id='edit-notes'><span class='fas fa-edit'></span></button>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block details %}
|
||||
@@ -21,21 +24,19 @@
|
||||
|
||||
{{ form }}
|
||||
<hr>
|
||||
<input type='submit' value='{% trans "Save" %}'/>
|
||||
<button type="submit" class='btn btn-default'>{% trans "Save" %}</button>
|
||||
</form>
|
||||
|
||||
{{ form.media }}
|
||||
|
||||
{% else %}
|
||||
{% if roles.purchase_order.change %}
|
||||
<button title='{% trans "Edit notes" %}' class='btn btn-default action-button' id='edit-notes'><span class='fas fa-edit'></span></button>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
<div class='panel panel-default'>
|
||||
<div class='panel-content'>
|
||||
{{ order.notes | markdownify }}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% endblock %}
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
{{ part.full_name }} <small><i>{{ part.description }}</i></small>
|
||||
</td>
|
||||
<td>
|
||||
<button class='btn btn-default btn-create' onClick='newSupplierPartFromOrderWizard()' id='new_supplier_part_{{ part.id }}' part='{{ part.pk }}' title='{% trans "Create new supplier part" $}' type='button'>
|
||||
<button class='btn btn-default btn-create' onClick='newSupplierPartFromOrderWizard()' id='new_supplier_part_{{ part.id }}' part='{{ part.pk }}' title='{% trans "Create new supplier part" %}' type='button'>
|
||||
<span part='{{ part.pk }}' class='fas fa-plus-circle'></span>
|
||||
</button>
|
||||
</td>
|
||||
@@ -66,7 +66,7 @@
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<button class='btn btn-default btn-remove' onclick='removeOrderRowFromOrderWizard()' id='del_item_{{ part.id }}' title='Remove part' type='button'>
|
||||
<button class='btn btn-default btn-remove' onclick='removeOrderRowFromOrderWizard()' id='del_item_{{ part.id }}' title='{% trans "Remove part" %}' type='button'>
|
||||
<span row='part_row_{{ part.id }}' class='fas fa-trash-alt icon-red'></span>
|
||||
</button>
|
||||
</td>
|
||||
|
||||
@@ -42,10 +42,11 @@
|
||||
<button
|
||||
class='btn btn-default btn-create'
|
||||
id='new_po_{{ supplier.id }}'
|
||||
title='Create new purchase order for {{ supplier.name }}'
|
||||
title='{% trans "Create new purchase order for {{ supplier.name }}" %}'
|
||||
type='button'
|
||||
supplierid='{{ supplier.id }}'
|
||||
onclick='newPurchaseOrderFromOrderWizard()'>
|
||||
<span supplier-id='{{ supplier.id }}' class='fas fa-plus-circle'></span>
|
||||
<span supplierid='{{ supplier.id }}' class='fas fa-plus-circle'></span>
|
||||
</button>
|
||||
</td>
|
||||
<td>
|
||||
|
||||
@@ -181,6 +181,13 @@ $("#po-table").inventreeTable({
|
||||
sortName: 'part__MPN',
|
||||
field: 'supplier_part_detail.MPN',
|
||||
title: '{% trans "MPN" %}',
|
||||
formatter: function(value, row, index, field) {
|
||||
if (row.supplier_part_detail.manufacturer_part) {
|
||||
return renderLink(value, `/manufacturer-part/${row.supplier_part_detail.manufacturer_part.pk}/`);
|
||||
} else {
|
||||
return "";
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
sortable: true,
|
||||
|
||||
@@ -15,18 +15,24 @@ InvenTree | {% trans "Purchase Orders" %}
|
||||
|
||||
<div id='table-buttons'>
|
||||
<div class='button-toolbar container-fluid' style='float: right;'>
|
||||
{% if roles.purchase_order.add %}
|
||||
<button class='btn btn-primary' type='button' id='po-create' title='{% trans "Create new purchase order" %}'>
|
||||
<span class='fas fa-plus-circle'></span> {% trans "New Purchase Order" %}</button>
|
||||
{% endif %}
|
||||
<button class='btn btn-default' type='button' id='view-calendar' title='{% trans "Display calendar view" %}'>
|
||||
<span class='fas fa-calendar-alt'></span>
|
||||
</button>
|
||||
<button class='btn btn-default' type='button' id='view-list' title='{% trans "Display list view" %}'>
|
||||
<span class='fas fa-th-list'></span>
|
||||
</button>
|
||||
<div class='filter-list' id='filter-list-purchaseorder'>
|
||||
<!-- An empty div in which the filter list will be constructed -->
|
||||
<div class='btn-group'>
|
||||
{% if roles.purchase_order.add %}
|
||||
<button class='btn btn-primary' type='button' id='po-create' title='{% trans "Create new purchase order" %}'>
|
||||
<span class='fas fa-plus-circle'></span> {% trans "New Purchase Order" %}
|
||||
</button>
|
||||
{% endif %}
|
||||
<button id='order-print' class='btn btn-default' title='{% trans "Print Order Reports" %}'>
|
||||
<span class='fas fa-print'></span>
|
||||
</button>
|
||||
<button class='btn btn-default' type='button' id='view-calendar' title='{% trans "Display calendar view" %}'>
|
||||
<span class='fas fa-calendar-alt'></span>
|
||||
</button>
|
||||
<button class='btn btn-default' type='button' id='view-list' title='{% trans "Display list view" %}'>
|
||||
<span class='fas fa-th-list'></span>
|
||||
</button>
|
||||
<div class='filter-list' id='filter-list-purchaseorder'>
|
||||
<!-- An empty div in which the filter list will be constructed -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -110,6 +116,7 @@ InvenTree | {% trans "Purchase Orders" %}
|
||||
initialView: 'dayGridMonth',
|
||||
nowIndicator: true,
|
||||
aspectRatio: 2.5,
|
||||
locale: '{{request.LANGUAGE_CODE}}',
|
||||
datesSet: function() {
|
||||
loadOrderEvents(calendar);
|
||||
}
|
||||
@@ -154,6 +161,18 @@ $("#view-list").click(function() {
|
||||
$("#view-calendar").show();
|
||||
});
|
||||
|
||||
$("#order-print").click(function() {
|
||||
var rows = $("#purchase-order-table").bootstrapTable('getSelections');
|
||||
|
||||
var orders = [];
|
||||
|
||||
rows.forEach(function(row) {
|
||||
orders.push(row.pk);
|
||||
});
|
||||
|
||||
printPurchaseOrderReports(orders);
|
||||
})
|
||||
|
||||
$("#po-create").click(function() {
|
||||
launchModalForm("{% url 'po-create' %}",
|
||||
{
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user