Compare commits

...

368 Commits
0.1.7 ... 0.2.1

Author SHA1 Message Date
Oliver
0b5557395b Update version.py 2021-04-18 19:53:15 +10:00
Oliver
6c410521a1 Merge pull request #1476 from SchrodingersGat/docker-improvements
Cleanup docker files
2021-04-18 19:52:42 +10:00
Oliver Walters
9d88d38bf8 Enforce line-endings for more file types 2021-04-18 19:39:03 +10:00
Oliver Walters
4531030551 Fix line endings 2021-04-18 19:37:11 +10:00
Oliver Walters
aced0e73c7 compose file cleanup 2021-04-18 18:58:00 +10:00
Oliver Walters
61eba2f7fc Typo fix 2021-04-18 18:54:21 +10:00
Oliver Walters
0926992b4f Updated nginx conf 2021-04-18 18:53:30 +10:00
Oliver Walters
d8e1e18f4d change web -> inventree 2021-04-18 17:21:06 +10:00
Oliver
b7247284a6 Merge pull request #1478 from SchrodingersGat/log-fix
Remove log output which was leaking password into the logs
2021-04-18 17:20:43 +10:00
Oliver Walters
1502a07b81 Remove log output which was leaking password into the logs 2021-04-18 16:54:35 +10:00
Oliver Walters
eb108edb60 Adds entrypoint for starting a development server 2021-04-18 16:26:32 +10:00
Oliver Walters
270c0ea85d Cleanup docker files 2021-04-18 15:24:33 +10:00
Oliver
83002a5d51 Merge pull request #1475 from SchrodingersGat/docker-fixes
Add binaries for database dumping
2021-04-18 15:23:55 +10:00
Oliver Walters
9eb559bec5 More install fixes 2021-04-18 15:09:01 +10:00
Oliver Walters
69473b9bff Fix install
Also make the web port configurable
2021-04-18 15:05:52 +10:00
Oliver Walters
c07aef7f75 Remove commented line 2021-04-18 14:58:02 +10:00
Oliver Walters
cbb94d2ff7 sqlite3 -> sqlite 2021-04-18 14:57:25 +10:00
Oliver Walters
75054f870e Fix 2021-04-18 14:50:22 +10:00
Oliver Walters
124db01b63 Add binaries for database dumping 2021-04-18 14:45:41 +10:00
Oliver
2be78f5d4c Merge pull request #1469 from eeintech/stock_item_template_fix
Fixed stock item template for items without manufacturer part
2021-04-17 08:05:21 +10:00
Oliver
d3da262687 Merge pull request #1470 from eeintech/hide_system_alert_nostaff
Hide system alert for non-staff users
2021-04-17 08:04:45 +10:00
eeintech
2b4723cc32 Hide system alert for non-staff users, introduced orange icon for less severe alert than background workers not running (like missing email config) 2021-04-16 12:22:13 -04:00
eeintech
09ef85ce9d Fixed stock item template for items without manufacturer part 2021-04-16 11:43:21 -04:00
Oliver
d4529ec1c4 Merge pull request #1464 from matmair/translation_improv
Translation improvments
2021-04-16 21:46:07 +10:00
Oliver
f8edcf8a0d Merge pull request #1466 from nwns/fix/handle_null_manufacturer_part
fix: don't link manufacturer part if it doesn't have one
2021-04-16 11:09:04 +10:00
Nigel
6f00c662a1 fix: don't link manufacturer part if it doesn't have one 2021-04-15 16:28:50 -06:00
Matthias
cfae92e22b more translated strings for api-titles and filters 2021-04-15 12:15:02 +02:00
Oliver
3e6429cb13 Merge pull request #1462 from eeintech/stock_label
Added revision and stock item QR code URL for label creation
2021-04-15 13:00:09 +10:00
Oliver
3a8cce9af3 Merge pull request #1417 from eeintech/manufacturer_part
Manufacturer Parts
2021-04-15 11:13:02 +10:00
eeintech
aa41e3e17d Fixed default url barcode setting 2021-04-14 16:24:24 -04:00
eeintech
1bf72ee335 Added revision and stock item URL for label creation 2021-04-14 16:00:28 -04:00
Matthias
73bcacc2d8 added tranlation strings 2021-04-14 12:23:51 +02:00
Matthias
79a643ccf5 Merge branch 'master' of https://github.com/inventree/InvenTree into translation_improv 2021-04-14 10:46:41 +02:00
Oliver
623d0366fc Update README.md
Fixed docker link
2021-04-14 07:55:12 +10:00
eeintech
1e6c6c678f Split supplier part update migration and added reverse method for manufacturer data 2021-04-13 10:25:53 -04:00
eeintech
42a73576da Merge branch 'master' of github.com:inventree/InvenTree into manufacturer_part 2021-04-13 09:48:55 -04:00
Oliver
a8d22dac62 Merge pull request #1458 from matmair/translation_improv
updated translation references
2021-04-13 21:20:32 +10:00
Matthias
75a249da95 Merge branch 'master' of https://github.com/inventree/InvenTree into translation_improv 2021-04-13 13:03:58 +02:00
Matthias
71425d3bc5 updated translation references 2021-04-13 12:56:18 +02:00
Oliver
79dc66e840 Merge pull request #1304 from SchrodingersGat/email-support
Support for email settings
2021-04-13 20:42:23 +10:00
Oliver Walters
d5034ece51 typo fix 2021-04-13 20:21:33 +10:00
Oliver Walters
04318c6d70 Adjust default email settings 2021-04-13 20:15:09 +10:00
Oliver Walters
96efb0eb28 Remove "forgot password" link if the email backend is not configured 2021-04-13 20:02:20 +10:00
eeintech
3eae70e745 Merged master and company migrations 2021-04-12 11:10:35 -04:00
Oliver Walters
f902b79d79 And more templates 2021-04-12 20:07:38 +10:00
Oliver Walters
12a4c22a9b Password reset templates 2021-04-12 20:02:05 +10:00
Oliver Walters
a9d490b716 PEP fixes 2021-04-12 19:20:41 +10:00
Oliver Walters
d243ff6b37 Offload email task to background worker 2021-04-12 19:18:47 +10:00
Oliver Walters
fb5a94a778 Support for email settings 2021-04-12 18:50:37 +10:00
Oliver
72f83771f1 Merge pull request #1453 from SchrodingersGat/part-parameter-api
Part parameter api
2021-04-11 19:41:02 +10:00
Oliver Walters
8a06eaa40d Unit testing 2021-04-11 19:28:39 +10:00
Oliver Walters
85c9bc1b81 Adds detail endpoint for PartParameter model 2021-04-11 18:56:35 +10:00
Oliver
471d009e84 Merge pull request #1452 from SchrodingersGat/workflow-fox
Fix workflow for publishing docker files
2021-04-11 15:45:40 +10:00
Oliver Walters
029808a986 Fix workflow for publishing docker files 2021-04-11 15:45:17 +10:00
Oliver
effd547260 Update version.py 2021-04-11 15:39:53 +10:00
Oliver
7c9ad3f406 Update version.py 2021-04-11 15:39:16 +10:00
Oliver
4cf0339393 Update README.md 2021-04-11 15:38:52 +10:00
Oliver
a2ff3e3474 Merge pull request #1398 from SchrodingersGat/django-q
Django q
2021-04-11 15:38:20 +10:00
Oliver Walters
c2f85b0447 docker-compose tweaks 2021-04-11 15:25:32 +10:00
Oliver Walters
8f07efa4e3 Add dockerhub badge 2021-04-11 15:15:11 +10:00
Oliver Walters
b490c5d035 Add new docker workflow for publising docker images on release 2021-04-11 15:08:13 +10:00
Oliver Walters
f9449da576 Merge remote-tracking branch 'upstream/master' into django-q
# Conflicts:
#	InvenTree/InvenTree/version.py
2021-04-11 15:03:33 +10:00
Oliver
34e95ab70c Update version.py 2021-04-11 14:49:41 +10:00
Oliver Walters
5f9236d280 Updates to docker files 2021-04-11 14:46:40 +10:00
Oliver Walters
44fe5721e0 Disgusting hack for tasks.py 2021-04-11 14:05:55 +10:00
Oliver Walters
f6f3815f31 Include worker status in main API call 2021-04-11 13:58:59 +10:00
Oliver Walters
78bcbe271a Update supervisor conf file 2021-04-11 13:45:56 +10:00
Oliver Walters
2e8d3b6424 Fix for tasks.py (??) 2021-04-11 13:22:16 +10:00
Oliver Walters
c9021fe991 Simplify docker build workflow 2021-04-10 22:48:23 +10:00
Oliver Walters
0e1b647e7b Remove mariadb test (uses the same backend as mysql!) 2021-04-10 22:47:30 +10:00
Oliver Walters
b74d365529 Merge remote-tracking branch 'upstream/master' into django-q 2021-04-10 22:46:20 +10:00
Oliver Walters
3da5505b58 Fix build workflow 2021-04-10 22:44:37 +10:00
Oliver Walters
5a168abbfe Separated docker file into separate directory 2021-04-10 22:42:08 +10:00
Oliver Walters
8f626d305e Fix location of entrypoint scripts 2021-04-10 22:35:10 +10:00
Oliver Walters
5d9e273559 Adds nxinx service 2021-04-10 22:25:07 +10:00
Oliver Walters
91b6f98f95 Update directory structure to match docker config 2021-04-10 22:08:36 +10:00
Oliver Walters
2f1db486a0 Do not use python virtual environment inside container 2021-04-10 21:40:27 +10:00
Oliver Walters
823f84e46a Simplified volume management in docker-compose 2021-04-10 20:58:51 +10:00
Oliver Walters
178715ce61 Auto create config file in specified location if it does not exist 2021-04-10 20:57:56 +10:00
Oliver Walters
e787c853e5 Update logger context 2021-04-10 20:08:13 +10:00
Oliver Walters
5e54b0f5cf Auto-generate key file if it does not exist! 2021-04-10 19:01:02 +10:00
Oliver Walters
9086c8a3bf Simplify external directory structure
- All InvenTree data now in a single subdir
- Copy default config file (if it does not exist)
- Config file is accessible from outside world
- Update start_server and start_worker scripts
2021-04-10 17:36:19 +10:00
Oliver
e011faa9b7 Merge pull request #1450 from SchrodingersGat/company-description-optional
Company description is no longer a required field
2021-04-10 16:19:30 +10:00
Oliver Walters
e6bd91c9e2 Company description is no longer a required field 2021-04-10 15:29:44 +10:00
Oliver Walters
1372343bd5 Updates to docker-compose file
- Note: not ready yet!
2021-04-10 15:27:50 +10:00
Oliver Walters
8eb571bddf Update dockerfile 2021-04-10 15:08:10 +10:00
Oliver
c7e1ac5648 Merge pull request #1449 from eeintech/fix_stock_ops
Fixed transfer stock action in template
2021-04-10 09:19:43 +10:00
Oliver
6bf3cc9e01 Merge pull request #1446 from eeintech/bom_yaml_export
Replace normalize with integer wrapper for quantity field
2021-04-10 09:17:23 +10:00
eeintech
6bf4140e5a Fixed transfer stock action in template 2021-04-09 16:55:05 -04:00
eeintech
afddf12339 Changed int to float 2021-04-08 22:04:26 -04:00
Oliver
6a1bb0a806 Merge pull request #1445 from eeintech/simple_stock_table_view
Simplified stock table view in Part and SupplierPart detail pages
2021-04-09 11:58:33 +10:00
eeintech
97e1bc0a67 Added missing part_detail reference 2021-04-08 21:46:11 -04:00
eeintech
cbddda6640 Remove normalize import 2021-04-08 14:41:06 -04:00
eeintech
7491cda313 Replace normalize with integer wrapper for quantity field 2021-04-08 14:35:47 -04:00
eeintech
b5a5f5b409 Simplified stock table view in Part and SupplierPart detail pages 2021-04-08 13:42:35 -04:00
eeintech
f8d1ee8805 Made company description optional 2021-04-08 13:24:17 -04:00
Oliver Walters
47a93bc4cb More environment variables for config.yaml 2021-04-08 21:01:52 +10:00
Oliver Walters
3381945e14 Add newline 2021-04-08 17:10:48 +10:00
eeintech
0bb2507dd6 Added manufacturer part deletion warning when deleting a part 2021-04-07 14:02:50 -04:00
eeintech
1074300ba0 Improved BOM export of combined manufacturer and supplier data 2021-04-07 12:24:32 -04:00
eeintech
ccd35fc4b4 Fixed supplier part list bug and hide manufacturer fields in supplier part edit form 2021-04-07 11:50:11 -04:00
eeintech
c0691c3e9b Decoupled manufacturer and supplier data in BOM export, aggregate data needs more work 2021-04-07 11:43:05 -04:00
eeintech
63ade51c6c Updated migrations after merge with master 2021-04-07 10:49:19 -04:00
Oliver Walters
3926276fd1 Greatly simplified "wait_for_db" command 2021-04-08 00:37:34 +10:00
eeintech
f39928368e Merge branch 'master' of github.com:inventree/InvenTree into manufacturer_part 2021-04-07 10:33:55 -04:00
eeintech
2db6af2af6 PO table and stock item template improvements 2021-04-07 10:33:31 -04:00
Oliver Walters
71cac6e269 Simplify waiting for db 2021-04-08 00:09:51 +10:00
Oliver Walters
ed304f571a Better configuration of github repo 2021-04-08 00:05:37 +10:00
eeintech
b2264940a3 Dynamic control of information to make cleaner supplier and manufacturer tables 2021-04-07 09:54:20 -04:00
Oliver Walters
14aead038e Adds docker_compose file 2021-04-07 23:46:30 +10:00
Oliver Walters
d4d9263131 Add option to specify config file via environment variable 2021-04-07 23:46:03 +10:00
Oliver Walters
9c38d67b52 Merge remote-tracking branch 'upstream/master' into django-q
# Conflicts:
#	InvenTree/InvenTree/status.py
#	InvenTree/templates/about.html
2021-04-07 22:29:47 +10:00
Oliver Walters
4a3ca4638c Dockerfile updates 2021-04-07 22:27:55 +10:00
Oliver Walters
d91531720b Unit testing for task scheduling 2021-04-07 22:17:24 +10:00
Oliver
5e0e364b6c Merge pull request #1441 from SchrodingersGat/missing-git
Hide git information if there is an error
2021-04-07 22:09:38 +10:00
Oliver
da63ec5351 Merge pull request #1437 from matmair/translation_improv
Translation improvements
2021-04-07 20:57:47 +10:00
Oliver Walters
6412cf1c87 Hide git information if there is an error 2021-04-07 20:55:44 +10:00
eeintech
734985faa9 Made manufacturer part ID dynamic for API supplier part create test 2021-04-06 14:53:35 -04:00
eeintech
52b2b9582d More tests and improved coverage (hopefully) 2021-04-06 14:30:03 -04:00
eeintech
7b4d3a3c07 Added test units for migration and API 2021-04-06 13:16:01 -04:00
Matthias
32eaf48c12 fixed styling 2021-04-06 18:33:57 +02:00
eeintech
76fe535ef9 Improved delete form to show supplier parts deletion 2021-04-06 09:32:59 -04:00
eeintech
bd65a42410 Removed global setting for manufacturer parts (enabled for all users) 2021-04-06 08:49:45 -04:00
eeintech
fd66e8b136 Added MPN link to supplier part list 2021-04-05 15:41:18 -04:00
eeintech
547dcc6e80 Deleted manufacturers tab from supplier detail page 2021-04-05 15:19:34 -04:00
eeintech
58ddc47065 Updated migration files to handle duplicate manufacturer data 2021-04-05 11:21:34 -04:00
Matthias
530b90042a added german(de) translations 2021-04-04 22:51:16 +02:00
Matthias
2c053eae4c added translations 2021-04-04 22:49:47 +02:00
Matthias
ef64d1e61d added label to DatePickerFormField 2021-04-04 22:49:17 +02:00
Matthias
adcb211572 set language in the used js scripts 2021-04-04 22:48:36 +02:00
Matthias
efd14fca64 made translation lazy 2021-04-04 22:47:01 +02:00
Matthias
20c455384e added more translation-strings 2021-04-04 22:44:14 +02:00
Matthias
c68220a597 migrations for all the translated models, totally forgot that 2021-04-03 14:11:28 +02:00
Matthias
cd7724d490 added german(de) translations for the new stuff 2021-04-03 13:48:02 +02:00
Matthias
f67210b20f added translation files for changes 2021-04-03 04:11:40 +02:00
Matthias
1854da380b made filters.js dynamic for translation 2021-04-03 04:07:27 +02:00
Matthias
0547e1c03b added more translations in html / js 2021-04-03 04:05:59 +02:00
Matthias
446bc06c1b switched translation methode to lazy 2021-04-03 04:01:40 +02:00
Matthias
2de6fcbfa4 added missing translation fields #753 2021-04-03 03:59:09 +02:00
Matthias
698b946403 activated translations for settings 2021-04-02 23:03:24 +02:00
eeintech
45ca8d0e93 Fixes to the way ManufacturerPart is saved, manufacturer table filtering and test units 2021-04-02 11:13:57 -04:00
eeintech
bb69e38c1a Simple and View test units 2021-04-01 16:30:06 -04:00
eeintech
94574b37ae Added Manufacturer parts to search, fixed icons, added manufacturer view in supplier part detail page 2021-04-01 10:00:15 -04:00
Oliver Walters
00c4519d28 Simplify dockerfile 2021-04-02 00:54:29 +11:00
Oliver Walters
2436b1f2c9 Entrypoint script - start.sh 2021-04-02 00:40:47 +11:00
eeintech
a8b858c824 Fixed serializer 2021-04-01 09:11:47 -04:00
Oliver Walters
8d3b9e2ca4 Updates to settings.py
- Create secret_key.txt if it does not exist
- Copy default settings file if it does not exist
2021-04-02 00:06:17 +11:00
Oliver Walters
be41be3981 Add "wait_for_db" management command 2021-04-02 00:03:56 +11:00
Oliver Walters
8e7e36089b Fix venv 2021-04-01 21:11:59 +11:00
Oliver Walters
47ba0599eb Reference environment variables in supervisor conf file 2021-04-01 20:44:27 +11:00
Oliver Walters
db858b3cfc Install packages inside venv 2021-04-01 20:44:13 +11:00
Oliver Walters
148600a9c4 Copy gunicorn.conf.py 2021-04-01 20:38:18 +11:00
Oliver Walters
839c29117d Dockerfile updates
- Pipe supervisor logs to stdout (so they are passed to the docker instance)
- Fix supervisor service
- Expose home dir and port as env vars
2021-04-01 20:30:51 +11:00
Oliver Walters
d446f8ddd1 Add supervisor conf file specific to docker 2021-04-01 20:14:31 +11:00
Oliver Walters
08a1a6cf43 Add configuration options for the Dockerfile 2021-04-01 20:14:17 +11:00
Oliver Walters
76ab38a06b Add docker info 2021-04-01 11:35:03 +11:00
eeintech
8c8b25a0d2 Validating API for SupplierPart, not able to spin-off ManufacturerPart from serialized data 2021-03-31 18:04:28 -04:00
Oliver Walters
38b9655ad9 Remove unused workflow 2021-04-01 08:43:58 +11:00
eeintech
9e56bf90c5 More web testing, looks ready 2021-03-31 13:53:55 -04:00
eeintech
2f2e5862a9 Merge branch 'master' of github.com:inventree/InvenTree into manufacturer_part 2021-03-31 13:10:33 -04:00
Oliver Walters
b9e81c3c0e Start supervisord
Ref: https://advancedweb.hu/supervisor-with-docker-lessons-learned/
2021-03-31 23:39:16 +11:00
Oliver Walters
b9f9b26ca5 Sudo not required, I guess? 2021-03-31 23:32:03 +11:00
Oliver Walters
7683cc1aaa APK not APT 2021-03-31 23:27:01 +11:00
Oliver Walters
ff6b127f1b Typo fixin' 2021-03-31 23:22:17 +11:00
Oliver Walters
8b227ce297 More required packages, I guess... 2021-03-31 23:20:32 +11:00
Oliver Walters
286cf9b102 gcc required 2021-03-31 23:12:27 +11:00
Oliver Walters
24d36e0b66 Getting there... 2021-03-31 23:09:24 +11:00
Oliver Walters
251ec7a02f Fix lib names 2021-03-31 23:06:54 +11:00
Oliver Walters
61f8b982ce lib name fix 2021-03-31 23:03:13 +11:00
Oliver Walters
1f881dd041 Run as root 2021-03-31 23:00:22 +11:00
Oliver Walters
42b400e619 typo fix 2021-03-31 22:58:32 +11:00
Oliver Walters
601aff8283 Install git 2021-03-31 22:55:44 +11:00
Oliver Walters
58bfc80f79 Alpine uses different commands 2021-03-31 22:54:17 +11:00
Oliver Walters
2746396d11 Fix tag name 2021-03-31 22:50:41 +11:00
Oliver Walters
6017cad6b3 So apparently I cannot spell... 2021-03-31 22:48:58 +11:00
Oliver Walters
1a7b6e2613 Fix 2021-03-31 22:47:41 +11:00
Oliver Walters
ab57fd3b76 Build docker image 2021-03-31 22:45:42 +11:00
Oliver Walters
c0a0ca4588 PEP fix 2021-03-31 22:35:48 +11:00
Oliver Walters
3f257279ee Specify directories for CI 2021-03-31 22:31:50 +11:00
Oliver Walters
731ec25b24 Merge remote-tracking branch 'inventree/master' into django-q
# Conflicts:
#	.github/workflows/style.yaml
#	.travis.yml
#	InvenTree/InvenTree/settings.py
2021-03-31 22:17:38 +11:00
Oliver
53c9475e6d Update README.md 2021-03-31 22:11:01 +11:00
Oliver
9ccff64679 Update README.md 2021-03-31 22:10:12 +11:00
Oliver
de3395ed26 Update README.md 2021-03-31 22:09:39 +11:00
Oliver
16433f49c6 Merge pull request #1433 from SchrodingersGat/coverage-workflow
Add workflow for code coverage
2021-03-31 22:08:08 +11:00
Oliver Walters
73e032e1d0 Specify database name 2021-03-31 21:54:13 +11:00
Oliver Walters
82b6c48946 Specify database name 2021-03-31 21:48:54 +11:00
Oliver Walters
566c3af39e Environment variables take preference! 2021-03-31 21:40:19 +11:00
Oliver Walters
5d141e3568 Always print database config 2021-03-31 21:24:14 +11:00
Oliver Walters
83cd24961d INFO level debug 2021-03-31 21:18:17 +11:00
Oliver Walters
737a378515 Extra debug output for tests 2021-03-31 21:17:17 +11:00
Oliver Walters
f71ebc20ec Remove travis script 2021-03-31 21:07:16 +11:00
Oliver Walters
ac9753e72c Add data import/export step 2021-03-31 20:58:30 +11:00
Oliver Walters
dc94376f6d Fix workflows 2021-03-31 20:46:26 +11:00
Oliver Walters
c846e2e65a Use env variables rather than custom ci scripts 2021-03-31 20:39:22 +11:00
Oliver Walters
608f47837f Update README.md with badges 2021-03-31 20:26:47 +11:00
Oliver Walters
2f6ee330de Add CI check against MariaDB 2021-03-31 20:20:10 +11:00
Oliver Walters
c66dddc03f Force TCP for postgres 2021-03-31 20:14:57 +11:00
Oliver Walters
48cbd3be97 Remove (old) docs 2021-03-31 20:13:12 +11:00
Oliver Walters
6b99808c52 Run as root 2021-03-31 20:12:45 +11:00
Oliver Walters
61d14a0eda Database naming fix 2021-03-31 20:07:42 +11:00
Oliver Walters
5aea35f8fa Force TCP 2021-03-31 20:04:18 +11:00
Oliver Walters
3cc0530419 Root password 2021-03-31 20:01:02 +11:00
Oliver Walters
cef75aabc5 Update 2021-03-31 19:59:23 +11:00
Oliver Walters
09693d0d09 Start service 2021-03-31 19:54:37 +11:00
Oliver Walters
70703f8588 Try localhost 2021-03-31 19:48:20 +11:00
Oliver Walters
67a4c5a9a2 Try pointing to different host 2021-03-31 19:45:58 +11:00
Oliver Walters
db8d93e2e9 Create mysql database manually 2021-03-31 17:54:42 +11:00
Oliver Walters
af52f0eace Typo fix 2021-03-31 17:45:57 +11:00
Oliver Walters
631e41e22a Fix postgres workflow 2021-03-31 17:40:37 +11:00
Oliver Walters
f8d29b7b3b Typo 2021-03-31 17:36:33 +11:00
Oliver Walters
9e4218d02f Mysql fixes 2021-03-31 17:34:12 +11:00
Oliver Walters
d09483f30c Workflow fixes 2021-03-31 17:28:30 +11:00
Oliver Walters
d1a42f55a2 Fix flake issues 2021-03-31 17:20:12 +11:00
Oliver Walters
bdd5fa96e7 Add tests for mysql and postgresql 2021-03-31 17:18:04 +11:00
Oliver Walters
4f87c848a5 Ensure flake8 fails 2021-03-31 17:17:40 +11:00
Oliver Walters
d20c3bb733 GITHUB_TOKEN 2021-03-31 17:08:24 +11:00
Oliver Walters
ae72224ece Fix coveralls 2021-03-31 16:57:44 +11:00
Oliver Walters
fd43f8dc64 Merge remote-tracking branch 'inventree/master' into coverage-workflow
# Conflicts:
#	.github/workflows/style.yaml
2021-03-31 16:26:02 +11:00
Oliver Walters
6b32142725 run on pull request 2021-03-31 16:24:33 +11:00
Oliver Walters
01e6635032 Add workflow for code coverage 2021-03-31 13:06:22 +11:00
Oliver
88a021f165 Merge pull request #1432 from SchrodingersGat/style-checks
Update style worfdlow
2021-03-31 13:05:59 +11:00
Oliver Walters
b16f85de65 Update style worfdlow 2021-03-31 12:41:39 +11:00
eeintech
0b1f22c7fd Almost there, needs some interface testing and tweaking 2021-03-30 18:08:33 -04:00
eeintech
9c8817d73b Fixed serializers and started update of templates 2021-03-30 16:48:16 -04:00
eeintech
811f9333e8 SupplierPart manufacturer data is not serializing 2021-03-30 13:55:20 -04:00
eeintech
a4d098194b New SupplierPart manufacturer_part field
New migration file with database update to manufacturer parts
Removed SourceItem model
2021-03-30 13:14:30 -04:00
eeintech
e78455085f Merge branch 'master' of github.com:inventree/InvenTree into manufacturer_part 2021-03-30 11:30:47 -04:00
Oliver
865436c42a Merge pull request #1431 from SchrodingersGat/workflows
Add code style workflow
2021-03-30 22:41:27 +11:00
Oliver Walters
db994fd908 Add code style workflow 2021-03-30 21:56:17 +11:00
Oliver Walters
83f8afe113 Add github actions 2021-03-30 21:33:49 +11:00
Oliver Walters
e7ed4c4eab Travis fixes 2021-03-30 21:24:06 +11:00
Oliver Walters
39b2c5f943 Reintroduce default database config 2021-03-30 21:18:09 +11:00
Oliver Walters
3ddbb6a6cd Check for empty values 2021-03-30 20:53:26 +11:00
Oliver
fd01e23245 Merge pull request #1430 from SchrodingersGat/missing-permission-fix
Emit warning rather than raise error
2021-03-30 10:28:54 +11:00
Oliver Walters
1a288168b7 PEP fixes 2021-03-30 10:00:43 +11:00
Oliver
f288b906ad Merge pull request #1426 from SchrodingersGat/assign-by-sn
Assign by sn
2021-03-30 09:29:49 +11:00
Oliver Walters
58c30f48d5 Remove extra whitespace 2021-03-30 09:28:02 +11:00
Oliver Walters
bfbdd72306 Remove unused import 2021-03-30 08:43:09 +11:00
Oliver Walters
0b78f3d931 Add unit testing for migrations 2021-03-30 08:42:44 +11:00
Oliver Walters
32cfe1b954 Emit warning rather than raise error
- All calling functions check for None anyway
2021-03-30 08:25:51 +11:00
Oliver
67f06d6e5d Merge pull request #1428 from inventree/dependabot/pip/pygments-2.7.4
Bump pygments from 2.2.0 to 2.7.4
2021-03-30 08:16:21 +11:00
Oliver Walters
408b9d5e5b Fix for unit testing 2021-03-30 08:08:55 +11:00
eeintech
50adb2ac61 SourceItem only for SupplierPart, added logic to templates 2021-03-29 15:39:25 -04:00
dependabot[bot]
49bb5634da Bump pygments from 2.2.0 to 2.7.4
Bumps [pygments](https://github.com/pygments/pygments) from 2.2.0 to 2.7.4.
- [Release notes](https://github.com/pygments/pygments/releases)
- [Changelog](https://github.com/pygments/pygments/blob/master/CHANGES)
- [Commits](https://github.com/pygments/pygments/compare/2.2.0...2.7.4)

Signed-off-by: dependabot[bot] <support@github.com>
2021-03-29 19:20:11 +00:00
eeintech
e6dfb7da52 Added global setting to enable manufacturer parts
Created SourceItem model
Updated templates
2021-03-29 13:22:15 -04:00
eeintech
cbb887ae79 Merge branch 'master' of github.com:inventree/InvenTree into manufacturer_part 2021-03-29 10:12:20 -04:00
Oliver Walters
709bfb1bd2 Remove "unique" constraint for part / order relationship 2021-03-30 00:14:47 +11:00
Oliver Walters
217097c9d3 Add custom form template 2021-03-30 00:10:28 +11:00
Oliver Walters
19059ea4cf Tweaks 2021-03-29 23:38:38 +11:00
Oliver Walters
d64dd68403 Agk, working out forms is hard 2021-03-29 23:32:41 +11:00
Oliver Walters
bd87f4c733 Adds form to assign stock item by serial numbers 2021-03-29 23:10:36 +11:00
Oliver
19c03e1472 Merge pull request #1424 from SchrodingersGat/notes-css
HTML / CSS fixes
2021-03-29 17:01:23 +11:00
Oliver Walters
cffe2ba84b Add a separate form for creating a sales order allocation 2021-03-29 16:44:01 +11:00
Oliver Walters
7a4b90649a HTML / CSS fixes 2021-03-29 16:36:27 +11:00
Oliver
88c1bc79d7 Merge pull request #1423 from matmair/translation-de
german translations
2021-03-29 09:12:45 +11:00
Matthias
a3ab70b05d finished german translation, small corrections 2021-03-28 18:09:35 +02:00
Matthias
c558a04162 updated translation reference 2021-03-28 18:08:07 +02:00
Oliver
1ffd3a0070 Merge pull request #1409 from eeintech/fix_stock_ops
Template fix for stock actions
2021-03-28 20:42:14 +11:00
eeintech
e0a0bfdadb Fix style 2021-03-25 15:39:16 -04:00
eeintech
c85d737446 Fix CI 2021-03-25 15:04:48 -04:00
Oliver
5a5e76e0a6 Merge pull request #1414 from inventree/dependabot/pip/djangorestframework-3.11.2
Bump djangorestframework from 3.10.3 to 3.11.2
2021-03-25 17:29:43 +11:00
eeintech
fd4f33d45b More templates updates 2021-03-24 12:39:26 -04:00
eeintech
4abd8587ab Updated company templates 2021-03-24 12:08:45 -04:00
eeintech
afd2dacfc7 Can now create, view list of parts and view detail page 2021-03-24 11:44:51 -04:00
Oliver Walters
3a0c68bf5c Add invoke task to start background worker 2021-03-24 22:42:04 +11:00
Oliver Walters
df0ab2359f Remove invoke tasks which perform system commands
- tasks.py is now for InvenTree specific tasks only
2021-03-24 22:24:47 +11:00
eeintech
e28dde7f7b Fixed navbar, added missing template and urls 2021-03-23 17:45:03 -04:00
eeintech
08ffbee8ed Fixed model name and added to part navbar 2021-03-23 17:33:29 -04:00
Oliver Walters
ce64feb79d Update supervisor conf file 2021-03-24 08:32:00 +11:00
Oliver Walters
e3f49b8996 Install invoke and gunicorn as part of requirements.txt 2021-03-24 08:31:53 +11:00
eeintech
8f610d826f Added URLs and templates 2021-03-23 17:16:29 -04:00
eeintech
e897864396 Added ManufacturerPart model, form and views 2021-03-23 17:01:54 -04:00
Oliver Walters
edbbfff1af Reduce frequency of heartbeat 2021-03-23 19:58:29 +11:00
Oliver Walters
8fd666e662 Improvements for "check for updates" task
- Let it throw an error if something fails
- Errors are caught as "unsuccessful tasks"
2021-03-22 11:20:09 +11:00
dependabot[bot]
f25c83226f Bump djangorestframework from 3.10.3 to 3.11.2
Bumps [djangorestframework](https://github.com/encode/django-rest-framework) from 3.10.3 to 3.11.2.
- [Release notes](https://github.com/encode/django-rest-framework/releases)
- [Commits](https://github.com/encode/django-rest-framework/compare/3.10.3...3.11.2)

Signed-off-by: dependabot[bot] <support@github.com>
2021-03-19 22:23:09 +00:00
Oliver Walters
b7718d9c6c Specify user and logfile 2021-03-19 22:08:11 +11:00
Oliver Walters
283663633a First pass at a supervisor.conf file 2021-03-19 21:52:36 +11:00
Oliver
c9464fd393 Merge pull request #1412 from SchrodingersGat/pillow-version
Bump pillow version
2021-03-19 11:25:10 +11:00
Oliver Walters
84aea1d587 Bump pillow version 2021-03-19 10:59:28 +11:00
Oliver
57289fe141 Merge pull request #1410 from SchrodingersGat/image-downloader
Image downloader
2021-03-18 11:10:07 +11:00
Oliver Walters
9c91ba4692 Add image download functionality for company 2021-03-18 09:20:24 +11:00
Oliver Walters
4e7243b999 Add modal image overlay for company 2021-03-17 23:55:21 +11:00
Oliver Walters
e3a5a56371 Add "modal image" display for part thumbnails 2021-03-17 23:44:47 +11:00
Oliver Walters
db47629867 Cleanup 2021-03-17 23:15:48 +11:00
Oliver Walters
8b310d8e47 Check length of response 2021-03-17 23:11:38 +11:00
Oliver Walters
be30933bfa Add custom form template 2021-03-17 23:06:56 +11:00
Oliver Walters
9a710ca28f Fix image download code 2021-03-17 23:02:32 +11:00
Oliver Walters
47a1143570 Catch error when generating company thumbnail images 2021-03-17 11:55:51 +11:00
Oliver Walters
5f19f534fc Catch error if invalid image is uploaded 2021-03-17 09:47:57 +11:00
Oliver
e2e870858d Merge pull request #1400 from eeintech/ipn_edit_setting
Add setting to disable IPN editing after part is created (web only)
2021-03-17 09:47:39 +11:00
Oliver Walters
15678f789c Add global setting to enable download of files / images from remote URL 2021-03-17 08:40:30 +11:00
Oliver Walters
45edb7e802 Add button 2021-03-17 08:28:38 +11:00
Oliver Walters
5b7d35e6f7 add View 2021-03-17 08:28:28 +11:00
Oliver Walters
3900f9b1b6 Add form for submitting image URL 2021-03-17 08:28:12 +11:00
eeintech
8619af9f09 Template fix for stock actions 2021-03-16 12:32:56 -04:00
eeintech
85474516a3 Merge branch 'master' of github.com:inventree/InvenTree into ipn_edit_setting 2021-03-16 10:23:27 -04:00
Oliver Walters
6946abae13 CSS fix for modal error info dialog 2021-03-16 16:42:33 +11:00
Oliver
5e48009241 Merge pull request #1407 from eeintech/bom_match_headers
Split required and part match headers for BOM import
2021-03-16 08:19:32 +11:00
eeintech
64a57128bc Return True for BOM valid flag if part does not have BOM items 2021-03-15 10:37:30 -04:00
eeintech
d39bd88440 Split required and part match headers for BOM import 2021-03-15 09:41:04 -04:00
eeintech
7b81a470b9 Merge branch 'master' of github.com:inventree/InvenTree into ipn_edit_setting 2021-03-15 08:50:31 -04:00
Oliver
247cbe0154 Merge pull request #1406 from SchrodingersGat/part-pricing-form-error
Add option to hide form error message
2021-03-15 21:01:19 +11:00
Oliver Walters
2de879d2ba Add option to hide form error message 2021-03-15 20:30:18 +11:00
Oliver
b17a50bd51 Merge pull request #1405 from SchrodingersGat/css-tweak
Small tweak for breadcrumb div css
2021-03-15 17:52:14 +11:00
Oliver Walters
9918860820 Small tweak for breadcrumb div css 2021-03-15 17:00:14 +11:00
Oliver Walters
c6e154f996 PEP style fixes 2021-03-15 10:15:48 +11:00
Oliver Walters
c1aed51de1 Fix import error 2021-03-15 09:34:32 +11:00
Oliver Walters
24823adc6d Adds unit tests for version number comparison 2021-03-15 08:51:50 +11:00
Oliver Walters
6ea846ce45 Add a #TODO 2021-03-15 08:36:27 +11:00
Oliver Walters
f6dd710d6e Automatically delete old heartbeat messages 2021-03-15 08:35:06 +11:00
Oliver Walters
de85d61451 Directly compare version tuples, rather than converting to primitive 2021-03-15 08:31:19 +11:00
Oliver
5ff18a0a3a Merge pull request #1403 from matmair/translations-de
unified translation scheme
2021-03-15 08:27:24 +11:00
Oliver
82faccc62f Merge pull request #1401 from eeintech/stock_filter_assembly
Stock filter for parts assemblies
2021-03-13 07:48:15 +11:00
Oliver
59e98bc22d Merge pull request #1402 from eeintech/fix_typo
Fixed build typo
2021-03-13 07:46:11 +11:00
eeintech
acb0b2c10a Fixed build typo 2021-03-12 11:46:56 -05:00
eeintech
429f9d0a13 Removed test print 2021-03-12 11:19:20 -05:00
eeintech
89c7c87f1e Add stock filter for parts assemblies 2021-03-12 11:18:19 -05:00
eeintech
b152f7041b Add setting to disable IPN editing after part is created (web only) 2021-03-12 10:30:31 -05:00
Oliver Walters
700effcee7 Remove celery reference 2021-03-12 16:57:27 +11:00
Oliver Walters
18b559fee7 Fix for unit test 2021-03-12 16:28:54 +11:00
Oliver Walters
9d404afec0 Add 'ignore' rules for the django-q tables 2021-03-12 16:00:25 +11:00
Oliver Walters
51616c8aca Merge remote-tracking branch 'upstream/master' into django-q 2021-03-12 15:47:03 +11:00
Oliver Walters
ef4dbda223 Catch errors if the DB is not up 2021-03-12 15:35:55 +11:00
Oliver Walters
006dd10a79 Delete successful tasks more than a month old 2021-03-12 15:35:33 +11:00
Oliver Walters
5b8eb1c530 Newline 2021-03-12 15:27:53 +11:00
Oliver Walters
bfb0cb3b47 Add a "heartbeat" task which runs every 5 minutes
- Allows us to track if the worker is running
- Due to Stat.get_all() not always working
2021-03-12 15:27:28 +11:00
Oliver
ed028aed62 Merge pull request #1397 from SchrodingersGat/order-report
Order report
2021-03-12 14:44:10 +11:00
Oliver Walters
c07f217416 Add "ignore" rules for new report models 2021-03-12 14:01:20 +11:00
Matthias
47c98db8a1 unified translation scheme 2021-03-11 12:44:28 +01:00
Oliver Walters
4925f24ca9 Add "up to date" info to the "about" window 2021-03-11 20:07:59 +11:00
Oliver Walters
18defcff16 Read version number from GitHub 2021-03-11 19:56:22 +11:00
Oliver Walters
3cf5aec289 Refactor 2021-03-11 19:21:28 +11:00
Oliver Walters
1532a0c3a1 Add InvenTree/apps.py 2021-03-11 17:18:57 +11:00
Oliver Walters
5949ccd74f Bug fix 2021-03-11 17:11:57 +11:00
Oliver Walters
f1ba20c3da Basic PO and SO reports 2021-03-11 15:01:25 +11:00
Oliver Walters
eb6310c774 Render company image to report 2021-03-11 15:01:15 +11:00
Oliver Walters
9d321f4833 Removed 2021-03-11 14:47:45 +11:00
Oliver Walters
e1ba0a9a99 Bug fix for tables 2021-03-11 14:24:28 +11:00
Oliver Walters
8e2a2c59bf Add more context data to reports 2021-03-11 14:19:25 +11:00
Oliver Walters
7ccd339b5c Print reports for multiple selected sales orders / purchase orders 2021-03-11 14:15:31 +11:00
Oliver Walters
fa95759a00 Enable printing for PO and SO 2021-03-11 14:09:57 +11:00
Oliver
23e19614a5 Merge pull request #1394 from mosenturm/translation_de
further translation de
2021-03-10 20:35:50 +11:00
Andreas Kaiser
3897166185 fix typo 2021-03-10 09:48:54 +01:00
Oliver Walters
7800664f4b Add printing endpoints 2021-03-10 18:29:22 +11:00
Oliver
448c3cc6f5 Merge pull request #1395 from SchrodingersGat/responsible-user
Responsible user
2021-03-10 18:27:46 +11:00
Oliver Walters
5a6a12604e Add detail endpoints 2021-03-10 17:13:19 +11:00
Oliver Walters
33e176e4e7 Add list view API endpoints 2021-03-10 17:09:37 +11:00
Oliver Walters
9b0595d232 Add serializers 2021-03-10 16:53:02 +11:00
Oliver Walters
7f05485954 Add new reports to the admin interface 2021-03-10 16:50:55 +11:00
Oliver Walters
727fd38978 Add new report models 2021-03-10 16:48:20 +11:00
Oliver Walters
d559d92f58 Display responsible owner for salesorder and purchaseorder 2021-03-10 16:26:20 +11:00
Oliver Walters
39d44ce32f Add "responsible" field to PO and SO models 2021-03-10 16:19:44 +11:00
Oliver Walters
5b68d82fa3 Skeleton for background tasks 2021-03-10 14:03:19 +11:00
Oliver Walters
660fed9196 Remove unused code from settings.py 2021-03-10 14:03:09 +11:00
Andreas Kaiser
6f63b43c1c Merge branch 'master' into translation_de 2021-03-09 11:14:07 +01:00
Andreas Kaiser
ca626ead6c german translation 2021-03-09 00:45:37 +01:00
Oliver
fb096bd65b Merge pull request #1393 from matmair/german-translations
updated german translations
2021-03-09 10:39:39 +11:00
Matthias
a00756ec3a added all obvious translations 2021-03-08 23:50:24 +01:00
Andreas Kaiser
af0c72d338 german translation 2021-03-08 17:45:22 +01:00
Andreas Kaiser
5ae5b9c0d4 german translation 2021-03-08 16:09:36 +01:00
Andreas Kaiser
48cd227f06 german translation, HTML tags refactored 2021-03-06 21:52:57 +01:00
Andreas Kaiser
ae3a0133eb Merge branch 'master' into translation_de 2021-03-06 13:50:39 +01:00
Oliver Walters
45b3c68930 New status info 2021-03-06 21:41:19 +11:00
Oliver Walters
7bec3ff5dd django-q 2021-03-06 20:58:57 +11:00
Oliver
9ea3193ffb Merge pull request #1391 from SchrodingersGat/order-parts-fix
Hacky fix for ordering parts form
2021-03-06 20:20:30 +11:00
Oliver Walters
8061669c70 Hacky fix for ordering parts form 2021-03-06 19:49:49 +11:00
Andreas Kaiser
ade1d36397 updated german translation, change tags bold italics 2021-03-05 01:03:08 +01:00
Oliver
a4257ad9df Update version.py 2021-03-04 22:52:40 +11:00
219 changed files with 13944 additions and 6914 deletions

5
.gitattributes vendored
View File

@@ -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
View 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
View 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
View 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
View 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
View 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
View 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

View File

@@ -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

View File

@@ -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)

View 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
)

View File

@@ -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'
}

View File

@@ -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': '',
}

View File

@@ -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):
"""

View File

@@ -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
)

View File

@@ -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:

View File

@@ -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

View 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!")

View File

@@ -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()

View File

@@ -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

View File

@@ -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",

View File

@@ -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;
}

View File

@@ -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

View File

@@ -1,4 +1,4 @@
from django.utils.translation import ugettext as _
from django.utils.translation import ugettext_lazy as _
class StatusCode:

View 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,
)

View 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)

View File

@@ -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)

View File

@@ -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'),

View File

@@ -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):

View File

@@ -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

View File

@@ -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

View File

@@ -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):

View File

@@ -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())

View 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'),
),
]

View File

@@ -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,

View File

@@ -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" %}',
}
);
});

View File

@@ -130,6 +130,7 @@ InvenTree | {% trans "Build Orders" %}
initialView: 'dayGridMonth',
nowIndicator: true,
aspectRatio: 2.5,
locale: '{{request.LANGUAGE_CODE}}',
datesSet: function() {
loadOrderEvents(calendar);
}

View File

@@ -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 %}

View File

@@ -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

View File

@@ -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:

View File

@@ -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

View File

@@ -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'),

View File

@@ -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

View File

@@ -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

View 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'

View File

@@ -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 """

View 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'),
),
]

