mirror of
https://github.com/inventree/InvenTree.git
synced 2025-12-18 12:56:31 -06:00
Compare commits
381 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7c09c20725 | ||
|
|
cffb921fb1 | ||
|
|
2d3e7e35af | ||
|
|
8b61acb048 | ||
|
|
0cfb293ca9 | ||
|
|
afa31b3415 | ||
|
|
d505e79be8 | ||
|
|
aee0970e49 | ||
|
|
7ee94f3574 | ||
|
|
b25df586cd | ||
|
|
92f5648656 | ||
|
|
ccb637773f | ||
|
|
d4da6211be | ||
|
|
fb94949538 | ||
|
|
f5150f549a | ||
|
|
6e65a736e7 | ||
|
|
d17056820b | ||
|
|
7e8664a4dd | ||
|
|
40822a93df | ||
|
|
67a73c1fbf | ||
|
|
9d19029ba9 | ||
|
|
c31b72bde2 | ||
|
|
6919eaa1e1 | ||
|
|
124967ed31 | ||
|
|
570010b99c | ||
|
|
4c96b34c7c | ||
|
|
f07f3b99cf | ||
|
|
cdc4c5d6d5 | ||
|
|
34c097c46a | ||
|
|
e28fe6df6a | ||
|
|
0dc6d9d37e | ||
|
|
5aec3df7c9 | ||
|
|
06f28898a0 | ||
|
|
e8e0ab8416 | ||
|
|
00eada2c3c | ||
|
|
c0650ba7f4 | ||
|
|
713d7960a8 | ||
|
|
6a78f6d451 | ||
|
|
41bbbdcd43 | ||
|
|
57123283f4 | ||
|
|
864a21ac85 | ||
|
|
99efbd4c40 | ||
|
|
dae45875fb | ||
|
|
d0f71ea6de | ||
|
|
b162c97226 | ||
|
|
c6f069028c | ||
|
|
b6bc5e3bff | ||
|
|
25caec4c53 | ||
|
|
7d095213a5 | ||
|
|
672119fb92 | ||
|
|
7f269898c4 | ||
|
|
82be9db3df | ||
|
|
90aa205057 | ||
|
|
4a259dc146 | ||
|
|
5af2fae120 | ||
|
|
834f80698b | ||
|
|
56a6943438 | ||
|
|
cc41752f9f | ||
|
|
a661d7e1a6 | ||
|
|
3b6ed585ab | ||
|
|
01f1ac49e3 | ||
|
|
5207b2ba21 | ||
|
|
6fd0380196 | ||
|
|
15bc457714 | ||
|
|
3fd0cf67b6 | ||
|
|
daa8496157 | ||
|
|
1259cea2c3 | ||
|
|
6731bc1b06 | ||
|
|
d51ac2f5c2 | ||
|
|
a147ce4284 | ||
|
|
8186e4bab0 | ||
|
|
2b08b0f2b9 | ||
|
|
febd1ad4a7 | ||
|
|
8eaaf62eda | ||
|
|
b2cb41f879 | ||
|
|
c04aa1bff7 | ||
|
|
3b9f57fc80 | ||
|
|
33ffa2f75f | ||
|
|
1fa4e7f5fb | ||
|
|
4a2fa36e30 | ||
|
|
6c415bc922 | ||
|
|
068c237c6e | ||
|
|
826102e10e | ||
|
|
066d69215f | ||
|
|
49118d8083 | ||
|
|
49d5573f8b | ||
|
|
9e456f5a11 | ||
|
|
0f4d60dceb | ||
|
|
23aebab6d0 | ||
|
|
e483b42df6 | ||
|
|
3715c5d637 | ||
|
|
ae4ebab957 | ||
|
|
4f266958e3 | ||
|
|
750dfcda07 | ||
|
|
0071a29af7 | ||
|
|
64c567474a | ||
|
|
8a42d9f2fa | ||
|
|
5261e96c8a | ||
|
|
f03f6c4386 | ||
|
|
0a9f2b37cf | ||
|
|
7dcc94b106 | ||
|
|
33d21594da | ||
|
|
6d80788618 | ||
|
|
8dd8505a2c | ||
|
|
05f7b30ab0 | ||
|
|
d9d21395d9 | ||
|
|
63b70614b6 | ||
|
|
34c3320cd5 | ||
|
|
50fee1bfe5 | ||
|
|
28e9d842bf | ||
|
|
d04fb0d826 | ||
|
|
520b8d7b2b | ||
|
|
c287a0a0b9 | ||
|
|
265ed5115a | ||
|
|
53d1040875 | ||
|
|
5ae7ca71d7 | ||
|
|
564635c368 | ||
|
|
31d6c77143 | ||
|
|
882bda46b4 | ||
|
|
f865573e48 | ||
|
|
434d084371 | ||
|
|
eecc435c02 | ||
|
|
55aa63dab4 | ||
|
|
77c950a729 | ||
|
|
dee47bdea8 | ||
|
|
8ea1086b03 | ||
|
|
e0e996a6c3 | ||
|
|
d4fe83170f | ||
|
|
534b60d4b8 | ||
|
|
725eb3c538 | ||
|
|
17c10da10e | ||
|
|
a82e219336 | ||
|
|
1327c1d3b1 | ||
|
|
356624a8fb | ||
|
|
66c1a2ef57 | ||
|
|
cb29ff14e0 | ||
|
|
3e977834c5 | ||
|
|
4bd4f2a0a3 | ||
|
|
2949289fab | ||
|
|
0939ffeb76 | ||
|
|
41336bd549 | ||
|
|
d059aff4f8 | ||
|
|
9cef038d6a | ||
|
|
356b6cf15b | ||
|
|
4b8e44bc4a | ||
|
|
ef7fca5633 | ||
|
|
244d364575 | ||
|
|
aa210efad6 | ||
|
|
91ca37c84b | ||
|
|
d62d4c9355 | ||
|
|
d0a7a24649 | ||
|
|
5264f816f1 | ||
|
|
2a7bf94793 | ||
|
|
868e005445 | ||
|
|
d827070585 | ||
|
|
e379b44606 | ||
|
|
d71fd1aad4 | ||
|
|
38be1fc696 | ||
|
|
a022b8223e | ||
|
|
23c0d68330 | ||
|
|
1a32e441b7 | ||
|
|
20273f1541 | ||
|
|
f88f5a39f8 | ||
|
|
1bdcbd1974 | ||
|
|
908e2ef8bc | ||
|
|
b46151b406 | ||
|
|
0f92468462 | ||
|
|
7ec194a14a | ||
|
|
a7846940c4 | ||
|
|
51fab36074 | ||
|
|
ca9f9e047c | ||
|
|
1f71a93d88 | ||
|
|
88ec40e454 | ||
|
|
f0933f216c | ||
|
|
919662054c | ||
|
|
b9dda51378 | ||
|
|
3c3ae43c18 | ||
|
|
c546ed5dcd | ||
|
|
da01177d23 | ||
|
|
aa2f63830e | ||
|
|
596d06cf1a | ||
|
|
b5f8635794 | ||
|
|
442c6e8b27 | ||
|
|
8275271ea0 | ||
|
|
bd653f2c49 | ||
|
|
561cc7a1dd | ||
|
|
a064ce13fc | ||
|
|
e103bd8880 | ||
|
|
3def3e1e89 | ||
|
|
61897cb0fc | ||
|
|
067d2be1f0 | ||
|
|
3098f6045f | ||
|
|
740d7678d7 | ||
|
|
b3ec748123 | ||
|
|
d2d5909701 | ||
|
|
43d47686c5 | ||
|
|
dae74a19d3 | ||
|
|
84f8bab418 | ||
|
|
720485709b | ||
|
|
fa480c0558 | ||
|
|
17c048e8dd | ||
|
|
a0534dafec | ||
|
|
89e60d28b0 | ||
|
|
6cd3b3176c | ||
|
|
fafd0397bc | ||
|
|
71c1faf9ff | ||
|
|
a257f94ac0 | ||
|
|
25e5a64cee | ||
|
|
5e9b012031 | ||
|
|
0ef033f800 | ||
|
|
1a6f06cceb | ||
|
|
d5d4cbaec1 | ||
|
|
ef37eada2f | ||
|
|
000229fcbb | ||
|
|
ee063f7508 | ||
|
|
f648433e53 | ||
|
|
bd2a2b5b26 | ||
|
|
9946fbda17 | ||
|
|
bacdb7776b | ||
|
|
6afb657acb | ||
|
|
98374ca466 | ||
|
|
173e1311d4 | ||
|
|
2152cb14b4 | ||
|
|
7f2804dff3 | ||
|
|
3f172cb065 | ||
|
|
cbdea9f18c | ||
|
|
4b599b7cdb | ||
|
|
7e6c5fae62 | ||
|
|
8b2f1b9313 | ||
|
|
64db28be67 | ||
|
|
381becef79 | ||
|
|
a1f33c4084 | ||
|
|
dbdbe69f7f | ||
|
|
9da8189899 | ||
|
|
e4bfe43c04 | ||
|
|
003a2d9f3c | ||
|
|
0ea8ade26c | ||
|
|
75774771dc | ||
|
|
20755a6dac | ||
|
|
4e1b9efe93 | ||
|
|
6e90ac367e | ||
|
|
400941c10f | ||
|
|
81a226c760 | ||
|
|
5ffbfe8eb8 | ||
|
|
39f9aa6141 | ||
|
|
16f3dfb678 | ||
|
|
b7473be8ef | ||
|
|
2261973331 | ||
|
|
789515e39d | ||
|
|
0effb584b9 | ||
|
|
339126b27a | ||
|
|
56255a98d8 | ||
|
|
d66c20ca94 | ||
|
|
ce6f54aeaa | ||
|
|
b92b7dc825 | ||
|
|
1887463f7f | ||
|
|
54855da522 | ||
|
|
d1c7877713 | ||
|
|
be96a2f7e3 | ||
|
|
871b853b9f | ||
|
|
f49130862e | ||
|
|
ab25a199ce | ||
|
|
bed2cec5e7 | ||
|
|
b870728125 | ||
|
|
225ad0ffa6 | ||
|
|
427f47310b | ||
|
|
3e2a5263a5 | ||
|
|
b876d31d09 | ||
|
|
30de734dfa | ||
|
|
f2eb66d854 | ||
|
|
fcba00bc69 | ||
|
|
b56a1ade24 | ||
|
|
fcb47fce09 | ||
|
|
eaf910d263 | ||
|
|
9be528a3fb | ||
|
|
40acf90efe | ||
|
|
0ae2fd9246 | ||
|
|
37e9bd8d9b | ||
|
|
3cc79d6def | ||
|
|
0e0405f337 | ||
|
|
fb75617807 | ||
|
|
d247ea7589 | ||
|
|
7c1615a2b6 | ||
|
|
41c07fc423 | ||
|
|
52ec213a28 | ||
|
|
cc1e580538 | ||
|
|
b1380687e6 | ||
|
|
8d92960f10 | ||
|
|
0d68dbcfa7 | ||
|
|
21e369e6cc | ||
|
|
2a31820abe | ||
|
|
ae2e2f36e4 | ||
|
|
2046c12600 | ||
|
|
df41fafefb | ||
|
|
8eaff6a353 | ||
|
|
fcbf0e6e93 | ||
|
|
6f54091354 | ||
|
|
5a9e5dea20 | ||
|
|
508a3fc35c | ||
|
|
b3ea2bfb9a | ||
|
|
3c98cd87a7 | ||
|
|
cf2abb4130 | ||
|
|
45a321694b | ||
|
|
8a995cc193 | ||
|
|
600eac7f1d | ||
|
|
a77fd23fcf | ||
|
|
cb77506111 | ||
|
|
c5a82f4b6e | ||
|
|
4a0be0dfb8 | ||
|
|
7c328969c9 | ||
|
|
774872e6a6 | ||
|
|
08f958dd72 | ||
|
|
94cd28ecb9 | ||
|
|
b5b7dc0fbf | ||
|
|
f90aa1d2cf | ||
|
|
1cffd41c07 | ||
|
|
d40fc59616 | ||
|
|
a9d1cadc12 | ||
|
|
55ebf48684 | ||
|
|
628a58e8fc | ||
|
|
fedbb834ee | ||
|
|
b46d1c2286 | ||
|
|
5107cf5694 | ||
|
|
9af9158c10 | ||
|
|
19522dfb92 | ||
|
|
b9155bbde9 | ||
|
|
2986e995d1 | ||
|
|
e781202daa | ||
|
|
56dda5eff4 | ||
|
|
180df8f110 | ||
|
|
4746a3ccff | ||
|
|
2c1a744c2d | ||
|
|
098cd0ec44 | ||
|
|
02e71bd2ce | ||
|
|
3e33326120 | ||
|
|
8d4e2ce498 | ||
|
|
ee6c922fad | ||
|
|
194ae49914 | ||
|
|
7f5aba423a | ||
|
|
db04f399c1 | ||
|
|
ed20e9d4a1 | ||
|
|
204cd967aa | ||
|
|
03043e67c7 | ||
|
|
2d17f957f1 | ||
|
|
2bc97764c7 | ||
|
|
9c84e9076f | ||
|
|
66e439a836 | ||
|
|
ce099f43f3 | ||
|
|
fa789036e0 | ||
|
|
70e07470db | ||
|
|
4ac8353099 | ||
|
|
ee17d5d3c3 | ||
|
|
0846daf1f6 | ||
|
|
8578a3b8d1 | ||
|
|
9b1d0bee3b | ||
|
|
28d49bdd47 | ||
|
|
6a19e94feb | ||
|
|
52eeffc2c4 | ||
|
|
f707dd3430 | ||
|
|
cb5db332d3 | ||
|
|
23b814569a | ||
|
|
37ab3d214d | ||
|
|
c579854e89 | ||
|
|
2bc34853e2 | ||
|
|
c469e48f26 | ||
|
|
bacd70687d | ||
|
|
89acc778f5 | ||
|
|
ac36048230 | ||
|
|
8a68313e5e | ||
|
|
9e1f56cdb8 | ||
|
|
7e9c095edb | ||
|
|
588713467d | ||
|
|
03a42fa360 | ||
|
|
c8be9cb90c | ||
|
|
36ec5e41b0 | ||
|
|
59f102af3c | ||
|
|
6854190ff9 | ||
|
|
d515e2d968 | ||
|
|
7c6901f445 | ||
|
|
5672c37c9f | ||
|
|
567826165c |
6
.gitignore
vendored
6
.gitignore
vendored
@@ -9,6 +9,10 @@ env/
|
||||
./build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
bin/
|
||||
lib64
|
||||
pyvenv.cfg
|
||||
share/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
@@ -36,6 +40,8 @@ InvenTree/media
|
||||
InvenTree/static
|
||||
media
|
||||
static
|
||||
inventree_media
|
||||
inventree_static
|
||||
|
||||
# Local config file
|
||||
config.yaml
|
||||
|
||||
@@ -7,16 +7,20 @@ python:
|
||||
|
||||
addons:
|
||||
apt-packages:
|
||||
-sqlite3
|
||||
- sqlite3
|
||||
|
||||
before_install:
|
||||
- sudo apt-get update
|
||||
- sudo apt-get install gettext
|
||||
- make install
|
||||
- make migrate
|
||||
- cd InvenTree && python3 manage.py createsuperuser --username InvenTreeAdmin --email admin@inventree.com --noinput && cd ..
|
||||
|
||||
script:
|
||||
- git ls-files --exclude-standard --others
|
||||
- cd InvenTree && python3 manage.py makemigrations && cd ..
|
||||
- python3 ci/check_migration_files.py
|
||||
- make coverage
|
||||
- make translate
|
||||
- make style
|
||||
|
||||
after_success:
|
||||
|
||||
@@ -4,6 +4,18 @@ Contributions to InvenTree are welcomed - please follow the guidelines below.
|
||||
|
||||
No pushing to master! New featues must be submitted in a separate branch (one branch per feature).
|
||||
|
||||
## Include Migration Files
|
||||
|
||||
Any required migration files **must** be included in the commit, or the pull-request will be rejected. If you change the underlying database schema, make sure you run `make migrate` and commit the migration files before submitting the PR.
|
||||
|
||||
## Update Translation Files
|
||||
|
||||
Any PRs which update translatable strings (i.e. text strings that will appear in the web-front UI) must also update the translation (locale) files to include hooks for the translated strings.
|
||||
|
||||
*This does not mean that all translations must be provided, but that the translation files must include locations for the translated strings to be written.*
|
||||
|
||||
To perform this step, simply run `make_translate` from the top level directory before submitting the PR.
|
||||
|
||||
## Testing
|
||||
|
||||
Any new code should be covered by unit tests - a submitted PR may not be accepted if the code coverage is decreased.
|
||||
|
||||
71
InvenTree/InvenTree/fields.py
Normal file
71
InvenTree/InvenTree/fields.py
Normal file
@@ -0,0 +1,71 @@
|
||||
""" Custom fields used in InvenTree """
|
||||
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from .validators import allowable_url_schemes
|
||||
|
||||
from django.forms.fields import URLField as FormURLField
|
||||
from django.db import models as models
|
||||
from django.core import validators
|
||||
from django import forms
|
||||
from decimal import Decimal
|
||||
|
||||
|
||||
class InvenTreeURLFormField(FormURLField):
|
||||
""" Custom URL form field with custom scheme validators """
|
||||
|
||||
default_validators = [validators.URLValidator(schemes=allowable_url_schemes())]
|
||||
|
||||
|
||||
class InvenTreeURLField(models.URLField):
|
||||
""" Custom URL field which has custom scheme validators """
|
||||
|
||||
default_validators = [validators.URLValidator(schemes=allowable_url_schemes())]
|
||||
|
||||
def formfield(self, **kwargs):
|
||||
return super().formfield(**{
|
||||
'form_class': InvenTreeURLFormField
|
||||
})
|
||||
|
||||
|
||||
def round_decimal(value, places):
|
||||
"""
|
||||
Round value to the specified number of places.
|
||||
"""
|
||||
|
||||
if value is not None:
|
||||
# see https://docs.python.org/2/library/decimal.html#decimal.Decimal.quantize for options
|
||||
return value.quantize(Decimal(10) ** -places)
|
||||
return value
|
||||
|
||||
|
||||
class RoundingDecimalFormField(forms.DecimalField):
|
||||
def to_python(self, value):
|
||||
value = super(RoundingDecimalFormField, self).to_python(value)
|
||||
value = round_decimal(value, self.decimal_places)
|
||||
return value
|
||||
|
||||
def prepare_value(self, value):
|
||||
"""
|
||||
Override the 'prepare_value' method, to remove trailing zeros when displaying.
|
||||
Why? It looks nice!
|
||||
"""
|
||||
|
||||
if type(value) == Decimal:
|
||||
return value.normalize()
|
||||
else:
|
||||
return value
|
||||
|
||||
|
||||
class RoundingDecimalField(models.DecimalField):
|
||||
def to_python(self, value):
|
||||
value = super(RoundingDecimalField, self).to_python(value)
|
||||
return round_decimal(value, self.decimal_places)
|
||||
|
||||
def formfield(self, **kwargs):
|
||||
defaults = {
|
||||
'form_class': RoundingDecimalFormField
|
||||
}
|
||||
defaults.update(kwargs)
|
||||
return super(RoundingDecimalField, self).formfield(**kwargs)
|
||||
@@ -43,6 +43,7 @@ class EditUserForm(HelperForm):
|
||||
class Meta:
|
||||
model = User
|
||||
fields = [
|
||||
'username',
|
||||
'first_name',
|
||||
'last_name',
|
||||
'email'
|
||||
|
||||
@@ -52,6 +52,50 @@ def str2bool(text, test=True):
|
||||
return str(text).lower() in ['0', 'n', 'no', 'none', 'f', 'false', 'off', ]
|
||||
|
||||
|
||||
def isNull(text):
|
||||
"""
|
||||
Test if a string 'looks' like a null value.
|
||||
This is useful for querying the API against a null key.
|
||||
|
||||
Args:
|
||||
text: Input text
|
||||
|
||||
Returns:
|
||||
True if the text looks like a null value
|
||||
"""
|
||||
|
||||
return str(text).strip().lower() in ['top', 'null', 'none', 'empty', 'false', '-1']
|
||||
|
||||
|
||||
def decimal2string(d):
|
||||
"""
|
||||
Format a Decimal number as a string,
|
||||
stripping out any trailing zeroes or decimal points.
|
||||
Essentially make it look like a whole number if it is one.
|
||||
|
||||
Args:
|
||||
d: A python Decimal object
|
||||
|
||||
Returns:
|
||||
A string representation of the input number
|
||||
"""
|
||||
|
||||
try:
|
||||
# Ensure that the provided string can actually be converted to a float
|
||||
float(d)
|
||||
except ValueError:
|
||||
# Not a number
|
||||
return str(d)
|
||||
|
||||
s = str(d)
|
||||
|
||||
# Return entire number if there is no decimal place
|
||||
if '.' not in s:
|
||||
return s
|
||||
|
||||
return s.rstrip("0").rstrip(".")
|
||||
|
||||
|
||||
def WrapWithQuotes(text, quote='"'):
|
||||
""" Wrap the supplied text with quotes
|
||||
|
||||
@@ -103,6 +147,7 @@ def GetExportFormats():
|
||||
'xls',
|
||||
'xlsx',
|
||||
'json',
|
||||
'yaml',
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -5,6 +5,8 @@ import logging
|
||||
import time
|
||||
import operator
|
||||
|
||||
from rest_framework.authtoken.models import Token
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -20,10 +22,49 @@ class AuthRequiredMiddleware(object):
|
||||
|
||||
response = self.get_response(request)
|
||||
|
||||
# Redirect any unauthorized HTTP requests to the login page
|
||||
if not request.user.is_authenticated:
|
||||
if not request.path_info == reverse_lazy('login') and not request.path_info.startswith('/api/'):
|
||||
return HttpResponseRedirect(reverse_lazy('login'))
|
||||
"""
|
||||
Normally, a web-based session would use csrftoken based authentication.
|
||||
However when running an external application (e.g. the InvenTree app),
|
||||
we wish to use token-based auth to grab media files.
|
||||
|
||||
So, we will allow token-based authentication but ONLY for the /media/ directory.
|
||||
|
||||
What problem is this solving?
|
||||
- The InvenTree mobile app does not use csrf token auth
|
||||
- Token auth is used by the Django REST framework, but that is under the /api/ endpoint
|
||||
- Media files (e.g. Part images) are required to be served to the app
|
||||
- We do not want to make /media/ files accessible without login!
|
||||
|
||||
There is PROBABLY a better way of going about this?
|
||||
a) Allow token-based authentication against a user?
|
||||
b) Serve /media/ files in a duplicate location e.g. /api/media/ ?
|
||||
c) Is there a "standard" way of solving this problem?
|
||||
|
||||
My [google|stackoverflow]-fu has failed me. So this hack has been created.
|
||||
"""
|
||||
|
||||
authorized = False
|
||||
|
||||
if 'Authorization' in request.headers.keys():
|
||||
auth = request.headers['Authorization'].strip()
|
||||
|
||||
if auth.startswith('Token') and len(auth.split()) == 2:
|
||||
token = auth.split()[1]
|
||||
|
||||
# Does the provided token match a valid user?
|
||||
if Token.objects.filter(key=token).exists():
|
||||
|
||||
allowed = ['/media/', '/static/']
|
||||
|
||||
# Only allow token-auth for /media/ or /static/ dirs!
|
||||
if any([request.path_info.startswith(a) for a in allowed]):
|
||||
authorized = True
|
||||
|
||||
# No authorization was found for the request
|
||||
if not authorized:
|
||||
if not request.path_info == reverse_lazy('login') and not request.path_info.startswith('/api/'):
|
||||
return HttpResponseRedirect(reverse_lazy('login'))
|
||||
|
||||
# Code to be executed for each request/response after
|
||||
# the view is called.
|
||||
@@ -38,6 +79,8 @@ class QueryCountMiddleware(object):
|
||||
status code of 200). It does not currently support
|
||||
multi-db setups.
|
||||
|
||||
To enable this middleware, set 'log_queries: True' in the local InvenTree config file.
|
||||
|
||||
Reference: https://www.dabapps.com/blog/logging-sql-queries-django-13/
|
||||
"""
|
||||
|
||||
|
||||
@@ -4,8 +4,11 @@ Generic models which provide extra functionality over base Django model types.
|
||||
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import os
|
||||
|
||||
from django.db import models
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from django.db.models.signals import pre_delete
|
||||
from django.dispatch import receiver
|
||||
@@ -15,6 +18,51 @@ from mptt.models import MPTTModel, TreeForeignKey
|
||||
from .validators import validate_tree_name
|
||||
|
||||
|
||||
def rename_attachment(instance, filename):
|
||||
"""
|
||||
Function for renaming an attachment file.
|
||||
The subdirectory for the uploaded file is determined by the implementing class.
|
||||
|
||||
Args:
|
||||
instance: Instance of a PartAttachment object
|
||||
filename: name of uploaded file
|
||||
|
||||
Returns:
|
||||
path to store file, format: '<subdir>/<id>/filename'
|
||||
"""
|
||||
|
||||
# Construct a path to store a file attachment for a given model type
|
||||
return os.path.join(instance.getSubdir(), filename)
|
||||
|
||||
|
||||
class InvenTreeAttachment(models.Model):
|
||||
""" Provides an abstracted class for managing file attachments.
|
||||
|
||||
Attributes:
|
||||
attachment: File
|
||||
comment: String descriptor for the attachment
|
||||
"""
|
||||
def getSubdir(self):
|
||||
"""
|
||||
Return the subdirectory under which attachments should be stored.
|
||||
Note: Re-implement this for each subclass of InvenTreeAttachment
|
||||
"""
|
||||
|
||||
return "attachments"
|
||||
|
||||
attachment = models.FileField(upload_to=rename_attachment,
|
||||
help_text=_('Select file to attach'))
|
||||
|
||||
comment = models.CharField(max_length=100, help_text=_('File comment'))
|
||||
|
||||
@property
|
||||
def basename(self):
|
||||
return os.path.basename(self.attachment.name)
|
||||
|
||||
class Meta:
|
||||
abstract = True
|
||||
|
||||
|
||||
class InvenTreeTree(MPTTModel):
|
||||
""" Provides an abstracted self-referencing tree model for data categories.
|
||||
|
||||
@@ -29,6 +77,8 @@ class InvenTreeTree(MPTTModel):
|
||||
|
||||
class Meta:
|
||||
abstract = True
|
||||
|
||||
# Names must be unique at any given level in the tree
|
||||
unique_together = ('name', 'parent')
|
||||
|
||||
class MPTTMeta:
|
||||
@@ -37,7 +87,6 @@ class InvenTreeTree(MPTTModel):
|
||||
name = models.CharField(
|
||||
blank=False,
|
||||
max_length=100,
|
||||
unique=True,
|
||||
validators=[validate_tree_name]
|
||||
)
|
||||
|
||||
|
||||
@@ -17,6 +17,10 @@ import logging
|
||||
import tempfile
|
||||
import yaml
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
|
||||
def eprint(*args, **kwargs):
|
||||
""" Print a warning message to stderr """
|
||||
@@ -99,6 +103,8 @@ INSTALLED_APPS = [
|
||||
'django_cleanup', # Automatically delete orphaned MEDIA files
|
||||
'qr_code', # Generate QR codes
|
||||
'mptt', # Modified Preorder Tree Traversal
|
||||
'markdownx', # Markdown editing
|
||||
'markdownify', # Markdown template rendering
|
||||
]
|
||||
|
||||
LOGGING = {
|
||||
@@ -115,6 +121,7 @@ LOGGING = {
|
||||
MIDDLEWARE = [
|
||||
'django.middleware.security.SecurityMiddleware',
|
||||
'django.contrib.sessions.middleware.SessionMiddleware',
|
||||
'django.middleware.locale.LocaleMiddleware',
|
||||
'django.middleware.common.CommonMiddleware',
|
||||
'django.middleware.csrf.CsrfViewMiddleware',
|
||||
'corsheaders.middleware.CorsMiddleware',
|
||||
@@ -158,6 +165,37 @@ REST_FRAMEWORK = {
|
||||
|
||||
WSGI_APPLICATION = 'InvenTree.wsgi.application'
|
||||
|
||||
# Markdownx configuration
|
||||
# Ref: https://neutronx.github.io/django-markdownx/customization/
|
||||
MARKDOWNX_MEDIA_PATH = datetime.now().strftime('markdownx/%Y/%m/%d')
|
||||
|
||||
# Markdownify configuration
|
||||
# Ref: https://django-markdownify.readthedocs.io/en/latest/settings.html
|
||||
|
||||
MARKDOWNIFY_WHITELIST_TAGS = [
|
||||
'a',
|
||||
'abbr',
|
||||
'b',
|
||||
'blockquote',
|
||||
'em',
|
||||
'h1', 'h2', 'h3',
|
||||
'i',
|
||||
'img',
|
||||
'li',
|
||||
'ol',
|
||||
'p',
|
||||
'strong',
|
||||
'ul'
|
||||
]
|
||||
|
||||
MARKDOWNIFY_WHITELIST_ATTRS = [
|
||||
'href',
|
||||
'src',
|
||||
'alt',
|
||||
]
|
||||
|
||||
MARKDOWNIFY_BLEACH = True
|
||||
|
||||
DATABASES = {}
|
||||
|
||||
"""
|
||||
@@ -213,11 +251,32 @@ AUTH_PASSWORD_VALIDATORS = [
|
||||
},
|
||||
]
|
||||
|
||||
# Extra (optional) URL validators
|
||||
# See https://docs.djangoproject.com/en/2.2/ref/validators/#django.core.validators.URLValidator
|
||||
|
||||
EXTRA_URL_SCHEMES = CONFIG.get('extra_url_schemes', [])
|
||||
|
||||
if not type(EXTRA_URL_SCHEMES) in [list]:
|
||||
eprint("Warning: extra_url_schemes not correctly formatted")
|
||||
EXTRA_URL_SCHEMES = []
|
||||
|
||||
# Internationalization
|
||||
# https://docs.djangoproject.com/en/1.10/topics/i18n/
|
||||
|
||||
LANGUAGE_CODE = 'en-us'
|
||||
LANGUAGE_CODE = CONFIG.get('language', 'en-us')
|
||||
|
||||
# If a new language translation is supported, it must be added here
|
||||
LANGUAGES = [
|
||||
('en', _('English')),
|
||||
('de', _('German')),
|
||||
('fr', _('French')),
|
||||
('pk', _('Polish')),
|
||||
]
|
||||
|
||||
LOCALE_PATHS = (
|
||||
os.path.join(BASE_DIR, 'locale/'),
|
||||
)
|
||||
|
||||
|
||||
TIME_ZONE = 'UTC'
|
||||
|
||||
@@ -227,6 +286,10 @@ USE_L10N = True
|
||||
|
||||
USE_TZ = True
|
||||
|
||||
DATE_INPUT_FORMATS = [
|
||||
"%Y-%m-%d",
|
||||
]
|
||||
|
||||
|
||||
# Static files (CSS, JavaScript, Images)
|
||||
# https://docs.djangoproject.com/en/1.10/howto/static-files/
|
||||
@@ -245,7 +308,10 @@ STATICFILES_DIRS = [
|
||||
MEDIA_URL = '/media/'
|
||||
|
||||
# The filesystem location for served static files
|
||||
MEDIA_ROOT = CONFIG.get('media_root', os.path.join(BASE_DIR, 'media'))
|
||||
MEDIA_ROOT = os.path.abspath(CONFIG.get('media_root', os.path.join(BASE_DIR, 'media')))
|
||||
|
||||
if DEBUG:
|
||||
print("MEDIA_ROOT:", MEDIA_ROOT)
|
||||
|
||||
# crispy forms use the bootstrap templates
|
||||
CRISPY_TEMPLATE_PACK = 'bootstrap'
|
||||
|
||||
@@ -1,3 +1,34 @@
|
||||
:root {
|
||||
--primary-color: #335d88;
|
||||
--secondary-color: #b69c80;
|
||||
--highlight-color: #f5efe8;
|
||||
--basic-color: #333;
|
||||
}
|
||||
|
||||
.markdownx .row {
|
||||
margin: 5px;
|
||||
padding: 5px;
|
||||
border: 1px solid #cce;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.markdownx-editor {
|
||||
width: 100%;
|
||||
border: 1px solid #cce;
|
||||
border-radius: 3px;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.panel-content {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.markdownx-preview {
|
||||
border: 1px solid #cce;
|
||||
border-radius: 3px;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.qr-code {
|
||||
max-width: 400px;
|
||||
max-height: 400px;
|
||||
@@ -21,6 +52,10 @@
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.glyphicon-right {
|
||||
float: right;
|
||||
}
|
||||
|
||||
.starred-part {
|
||||
color: #ffbb00;
|
||||
}
|
||||
@@ -77,6 +112,10 @@
|
||||
font-size: 100%;
|
||||
}
|
||||
|
||||
.label-right {
|
||||
float: right;
|
||||
}
|
||||
|
||||
/* Bootstrap table overrides */
|
||||
|
||||
.stock-sub-group td {
|
||||
@@ -98,11 +137,11 @@
|
||||
max-width: 250px;
|
||||
}
|
||||
|
||||
.bomrowvalid {
|
||||
.rowvalid {
|
||||
color: #050;
|
||||
}
|
||||
|
||||
.bomrowinvalid {
|
||||
.rowinvalid {
|
||||
color: #A00;
|
||||
font-style: italic;
|
||||
}
|
||||
@@ -152,6 +191,24 @@
|
||||
-webkit-opacity: 10%;
|
||||
}
|
||||
|
||||
/* grid display for part images */
|
||||
|
||||
.table-img-grid tr {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.table-img-grid td {
|
||||
padding: 10px;
|
||||
margin: 10px;
|
||||
}
|
||||
|
||||
.table-img-grid .grid-image {
|
||||
|
||||
height: 128px;
|
||||
width: 128px;
|
||||
object-fit: contain;
|
||||
background: #eee;
|
||||
}
|
||||
|
||||
.btn-glyph {
|
||||
padding-left: 6px;
|
||||
@@ -180,6 +237,20 @@
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.part-thumb-container:hover .part-thumb-overlay {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.part-thumb-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
opacity: 0;
|
||||
transition: .25s ease;
|
||||
padding: 15px;
|
||||
margin: 5px;
|
||||
}
|
||||
|
||||
.checkbox {
|
||||
margin-left: 20px;
|
||||
}
|
||||
@@ -297,6 +368,12 @@
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
input[type="submit"] {
|
||||
color: #333;
|
||||
background-color: #e6e6e6;
|
||||
border-color: #adadad;
|
||||
}
|
||||
|
||||
.modal textarea {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@@ -133,7 +133,14 @@ function loadBomTable(table, options) {
|
||||
title: 'Part',
|
||||
sortable: true,
|
||||
formatter: function(value, row, index, field) {
|
||||
return imageHoverIcon(row.sub_part_detail.image_url) + renderLink(row.sub_part_detail.full_name, row.sub_part_detail.url);
|
||||
var html = imageHoverIcon(row.sub_part_detail.image_url) + renderLink(row.sub_part_detail.full_name, row.sub_part_detail.url);
|
||||
|
||||
// Display an extra icon if this part is an assembly
|
||||
if (row.sub_part_detail.assembly) {
|
||||
html += "<a href='" + row.sub_part_detail.url + "bom'><span class='glyphicon-right glyphicon glyphicon-th-list'></span></a>";
|
||||
}
|
||||
|
||||
return html;
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -163,21 +170,16 @@ function loadBomTable(table, options) {
|
||||
formatter: function(value, row, index, field) {
|
||||
var text = value;
|
||||
|
||||
// The 'value' is a text string with (potentially) multiple trailing zeros
|
||||
// Let's make it a bit more pretty
|
||||
text = parseFloat(text);
|
||||
|
||||
if (row.overage) {
|
||||
text += "<small> (+" + row.overage + ") </small>";
|
||||
}
|
||||
|
||||
return text;
|
||||
},
|
||||
footerFormatter: function(data) {
|
||||
var quantity = 0;
|
||||
|
||||
data.forEach(function(item) {
|
||||
quantity += item.quantity;
|
||||
});
|
||||
|
||||
return quantity;
|
||||
},
|
||||
});
|
||||
|
||||
if (!options.editable) {
|
||||
@@ -280,14 +282,13 @@ function loadBomTable(table, options) {
|
||||
search: true,
|
||||
rowStyle: function(row, index) {
|
||||
if (row.validated) {
|
||||
return {classes: 'bomrowvalid'};
|
||||
return {classes: 'rowvalid'};
|
||||
} else {
|
||||
return {classes: 'bomrowinvalid'};
|
||||
return {classes: 'rowinvalid'};
|
||||
}
|
||||
},
|
||||
formatNoMatches: function() { return "No BOM items found"; },
|
||||
clickToSelect: true,
|
||||
showFooter: true,
|
||||
queryParams: function(p) {
|
||||
return params;
|
||||
},
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
function updateAllocationTotal(id, count, required) {
|
||||
|
||||
count = parseFloat(count);
|
||||
|
||||
$('#allocation-total-'+id).html(count);
|
||||
|
||||
@@ -27,21 +28,24 @@ function loadAllocationTable(table, part_id, part, url, required, button) {
|
||||
field: 'stock_item_detail',
|
||||
title: 'Stock Item',
|
||||
formatter: function(value, row, index, field) {
|
||||
return '' + value.quantity + ' x ' + value.part_name + ' @ ' + value.location_name;
|
||||
return '' + parseFloat(value.quantity) + ' x ' + value.part_name + ' @ ' + value.location_name;
|
||||
}
|
||||
},
|
||||
{
|
||||
field: 'stock_item_detail.quantity',
|
||||
title: 'Available',
|
||||
formatter: function(value, row, index, field) {
|
||||
return parseFloat(value);
|
||||
}
|
||||
},
|
||||
{
|
||||
field: 'quantity',
|
||||
title: 'Allocated',
|
||||
formatter: function(value, row, index, field) {
|
||||
var html = value;
|
||||
var html = parseFloat(value);
|
||||
|
||||
var bEdit = "<button class='btn btn-primary item-edit-button btn-sm' type='button' title='Edit stock allocation' url='/build/item/" + row.pk + "/edit/'><span class='glyphicon glyphicon-small glyphicon-edit'></span></button>";
|
||||
var bDel = "<button class='btn btn-danger item-del-button btn-sm' type='button' title='Delete stock allocation' url='/build/item/" + row.pk + "/delete/'><span class='glyphicon glyphicon-small glyphicon-trash'></span></button>";
|
||||
var bEdit = "<button class='btn item-edit-button btn-sm' type='button' title='Edit stock allocation' url='/build/item/" + row.pk + "/edit/'><span class='glyphicon glyphicon-small glyphicon-edit'></span></button>";
|
||||
var bDel = "<button class='btn item-del-button btn-sm' type='button' title='Delete stock allocation' url='/build/item/" + row.pk + "/delete/'><span class='glyphicon glyphicon-small glyphicon-trash'></span></button>";
|
||||
|
||||
html += "<div class='btn-group' style='float: right;'>" + bEdit + bDel + "</div>";
|
||||
|
||||
@@ -67,7 +71,7 @@ function loadAllocationTable(table, part_id, part, url, required, button) {
|
||||
var count = 0;
|
||||
|
||||
for (var i = 0; i < results.length; i++) {
|
||||
count += results[i].quantity;
|
||||
count += parseFloat(results[i].quantity);
|
||||
}
|
||||
|
||||
updateAllocationTotal(part_id, count, required);
|
||||
|
||||
@@ -41,6 +41,13 @@ function inventreeDocReady() {
|
||||
|
||||
modal.modal('show');
|
||||
});
|
||||
|
||||
// Callback to launch the 'Database Stats' window
|
||||
$('#launch-stats').click(function() {
|
||||
launchModalForm("/stats/", {
|
||||
no_post: true,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function isFileTransfer(transfer) {
|
||||
@@ -136,4 +143,53 @@ function imageHoverIcon(url) {
|
||||
`;
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
function inventreeSave(name, value) {
|
||||
/*
|
||||
* Save a key:value pair to local storage
|
||||
*/
|
||||
|
||||
var key = "inventree-" + name;
|
||||
localStorage.setItem(key, value);
|
||||
}
|
||||
|
||||
function inventreeLoad(name, defaultValue) {
|
||||
/*
|
||||
* Retrieve a key:value pair from local storage
|
||||
*/
|
||||
|
||||
var key = "inventree-" + name;
|
||||
|
||||
var value = localStorage.getItem(key);
|
||||
|
||||
if (value == null) {
|
||||
return defaultValue;
|
||||
} else {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
function inventreeLoadInt(name) {
|
||||
/*
|
||||
* Retrieve a value from local storage, and attempt to cast to integer
|
||||
*/
|
||||
|
||||
var data = inventreeLoad(name);
|
||||
|
||||
return parseInt(data, 10);
|
||||
}
|
||||
|
||||
function inventreeLoadFloat(name) {
|
||||
|
||||
var data = inventreeLoad(name);
|
||||
|
||||
return parseFloat(data);
|
||||
}
|
||||
|
||||
function inventreeDel(name) {
|
||||
|
||||
var key = 'inventree-' + name;
|
||||
|
||||
localStorage.removeItem(key);
|
||||
}
|
||||
@@ -98,4 +98,96 @@ function removePurchaseOrderLineItem(e) {
|
||||
launchModalForm(url, {
|
||||
reload: true,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
function loadPurchaseOrderTable(table, options) {
|
||||
/* Create a purchase-order table */
|
||||
|
||||
table.inventreeTable({
|
||||
url: options.url,
|
||||
formatNoMatches: function() { return "No purchase orders found"; },
|
||||
columns: [
|
||||
{
|
||||
field: 'pk',
|
||||
title: 'ID',
|
||||
visible: false,
|
||||
},
|
||||
{
|
||||
sortable: true,
|
||||
field: 'supplier',
|
||||
title: 'Supplier',
|
||||
formatter: function(value, row, index, field) {
|
||||
return imageHoverIcon(row.supplier__image) + renderLink(row.supplier__name, '/company/' + value + '/purchase-orders/');
|
||||
}
|
||||
},
|
||||
{
|
||||
sortable: true,
|
||||
field: 'reference',
|
||||
title: 'Reference',
|
||||
formatter: function(value, row, index, field) {
|
||||
return renderLink(value, "/order/purchase-order/" + row.pk + "/");
|
||||
}
|
||||
},
|
||||
{
|
||||
sortable: true,
|
||||
field: 'creation_date',
|
||||
title: 'Date',
|
||||
},
|
||||
{
|
||||
sortable: true,
|
||||
field: 'description',
|
||||
title: 'Description',
|
||||
},
|
||||
{
|
||||
sortable: true,
|
||||
field: 'status',
|
||||
title: 'Status',
|
||||
formatter: function(value, row, index, field) {
|
||||
return orderStatusLabel(row.status, row.status_text);
|
||||
}
|
||||
},
|
||||
{
|
||||
sortable: true,
|
||||
field: 'lines',
|
||||
title: 'Items'
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
function orderStatusLabel(code, label) {
|
||||
/* Render a purchase-order status label. */
|
||||
|
||||
var html = "<span class='label";
|
||||
|
||||
switch (code) {
|
||||
case 10: // pending
|
||||
html += " label-info";
|
||||
break;
|
||||
case 20: // placed
|
||||
html += " label-primary";
|
||||
break;
|
||||
case 30: // complete
|
||||
html += " label-success";
|
||||
break;
|
||||
case 40: // cancelled
|
||||
case 50: // lost
|
||||
html += " label-warning";
|
||||
break;
|
||||
case 60: // returned
|
||||
html += " label-danger";
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
html += "'>";
|
||||
html += label;
|
||||
html += "</span>";
|
||||
|
||||
console.log(html);
|
||||
|
||||
return html;
|
||||
}
|
||||
@@ -95,6 +95,10 @@ function loadPartTable(table, url, options={}) {
|
||||
query.active = true;
|
||||
}
|
||||
|
||||
// Include sub-category search
|
||||
// TODO - Make this user-configurable!
|
||||
query.cascade = true;
|
||||
|
||||
var columns = [
|
||||
{
|
||||
field: 'pk',
|
||||
@@ -135,8 +139,12 @@ function loadPartTable(table, url, options={}) {
|
||||
name = '<i>' + name + '</i>';
|
||||
}
|
||||
|
||||
var display = imageHoverIcon(row.image) + renderLink(name, '/part/' + row.pk + '/');
|
||||
var display = imageHoverIcon(row.thumbnail) + renderLink(name, '/part/' + row.pk + '/');
|
||||
|
||||
if (row.is_template) {
|
||||
display = display + "<span class='label label-info' style='float: right;'>TEMPLATE</span>";
|
||||
}
|
||||
|
||||
if (!row.active) {
|
||||
display = display + "<span class='label label-warning' style='float: right;'>INACTIVE</span>";
|
||||
}
|
||||
@@ -177,25 +185,38 @@ function loadPartTable(table, url, options={}) {
|
||||
title: 'Stock',
|
||||
searchable: false,
|
||||
sortable: true,
|
||||
formatter: function(value, row, index, field) {
|
||||
formatter: function(value, row, index, field) {
|
||||
var link = "stock";
|
||||
|
||||
if (value) {
|
||||
return renderLink(value, '/part/' + row.pk + '/stock/');
|
||||
}
|
||||
else {
|
||||
return "<span class='label label-warning'>No Stock</span>";
|
||||
// There IS stock available for this part
|
||||
|
||||
// Is stock "low" (below the 'minimum_stock' quantity)?
|
||||
if (row.minimum_stock && row.minimum_stock > value) {
|
||||
value += "<span class='label label-right label-warning'>Low stock</span>";
|
||||
}
|
||||
|
||||
} else if (row.on_order) {
|
||||
// There is no stock available, but stock is on order
|
||||
value = "0<span class='label label-right label-primary'>On Order : " + row.on_order + "</span>";
|
||||
link = "orders";
|
||||
} else if (row.building) {
|
||||
// There is no stock available, but stock is being built
|
||||
value = "0<span class='label label-right label-info'>Building : " + row.building + "</span>";
|
||||
link = "builds";
|
||||
} else {
|
||||
// There is no stock available
|
||||
value = "0<span class='label label-right label-danger'>No Stock</span>";
|
||||
}
|
||||
|
||||
return renderLink(value, '/part/' + row.pk + "/" + link + "/");
|
||||
}
|
||||
});
|
||||
|
||||
$(table).bootstrapTable({
|
||||
$(table).inventreeTable({
|
||||
url: url,
|
||||
sortable: true,
|
||||
search: true,
|
||||
sortName: 'name',
|
||||
method: 'get',
|
||||
pagination: true,
|
||||
pageSize: 25,
|
||||
rememberOrder: true,
|
||||
formatNoMatches: function() { return "No parts found"; },
|
||||
queryParams: function(p) {
|
||||
return query;
|
||||
|
||||
@@ -42,13 +42,14 @@ function loadStockTable(table, options) {
|
||||
|
||||
var params = options.params || {};
|
||||
|
||||
table.bootstrapTable({
|
||||
sortable: true,
|
||||
search: true,
|
||||
// Enforce 'cascade' option
|
||||
// TODO - Make this user-configurable?
|
||||
params.cascade = true;
|
||||
|
||||
console.log('load stock table');
|
||||
|
||||
table.inventreeTable({
|
||||
method: 'get',
|
||||
pagination: true,
|
||||
pageSize: 25,
|
||||
rememberOrder: true,
|
||||
formatNoMatches: function() {
|
||||
return 'No stock items matching query';
|
||||
},
|
||||
@@ -69,7 +70,7 @@ function loadStockTable(table, options) {
|
||||
|
||||
name += row.part__name;
|
||||
|
||||
return imageHoverIcon(row.part__image) + name + ' <i>(' + data.length + ' items)</i>';
|
||||
return imageHoverIcon(row.part__thumbnail) + name + ' <i>(' + data.length + ' items)</i>';
|
||||
}
|
||||
else if (field == 'part__description') {
|
||||
return row.part__description;
|
||||
@@ -83,6 +84,8 @@ function loadStockTable(table, options) {
|
||||
items += 1;
|
||||
});
|
||||
|
||||
stock = +stock.toFixed(5);
|
||||
|
||||
return stock + " (" + items + " items)";
|
||||
} else if (field == 'batch') {
|
||||
var batches = [];
|
||||
@@ -128,6 +131,28 @@ function loadStockTable(table, options) {
|
||||
// A single location!
|
||||
return renderLink(row.location__path, '/stock/location/' + row.location + '/')
|
||||
}
|
||||
} else if (field == 'notes') {
|
||||
var notes = [];
|
||||
|
||||
data.forEach(function(item) {
|
||||
var note = item.notes;
|
||||
|
||||
if (!note || note == '') {
|
||||
note = '-';
|
||||
}
|
||||
|
||||
if (!notes.includes(note)) {
|
||||
notes.push(note);
|
||||
}
|
||||
});
|
||||
|
||||
if (notes.length > 1) {
|
||||
return '...';
|
||||
} else if (notes.length == 1) {
|
||||
return notes[0] || '-';
|
||||
} else {
|
||||
return '-';
|
||||
}
|
||||
}
|
||||
else {
|
||||
return '';
|
||||
@@ -163,7 +188,7 @@ function loadStockTable(table, options) {
|
||||
name += row.part__revision;
|
||||
}
|
||||
|
||||
return imageHoverIcon(row.part__image) + renderLink(name, '/part/' + row.part + '/stock/');
|
||||
return imageHoverIcon(row.part__thumbnail) + renderLink(name, '/part/' + row.part + '/stock/');
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -291,6 +316,18 @@ function loadStockTable(table, options) {
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
$("#multi-item-delete").click(function() {
|
||||
var selections = $("#stock-table").bootstrapTable("getSelections");
|
||||
|
||||
var stock = [];
|
||||
|
||||
selections.forEach(function(item) {
|
||||
stock.push(item.pk);
|
||||
});
|
||||
|
||||
stockAdjustment('delete');
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -352,6 +389,9 @@ function loadStockTrackingTable(table, options) {
|
||||
cols.push({
|
||||
field: 'quantity',
|
||||
title: 'Quantity',
|
||||
formatter: function(value, row, index, field) {
|
||||
return parseFloat(value);
|
||||
},
|
||||
});
|
||||
|
||||
cols.push({
|
||||
@@ -386,15 +426,10 @@ function loadStockTrackingTable(table, options) {
|
||||
}
|
||||
});
|
||||
|
||||
table.bootstrapTable({
|
||||
sortable: true,
|
||||
search: true,
|
||||
table.inventreeTable({
|
||||
method: 'get',
|
||||
rememberOrder: true,
|
||||
queryParams: options.params,
|
||||
columns: cols,
|
||||
pagination: true,
|
||||
pageSize: 50,
|
||||
url: options.url,
|
||||
});
|
||||
|
||||
|
||||
@@ -44,6 +44,31 @@ function isNumeric(n) {
|
||||
}
|
||||
|
||||
|
||||
/* Wrapper function for bootstrapTable.
|
||||
* Sets some useful defaults, and manage persistent settings.
|
||||
*/
|
||||
$.fn.inventreeTable = function(options) {
|
||||
|
||||
var tableName = options.name || 'table';
|
||||
|
||||
var varName = tableName + '-pagesize';
|
||||
|
||||
options.pagination = true;
|
||||
options.pageSize = inventreeLoad(varName, 25);
|
||||
options.pageList = [25, 50, 100, 250, 'all'];
|
||||
options.rememberOrder = true;
|
||||
options.sortable = true;
|
||||
options.search = true;
|
||||
|
||||
// Callback to save pagination data
|
||||
options.onPageChange = function(number, size) {
|
||||
inventreeSave(varName, size);
|
||||
};
|
||||
|
||||
// Standard options for all tables
|
||||
this.bootstrapTable(options);
|
||||
}
|
||||
|
||||
function customGroupSorter(sortName, sortOrder, sortData) {
|
||||
|
||||
console.log('got here');
|
||||
|
||||
@@ -12,6 +12,15 @@ class StatusCode:
|
||||
""" Return the status code label associated with the provided value """
|
||||
return cls.options.get(value, value)
|
||||
|
||||
@classmethod
|
||||
def value(cls, label):
|
||||
""" Return the value associated with the provided label """
|
||||
for k in cls.options.keys():
|
||||
if cls.options[k].lower() == label.lower():
|
||||
return k
|
||||
|
||||
raise ValueError("Label not found")
|
||||
|
||||
|
||||
class OrderStatus(StatusCode):
|
||||
|
||||
@@ -62,11 +71,17 @@ class StockStatus(StatusCode):
|
||||
LOST: _("Lost"),
|
||||
}
|
||||
|
||||
# The following codes correspond to parts that are 'available'
|
||||
# The following codes correspond to parts that are 'available' or 'in stock'
|
||||
AVAILABLE_CODES = [
|
||||
OK,
|
||||
ATTENTION,
|
||||
DAMAGED
|
||||
DAMAGED,
|
||||
]
|
||||
|
||||
# The following codes correspond to parts that are 'unavailable'
|
||||
UNAVAILABLE_CODES = [
|
||||
DESTROYED,
|
||||
LOST,
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -33,7 +33,8 @@ from django.conf.urls.static import static
|
||||
from django.views.generic.base import RedirectView
|
||||
from rest_framework.documentation import include_docs_urls
|
||||
|
||||
from .views import IndexView, SearchView, SettingsView, EditUserView, SetPasswordView
|
||||
from .views import IndexView, SearchView, DatabaseStatsView
|
||||
from .views import SettingsView, EditUserView, SetPasswordView
|
||||
from .views import InfoView
|
||||
|
||||
from users.urls import user_urls
|
||||
@@ -61,6 +62,7 @@ settings_urls = [
|
||||
url(r'^user/?', SettingsView.as_view(template_name='InvenTree/settings/user.html'), name='settings-user'),
|
||||
url(r'^currency/?', SettingsView.as_view(template_name='InvenTree/settings/currency.html'), name='settings-currency'),
|
||||
url(r'^part/?', SettingsView.as_view(template_name='InvenTree/settings/part.html'), name='settings-part'),
|
||||
url(r'^other/?', SettingsView.as_view(template_name='InvenTree/settings/other.html'), name='settings-other'),
|
||||
|
||||
# Catch any other urls
|
||||
url(r'^.*$', SettingsView.as_view(template_name='InvenTree/settings/user.html'), name='settings'),
|
||||
@@ -96,17 +98,19 @@ urlpatterns = [
|
||||
|
||||
url(r'^index/', IndexView.as_view(), name='index'),
|
||||
url(r'^search/', SearchView.as_view(), name='search'),
|
||||
url(r'^stats/', DatabaseStatsView.as_view(), name='stats'),
|
||||
|
||||
url(r'^api/', include(apipatterns)),
|
||||
url(r'^api-doc/', include_docs_urls(title='InvenTree API')),
|
||||
|
||||
url(r'^markdownx/', include('markdownx.urls')),
|
||||
]
|
||||
|
||||
# Static file access
|
||||
urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)
|
||||
|
||||
if settings.DEBUG:
|
||||
# Media file access
|
||||
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
|
||||
# Media file access
|
||||
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
|
||||
|
||||
# Send any unknown URLs to the parts page
|
||||
urlpatterns += [url(r'^.*$', RedirectView.as_view(url='/index/', permanent=False), name='index')]
|
||||
|
||||
@@ -2,9 +2,32 @@
|
||||
Custom field validators for InvenTree
|
||||
"""
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from common.models import InvenTreeSetting
|
||||
|
||||
import re
|
||||
|
||||
|
||||
def allowable_url_schemes():
|
||||
""" Return the list of allowable URL schemes.
|
||||
In addition to the default schemes allowed by Django,
|
||||
the install configuration file (config.yaml) can specify
|
||||
extra schemas """
|
||||
|
||||
# Default schemes
|
||||
schemes = ['http', 'https', 'ftp', 'ftps']
|
||||
|
||||
extra = settings.EXTRA_URL_SCHEMES
|
||||
|
||||
for e in extra:
|
||||
if e.lower() not in schemes:
|
||||
schemes.append(e.lower())
|
||||
|
||||
return schemes
|
||||
|
||||
|
||||
def validate_part_name(value):
|
||||
""" Prevent some illegal characters in part names.
|
||||
@@ -17,6 +40,18 @@ def validate_part_name(value):
|
||||
)
|
||||
|
||||
|
||||
def validate_part_ipn(value):
|
||||
""" Validate the Part IPN against regex rule """
|
||||
|
||||
pattern = InvenTreeSetting.get_setting('part_ipn_regex')
|
||||
|
||||
if pattern:
|
||||
match = re.search(pattern, value)
|
||||
|
||||
if match is None:
|
||||
raise ValidationError(_('IPN must match regex pattern') + " '{pat}'".format(pat=pattern))
|
||||
|
||||
|
||||
def validate_tree_name(value):
|
||||
""" Prevent illegal characters in tree item names """
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ Provides information on the current InvenTree version
|
||||
|
||||
import subprocess
|
||||
|
||||
INVENTREE_SW_VERSION = "0.0.6"
|
||||
INVENTREE_SW_VERSION = "0.0.10"
|
||||
|
||||
|
||||
def inventreeVersion():
|
||||
@@ -15,6 +15,12 @@ def inventreeVersion():
|
||||
def inventreeCommitHash():
|
||||
""" Returns the git commit hash for the running codebase """
|
||||
|
||||
commit = str(subprocess.check_output('git rev-parse --short HEAD'.split()), 'utf-8').strip()
|
||||
return str(subprocess.check_output('git rev-parse --short HEAD'.split()), 'utf-8').strip()
|
||||
|
||||
return commit
|
||||
|
||||
def inventreeCommitDate():
|
||||
""" Returns the git commit date for the running codebase """
|
||||
|
||||
d = str(subprocess.check_output('git show -s --format=%ci'.split()), 'utf-8').strip()
|
||||
|
||||
return d.split(' ')[0]
|
||||
|
||||
@@ -8,6 +8,7 @@ as JSON objects and passing them to modal forms (using jQuery / bootstrap).
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.template.loader import render_to_string
|
||||
from django.http import JsonResponse, HttpResponseRedirect
|
||||
|
||||
@@ -15,7 +16,9 @@ from django.views import View
|
||||
from django.views.generic import UpdateView, CreateView
|
||||
from django.views.generic.base import TemplateView
|
||||
|
||||
from part.models import Part
|
||||
from part.models import Part, PartCategory
|
||||
from stock.models import StockLocation, StockItem
|
||||
from common.models import InvenTreeSetting
|
||||
|
||||
from .forms import DeleteForm, EditUserForm, SetPasswordForm
|
||||
from .helpers import str2bool
|
||||
@@ -163,6 +166,8 @@ class AjaxMixin(object):
|
||||
|
||||
if form:
|
||||
context['form'] = form
|
||||
else:
|
||||
context['form'] = None
|
||||
|
||||
data['title'] = self.ajax_form_title
|
||||
|
||||
@@ -237,6 +242,18 @@ class AjaxCreateView(AjaxMixin, CreateView):
|
||||
- Handles form validation via AJAX POST requests
|
||||
"""
|
||||
|
||||
def pre_save(self, **kwargs):
|
||||
"""
|
||||
Hook for doing something before the form is validated
|
||||
"""
|
||||
pass
|
||||
|
||||
def post_save(self, **kwargs):
|
||||
"""
|
||||
Hook for doing something with the created object after it is saved
|
||||
"""
|
||||
pass
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
""" Creates form with initial data, and renders JSON response """
|
||||
|
||||
@@ -254,26 +271,29 @@ class AjaxCreateView(AjaxMixin, CreateView):
|
||||
- Return status info (success / failure)
|
||||
"""
|
||||
self.request = request
|
||||
form = self.get_form()
|
||||
self.form = self.get_form()
|
||||
|
||||
# Extra JSON data sent alongside form
|
||||
data = {
|
||||
'form_valid': form.is_valid(),
|
||||
'form_valid': self.form.is_valid(),
|
||||
}
|
||||
|
||||
if form.is_valid():
|
||||
obj = form.save()
|
||||
if self.form.is_valid():
|
||||
|
||||
self.pre_save()
|
||||
self.object = self.form.save()
|
||||
self.post_save()
|
||||
|
||||
# Return the PK of the newly-created object
|
||||
data['pk'] = obj.pk
|
||||
data['text'] = str(obj)
|
||||
data['pk'] = self.object.pk
|
||||
data['text'] = str(self.object)
|
||||
|
||||
try:
|
||||
data['url'] = obj.get_absolute_url()
|
||||
data['url'] = self.object.get_absolute_url()
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
return self.renderJsonResponse(request, form, data)
|
||||
return self.renderJsonResponse(request, self.form, data)
|
||||
|
||||
|
||||
class AjaxUpdateView(AjaxMixin, UpdateView):
|
||||
@@ -511,3 +531,41 @@ class SettingsView(TemplateView):
|
||||
"""
|
||||
|
||||
template_name = "InvenTree/settings.html"
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
|
||||
ctx = super().get_context_data(**kwargs).copy()
|
||||
|
||||
ctx['settings'] = InvenTreeSetting.objects.all().order_by('key')
|
||||
|
||||
return ctx
|
||||
|
||||
|
||||
class DatabaseStatsView(AjaxView):
|
||||
""" View for displaying database statistics """
|
||||
|
||||
ajax_template_name = "stats.html"
|
||||
ajax_form_title = _("Database Statistics")
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
|
||||
ctx = {}
|
||||
|
||||
# Part stats
|
||||
ctx['part_count'] = Part.objects.count()
|
||||
ctx['part_cat_count'] = PartCategory.objects.count()
|
||||
|
||||
# Stock stats
|
||||
ctx['stock_item_count'] = StockItem.objects.count()
|
||||
ctx['stock_loc_count'] = StockLocation.objects.count()
|
||||
|
||||
"""
|
||||
TODO: Other ideas for database metrics
|
||||
|
||||
- "Popular" parts (used to make other parts?)
|
||||
- Most ordered part
|
||||
- Most sold part
|
||||
- etc etc etc
|
||||
"""
|
||||
|
||||
return ctx
|
||||
|
||||
@@ -26,7 +26,6 @@ class EditBuildForm(HelperForm):
|
||||
'take_from',
|
||||
'batch',
|
||||
'URL',
|
||||
'notes',
|
||||
]
|
||||
|
||||
|
||||
|
||||
19
InvenTree/build/migrations/0006_auto_20190913_1407.py
Normal file
19
InvenTree/build/migrations/0006_auto_20190913_1407.py
Normal file
@@ -0,0 +1,19 @@
|
||||
# Generated by Django 2.2.5 on 2019-09-13 14:07
|
||||
|
||||
import InvenTree.fields
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('build', '0005_auto_20190604_2217'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='build',
|
||||
name='URL',
|
||||
field=InvenTree.fields.InvenTreeURLField(blank=True, help_text='Link to external URL'),
|
||||
),
|
||||
]
|
||||
19
InvenTree/build/migrations/0007_auto_20191118_2321.py
Normal file
19
InvenTree/build/migrations/0007_auto_20191118_2321.py
Normal file
@@ -0,0 +1,19 @@
|
||||
# Generated by Django 2.2.5 on 2019-11-18 23:21
|
||||
|
||||
import django.core.validators
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('build', '0006_auto_20190913_1407'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
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(1)]),
|
||||
),
|
||||
]
|
||||
19
InvenTree/build/migrations/0008_auto_20200201_1247.py
Normal file
19
InvenTree/build/migrations/0008_auto_20200201_1247.py
Normal file
@@ -0,0 +1,19 @@
|
||||
# Generated by Django 2.2.9 on 2020-02-01 12:47
|
||||
|
||||
from django.db import migrations
|
||||
import markdownx.models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('build', '0007_auto_20191118_2321'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='build',
|
||||
name='notes',
|
||||
field=markdownx.models.MarkdownxField(blank=True, help_text='Extra build notes'),
|
||||
),
|
||||
]
|
||||
18
InvenTree/build/migrations/0009_auto_20200210_1032.py
Normal file
18
InvenTree/build/migrations/0009_auto_20200210_1032.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 2.2.9 on 2020-02-10 10:32
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('build', '0008_auto_20200201_1247'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='build',
|
||||
name='creation_date',
|
||||
field=models.DateField(auto_now_add=True),
|
||||
),
|
||||
]
|
||||
19
InvenTree/build/migrations/0010_auto_20200318_1027.py
Normal file
19
InvenTree/build/migrations/0010_auto_20200318_1027.py
Normal file
@@ -0,0 +1,19 @@
|
||||
# Generated by Django 2.2.9 on 2020-03-18 10:27
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('build', '0009_auto_20200210_1032'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='build',
|
||||
name='part',
|
||||
field=models.ForeignKey(help_text='Select part to build', limit_choices_to={'active': True, 'assembly': True, 'is_template': False, 'virtual': False}, on_delete=django.db.models.deletion.CASCADE, related_name='builds', to='part.Part'),
|
||||
),
|
||||
]
|
||||
@@ -16,7 +16,11 @@ from django.db import models, transaction
|
||||
from django.db.models import Sum
|
||||
from django.core.validators import MinValueValidator
|
||||
|
||||
from markdownx.models import MarkdownxField
|
||||
|
||||
from InvenTree.status_codes import BuildStatus
|
||||
from InvenTree.fields import InvenTreeURLField
|
||||
from InvenTree.helpers import decimal2string
|
||||
|
||||
from stock.models import StockItem
|
||||
from part.models import Part, BomItem
|
||||
@@ -39,7 +43,7 @@ class Build(models.Model):
|
||||
"""
|
||||
|
||||
def __str__(self):
|
||||
return "Build {q} x {part}".format(q=self.quantity, part=str(self.part))
|
||||
return "Build {q} x {part}".format(q=decimal2string(self.quantity), part=str(self.part))
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('build-detail', kwargs={'pk': self.id})
|
||||
@@ -47,39 +51,40 @@ class Build(models.Model):
|
||||
title = models.CharField(
|
||||
blank=False,
|
||||
max_length=100,
|
||||
help_text='Brief description of the build')
|
||||
help_text=_('Brief description of the build'))
|
||||
|
||||
part = models.ForeignKey('part.Part', on_delete=models.CASCADE,
|
||||
related_name='builds',
|
||||
limit_choices_to={
|
||||
'is_template': False,
|
||||
'assembly': True,
|
||||
'active': True
|
||||
'active': True,
|
||||
'virtual': False,
|
||||
},
|
||||
help_text='Select part to build',
|
||||
help_text=_('Select part to build'),
|
||||
)
|
||||
|
||||
take_from = models.ForeignKey('stock.StockLocation', on_delete=models.SET_NULL,
|
||||
related_name='sourcing_builds',
|
||||
null=True, blank=True,
|
||||
help_text='Select location to take stock from for this build (leave blank to take from any stock location)'
|
||||
help_text=_('Select location to take stock from for this build (leave blank to take from any stock location)')
|
||||
)
|
||||
|
||||
quantity = models.PositiveIntegerField(
|
||||
default=1,
|
||||
validators=[MinValueValidator(1)],
|
||||
help_text='Number of parts to build'
|
||||
help_text=_('Number of parts to build')
|
||||
)
|
||||
|
||||
status = models.PositiveIntegerField(default=BuildStatus.PENDING,
|
||||
choices=BuildStatus.items(),
|
||||
validators=[MinValueValidator(0)],
|
||||
help_text='Build status')
|
||||
help_text=_('Build status'))
|
||||
|
||||
batch = models.CharField(max_length=100, blank=True, null=True,
|
||||
help_text='Batch code for this build output')
|
||||
help_text=_('Batch code for this build output'))
|
||||
|
||||
creation_date = models.DateField(auto_now=True, editable=False)
|
||||
creation_date = models.DateField(auto_now_add=True, editable=False)
|
||||
|
||||
completion_date = models.DateField(null=True, blank=True)
|
||||
|
||||
@@ -89,10 +94,13 @@ class Build(models.Model):
|
||||
related_name='builds_completed'
|
||||
)
|
||||
|
||||
URL = models.URLField(blank=True, help_text='Link to external URL')
|
||||
URL = InvenTreeURLField(blank=True, help_text=_('Link to external URL'))
|
||||
|
||||
notes = models.TextField(blank=True, help_text='Extra build notes')
|
||||
""" Notes attached to each build output """
|
||||
notes = MarkdownxField(blank=True, help_text=_('Extra build notes'))
|
||||
|
||||
@property
|
||||
def output_count(self):
|
||||
return self.build_outputs.count()
|
||||
|
||||
@transaction.atomic
|
||||
def cancelBuild(self, user):
|
||||
@@ -232,7 +240,7 @@ class Build(models.Model):
|
||||
now=str(datetime.now().date())
|
||||
)
|
||||
|
||||
if self.part.trackable:
|
||||
if self.part.trackable and serial_numbers:
|
||||
# Add new serial numbers
|
||||
for serial in serial_numbers:
|
||||
item = StockItem.objects.create(
|
||||
@@ -398,18 +406,20 @@ class BuildItem(models.Model):
|
||||
Build,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='allocated_stock',
|
||||
help_text='Build to allocate parts'
|
||||
help_text=_('Build to allocate parts')
|
||||
)
|
||||
|
||||
stock_item = models.ForeignKey(
|
||||
'stock.StockItem',
|
||||
on_delete=models.CASCADE,
|
||||
related_name='allocations',
|
||||
help_text='Stock Item to allocate to build',
|
||||
help_text=_('Stock Item to allocate to build'),
|
||||
)
|
||||
|
||||
quantity = models.PositiveIntegerField(
|
||||
quantity = models.DecimalField(
|
||||
decimal_places=5,
|
||||
max_digits=15,
|
||||
default=1,
|
||||
validators=[MinValueValidator(1)],
|
||||
help_text='Stock quantity to allocate to build'
|
||||
help_text=_('Stock quantity to allocate to build')
|
||||
)
|
||||
|
||||
@@ -62,9 +62,7 @@ InvenTree | Allocate Parts
|
||||
|
||||
{% else %}
|
||||
|
||||
$("#build-list").bootstrapTable({
|
||||
search: true,
|
||||
sortable: true,
|
||||
$("#build-list").inventreeTable({
|
||||
});
|
||||
|
||||
$("#btn-allocate").click(function() {
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
{% load i18n %}
|
||||
{% load inventree_extras %}
|
||||
|
||||
<div class='row'>
|
||||
<h4>Allocate Stock to Build</h4>
|
||||
<h4>{% trans "Allocate Stock to Build" %}</h4>
|
||||
<div class='col-sm-6'>
|
||||
</div>
|
||||
<div class='col-sm-6'>
|
||||
<div class='btn-group' style='float: right;'>
|
||||
<button class='btn btn-primary' type='button' title='Automatic allocation' id='auto-allocate-build'>Auto Allocate</button>
|
||||
<button class='btn btn-warning' type='button' title='Unallocate build stock' id='unallocate-build'>Unallocate</button>
|
||||
<button class='btn btn-primary' type='button' title='Automatic allocation' id='auto-allocate-build'>{% trans "Auto Allocate" %}</button>
|
||||
<button class='btn btn-warning' type='button' title='Unallocate build stock' id='unallocate-build'>{% trans "Unallocate" %}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -14,16 +16,16 @@
|
||||
|
||||
<div class='row'>
|
||||
<div class='col-sm-6'>
|
||||
<h4>Part</h4>
|
||||
<h4>{% trans "Part" %}</h4>
|
||||
</div>
|
||||
<div class='col-sm-2'>
|
||||
<h4>Available</h4>
|
||||
<h4>{% trans "Available" %}</h4>
|
||||
</div>
|
||||
<div class='col-sm-2'>
|
||||
<h4>Required</h4>
|
||||
<h4>{% trans "Required" %}</h4>
|
||||
</div>
|
||||
<div class='col-sm-2'>
|
||||
<h4>Allocated</h4>
|
||||
<h4>{% trans "Allocated" %}</h4>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,36 +1,39 @@
|
||||
<h4>Required Parts</h4>
|
||||
{% load i18n %}
|
||||
{% load inventree_extras %}
|
||||
|
||||
<h4>{% trans "Required Parts" %}</h4>
|
||||
<hr>
|
||||
|
||||
<div id='build-item-toolbar'>
|
||||
<div class='btn-group'>
|
||||
<button class='btn btn-primary' type='button' id='btn-allocate' title='Allocate Stock'>Allocate</button>
|
||||
<button class='btn btn-primary' type='button' id='btn-order-parts' title='Order Parts'>Order Parts</button>
|
||||
<button class='btn btn-primary' type='button' id='btn-allocate' title='Allocate Stock'>{% trans "Allocate" %}</button>
|
||||
<button class='btn btn-primary' type='button' id='btn-order-parts' title='Order Parts'>{% trans "Order Parts" %}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<table class='table table-striped table-condensed' id='build-list' data-sorting='true' data-toolbar='#build-item-toolbar'>
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-sortable='true'>Part</th>
|
||||
<th>Description</th>
|
||||
<th data-sortable='true'>Available</th>
|
||||
<th data-sortable='true'>Required</th>
|
||||
<th data-sortable='true'>Allocated</th>
|
||||
<th data-sortable='true'>On Order</th>
|
||||
<th data-sortable='true'>{% trans "Part" %}</th>
|
||||
<th>{% trans "Description" %}</th>
|
||||
<th data-sortable='true'>{% trans "Available" %}</th>
|
||||
<th data-sortable='true'>{% trans "Required" %}</th>
|
||||
<th data-sortable='true'>{% trans "Allocated" %}</th>
|
||||
<th data-sortable='true'>{% trans "On Order" %}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for item in build.required_parts %}
|
||||
<tr>
|
||||
<tr {% if build.status == BuildStatus.PENDING %}class='{% if item.part.total_stock > item.quantity %}rowvalid{% else %}rowinvalid{% endif %}'{% endif %}>
|
||||
<td>
|
||||
{% include "hover_image.html" with image=item.part.image hover=True %}
|
||||
<a class='hover-icon'a href="{% url 'part-detail' item.part.id %}">{{ item.part.full_name }}</a>
|
||||
</td>
|
||||
<td>{{ item.part.description }}</td>
|
||||
<td>{{ item.part.total_stock }}</td>
|
||||
<td>{{ item.quantity }}</td>
|
||||
<td>{% decimal item.part.total_stock %}</td>
|
||||
<td>{% decimal item.quantity %}</td>
|
||||
<td>{{ item.allocated }}</td>
|
||||
<td>{{ item.part.on_order }}</td>
|
||||
<td>{% decimal item.part.on_order %}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
|
||||
@@ -15,10 +15,10 @@
|
||||
|
||||
{% block collapse_heading %}
|
||||
<div class='col-sm-2'>
|
||||
<b>{{ item.sub_part.total_stock }}</b>
|
||||
<b>{% decimal item.sub_part.total_stock %}</b>
|
||||
</div>
|
||||
<div class='col-sm-2'>
|
||||
<b>{% multiply build.quantity item.quantity %}</b>
|
||||
<b>{% multiply build.quantity item.quantity %}{% if item.overage %} (+ {{ item.overage }}){% endif %}</b>
|
||||
</div>
|
||||
<div class='col-sm-2'>
|
||||
<b><span id='allocation-total-{{ item.sub_part.id }}'>{% part_allocation_count build item.sub_part %}</span></b>
|
||||
|
||||
@@ -34,7 +34,7 @@ InvenTree | Build - {{ build }}
|
||||
<button type='button' class='btn btn-default btn-glyph' id='build-complete' title="Complete Build">
|
||||
<span class='glyphicon glyphicon-send'/>
|
||||
</button>
|
||||
<button type='button' class='btn btn-default btn-glyph' id='build-cancel'>
|
||||
<button type='button' class='btn btn-default btn-glyph' id='build-cancel' title='Cancel Build'>
|
||||
<span class='glyphicon glyphicon-remove'/>
|
||||
</button>
|
||||
{% endif %}
|
||||
@@ -90,6 +90,10 @@ InvenTree | Build - {{ build }}
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block js_load %}
|
||||
<script type='text/javascript' src="{% static 'script/inventree/stock.js' %}"></script>
|
||||
{% endblock %}
|
||||
|
||||
{% block js_ready %}
|
||||
|
||||
$("#build-edit").click(function () {
|
||||
|
||||
32
InvenTree/build/templates/build/build_output.html
Normal file
32
InvenTree/build/templates/build/build_output.html
Normal file
@@ -0,0 +1,32 @@
|
||||
{% extends "build/build_base.html" %}
|
||||
{% load static %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block details %}
|
||||
|
||||
{% include "build/tabs.html" with tab='output' %}
|
||||
|
||||
<h4>{% trans "Build Outputs" %}</h4>
|
||||
<hr>
|
||||
|
||||
{% include "stock_table.html" with read_only=True %}
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block js_ready %}
|
||||
{{ block.super }}
|
||||
|
||||
loadStockTable($("#stock-table"), {
|
||||
params: {
|
||||
location_detail: true,
|
||||
part_details: true,
|
||||
build: {{ build.id }},
|
||||
},
|
||||
groupByField: 'location',
|
||||
buttons: [
|
||||
'#stock-options',
|
||||
],
|
||||
url: "{% url 'api-stock-list' %}",
|
||||
});
|
||||
|
||||
{% endblock %}
|
||||
@@ -1,7 +1,9 @@
|
||||
{% extends "modal_delete_form.html" %}
|
||||
{% load i18n %}
|
||||
{% load inventree_extras %}
|
||||
|
||||
{% block pre_form_content %}
|
||||
Are you sure you want to unallocate these parts?
|
||||
{% trans "Are you sure you want to unallocate these parts?" %}
|
||||
<br>
|
||||
This will remove {{ item.quantity }} parts from build '{{ item.build.title }}'.
|
||||
This will remove {% decimal item.quantity %} parts from build '{{ item.build.title }}'.
|
||||
{% endblock %}
|
||||
@@ -1,74 +1,67 @@
|
||||
{% extends "build/build_base.html" %}
|
||||
{% load static %}
|
||||
|
||||
{% load i18n %}
|
||||
{% block details %}
|
||||
|
||||
{% include "build/tabs.html" with tab='details' %}
|
||||
|
||||
<h4>Build Details</h4>
|
||||
<h4>{% trans "Build Details" %}</h4>
|
||||
|
||||
<hr>
|
||||
|
||||
<table class='table table-striped'>
|
||||
<tr>
|
||||
<td>Title</td><td>{{ build.title }}</td>
|
||||
<td>{% trans "Title" %}</td><td>{{ build.title }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Part</td><td><a href="{% url 'part-build' build.part.id %}">{{ build.part.full_name }}</a></td>
|
||||
<td>{% trans "Part" %}</td><td><a href="{% url 'part-build' build.part.id %}">{{ build.part.full_name }}</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Quantity</td><td>{{ build.quantity }}</td>
|
||||
<td>{% trans "Quantity" %}</td><td>{{ build.quantity }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Stock Source</td>
|
||||
<td>{% trans "Stock Source" %}</td>
|
||||
<td>
|
||||
{% if build.take_from %}
|
||||
<a href="{% url 'stock-location-detail' build.take_from.id %}">{{ build.take_from }}</a>
|
||||
{% else %}
|
||||
Stock can be taken from any available location.
|
||||
{% trans "Stock can be taken from any available location." %}
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Status</td><td>{% include "build_status.html" with build=build %}</td>
|
||||
<td>{% trans "Status" %}</td><td>{% include "build_status.html" with build=build %}</td>
|
||||
</tr>
|
||||
{% if build.batch %}
|
||||
<tr>
|
||||
<td>Batch</td><td>{{ build.batch }}</td>
|
||||
<td>{% trans "Batch" %}</td><td>{{ build.batch }}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% if build.URL %}
|
||||
<tr>
|
||||
<td>URL</td><td><a href="{{ build.URL }}">{{ build.URL }}</a></td>
|
||||
<td>{% trans "URL" %}</td><td><a href="{{ build.URL }}">{{ build.URL }}</a></td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
<tr>
|
||||
<td>Created</td><td>{{ build.creation_date }}</td>
|
||||
<td>{% trans "Created" %}</td><td>{{ build.creation_date }}</td>
|
||||
</tr>
|
||||
{% if build.is_active %}
|
||||
<tr>
|
||||
<td>Enough Parts?</td>
|
||||
<td>{% trans "Enough Parts?" %}</td>
|
||||
<td>
|
||||
{% if build.can_build %}
|
||||
Yes
|
||||
{% trans "Yes" %}
|
||||
{% else %}
|
||||
No
|
||||
{% trans "No" %}
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% if build.completion_date %}
|
||||
<tr>
|
||||
<td>Completed</td><td>{{ build.completion_date }}{% if build.completed_by %}<span class='badge'>{{ build.completed_by }}</span>{% endif %}</td>
|
||||
<td>{% trans "Completed" %}</td><td>{{ build.completion_date }}{% if build.completed_by %}<span class='badge'>{{ build.completed_by }}</span>{% endif %}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
</table>
|
||||
|
||||
{% if build.notes %}
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading"><b>Notes</b></div>
|
||||
<div class="panel-body">{{ build.notes }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% endblock %}
|
||||
|
||||
@@ -44,9 +44,7 @@ InvenTree | Build List
|
||||
});
|
||||
});
|
||||
|
||||
$(".build-table").bootstrapTable({
|
||||
sortable: true,
|
||||
search: true,
|
||||
$(".build-table").inventreeTable({
|
||||
formatNoMatches: function() { return 'No builds found'; },
|
||||
columns: [
|
||||
{
|
||||
|
||||
56
InvenTree/build/templates/build/notes.html
Normal file
56
InvenTree/build/templates/build/notes.html
Normal file
@@ -0,0 +1,56 @@
|
||||
{% extends "build/build_base.html" %}
|
||||
|
||||
{% load static %}
|
||||
{% load i18n %}
|
||||
{% load markdownify %}
|
||||
|
||||
{% block details %}
|
||||
|
||||
{% include "build/tabs.html" with tab='notes' %}
|
||||
|
||||
|
||||
{% if editing %}
|
||||
<h4>{% trans "Build Notes" %}</h4>
|
||||
<hr>
|
||||
<form method='POST'>
|
||||
{% csrf_token %}
|
||||
|
||||
{{ form }}
|
||||
<hr>
|
||||
<input type="submit" value='{% trans "Save" %}'/>
|
||||
|
||||
</form>
|
||||
|
||||
{{ form.media }}
|
||||
|
||||
{% else %}
|
||||
|
||||
<div class='row'>
|
||||
<div class='col-sm-6'>
|
||||
<h4>{% trans "Build Notes" %}</h4>
|
||||
</div>
|
||||
<div class='col-sm-6'>
|
||||
<button title='{% trans "Edit notes" %}' class='btn btn-default btn-glyph float-right' id='edit-notes'><span class='glyphicon glyphicon-edit'></span></button>
|
||||
</div>
|
||||
</div>
|
||||
<hr>
|
||||
<div class='panel panel-default'>
|
||||
<div class='panel-content'>
|
||||
{{ build.notes | markdownify }}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block js_ready %}
|
||||
{{ block.super }}
|
||||
|
||||
{% if editing %}
|
||||
{% else %}
|
||||
$("#edit-notes").click(function() {
|
||||
location.href = "{% url 'build-notes' build.id %}?edit=1";
|
||||
});
|
||||
{% endif %}
|
||||
|
||||
{% endblock %}
|
||||
@@ -1,8 +1,16 @@
|
||||
{% load i18n %}
|
||||
|
||||
<ul class='nav nav-tabs'>
|
||||
<li{% if tab == 'details' %} class='active'{% endif %}>
|
||||
<a href="{% url 'build-detail' build.id %}">Details</a>
|
||||
<a href="{% url 'build-detail' build.id %}">{% trans "Details" %}</a>
|
||||
</li>
|
||||
<li{% if tab == 'output' %} class='active'{% endif %}>
|
||||
<a href="{% url 'build-output' build.id %}">{% trans "Outputs" %}{% if build.output_count > 0%}<span class='badge'>{{ build.output_count }}</span>{% endif %}</a>
|
||||
</li>
|
||||
<li{% if tab == 'notes' %} class='active'{% endif %}>
|
||||
<a href="{% url 'build-notes' build.id %}">{% trans "Notes" %}{% if build.notes %} <span class='glyphicon glyphicon-small glyphicon-info-sign'></span>{% endif %}</a>
|
||||
</li>
|
||||
<li{% if tab == 'allocate' %} class='active'{% endif %}>
|
||||
<a href="{% url 'build-allocate' build.id %}">Assign Parts</a>
|
||||
<a href="{% url 'build-allocate' build.id %}">{% trans "Assign Parts" %}</a>
|
||||
</li>
|
||||
</ul>
|
||||
@@ -1,9 +1,10 @@
|
||||
{% extends "modal_form.html" %}
|
||||
|
||||
{% load i18n %}
|
||||
{% load inventree_extras %}
|
||||
{% block pre_form_content %}
|
||||
|
||||
{{ block.super }}
|
||||
|
||||
Are you sure you wish to unallocate all stock for this build?
|
||||
{% trans "Are you sure you wish to unallocate all stock for this build?" %}
|
||||
|
||||
{% endblock %}
|
||||
@@ -25,6 +25,10 @@ build_detail_urls = [
|
||||
url(r'^auto-allocate/?', views.BuildAutoAllocate.as_view(), name='build-auto-allocate'),
|
||||
url(r'^unallocate/', views.BuildUnallocate.as_view(), name='build-unallocate'),
|
||||
|
||||
url(r'^notes/', views.BuildNotes.as_view(), name='build-notes'),
|
||||
|
||||
url(r'^output/', views.BuildDetail.as_view(template_name='build/build_output.html'), name='build-output'),
|
||||
|
||||
url(r'^.*$', views.BuildDetail.as_view(), name='build-detail'),
|
||||
]
|
||||
|
||||
|
||||
@@ -7,8 +7,9 @@ from __future__ import unicode_literals
|
||||
|
||||
from django.utils.translation import ugettext as _
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.views.generic import DetailView, ListView
|
||||
from django.views.generic import DetailView, ListView, UpdateView
|
||||
from django.forms import HiddenInput
|
||||
from django.urls import reverse
|
||||
|
||||
from part.models import Part
|
||||
from .models import Build, BuildItem
|
||||
@@ -52,7 +53,7 @@ class BuildCancel(AjaxUpdateView):
|
||||
|
||||
model = Build
|
||||
ajax_template_name = 'build/cancel.html'
|
||||
ajax_form_title = 'Cancel Build'
|
||||
ajax_form_title = _('Cancel Build')
|
||||
context_object_name = 'build'
|
||||
form_class = forms.CancelBuildForm
|
||||
|
||||
@@ -70,12 +71,12 @@ class BuildCancel(AjaxUpdateView):
|
||||
if confirm:
|
||||
build.cancelBuild(request.user)
|
||||
else:
|
||||
form.errors['confirm_cancel'] = ['Confirm build cancellation']
|
||||
form.errors['confirm_cancel'] = [_('Confirm build cancellation')]
|
||||
valid = False
|
||||
|
||||
data = {
|
||||
'form_valid': valid,
|
||||
'danger': 'Build was cancelled'
|
||||
'danger': _('Build was cancelled')
|
||||
}
|
||||
|
||||
return self.renderJsonResponse(request, form, data=data)
|
||||
@@ -91,7 +92,7 @@ class BuildAutoAllocate(AjaxUpdateView):
|
||||
model = Build
|
||||
form_class = forms.ConfirmBuildForm
|
||||
context_object_name = 'build'
|
||||
ajax_form_title = 'Allocate Stock'
|
||||
ajax_form_title = _('Allocate Stock')
|
||||
ajax_template_name = 'build/auto_allocate.html'
|
||||
|
||||
def get_context_data(self, *args, **kwargs):
|
||||
@@ -104,7 +105,7 @@ class BuildAutoAllocate(AjaxUpdateView):
|
||||
context['build'] = build
|
||||
context['allocations'] = build.getAutoAllocations()
|
||||
except Build.DoesNotExist:
|
||||
context['error'] = 'No matching build found'
|
||||
context['error'] = _('No matching build found')
|
||||
|
||||
return context
|
||||
|
||||
@@ -123,8 +124,8 @@ class BuildAutoAllocate(AjaxUpdateView):
|
||||
valid = False
|
||||
|
||||
if confirm is False:
|
||||
form.errors['confirm'] = ['Confirm stock allocation']
|
||||
form.non_field_errors = 'Check the confirmation box at the bottom of the list'
|
||||
form.errors['confirm'] = [_('Confirm stock allocation')]
|
||||
form.non_field_errors = _('Check the confirmation box at the bottom of the list')
|
||||
else:
|
||||
build.autoAllocate()
|
||||
valid = True
|
||||
@@ -144,7 +145,7 @@ class BuildUnallocate(AjaxUpdateView):
|
||||
|
||||
model = Build
|
||||
form_class = forms.ConfirmBuildForm
|
||||
ajax_form_title = "Unallocate Stock"
|
||||
ajax_form_title = _("Unallocate Stock")
|
||||
ajax_template_name = "build/unallocate.html"
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
@@ -157,8 +158,8 @@ class BuildUnallocate(AjaxUpdateView):
|
||||
valid = False
|
||||
|
||||
if confirm is False:
|
||||
form.errors['confirm'] = ['Confirm unallocation of build stock']
|
||||
form.non_field_errors = 'Check the confirmation box'
|
||||
form.errors['confirm'] = [_('Confirm unallocation of build stock')]
|
||||
form.non_field_errors = _('Check the confirmation box')
|
||||
else:
|
||||
build.unallocateStock()
|
||||
valid = True
|
||||
@@ -181,7 +182,7 @@ class BuildComplete(AjaxUpdateView):
|
||||
model = Build
|
||||
form_class = forms.CompleteBuildForm
|
||||
context_object_name = "build"
|
||||
ajax_form_title = "Complete Build"
|
||||
ajax_form_title = _("Complete Build")
|
||||
ajax_template_name = "build/complete.html"
|
||||
|
||||
def get_form(self):
|
||||
@@ -254,14 +255,14 @@ class BuildComplete(AjaxUpdateView):
|
||||
|
||||
if confirm is False:
|
||||
form.errors['confirm'] = [
|
||||
'Confirm completion of build',
|
||||
_('Confirm completion of build'),
|
||||
]
|
||||
else:
|
||||
try:
|
||||
location = StockLocation.objects.get(id=loc_id)
|
||||
valid = True
|
||||
except StockLocation.DoesNotExist:
|
||||
form.errors['location'] = ['Invalid location selected']
|
||||
form.errors['location'] = [_('Invalid location selected')]
|
||||
|
||||
serials = []
|
||||
|
||||
@@ -305,10 +306,32 @@ class BuildComplete(AjaxUpdateView):
|
||||
def get_data(self):
|
||||
""" Provide feedback data back to the form """
|
||||
return {
|
||||
'info': 'Build marked as COMPLETE'
|
||||
'info': _('Build marked as COMPLETE')
|
||||
}
|
||||
|
||||
|
||||
class BuildNotes(UpdateView):
|
||||
""" View for editing the 'notes' field of a Build object.
|
||||
"""
|
||||
|
||||
context_object_name = 'build'
|
||||
template_name = 'build/notes.html'
|
||||
model = Build
|
||||
|
||||
fields = ['notes']
|
||||
|
||||
def get_success_url(self):
|
||||
return reverse('build-notes', kwargs={'pk': self.get_object().id})
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
|
||||
ctx = super().get_context_data(**kwargs)
|
||||
|
||||
ctx['editing'] = str2bool(self.request.GET.get('edit', ''))
|
||||
|
||||
return ctx
|
||||
|
||||
|
||||
class BuildDetail(DetailView):
|
||||
""" Detail view of a single Build object. """
|
||||
model = Build
|
||||
@@ -359,7 +382,7 @@ class BuildCreate(AjaxCreateView):
|
||||
model = Build
|
||||
context_object_name = 'build'
|
||||
form_class = forms.EditBuildForm
|
||||
ajax_form_title = 'Start new Build'
|
||||
ajax_form_title = _('Start new Build')
|
||||
ajax_template_name = 'modal_form.html'
|
||||
|
||||
def get_initial(self):
|
||||
@@ -382,7 +405,7 @@ class BuildCreate(AjaxCreateView):
|
||||
|
||||
def get_data(self):
|
||||
return {
|
||||
'success': 'Created new build',
|
||||
'success': _('Created new build'),
|
||||
}
|
||||
|
||||
|
||||
@@ -392,12 +415,12 @@ class BuildUpdate(AjaxUpdateView):
|
||||
model = Build
|
||||
form_class = forms.EditBuildForm
|
||||
context_object_name = 'build'
|
||||
ajax_form_title = 'Edit Build Details'
|
||||
ajax_form_title = _('Edit Build Details')
|
||||
ajax_template_name = 'modal_form.html'
|
||||
|
||||
def get_data(self):
|
||||
return {
|
||||
'info': 'Edited build',
|
||||
'info': _('Edited build'),
|
||||
}
|
||||
|
||||
|
||||
@@ -406,7 +429,7 @@ class BuildDelete(AjaxDeleteView):
|
||||
|
||||
model = Build
|
||||
ajax_template_name = 'build/delete_build.html'
|
||||
ajax_form_title = 'Delete Build'
|
||||
ajax_form_title = _('Delete Build')
|
||||
|
||||
|
||||
class BuildItemDelete(AjaxDeleteView):
|
||||
@@ -416,12 +439,12 @@ class BuildItemDelete(AjaxDeleteView):
|
||||
|
||||
model = BuildItem
|
||||
ajax_template_name = 'build/delete_build_item.html'
|
||||
ajax_form_title = 'Unallocate Stock'
|
||||
ajax_form_title = _('Unallocate Stock')
|
||||
context_object_name = 'item'
|
||||
|
||||
def get_data(self):
|
||||
return {
|
||||
'danger': 'Removed parts from build allocation'
|
||||
'danger': _('Removed parts from build allocation')
|
||||
}
|
||||
|
||||
|
||||
@@ -431,7 +454,7 @@ class BuildItemCreate(AjaxCreateView):
|
||||
model = BuildItem
|
||||
form_class = forms.EditBuildItemForm
|
||||
ajax_template_name = 'build/create_build_item.html'
|
||||
ajax_form_title = 'Allocate new Part'
|
||||
ajax_form_title = _('Allocate new Part')
|
||||
|
||||
part = None
|
||||
available_stock = None
|
||||
@@ -547,11 +570,11 @@ class BuildItemEdit(AjaxUpdateView):
|
||||
model = BuildItem
|
||||
ajax_template_name = 'modal_form.html'
|
||||
form_class = forms.EditBuildItemForm
|
||||
ajax_form_title = 'Edit Stock Allocation'
|
||||
ajax_form_title = _('Edit Stock Allocation')
|
||||
|
||||
def get_data(self):
|
||||
return {
|
||||
'info': 'Updated Build Item',
|
||||
'info': _('Updated Build Item'),
|
||||
}
|
||||
|
||||
def get_form(self):
|
||||
|
||||
@@ -1,10 +1,21 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.contrib import admin
|
||||
|
||||
from .models import Currency
|
||||
from import_export.admin import ImportExportModelAdmin
|
||||
|
||||
from .models import Currency, InvenTreeSetting
|
||||
|
||||
|
||||
class CurrencyAdmin(admin.ModelAdmin):
|
||||
class CurrencyAdmin(ImportExportModelAdmin):
|
||||
list_display = ('symbol', 'suffix', 'description', 'value', 'base')
|
||||
|
||||
|
||||
class SettingsAdmin(ImportExportModelAdmin):
|
||||
|
||||
list_display = ('key', 'value', 'description')
|
||||
|
||||
|
||||
admin.site.register(Currency, CurrencyAdmin)
|
||||
admin.site.register(InvenTreeSetting, SettingsAdmin)
|
||||
|
||||
@@ -1,5 +1,48 @@
|
||||
from django.apps import AppConfig
|
||||
from django.db.utils import OperationalError, ProgrammingError
|
||||
|
||||
import os
|
||||
|
||||
import yaml
|
||||
|
||||
|
||||
class CommonConfig(AppConfig):
|
||||
name = 'common'
|
||||
|
||||
def ready(self):
|
||||
""" Will be called when the Common app is first loaded """
|
||||
self.populate_default_settings()
|
||||
|
||||
def populate_default_settings(self):
|
||||
""" Populate the default values for InvenTree key:value pairs.
|
||||
If a setting does not exist, it will be created.
|
||||
"""
|
||||
|
||||
from .models import InvenTreeSetting
|
||||
|
||||
here = os.path.dirname(os.path.abspath(__file__))
|
||||
settings_file = os.path.join(here, 'kvp.yaml')
|
||||
|
||||
with open(settings_file) as kvp:
|
||||
values = yaml.safe_load(kvp)
|
||||
|
||||
for value in values:
|
||||
key = value['key']
|
||||
default = value['default']
|
||||
description = value['description']
|
||||
|
||||
try:
|
||||
# If a particular setting does not exist in the database, create it now
|
||||
if not InvenTreeSetting.objects.filter(key=key).exists():
|
||||
setting = InvenTreeSetting(
|
||||
key=key,
|
||||
value=default,
|
||||
description=description
|
||||
)
|
||||
|
||||
setting.save()
|
||||
|
||||
print("Creating new key: '{k}' = '{v}'".format(k=key, v=default))
|
||||
except (OperationalError, ProgrammingError):
|
||||
# Migrations have not yet been applied - table does not exist
|
||||
break
|
||||
|
||||
13
InvenTree/common/kvp.yaml
Normal file
13
InvenTree/common/kvp.yaml
Normal file
@@ -0,0 +1,13 @@
|
||||
# This file contains the default values for the key:value settings available in InvenTree
|
||||
# This file should not be edited locally.
|
||||
|
||||
# Note: The description strings provided here will be translatable,
|
||||
# so ensure that any translations are provided as appropriate.
|
||||
|
||||
- key: 'part_ipn_regex'
|
||||
default: ''
|
||||
description: 'Format string for internal part number'
|
||||
|
||||
- key: part_deep_copy
|
||||
default: True
|
||||
description: 'Parts are deep-copied by default'
|
||||
21
InvenTree/common/migrations/0004_inventreesetting.py
Normal file
21
InvenTree/common/migrations/0004_inventreesetting.py
Normal file
@@ -0,0 +1,21 @@
|
||||
# Generated by Django 2.2.5 on 2019-09-15 12:44
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('common', '0003_auto_20190902_2310'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='InvenTreeSetting',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('key', models.CharField(help_text='Settings key', max_length=50, unique=True)),
|
||||
('value', models.CharField(blank=True, help_text='Settings value', max_length=200)),
|
||||
],
|
||||
),
|
||||
]
|
||||
23
InvenTree/common/migrations/0005_auto_20190915_1256.py
Normal file
23
InvenTree/common/migrations/0005_auto_20190915_1256.py
Normal file
@@ -0,0 +1,23 @@
|
||||
# Generated by Django 2.2.5 on 2019-09-15 12:56
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('common', '0004_inventreesetting'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='inventreesetting',
|
||||
name='description',
|
||||
field=models.CharField(blank=True, help_text='Settings description', max_length=200),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='inventreesetting',
|
||||
name='key',
|
||||
field=models.CharField(help_text='Settings key (must be unique - case insensitive', max_length=50, unique=True),
|
||||
),
|
||||
]
|
||||
17
InvenTree/common/migrations/0006_auto_20200203_0951.py
Normal file
17
InvenTree/common/migrations/0006_auto_20200203_0951.py
Normal file
@@ -0,0 +1,17 @@
|
||||
# Generated by Django 2.2.9 on 2020-02-03 09:51
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('common', '0005_auto_20190915_1256'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name='inventreesetting',
|
||||
options={'verbose_name': 'InvenTree Setting', 'verbose_name_plural': 'InvenTree Settings'},
|
||||
),
|
||||
]
|
||||
@@ -9,6 +9,83 @@ from __future__ import unicode_literals
|
||||
from django.db import models
|
||||
from django.utils.translation import ugettext as _
|
||||
from django.core.validators import MinValueValidator, MaxValueValidator
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
|
||||
class InvenTreeSetting(models.Model):
|
||||
"""
|
||||
An InvenTreeSetting object is a key:value pair used for storing
|
||||
single values (e.g. one-off settings values).
|
||||
|
||||
The class provides a way of retrieving the value for a particular key,
|
||||
even if that key does not exist.
|
||||
"""
|
||||
|
||||
class Meta:
|
||||
verbose_name = "InvenTree Setting"
|
||||
verbose_name_plural = "InvenTree Settings"
|
||||
|
||||
@classmethod
|
||||
def get_setting(cls, key, backup_value=None):
|
||||
"""
|
||||
Get the value of a particular setting.
|
||||
If it does not exist, return the backup value (default = None)
|
||||
"""
|
||||
|
||||
try:
|
||||
setting = InvenTreeSetting.objects.get(key__iexact=key)
|
||||
return setting.value
|
||||
except InvenTreeSetting.DoesNotExist:
|
||||
return backup_value
|
||||
|
||||
@classmethod
|
||||
def set_setting(cls, key, value, user, create=True):
|
||||
"""
|
||||
Set the value of a particular setting.
|
||||
If it does not exist, option to create it.
|
||||
|
||||
Args:
|
||||
key: settings key
|
||||
value: New value
|
||||
user: User object (must be staff member to update a core setting)
|
||||
create: If True, create a new setting if the specified key does not exist.
|
||||
"""
|
||||
|
||||
if not user.is_staff:
|
||||
return
|
||||
|
||||
try:
|
||||
setting = InvenTreeSetting.objects.get(key__iexact=key)
|
||||
except InvenTreeSetting.DoesNotExist:
|
||||
|
||||
if create:
|
||||
setting = InvenTreeSetting(key=key)
|
||||
else:
|
||||
return
|
||||
|
||||
setting.value = value
|
||||
setting.save()
|
||||
|
||||
key = models.CharField(max_length=50, blank=False, unique=True, help_text=_('Settings key (must be unique - case insensitive'))
|
||||
|
||||
value = models.CharField(max_length=200, blank=True, unique=False, help_text=_('Settings value'))
|
||||
|
||||
description = models.CharField(max_length=200, blank=True, unique=False, help_text=_('Settings description'))
|
||||
|
||||
def validate_unique(self, exclude=None):
|
||||
""" Ensure that the key:value pair is unique.
|
||||
In addition to the base validators, this ensures that the 'key'
|
||||
is unique, using a case-insensitive comparison.
|
||||
"""
|
||||
|
||||
super().validate_unique(exclude)
|
||||
|
||||
try:
|
||||
setting = InvenTreeSetting.objects.exclude(id=self.id).filter(key__iexact=self.key)
|
||||
if setting.exists():
|
||||
raise ValidationError({'key': _('Key string must be unique')})
|
||||
except InvenTreeSetting.DoesNotExist:
|
||||
pass
|
||||
|
||||
|
||||
class Currency(models.Model):
|
||||
|
||||
@@ -5,6 +5,8 @@ Django views for interacting with common models
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.utils.translation import ugettext as _
|
||||
|
||||
from InvenTree.views import AjaxCreateView, AjaxUpdateView, AjaxDeleteView
|
||||
|
||||
from . import models
|
||||
@@ -16,7 +18,7 @@ class CurrencyCreate(AjaxCreateView):
|
||||
|
||||
model = models.Currency
|
||||
form_class = forms.CurrencyEditForm
|
||||
ajax_form_title = 'Create new Currency'
|
||||
ajax_form_title = _('Create new Currency')
|
||||
|
||||
|
||||
class CurrencyEdit(AjaxUpdateView):
|
||||
@@ -24,12 +26,12 @@ class CurrencyEdit(AjaxUpdateView):
|
||||
|
||||
model = models.Currency
|
||||
form_class = forms.CurrencyEditForm
|
||||
ajax_form_title = 'Edit Currency'
|
||||
ajax_form_title = _('Edit Currency')
|
||||
|
||||
|
||||
class CurrencyDelete(AjaxDeleteView):
|
||||
""" View for deleting an existing Currency object """
|
||||
|
||||
model = models.Currency
|
||||
ajax_form_title = 'Delete Currency'
|
||||
ajax_form_title = _('Delete Currency')
|
||||
ajax_template_name = "common/delete_currency.html"
|
||||
|
||||
@@ -1,20 +1,91 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.contrib import admin
|
||||
|
||||
from import_export.admin import ImportExportModelAdmin
|
||||
from import_export.resources import ModelResource
|
||||
from import_export.fields import Field
|
||||
import import_export.widgets as widgets
|
||||
|
||||
from .models import Company
|
||||
from .models import SupplierPart
|
||||
from .models import SupplierPriceBreak
|
||||
|
||||
from part.models import Part
|
||||
from common.models import Currency
|
||||
|
||||
|
||||
class CompanyResource(ModelResource):
|
||||
""" Class for managing Company data import/export """
|
||||
|
||||
class Meta:
|
||||
model = Company
|
||||
skip_unchanged = True
|
||||
report_skipped = False
|
||||
clean_model_instances = True
|
||||
|
||||
|
||||
class CompanyAdmin(ImportExportModelAdmin):
|
||||
|
||||
resource_class = CompanyResource
|
||||
|
||||
list_display = ('name', 'website', 'contact')
|
||||
|
||||
|
||||
class SupplierPartResource(ModelResource):
|
||||
""" Class for managing SupplierPart data import/export """
|
||||
|
||||
part = Field(attribute='part', widget=widgets.ForeignKeyWidget(Part))
|
||||
|
||||
part_name = Field(attribute='part__full_name', readonly=True)
|
||||
|
||||
supplier = Field(attribute='supplier', widget=widgets.ForeignKeyWidget(Company))
|
||||
|
||||
supplier_name = Field(attribute='supplier__name', readonly=True)
|
||||
|
||||
class Meta:
|
||||
model = SupplierPart
|
||||
skip_unchanged = True
|
||||
report_skipped = False
|
||||
clean_model_instances = True
|
||||
|
||||
|
||||
class SupplierPartAdmin(ImportExportModelAdmin):
|
||||
|
||||
resource_class = SupplierPartResource
|
||||
|
||||
list_display = ('part', 'supplier', 'SKU')
|
||||
|
||||
|
||||
class SupplierPriceBreakResource(ModelResource):
|
||||
""" Class for managing SupplierPriceBreak data import/export """
|
||||
|
||||
part = Field(attribute='part', widget=widgets.ForeignKeyWidget(SupplierPart))
|
||||
|
||||
currency = Field(attribute='currency', widget=widgets.ForeignKeyWidget(Currency))
|
||||
|
||||
supplier_id = Field(attribute='part__supplier__pk', readonly=True)
|
||||
|
||||
supplier_name = Field(attribute='part__supplier__name', readonly=True)
|
||||
|
||||
part_name = Field(attribute='part__part__full_name', readonly=True)
|
||||
|
||||
SKU = Field(attribute='part__SKU', readonly=True)
|
||||
|
||||
MPN = Field(attribute='part__MPN', readonly=True)
|
||||
|
||||
class Meta:
|
||||
model = SupplierPriceBreak
|
||||
skip_unchanged = True
|
||||
report_skipped = False
|
||||
clean_model_instances = True
|
||||
|
||||
|
||||
class SupplierPriceBreakAdmin(ImportExportModelAdmin):
|
||||
|
||||
resource_class = SupplierPriceBreakResource
|
||||
|
||||
list_display = ('part', 'quantity', 'cost')
|
||||
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ class CompanyList(generics.ListCreateAPIView):
|
||||
|
||||
serializer_class = CompanySerializer
|
||||
queryset = Company.objects.all()
|
||||
|
||||
permission_classes = [
|
||||
permissions.IsAuthenticated,
|
||||
]
|
||||
|
||||
@@ -15,6 +15,13 @@
|
||||
supplier: 1
|
||||
SKU: 'ACME0002'
|
||||
|
||||
- model: company.supplierpart
|
||||
pk: 3
|
||||
fields:
|
||||
part: 1
|
||||
supplier: 1
|
||||
SKU: 'ACME0003'
|
||||
|
||||
# Widget purchaseable from ACME
|
||||
- model: company.supplierpart
|
||||
pk: 100
|
||||
@@ -33,7 +40,7 @@
|
||||
|
||||
# M2x4 LPHS from Zerg Corp
|
||||
- model: company.supplierpart
|
||||
pk: 3
|
||||
pk: 7
|
||||
fields:
|
||||
part: 1
|
||||
supplier: 3
|
||||
|
||||
@@ -6,6 +6,7 @@ Django Forms for interacting with Company app
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from InvenTree.forms import HelperForm
|
||||
from InvenTree.fields import RoundingDecimalFormField
|
||||
|
||||
from .models import Company
|
||||
from .models import SupplierPart
|
||||
@@ -27,7 +28,6 @@ class EditCompanyForm(HelperForm):
|
||||
'contact',
|
||||
'is_customer',
|
||||
'is_supplier',
|
||||
'notes'
|
||||
]
|
||||
|
||||
|
||||
@@ -65,6 +65,10 @@ class EditSupplierPartForm(HelperForm):
|
||||
class EditPriceBreakForm(HelperForm):
|
||||
""" Form for creating / editing a supplier price break """
|
||||
|
||||
quantity = RoundingDecimalFormField(max_digits=10, decimal_places=5)
|
||||
|
||||
cost = RoundingDecimalFormField(max_digits=10, decimal_places=5)
|
||||
|
||||
class Meta:
|
||||
model = SupplierPriceBreak
|
||||
fields = [
|
||||
|
||||
24
InvenTree/company/migrations/0008_auto_20190913_1407.py
Normal file
24
InvenTree/company/migrations/0008_auto_20190913_1407.py
Normal file
@@ -0,0 +1,24 @@
|
||||
# Generated by Django 2.2.5 on 2019-09-13 14:07
|
||||
|
||||
import InvenTree.fields
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('company', '0007_remove_supplierpart_lead_time'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='company',
|
||||
name='URL',
|
||||
field=InvenTree.fields.InvenTreeURLField(blank=True, help_text='Link to external company information'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='supplierpart',
|
||||
name='URL',
|
||||
field=InvenTree.fields.InvenTreeURLField(blank=True, help_text='URL for external supplier part link'),
|
||||
),
|
||||
]
|
||||
19
InvenTree/company/migrations/0009_auto_20191118_2323.py
Normal file
19
InvenTree/company/migrations/0009_auto_20191118_2323.py
Normal file
@@ -0,0 +1,19 @@
|
||||
# Generated by Django 2.2.5 on 2019-11-18 23:23
|
||||
|
||||
import django.core.validators
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('company', '0008_auto_20190913_1407'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='supplierpricebreak',
|
||||
name='quantity',
|
||||
field=models.DecimalField(decimal_places=5, default=1, max_digits=15, validators=[django.core.validators.MinValueValidator(1)]),
|
||||
),
|
||||
]
|
||||
19
InvenTree/company/migrations/0010_auto_20200201_1231.py
Normal file
19
InvenTree/company/migrations/0010_auto_20200201_1231.py
Normal file
@@ -0,0 +1,19 @@
|
||||
# Generated by Django 2.2.9 on 2020-02-01 12:31
|
||||
|
||||
from django.db import migrations
|
||||
import markdownx.models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('company', '0009_auto_20191118_2323'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='company',
|
||||
name='notes',
|
||||
field=markdownx.models.MarkdownxField(blank=True),
|
||||
),
|
||||
]
|
||||
20
InvenTree/company/migrations/0011_auto_20200318_1114.py
Normal file
20
InvenTree/company/migrations/0011_auto_20200318_1114.py
Normal file
@@ -0,0 +1,20 @@
|
||||
# Generated by Django 2.2.9 on 2020-03-18 11:14
|
||||
|
||||
import InvenTree.fields
|
||||
import django.core.validators
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('company', '0010_auto_20200201_1231'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='supplierpricebreak',
|
||||
name='cost',
|
||||
field=InvenTree.fields.RoundingDecimalField(decimal_places=5, max_digits=10, validators=[django.core.validators.MinValueValidator(0)]),
|
||||
),
|
||||
]
|
||||
20
InvenTree/company/migrations/0012_auto_20200318_1114.py
Normal file
20
InvenTree/company/migrations/0012_auto_20200318_1114.py
Normal file
@@ -0,0 +1,20 @@
|
||||
# Generated by Django 2.2.9 on 2020-03-18 11:14
|
||||
|
||||
import InvenTree.fields
|
||||
import django.core.validators
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('company', '0011_auto_20200318_1114'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='supplierpricebreak',
|
||||
name='quantity',
|
||||
field=InvenTree.fields.RoundingDecimalField(decimal_places=5, default=1, max_digits=15, validators=[django.core.validators.MinValueValidator(1)]),
|
||||
),
|
||||
]
|
||||
@@ -10,6 +10,7 @@ import os
|
||||
import math
|
||||
from decimal import Decimal
|
||||
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.core.validators import MinValueValidator
|
||||
from django.db import models
|
||||
from django.db.models import Sum
|
||||
@@ -17,8 +18,10 @@ from django.db.models import Sum
|
||||
from django.apps import apps
|
||||
from django.urls import reverse
|
||||
from django.conf import settings
|
||||
from django.contrib.staticfiles.templatetags.staticfiles import static
|
||||
|
||||
from markdownx.models import MarkdownxField
|
||||
|
||||
from InvenTree.fields import InvenTreeURLField, RoundingDecimalField
|
||||
from InvenTree.status_codes import OrderStatus
|
||||
from common.models import Currency
|
||||
|
||||
@@ -68,32 +71,32 @@ class Company(models.Model):
|
||||
"""
|
||||
|
||||
name = models.CharField(max_length=100, blank=False, unique=True,
|
||||
help_text='Company name')
|
||||
help_text=_('Company name'))
|
||||
|
||||
description = models.CharField(max_length=500, help_text='Description of the company')
|
||||
description = models.CharField(max_length=500, help_text=_('Description of the company'))
|
||||
|
||||
website = models.URLField(blank=True, help_text='Company website URL')
|
||||
website = models.URLField(blank=True, help_text=_('Company website URL'))
|
||||
|
||||
address = models.CharField(max_length=200,
|
||||
blank=True, help_text='Company address')
|
||||
blank=True, help_text=_('Company address'))
|
||||
|
||||
phone = models.CharField(max_length=50,
|
||||
blank=True, help_text='Contact phone number')
|
||||
blank=True, help_text=_('Contact phone number'))
|
||||
|
||||
email = models.EmailField(blank=True, help_text='Contact email address')
|
||||
email = models.EmailField(blank=True, help_text=_('Contact email address'))
|
||||
|
||||
contact = models.CharField(max_length=100,
|
||||
blank=True, help_text='Point of contact')
|
||||
blank=True, help_text=_('Point of contact'))
|
||||
|
||||
URL = models.URLField(blank=True, help_text='Link to external company information')
|
||||
URL = InvenTreeURLField(blank=True, help_text=_('Link to external company information'))
|
||||
|
||||
image = models.ImageField(upload_to=rename_company_image, max_length=255, null=True, blank=True)
|
||||
|
||||
notes = models.TextField(blank=True)
|
||||
notes = MarkdownxField(blank=True)
|
||||
|
||||
is_customer = models.BooleanField(default=False, help_text='Do you sell items to this company?')
|
||||
is_customer = models.BooleanField(default=False, help_text=_('Do you sell items to this company?'))
|
||||
|
||||
is_supplier = models.BooleanField(default=True, help_text='Do you purchase items from this company?')
|
||||
is_supplier = models.BooleanField(default=True, help_text=_('Do you purchase items from this company?'))
|
||||
|
||||
def __str__(self):
|
||||
""" Get string representation of a Company """
|
||||
@@ -109,7 +112,7 @@ class Company(models.Model):
|
||||
if self.image:
|
||||
return os.path.join(settings.MEDIA_URL, str(self.image.url))
|
||||
else:
|
||||
return static('/img/blank_image.png')
|
||||
return os.path.join(settings.STATIC_URL, 'img/blank_image.png')
|
||||
|
||||
@property
|
||||
def part_count(self):
|
||||
@@ -223,32 +226,32 @@ class SupplierPart(models.Model):
|
||||
'purchaseable': True,
|
||||
'is_template': False,
|
||||
},
|
||||
help_text='Select part',
|
||||
help_text=_('Select part'),
|
||||
)
|
||||
|
||||
supplier = models.ForeignKey(Company, on_delete=models.CASCADE,
|
||||
related_name='parts',
|
||||
limit_choices_to={'is_supplier': True},
|
||||
help_text='Select supplier',
|
||||
help_text=_('Select supplier'),
|
||||
)
|
||||
|
||||
SKU = models.CharField(max_length=100, help_text='Supplier stock keeping unit')
|
||||
SKU = models.CharField(max_length=100, help_text=_('Supplier stock keeping unit'))
|
||||
|
||||
manufacturer = models.CharField(max_length=100, blank=True, help_text='Manufacturer')
|
||||
manufacturer = models.CharField(max_length=100, blank=True, help_text=_('Manufacturer'))
|
||||
|
||||
MPN = models.CharField(max_length=100, blank=True, help_text='Manufacturer part number')
|
||||
MPN = models.CharField(max_length=100, blank=True, help_text=_('Manufacturer part number'))
|
||||
|
||||
URL = models.URLField(blank=True, help_text='URL for external supplier part link')
|
||||
URL = InvenTreeURLField(blank=True, help_text=_('URL for external supplier part link'))
|
||||
|
||||
description = models.CharField(max_length=250, blank=True, help_text='Supplier part description')
|
||||
description = models.CharField(max_length=250, blank=True, help_text=_('Supplier part description'))
|
||||
|
||||
note = models.CharField(max_length=100, blank=True, help_text='Notes')
|
||||
note = models.CharField(max_length=100, blank=True, 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)], help_text=_('Minimum charge (e.g. stocking fee)'))
|
||||
|
||||
packaging = models.CharField(max_length=50, blank=True, help_text='Part packaging')
|
||||
packaging = models.CharField(max_length=50, blank=True, help_text=_('Part packaging'))
|
||||
|
||||
multiple = models.PositiveIntegerField(default=1, validators=[MinValueValidator(1)], help_text='Order multiple')
|
||||
multiple = models.PositiveIntegerField(default=1, validators=[MinValueValidator(1)], help_text=('Order multiple'))
|
||||
|
||||
# TODO - Reimplement lead-time as a charfield with special validation (pattern matching).
|
||||
# lead_time = models.DurationField(blank=True, null=True)
|
||||
@@ -378,9 +381,9 @@ class SupplierPriceBreak(models.Model):
|
||||
|
||||
part = models.ForeignKey(SupplierPart, on_delete=models.CASCADE, related_name='pricebreaks')
|
||||
|
||||
quantity = models.PositiveIntegerField(default=1, validators=[MinValueValidator(1)])
|
||||
quantity = RoundingDecimalField(max_digits=15, decimal_places=5, default=1, validators=[MinValueValidator(1)])
|
||||
|
||||
cost = models.DecimalField(max_digits=10, decimal_places=5, validators=[MinValueValidator(0)])
|
||||
cost = RoundingDecimalField(max_digits=10, decimal_places=5, validators=[MinValueValidator(0)])
|
||||
|
||||
currency = models.ForeignKey(Currency, blank=True, null=True, on_delete=models.SET_NULL)
|
||||
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% load static %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block page_title %}
|
||||
InvenTree | Company - {{ company.name }}
|
||||
InvenTree | {% trans "Company" %} - {{ company.name }}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
@@ -44,27 +45,27 @@ InvenTree | Company - {{ company.name }}
|
||||
<table class="table">
|
||||
{% if company.website %}
|
||||
<tr>
|
||||
<td>Website</td><td><a href="{{ company.website }}">{{ company.website }}</a></td>
|
||||
<td>{% trans "Website" %}</td><td><a href="{{ company.website }}">{{ company.website }}</a></td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% if company.address %}
|
||||
<tr>
|
||||
<td>Address</td><td>{{ company.address }}</td>
|
||||
<td>{% trans "Address" %}</td><td>{{ company.address }}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% if company.phone %}
|
||||
<tr>
|
||||
<td>Phone</td><td>{{ company.phone }}</td>
|
||||
<td>{% trans "Phone" %}</td><td>{{ company.phone }}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% if company.email %}
|
||||
<tr>
|
||||
<td>Email</td><td>{{ company.email }}</td>
|
||||
<td>{% trans "Email" %}</td><td>{{ company.email }}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% if company.contact %}
|
||||
<tr>
|
||||
<td>Contact</td><td>{{ company.contact }}</td>
|
||||
<td>{% trans "Contact" %}</td><td>{{ company.contact }}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
</table>
|
||||
@@ -99,7 +100,7 @@ InvenTree | Company - {{ company.name }}
|
||||
});
|
||||
|
||||
$("#company-order-2").click(function() {
|
||||
launchModalForm("{% url 'purchase-order-create' %}",
|
||||
launchModalForm("{% url 'po-create' %}",
|
||||
{
|
||||
data: {
|
||||
supplier: {{ company.id }},
|
||||
|
||||
@@ -1,30 +1,24 @@
|
||||
{% extends "company/company_base.html" %}
|
||||
{% load static %}
|
||||
{% load i18n %}}
|
||||
{% block details %}
|
||||
|
||||
{% include 'company/tabs.html' with tab='details' %}
|
||||
|
||||
<h4>Company Details</h4>
|
||||
<h4>{% trans "Company Details" %}</h4>
|
||||
<hr>
|
||||
|
||||
<table class='table table-striped'>
|
||||
<tr>
|
||||
<td>Customer</td>
|
||||
<td>{% trans "Customer" %}</td>
|
||||
<td>{% include 'yesnolabel.html' with value=company.is_customer %}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Supplier</td>
|
||||
<td>{% trans "Supplier" %}</td>
|
||||
<td>{% include 'yesnolabel.html' with value=company.is_supplier %}</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
{% if company.notes %}
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading"><b>Notes</b></div>
|
||||
<div class="panel-body">{{ company.notes }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% endblock %}
|
||||
{% block js_ready %}
|
||||
{{ block.super }}
|
||||
|
||||
@@ -1,20 +1,22 @@
|
||||
{% extends "company/company_base.html" %}
|
||||
{% load static %}
|
||||
{% block details %}
|
||||
{% load i18n %}
|
||||
|
||||
{% include 'company/tabs.html' with tab='parts' %}
|
||||
|
||||
<h4>Supplier Parts</h4>
|
||||
<h4>{% trans "Supplier Parts" %}</h4>
|
||||
|
||||
<hr>
|
||||
|
||||
<div id='button-toolbar'>
|
||||
<button class="btn btn-success" id='part-create'>New Supplier Part</button>
|
||||
<button class="btn btn-success" id='part-create'>{% trans "New Supplier Part" %}</button>
|
||||
<div class="dropdown" style="float: right;">
|
||||
<button class="btn btn-primary dropdown-toggle" type="button" data-toggle="dropdown">Options
|
||||
<button 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='multi-part-order' title='Order parts'>Order Parts</a></li>
|
||||
<li><a href='#' id='multi-part-order' title='Order parts'>{% trans "Order Parts" %}</a></li>
|
||||
<li><a href='#' id='multi-part-delete' title='Delete parts'>{% trans "Delete Parts" %}</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
@@ -45,11 +47,7 @@
|
||||
});
|
||||
});
|
||||
|
||||
$("#part-table").bootstrapTable({
|
||||
sortable: true,
|
||||
search: true,
|
||||
pagination: true,
|
||||
pageSize: 50,
|
||||
$("#part-table").inventreeTable({
|
||||
formatNoMatches: function() { return "No supplier parts found for {{ company.name }}"; },
|
||||
queryParams: function(p) {
|
||||
return {
|
||||
@@ -64,7 +62,7 @@
|
||||
{
|
||||
sortable: true,
|
||||
field: 'part_detail.full_name',
|
||||
title: 'Part',
|
||||
title: '{% trans "Part" %}',
|
||||
formatter: function(value, row, index, field) {
|
||||
return imageHoverIcon(row.part_detail.image_url) + renderLink(value, '/part/' + row.part + '/suppliers/');
|
||||
}
|
||||
@@ -72,7 +70,7 @@
|
||||
{
|
||||
sortable: true,
|
||||
field: 'SKU',
|
||||
title: 'SKU',
|
||||
title: '{% trans "SKU" %}',
|
||||
formatter: function(value, row, index, field) {
|
||||
return renderLink(value, row.url);
|
||||
}
|
||||
@@ -80,7 +78,7 @@
|
||||
{
|
||||
sortable: true,
|
||||
field: 'manufacturer',
|
||||
title: 'Manufacturer',
|
||||
title: '{% trans "Manufacturer" %}',
|
||||
},
|
||||
{
|
||||
sortable: true,
|
||||
@@ -89,7 +87,7 @@
|
||||
},
|
||||
{
|
||||
field: 'URL',
|
||||
title: 'URL',
|
||||
title: '{% trans "URL" %}',
|
||||
formatter: function(value, row, index, field) {
|
||||
if (value) {
|
||||
return renderLink(value, value);
|
||||
@@ -102,6 +100,23 @@
|
||||
url: "{% url 'api-part-supplier-list' %}"
|
||||
});
|
||||
|
||||
$("#multi-part-delete").click(function() {
|
||||
var selections = $("#part-table").bootstrapTable("getSelections");
|
||||
|
||||
var parts = [];
|
||||
|
||||
selections.forEach(function(item) {
|
||||
parts.push(item.pk);
|
||||
});
|
||||
|
||||
launchModalForm("{% url 'supplier-part-delete' %}", {
|
||||
data: {
|
||||
parts: parts,
|
||||
},
|
||||
reload: true,
|
||||
});
|
||||
});
|
||||
|
||||
$("#multi-part-order").click(function() {
|
||||
var selections = $("#part-table").bootstrapTable("getSelections");
|
||||
|
||||
|
||||
@@ -1,31 +1,34 @@
|
||||
{% extends "company/company_base.html" %}
|
||||
{% load static %}
|
||||
{% block details %}
|
||||
{% load i18n %}
|
||||
|
||||
{% include 'company/tabs.html' with tab='po' %}
|
||||
|
||||
<h4>Open Purchase Orders</h4>
|
||||
<h4>{% trans "Purchase Orders" %}</h4>
|
||||
<hr>
|
||||
|
||||
<div id='button-bar'>
|
||||
<div class='btn-group'>
|
||||
<button class='btn btn-primary' type='button' id='company-order-2' title='Create new purchase order'>New Purchase Order</button>
|
||||
<button class='btn btn-primary' type='button' id='company-order2' title='Create new purchase order'>{% trans "New Purchase Order" %}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% include "order/po_table.html" with orders=company.outstanding_purchase_orders.all toolbar='#button-bar' %}
|
||||
|
||||
{% if company.closed_purchase_orders.count > 0 %}
|
||||
{% include "order/po_table_collapse.html" with title="Closed Orders" orders=company.closed_purchase_orders.all %}
|
||||
{% endif %}
|
||||
<table class='table table-striped table-condensed po-table' id='purchase-order-table' data-toolbar='#button-bar'>
|
||||
</table>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block js_ready %}
|
||||
{{ block.super }}
|
||||
|
||||
loadPurchaseOrderTable($("#purchase-order-table"), {
|
||||
url: "{% url 'api-po-list' %}?supplier={{ company.id }}",
|
||||
});
|
||||
|
||||
|
||||
function newOrder() {
|
||||
launchModalForm("{% url 'purchase-order-create' %}",
|
||||
launchModalForm("{% url 'po-create' %}",
|
||||
{
|
||||
data: {
|
||||
supplier: {{ company.id }},
|
||||
@@ -38,13 +41,11 @@
|
||||
newOrder();
|
||||
});
|
||||
|
||||
$("#company-order-2").click(function() {
|
||||
$("#company-order2").click(function() {
|
||||
newOrder();
|
||||
});
|
||||
|
||||
$("#po-table").bootstrapTable({
|
||||
search: true,
|
||||
sortable: true,
|
||||
$(".po-table").inventreeTable({
|
||||
});
|
||||
|
||||
{% endblock %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
{% extends "company/company_base.html" %}
|
||||
{% load static %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block details %}
|
||||
|
||||
{% include "company/tabs.html" with tab='stock' %}
|
||||
|
||||
<h4>Supplier Stock</h4>
|
||||
<h4>{% trans "Supplier Stock" %}</h4>
|
||||
|
||||
<hr>
|
||||
|
||||
@@ -29,7 +30,7 @@
|
||||
|
||||
$("#stock-export").click(function() {
|
||||
launchModalForm("{% url 'stock-export-options' %}", {
|
||||
submit_text: "Export",
|
||||
submit_text: '{% trans "Export" %}',
|
||||
success: function(response) {
|
||||
var url = "{% url 'stock-export' %}";
|
||||
|
||||
|
||||
@@ -1,19 +1,20 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% load static %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block page_title %}
|
||||
InvenTree | Supplier List
|
||||
InvenTree | {% trans "Supplier List" %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<h3>Supplier List</h3>
|
||||
<h3>{% trans "Supplier List" %}</h3>
|
||||
<hr>
|
||||
|
||||
<div id='button-toolbar'>
|
||||
<div class='btn-group'>
|
||||
<button type='button' class="btn btn-success" id='new-company' title='Add new supplier'>New Supplier</button>
|
||||
<button type='button' class="btn btn-success" id='new-company' title='Add new supplier'>{% trans "New Supplier" %}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -32,21 +33,17 @@ InvenTree | Supplier List
|
||||
});
|
||||
});
|
||||
|
||||
$("#company-table").bootstrapTable({
|
||||
sortable: true,
|
||||
search: true,
|
||||
pagination: true,
|
||||
pageSize: 50,
|
||||
$("#company-table").inventreeTable({
|
||||
formatNoMatches: function() { return "No company information found"; },
|
||||
columns: [
|
||||
{
|
||||
field: 'pk',
|
||||
title: 'ID',
|
||||
title: '{% trans "ID" %}',
|
||||
visible: false,
|
||||
},
|
||||
{
|
||||
field: 'name',
|
||||
title: 'Supplier',
|
||||
title: '{% trans "Supplier" %}',
|
||||
sortable: true,
|
||||
formatter: function(value, row, index, field) {
|
||||
return imageHoverIcon(row.image) + renderLink(value, row.url);
|
||||
@@ -54,12 +51,12 @@ InvenTree | Supplier List
|
||||
},
|
||||
{
|
||||
field: 'description',
|
||||
title: 'Description',
|
||||
title: '{% trans "Description" %}',
|
||||
sortable: true,
|
||||
},
|
||||
{
|
||||
field: 'website',
|
||||
title: 'Website',
|
||||
title: '{% trans "Website" %}',
|
||||
formatter: function(value, row, index, field) {
|
||||
if (value) {
|
||||
return renderLink(value, value);
|
||||
@@ -69,7 +66,7 @@ InvenTree | Supplier List
|
||||
},
|
||||
{
|
||||
field: 'part_count',
|
||||
title: 'Parts',
|
||||
title: '{% trans "Parts" %}',
|
||||
sortable: true,
|
||||
formatter: function(value, row, index, field) {
|
||||
return renderLink(value, row.url + 'parts/');
|
||||
|
||||
53
InvenTree/company/templates/company/notes.html
Normal file
53
InvenTree/company/templates/company/notes.html
Normal file
@@ -0,0 +1,53 @@
|
||||
{% extends "company/company_base.html" %}
|
||||
{% load static %}
|
||||
{% load i18n %}
|
||||
{% block details %}
|
||||
{% load markdownify %}
|
||||
|
||||
{% include 'company/tabs.html' with tab='notes' %}
|
||||
|
||||
{% if editing %}
|
||||
<h4>{% trans "Company Notes" %}</h4>
|
||||
<hr>
|
||||
<form method='POST'>
|
||||
{% csrf_token %}
|
||||
|
||||
{{ form }}
|
||||
<hr>
|
||||
<input type="submit" value='{% trans "Save" %}'/>
|
||||
|
||||
</form>
|
||||
|
||||
{{ form.media }}
|
||||
|
||||
{% else %}
|
||||
|
||||
<div class='row'>
|
||||
<div class='col-sm-6'>
|
||||
<h4>{% trans "Company Notes" %}</h4>
|
||||
</div>
|
||||
<div class='col-sm-6'>
|
||||
<button title='{% trans "Edit notes" %}' class='btn btn-default btn-glyph float-right' id='edit-notes'><span class='glyphicon glyphicon-edit'></span></button>
|
||||
</div>
|
||||
</div>
|
||||
<hr>
|
||||
<div class='panel panel-default'>
|
||||
<div class='panel-content'>
|
||||
{{ company.notes | markdownify }}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block js_ready %}
|
||||
{{ block.super }}
|
||||
|
||||
{% if editing %}
|
||||
{% else %}
|
||||
$("#edit-notes").click(function() {
|
||||
location.href = "{% url 'company-notes' company.id %}?edit=1";
|
||||
});
|
||||
{% endif %}
|
||||
|
||||
{% endblock %}
|
||||
@@ -1,49 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<h3>Supplier Order Details</h3>
|
||||
|
||||
<table class='table table-striped'>
|
||||
<tr>
|
||||
<td>Reference</td>
|
||||
<td>{{ order.internal_ref }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Supplier</td>
|
||||
<td>
|
||||
{% if order.supplier %}
|
||||
<a href="{% url 'supplier-detail-orders' order.supplier.id %}">{{ order.supplier.name }}</a>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Status</td>
|
||||
<td>{% include "supplier/order_status.html" with order=order %}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Created Date</td>
|
||||
<td>{{ order.created_date }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Issued Date</td>
|
||||
<td>{{ order.issued_date }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Delivered Date</td>
|
||||
<td>{{ order.delivery_date }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
|
||||
{% if order.notes %}
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading"><b>Notes</b></div>
|
||||
<div class="panel-body">{{ order.notes }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<h2>TODO</h2>
|
||||
Here we list all the line ites which exist under this order...
|
||||
|
||||
{% endblock %}
|
||||
@@ -1,5 +1,28 @@
|
||||
{% extends "modal_delete_form.html" %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block pre_form_content %}
|
||||
Are you sure you want to delete this supplier part?
|
||||
{% trans "Are you sure you want to delete the following Supplier Parts?" %}
|
||||
|
||||
<hr>
|
||||
{% endblock %}
|
||||
|
||||
{% block form_data %}
|
||||
<table class='table table-striped table-condensed'>
|
||||
{% for part in parts %}
|
||||
<tr>
|
||||
<input type='hidden' name='supplier-part-{{ part.id}}' value='supplier-part-{{ part.id }}'/>
|
||||
|
||||
<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 }}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
|
||||
{% endblock %}
|
||||
@@ -1,171 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
{% load static %}
|
||||
|
||||
{% block page_title %}
|
||||
InvenTree | {{ company.name }} - Parts
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<div class='row'>
|
||||
<div class='col-sm-6'>
|
||||
<h3>Supplier Part</h3>
|
||||
<div class='btn-row'>
|
||||
<div class='btn-group'>
|
||||
<button type='button' class='btn btn-default btn-glyph' id='edit-part' title='Edit supplier part'>
|
||||
<span class='glyphicon glyphicon-edit'/>
|
||||
</button>
|
||||
<button type='button' class='btn btn-default btn-glyph' id='delete-part' title='Delete supplier part'>
|
||||
<span class='glyphicon glyphicon-trash'/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class='col-sm-6'>
|
||||
<div class='media-left'>
|
||||
<img class='part-thumb'
|
||||
{% if part.part.image %}
|
||||
src='{{ part.part.image.url }}'
|
||||
{% else %}
|
||||
src="{% static 'img/blank_image.png' %}"
|
||||
{% endif %}/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
|
||||
<div class='row'>
|
||||
<div class='col-sm-6'>
|
||||
<h4>Supplier Part Details</h4>
|
||||
<table class="table table-striped table-condensed">
|
||||
<tr>
|
||||
<td>Internal Part</td>
|
||||
<td>
|
||||
{% if part.part %}
|
||||
<a href="{% url 'part-suppliers' part.part.id %}">{{ part.part.full_name }}</a>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr><td>Supplier</td><td><a href="{% url 'company-detail-parts' part.supplier.id %}">{{ part.supplier.name }}</a></td></tr>
|
||||
<tr><td>SKU</td><td>{{ part.SKU }}</tr></tr>
|
||||
{% if part.URL %}
|
||||
<tr><td>URL</td><td><a href="{{ part.URL }}">{{ part.URL }}</a></td></tr>
|
||||
{% endif %}
|
||||
{% if part.description %}
|
||||
<tr><td>Description</td><td>{{ part.description }}</td></tr>
|
||||
{% endif %}
|
||||
{% if part.manufacturer %}
|
||||
<tr><td>Manufacturer</td><td>{{ part.manufacturer }}</td></tr>
|
||||
<tr><td>MPN</td><td>{{ part.MPN }}</td></tr>
|
||||
{% endif %}
|
||||
{% if part.note %}
|
||||
<tr><td>Note</td><td>{{ part.note }}</td></tr>
|
||||
{% endif %}
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class='col-sm-6'>
|
||||
<h4>Pricing Information</h4>
|
||||
<table class="table table-striped table-condensed">
|
||||
<tr><td>Order Multiple</td><td>{{ part.multiple }}</td></tr>
|
||||
{% if part.base_cost > 0 %}
|
||||
<tr><td>Base Price (Flat Fee)</td><td>{{ part.base_cost }}</td></tr>
|
||||
{% endif %}
|
||||
<tr>
|
||||
<th>Price Breaks</th>
|
||||
<th>
|
||||
<div style='float: right;'>
|
||||
<button class='btn btn-primary' id='new-price-break' type='button'>New Price Break</button>
|
||||
</div>
|
||||
</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Quantity</th>
|
||||
<th>Price</th>
|
||||
</tr>
|
||||
{% if part.price_breaks.all %}
|
||||
{% for pb in part.price_breaks.all %}
|
||||
<tr>
|
||||
<td>{{ pb.quantity }}</td>
|
||||
<td>
|
||||
{% if pb.currency %}{{ pb.currency.symbol }}{% endif %}
|
||||
{{ pb.cost }}
|
||||
{% if pb.currency %}{{ pb.currency.suffix }}{% endif %}
|
||||
<div class='btn-group' style='float: right;'>
|
||||
<button title='Edit Price Break' class='btn btn-default btn-sm' type='button' url="{% url 'price-break-edit' pb.id %}"><span class='glyphicon glyphicon-edit'></span></button>
|
||||
<button title='Delete Price Break' class='btn btn-default btn-sm' type='button' url="{% url 'price-break-delete' pb.id %}"><span class='glyphicon glyphicon-trash'></span></button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan='2'>
|
||||
<span class='warning-msg'><i>No price breaks have been added for this part</i></span>
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
|
||||
<h4>Purchase Orders</h4>
|
||||
{% include "order/po_table.html" with orders=part.purchase_orders %}
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block js_ready %}
|
||||
{{ block.super }}
|
||||
$('#edit-part').click(function () {
|
||||
launchModalForm(
|
||||
"{% url 'supplier-part-edit' part.id %}",
|
||||
{
|
||||
reload: true
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
$('#delete-part').click(function() {
|
||||
launchModalForm(
|
||||
"{% url 'supplier-part-delete' part.id %}",
|
||||
{
|
||||
redirect: "{% url 'company-index' %}"
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
$('#new-price-break').click(function() {
|
||||
launchModalForm("{% url 'price-break-create' %}",
|
||||
{
|
||||
reload: true,
|
||||
data: {
|
||||
part: {{ part.id }},
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
$('.pb-edit-button').click(function() {
|
||||
var button = $(this);
|
||||
|
||||
launchModalForm(button.attr('url'),
|
||||
{
|
||||
reload: true,
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
$('.pb-delete-button').click(function() {
|
||||
var button = $(this);
|
||||
|
||||
launchModalForm(button.attr('url'),
|
||||
{
|
||||
reload: true,
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
{% endblock %}
|
||||
98
InvenTree/company/templates/company/supplier_part_base.html
Normal file
98
InvenTree/company/templates/company/supplier_part_base.html
Normal file
@@ -0,0 +1,98 @@
|
||||
{% extends "base.html" %}
|
||||
{% load static %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block page_title %}
|
||||
InvenTree | {% trans "Supplier Part" %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<div class='row'>
|
||||
<div class='col-sm-6'>
|
||||
<h3>{% trans "Supplier Part" %}</h3>
|
||||
<div class='btn-row'>
|
||||
<div class='btn-group'>
|
||||
<button type='button' class='btn btn-default btn-glyph' id='edit-part' title='Edit supplier part'>
|
||||
<span class='glyphicon glyphicon-edit'/>
|
||||
</button>
|
||||
<button type='button' class='btn btn-default btn-glyph' id='delete-part' title='Delete supplier part'>
|
||||
<span class='glyphicon glyphicon-trash'/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class='media-left'>
|
||||
<img class='part-thumb'
|
||||
{% if part.part.image %}
|
||||
src='{{ part.part.image.url }}'
|
||||
{% else %}
|
||||
src="{% static 'img/blank_image.png' %}"
|
||||
{% endif %}/>
|
||||
</div>
|
||||
</div>
|
||||
<div class='col-sm-6'>
|
||||
<h4>{% trans "Supplier Part Details" %}</h4>
|
||||
<table class="table table-striped table-condensed">
|
||||
<tr>
|
||||
<td>{% trans "Internal Part" %}</td>
|
||||
<td>
|
||||
{% if part.part %}
|
||||
<a href="{% url 'part-suppliers' part.part.id %}">{{ part.part.full_name }}</a>
|
||||
{% 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 "SKU" %}</td><td>{{ part.SKU }}</tr></tr>
|
||||
{% if part.URL %}
|
||||
<tr><td>{% trans "URL" %}</td><td><a href="{{ part.URL }}">{{ part.URL }}</a></td></tr>
|
||||
{% endif %}
|
||||
{% if part.description %}
|
||||
<tr><td>{% trans "Description" %}</td><td>{{ part.description }}</td></tr>
|
||||
{% endif %}
|
||||
{% if part.manufacturer %}
|
||||
<tr><td>{% trans "Manufacturer" %}</td><td>{{ part.manufacturer }}</td></tr>
|
||||
<tr><td>{% trans "MPN" %}</td><td>{{ part.MPN }}</td></tr>
|
||||
{% endif %}
|
||||
{% if part.note %}
|
||||
<tr><td>{% trans "Note" %}</td><td>{{ part.note }}</td></tr>
|
||||
{% endif %}
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<hr>
|
||||
|
||||
<div class='container-fluid'>
|
||||
{% block details %}
|
||||
<!-- Particular SupplierPart page goes here ... -->
|
||||
{% endblock %}
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block js_ready %}
|
||||
{{ block.super }}
|
||||
|
||||
$('#edit-part').click(function () {
|
||||
launchModalForm(
|
||||
"{% url 'supplier-part-edit' part.id %}",
|
||||
{
|
||||
reload: true
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
$('#delete-part').click(function() {
|
||||
launchModalForm(
|
||||
"{% url 'supplier-part-delete' %}?part={{ part.id }}",
|
||||
{
|
||||
redirect: "{% url 'company-detail-parts' part.supplier.id %}"
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,44 @@
|
||||
{% extends "company/supplier_part_base.html" %}
|
||||
{% load static %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block details %}
|
||||
|
||||
{% include "company/supplier_part_tabs.html" with tab='details' %}
|
||||
|
||||
<hr>
|
||||
|
||||
<h4>{% trans "Supplier Part Details" %}</h4>
|
||||
<table class="table table-striped table-condensed">
|
||||
<tr>
|
||||
<td>{% trans "Internal Part" %}</td>
|
||||
<td>
|
||||
{% if part.part %}
|
||||
<a href="{% url 'part-suppliers' part.part.id %}">{{ part.part.full_name }}</a>
|
||||
{% 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 "SKU" %}</td><td>{{ part.SKU }}</tr></tr>
|
||||
{% if part.URL %}
|
||||
<tr><td>{% trans "URL" %}</td><td><a href="{{ part.URL }}">{{ part.URL }}</a></td></tr>
|
||||
{% endif %}
|
||||
{% if part.description %}
|
||||
<tr><td>{% trans "Description" %}</td><td>{{ part.description }}</td></tr>
|
||||
{% endif %}
|
||||
{% if part.manufacturer %}
|
||||
<tr><td>{% trans "Manufacturer" %}</td><td>{{ part.manufacturer }}</td></tr>
|
||||
<tr><td>{% trans "MPN" %}</td><td>{{ part.MPN }}</td></tr>
|
||||
{% endif %}
|
||||
{% if part.note %}
|
||||
<tr><td>{% trans "Note" %}</td><td>{{ part.note }}</td></tr>
|
||||
{% endif %}
|
||||
</table>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block js_ready %}
|
||||
{{ block.super }}
|
||||
|
||||
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,31 @@
|
||||
{% extends "company/supplier_part_base.html" %}
|
||||
{% load static %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block details %}
|
||||
|
||||
{% include "company/supplier_part_tabs.html" with tab='orders' %}
|
||||
|
||||
<hr>
|
||||
|
||||
<h4>{% trans "Supplier Part Orders" %}</h4>
|
||||
|
||||
<div id='button-bar'>
|
||||
<div class='btn-group'>
|
||||
<button class='btn btn-primary' type='button' id='part-order2' title='Order part'>Order Part</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<table class='table table-striped table-condensed po-table' id='purchase-order-table' data-toolbar='#button-bar'>
|
||||
</table>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block js_ready %}
|
||||
{{ block.super }}
|
||||
|
||||
loadPurchaseOrderTable($("#purchase-order-table"), {
|
||||
url: "{% url 'api-po-list' %}?supplier_part={{ part.id }}",
|
||||
});
|
||||
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,92 @@
|
||||
{% extends "company/supplier_part_base.html" %}
|
||||
{% load static %}
|
||||
{% load i18n %}
|
||||
{% load inventree_extras %}
|
||||
|
||||
{% block details %}
|
||||
|
||||
{% include "company/supplier_part_tabs.html" with tab='pricing' %}
|
||||
|
||||
<hr>
|
||||
|
||||
<h4>{% trans "Pricing Information" %}</h4>
|
||||
<table class="table table-striped table-condensed">
|
||||
<tr><td>{% trans "Order Multiple" %}</td><td>{{ part.multiple }}</td></tr>
|
||||
{% if part.base_cost > 0 %}
|
||||
<tr><td>{% trans "Base Price (Flat Fee)" %}</td><td>{{ part.base_cost }}</td></tr>
|
||||
{% endif %}
|
||||
<tr>
|
||||
<th>{% trans "Price Breaks" %}</th>
|
||||
<th>
|
||||
<div style='float: right;'>
|
||||
<button class='btn btn-primary' id='new-price-break' type='button'>{% trans "New Price Break" %}</button>
|
||||
</div>
|
||||
</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>{% trans "Quantity" %}</th>
|
||||
<th>{% trans "Price" %}</th>
|
||||
</tr>
|
||||
{% if part.price_breaks.all %}
|
||||
{% for pb in part.price_breaks.all %}
|
||||
<tr>
|
||||
<td>{% decimal pb.quantity %}</td>
|
||||
<td>
|
||||
{% if pb.currency %}{{ pb.currency.symbol }}{% endif %}
|
||||
{% decimal pb.cost %}
|
||||
{% if pb.currency %}{{ pb.currency.suffix }}{% endif %}
|
||||
<div class='btn-group' style='float: right;'>
|
||||
<button title='Edit Price Break' class='btn btn-default btn-sm pb-edit-button' type='button' url="{% url 'price-break-edit' pb.id %}"><span class='glyphicon glyphicon-edit'></span></button>
|
||||
<button title='Delete Price Break' class='btn btn-default btn-sm pb-delete-button' type='button' url="{% url 'price-break-delete' pb.id %}"><span class='glyphicon glyphicon-trash'></span></button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan='2'>
|
||||
<span class='warning-msg'><i>{% trans "No price breaks have been added for this part" %}</i></span>
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
</table>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block js_ready %}
|
||||
{{ block.super }}
|
||||
|
||||
|
||||
|
||||
$('#new-price-break').click(function() {
|
||||
launchModalForm("{% url 'price-break-create' %}",
|
||||
{
|
||||
reload: true,
|
||||
data: {
|
||||
part: {{ part.id }},
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
$('.pb-edit-button').click(function() {
|
||||
var button = $(this);
|
||||
|
||||
launchModalForm(button.attr('url'),
|
||||
{
|
||||
reload: true,
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
$('.pb-delete-button').click(function() {
|
||||
var button = $(this);
|
||||
|
||||
launchModalForm(button.attr('url'),
|
||||
{
|
||||
reload: true,
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
{% endblock %}
|
||||
72
InvenTree/company/templates/company/supplier_part_stock.html
Normal file
72
InvenTree/company/templates/company/supplier_part_stock.html
Normal file
@@ -0,0 +1,72 @@
|
||||
{% extends "company/supplier_part_base.html" %}
|
||||
{% load static %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block details %}
|
||||
|
||||
{% include "company/supplier_part_tabs.html" with tab='stock' %}
|
||||
|
||||
<hr>
|
||||
|
||||
<h4>{% trans "Supplier Part Stock" %}</h4>
|
||||
|
||||
{% include "stock_table.html" %}
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block js_load %}
|
||||
{{ block.super }}
|
||||
<script type='text/javascript' src="{% static 'script/inventree/stock.js' %}"></script>
|
||||
{% endblock %}
|
||||
|
||||
{% block js_ready %}
|
||||
{{ block.super }}
|
||||
|
||||
loadStockTable($("#stock-table"), {
|
||||
params: {
|
||||
supplier_part: {{ part.id }},
|
||||
location_detail: true,
|
||||
part_detail: true,
|
||||
},
|
||||
groupByField: 'location',
|
||||
buttons: ['#stock-options'],
|
||||
url: "{% url 'api-stock-list' %}",
|
||||
});
|
||||
|
||||
$("#stock-export").click(function() {
|
||||
launchModalForm("{% url 'stock-export-options' %}", {
|
||||
submit_text: '{% trans "Export" %}',
|
||||
success: function(response) {
|
||||
var url = "{% url 'stock-export' %}";
|
||||
|
||||
url += "?format=" + response.format;
|
||||
url += "&cascade=" + response.cascade;
|
||||
url += "&supplier_part={{ part.id }}";
|
||||
|
||||
location.href = url;
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
$("#item-create").click(function() {
|
||||
launchModalForm("{% url 'stock-item-create' %}", {
|
||||
reload: true,
|
||||
data: {
|
||||
part: {{ part.part.id }},
|
||||
supplier_part: {{ part.id }},
|
||||
},
|
||||
secondary: [
|
||||
{
|
||||
field: 'location',
|
||||
label: '{% trans "New Location" %}',
|
||||
title: '{% trans "Create New Location" %}',
|
||||
url: "{% url 'stock-location-create' %}",
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
|
||||
{% endblock %}
|
||||
13
InvenTree/company/templates/company/supplier_part_tabs.html
Normal file
13
InvenTree/company/templates/company/supplier_part_tabs.html
Normal file
@@ -0,0 +1,13 @@
|
||||
{% load i18n %}
|
||||
|
||||
<ul class='nav nav-tabs'>
|
||||
<li{% if tab == 'pricing' %} class='active'{% endif %}>
|
||||
<a href="{% url 'supplier-part-pricing' part.id %}">{% trans "Pricing" %}</a>
|
||||
</li>
|
||||
<li{% if tab == 'stock' %} class='active'{% endif %}>
|
||||
<a href="{% url 'supplier-part-stock' part.id %}">{% trans "Stock" %}</a>
|
||||
</li>
|
||||
<li {% if tab == 'orders' %} class='active'{% endif %}>
|
||||
<a href="{% url 'supplier-part-orders' part.id %}">{% trans "Orders" %}</a>
|
||||
</li>
|
||||
</ul>
|
||||
@@ -1,23 +1,28 @@
|
||||
{% load i18n %}
|
||||
|
||||
<ul class='nav nav-tabs'>
|
||||
<li{% if tab == 'details' %} class='active'{% endif %}>
|
||||
<a href="{% url 'company-detail' company.id %}">Details</a>
|
||||
<a href="{% url 'company-detail' company.id %}">{% trans "Details" %}</a>
|
||||
</li>
|
||||
{% if company.is_supplier %}
|
||||
<li{% if tab == 'parts' %} class='active'{% endif %}>
|
||||
<a href="{% url 'company-detail-parts' company.id %}">Supplier Parts <span class='badge'>{{ company.part_count }}</span></a>
|
||||
<a href="{% url 'company-detail-parts' company.id %}">{% trans "Supplier Parts" %} <span class='badge'>{{ company.part_count }}</span></a>
|
||||
</li>
|
||||
<li{% if tab == 'stock' %} class='active'{% endif %}>
|
||||
<a href="{% url 'company-detail-stock' company.id %}">Stock <span class='badge'>{{ company.stock_count }}</a>
|
||||
<a href="{% url 'company-detail-stock' company.id %}">{% trans "Stock" %} <span class='badge'>{{ company.stock_count }}</a>
|
||||
</li>
|
||||
<li{% if tab == 'po' %} class='active'{% endif %}>
|
||||
<a href="{% url 'company-detail-purchase-orders' company.id %}">Purchase Orders <span class='badge'>{{ company.purchase_orders.count }}</span></a>
|
||||
<a href="{% url 'company-detail-purchase-orders' company.id %}">{% trans "Purchase Orders" %} <span class='badge'>{{ company.purchase_orders.count }}</span></a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if company.is_customer %}
|
||||
{% if 0 %}
|
||||
<li{% if tab == 'co' %} class='active'{% endif %}>
|
||||
<a href="#">Sales Orders</a>
|
||||
<a href="#">{% trans "Sales Orders" %}</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
<li{% if tab == 'notes' %} class='active'{% endif %}>
|
||||
<a href="{% url 'company-notes' company.id %}">{% trans "Notes" %}{% if company.notes %} <span class='glyphicon glyphicon-small glyphicon-info-sign'></span>{% endif %}</a>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
64
InvenTree/company/test_views.py
Normal file
64
InvenTree/company/test_views.py
Normal file
@@ -0,0 +1,64 @@
|
||||
""" Unit tests for Company views (see views.py) """
|
||||
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
from django.contrib.auth import get_user_model
|
||||
|
||||
from .models import SupplierPart
|
||||
|
||||
|
||||
class CompanyViewTest(TestCase):
|
||||
|
||||
fixtures = [
|
||||
'category',
|
||||
'part',
|
||||
'location',
|
||||
'company',
|
||||
'supplier_part',
|
||||
]
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
|
||||
# Create a user
|
||||
User = get_user_model()
|
||||
User.objects.create_user('username', 'user@email.com', 'password')
|
||||
|
||||
self.client.login(username='username', password='password')
|
||||
|
||||
def test_company_index(self):
|
||||
""" Test the company index """
|
||||
|
||||
response = self.client.get(reverse('company-index'))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_supplier_part_delete(self):
|
||||
""" Test the SupplierPartDelete view """
|
||||
|
||||
url = reverse('supplier-part-delete')
|
||||
|
||||
# Get form using 'part' argument
|
||||
response = self.client.get(url, {'part': '1'}, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
# Get form using 'parts' argument
|
||||
response = self.client.get(url + '?parts[]=1&parts[]=2', HTTP_X_REQUESTED_WITH='XMLHttpRequest')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
# POST to delete two parts
|
||||
n = SupplierPart.objects.count()
|
||||
response = self.client.post(
|
||||
url,
|
||||
{
|
||||
'supplier-part-2': 'supplier-part-2',
|
||||
'supplier-part-3': 'supplier-part-3',
|
||||
'confirm_delete': True
|
||||
},
|
||||
HTTP_X_REQUESTED_WITH='XMLHttpRequest')
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
self.assertEqual(n - 2, SupplierPart.objects.count())
|
||||
@@ -56,7 +56,7 @@ class CompanySimpleTest(TestCase):
|
||||
zerg = Company.objects.get(pk=3)
|
||||
|
||||
self.assertTrue(acme.has_parts)
|
||||
self.assertEqual(acme.part_count, 3)
|
||||
self.assertEqual(acme.part_count, 4)
|
||||
|
||||
self.assertTrue(appel.has_parts)
|
||||
self.assertEqual(appel.part_count, 2)
|
||||
|
||||
@@ -18,6 +18,7 @@ company_detail_urls = [
|
||||
url(r'parts/?', views.CompanyDetail.as_view(template_name='company/detail_part.html'), name='company-detail-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/detail_purchase_orders.html'), name='company-detail-purchase-orders'),
|
||||
url(r'notes/?', views.CompanyNotes.as_view(), name='company-notes'),
|
||||
|
||||
url(r'thumbnail/?', views.CompanyImage.as_view(), name='company-image'),
|
||||
|
||||
@@ -46,14 +47,19 @@ price_break_urls = [
|
||||
]
|
||||
|
||||
supplier_part_detail_urls = [
|
||||
url(r'edit/?', views.SupplierPartEdit.as_view(), name='supplier-part-edit'),
|
||||
url(r'delete/?', views.SupplierPartDelete.as_view(), name='supplier-part-delete'),
|
||||
url(r'^edit/?', views.SupplierPartEdit.as_view(), name='supplier-part-edit'),
|
||||
|
||||
url('^.*$', views.SupplierPartDetail.as_view(), name='supplier-part-detail'),
|
||||
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'),
|
||||
|
||||
url('^.*$', views.SupplierPartDetail.as_view(template_name='company/supplier_part_pricing.html'), name='supplier-part-detail'),
|
||||
]
|
||||
|
||||
supplier_part_urls = [
|
||||
url(r'^new/?', views.SupplierPartCreate.as_view(), name='supplier-part-create'),
|
||||
|
||||
url(r'delete/', views.SupplierPartDelete.as_view(), name='supplier-part-delete'),
|
||||
|
||||
url(r'^(?P<pk>\d+)/', include(supplier_part_detail_urls)),
|
||||
]
|
||||
|
||||
@@ -6,12 +6,17 @@ Django views for interacting with Company app
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.views.generic import DetailView, ListView
|
||||
from django.utils.translation import ugettext as _
|
||||
from django.views.generic import DetailView, ListView, UpdateView
|
||||
|
||||
from django.urls import reverse
|
||||
from django.forms import HiddenInput
|
||||
|
||||
from InvenTree.views import AjaxCreateView, AjaxUpdateView, AjaxDeleteView
|
||||
from InvenTree.status_codes import OrderStatus
|
||||
from InvenTree.helpers import str2bool
|
||||
|
||||
from common.models import Currency
|
||||
|
||||
from .models import Company
|
||||
from .models import SupplierPart
|
||||
@@ -51,6 +56,28 @@ class CompanyIndex(ListView):
|
||||
return queryset
|
||||
|
||||
|
||||
class CompanyNotes(UpdateView):
|
||||
""" View for editing the 'notes' field of a Company object.
|
||||
"""
|
||||
|
||||
context_object_name = 'company'
|
||||
template_name = 'company/notes.html'
|
||||
model = Company
|
||||
|
||||
fields = ['notes']
|
||||
|
||||
def get_success_url(self):
|
||||
return reverse('company-notes', kwargs={'pk': self.get_object().id})
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
|
||||
ctx = super().get_context_data(**kwargs)
|
||||
|
||||
ctx['editing'] = str2bool(self.request.GET.get('edit', ''))
|
||||
|
||||
return ctx
|
||||
|
||||
|
||||
class CompanyDetail(DetailView):
|
||||
""" Detail view for Company object """
|
||||
context_obect_name = 'company'
|
||||
@@ -69,12 +96,12 @@ class CompanyImage(AjaxUpdateView):
|
||||
""" View for uploading an image for the Company """
|
||||
model = Company
|
||||
ajax_template_name = 'modal_form.html'
|
||||
ajax_form_title = 'Update Company Image'
|
||||
ajax_form_title = _('Update Company Image')
|
||||
form_class = CompanyImageForm
|
||||
|
||||
def get_data(self):
|
||||
return {
|
||||
'success': 'Updated company image',
|
||||
'success': _('Updated company image'),
|
||||
}
|
||||
|
||||
|
||||
@@ -84,11 +111,11 @@ class CompanyEdit(AjaxUpdateView):
|
||||
form_class = EditCompanyForm
|
||||
context_object_name = 'company'
|
||||
ajax_template_name = 'modal_form.html'
|
||||
ajax_form_title = 'Edit Company'
|
||||
ajax_form_title = _('Edit Company')
|
||||
|
||||
def get_data(self):
|
||||
return {
|
||||
'info': 'Edited company information',
|
||||
'info': _('Edited company information'),
|
||||
}
|
||||
|
||||
|
||||
@@ -98,11 +125,11 @@ class CompanyCreate(AjaxCreateView):
|
||||
context_object_name = 'company'
|
||||
form_class = EditCompanyForm
|
||||
ajax_template_name = 'modal_form.html'
|
||||
ajax_form_title = "Create new Company"
|
||||
ajax_form_title = _("Create new Company")
|
||||
|
||||
def get_data(self):
|
||||
return {
|
||||
'success': "Created new company",
|
||||
'success': _("Created new company"),
|
||||
}
|
||||
|
||||
|
||||
@@ -112,19 +139,19 @@ class CompanyDelete(AjaxDeleteView):
|
||||
model = Company
|
||||
success_url = '/company/'
|
||||
ajax_template_name = 'company/delete.html'
|
||||
ajax_form_title = 'Delete Company'
|
||||
ajax_form_title = _('Delete Company')
|
||||
context_object_name = 'company'
|
||||
|
||||
def get_data(self):
|
||||
return {
|
||||
'danger': 'Company was deleted',
|
||||
'danger': _('Company was deleted'),
|
||||
}
|
||||
|
||||
|
||||
class SupplierPartDetail(DetailView):
|
||||
""" Detail view for SupplierPart """
|
||||
model = SupplierPart
|
||||
template_name = 'company/partdetail.html'
|
||||
template_name = 'company/supplier_part_detail.html'
|
||||
context_object_name = 'part'
|
||||
queryset = SupplierPart.objects.all()
|
||||
|
||||
@@ -142,7 +169,7 @@ class SupplierPartEdit(AjaxUpdateView):
|
||||
context_object_name = 'part'
|
||||
form_class = EditSupplierPartForm
|
||||
ajax_template_name = 'modal_form.html'
|
||||
ajax_form_title = 'Edit Supplier Part'
|
||||
ajax_form_title = _('Edit Supplier Part')
|
||||
|
||||
|
||||
class SupplierPartCreate(AjaxCreateView):
|
||||
@@ -151,7 +178,7 @@ class SupplierPartCreate(AjaxCreateView):
|
||||
model = SupplierPart
|
||||
form_class = EditSupplierPartForm
|
||||
ajax_template_name = 'modal_form.html'
|
||||
ajax_form_title = 'Create new Supplier Part'
|
||||
ajax_form_title = _('Create new Supplier Part')
|
||||
context_object_name = 'part'
|
||||
|
||||
def get_form(self):
|
||||
@@ -197,12 +224,80 @@ class SupplierPartCreate(AjaxCreateView):
|
||||
|
||||
|
||||
class SupplierPartDelete(AjaxDeleteView):
|
||||
""" Delete view for removing a SupplierPart """
|
||||
model = SupplierPart
|
||||
""" Delete view for removing a SupplierPart.
|
||||
|
||||
SupplierParts can be deleted using a variety of 'selectors'.
|
||||
|
||||
- ?part=<pk> -> Delete a single SupplierPart object
|
||||
- ?parts=[] -> Delete a list of SupplierPart objects
|
||||
|
||||
"""
|
||||
|
||||
success_url = '/supplier/'
|
||||
ajax_template_name = 'company/partdelete.html'
|
||||
ajax_form_title = 'Delete Supplier Part'
|
||||
context_object_name = 'supplier_part'
|
||||
ajax_form_title = _('Delete Supplier Part')
|
||||
|
||||
parts = []
|
||||
|
||||
def get_context_data(self):
|
||||
ctx = {}
|
||||
|
||||
ctx['parts'] = self.parts
|
||||
|
||||
return ctx
|
||||
|
||||
def get_parts(self):
|
||||
""" Determine which SupplierPart object(s) the user wishes to delete.
|
||||
"""
|
||||
|
||||
self.parts = []
|
||||
|
||||
# User passes a single SupplierPart ID
|
||||
if 'part' in self.request.GET:
|
||||
try:
|
||||
self.parts.append(SupplierPart.objects.get(pk=self.request.GET.get('part')))
|
||||
except (ValueError, SupplierPart.DoesNotExist):
|
||||
pass
|
||||
|
||||
elif 'parts[]' in self.request.GET:
|
||||
|
||||
part_id_list = self.request.GET.getlist('parts[]')
|
||||
|
||||
self.parts = SupplierPart.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 supplier parts.
|
||||
"""
|
||||
|
||||
self.request = request
|
||||
self.parts = []
|
||||
|
||||
for item in self.request.POST:
|
||||
if item.startswith('supplier-part-'):
|
||||
pk = item.replace('supplier-part-', '')
|
||||
|
||||
try:
|
||||
self.parts.append(SupplierPart.objects.get(pk=pk))
|
||||
except (ValueError, SupplierPart.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 PriceBreakCreate(AjaxCreateView):
|
||||
@@ -210,7 +305,7 @@ class PriceBreakCreate(AjaxCreateView):
|
||||
|
||||
model = SupplierPriceBreak
|
||||
form_class = EditPriceBreakForm
|
||||
ajax_form_title = 'Add Price Break'
|
||||
ajax_form_title = _('Add Price Break')
|
||||
ajax_template_name = 'modal_form.html'
|
||||
|
||||
def get_data(self):
|
||||
@@ -237,6 +332,13 @@ class PriceBreakCreate(AjaxCreateView):
|
||||
|
||||
initials['part'] = self.get_part()
|
||||
|
||||
# Pre-select the default currency
|
||||
try:
|
||||
base = Currency.objects.get(base=True)
|
||||
initials['currency'] = base
|
||||
except Currency.DoesNotExist:
|
||||
pass
|
||||
|
||||
return initials
|
||||
|
||||
|
||||
@@ -245,7 +347,7 @@ class PriceBreakEdit(AjaxUpdateView):
|
||||
|
||||
model = SupplierPriceBreak
|
||||
form_class = EditPriceBreakForm
|
||||
ajax_form_title = 'Edit Price Break'
|
||||
ajax_form_title = _('Edit Price Break')
|
||||
ajax_template_name = 'modal_form.html'
|
||||
|
||||
def get_form(self):
|
||||
@@ -260,5 +362,5 @@ class PriceBreakDelete(AjaxDeleteView):
|
||||
""" View for deleting a supplier price break """
|
||||
|
||||
model = SupplierPriceBreak
|
||||
ajax_form_title = "Delete Price Break"
|
||||
ajax_form_title = _("Delete Price Break")
|
||||
ajax_template_name = 'modal_delete_form.html'
|
||||
|
||||
@@ -18,6 +18,9 @@ database:
|
||||
#HOST: ''
|
||||
#PORT: ''
|
||||
|
||||
# Select default system language (default is 'en-us')
|
||||
language: en-us
|
||||
|
||||
# Set debug to False to run in production mode
|
||||
debug: True
|
||||
|
||||
@@ -47,7 +50,16 @@ media_root: '../inventree_media'
|
||||
# By default it is stored in a directory named 'static' local to the InvenTree directory
|
||||
static_root: '../inventree_static'
|
||||
|
||||
# Optional URL schemes to allow in URL fields
|
||||
# By default, only the following schemes are allowed: ['http', 'https', 'ftp', 'ftps']
|
||||
# Uncomment the lines below to allow extra schemes
|
||||
#extra_url_schemes:
|
||||
# - mailto
|
||||
# - git
|
||||
# - ssh
|
||||
|
||||
# Logging options
|
||||
# If debug mode is enabled, set log_queries to True to show aggregate database queries in the debug console
|
||||
log_queries: False
|
||||
|
||||
# Backup options
|
||||
|
||||
BIN
InvenTree/locale/de/LC_MESSAGES/django.mo
Normal file
BIN
InvenTree/locale/de/LC_MESSAGES/django.mo
Normal file
Binary file not shown.
2504
InvenTree/locale/de/LC_MESSAGES/django.po
Normal file
2504
InvenTree/locale/de/LC_MESSAGES/django.po
Normal file
File diff suppressed because it is too large
Load Diff
BIN
InvenTree/locale/en/LC_MESSAGES/django.mo
Normal file
BIN
InvenTree/locale/en/LC_MESSAGES/django.mo
Normal file
Binary file not shown.
2253
InvenTree/locale/en/LC_MESSAGES/django.po
Normal file
2253
InvenTree/locale/en/LC_MESSAGES/django.po
Normal file
File diff suppressed because it is too large
Load Diff
BIN
InvenTree/locale/es/LC_MESSAGES/django.mo
Normal file
BIN
InvenTree/locale/es/LC_MESSAGES/django.mo
Normal file
Binary file not shown.
2253
InvenTree/locale/es/LC_MESSAGES/django.po
Normal file
2253
InvenTree/locale/es/LC_MESSAGES/django.po
Normal file
File diff suppressed because it is too large
Load Diff
@@ -3,10 +3,15 @@ from __future__ import unicode_literals
|
||||
|
||||
from django.contrib import admin
|
||||
|
||||
from import_export.admin import ImportExportModelAdmin
|
||||
|
||||
from import_export.resources import ModelResource
|
||||
from import_export.fields import Field
|
||||
|
||||
from .models import PurchaseOrder, PurchaseOrderLineItem
|
||||
|
||||
|
||||
class PurchaseOrderAdmin(admin.ModelAdmin):
|
||||
class PurchaseOrderAdmin(ImportExportModelAdmin):
|
||||
|
||||
list_display = (
|
||||
'reference',
|
||||
@@ -17,7 +22,27 @@ class PurchaseOrderAdmin(admin.ModelAdmin):
|
||||
)
|
||||
|
||||
|
||||
class PurchaseOrderLineItemAdmin(admin.ModelAdmin):
|
||||
class POLineItemResource(ModelResource):
|
||||
""" Class for managing import / export of POLineItem data """
|
||||
|
||||
part_name = Field(attribute='part__part__name', readonly=True)
|
||||
|
||||
manufacturer = Field(attribute='part__manufacturer', readonly=True)
|
||||
|
||||
MPN = Field(attribute='part__MPN', readonly=True)
|
||||
|
||||
SKU = Field(attribute='part__SKU', readonly=True)
|
||||
|
||||
class Meta:
|
||||
model = PurchaseOrderLineItem
|
||||
skip_unchanged = True
|
||||
report_skipped = False
|
||||
clean_model_instances = True
|
||||
|
||||
|
||||
class PurchaseOrderLineItemAdmin(ImportExportModelAdmin):
|
||||
|
||||
resource_class = POLineItemResource
|
||||
|
||||
list_display = (
|
||||
'order',
|
||||
|
||||
@@ -7,9 +7,19 @@ from __future__ import unicode_literals
|
||||
|
||||
from django_filters.rest_framework import DjangoFilterBackend
|
||||
from rest_framework import generics, permissions
|
||||
from rest_framework import filters
|
||||
from rest_framework.response import Response
|
||||
|
||||
from django.conf import settings
|
||||
from django.conf.urls import url
|
||||
|
||||
from InvenTree.status_codes import OrderStatus
|
||||
|
||||
import os
|
||||
|
||||
from part.models import Part
|
||||
from company.models import SupplierPart
|
||||
|
||||
from .models import PurchaseOrder, PurchaseOrderLineItem
|
||||
from .serializers import POSerializer, POLineItemSerializer
|
||||
|
||||
@@ -24,18 +34,87 @@ class POList(generics.ListCreateAPIView):
|
||||
queryset = PurchaseOrder.objects.all()
|
||||
serializer_class = POSerializer
|
||||
|
||||
def list(self, request, *args, **kwargs):
|
||||
|
||||
queryset = self.get_queryset().prefetch_related('supplier', 'lines')
|
||||
|
||||
queryset = self.filter_queryset(queryset)
|
||||
|
||||
# Special filtering for 'status' field
|
||||
if 'status' in request.GET:
|
||||
status = request.GET['status']
|
||||
|
||||
# First attempt to filter by integer value
|
||||
try:
|
||||
status = int(status)
|
||||
queryset = queryset.filter(status=status)
|
||||
except ValueError:
|
||||
try:
|
||||
value = OrderStatus.value(status)
|
||||
queryset = queryset.filter(status=value)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# Attempt to filter by part
|
||||
if 'part' in request.GET:
|
||||
try:
|
||||
part = Part.objects.get(pk=request.GET['part'])
|
||||
queryset = queryset.filter(id__in=[p.id for p in part.purchase_orders()])
|
||||
except (Part.DoesNotExist, ValueError):
|
||||
pass
|
||||
|
||||
# Attempt to filter by supplier part
|
||||
if 'supplier_part' in request.GET:
|
||||
try:
|
||||
supplier_part = SupplierPart.objects.get(pk=request.GET['supplier_part'])
|
||||
queryset = queryset.filter(id__in=[p.id for p in supplier_part.purchase_orders()])
|
||||
except (ValueError, SupplierPart.DoesNotExist):
|
||||
pass
|
||||
|
||||
data = queryset.values(
|
||||
'pk',
|
||||
'supplier',
|
||||
'supplier__name',
|
||||
'supplier__image',
|
||||
'reference',
|
||||
'description',
|
||||
'URL',
|
||||
'status',
|
||||
'notes',
|
||||
'creation_date',
|
||||
)
|
||||
|
||||
for item in data:
|
||||
|
||||
order = queryset.get(pk=item['pk'])
|
||||
|
||||
item['supplier__image'] = os.path.join(settings.MEDIA_URL, item['supplier__image'])
|
||||
item['status_text'] = OrderStatus.label(item['status'])
|
||||
item['lines'] = order.lines.count()
|
||||
|
||||
return Response(data)
|
||||
|
||||
permission_classes = [
|
||||
permissions.IsAuthenticated,
|
||||
]
|
||||
|
||||
filter_backends = [
|
||||
DjangoFilterBackend,
|
||||
filters.SearchFilter,
|
||||
filters.OrderingFilter,
|
||||
]
|
||||
|
||||
filter_fields = [
|
||||
'supplier',
|
||||
]
|
||||
|
||||
ordering_fields = [
|
||||
'creation_date',
|
||||
'reference',
|
||||
]
|
||||
|
||||
ordering = '-creation_date'
|
||||
|
||||
|
||||
class PODetail(generics.RetrieveUpdateAPIView):
|
||||
""" API endpoint for detail view of a PurchaseOrder object """
|
||||
|
||||
@@ -35,8 +35,16 @@
|
||||
quantity: 250
|
||||
received: 50
|
||||
|
||||
# 1000 x ACME0003
|
||||
- model: order.purchaseorderlineitem
|
||||
fields:
|
||||
order: 1
|
||||
part: 3
|
||||
quantity: 1000
|
||||
|
||||
# 100 x ZERGLPHS (M2x4 LPHS)
|
||||
- model: order.purchaseorderlineitem
|
||||
pk: 22
|
||||
fields:
|
||||
order: 2
|
||||
part: 3
|
||||
|
||||
@@ -6,15 +6,20 @@ Django Forms for interacting with Order objects
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django import forms
|
||||
from django.utils.translation import ugettext as _
|
||||
|
||||
from mptt.fields import TreeNodeChoiceField
|
||||
|
||||
from InvenTree.forms import HelperForm
|
||||
from InvenTree.fields import RoundingDecimalFormField
|
||||
|
||||
from .models import PurchaseOrder, PurchaseOrderLineItem
|
||||
from stock.models import StockLocation
|
||||
from .models import PurchaseOrder, PurchaseOrderLineItem, PurchaseOrderAttachment
|
||||
|
||||
|
||||
class IssuePurchaseOrderForm(HelperForm):
|
||||
|
||||
confirm = forms.BooleanField(required=False, help_text='Place order')
|
||||
confirm = forms.BooleanField(required=False, help_text=_('Place order'))
|
||||
|
||||
class Meta:
|
||||
model = PurchaseOrder
|
||||
@@ -23,6 +28,39 @@ class IssuePurchaseOrderForm(HelperForm):
|
||||
]
|
||||
|
||||
|
||||
class CompletePurchaseOrderForm(HelperForm):
|
||||
|
||||
confirm = forms.BooleanField(required=False, help_text=_("Mark order as complete"))
|
||||
|
||||
class Meta:
|
||||
model = PurchaseOrder
|
||||
fields = [
|
||||
'confirm',
|
||||
]
|
||||
|
||||
|
||||
class CancelPurchaseOrderForm(HelperForm):
|
||||
|
||||
confirm = forms.BooleanField(required=False, help_text=_('Cancel order'))
|
||||
|
||||
class Meta:
|
||||
model = PurchaseOrder
|
||||
fields = [
|
||||
'confirm',
|
||||
]
|
||||
|
||||
|
||||
class ReceivePurchaseOrderForm(HelperForm):
|
||||
|
||||
location = TreeNodeChoiceField(queryset=StockLocation.objects.all(), required=True, help_text=_('Receive parts to this location'))
|
||||
|
||||
class Meta:
|
||||
model = PurchaseOrder
|
||||
fields = [
|
||||
'location',
|
||||
]
|
||||
|
||||
|
||||
class EditPurchaseOrderForm(HelperForm):
|
||||
""" Form for editing a PurchaseOrder object """
|
||||
|
||||
@@ -33,13 +71,26 @@ class EditPurchaseOrderForm(HelperForm):
|
||||
'supplier',
|
||||
'description',
|
||||
'URL',
|
||||
'notes'
|
||||
]
|
||||
|
||||
|
||||
class EditPurchaseOrderAttachmentForm(HelperForm):
|
||||
""" Form for editing a PurchaseOrderAttachment object """
|
||||
|
||||
class Meta:
|
||||
model = PurchaseOrderAttachment
|
||||
fields = [
|
||||
'order',
|
||||
'attachment',
|
||||
'comment'
|
||||
]
|
||||
|
||||
|
||||
class EditPurchaseOrderLineItemForm(HelperForm):
|
||||
""" Form for editing a PurchaseOrderLineItem object """
|
||||
|
||||
quantity = RoundingDecimalFormField(max_digits=10, decimal_places=5)
|
||||
|
||||
class Meta:
|
||||
model = PurchaseOrderLineItem
|
||||
fields = [
|
||||
|
||||
19
InvenTree/order/migrations/0013_auto_20191118_2323.py
Normal file
19
InvenTree/order/migrations/0013_auto_20191118_2323.py
Normal file
@@ -0,0 +1,19 @@
|
||||
# Generated by Django 2.2.5 on 2019-11-18 23:23
|
||||
|
||||
import django.core.validators
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('order', '0012_auto_20190617_1943'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='purchaseorderlineitem',
|
||||
name='quantity',
|
||||
field=models.DecimalField(decimal_places=5, default=1, help_text='Item quantity', max_digits=15, validators=[django.core.validators.MinValueValidator(0)]),
|
||||
),
|
||||
]
|
||||
18
InvenTree/order/migrations/0014_auto_20191118_2328.py
Normal file
18
InvenTree/order/migrations/0014_auto_20191118_2328.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 2.2.5 on 2019-11-18 23:28
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('order', '0013_auto_20191118_2323'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='purchaseorderlineitem',
|
||||
name='received',
|
||||
field=models.DecimalField(decimal_places=5, default=0, help_text='Number of items received', max_digits=15),
|
||||
),
|
||||
]
|
||||
19
InvenTree/order/migrations/0015_auto_20200201_2346.py
Normal file
19
InvenTree/order/migrations/0015_auto_20200201_2346.py
Normal file
@@ -0,0 +1,19 @@
|
||||
# Generated by Django 2.2.9 on 2020-02-01 23:46
|
||||
|
||||
from django.db import migrations
|
||||
import markdownx.models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('order', '0014_auto_20191118_2328'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='purchaseorder',
|
||||
name='notes',
|
||||
field=markdownx.models.MarkdownxField(blank=True, help_text='Order notes'),
|
||||
),
|
||||
]
|
||||
27
InvenTree/order/migrations/0016_purchaseorderattachment.py
Normal file
27
InvenTree/order/migrations/0016_purchaseorderattachment.py
Normal file
@@ -0,0 +1,27 @@
|
||||
# Generated by Django 2.2.9 on 2020-03-22 07:01
|
||||
|
||||
import InvenTree.models
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('order', '0015_auto_20200201_2346'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='PurchaseOrderAttachment',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('attachment', models.FileField(help_text='Select file to attach', upload_to=InvenTree.models.rename_attachment)),
|
||||
('comment', models.CharField(help_text='File comment', max_length=100)),
|
||||
('order', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='attachments', to='order.PurchaseOrder')),
|
||||
],
|
||||
options={
|
||||
'abstract': False,
|
||||
},
|
||||
),
|
||||
]
|
||||
20
InvenTree/order/migrations/0017_auto_20200331_1000.py
Normal file
20
InvenTree/order/migrations/0017_auto_20200331_1000.py
Normal file
@@ -0,0 +1,20 @@
|
||||
# Generated by Django 2.2.10 on 2020-03-31 10:00
|
||||
|
||||
import InvenTree.fields
|
||||
import django.core.validators
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('order', '0016_purchaseorderattachment'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
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)]),
|
||||
),
|
||||
]
|
||||
@@ -5,19 +5,25 @@ Order model definitions
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from django.db import models, transaction
|
||||
from django.db.models import F
|
||||
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 _
|
||||
|
||||
import tablib
|
||||
from markdownx.models import MarkdownxField
|
||||
|
||||
import os
|
||||
from datetime import datetime
|
||||
|
||||
from stock.models import StockItem
|
||||
from company.models import Company, SupplierPart
|
||||
|
||||
from InvenTree.fields import RoundingDecimalField
|
||||
from InvenTree.helpers import decimal2string
|
||||
from InvenTree.status_codes import OrderStatus
|
||||
from InvenTree.models import InvenTreeAttachment
|
||||
|
||||
|
||||
class Order(models.Model):
|
||||
@@ -80,7 +86,7 @@ class Order(models.Model):
|
||||
|
||||
complete_date = models.DateField(blank=True, null=True)
|
||||
|
||||
notes = models.TextField(blank=True, help_text=_('Order notes'))
|
||||
notes = MarkdownxField(blank=True, help_text=_('Order notes'))
|
||||
|
||||
def place_order(self):
|
||||
""" Marks the order as PLACED. Order must be currently PENDING. """
|
||||
@@ -98,6 +104,13 @@ class Order(models.Model):
|
||||
self.complete_date = datetime.now().date()
|
||||
self.save()
|
||||
|
||||
def cancel_order(self):
|
||||
""" Marks the order as CANCELLED. """
|
||||
|
||||
if self.status in [OrderStatus.PLACED, OrderStatus.PENDING]:
|
||||
self.status = OrderStatus.CANCELLED
|
||||
self.save()
|
||||
|
||||
|
||||
class PurchaseOrder(Order):
|
||||
""" A PurchaseOrder represents goods shipped inwards from an external supplier.
|
||||
@@ -125,56 +138,8 @@ class PurchaseOrder(Order):
|
||||
related_name='+'
|
||||
)
|
||||
|
||||
def export_to_file(self, **kwargs):
|
||||
""" Export order information to external file """
|
||||
|
||||
file_format = kwargs.get('format', 'csv').lower()
|
||||
|
||||
data = tablib.Dataset(headers=[
|
||||
'Line',
|
||||
'Part',
|
||||
'Description',
|
||||
'Manufacturer',
|
||||
'MPN',
|
||||
'Order Code',
|
||||
'Quantity',
|
||||
'Received',
|
||||
'Reference',
|
||||
'Notes',
|
||||
])
|
||||
|
||||
idx = 0
|
||||
|
||||
for item in self.lines.all():
|
||||
|
||||
line = []
|
||||
|
||||
line.append(idx)
|
||||
|
||||
if item.part:
|
||||
line.append(item.part.part.name)
|
||||
line.append(item.part.part.description)
|
||||
|
||||
line.append(item.part.manufacturer)
|
||||
line.append(item.part.MPN)
|
||||
line.append(item.part.SKU)
|
||||
|
||||
else:
|
||||
line += [[] * 5]
|
||||
|
||||
line.append(item.quantity)
|
||||
line.append(item.received)
|
||||
line.append(item.reference)
|
||||
line.append(item.notes)
|
||||
|
||||
idx += 1
|
||||
|
||||
data.append(line)
|
||||
|
||||
return data.export(file_format)
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('purchase-order-detail', kwargs={'pk': self.id})
|
||||
return reverse('po-detail', kwargs={'pk': self.id})
|
||||
|
||||
@transaction.atomic
|
||||
def add_line_item(self, supplier_part, quantity, group=True, reference=''):
|
||||
@@ -226,7 +191,13 @@ class PurchaseOrder(Order):
|
||||
Any line item where 'received' < 'quantity' will be returned.
|
||||
"""
|
||||
|
||||
return [line for line in self.lines.all() if line.quantity > line.received]
|
||||
return self.lines.filter(quantity__gt=F('received'))
|
||||
|
||||
@property
|
||||
def is_complete(self):
|
||||
""" Return True if all line items have been received """
|
||||
|
||||
return self.pending_line_items().count() == 0
|
||||
|
||||
@transaction.atomic
|
||||
def receive_line_item(self, line, location, quantity, user):
|
||||
@@ -271,6 +242,17 @@ class PurchaseOrder(Order):
|
||||
self.complete_order() # This will save the model
|
||||
|
||||
|
||||
class PurchaseOrderAttachment(InvenTreeAttachment):
|
||||
"""
|
||||
Model for storing file attachments against a PurchaseOrder object
|
||||
"""
|
||||
|
||||
def getSubdir(self):
|
||||
return os.path.join("po_files", str(self.order.id))
|
||||
|
||||
order = models.ForeignKey(PurchaseOrder, on_delete=models.CASCADE, related_name="attachments")
|
||||
|
||||
|
||||
class OrderLineItem(models.Model):
|
||||
""" Abstract model for an order line item
|
||||
|
||||
@@ -283,7 +265,7 @@ class OrderLineItem(models.Model):
|
||||
class Meta:
|
||||
abstract = True
|
||||
|
||||
quantity = models.PositiveIntegerField(validators=[MinValueValidator(0)], default=1, help_text=_('Item quantity'))
|
||||
quantity = RoundingDecimalField(max_digits=15, decimal_places=5, validators=[MinValueValidator(0)], default=1, help_text=_('Item quantity'))
|
||||
|
||||
reference = models.CharField(max_length=100, blank=True, help_text=_('Line item reference'))
|
||||
|
||||
@@ -305,7 +287,7 @@ class PurchaseOrderLineItem(OrderLineItem):
|
||||
|
||||
def __str__(self):
|
||||
return "{n} x {part} from {supplier} (for {po})".format(
|
||||
n=self.quantity,
|
||||
n=decimal2string(self.quantity),
|
||||
part=self.part.SKU if self.part else 'unknown part',
|
||||
supplier=self.order.supplier.name,
|
||||
po=self.order)
|
||||
@@ -325,7 +307,7 @@ class PurchaseOrderLineItem(OrderLineItem):
|
||||
help_text=_("Supplier part"),
|
||||
)
|
||||
|
||||
received = models.PositiveIntegerField(default=0, help_text=_('Number of items received'))
|
||||
received = models.DecimalField(decimal_places=5, max_digits=15, default=0, help_text=_('Number of items received'))
|
||||
|
||||
def remaining(self):
|
||||
""" Calculate the number of items remaining to be received """
|
||||
|
||||
132
InvenTree/order/templates/order/order_base.html
Normal file
132
InvenTree/order/templates/order/order_base.html
Normal file
@@ -0,0 +1,132 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% load i18n %}
|
||||
{% load static %}
|
||||
{% load inventree_extras %}
|
||||
|
||||
{% block page_title %}
|
||||
InvenTree | {{ order }}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<div class='row'>
|
||||
<div class='col-sm-6'>
|
||||
<div class='media'>
|
||||
<div class='media-left'>
|
||||
<img class='part-thumb'
|
||||
{% if order.supplier.image %}
|
||||
src="{{ order.supplier.image.url }}"
|
||||
{% else %}
|
||||
src="{% static 'img/blank_image.png' %}"
|
||||
{% endif %}
|
||||
/>
|
||||
</div>
|
||||
<div class='media-body'>
|
||||
<h4>{{ order }}</h4>
|
||||
<p>{{ order.description }}</p>
|
||||
{% if order.URL %}
|
||||
<a href="{{ order.URL }}">{{ order.URL }}</a>
|
||||
{% endif %}
|
||||
<p>
|
||||
<div class='btn-row'>
|
||||
<div class='btn-group'>
|
||||
<button type='button' class='btn btn-default btn-glyph' id='edit-order' title='Edit order information'>
|
||||
<span class='glyphicon glyphicon-edit'></span>
|
||||
</button>
|
||||
<button type='button' class='btn btn-default btn-glyph' id='export-order' title='Export order to file'>
|
||||
<span class='glyphicon glyphicon-download-alt'></span>
|
||||
</button>
|
||||
{% if order.status == OrderStatus.PENDING and order.lines.count > 0 %}
|
||||
<button type='button' class='btn btn-default btn-glyph' id='place-order' title='Place order'>
|
||||
<span class='glyphicon glyphicon-send'></span>
|
||||
</button>
|
||||
{% elif order.status == OrderStatus.PLACED %}
|
||||
<button type='button' class='btn btn-default btn-glyph' id='receive-order' title='Receive items'>
|
||||
<span class='glyphicon glyphicon-check'></span>
|
||||
</button>
|
||||
<button type='button' class='btn btn-default btn-glyph' id='complete-order' title='Mark order as complete'>
|
||||
<span class='glyphicon glyphicon-ok'></span>
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if order.status == OrderStatus.PENDING or order.status == OrderStatus.PLACED %}
|
||||
<button type='button' class='btn btn-default btn-glyph' id='cancel-order' title='Cancel order'>
|
||||
<span class='glyphicon glyphicon-remove'></span>
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class='col-sm-6'>
|
||||
<h4>{% trans "Purchase Order Details" %}</h4>
|
||||
<table class='table'>
|
||||
<tr>
|
||||
<td>{% trans "Supplier" %}</td>
|
||||
<td><a href="{% url 'company-detail' order.supplier.id %}">{{ order.supplier }}</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>{% trans "Status" %}</td>
|
||||
<td>{% include "order/order_status.html" %}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>{% trans "Created" %}</td>
|
||||
<td>{{ order.creation_date }}<span class='badge'>{{ order.created_by }}</span></td>
|
||||
</tr>
|
||||
{% if order.issue_date %}
|
||||
<tr>
|
||||
<td>{% trans "Issued" %}</td>
|
||||
<td>{{ order.issue_date }}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% if order.status == OrderStatus.COMPLETE %}
|
||||
<tr>
|
||||
<td>{% trans "Received" %}</td>
|
||||
<td>{{ order.complete_date }}<span class='badge'>{{ order.received_by }}</span></td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
<div class='container-fluid'>
|
||||
{% block details %}
|
||||
|
||||
<!-- Specific order details to go here -->
|
||||
|
||||
{% endblock %}
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block js_ready %}
|
||||
{{ block.super }}
|
||||
|
||||
{% if order.status == OrderStatus.PENDING and order.lines.count > 0 %}
|
||||
$("#place-order").click(function() {
|
||||
launchModalForm("{% url 'po-issue' order.id %}",
|
||||
{
|
||||
reload: true,
|
||||
});
|
||||
});
|
||||
{% endif %}
|
||||
|
||||
$("#edit-order").click(function() {
|
||||
launchModalForm("{% url 'po-edit' order.id %}",
|
||||
{
|
||||
reload: true,
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
$("#cancel-order").click(function() {
|
||||
launchModalForm("{% url 'po-cancel' order.id %}", {
|
||||
reload: true,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
{% endblock %}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user