View 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'),
),
]

View 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')},
},
),
]

View 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'),
),
]

View 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),
]

View 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',
),
]

View File

@@ -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")

View File

@@ -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 """

View File

@@ -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 %}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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 %}

View File

@@ -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,
},

View 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 %}

View File

@@ -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 %}

View File

@@ -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 %}

View File

@@ -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 %}

View File

@@ -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>

View File

@@ -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 %}

View File

@@ -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>

View File

@@ -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 %}

View File

@@ -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 %}"
}
);
});

View File

@@ -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 %}

View File

@@ -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>

View File

@@ -1,4 +1,5 @@
{% load i18n %}
{% load inventree_extras %}
<ul class='list-group'>

View File

@@ -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>

View File

@@ -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 %}

View File

@@ -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'],

View File

@@ -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')

View File

@@ -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

View File

@@ -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())

View File

@@ -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)

View File

@@ -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'),

View File

@@ -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'

View File

@@ -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:

View File

@@ -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

View File

@@ -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):

View File

@@ -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()
}

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

View File

@@ -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

View 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'),
),
]

View 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(),
),
]

View 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'),
),
]

View File

@@ -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

View File

@@ -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 %}",
{

View File

@@ -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 %}

View File

@@ -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 %}

View File

@@ -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 %}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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,

View File

@@ -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