mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +00:00
Merge branch 'inventree:master' into bpm-purchase-price
This commit is contained in:
commit
e24a158919
1
.gitignore
vendored
1
.gitignore
vendored
@ -37,6 +37,7 @@ local_settings.py
|
|||||||
|
|
||||||
# Files used for testing
|
# Files used for testing
|
||||||
dummy_image.*
|
dummy_image.*
|
||||||
|
_tmp.csv
|
||||||
|
|
||||||
# Sphinx files
|
# Sphinx files
|
||||||
docs/_build
|
docs/_build
|
||||||
|
@ -32,27 +32,37 @@ class InvenTreeConfig(AppConfig):
|
|||||||
|
|
||||||
logger.info("Starting background tasks...")
|
logger.info("Starting background tasks...")
|
||||||
|
|
||||||
|
# Remove successful task results from the database
|
||||||
InvenTree.tasks.schedule_task(
|
InvenTree.tasks.schedule_task(
|
||||||
'InvenTree.tasks.delete_successful_tasks',
|
'InvenTree.tasks.delete_successful_tasks',
|
||||||
schedule_type=Schedule.DAILY,
|
schedule_type=Schedule.DAILY,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Check for InvenTree updates
|
||||||
InvenTree.tasks.schedule_task(
|
InvenTree.tasks.schedule_task(
|
||||||
'InvenTree.tasks.check_for_updates',
|
'InvenTree.tasks.check_for_updates',
|
||||||
schedule_type=Schedule.DAILY
|
schedule_type=Schedule.DAILY
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Heartbeat to let the server know the background worker is running
|
||||||
InvenTree.tasks.schedule_task(
|
InvenTree.tasks.schedule_task(
|
||||||
'InvenTree.tasks.heartbeat',
|
'InvenTree.tasks.heartbeat',
|
||||||
schedule_type=Schedule.MINUTES,
|
schedule_type=Schedule.MINUTES,
|
||||||
minutes=15
|
minutes=15
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Keep exchange rates up to date
|
||||||
InvenTree.tasks.schedule_task(
|
InvenTree.tasks.schedule_task(
|
||||||
'InvenTree.tasks.update_exchange_rates',
|
'InvenTree.tasks.update_exchange_rates',
|
||||||
schedule_type=Schedule.DAILY,
|
schedule_type=Schedule.DAILY,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Remove expired sessions
|
||||||
|
InvenTree.tasks.schedule_task(
|
||||||
|
'InvenTree.tasks.delete_expired_sessions',
|
||||||
|
schedule_type=Schedule.DAILY,
|
||||||
|
)
|
||||||
|
|
||||||
def update_exchange_rates(self):
|
def update_exchange_rates(self):
|
||||||
"""
|
"""
|
||||||
Update exchange rates each time the server is started, *if*:
|
Update exchange rates each time the server is started, *if*:
|
||||||
|
@ -5,8 +5,10 @@ Generic models which provide extra functionality over base Django model types.
|
|||||||
from __future__ import unicode_literals
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
import logging
|
||||||
|
|
||||||
from django.db import models
|
from django.db import models
|
||||||
|
from django.conf import settings
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
from django.contrib.contenttypes.models import ContentType
|
from django.contrib.contenttypes.models import ContentType
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
@ -21,6 +23,9 @@ from mptt.exceptions import InvalidMove
|
|||||||
from .validators import validate_tree_name
|
from .validators import validate_tree_name
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger('inventree')
|
||||||
|
|
||||||
|
|
||||||
def rename_attachment(instance, filename):
|
def rename_attachment(instance, filename):
|
||||||
"""
|
"""
|
||||||
Function for renaming an attachment file.
|
Function for renaming an attachment file.
|
||||||
@ -77,6 +82,72 @@ class InvenTreeAttachment(models.Model):
|
|||||||
def basename(self):
|
def basename(self):
|
||||||
return os.path.basename(self.attachment.name)
|
return os.path.basename(self.attachment.name)
|
||||||
|
|
||||||
|
@basename.setter
|
||||||
|
def basename(self, fn):
|
||||||
|
"""
|
||||||
|
Function to rename the attachment file.
|
||||||
|
|
||||||
|
- Filename cannot be empty
|
||||||
|
- Filename cannot contain illegal characters
|
||||||
|
- Filename must specify an extension
|
||||||
|
- Filename cannot match an existing file
|
||||||
|
"""
|
||||||
|
|
||||||
|
fn = fn.strip()
|
||||||
|
|
||||||
|
if len(fn) == 0:
|
||||||
|
raise ValidationError(_('Filename must not be empty'))
|
||||||
|
|
||||||
|
attachment_dir = os.path.join(
|
||||||
|
settings.MEDIA_ROOT,
|
||||||
|
self.getSubdir()
|
||||||
|
)
|
||||||
|
|
||||||
|
old_file = os.path.join(
|
||||||
|
settings.MEDIA_ROOT,
|
||||||
|
self.attachment.name
|
||||||
|
)
|
||||||
|
|
||||||
|
new_file = os.path.join(
|
||||||
|
settings.MEDIA_ROOT,
|
||||||
|
self.getSubdir(),
|
||||||
|
fn
|
||||||
|
)
|
||||||
|
|
||||||
|
new_file = os.path.abspath(new_file)
|
||||||
|
|
||||||
|
# Check that there are no directory tricks going on...
|
||||||
|
if not os.path.dirname(new_file) == attachment_dir:
|
||||||
|
logger.error(f"Attempted to rename attachment outside valid directory: '{new_file}'")
|
||||||
|
raise ValidationError(_("Invalid attachment directory"))
|
||||||
|
|
||||||
|
# Ignore further checks if the filename is not actually being renamed
|
||||||
|
if new_file == old_file:
|
||||||
|
return
|
||||||
|
|
||||||
|
forbidden = ["'", '"', "#", "@", "!", "&", "^", "<", ">", ":", ";", "/", "\\", "|", "?", "*", "%", "~", "`"]
|
||||||
|
|
||||||
|
for c in forbidden:
|
||||||
|
if c in fn:
|
||||||
|
raise ValidationError(_(f"Filename contains illegal character '{c}'"))
|
||||||
|
|
||||||
|
if len(fn.split('.')) < 2:
|
||||||
|
raise ValidationError(_("Filename missing extension"))
|
||||||
|
|
||||||
|
if not os.path.exists(old_file):
|
||||||
|
logger.error(f"Trying to rename attachment '{old_file}' which does not exist")
|
||||||
|
return
|
||||||
|
|
||||||
|
if os.path.exists(new_file):
|
||||||
|
raise ValidationError(_("Attachment with this filename already exists"))
|
||||||
|
|
||||||
|
try:
|
||||||
|
os.rename(old_file, new_file)
|
||||||
|
self.attachment.name = os.path.join(self.getSubdir(), fn)
|
||||||
|
self.save()
|
||||||
|
except:
|
||||||
|
raise ValidationError(_("Error renaming file"))
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
abstract = True
|
abstract = True
|
||||||
|
|
||||||
|
@ -167,6 +167,18 @@ class InvenTreeModelSerializer(serializers.ModelSerializer):
|
|||||||
|
|
||||||
return self.instance
|
return self.instance
|
||||||
|
|
||||||
|
def update(self, instance, validated_data):
|
||||||
|
"""
|
||||||
|
Catch any django ValidationError, and re-throw as a DRF ValidationError
|
||||||
|
"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
instance = super().update(instance, validated_data)
|
||||||
|
except (ValidationError, DjangoValidationError) as exc:
|
||||||
|
raise ValidationError(detail=serializers.as_serializer_error(exc))
|
||||||
|
|
||||||
|
return instance
|
||||||
|
|
||||||
def run_validation(self, data=empty):
|
def run_validation(self, data=empty):
|
||||||
"""
|
"""
|
||||||
Perform serializer validation.
|
Perform serializer validation.
|
||||||
@ -188,7 +200,10 @@ class InvenTreeModelSerializer(serializers.ModelSerializer):
|
|||||||
|
|
||||||
# Update instance fields
|
# Update instance fields
|
||||||
for attr, value in data.items():
|
for attr, value in data.items():
|
||||||
setattr(instance, attr, value)
|
try:
|
||||||
|
setattr(instance, attr, value)
|
||||||
|
except (ValidationError, DjangoValidationError) as exc:
|
||||||
|
raise ValidationError(detail=serializers.as_serializer_error(exc))
|
||||||
|
|
||||||
# Run a 'full_clean' on the model.
|
# Run a 'full_clean' on the model.
|
||||||
# Note that by default, DRF does *not* perform full model validation!
|
# Note that by default, DRF does *not* perform full model validation!
|
||||||
@ -208,6 +223,22 @@ class InvenTreeModelSerializer(serializers.ModelSerializer):
|
|||||||
return data
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
class InvenTreeAttachmentSerializer(InvenTreeModelSerializer):
|
||||||
|
"""
|
||||||
|
Special case of an InvenTreeModelSerializer, which handles an "attachment" model.
|
||||||
|
|
||||||
|
The only real addition here is that we support "renaming" of the attachment file.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# The 'filename' field must be present in the serializer
|
||||||
|
filename = serializers.CharField(
|
||||||
|
label=_('Filename'),
|
||||||
|
required=False,
|
||||||
|
source='basename',
|
||||||
|
allow_blank=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class InvenTreeAttachmentSerializerField(serializers.FileField):
|
class InvenTreeAttachmentSerializerField(serializers.FileField):
|
||||||
"""
|
"""
|
||||||
Override the DRF native FileField serializer,
|
Override the DRF native FileField serializer,
|
||||||
|
@ -169,6 +169,30 @@ else:
|
|||||||
logger.exception(f"Couldn't load keyfile {key_file}")
|
logger.exception(f"Couldn't load keyfile {key_file}")
|
||||||
sys.exit(-1)
|
sys.exit(-1)
|
||||||
|
|
||||||
|
# The filesystem location for served static files
|
||||||
|
STATIC_ROOT = os.path.abspath(
|
||||||
|
get_setting(
|
||||||
|
'INVENTREE_STATIC_ROOT',
|
||||||
|
CONFIG.get('static_root', None)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if STATIC_ROOT is None:
|
||||||
|
print("ERROR: INVENTREE_STATIC_ROOT directory not defined")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# The filesystem location for served static files
|
||||||
|
MEDIA_ROOT = os.path.abspath(
|
||||||
|
get_setting(
|
||||||
|
'INVENTREE_MEDIA_ROOT',
|
||||||
|
CONFIG.get('media_root', None)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if MEDIA_ROOT is None:
|
||||||
|
print("ERROR: INVENTREE_MEDIA_ROOT directory is not defined")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
# List of allowed hosts (default = allow all)
|
# List of allowed hosts (default = allow all)
|
||||||
ALLOWED_HOSTS = CONFIG.get('allowed_hosts', ['*'])
|
ALLOWED_HOSTS = CONFIG.get('allowed_hosts', ['*'])
|
||||||
|
|
||||||
@ -189,22 +213,12 @@ if cors_opt:
|
|||||||
# Web URL endpoint for served static files
|
# Web URL endpoint for served static files
|
||||||
STATIC_URL = '/static/'
|
STATIC_URL = '/static/'
|
||||||
|
|
||||||
# The filesystem location for served static files
|
STATICFILES_DIRS = []
|
||||||
STATIC_ROOT = os.path.abspath(
|
|
||||||
get_setting(
|
|
||||||
'INVENTREE_STATIC_ROOT',
|
|
||||||
CONFIG.get('static_root', '/home/inventree/data/static')
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
STATICFILES_DIRS = [
|
|
||||||
os.path.join(BASE_DIR, 'InvenTree', 'static'),
|
|
||||||
]
|
|
||||||
|
|
||||||
# Translated Template settings
|
# Translated Template settings
|
||||||
STATICFILES_I18_PREFIX = 'i18n'
|
STATICFILES_I18_PREFIX = 'i18n'
|
||||||
STATICFILES_I18_SRC = os.path.join(BASE_DIR, 'templates', 'js', 'translated')
|
STATICFILES_I18_SRC = os.path.join(BASE_DIR, 'templates', 'js', 'translated')
|
||||||
STATICFILES_I18_TRG = STATICFILES_DIRS[0] + '_' + STATICFILES_I18_PREFIX
|
STATICFILES_I18_TRG = os.path.join(BASE_DIR, 'InvenTree', 'static_i18n')
|
||||||
STATICFILES_DIRS.append(STATICFILES_I18_TRG)
|
STATICFILES_DIRS.append(STATICFILES_I18_TRG)
|
||||||
STATICFILES_I18_TRG = os.path.join(STATICFILES_I18_TRG, STATICFILES_I18_PREFIX)
|
STATICFILES_I18_TRG = os.path.join(STATICFILES_I18_TRG, STATICFILES_I18_PREFIX)
|
||||||
|
|
||||||
@ -218,19 +232,11 @@ STATIC_COLOR_THEMES_DIR = os.path.join(STATIC_ROOT, 'css', 'color-themes')
|
|||||||
# Web URL endpoint for served media files
|
# Web URL endpoint for served media files
|
||||||
MEDIA_URL = '/media/'
|
MEDIA_URL = '/media/'
|
||||||
|
|
||||||
# The filesystem location for served static files
|
|
||||||
MEDIA_ROOT = os.path.abspath(
|
|
||||||
get_setting(
|
|
||||||
'INVENTREE_MEDIA_ROOT',
|
|
||||||
CONFIG.get('media_root', '/home/inventree/data/media')
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
if DEBUG:
|
if DEBUG:
|
||||||
logger.info("InvenTree running in DEBUG mode")
|
logger.info("InvenTree running in DEBUG mode")
|
||||||
|
|
||||||
logger.info(f"MEDIA_ROOT: '{MEDIA_ROOT}'")
|
logger.debug(f"MEDIA_ROOT: '{MEDIA_ROOT}'")
|
||||||
logger.info(f"STATIC_ROOT: '{STATIC_ROOT}'")
|
logger.debug(f"STATIC_ROOT: '{STATIC_ROOT}'")
|
||||||
|
|
||||||
# Application definition
|
# Application definition
|
||||||
|
|
||||||
@ -320,6 +326,7 @@ TEMPLATES = [
|
|||||||
'django.template.context_processors.i18n',
|
'django.template.context_processors.i18n',
|
||||||
'django.contrib.auth.context_processors.auth',
|
'django.contrib.auth.context_processors.auth',
|
||||||
'django.contrib.messages.context_processors.messages',
|
'django.contrib.messages.context_processors.messages',
|
||||||
|
# Custom InvenTree context processors
|
||||||
'InvenTree.context.health_status',
|
'InvenTree.context.health_status',
|
||||||
'InvenTree.context.status_codes',
|
'InvenTree.context.status_codes',
|
||||||
'InvenTree.context.user_roles',
|
'InvenTree.context.user_roles',
|
||||||
@ -413,7 +420,7 @@ Configure the database backend based on the user-specified values.
|
|||||||
- The following code lets the user "mix and match" database configuration
|
- The following code lets the user "mix and match" database configuration
|
||||||
"""
|
"""
|
||||||
|
|
||||||
logger.info("Configuring database backend:")
|
logger.debug("Configuring database backend:")
|
||||||
|
|
||||||
# Extract database configuration from the config.yaml file
|
# Extract database configuration from the config.yaml file
|
||||||
db_config = CONFIG.get('database', {})
|
db_config = CONFIG.get('database', {})
|
||||||
@ -467,11 +474,9 @@ if db_engine in ['sqlite3', 'postgresql', 'mysql']:
|
|||||||
db_name = db_config['NAME']
|
db_name = db_config['NAME']
|
||||||
db_host = db_config.get('HOST', "''")
|
db_host = db_config.get('HOST', "''")
|
||||||
|
|
||||||
print("InvenTree Database Configuration")
|
logger.info(f"DB_ENGINE: {db_engine}")
|
||||||
print("================================")
|
logger.info(f"DB_NAME: {db_name}")
|
||||||
print(f"ENGINE: {db_engine}")
|
logger.info(f"DB_HOST: {db_host}")
|
||||||
print(f"NAME: {db_name}")
|
|
||||||
print(f"HOST: {db_host}")
|
|
||||||
|
|
||||||
DATABASES['default'] = db_config
|
DATABASES['default'] = db_config
|
||||||
|
|
||||||
|
@ -640,6 +640,11 @@
|
|||||||
z-index: 9999;
|
z-index: 9999;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.modal-error {
|
||||||
|
border: 2px #FCC solid;
|
||||||
|
background-color: #f5f0f0;
|
||||||
|
}
|
||||||
|
|
||||||
.modal-header {
|
.modal-header {
|
||||||
border-bottom: 1px solid #ddd;
|
border-bottom: 1px solid #ddd;
|
||||||
}
|
}
|
||||||
@ -730,6 +735,13 @@
|
|||||||
padding: 10px;
|
padding: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.form-panel {
|
||||||
|
border-radius: 5px;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
padding: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
.modal input {
|
.modal input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
@ -1037,6 +1049,11 @@ a.anchor {
|
|||||||
height: 30px;
|
height: 30px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Force minimum width of number input fields to show at least ~5 digits */
|
||||||
|
input[type='number']{
|
||||||
|
min-width: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
.search-menu {
|
.search-menu {
|
||||||
padding-top: 2rem;
|
padding-top: 2rem;
|
||||||
}
|
}
|
||||||
|
@ -36,7 +36,7 @@ def schedule_task(taskname, **kwargs):
|
|||||||
# If this task is already scheduled, don't schedule it again
|
# If this task is already scheduled, don't schedule it again
|
||||||
# Instead, update the scheduling parameters
|
# Instead, update the scheduling parameters
|
||||||
if Schedule.objects.filter(func=taskname).exists():
|
if Schedule.objects.filter(func=taskname).exists():
|
||||||
logger.info(f"Scheduled task '{taskname}' already exists - updating!")
|
logger.debug(f"Scheduled task '{taskname}' already exists - updating!")
|
||||||
|
|
||||||
Schedule.objects.filter(func=taskname).update(**kwargs)
|
Schedule.objects.filter(func=taskname).update(**kwargs)
|
||||||
else:
|
else:
|
||||||
@ -204,6 +204,25 @@ def check_for_updates():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def delete_expired_sessions():
|
||||||
|
"""
|
||||||
|
Remove any expired user sessions from the database
|
||||||
|
"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
from django.contrib.sessions.models import Session
|
||||||
|
|
||||||
|
# Delete any sessions that expired more than a day ago
|
||||||
|
expired = Session.objects.filter(expire_date__lt=timezone.now() - timedelta(days=1))
|
||||||
|
|
||||||
|
if True or expired.count() > 0:
|
||||||
|
logger.info(f"Deleting {expired.count()} expired sessions.")
|
||||||
|
expired.delete()
|
||||||
|
|
||||||
|
except AppRegistryNotReady:
|
||||||
|
logger.info("Could not perform 'delete_expired_sessions' - App registry not ready")
|
||||||
|
|
||||||
|
|
||||||
def update_exchange_rates():
|
def update_exchange_rates():
|
||||||
"""
|
"""
|
||||||
Update currency exchange rates
|
Update currency exchange rates
|
||||||
|
@ -10,7 +10,8 @@ from django.db.models import BooleanField
|
|||||||
|
|
||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
|
|
||||||
from InvenTree.serializers import InvenTreeModelSerializer, InvenTreeAttachmentSerializerField, UserSerializerBrief
|
from InvenTree.serializers import InvenTreeModelSerializer, InvenTreeAttachmentSerializer
|
||||||
|
from InvenTree.serializers import InvenTreeAttachmentSerializerField, UserSerializerBrief
|
||||||
|
|
||||||
from stock.serializers import StockItemSerializerBrief
|
from stock.serializers import StockItemSerializerBrief
|
||||||
from stock.serializers import LocationSerializer
|
from stock.serializers import LocationSerializer
|
||||||
@ -158,7 +159,7 @@ class BuildItemSerializer(InvenTreeModelSerializer):
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
class BuildAttachmentSerializer(InvenTreeModelSerializer):
|
class BuildAttachmentSerializer(InvenTreeAttachmentSerializer):
|
||||||
"""
|
"""
|
||||||
Serializer for a BuildAttachment
|
Serializer for a BuildAttachment
|
||||||
"""
|
"""
|
||||||
@ -172,6 +173,7 @@ class BuildAttachmentSerializer(InvenTreeModelSerializer):
|
|||||||
'pk',
|
'pk',
|
||||||
'build',
|
'build',
|
||||||
'attachment',
|
'attachment',
|
||||||
|
'filename',
|
||||||
'comment',
|
'comment',
|
||||||
'upload_date',
|
'upload_date',
|
||||||
]
|
]
|
||||||
|
@ -369,6 +369,7 @@ loadAttachmentTable(
|
|||||||
|
|
||||||
constructForm(url, {
|
constructForm(url, {
|
||||||
fields: {
|
fields: {
|
||||||
|
filename: {},
|
||||||
comment: {},
|
comment: {},
|
||||||
},
|
},
|
||||||
onSuccess: reloadAttachmentTable,
|
onSuccess: reloadAttachmentTable,
|
||||||
|
@ -20,7 +20,6 @@ from djmoney.contrib.exchange.models import convert_money
|
|||||||
from djmoney.contrib.exchange.exceptions import MissingRate
|
from djmoney.contrib.exchange.exceptions import MissingRate
|
||||||
|
|
||||||
from django.utils.translation import ugettext_lazy as _
|
from django.utils.translation import ugettext_lazy as _
|
||||||
from django.utils.html import format_html
|
|
||||||
from django.core.validators import MinValueValidator, URLValidator
|
from django.core.validators import MinValueValidator, URLValidator
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
|
|
||||||
@ -49,55 +48,37 @@ class BaseInvenTreeSetting(models.Model):
|
|||||||
are assigned their default values
|
are assigned their default values
|
||||||
"""
|
"""
|
||||||
|
|
||||||
keys = set()
|
|
||||||
settings = []
|
|
||||||
|
|
||||||
results = cls.objects.all()
|
results = cls.objects.all()
|
||||||
|
|
||||||
if user is not None:
|
if user is not None:
|
||||||
results = results.filter(user=user)
|
results = results.filter(user=user)
|
||||||
|
|
||||||
# Query the database
|
# Query the database
|
||||||
|
settings = {}
|
||||||
|
|
||||||
for setting in results:
|
for setting in results:
|
||||||
if setting.key:
|
if setting.key:
|
||||||
settings.append({
|
settings[setting.key.upper()] = setting.value
|
||||||
"key": setting.key.upper(),
|
|
||||||
"value": setting.value
|
|
||||||
})
|
|
||||||
|
|
||||||
keys.add(setting.key.upper())
|
|
||||||
|
|
||||||
# Specify any "default" values which are not in the database
|
# Specify any "default" values which are not in the database
|
||||||
for key in cls.GLOBAL_SETTINGS.keys():
|
for key in cls.GLOBAL_SETTINGS.keys():
|
||||||
|
|
||||||
if key.upper() not in keys:
|
if key.upper() not in settings:
|
||||||
|
|
||||||
settings.append({
|
settings[key.upper()] = cls.get_setting_default(key)
|
||||||
"key": key.upper(),
|
|
||||||
"value": cls.get_setting_default(key)
|
|
||||||
})
|
|
||||||
|
|
||||||
# Enforce javascript formatting
|
|
||||||
for idx, setting in enumerate(settings):
|
|
||||||
|
|
||||||
key = setting['key']
|
|
||||||
value = setting['value']
|
|
||||||
|
|
||||||
|
for key, value in settings.items():
|
||||||
validator = cls.get_setting_validator(key)
|
validator = cls.get_setting_validator(key)
|
||||||
|
|
||||||
# Convert to javascript compatible booleans
|
|
||||||
if cls.validator_is_bool(validator):
|
if cls.validator_is_bool(validator):
|
||||||
value = str(value).lower()
|
value = InvenTree.helpers.str2bool(value)
|
||||||
|
|
||||||
# Numerical values remain the same
|
|
||||||
elif cls.validator_is_int(validator):
|
elif cls.validator_is_int(validator):
|
||||||
pass
|
try:
|
||||||
|
value = int(value)
|
||||||
|
except ValueError:
|
||||||
|
value = cls.get_setting_default(key)
|
||||||
|
|
||||||
# Wrap strings with quotes
|
settings[key] = value
|
||||||
else:
|
|
||||||
value = format_html("'{}'", value)
|
|
||||||
|
|
||||||
setting["value"] = value
|
|
||||||
|
|
||||||
return settings
|
return settings
|
||||||
|
|
||||||
|
@ -24,19 +24,17 @@
|
|||||||
</button>
|
</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<div class='btn-group'>
|
<div class='btn-group'>
|
||||||
<div class="dropdown" style="float: right;">
|
<button class="btn btn-primary dropdown-toggle" id='supplier-table-options' type="button" data-toggle="dropdown">{% trans "Options" %}
|
||||||
<button class="btn btn-primary dropdown-toggle" type="button" data-toggle="dropdown">{% trans "Options" %}
|
<span class="caret"></span>
|
||||||
<span class="caret"></span>
|
</button>
|
||||||
</button>
|
<ul class="dropdown-menu">
|
||||||
<ul class="dropdown-menu">
|
{% if roles.purchase_order.add %}
|
||||||
{% if roles.purchase_order.add %}
|
<li><a href='#' id='multi-supplier-part-order' title='{% trans "Order parts" %}'>{% trans "Order Parts" %}</a></li>
|
||||||
<li><a href='#' id='multi-part-order' title='{% trans "Order parts" %}'>{% trans "Order Parts" %}</a></li>
|
{% endif %}
|
||||||
{% endif %}
|
{% if roles.purchase_order.delete %}
|
||||||
{% if roles.purchase_order.delete %}
|
<li><a href='#' id='multi-supplier-part-delete' title='{% trans "Delete parts" %}'>{% trans "Delete Parts" %}</a></li>
|
||||||
<li><a href='#' id='multi-part-delete' title='{% trans "Delete parts" %}'>{% trans "Delete Parts" %}</a></li>
|
{% endif %}
|
||||||
{% endif %}
|
</ul>
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class='filter-list' id='filter-list-supplier-part'>
|
<div class='filter-list' id='filter-list-supplier-part'>
|
||||||
@ -59,27 +57,25 @@
|
|||||||
{% if roles.purchase_order.change %}
|
{% if roles.purchase_order.change %}
|
||||||
<div id='manufacturer-part-button-toolbar'>
|
<div id='manufacturer-part-button-toolbar'>
|
||||||
<div class='button-toolbar container-fluid'>
|
<div class='button-toolbar container-fluid'>
|
||||||
<div class='btn-group role='group'>
|
<div class='btn-group' role='group'>
|
||||||
{% if roles.purchase_order.add %}
|
{% if roles.purchase_order.add %}
|
||||||
<button class="btn btn-success" id='manufacturer-part-create' title='{% trans "Create new manufacturer part" %}'>
|
<button type="button" class="btn btn-success" id='manufacturer-part-create' title='{% trans "Create new manufacturer part" %}'>
|
||||||
<span class='fas fa-plus-circle'></span> {% trans "New Manufacturer Part" %}
|
<span class='fas fa-plus-circle'></span> {% trans "New Manufacturer Part" %}
|
||||||
</button>
|
</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<div class='btn-group'>
|
<div class='btn-group' role='group'>
|
||||||
<div class="dropdown" style="float: right;">
|
<button class="btn btn-primary dropdown-toggle" id='manufacturer-table-options' type="button" data-toggle="dropdown">{% trans "Options" %}
|
||||||
<button class="btn btn-primary dropdown-toggle" id='table-options', type="button" data-toggle="dropdown">{% trans "Options" %}
|
<span class="caret"></span>
|
||||||
<span class="caret"></span>
|
</button>
|
||||||
</button>
|
<ul class="dropdown-menu">
|
||||||
<ul class="dropdown-menu">
|
{% if roles.purchase_order.add %}
|
||||||
{% if roles.purchase_order.add %}
|
<li><a href='#' id='multi-manufacturer-part-order' title='{% trans "Order parts" %}'>{% trans "Order Parts" %}</a></li>
|
||||||
<li><a href='#' id='multi-part-order' title='{% trans "Order parts" %}'>{% trans "Order Parts" %}</a></li>
|
{% endif %}
|
||||||
{% endif %}
|
{% if roles.purchase_order.delete %}
|
||||||
{% if roles.purchase_order.delete %}
|
<li><a href='#' id='multi-manufacturer-part-delete' title='{% trans "Delete parts" %}'>{% trans "Delete Parts" %}</a></li>
|
||||||
<li><a href='#' id='multi-part-delete' title='{% trans "Delete parts" %}'>{% trans "Delete Parts" %}</a></li>
|
{% endif %}
|
||||||
{% endif %}
|
</ul>
|
||||||
</ul>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class='filter-list' id='filter-list-supplier-part'>
|
<div class='filter-list' id='filter-list-supplier-part'>
|
||||||
<!-- Empty div (will be filled out with available BOM filters) -->
|
<!-- Empty div (will be filled out with available BOM filters) -->
|
||||||
@ -87,7 +83,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<table class='table table-striped table-condensed' id='part-table' data-toolbar='#manufacturer-part-button-toolbar'>
|
<table class='table table-striped table-condensed' id='manufacturer-part-table' data-toolbar='#manufacturer-part-button-toolbar'>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -274,6 +270,10 @@
|
|||||||
|
|
||||||
{% if company.is_manufacturer %}
|
{% if company.is_manufacturer %}
|
||||||
|
|
||||||
|
function reloadManufacturerPartTable() {
|
||||||
|
$('#manufacturer-part-table').bootstrapTable('refresh');
|
||||||
|
}
|
||||||
|
|
||||||
$("#manufacturer-part-create").click(function () {
|
$("#manufacturer-part-create").click(function () {
|
||||||
|
|
||||||
createManufacturerPart({
|
createManufacturerPart({
|
||||||
@ -285,7 +285,7 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
loadManufacturerPartTable(
|
loadManufacturerPartTable(
|
||||||
"#part-table",
|
"#manufacturer-part-table",
|
||||||
"{% url 'api-manufacturer-part-list' %}",
|
"{% url 'api-manufacturer-part-list' %}",
|
||||||
{
|
{
|
||||||
params: {
|
params: {
|
||||||
@ -296,20 +296,20 @@
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
linkButtonsToSelection($("#manufacturer-table"), ['#table-options']);
|
linkButtonsToSelection($("#manufacturer-part-table"), ['#manufacturer-table-options']);
|
||||||
|
|
||||||
$("#multi-part-delete").click(function() {
|
$("#multi-manufacturer-part-delete").click(function() {
|
||||||
var selections = $("#part-table").bootstrapTable("getSelections");
|
var selections = $("#manufacturer-part-table").bootstrapTable("getSelections");
|
||||||
|
|
||||||
deleteManufacturerParts(selections, {
|
deleteManufacturerParts(selections, {
|
||||||
onSuccess: function() {
|
onSuccess: function() {
|
||||||
$("#part-table").bootstrapTable("refresh");
|
$("#manufacturer-part-table").bootstrapTable("refresh");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
$("#multi-part-order").click(function() {
|
$("#multi-manufacturer-part-order").click(function() {
|
||||||
var selections = $("#part-table").bootstrapTable("getSelections");
|
var selections = $("#manufacturer-part-table").bootstrapTable("getSelections");
|
||||||
|
|
||||||
var parts = [];
|
var parts = [];
|
||||||
|
|
||||||
@ -353,9 +353,9 @@
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
{% endif %}
|
linkButtonsToSelection($("#supplier-part-table"), ['#supplier-table-options']);
|
||||||
|
|
||||||
$("#multi-part-delete").click(function() {
|
$("#multi-supplier-part-delete").click(function() {
|
||||||
var selections = $("#supplier-part-table").bootstrapTable("getSelections");
|
var selections = $("#supplier-part-table").bootstrapTable("getSelections");
|
||||||
|
|
||||||
var requests = [];
|
var requests = [];
|
||||||
@ -379,8 +379,8 @@
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
$("#multi-part-order").click(function() {
|
$("#multi-supplier-part-order").click(function() {
|
||||||
var selections = $("#part-table").bootstrapTable("getSelections");
|
var selections = $("#supplier-part-table").bootstrapTable("getSelections");
|
||||||
|
|
||||||
var parts = [];
|
var parts = [];
|
||||||
|
|
||||||
@ -395,6 +395,8 @@
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
attachNavCallbacks({
|
attachNavCallbacks({
|
||||||
name: 'company',
|
name: 'company',
|
||||||
default: 'company-stock'
|
default: 'company-stock'
|
||||||
|
@ -109,7 +109,7 @@ src="{% static 'img/blank_image.png' %}"
|
|||||||
<span class='fas fa-plus-circle'></span> {% trans "New Supplier Part" %}
|
<span class='fas fa-plus-circle'></span> {% trans "New Supplier Part" %}
|
||||||
</button>
|
</button>
|
||||||
<div id='opt-dropdown' class="btn-group">
|
<div id='opt-dropdown' class="btn-group">
|
||||||
<button id='supplier-part-options' class="btn btn-primary dropdown-toggle" type="button" data-toggle="dropdown">{% trans "Options" %}<span class="caret"></span></button>
|
<button id='supplier-part-options' class="btn btn-primary dropdown-toggle" type="button" data-toggle="dropdown">{% trans "Options" %} <span class="caret"></span></button>
|
||||||
<ul class="dropdown-menu">
|
<ul class="dropdown-menu">
|
||||||
<li><a href='#' id='supplier-part-delete' title='{% trans "Delete supplier parts" %}'>{% trans "Delete" %}</a></li>
|
<li><a href='#' id='supplier-part-delete' title='{% trans "Delete supplier parts" %}'>{% trans "Delete" %}</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
@ -133,7 +133,7 @@ src="{% static 'img/blank_image.png' %}"
|
|||||||
<span class='fas fa-plus-circle'></span> {% trans "New Parameter" %}
|
<span class='fas fa-plus-circle'></span> {% trans "New Parameter" %}
|
||||||
</button>
|
</button>
|
||||||
<div id='opt-dropdown' class="btn-group">
|
<div id='opt-dropdown' class="btn-group">
|
||||||
<button id='parameter-options' class="btn btn-primary dropdown-toggle" type="button" data-toggle="dropdown">{% trans "Options" %}<span class="caret"></span></button>
|
<button id='parameter-options' class="btn btn-primary dropdown-toggle" type="button" data-toggle="dropdown">{% trans "Options" %} <span class="caret"></span></button>
|
||||||
<ul class="dropdown-menu">
|
<ul class="dropdown-menu">
|
||||||
<li><a href='#' id='multi-parameter-delete' title='{% trans "Delete parameters" %}'>{% trans "Delete" %}</a></li>
|
<li><a href='#' id='multi-parameter-delete' title='{% trans "Delete parameters" %}'>{% trans "Delete" %}</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
|
@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 3.2.4 on 2021-08-12 17:49
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('company', '0040_alter_company_currency'),
|
||||||
|
('order', '0048_auto_20210702_2321'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterUniqueTogether(
|
||||||
|
name='purchaseorderlineitem',
|
||||||
|
unique_together={('order', 'part', 'quantity', 'purchase_price')},
|
||||||
|
),
|
||||||
|
]
|
@ -729,7 +729,7 @@ class PurchaseOrderLineItem(OrderLineItem):
|
|||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
unique_together = (
|
unique_together = (
|
||||||
('order', 'part')
|
('order', 'part', 'quantity', 'purchase_price')
|
||||||
)
|
)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
|
@ -14,6 +14,7 @@ from rest_framework import serializers
|
|||||||
from sql_util.utils import SubqueryCount
|
from sql_util.utils import SubqueryCount
|
||||||
|
|
||||||
from InvenTree.serializers import InvenTreeModelSerializer
|
from InvenTree.serializers import InvenTreeModelSerializer
|
||||||
|
from InvenTree.serializers import InvenTreeAttachmentSerializer
|
||||||
from InvenTree.serializers import InvenTreeMoneySerializer
|
from InvenTree.serializers import InvenTreeMoneySerializer
|
||||||
from InvenTree.serializers import InvenTreeAttachmentSerializerField
|
from InvenTree.serializers import InvenTreeAttachmentSerializerField
|
||||||
|
|
||||||
@ -160,7 +161,7 @@ class POLineItemSerializer(InvenTreeModelSerializer):
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
class POAttachmentSerializer(InvenTreeModelSerializer):
|
class POAttachmentSerializer(InvenTreeAttachmentSerializer):
|
||||||
"""
|
"""
|
||||||
Serializers for the PurchaseOrderAttachment model
|
Serializers for the PurchaseOrderAttachment model
|
||||||
"""
|
"""
|
||||||
@ -174,6 +175,7 @@ class POAttachmentSerializer(InvenTreeModelSerializer):
|
|||||||
'pk',
|
'pk',
|
||||||
'order',
|
'order',
|
||||||
'attachment',
|
'attachment',
|
||||||
|
'filename',
|
||||||
'comment',
|
'comment',
|
||||||
'upload_date',
|
'upload_date',
|
||||||
]
|
]
|
||||||
@ -381,7 +383,7 @@ class SOLineItemSerializer(InvenTreeModelSerializer):
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
class SOAttachmentSerializer(InvenTreeModelSerializer):
|
class SOAttachmentSerializer(InvenTreeAttachmentSerializer):
|
||||||
"""
|
"""
|
||||||
Serializers for the SalesOrderAttachment model
|
Serializers for the SalesOrderAttachment model
|
||||||
"""
|
"""
|
||||||
@ -395,6 +397,7 @@ class SOAttachmentSerializer(InvenTreeModelSerializer):
|
|||||||
'pk',
|
'pk',
|
||||||
'order',
|
'order',
|
||||||
'attachment',
|
'attachment',
|
||||||
|
'filename',
|
||||||
'comment',
|
'comment',
|
||||||
'upload_date',
|
'upload_date',
|
||||||
]
|
]
|
||||||
|
@ -115,7 +115,7 @@
|
|||||||
{{ block.super }}
|
{{ block.super }}
|
||||||
|
|
||||||
$('.bomselect').select2({
|
$('.bomselect').select2({
|
||||||
dropdownAutoWidth: true,
|
width: '100%',
|
||||||
matcher: partialMatcher,
|
matcher: partialMatcher,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -122,6 +122,7 @@
|
|||||||
|
|
||||||
constructForm(url, {
|
constructForm(url, {
|
||||||
fields: {
|
fields: {
|
||||||
|
filename: {},
|
||||||
comment: {},
|
comment: {},
|
||||||
},
|
},
|
||||||
onSuccess: reloadAttachmentTable,
|
onSuccess: reloadAttachmentTable,
|
||||||
@ -327,7 +328,7 @@ $("#po-table").inventreeTable({
|
|||||||
{
|
{
|
||||||
sortable: true,
|
sortable: true,
|
||||||
sortName: 'part__MPN',
|
sortName: 'part__MPN',
|
||||||
field: 'supplier_part_detail.MPN',
|
field: 'supplier_part_detail.manufacturer_part_detail.MPN',
|
||||||
title: '{% trans "MPN" %}',
|
title: '{% trans "MPN" %}',
|
||||||
formatter: function(value, row, index, field) {
|
formatter: function(value, row, index, field) {
|
||||||
if (row.supplier_part_detail && row.supplier_part_detail.manufacturer_part) {
|
if (row.supplier_part_detail && row.supplier_part_detail.manufacturer_part) {
|
||||||
|
@ -112,6 +112,7 @@
|
|||||||
|
|
||||||
constructForm(url, {
|
constructForm(url, {
|
||||||
fields: {
|
fields: {
|
||||||
|
filename: {},
|
||||||
comment: {},
|
comment: {},
|
||||||
},
|
},
|
||||||
onSuccess: reloadAttachmentTable,
|
onSuccess: reloadAttachmentTable,
|
||||||
|
@ -9,12 +9,14 @@ from django.conf.urls import url, include
|
|||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.http import JsonResponse
|
from django.http import JsonResponse
|
||||||
from django.db.models import Q, F, Count, Min, Max, Avg
|
from django.db.models import Q, F, Count, Min, Max, Avg
|
||||||
|
from django.db import transaction
|
||||||
from django.utils.translation import ugettext_lazy as _
|
from django.utils.translation import ugettext_lazy as _
|
||||||
|
|
||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
from rest_framework import filters, serializers
|
from rest_framework import filters, serializers
|
||||||
from rest_framework import generics
|
from rest_framework import generics
|
||||||
|
from rest_framework.exceptions import ValidationError
|
||||||
|
|
||||||
from django_filters.rest_framework import DjangoFilterBackend
|
from django_filters.rest_framework import DjangoFilterBackend
|
||||||
from django_filters import rest_framework as rest_filters
|
from django_filters import rest_framework as rest_filters
|
||||||
@ -23,7 +25,7 @@ from djmoney.money import Money
|
|||||||
from djmoney.contrib.exchange.models import convert_money
|
from djmoney.contrib.exchange.models import convert_money
|
||||||
from djmoney.contrib.exchange.exceptions import MissingRate
|
from djmoney.contrib.exchange.exceptions import MissingRate
|
||||||
|
|
||||||
from decimal import Decimal
|
from decimal import Decimal, InvalidOperation
|
||||||
|
|
||||||
from .models import Part, PartCategory, BomItem
|
from .models import Part, PartCategory, BomItem
|
||||||
from .models import PartParameter, PartParameterTemplate
|
from .models import PartParameter, PartParameterTemplate
|
||||||
@ -31,7 +33,10 @@ from .models import PartAttachment, PartTestTemplate
|
|||||||
from .models import PartSellPriceBreak, PartInternalPriceBreak
|
from .models import PartSellPriceBreak, PartInternalPriceBreak
|
||||||
from .models import PartCategoryParameterTemplate
|
from .models import PartCategoryParameterTemplate
|
||||||
|
|
||||||
from stock.models import StockItem
|
from company.models import Company, ManufacturerPart, SupplierPart
|
||||||
|
|
||||||
|
from stock.models import StockItem, StockLocation
|
||||||
|
|
||||||
from common.models import InvenTreeSetting
|
from common.models import InvenTreeSetting
|
||||||
from build.models import Build
|
from build.models import Build
|
||||||
|
|
||||||
@ -630,6 +635,7 @@ class PartList(generics.ListCreateAPIView):
|
|||||||
else:
|
else:
|
||||||
return Response(data)
|
return Response(data)
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
def create(self, request, *args, **kwargs):
|
def create(self, request, *args, **kwargs):
|
||||||
"""
|
"""
|
||||||
We wish to save the user who created this part!
|
We wish to save the user who created this part!
|
||||||
@ -637,6 +643,8 @@ class PartList(generics.ListCreateAPIView):
|
|||||||
Note: Implementation copied from DRF class CreateModelMixin
|
Note: Implementation copied from DRF class CreateModelMixin
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
# TODO: Unit tests for this function!
|
||||||
|
|
||||||
serializer = self.get_serializer(data=request.data)
|
serializer = self.get_serializer(data=request.data)
|
||||||
serializer.is_valid(raise_exception=True)
|
serializer.is_valid(raise_exception=True)
|
||||||
|
|
||||||
@ -680,21 +688,97 @@ class PartList(generics.ListCreateAPIView):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
# Optionally create initial stock item
|
# Optionally create initial stock item
|
||||||
try:
|
initial_stock = str2bool(request.data.get('initial_stock', False))
|
||||||
initial_stock = Decimal(request.data.get('initial_stock', 0))
|
|
||||||
|
|
||||||
if initial_stock > 0 and part.default_location is not None:
|
if initial_stock:
|
||||||
|
try:
|
||||||
|
|
||||||
stock_item = StockItem(
|
initial_stock_quantity = Decimal(request.data.get('initial_stock_quantity', ''))
|
||||||
|
|
||||||
|
if initial_stock_quantity <= 0:
|
||||||
|
raise ValidationError({
|
||||||
|
'initial_stock_quantity': [_('Must be greater than zero')],
|
||||||
|
})
|
||||||
|
except (ValueError, InvalidOperation): # Invalid quantity provided
|
||||||
|
raise ValidationError({
|
||||||
|
'initial_stock_quantity': [_('Must be a valid quantity')],
|
||||||
|
})
|
||||||
|
|
||||||
|
initial_stock_location = request.data.get('initial_stock_location', None)
|
||||||
|
|
||||||
|
try:
|
||||||
|
initial_stock_location = StockLocation.objects.get(pk=initial_stock_location)
|
||||||
|
except (ValueError, StockLocation.DoesNotExist):
|
||||||
|
initial_stock_location = None
|
||||||
|
|
||||||
|
if initial_stock_location is None:
|
||||||
|
if part.default_location is not None:
|
||||||
|
initial_stock_location = part.default_location
|
||||||
|
else:
|
||||||
|
raise ValidationError({
|
||||||
|
'initial_stock_location': [_('Specify location for initial part stock')],
|
||||||
|
})
|
||||||
|
|
||||||
|
stock_item = StockItem(
|
||||||
|
part=part,
|
||||||
|
quantity=initial_stock_quantity,
|
||||||
|
location=initial_stock_location,
|
||||||
|
)
|
||||||
|
|
||||||
|
stock_item.save(user=request.user)
|
||||||
|
|
||||||
|
# Optionally add manufacturer / supplier data to the part
|
||||||
|
if part.purchaseable and str2bool(request.data.get('add_supplier_info', False)):
|
||||||
|
|
||||||
|
try:
|
||||||
|
manufacturer = Company.objects.get(pk=request.data.get('manufacturer', None))
|
||||||
|
except:
|
||||||
|
manufacturer = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
supplier = Company.objects.get(pk=request.data.get('supplier', None))
|
||||||
|
except:
|
||||||
|
supplier = None
|
||||||
|
|
||||||
|
mpn = str(request.data.get('MPN', '')).strip()
|
||||||
|
sku = str(request.data.get('SKU', '')).strip()
|
||||||
|
|
||||||
|
# Construct a manufacturer part
|
||||||
|
if manufacturer or mpn:
|
||||||
|
if not manufacturer:
|
||||||
|
raise ValidationError({
|
||||||
|
'manufacturer': [_("This field is required")]
|
||||||
|
})
|
||||||
|
if not mpn:
|
||||||
|
raise ValidationError({
|
||||||
|
'MPN': [_("This field is required")]
|
||||||
|
})
|
||||||
|
|
||||||
|
manufacturer_part = ManufacturerPart.objects.create(
|
||||||
part=part,
|
part=part,
|
||||||
quantity=initial_stock,
|
manufacturer=manufacturer,
|
||||||
location=part.default_location,
|
MPN=mpn
|
||||||
)
|
)
|
||||||
|
else:
|
||||||
|
# No manufacturer part data specified
|
||||||
|
manufacturer_part = None
|
||||||
|
|
||||||
stock_item.save(user=request.user)
|
if supplier or sku:
|
||||||
|
if not supplier:
|
||||||
|
raise ValidationError({
|
||||||
|
'supplier': [_("This field is required")]
|
||||||
|
})
|
||||||
|
if not sku:
|
||||||
|
raise ValidationError({
|
||||||
|
'SKU': [_("This field is required")]
|
||||||
|
})
|
||||||
|
|
||||||
except:
|
SupplierPart.objects.create(
|
||||||
pass
|
part=part,
|
||||||
|
supplier=supplier,
|
||||||
|
SKU=sku,
|
||||||
|
manufacturer_part=manufacturer_part,
|
||||||
|
)
|
||||||
|
|
||||||
headers = self.get_success_headers(serializer.data)
|
headers = self.get_success_headers(serializer.data)
|
||||||
|
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
"""
|
"""
|
||||||
JSON serializers for Part app
|
JSON serializers for Part app
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import imghdr
|
import imghdr
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
|
||||||
@ -16,7 +17,9 @@ from djmoney.contrib.django_rest_framework import MoneyField
|
|||||||
from InvenTree.serializers import (InvenTreeAttachmentSerializerField,
|
from InvenTree.serializers import (InvenTreeAttachmentSerializerField,
|
||||||
InvenTreeImageSerializerField,
|
InvenTreeImageSerializerField,
|
||||||
InvenTreeModelSerializer,
|
InvenTreeModelSerializer,
|
||||||
|
InvenTreeAttachmentSerializer,
|
||||||
InvenTreeMoneySerializer)
|
InvenTreeMoneySerializer)
|
||||||
|
|
||||||
from InvenTree.status_codes import BuildStatus, PurchaseOrderStatus
|
from InvenTree.status_codes import BuildStatus, PurchaseOrderStatus
|
||||||
from stock.models import StockItem
|
from stock.models import StockItem
|
||||||
|
|
||||||
@ -51,7 +54,7 @@ class CategorySerializer(InvenTreeModelSerializer):
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
class PartAttachmentSerializer(InvenTreeModelSerializer):
|
class PartAttachmentSerializer(InvenTreeAttachmentSerializer):
|
||||||
"""
|
"""
|
||||||
Serializer for the PartAttachment class
|
Serializer for the PartAttachment class
|
||||||
"""
|
"""
|
||||||
@ -65,6 +68,7 @@ class PartAttachmentSerializer(InvenTreeModelSerializer):
|
|||||||
'pk',
|
'pk',
|
||||||
'part',
|
'part',
|
||||||
'attachment',
|
'attachment',
|
||||||
|
'filename',
|
||||||
'comment',
|
'comment',
|
||||||
'upload_date',
|
'upload_date',
|
||||||
]
|
]
|
||||||
|
@ -132,12 +132,13 @@
|
|||||||
</button>
|
</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<div class='btn-group'>
|
<div class='btn-group'>
|
||||||
<button id='part-options' class='btn btn-primary dropdown-toggle' type='button' data-toggle="dropdown">{% trans "Options" %}<span class='caret'></span></button>
|
<button id='part-options' class='btn btn-primary dropdown-toggle' type='button' data-toggle="dropdown">{% trans "Options" %} <span class='caret'></span></button>
|
||||||
<ul class='dropdown-menu'>
|
<ul class='dropdown-menu'>
|
||||||
{% if roles.part.change %}
|
{% if roles.part.change %}
|
||||||
<li><a href='#' id='multi-part-category' title='{% trans "Set category" %}'>{% trans "Set Category" %}</a></li>
|
<li><a href='#' id='multi-part-category' title='{% trans "Set category" %}'>{% trans "Set Category" %}</a></li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<li><a href='#' id='multi-part-order' title='{% trans "Order parts" %}'>{% trans "Order Parts" %}</a></li>
|
<li><a href='#' id='multi-part-order' title='{% trans "Order parts" %}'>{% trans "Order Parts" %}</a></li>
|
||||||
|
<li><a href='#' id='multi-part-print-label' title='{% trans "Print Labels" %}'>{% trans "Print Labels" %}</a></li>
|
||||||
<li><a href='#' id='multi-part-export' title='{% trans "Export" %}'>{% trans "Export Data" %}</a></li>
|
<li><a href='#' id='multi-part-export' title='{% trans "Export" %}'>{% trans "Export Data" %}</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
@ -276,6 +277,7 @@
|
|||||||
constructForm('{% url "api-part-list" %}', {
|
constructForm('{% url "api-part-list" %}', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
fields: fields,
|
fields: fields,
|
||||||
|
groups: partGroups(),
|
||||||
title: '{% trans "Create Part" %}',
|
title: '{% trans "Create Part" %}',
|
||||||
onSuccess: function(data) {
|
onSuccess: function(data) {
|
||||||
// Follow the new part
|
// Follow the new part
|
||||||
@ -336,4 +338,4 @@
|
|||||||
default: 'part-stock'
|
default: 'part-stock'
|
||||||
});
|
});
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
@ -289,7 +289,7 @@
|
|||||||
<span class='fas fa-plus-circle'></span> {% trans "New Supplier Part" %}
|
<span class='fas fa-plus-circle'></span> {% trans "New Supplier Part" %}
|
||||||
</button>
|
</button>
|
||||||
<div id='opt-dropdown' class="btn-group">
|
<div id='opt-dropdown' class="btn-group">
|
||||||
<button id='supplier-part-options' class="btn btn-primary dropdown-toggle" type="button" data-toggle="dropdown">{% trans "Options" %}<span class="caret"></span></button>
|
<button id='supplier-part-options' class="btn btn-primary dropdown-toggle" type="button" data-toggle="dropdown">{% trans "Options" %} <span class="caret"></span></button>
|
||||||
<ul class="dropdown-menu">
|
<ul class="dropdown-menu">
|
||||||
<li><a href='#' id='supplier-part-delete' title='{% trans "Delete supplier parts" %}'>{% trans "Delete" %}</a></li>
|
<li><a href='#' id='supplier-part-delete' title='{% trans "Delete supplier parts" %}'>{% trans "Delete" %}</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
@ -312,7 +312,7 @@
|
|||||||
<span class='fas fa-plus-circle'></span> {% trans "New Manufacturer Part" %}
|
<span class='fas fa-plus-circle'></span> {% trans "New Manufacturer Part" %}
|
||||||
</button>
|
</button>
|
||||||
<div id='opt-dropdown' class="btn-group">
|
<div id='opt-dropdown' class="btn-group">
|
||||||
<button id='manufacturer-part-options' class="btn btn-primary dropdown-toggle" type="button" data-toggle="dropdown">{% trans "Options" %}<span class="caret"></span></button>
|
<button id='manufacturer-part-options' class="btn btn-primary dropdown-toggle" type="button" data-toggle="dropdown">{% trans "Options" %} <span class="caret"></span></button>
|
||||||
<ul class="dropdown-menu">
|
<ul class="dropdown-menu">
|
||||||
<li><a href='#' id='manufacturer-part-delete' title='{% trans "Delete manufacturer parts" %}'>{% trans "Delete" %}</a></li>
|
<li><a href='#' id='manufacturer-part-delete' title='{% trans "Delete manufacturer parts" %}'>{% trans "Delete" %}</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
@ -868,6 +868,7 @@
|
|||||||
|
|
||||||
constructForm(url, {
|
constructForm(url, {
|
||||||
fields: {
|
fields: {
|
||||||
|
filename: {},
|
||||||
comment: {},
|
comment: {},
|
||||||
},
|
},
|
||||||
title: '{% trans "Edit Attachment" %}',
|
title: '{% trans "Edit Attachment" %}',
|
||||||
|
@ -1,8 +1,24 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "part/part_app_base.html" %}
|
||||||
{% load inventree_extras %}
|
{% load inventree_extras %}
|
||||||
{% load i18n %}
|
{% load i18n %}
|
||||||
{% load static %}
|
{% load static %}
|
||||||
|
|
||||||
|
{% block menubar %}
|
||||||
|
<ul class='list-group'>
|
||||||
|
<li class='list-group-item'>
|
||||||
|
<a href='#' id='part-menu-toggle'>
|
||||||
|
<span class='menu-tab-icon fas fa-expand-arrows-alt'></span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class='list-group-item' title='{% trans "Return To Parts" %}'>
|
||||||
|
<a href='{% url "part-index" %}' id='select-upload-file' class='nav-toggle'>
|
||||||
|
<span class='fas fa-undo side-icon'></span>
|
||||||
|
{% trans "Return To Parts" %}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class='panel panel-default panel-inventree'>
|
<div class='panel panel-default panel-inventree'>
|
||||||
<div class='panel-heading'>
|
<div class='panel-heading'>
|
||||||
@ -54,4 +70,9 @@
|
|||||||
{% block js_ready %}
|
{% block js_ready %}
|
||||||
{{ block.super }}
|
{{ block.super }}
|
||||||
|
|
||||||
|
enableNavbar({
|
||||||
|
label: 'part',
|
||||||
|
toggleId: '#part-menu-toggle',
|
||||||
|
});
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
@ -6,6 +6,7 @@ over and above the built-in Django tags.
|
|||||||
|
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
from django.utils.html import format_html
|
||||||
|
|
||||||
from django.utils.translation import ugettext_lazy as _
|
from django.utils.translation import ugettext_lazy as _
|
||||||
from django.conf import settings as djangosettings
|
from django.conf import settings as djangosettings
|
||||||
@ -262,6 +263,26 @@ def get_available_themes(*args, **kwargs):
|
|||||||
return themes
|
return themes
|
||||||
|
|
||||||
|
|
||||||
|
@register.simple_tag()
|
||||||
|
def primitive_to_javascript(primitive):
|
||||||
|
"""
|
||||||
|
Convert a python primitive to a javascript primitive.
|
||||||
|
|
||||||
|
e.g. True -> true
|
||||||
|
'hello' -> '"hello"'
|
||||||
|
"""
|
||||||
|
|
||||||
|
if type(primitive) is bool:
|
||||||
|
return str(primitive).lower()
|
||||||
|
|
||||||
|
elif type(primitive) in [int, float]:
|
||||||
|
return primitive
|
||||||
|
|
||||||
|
else:
|
||||||
|
# Wrap with quotes
|
||||||
|
return format_html("'{}'", primitive)
|
||||||
|
|
||||||
|
|
||||||
@register.filter
|
@register.filter
|
||||||
def keyvalue(dict, key):
|
def keyvalue(dict, key):
|
||||||
"""
|
"""
|
||||||
|
@ -129,6 +129,7 @@ class PartAPITest(InvenTreeAPITestCase):
|
|||||||
'location',
|
'location',
|
||||||
'bom',
|
'bom',
|
||||||
'test_templates',
|
'test_templates',
|
||||||
|
'company',
|
||||||
]
|
]
|
||||||
|
|
||||||
roles = [
|
roles = [
|
||||||
@ -465,6 +466,128 @@ class PartAPITest(InvenTreeAPITestCase):
|
|||||||
self.assertFalse(response.data['active'])
|
self.assertFalse(response.data['active'])
|
||||||
self.assertFalse(response.data['purchaseable'])
|
self.assertFalse(response.data['purchaseable'])
|
||||||
|
|
||||||
|
def test_initial_stock(self):
|
||||||
|
"""
|
||||||
|
Tests for initial stock quantity creation
|
||||||
|
"""
|
||||||
|
|
||||||
|
url = reverse('api-part-list')
|
||||||
|
|
||||||
|
# Track how many parts exist at the start of this test
|
||||||
|
n = Part.objects.count()
|
||||||
|
|
||||||
|
# Set up required part data
|
||||||
|
data = {
|
||||||
|
'category': 1,
|
||||||
|
'name': "My lil' test part",
|
||||||
|
'description': 'A part with which to test',
|
||||||
|
}
|
||||||
|
|
||||||
|
# Signal that we want to add initial stock
|
||||||
|
data['initial_stock'] = True
|
||||||
|
|
||||||
|
# Post without a quantity
|
||||||
|
response = self.post(url, data, expected_code=400)
|
||||||
|
self.assertIn('initial_stock_quantity', response.data)
|
||||||
|
|
||||||
|
# Post with an invalid quantity
|
||||||
|
data['initial_stock_quantity'] = "ax"
|
||||||
|
response = self.post(url, data, expected_code=400)
|
||||||
|
self.assertIn('initial_stock_quantity', response.data)
|
||||||
|
|
||||||
|
# Post with a negative quantity
|
||||||
|
data['initial_stock_quantity'] = -1
|
||||||
|
response = self.post(url, data, expected_code=400)
|
||||||
|
self.assertIn('Must be greater than zero', response.data['initial_stock_quantity'])
|
||||||
|
|
||||||
|
# Post with a valid quantity
|
||||||
|
data['initial_stock_quantity'] = 12345
|
||||||
|
|
||||||
|
response = self.post(url, data, expected_code=400)
|
||||||
|
self.assertIn('initial_stock_location', response.data)
|
||||||
|
|
||||||
|
# Check that the number of parts has not increased (due to form failures)
|
||||||
|
self.assertEqual(Part.objects.count(), n)
|
||||||
|
|
||||||
|
# Now, set a location
|
||||||
|
data['initial_stock_location'] = 1
|
||||||
|
|
||||||
|
response = self.post(url, data, expected_code=201)
|
||||||
|
|
||||||
|
# Check that the part has been created
|
||||||
|
self.assertEqual(Part.objects.count(), n + 1)
|
||||||
|
|
||||||
|
pk = response.data['pk']
|
||||||
|
|
||||||
|
new_part = Part.objects.get(pk=pk)
|
||||||
|
|
||||||
|
self.assertEqual(new_part.total_stock, 12345)
|
||||||
|
|
||||||
|
def test_initial_supplier_data(self):
|
||||||
|
"""
|
||||||
|
Tests for initial creation of supplier / manufacturer data
|
||||||
|
"""
|
||||||
|
|
||||||
|
url = reverse('api-part-list')
|
||||||
|
|
||||||
|
n = Part.objects.count()
|
||||||
|
|
||||||
|
# Set up initial part data
|
||||||
|
data = {
|
||||||
|
'category': 1,
|
||||||
|
'name': 'Buy Buy Buy',
|
||||||
|
'description': 'A purchaseable part',
|
||||||
|
'purchaseable': True,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Signal that we wish to create initial supplier data
|
||||||
|
data['add_supplier_info'] = True
|
||||||
|
|
||||||
|
# Specify MPN but not manufacturer
|
||||||
|
data['MPN'] = 'MPN-123'
|
||||||
|
|
||||||
|
response = self.post(url, data, expected_code=400)
|
||||||
|
self.assertIn('manufacturer', response.data)
|
||||||
|
|
||||||
|
# Specify manufacturer but not MPN
|
||||||
|
del data['MPN']
|
||||||
|
data['manufacturer'] = 1
|
||||||
|
response = self.post(url, data, expected_code=400)
|
||||||
|
self.assertIn('MPN', response.data)
|
||||||
|
|
||||||
|
# Specify SKU but not supplier
|
||||||
|
del data['manufacturer']
|
||||||
|
data['SKU'] = 'SKU-123'
|
||||||
|
response = self.post(url, data, expected_code=400)
|
||||||
|
self.assertIn('supplier', response.data)
|
||||||
|
|
||||||
|
# Specify supplier but not SKU
|
||||||
|
del data['SKU']
|
||||||
|
data['supplier'] = 1
|
||||||
|
response = self.post(url, data, expected_code=400)
|
||||||
|
self.assertIn('SKU', response.data)
|
||||||
|
|
||||||
|
# Check that no new parts have been created
|
||||||
|
self.assertEqual(Part.objects.count(), n)
|
||||||
|
|
||||||
|
# Now, fully specify the details
|
||||||
|
data['SKU'] = 'SKU-123'
|
||||||
|
data['supplier'] = 3
|
||||||
|
data['MPN'] = 'MPN-123'
|
||||||
|
data['manufacturer'] = 6
|
||||||
|
|
||||||
|
response = self.post(url, data, expected_code=201)
|
||||||
|
|
||||||
|
self.assertEqual(Part.objects.count(), n + 1)
|
||||||
|
|
||||||
|
pk = response.data['pk']
|
||||||
|
|
||||||
|
new_part = Part.objects.get(pk=pk)
|
||||||
|
|
||||||
|
# Check that there is a new manufacturer part *and* a new supplier part
|
||||||
|
self.assertEqual(new_part.supplier_parts.count(), 1)
|
||||||
|
self.assertEqual(new_part.manufacturer_parts.count(), 1)
|
||||||
|
|
||||||
|
|
||||||
class PartDetailTests(InvenTreeAPITestCase):
|
class PartDetailTests(InvenTreeAPITestCase):
|
||||||
"""
|
"""
|
||||||
|
@ -2,6 +2,8 @@
|
|||||||
Unit testing for BOM export functionality
|
Unit testing for BOM export functionality
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import csv
|
||||||
|
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
|
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
@ -47,13 +49,63 @@ class BomExportTest(TestCase):
|
|||||||
|
|
||||||
self.url = reverse('bom-download', kwargs={'pk': 100})
|
self.url = reverse('bom-download', kwargs={'pk': 100})
|
||||||
|
|
||||||
|
def test_bom_template(self):
|
||||||
|
"""
|
||||||
|
Test that the BOM template can be downloaded from the server
|
||||||
|
"""
|
||||||
|
|
||||||
|
url = reverse('bom-upload-template')
|
||||||
|
|
||||||
|
# Download an XLS template
|
||||||
|
response = self.client.get(url, data={'format': 'xls'})
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertEqual(
|
||||||
|
response.headers['Content-Disposition'],
|
||||||
|
'attachment; filename="InvenTree_BOM_Template.xls"'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Return a simple CSV template
|
||||||
|
response = self.client.get(url, data={'format': 'csv'})
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertEqual(
|
||||||
|
response.headers['Content-Disposition'],
|
||||||
|
'attachment; filename="InvenTree_BOM_Template.csv"'
|
||||||
|
)
|
||||||
|
|
||||||
|
filename = '_tmp.csv'
|
||||||
|
|
||||||
|
with open(filename, 'wb') as f:
|
||||||
|
f.write(response.getvalue())
|
||||||
|
|
||||||
|
with open(filename, 'r') as f:
|
||||||
|
reader = csv.reader(f, delimiter=',')
|
||||||
|
|
||||||
|
for line in reader:
|
||||||
|
headers = line
|
||||||
|
break
|
||||||
|
|
||||||
|
expected = [
|
||||||
|
'part_id',
|
||||||
|
'part_ipn',
|
||||||
|
'part_name',
|
||||||
|
'quantity',
|
||||||
|
'optional',
|
||||||
|
'overage',
|
||||||
|
'reference',
|
||||||
|
'note',
|
||||||
|
'inherited',
|
||||||
|
'allow_variants',
|
||||||
|
]
|
||||||
|
|
||||||
|
# Ensure all the expected headers are in the provided file
|
||||||
|
for header in expected:
|
||||||
|
self.assertTrue(header in headers)
|
||||||
|
|
||||||
def test_export_csv(self):
|
def test_export_csv(self):
|
||||||
"""
|
"""
|
||||||
Test BOM download in CSV format
|
Test BOM download in CSV format
|
||||||
"""
|
"""
|
||||||
|
|
||||||
print("URL", self.url)
|
|
||||||
|
|
||||||
params = {
|
params = {
|
||||||
'file_format': 'csv',
|
'file_format': 'csv',
|
||||||
'cascade': True,
|
'cascade': True,
|
||||||
@ -70,6 +122,47 @@ class BomExportTest(TestCase):
|
|||||||
content = response.headers['Content-Disposition']
|
content = response.headers['Content-Disposition']
|
||||||
self.assertEqual(content, 'attachment; filename="BOB | Bob | A2_BOM.csv"')
|
self.assertEqual(content, 'attachment; filename="BOB | Bob | A2_BOM.csv"')
|
||||||
|
|
||||||
|
filename = '_tmp.csv'
|
||||||
|
|
||||||
|
with open(filename, 'wb') as f:
|
||||||
|
f.write(response.getvalue())
|
||||||
|
|
||||||
|
# Read the file
|
||||||
|
with open(filename, 'r') as f:
|
||||||
|
reader = csv.reader(f, delimiter=',')
|
||||||
|
|
||||||
|
for line in reader:
|
||||||
|
headers = line
|
||||||
|
break
|
||||||
|
|
||||||
|
expected = [
|
||||||
|
'level',
|
||||||
|
'bom_id',
|
||||||
|
'parent_part_id',
|
||||||
|
'parent_part_ipn',
|
||||||
|
'parent_part_name',
|
||||||
|
'part_id',
|
||||||
|
'part_ipn',
|
||||||
|
'part_name',
|
||||||
|
'part_description',
|
||||||
|
'sub_assembly',
|
||||||
|
'quantity',
|
||||||
|
'optional',
|
||||||
|
'overage',
|
||||||
|
'reference',
|
||||||
|
'note',
|
||||||
|
'inherited',
|
||||||
|
'allow_variants',
|
||||||
|
'Default Location',
|
||||||
|
'Available Stock',
|
||||||
|
]
|
||||||
|
|
||||||
|
for header in expected:
|
||||||
|
self.assertTrue(header in headers)
|
||||||
|
|
||||||
|
for header in headers:
|
||||||
|
self.assertTrue(header in expected)
|
||||||
|
|
||||||
def test_export_xls(self):
|
def test_export_xls(self):
|
||||||
"""
|
"""
|
||||||
Test BOM download in XLS format
|
Test BOM download in XLS format
|
||||||
|
@ -25,7 +25,7 @@ import common.models
|
|||||||
from company.serializers import SupplierPartSerializer
|
from company.serializers import SupplierPartSerializer
|
||||||
from part.serializers import PartBriefSerializer
|
from part.serializers import PartBriefSerializer
|
||||||
from InvenTree.serializers import UserSerializerBrief, InvenTreeModelSerializer
|
from InvenTree.serializers import UserSerializerBrief, InvenTreeModelSerializer
|
||||||
from InvenTree.serializers import InvenTreeAttachmentSerializerField
|
from InvenTree.serializers import InvenTreeAttachmentSerializer, InvenTreeAttachmentSerializerField
|
||||||
|
|
||||||
|
|
||||||
class LocationBriefSerializer(InvenTreeModelSerializer):
|
class LocationBriefSerializer(InvenTreeModelSerializer):
|
||||||
@ -253,7 +253,7 @@ class LocationSerializer(InvenTreeModelSerializer):
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
class StockItemAttachmentSerializer(InvenTreeModelSerializer):
|
class StockItemAttachmentSerializer(InvenTreeAttachmentSerializer):
|
||||||
""" Serializer for StockItemAttachment model """
|
""" Serializer for StockItemAttachment model """
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
@ -277,6 +277,7 @@ class StockItemAttachmentSerializer(InvenTreeModelSerializer):
|
|||||||
'pk',
|
'pk',
|
||||||
'stock_item',
|
'stock_item',
|
||||||
'attachment',
|
'attachment',
|
||||||
|
'filename',
|
||||||
'comment',
|
'comment',
|
||||||
'upload_date',
|
'upload_date',
|
||||||
'user',
|
'user',
|
||||||
|
@ -215,6 +215,7 @@
|
|||||||
|
|
||||||
constructForm(url, {
|
constructForm(url, {
|
||||||
fields: {
|
fields: {
|
||||||
|
filename: {},
|
||||||
comment: {},
|
comment: {},
|
||||||
},
|
},
|
||||||
title: '{% trans "Edit Attachment" %}',
|
title: '{% trans "Edit Attachment" %}',
|
||||||
|
@ -2,16 +2,17 @@
|
|||||||
// InvenTree settings
|
// InvenTree settings
|
||||||
|
|
||||||
{% user_settings request.user as USER_SETTINGS %}
|
{% user_settings request.user as USER_SETTINGS %}
|
||||||
{% global_settings as GLOBAL_SETTINGS %}
|
|
||||||
|
|
||||||
var user_settings = {
|
var user_settings = {
|
||||||
{% for setting in USER_SETTINGS %}
|
{% for key, value in USER_SETTINGS.items %}
|
||||||
{{ setting.key }}: {{ setting.value }},
|
{{ key }}: {% primitive_to_javascript value %},
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
{% global_settings as GLOBAL_SETTINGS %}
|
||||||
|
|
||||||
var global_settings = {
|
var global_settings = {
|
||||||
{% for setting in GLOBAL_SETTINGS %}
|
{% for key, value in GLOBAL_SETTINGS.items %}
|
||||||
{{ setting.key }}: {{ setting.value }},
|
{{ key }}: {% primitive_to_javascript value %},
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
};
|
};
|
@ -42,9 +42,32 @@ function loadAttachmentTable(url, options) {
|
|||||||
title: '{% trans "File" %}',
|
title: '{% trans "File" %}',
|
||||||
formatter: function(value, row) {
|
formatter: function(value, row) {
|
||||||
|
|
||||||
var split = value.split('/');
|
var icon = 'fa-file-alt';
|
||||||
|
|
||||||
return renderLink(split[split.length - 1], value);
|
var fn = value.toLowerCase();
|
||||||
|
|
||||||
|
if (fn.endsWith('.pdf')) {
|
||||||
|
icon = 'fa-file-pdf';
|
||||||
|
} else if (fn.endsWith('.xls') || fn.endsWith('.xlsx')) {
|
||||||
|
icon = 'fa-file-excel';
|
||||||
|
} else if (fn.endsWith('.doc') || fn.endsWith('.docx')) {
|
||||||
|
icon = 'fa-file-word';
|
||||||
|
} else {
|
||||||
|
var images = ['.png', '.jpg', '.bmp', '.gif', '.svg', '.tif'];
|
||||||
|
|
||||||
|
images.forEach(function (suffix) {
|
||||||
|
if (fn.endsWith(suffix)) {
|
||||||
|
icon = 'fa-file-image';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
var split = value.split('/');
|
||||||
|
var filename = split[split.length - 1];
|
||||||
|
|
||||||
|
var html = `<span class='fas ${icon}'></span> ${filename}`;
|
||||||
|
|
||||||
|
return renderLink(html, value);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
@ -252,7 +252,7 @@ function loadBomTable(table, options) {
|
|||||||
sortable: true,
|
sortable: true,
|
||||||
formatter: function(value, row, index, field) {
|
formatter: function(value, row, index, field) {
|
||||||
|
|
||||||
var url = `/part/${row.sub_part_detail.pk}/stock/`;
|
var url = `/part/${row.sub_part_detail.pk}/?display=stock`;
|
||||||
var text = value;
|
var text = value;
|
||||||
|
|
||||||
if (value == null || value <= 0) {
|
if (value == null || value <= 0) {
|
||||||
|
@ -264,6 +264,10 @@ function constructForm(url, options) {
|
|||||||
// Default HTTP method
|
// Default HTTP method
|
||||||
options.method = options.method || 'PATCH';
|
options.method = options.method || 'PATCH';
|
||||||
|
|
||||||
|
// Default "groups" definition
|
||||||
|
options.groups = options.groups || {};
|
||||||
|
options.current_group = null;
|
||||||
|
|
||||||
// Construct an "empty" data object if not provided
|
// Construct an "empty" data object if not provided
|
||||||
if (!options.data) {
|
if (!options.data) {
|
||||||
options.data = {};
|
options.data = {};
|
||||||
@ -362,6 +366,13 @@ function constructFormBody(fields, options) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Initialize an "empty" field for each specified field
|
||||||
|
for (field in displayed_fields) {
|
||||||
|
if (!(field in fields)) {
|
||||||
|
fields[field] = {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Provide each field object with its own name
|
// Provide each field object with its own name
|
||||||
for(field in fields) {
|
for(field in fields) {
|
||||||
fields[field].name = field;
|
fields[field].name = field;
|
||||||
@ -379,52 +390,18 @@ function constructFormBody(fields, options) {
|
|||||||
// Override existing query filters (if provided!)
|
// Override existing query filters (if provided!)
|
||||||
fields[field].filters = Object.assign(fields[field].filters || {}, field_options.filters);
|
fields[field].filters = Object.assign(fields[field].filters || {}, field_options.filters);
|
||||||
|
|
||||||
// TODO: Refactor the following code with Object.assign (see above)
|
for (var opt in field_options) {
|
||||||
|
|
||||||
// "before" and "after" renders
|
var val = field_options[opt];
|
||||||
fields[field].before = field_options.before;
|
|
||||||
fields[field].after = field_options.after;
|
|
||||||
|
|
||||||
// Secondary modal options
|
if (opt == 'filters') {
|
||||||
fields[field].secondary = field_options.secondary;
|
// ignore filters (see above)
|
||||||
|
} else if (opt == 'icon') {
|
||||||
// Edit callback
|
// Specify custom icon
|
||||||
fields[field].onEdit = field_options.onEdit;
|
fields[field].prefix = `<span class='fas ${val}'></span>`;
|
||||||
|
} else {
|
||||||
fields[field].multiline = field_options.multiline;
|
fields[field][opt] = field_options[opt];
|
||||||
|
}
|
||||||
// Custom help_text
|
|
||||||
if (field_options.help_text) {
|
|
||||||
fields[field].help_text = field_options.help_text;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Custom label
|
|
||||||
if (field_options.label) {
|
|
||||||
fields[field].label = field_options.label;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Custom placeholder
|
|
||||||
if (field_options.placeholder) {
|
|
||||||
fields[field].placeholder = field_options.placeholder;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Choices
|
|
||||||
if (field_options.choices) {
|
|
||||||
fields[field].choices = field_options.choices;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Field prefix
|
|
||||||
if (field_options.prefix) {
|
|
||||||
fields[field].prefix = field_options.prefix;
|
|
||||||
} else if (field_options.icon) {
|
|
||||||
// Specify icon like 'fa-user'
|
|
||||||
fields[field].prefix = `<span class='fas ${field_options.icon}'></span>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
fields[field].hidden = field_options.hidden;
|
|
||||||
|
|
||||||
if (field_options.read_only != null) {
|
|
||||||
fields[field].read_only = field_options.read_only;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -465,8 +442,10 @@ function constructFormBody(fields, options) {
|
|||||||
html += constructField(name, field, options);
|
html += constructField(name, field, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Dynamically create the modals,
|
if (options.current_group) {
|
||||||
// so that we can have an infinite number of stacks!
|
// Close out the current group
|
||||||
|
html += `</div></div>`;
|
||||||
|
}
|
||||||
|
|
||||||
// Create a new modal if one does not exists
|
// Create a new modal if one does not exists
|
||||||
if (!options.modal) {
|
if (!options.modal) {
|
||||||
@ -535,6 +514,11 @@ function constructFormBody(fields, options) {
|
|||||||
submitFormData(fields, options);
|
submitFormData(fields, options);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
initializeGroups(fields, options);
|
||||||
|
|
||||||
|
// Scroll to the top
|
||||||
|
$(options.modal).find('.modal-form-content-wrapper').scrollTop(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -860,9 +844,12 @@ function handleFormErrors(errors, fields, options) {
|
|||||||
|
|
||||||
var non_field_errors = $(options.modal).find('#non-field-errors');
|
var non_field_errors = $(options.modal).find('#non-field-errors');
|
||||||
|
|
||||||
|
// TODO: Display the JSON error text when hovering over the "info" icon
|
||||||
non_field_errors.append(
|
non_field_errors.append(
|
||||||
`<div class='alert alert-block alert-danger'>
|
`<div class='alert alert-block alert-danger'>
|
||||||
<b>{% trans "Form errors exist" %}</b>
|
<b>{% trans "Form errors exist" %}</b>
|
||||||
|
<span id='form-errors-info' class='float-right fas fa-info-circle icon-red'>
|
||||||
|
</span>
|
||||||
</div>`
|
</div>`
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -883,6 +870,8 @@ function handleFormErrors(errors, fields, options) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var first_error_field = null;
|
||||||
|
|
||||||
for (field_name in errors) {
|
for (field_name in errors) {
|
||||||
|
|
||||||
// Add the 'has-error' class
|
// Add the 'has-error' class
|
||||||
@ -892,6 +881,10 @@ function handleFormErrors(errors, fields, options) {
|
|||||||
|
|
||||||
var field_errors = errors[field_name];
|
var field_errors = errors[field_name];
|
||||||
|
|
||||||
|
if (field_errors && !first_error_field && isFieldVisible(field_name, options)) {
|
||||||
|
first_error_field = field_name;
|
||||||
|
}
|
||||||
|
|
||||||
// Add an entry for each returned error message
|
// Add an entry for each returned error message
|
||||||
for (var idx = field_errors.length-1; idx >= 0; idx--) {
|
for (var idx = field_errors.length-1; idx >= 0; idx--) {
|
||||||
|
|
||||||
@ -905,6 +898,24 @@ function handleFormErrors(errors, fields, options) {
|
|||||||
field_dom.append(html);
|
field_dom.append(html);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (first_error_field) {
|
||||||
|
// Ensure that the field in question is visible
|
||||||
|
document.querySelector(`#div_id_${field_name}`).scrollIntoView({
|
||||||
|
behavior: 'smooth',
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Scroll to the top of the form
|
||||||
|
$(options.modal).find('.modal-form-content-wrapper').scrollTop(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
$(options.modal).find('.modal-content').addClass('modal-error');
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function isFieldVisible(field, options) {
|
||||||
|
|
||||||
|
return $(options.modal).find(`#div_id_${field}`).is(':visible');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -932,7 +943,10 @@ function addFieldCallbacks(fields, options) {
|
|||||||
function addFieldCallback(name, field, options) {
|
function addFieldCallback(name, field, options) {
|
||||||
|
|
||||||
$(options.modal).find(`#id_${name}`).change(function() {
|
$(options.modal).find(`#id_${name}`).change(function() {
|
||||||
field.onEdit(name, field, options);
|
|
||||||
|
var value = getFormFieldValue(name, field, options);
|
||||||
|
|
||||||
|
field.onEdit(value, name, field, options);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -960,6 +974,71 @@ function addClearCallback(name, field, options) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Initialize callbacks and initial states for groups
|
||||||
|
function initializeGroups(fields, options) {
|
||||||
|
|
||||||
|
var modal = options.modal;
|
||||||
|
|
||||||
|
// Callback for when the group is expanded
|
||||||
|
$(modal).find('.form-panel-content').on('show.bs.collapse', function() {
|
||||||
|
|
||||||
|
var panel = $(this).closest('.form-panel');
|
||||||
|
var group = panel.attr('group');
|
||||||
|
|
||||||
|
var icon = $(modal).find(`#group-icon-${group}`);
|
||||||
|
|
||||||
|
icon.removeClass('fa-angle-right');
|
||||||
|
icon.addClass('fa-angle-up');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Callback for when the group is collapsed
|
||||||
|
$(modal).find('.form-panel-content').on('hide.bs.collapse', function() {
|
||||||
|
|
||||||
|
var panel = $(this).closest('.form-panel');
|
||||||
|
var group = panel.attr('group');
|
||||||
|
|
||||||
|
var icon = $(modal).find(`#group-icon-${group}`);
|
||||||
|
|
||||||
|
icon.removeClass('fa-angle-up');
|
||||||
|
icon.addClass('fa-angle-right');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set initial state of each specified group
|
||||||
|
for (var group in options.groups) {
|
||||||
|
|
||||||
|
var group_options = options.groups[group];
|
||||||
|
|
||||||
|
if (group_options.collapsed) {
|
||||||
|
$(modal).find(`#form-panel-content-${group}`).collapse("hide");
|
||||||
|
} else {
|
||||||
|
$(modal).find(`#form-panel-content-${group}`).collapse("show");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (group_options.hidden) {
|
||||||
|
hideFormGroup(group, options);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hide a form group
|
||||||
|
function hideFormGroup(group, options) {
|
||||||
|
$(options.modal).find(`#form-panel-${group}`).hide();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show a form group
|
||||||
|
function showFormGroup(group, options) {
|
||||||
|
$(options.modal).find(`#form-panel-${group}`).show();
|
||||||
|
}
|
||||||
|
|
||||||
|
function setFormGroupVisibility(group, vis, options) {
|
||||||
|
if (vis) {
|
||||||
|
showFormGroup(group, options);
|
||||||
|
} else {
|
||||||
|
hideFormGroup(group, options);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
function initializeRelatedFields(fields, options) {
|
function initializeRelatedFields(fields, options) {
|
||||||
|
|
||||||
var field_names = options.field_names;
|
var field_names = options.field_names;
|
||||||
@ -1353,6 +1432,8 @@ function renderModelData(name, model, data, parameters, options) {
|
|||||||
*/
|
*/
|
||||||
function constructField(name, parameters, options) {
|
function constructField(name, parameters, options) {
|
||||||
|
|
||||||
|
var html = '';
|
||||||
|
|
||||||
// Shortcut for simple visual fields
|
// Shortcut for simple visual fields
|
||||||
if (parameters.type == 'candy') {
|
if (parameters.type == 'candy') {
|
||||||
return constructCandyInput(name, parameters, options);
|
return constructCandyInput(name, parameters, options);
|
||||||
@ -1365,13 +1446,58 @@ function constructField(name, parameters, options) {
|
|||||||
return constructHiddenInput(name, parameters, options);
|
return constructHiddenInput(name, parameters, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Are we ending a group?
|
||||||
|
if (options.current_group && parameters.group != options.current_group) {
|
||||||
|
html += `</div></div>`;
|
||||||
|
|
||||||
|
// Null out the current "group" so we can start a new one
|
||||||
|
options.current_group = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Are we starting a new group?
|
||||||
|
if (parameters.group) {
|
||||||
|
|
||||||
|
var group = parameters.group;
|
||||||
|
|
||||||
|
var group_options = options.groups[group] || {};
|
||||||
|
|
||||||
|
// Are we starting a new group?
|
||||||
|
// Add HTML for the start of a separate panel
|
||||||
|
if (parameters.group != options.current_group) {
|
||||||
|
|
||||||
|
html += `
|
||||||
|
<div class='panel form-panel' id='form-panel-${group}' group='${group}'>
|
||||||
|
<div class='panel-heading form-panel-heading' id='form-panel-heading-${group}'>`;
|
||||||
|
if (group_options.collapsible) {
|
||||||
|
html += `
|
||||||
|
<div data-toggle='collapse' data-target='#form-panel-content-${group}'>
|
||||||
|
<a href='#'><span id='group-icon-${group}' class='fas fa-angle-up'></span>
|
||||||
|
`;
|
||||||
|
} else {
|
||||||
|
html += `<div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
html += `<h4 style='display: inline;'>${group_options.title || group}</h4>`;
|
||||||
|
|
||||||
|
if (group_options.collapsible) {
|
||||||
|
html += `</a>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
html += `
|
||||||
|
</div></div>
|
||||||
|
<div class='panel-content form-panel-content' id='form-panel-content-${group}'>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep track of the group we are in
|
||||||
|
options.current_group = group;
|
||||||
|
}
|
||||||
|
|
||||||
var form_classes = 'form-group';
|
var form_classes = 'form-group';
|
||||||
|
|
||||||
if (parameters.errors) {
|
if (parameters.errors) {
|
||||||
form_classes += ' has-error';
|
form_classes += ' has-error';
|
||||||
}
|
}
|
||||||
|
|
||||||
var html = '';
|
|
||||||
|
|
||||||
// Optional content to render before the field
|
// Optional content to render before the field
|
||||||
if (parameters.before) {
|
if (parameters.before) {
|
||||||
@ -1428,13 +1554,14 @@ function constructField(name, parameters, options) {
|
|||||||
html += `</div>`; // input-group
|
html += `</div>`; // input-group
|
||||||
}
|
}
|
||||||
|
|
||||||
// Div for error messages
|
|
||||||
html += `<div id='errors-${name}'></div>`;
|
|
||||||
|
|
||||||
if (parameters.help_text) {
|
if (parameters.help_text) {
|
||||||
html += constructHelpText(name, parameters, options);
|
html += constructHelpText(name, parameters, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Div for error messages
|
||||||
|
html += `<div id='errors-${name}'></div>`;
|
||||||
|
|
||||||
|
|
||||||
html += `</div>`; // controls
|
html += `</div>`; // controls
|
||||||
html += `</div>`; // form-group
|
html += `</div>`; // form-group
|
||||||
|
|
||||||
@ -1599,6 +1726,10 @@ function constructInputOptions(name, classes, type, parameters) {
|
|||||||
opts.push(`placeholder='${parameters.placeholder}'`);
|
opts.push(`placeholder='${parameters.placeholder}'`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (parameters.type == 'boolean') {
|
||||||
|
opts.push(`style='display: inline-block; width: 20px; margin-right: 20px;'`);
|
||||||
|
}
|
||||||
|
|
||||||
if (parameters.multiline) {
|
if (parameters.multiline) {
|
||||||
return `<textarea ${opts.join(' ')}></textarea>`;
|
return `<textarea ${opts.join(' ')}></textarea>`;
|
||||||
} else {
|
} else {
|
||||||
@ -1772,7 +1903,13 @@ function constructCandyInput(name, parameters, options) {
|
|||||||
*/
|
*/
|
||||||
function constructHelpText(name, parameters, options) {
|
function constructHelpText(name, parameters, options) {
|
||||||
|
|
||||||
var html = `<div id='hint_id_${name}' class='help-block'><i>${parameters.help_text}</i></div>`;
|
var style = '';
|
||||||
|
|
||||||
|
if (parameters.type == 'boolean') {
|
||||||
|
style = `style='display: inline-block; margin-left: 25px' `;
|
||||||
|
}
|
||||||
|
|
||||||
|
var html = `<div id='hint_id_${name}' ${style}class='help-block'><i>${parameters.help_text}</i></div>`;
|
||||||
|
|
||||||
return html;
|
return html;
|
||||||
}
|
}
|
@ -13,6 +13,31 @@ function yesNoLabel(value) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function partGroups(options={}) {
|
||||||
|
|
||||||
|
return {
|
||||||
|
attributes: {
|
||||||
|
title: '{% trans "Part Attributes" %}',
|
||||||
|
collapsible: true,
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
title: '{% trans "Part Creation Options" %}',
|
||||||
|
collapsible: true,
|
||||||
|
},
|
||||||
|
duplicate: {
|
||||||
|
title: '{% trans "Part Duplication Options" %}',
|
||||||
|
collapsible: true,
|
||||||
|
},
|
||||||
|
supplier: {
|
||||||
|
title: '{% trans "Supplier Options" %}',
|
||||||
|
collapsible: true,
|
||||||
|
hidden: !global_settings.PART_PURCHASEABLE,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
// Construct fieldset for part forms
|
// Construct fieldset for part forms
|
||||||
function partFields(options={}) {
|
function partFields(options={}) {
|
||||||
|
|
||||||
@ -48,36 +73,44 @@ function partFields(options={}) {
|
|||||||
minimum_stock: {
|
minimum_stock: {
|
||||||
icon: 'fa-boxes',
|
icon: 'fa-boxes',
|
||||||
},
|
},
|
||||||
attributes: {
|
|
||||||
type: 'candy',
|
|
||||||
html: `<hr><h4><i>{% trans "Part Attributes" %}</i></h4><hr>`
|
|
||||||
},
|
|
||||||
component: {
|
component: {
|
||||||
value: global_settings.PART_COMPONENT,
|
value: global_settings.PART_COMPONENT,
|
||||||
|
group: 'attributes',
|
||||||
},
|
},
|
||||||
assembly: {
|
assembly: {
|
||||||
value: global_settings.PART_ASSEMBLY,
|
value: global_settings.PART_ASSEMBLY,
|
||||||
|
group: 'attributes',
|
||||||
},
|
},
|
||||||
is_template: {
|
is_template: {
|
||||||
value: global_settings.PART_TEMPLATE,
|
value: global_settings.PART_TEMPLATE,
|
||||||
|
group: 'attributes',
|
||||||
},
|
},
|
||||||
trackable: {
|
trackable: {
|
||||||
value: global_settings.PART_TRACKABLE,
|
value: global_settings.PART_TRACKABLE,
|
||||||
|
group: 'attributes',
|
||||||
},
|
},
|
||||||
purchaseable: {
|
purchaseable: {
|
||||||
value: global_settings.PART_PURCHASEABLE,
|
value: global_settings.PART_PURCHASEABLE,
|
||||||
|
group: 'attributes',
|
||||||
|
onEdit: function(value, name, field, options) {
|
||||||
|
setFormGroupVisibility('supplier', value, options);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
salable: {
|
salable: {
|
||||||
value: global_settings.PART_SALABLE,
|
value: global_settings.PART_SALABLE,
|
||||||
|
group: 'attributes',
|
||||||
},
|
},
|
||||||
virtual: {
|
virtual: {
|
||||||
value: global_settings.PART_VIRTUAL,
|
value: global_settings.PART_VIRTUAL,
|
||||||
|
group: 'attributes',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// If editing a part, we can set the "active" status
|
// If editing a part, we can set the "active" status
|
||||||
if (options.edit) {
|
if (options.edit) {
|
||||||
fields.active = {};
|
fields.active = {
|
||||||
|
group: 'attributes'
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pop expiry field
|
// Pop expiry field
|
||||||
@ -91,16 +124,32 @@ function partFields(options={}) {
|
|||||||
// No supplier parts available yet
|
// No supplier parts available yet
|
||||||
delete fields["default_supplier"];
|
delete fields["default_supplier"];
|
||||||
|
|
||||||
fields.create = {
|
|
||||||
type: 'candy',
|
|
||||||
html: `<hr><h4><i>{% trans "Part Creation Options" %}</i></h4><hr>`,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (global_settings.PART_CREATE_INITIAL) {
|
if (global_settings.PART_CREATE_INITIAL) {
|
||||||
|
|
||||||
fields.initial_stock = {
|
fields.initial_stock = {
|
||||||
|
type: 'boolean',
|
||||||
|
label: '{% trans "Create Initial Stock" %}',
|
||||||
|
help_text: '{% trans "Create an initial stock item for this part" %}',
|
||||||
|
group: 'create',
|
||||||
|
};
|
||||||
|
|
||||||
|
fields.initial_stock_quantity = {
|
||||||
type: 'decimal',
|
type: 'decimal',
|
||||||
|
value: 1,
|
||||||
label: '{% trans "Initial Stock Quantity" %}',
|
label: '{% trans "Initial Stock Quantity" %}',
|
||||||
help_text: '{% trans "Initialize part stock with specified quantity" %}',
|
help_text: '{% trans "Specify initial stock quantity for this part" %}',
|
||||||
|
group: 'create',
|
||||||
|
};
|
||||||
|
|
||||||
|
// TODO - Allow initial location of stock to be specified
|
||||||
|
fields.initial_stock_location = {
|
||||||
|
label: '{% trans "Location" %}',
|
||||||
|
help_text: '{% trans "Select destination stock location" %}',
|
||||||
|
type: 'related field',
|
||||||
|
required: true,
|
||||||
|
api_url: `/api/stock/location/`,
|
||||||
|
model: 'stocklocation',
|
||||||
|
group: 'create',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -109,21 +158,65 @@ function partFields(options={}) {
|
|||||||
label: '{% trans "Copy Category Parameters" %}',
|
label: '{% trans "Copy Category Parameters" %}',
|
||||||
help_text: '{% trans "Copy parameter templates from selected part category" %}',
|
help_text: '{% trans "Copy parameter templates from selected part category" %}',
|
||||||
value: global_settings.PART_CATEGORY_PARAMETERS,
|
value: global_settings.PART_CATEGORY_PARAMETERS,
|
||||||
|
group: 'create',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Supplier options
|
||||||
|
fields.add_supplier_info = {
|
||||||
|
type: 'boolean',
|
||||||
|
label: '{% trans "Add Supplier Data" %}',
|
||||||
|
help_text: '{% trans "Create initial supplier data for this part" %}',
|
||||||
|
group: 'supplier',
|
||||||
|
};
|
||||||
|
|
||||||
|
fields.supplier = {
|
||||||
|
type: 'related field',
|
||||||
|
model: 'company',
|
||||||
|
label: '{% trans "Supplier" %}',
|
||||||
|
help_text: '{% trans "Select supplier" %}',
|
||||||
|
filters: {
|
||||||
|
'is_supplier': true,
|
||||||
|
},
|
||||||
|
api_url: '{% url "api-company-list" %}',
|
||||||
|
group: 'supplier',
|
||||||
|
};
|
||||||
|
|
||||||
|
fields.SKU = {
|
||||||
|
type: 'string',
|
||||||
|
label: '{% trans "SKU" %}',
|
||||||
|
help_text: '{% trans "Supplier stock keeping unit" %}',
|
||||||
|
group: 'supplier',
|
||||||
|
};
|
||||||
|
|
||||||
|
fields.manufacturer = {
|
||||||
|
type: 'related field',
|
||||||
|
model: 'company',
|
||||||
|
label: '{% trans "Manufacturer" %}',
|
||||||
|
help_text: '{% trans "Select manufacturer" %}',
|
||||||
|
filters: {
|
||||||
|
'is_manufacturer': true,
|
||||||
|
},
|
||||||
|
api_url: '{% url "api-company-list" %}',
|
||||||
|
group: 'supplier',
|
||||||
|
};
|
||||||
|
|
||||||
|
fields.MPN = {
|
||||||
|
type: 'string',
|
||||||
|
label: '{% trans "MPN" %}',
|
||||||
|
help_text: '{% trans "Manufacturer Part Number" %}',
|
||||||
|
group: 'supplier',
|
||||||
|
};
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Additional fields when "duplicating" a part
|
// Additional fields when "duplicating" a part
|
||||||
if (options.duplicate) {
|
if (options.duplicate) {
|
||||||
|
|
||||||
fields.duplicate = {
|
|
||||||
type: 'candy',
|
|
||||||
html: `<hr><h4><i>{% trans "Part Duplication Options" %}</i></h4><hr>`,
|
|
||||||
};
|
|
||||||
|
|
||||||
fields.copy_from = {
|
fields.copy_from = {
|
||||||
type: 'integer',
|
type: 'integer',
|
||||||
hidden: true,
|
hidden: true,
|
||||||
value: options.duplicate,
|
value: options.duplicate,
|
||||||
|
group: 'duplicate',
|
||||||
},
|
},
|
||||||
|
|
||||||
fields.copy_image = {
|
fields.copy_image = {
|
||||||
@ -131,6 +224,7 @@ function partFields(options={}) {
|
|||||||
label: '{% trans "Copy Image" %}',
|
label: '{% trans "Copy Image" %}',
|
||||||
help_text: '{% trans "Copy image from original part" %}',
|
help_text: '{% trans "Copy image from original part" %}',
|
||||||
value: true,
|
value: true,
|
||||||
|
group: 'duplicate',
|
||||||
},
|
},
|
||||||
|
|
||||||
fields.copy_bom = {
|
fields.copy_bom = {
|
||||||
@ -138,6 +232,7 @@ function partFields(options={}) {
|
|||||||
label: '{% trans "Copy BOM" %}',
|
label: '{% trans "Copy BOM" %}',
|
||||||
help_text: '{% trans "Copy bill of materials from original part" %}',
|
help_text: '{% trans "Copy bill of materials from original part" %}',
|
||||||
value: global_settings.PART_COPY_BOM,
|
value: global_settings.PART_COPY_BOM,
|
||||||
|
group: 'duplicate',
|
||||||
};
|
};
|
||||||
|
|
||||||
fields.copy_parameters = {
|
fields.copy_parameters = {
|
||||||
@ -145,6 +240,7 @@ function partFields(options={}) {
|
|||||||
label: '{% trans "Copy Parameters" %}',
|
label: '{% trans "Copy Parameters" %}',
|
||||||
help_text: '{% trans "Copy parameter data from original part" %}',
|
help_text: '{% trans "Copy parameter data from original part" %}',
|
||||||
value: global_settings.PART_COPY_PARAMETERS,
|
value: global_settings.PART_COPY_PARAMETERS,
|
||||||
|
group: 'duplicate',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -191,8 +287,11 @@ function editPart(pk, options={}) {
|
|||||||
edit: true
|
edit: true
|
||||||
});
|
});
|
||||||
|
|
||||||
|
var groups = partGroups({});
|
||||||
|
|
||||||
constructForm(url, {
|
constructForm(url, {
|
||||||
fields: fields,
|
fields: fields,
|
||||||
|
groups: partGroups(),
|
||||||
title: '{% trans "Edit Part" %}',
|
title: '{% trans "Edit Part" %}',
|
||||||
reload: true,
|
reload: true,
|
||||||
});
|
});
|
||||||
@ -221,6 +320,7 @@ function duplicatePart(pk, options={}) {
|
|||||||
constructForm('{% url "api-part-list" %}', {
|
constructForm('{% url "api-part-list" %}', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
fields: fields,
|
fields: fields,
|
||||||
|
groups: partGroups(),
|
||||||
title: '{% trans "Duplicate Part" %}',
|
title: '{% trans "Duplicate Part" %}',
|
||||||
data: data,
|
data: data,
|
||||||
onSuccess: function(data) {
|
onSuccess: function(data) {
|
||||||
@ -400,7 +500,7 @@ function loadPartVariantTable(table, partId, options={}) {
|
|||||||
field: 'in_stock',
|
field: 'in_stock',
|
||||||
title: '{% trans "Stock" %}',
|
title: '{% trans "Stock" %}',
|
||||||
formatter: function(value, row) {
|
formatter: function(value, row) {
|
||||||
return renderLink(value, `/part/${row.pk}/stock/`);
|
return renderLink(value, `/part/${row.pk}/?display=stock`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
@ -903,6 +1003,18 @@ function loadPartTable(table, url, options={}) {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
$('#multi-part-print-label').click(function() {
|
||||||
|
var selections = $(table).bootstrapTable('getSelections');
|
||||||
|
|
||||||
|
var items = [];
|
||||||
|
|
||||||
|
selections.forEach(function(item) {
|
||||||
|
items.push(item.pk);
|
||||||
|
});
|
||||||
|
|
||||||
|
printPartLabels(items);
|
||||||
|
});
|
||||||
|
|
||||||
$('#multi-part-export').click(function() {
|
$('#multi-part-export').click(function() {
|
||||||
var selections = $(table).bootstrapTable("getSelections");
|
var selections = $(table).bootstrapTable("getSelections");
|
||||||
|
|
||||||
|
@ -1066,7 +1066,7 @@ function loadStockTable(table, options) {
|
|||||||
return '-';
|
return '-';
|
||||||
}
|
}
|
||||||
|
|
||||||
var link = `/supplier-part/${row.supplier_part}/stock/`;
|
var link = `/supplier-part/${row.supplier_part}/?display=stock`;
|
||||||
|
|
||||||
var text = '';
|
var text = '';
|
||||||
|
|
||||||
|
@ -35,51 +35,48 @@ ENV INVENTREE_SECRET_KEY_FILE="${INVENTREE_DATA_DIR}/secret_key.txt"
|
|||||||
ENV INVENTREE_GUNICORN_WORKERS="4"
|
ENV INVENTREE_GUNICORN_WORKERS="4"
|
||||||
ENV INVENTREE_BACKGROUND_WORKERS="4"
|
ENV INVENTREE_BACKGROUND_WORKERS="4"
|
||||||
|
|
||||||
# Default web server port is 8000
|
# Default web server address:port
|
||||||
ENV INVENTREE_WEB_PORT="8000"
|
ENV INVENTREE_WEB_ADDR=0.0.0.0
|
||||||
|
ENV INVENTREE_WEB_PORT=8000
|
||||||
|
|
||||||
LABEL org.label-schema.schema-version="1.0" \
|
LABEL org.label-schema.schema-version="1.0" \
|
||||||
org.label-schema.build-date=${DATE} \
|
org.label-schema.build-date=${DATE} \
|
||||||
org.label-schema.vendor="inventree" \
|
org.label-schema.vendor="inventree" \
|
||||||
org.label-schema.name="inventree/inventree" \
|
org.label-schema.name="inventree/inventree" \
|
||||||
org.label-schema.url="https://hub.docker.com/r/inventree/inventree" \
|
org.label-schema.url="https://hub.docker.com/r/inventree/inventree" \
|
||||||
org.label-schema.version=${INVENTREE_VERSION} \
|
org.label-schema.vcs-url=${INVENTREE_GIT_REPO} \
|
||||||
org.label-schema.vcs-url=${INVENTREE_REPO} \
|
org.label-schema.vcs-branch=${INVENTREE_GIT_BRANCH} \
|
||||||
org.label-schema.vcs-branch=${BRANCH} \
|
org.label-schema.vcs-ref=${INVENTREE_GIT_TAG}
|
||||||
org.label-schema.vcs-ref=${COMMIT}
|
|
||||||
|
|
||||||
# Create user account
|
# Create user account
|
||||||
RUN addgroup -S inventreegroup && adduser -S inventree -G inventreegroup
|
RUN addgroup -S inventreegroup && adduser -S inventree -G inventreegroup
|
||||||
|
|
||||||
WORKDIR ${INVENTREE_HOME}
|
|
||||||
|
|
||||||
# Install required system packages
|
# Install required system packages
|
||||||
RUN apk add --no-cache git make bash \
|
RUN apk add --no-cache git make bash \
|
||||||
gcc libgcc g++ libstdc++ \
|
gcc libgcc g++ libstdc++ \
|
||||||
libjpeg-turbo libjpeg-turbo-dev jpeg jpeg-dev \
|
libjpeg-turbo libjpeg-turbo-dev jpeg jpeg-dev \
|
||||||
libffi libffi-dev \
|
libffi libffi-dev \
|
||||||
zlib zlib-dev
|
zlib zlib-dev \
|
||||||
|
# Cairo deps for WeasyPrint (these will be deprecated once WeasyPrint drops cairo requirement)
|
||||||
|
cairo cairo-dev pango pango-dev \
|
||||||
|
# Fonts
|
||||||
|
fontconfig ttf-droid ttf-liberation ttf-dejavu ttf-opensans ttf-ubuntu-font-family font-croscore font-noto \
|
||||||
|
# Core python
|
||||||
|
python3 python3-dev py3-pip \
|
||||||
|
# SQLite support
|
||||||
|
sqlite \
|
||||||
|
# PostgreSQL support
|
||||||
|
postgresql postgresql-contrib postgresql-dev libpq \
|
||||||
|
# MySQL/MariaDB support
|
||||||
|
mariadb-connector-c mariadb-dev mariadb-client
|
||||||
|
|
||||||
# Cairo deps for WeasyPrint (these will be deprecated once WeasyPrint drops cairo requirement)
|
# Install required base-level python packages
|
||||||
RUN apk add --no-cache cairo cairo-dev pango pango-dev
|
COPY requirements.txt requirements.txt
|
||||||
RUN apk add --no-cache fontconfig ttf-droid ttf-liberation ttf-dejavu ttf-opensans ttf-ubuntu-font-family font-croscore font-noto
|
RUN pip install --no-cache-dir -U -r requirements.txt
|
||||||
|
|
||||||
# Python
|
|
||||||
RUN apk add --no-cache python3 python3-dev py3-pip
|
|
||||||
|
|
||||||
# SQLite support
|
|
||||||
RUN apk add --no-cache sqlite
|
|
||||||
|
|
||||||
# PostgreSQL support
|
|
||||||
RUN apk add --no-cache postgresql postgresql-contrib postgresql-dev libpq
|
|
||||||
|
|
||||||
# MySQL support
|
|
||||||
RUN apk add --no-cache mariadb-connector-c mariadb-dev mariadb-client
|
|
||||||
|
|
||||||
# Install required python packages
|
|
||||||
RUN pip install --no-cache-dir -U psycopg2 mysqlclient pgcli mariadb
|
|
||||||
|
|
||||||
|
# Production code (pulled from tagged github release)
|
||||||
FROM base as production
|
FROM base as production
|
||||||
|
|
||||||
# Clone source code
|
# Clone source code
|
||||||
RUN echo "Downloading InvenTree from ${INVENTREE_GIT_REPO}"
|
RUN echo "Downloading InvenTree from ${INVENTREE_GIT_REPO}"
|
||||||
|
|
||||||
@ -88,30 +85,35 @@ RUN git clone --branch ${INVENTREE_GIT_BRANCH} --depth 1 ${INVENTREE_GIT_REPO} $
|
|||||||
# Checkout against a particular git tag
|
# Checkout against a particular git tag
|
||||||
RUN if [ -n "${INVENTREE_GIT_TAG}" ] ; then cd ${INVENTREE_HOME} && git fetch --all --tags && git checkout tags/${INVENTREE_GIT_TAG} -b v${INVENTREE_GIT_TAG}-branch ; fi
|
RUN if [ -n "${INVENTREE_GIT_TAG}" ] ; then cd ${INVENTREE_HOME} && git fetch --all --tags && git checkout tags/${INVENTREE_GIT_TAG} -b v${INVENTREE_GIT_TAG}-branch ; fi
|
||||||
|
|
||||||
|
RUN chown -R inventree:inventreegroup ${INVENTREE_HOME}/*
|
||||||
|
|
||||||
|
# Drop to the inventree user
|
||||||
|
USER inventree
|
||||||
|
|
||||||
# Install InvenTree packages
|
# Install InvenTree packages
|
||||||
RUN pip install --no-cache-dir -U -r ${INVENTREE_HOME}/requirements.txt
|
RUN pip3 install --no-cache-dir --disable-pip-version-check -r ${INVENTREE_HOME}/requirements.txt
|
||||||
|
|
||||||
# Copy gunicorn config file
|
# Need to be running from within this directory
|
||||||
COPY gunicorn.conf.py ${INVENTREE_HOME}/gunicorn.conf.py
|
WORKDIR ${INVENTREE_MNG_DIR}
|
||||||
|
|
||||||
# Copy startup scripts
|
# Server init entrypoint
|
||||||
COPY start_prod_server.sh ${INVENTREE_HOME}/start_prod_server.sh
|
ENTRYPOINT ["/bin/bash", "../docker/init.sh"]
|
||||||
COPY start_prod_worker.sh ${INVENTREE_HOME}/start_prod_worker.sh
|
|
||||||
|
|
||||||
RUN chmod 755 ${INVENTREE_HOME}/start_prod_server.sh
|
# Launch the production server
|
||||||
RUN chmod 755 ${INVENTREE_HOME}/start_prod_worker.sh
|
# TODO: Work out why environment variables cannot be interpolated in this command
|
||||||
|
# TODO: e.g. -b ${INVENTREE_WEB_ADDR}:${INVENTREE_WEB_PORT} fails here
|
||||||
WORKDIR ${INVENTREE_HOME}
|
CMD gunicorn -c ./docker/gunicorn.conf.py InvenTree.wsgi -b 0.0.0.0:8000 --chdir ./InvenTree
|
||||||
|
|
||||||
# Let us begin
|
|
||||||
CMD ["bash", "./start_prod_server.sh"]
|
|
||||||
|
|
||||||
FROM base as dev
|
FROM base as dev
|
||||||
|
|
||||||
# The development image requires the source code to be mounted to /home/inventree/
|
# The development image requires the source code to be mounted to /home/inventree/
|
||||||
# So from here, we don't actually "do" anything, apart from some file management
|
# So from here, we don't actually "do" anything, apart from some file management
|
||||||
|
|
||||||
ENV INVENTREE_DEV_DIR = "${INVENTREE_HOME}/dev"
|
ENV INVENTREE_DEV_DIR="${INVENTREE_HOME}/dev"
|
||||||
|
|
||||||
|
# Location for python virtual environment
|
||||||
|
# If the INVENTREE_PY_ENV variable is set, the entrypoint script will use it!
|
||||||
|
ENV INVENTREE_PY_ENV="${INVENTREE_DEV_DIR}/env"
|
||||||
|
|
||||||
# Override default path settings
|
# Override default path settings
|
||||||
ENV INVENTREE_STATIC_ROOT="${INVENTREE_DEV_DIR}/static"
|
ENV INVENTREE_STATIC_ROOT="${INVENTREE_DEV_DIR}/static"
|
||||||
@ -121,5 +123,9 @@ ENV INVENTREE_SECRET_KEY_FILE="${INVENTREE_DEV_DIR}/secret_key.txt"
|
|||||||
|
|
||||||
WORKDIR ${INVENTREE_HOME}
|
WORKDIR ${INVENTREE_HOME}
|
||||||
|
|
||||||
|
# Entrypoint ensures that we are running in the python virtual environment
|
||||||
|
ENTRYPOINT ["/bin/bash", "./docker/init.sh"]
|
||||||
|
|
||||||
# Launch the development server
|
# Launch the development server
|
||||||
CMD ["bash", "/home/inventree/docker/start_dev_server.sh"]
|
CMD ["invoke", "server", "-a", "${INVENTREE_WEB_ADDR}:${INVENTREE_WEB_PORT}"]
|
||||||
|
|
||||||
|
@ -1,9 +1,15 @@
|
|||||||
|
# InvenTree environment variables for a development setup
|
||||||
|
|
||||||
|
# Set DEBUG to False for a production environment!
|
||||||
|
INVENTREE_DEBUG=True
|
||||||
|
|
||||||
|
# Change verbosity level for debug output
|
||||||
|
INVENTREE_DEBUG_LEVEL="INFO"
|
||||||
|
|
||||||
|
# Database linking options
|
||||||
INVENTREE_DB_ENGINE=sqlite3
|
INVENTREE_DB_ENGINE=sqlite3
|
||||||
INVENTREE_DB_NAME=/home/inventree/dev/inventree_db.sqlite3
|
INVENTREE_DB_NAME=/home/inventree/dev/inventree_db.sqlite3
|
||||||
INVENTREE_MEDIA_ROOT=/home/inventree/dev/media
|
# INVENTREE_DB_HOST=hostaddress
|
||||||
INVENTREE_STATIC_ROOT=/home/inventree/dev/static
|
# INVENTREE_DB_PORT=5432
|
||||||
INVENTREE_CONFIG_FILE=/home/inventree/dev/config.yaml
|
# INVENTREE_DB_USERNAME=dbuser
|
||||||
INVENTREE_SECRET_KEY_FILE=/home/inventree/dev/secret_key.txt
|
# INVENTREE_DB_PASSWEORD=dbpassword
|
||||||
INVENTREE_DEBUG=true
|
|
||||||
INVENTREE_WEB_ADDR=0.0.0.0
|
|
||||||
INVENTREE_WEB_PORT=8000
|
|
||||||
|
@ -19,6 +19,7 @@ services:
|
|||||||
context: .
|
context: .
|
||||||
target: dev
|
target: dev
|
||||||
ports:
|
ports:
|
||||||
|
# Expose web server on port 8000
|
||||||
- 8000:8000
|
- 8000:8000
|
||||||
volumes:
|
volumes:
|
||||||
# Ensure you specify the location of the 'src' directory at the end of this file
|
# Ensure you specify the location of the 'src' directory at the end of this file
|
||||||
@ -26,7 +27,6 @@ services:
|
|||||||
env_file:
|
env_file:
|
||||||
# Environment variables required for the dev server are configured in dev-config.env
|
# Environment variables required for the dev server are configured in dev-config.env
|
||||||
- dev-config.env
|
- dev-config.env
|
||||||
|
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
# Background worker process handles long-running or periodic tasks
|
# Background worker process handles long-running or periodic tasks
|
||||||
@ -35,7 +35,7 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
target: dev
|
target: dev
|
||||||
entrypoint: /home/inventree/docker/start_dev_worker.sh
|
command: invoke worker
|
||||||
depends_on:
|
depends_on:
|
||||||
- inventree-dev-server
|
- inventree-dev-server
|
||||||
volumes:
|
volumes:
|
||||||
|
@ -21,12 +21,13 @@ services:
|
|||||||
# just make sure that you change the INVENTREE_DB_xxx vars below
|
# just make sure that you change the INVENTREE_DB_xxx vars below
|
||||||
inventree-db:
|
inventree-db:
|
||||||
container_name: inventree-db
|
container_name: inventree-db
|
||||||
image: postgres
|
image: postgres:13
|
||||||
ports:
|
ports:
|
||||||
- 5432/tcp
|
- 5432/tcp
|
||||||
environment:
|
environment:
|
||||||
- PGDATA=/var/lib/postgresql/data/pgdb
|
- PGDATA=/var/lib/postgresql/data/pgdb
|
||||||
# The pguser and pgpassword values must be the same in the other containers
|
# The pguser and pgpassword values must be the same in the other containers
|
||||||
|
# Ensure that these are correctly configured in your prod-config.env file
|
||||||
- POSTGRES_USER=pguser
|
- POSTGRES_USER=pguser
|
||||||
- POSTGRES_PASSWORD=pgpassword
|
- POSTGRES_PASSWORD=pgpassword
|
||||||
volumes:
|
volumes:
|
||||||
@ -38,6 +39,8 @@ services:
|
|||||||
# Uses gunicorn as the web server
|
# Uses gunicorn as the web server
|
||||||
inventree-server:
|
inventree-server:
|
||||||
container_name: inventree-server
|
container_name: inventree-server
|
||||||
|
# If you wish to specify a particular InvenTree version, do so here
|
||||||
|
# e.g. image: inventree/inventree:0.5.2
|
||||||
image: inventree/inventree:latest
|
image: inventree/inventree:latest
|
||||||
expose:
|
expose:
|
||||||
- 8000
|
- 8000
|
||||||
@ -46,39 +49,27 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
# Data volume must map to /home/inventree/data
|
# Data volume must map to /home/inventree/data
|
||||||
- data:/home/inventree/data
|
- data:/home/inventree/data
|
||||||
environment:
|
env_file:
|
||||||
# Default environment variables are configured to match the 'db' container
|
# Environment variables required for the production server are configured in prod-config.env
|
||||||
# Note: If you change the database image, these will need to be adjusted
|
- prod-config.env
|
||||||
# Note: INVENTREE_DB_HOST should match the container name of the database
|
|
||||||
- INVENTREE_DB_USER=pguser
|
|
||||||
- INVENTREE_DB_PASSWORD=pgpassword
|
|
||||||
- INVENTREE_DB_ENGINE=postgresql
|
|
||||||
- INVENTREE_DB_NAME=inventree
|
|
||||||
- INVENTREE_DB_HOST=inventree-db
|
|
||||||
- INVENTREE_DB_PORT=5432
|
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
# Background worker process handles long-running or periodic tasks
|
# Background worker process handles long-running or periodic tasks
|
||||||
inventree-worker:
|
inventree-worker:
|
||||||
container_name: inventree-worker
|
container_name: inventree-worker
|
||||||
|
# If you wish to specify a particular InvenTree version, do so here
|
||||||
|
# e.g. image: inventree/inventree:0.5.2
|
||||||
image: inventree/inventree:latest
|
image: inventree/inventree:latest
|
||||||
entrypoint: ./start_prod_worker.sh
|
command: invoke worker
|
||||||
depends_on:
|
depends_on:
|
||||||
- inventree-db
|
- inventree-db
|
||||||
- inventree-server
|
- inventree-server
|
||||||
volumes:
|
volumes:
|
||||||
# Data volume must map to /home/inventree/data
|
# Data volume must map to /home/inventree/data
|
||||||
- data:/home/inventree/data
|
- data:/home/inventree/data
|
||||||
environment:
|
env_file:
|
||||||
# Default environment variables are configured to match the 'db' container
|
# Environment variables required for the production server are configured in prod-config.env
|
||||||
# Note: If you change the database image, these will need to be adjusted
|
- prod-config.env
|
||||||
# Note: INVENTREE_DB_HOST should match the container name of the database
|
|
||||||
- INVENTREE_DB_USER=pguser
|
|
||||||
- INVENTREE_DB_PASSWORD=pgpassword
|
|
||||||
- INVENTREE_DB_ENGINE=postgresql
|
|
||||||
- INVENTREE_DB_NAME=inventree
|
|
||||||
- INVENTREE_DB_HOST=inventree-db
|
|
||||||
- INVENTREE_DB_PORT=5432
|
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
# nginx acts as a reverse proxy
|
# nginx acts as a reverse proxy
|
||||||
@ -88,7 +79,7 @@ services:
|
|||||||
# NOTE: You will need to provide a working nginx.conf file!
|
# NOTE: You will need to provide a working nginx.conf file!
|
||||||
inventree-proxy:
|
inventree-proxy:
|
||||||
container_name: inventree-proxy
|
container_name: inventree-proxy
|
||||||
image: nginx
|
image: nginx:stable
|
||||||
depends_on:
|
depends_on:
|
||||||
- inventree-server
|
- inventree-server
|
||||||
ports:
|
ports:
|
||||||
|
42
docker/init.sh
Normal file
42
docker/init.sh
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# exit when any command fails
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Create required directory structure (if it does not already exist)
|
||||||
|
if [[ ! -d "$INVENTREE_STATIC_ROOT" ]]; then
|
||||||
|
echo "Creating directory $INVENTREE_STATIC_ROOT"
|
||||||
|
mkdir -p $INVENTREE_STATIC_ROOT
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ ! -d "$INVENTREE_MEDIA_ROOT" ]]; then
|
||||||
|
echo "Creating directory $INVENTREE_MEDIA_ROOT"
|
||||||
|
mkdir -p $INVENTREE_MEDIA_ROOT
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check if "config.yaml" has been copied into the correct location
|
||||||
|
if test -f "$INVENTREE_CONFIG_FILE"; then
|
||||||
|
echo "$INVENTREE_CONFIG_FILE exists - skipping"
|
||||||
|
else
|
||||||
|
echo "Copying config file to $INVENTREE_CONFIG_FILE"
|
||||||
|
cp $INVENTREE_HOME/InvenTree/config_template.yaml $INVENTREE_CONFIG_FILE
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Setup a python virtual environment
|
||||||
|
# This should be done on the *mounted* filesystem,
|
||||||
|
# so that the installed modules persist!
|
||||||
|
if [[ -n "$INVENTREE_PY_ENV" ]]; then
|
||||||
|
echo "Using Python virtual environment: ${INVENTREE_PY_ENV}"
|
||||||
|
# Setup a virtual environment (within the "dev" directory)
|
||||||
|
python3 -m venv ${INVENTREE_PY_ENV}
|
||||||
|
|
||||||
|
# Activate the virtual environment
|
||||||
|
source ${INVENTREE_PY_ENV}/bin/activate
|
||||||
|
|
||||||
|
# Note: Python packages will have to be installed on first run
|
||||||
|
# e.g docker-compose -f docker-compose.dev.yml run inventree-dev-server invoke install
|
||||||
|
fi
|
||||||
|
|
||||||
|
cd ${INVENTREE_HOME}
|
||||||
|
|
||||||
|
# Launch the CMD *after* the ENTRYPOINT completes
|
||||||
|
exec "$@"
|
16
docker/prod-config.env
Normal file
16
docker/prod-config.env
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
# InvenTree environment variables for a production setup
|
||||||
|
|
||||||
|
# Note: If your production setup varies from the example, you may want to change these values
|
||||||
|
|
||||||
|
# Ensure debug is false for a production setup
|
||||||
|
INVENTREE_DEBUG=False
|
||||||
|
INVENTREE_LOG_LEVEL="WARNING"
|
||||||
|
|
||||||
|
# Database configuration
|
||||||
|
# Note: The example setup is for a PostgreSQL database (change as required)
|
||||||
|
INVENTREE_DB_ENGINE=postgresql
|
||||||
|
INVENTREE_DB_NAME=inventree
|
||||||
|
INVENTREE_DB_HOST=inventree-db
|
||||||
|
INVENTREE_DB_PORT=5432
|
||||||
|
INVENTREE_DB_USER=pguser
|
||||||
|
INVENTREE_DB_PASSWORD=pgpassword
|
13
docker/requirements.txt
Normal file
13
docker/requirements.txt
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
# Base python requirements for docker containers
|
||||||
|
|
||||||
|
# Basic package requirements
|
||||||
|
setuptools>=57.4.0
|
||||||
|
wheel>=0.37.0
|
||||||
|
invoke>=1.4.0 # Invoke build tool
|
||||||
|
gunicorn>=20.1.0 # Gunicorn web server
|
||||||
|
|
||||||
|
# Database links
|
||||||
|
psycopg2>=2.9.1
|
||||||
|
mysqlclient>=2.0.3
|
||||||
|
pgcli>=3.1.0
|
||||||
|
mariadb>=1.0.7
|
@ -1,51 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
|
|
||||||
# Create required directory structure (if it does not already exist)
|
|
||||||
if [[ ! -d "$INVENTREE_STATIC_ROOT" ]]; then
|
|
||||||
echo "Creating directory $INVENTREE_STATIC_ROOT"
|
|
||||||
mkdir -p $INVENTREE_STATIC_ROOT
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ ! -d "$INVENTREE_MEDIA_ROOT" ]]; then
|
|
||||||
echo "Creating directory $INVENTREE_MEDIA_ROOT"
|
|
||||||
mkdir -p $INVENTREE_MEDIA_ROOT
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Check if "config.yaml" has been copied into the correct location
|
|
||||||
if test -f "$INVENTREE_CONFIG_FILE"; then
|
|
||||||
echo "$INVENTREE_CONFIG_FILE exists - skipping"
|
|
||||||
else
|
|
||||||
echo "Copying config file to $INVENTREE_CONFIG_FILE"
|
|
||||||
cp $INVENTREE_HOME/InvenTree/config_template.yaml $INVENTREE_CONFIG_FILE
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Setup a virtual environment (within the "dev" directory)
|
|
||||||
python3 -m venv ./dev/env
|
|
||||||
|
|
||||||
# Activate the virtual environment
|
|
||||||
source ./dev/env/bin/activate
|
|
||||||
|
|
||||||
echo "Installing required packages..."
|
|
||||||
pip install --no-cache-dir -U -r ${INVENTREE_HOME}/requirements.txt
|
|
||||||
|
|
||||||
echo "Starting InvenTree server..."
|
|
||||||
|
|
||||||
# Wait for the database to be ready
|
|
||||||
cd ${INVENTREE_HOME}/InvenTree
|
|
||||||
python3 manage.py wait_for_db
|
|
||||||
|
|
||||||
sleep 10
|
|
||||||
|
|
||||||
echo "Running InvenTree database migrations..."
|
|
||||||
|
|
||||||
# We assume at this stage that the database is up and running
|
|
||||||
# Ensure that the database schema are up to date
|
|
||||||
python3 manage.py check || exit 1
|
|
||||||
python3 manage.py migrate --noinput || exit 1
|
|
||||||
python3 manage.py migrate --run-syncdb || exit 1
|
|
||||||
python3 manage.py clearsessions || exit 1
|
|
||||||
|
|
||||||
invoke static
|
|
||||||
|
|
||||||
# Launch a development server
|
|
||||||
python3 manage.py runserver ${INVENTREE_WEB_ADDR}:${INVENTREE_WEB_PORT}
|
|
@ -1,19 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
|
|
||||||
echo "Starting InvenTree worker..."
|
|
||||||
|
|
||||||
cd $INVENTREE_HOME
|
|
||||||
|
|
||||||
# Activate virtual environment
|
|
||||||
source ./dev/env/bin/activate
|
|
||||||
|
|
||||||
sleep 5
|
|
||||||
|
|
||||||
# Wait for the database to be ready
|
|
||||||
cd InvenTree
|
|
||||||
python3 manage.py wait_for_db
|
|
||||||
|
|
||||||
sleep 10
|
|
||||||
|
|
||||||
# Now we can launch the background worker process
|
|
||||||
python3 manage.py qcluster
|
|
@ -1,42 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
|
|
||||||
# Create required directory structure (if it does not already exist)
|
|
||||||
if [[ ! -d "$INVENTREE_STATIC_ROOT" ]]; then
|
|
||||||
echo "Creating directory $INVENTREE_STATIC_ROOT"
|
|
||||||
mkdir -p $INVENTREE_STATIC_ROOT
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ ! -d "$INVENTREE_MEDIA_ROOT" ]]; then
|
|
||||||
echo "Creating directory $INVENTREE_MEDIA_ROOT"
|
|
||||||
mkdir -p $INVENTREE_MEDIA_ROOT
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Check if "config.yaml" has been copied into the correct location
|
|
||||||
if test -f "$INVENTREE_CONFIG_FILE"; then
|
|
||||||
echo "$INVENTREE_CONFIG_FILE exists - skipping"
|
|
||||||
else
|
|
||||||
echo "Copying config file to $INVENTREE_CONFIG_FILE"
|
|
||||||
cp $INVENTREE_HOME/InvenTree/config_template.yaml $INVENTREE_CONFIG_FILE
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "Starting InvenTree server..."
|
|
||||||
|
|
||||||
# Wait for the database to be ready
|
|
||||||
cd $INVENTREE_MNG_DIR
|
|
||||||
python3 manage.py wait_for_db
|
|
||||||
|
|
||||||
sleep 10
|
|
||||||
|
|
||||||
echo "Running InvenTree database migrations and collecting static files..."
|
|
||||||
|
|
||||||
# We assume at this stage that the database is up and running
|
|
||||||
# Ensure that the database schema are up to date
|
|
||||||
python3 manage.py check || exit 1
|
|
||||||
python3 manage.py migrate --noinput || exit 1
|
|
||||||
python3 manage.py migrate --run-syncdb || exit 1
|
|
||||||
python3 manage.py prerender || exit 1
|
|
||||||
python3 manage.py collectstatic --noinput || exit 1
|
|
||||||
python3 manage.py clearsessions || exit 1
|
|
||||||
|
|
||||||
# Now we can launch the server
|
|
||||||
gunicorn -c $INVENTREE_HOME/gunicorn.conf.py InvenTree.wsgi -b 0.0.0.0:$INVENTREE_WEB_PORT
|
|
@ -1,14 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
|
|
||||||
echo "Starting InvenTree worker..."
|
|
||||||
|
|
||||||
sleep 5
|
|
||||||
|
|
||||||
# Wait for the database to be ready
|
|
||||||
cd $INVENTREE_MNG_DIR
|
|
||||||
python3 manage.py wait_for_db
|
|
||||||
|
|
||||||
sleep 10
|
|
||||||
|
|
||||||
# Now we can launch the background worker process
|
|
||||||
python3 manage.py qcluster
|
|
@ -1,11 +1,5 @@
|
|||||||
# Basic package requirements
|
|
||||||
setuptools>=57.4.0
|
|
||||||
wheel>=0.37.0
|
|
||||||
invoke>=1.4.0 # Invoke build tool
|
|
||||||
gunicorn>=20.1.0 # Gunicorn web server
|
|
||||||
|
|
||||||
# Django framework
|
# Django framework
|
||||||
Django==3.2.4 # Django package
|
Django==3.2.4 # Django package
|
||||||
|
|
||||||
pillow==8.2.0 # Image manipulation
|
pillow==8.2.0 # Image manipulation
|
||||||
djangorestframework==3.12.4 # DRF framework
|
djangorestframework==3.12.4 # DRF framework
|
||||||
|
75
tasks.py
75
tasks.py
@ -65,7 +65,7 @@ def manage(c, cmd, pty=False):
|
|||||||
cmd - django command to run
|
cmd - django command to run
|
||||||
"""
|
"""
|
||||||
|
|
||||||
c.run('cd "{path}" && python3 manage.py {cmd}'.format(
|
result = c.run('cd "{path}" && python3 manage.py {cmd}'.format(
|
||||||
path=managePyDir(),
|
path=managePyDir(),
|
||||||
cmd=cmd
|
cmd=cmd
|
||||||
), pty=pty)
|
), pty=pty)
|
||||||
@ -80,14 +80,6 @@ def install(c):
|
|||||||
# Install required Python packages with PIP
|
# Install required Python packages with PIP
|
||||||
c.run('pip3 install -U -r requirements.txt')
|
c.run('pip3 install -U -r requirements.txt')
|
||||||
|
|
||||||
# If a config.yaml file does not exist, copy from the template!
|
|
||||||
CONFIG_FILE = os.path.join(localDir(), 'InvenTree', 'config.yaml')
|
|
||||||
CONFIG_TEMPLATE_FILE = os.path.join(localDir(), 'InvenTree', 'config_template.yaml')
|
|
||||||
|
|
||||||
if not os.path.exists(CONFIG_FILE):
|
|
||||||
print("Config file 'config.yaml' does not exist - copying from template.")
|
|
||||||
copyfile(CONFIG_TEMPLATE_FILE, CONFIG_FILE)
|
|
||||||
|
|
||||||
|
|
||||||
@task
|
@task
|
||||||
def shell(c):
|
def shell(c):
|
||||||
@ -97,13 +89,6 @@ def shell(c):
|
|||||||
|
|
||||||
manage(c, 'shell', pty=True)
|
manage(c, 'shell', pty=True)
|
||||||
|
|
||||||
@task
|
|
||||||
def worker(c):
|
|
||||||
"""
|
|
||||||
Run the InvenTree background worker process
|
|
||||||
"""
|
|
||||||
|
|
||||||
manage(c, 'qcluster', pty=True)
|
|
||||||
|
|
||||||
@task
|
@task
|
||||||
def superuser(c):
|
def superuser(c):
|
||||||
@ -113,6 +98,7 @@ def superuser(c):
|
|||||||
|
|
||||||
manage(c, 'createsuperuser', pty=True)
|
manage(c, 'createsuperuser', pty=True)
|
||||||
|
|
||||||
|
|
||||||
@task
|
@task
|
||||||
def check(c):
|
def check(c):
|
||||||
"""
|
"""
|
||||||
@ -121,13 +107,24 @@ def check(c):
|
|||||||
|
|
||||||
manage(c, "check")
|
manage(c, "check")
|
||||||
|
|
||||||
|
|
||||||
@task
|
@task
|
||||||
def wait(c):
|
def wait(c):
|
||||||
"""
|
"""
|
||||||
Wait until the database connection is ready
|
Wait until the database connection is ready
|
||||||
"""
|
"""
|
||||||
|
|
||||||
manage(c, "wait_for_db")
|
return manage(c, "wait_for_db")
|
||||||
|
|
||||||
|
|
||||||
|
@task(pre=[wait])
|
||||||
|
def worker(c):
|
||||||
|
"""
|
||||||
|
Run the InvenTree background worker process
|
||||||
|
"""
|
||||||
|
|
||||||
|
manage(c, 'qcluster', pty=True)
|
||||||
|
|
||||||
|
|
||||||
@task
|
@task
|
||||||
def rebuild(c):
|
def rebuild(c):
|
||||||
@ -137,6 +134,7 @@ def rebuild(c):
|
|||||||
|
|
||||||
manage(c, "rebuild_models")
|
manage(c, "rebuild_models")
|
||||||
|
|
||||||
|
|
||||||
@task
|
@task
|
||||||
def clean_settings(c):
|
def clean_settings(c):
|
||||||
"""
|
"""
|
||||||
@ -145,7 +143,7 @@ def clean_settings(c):
|
|||||||
|
|
||||||
manage(c, "clean_settings")
|
manage(c, "clean_settings")
|
||||||
|
|
||||||
@task
|
@task(post=[rebuild])
|
||||||
def migrate(c):
|
def migrate(c):
|
||||||
"""
|
"""
|
||||||
Performs database migrations.
|
Performs database migrations.
|
||||||
@ -156,7 +154,7 @@ def migrate(c):
|
|||||||
print("========================================")
|
print("========================================")
|
||||||
|
|
||||||
manage(c, "makemigrations")
|
manage(c, "makemigrations")
|
||||||
manage(c, "migrate")
|
manage(c, "migrate --noinput")
|
||||||
manage(c, "migrate --run-syncdb")
|
manage(c, "migrate --run-syncdb")
|
||||||
manage(c, "check")
|
manage(c, "check")
|
||||||
|
|
||||||
@ -175,22 +173,6 @@ def static(c):
|
|||||||
manage(c, "collectstatic --no-input")
|
manage(c, "collectstatic --no-input")
|
||||||
|
|
||||||
|
|
||||||
@task(pre=[install, migrate, static, clean_settings])
|
|
||||||
def update(c):
|
|
||||||
"""
|
|
||||||
Update InvenTree installation.
|
|
||||||
|
|
||||||
This command should be invoked after source code has been updated,
|
|
||||||
e.g. downloading new code from GitHub.
|
|
||||||
|
|
||||||
The following tasks are performed, in order:
|
|
||||||
|
|
||||||
- install
|
|
||||||
- migrate
|
|
||||||
- static
|
|
||||||
"""
|
|
||||||
pass
|
|
||||||
|
|
||||||
@task(post=[static])
|
@task(post=[static])
|
||||||
def translate(c):
|
def translate(c):
|
||||||
"""
|
"""
|
||||||
@ -206,7 +188,26 @@ def translate(c):
|
|||||||
|
|
||||||
path = os.path.join('InvenTree', 'script', 'translation_stats.py')
|
path = os.path.join('InvenTree', 'script', 'translation_stats.py')
|
||||||
|
|
||||||
c.run(f'python {path}')
|
c.run(f'python3 {path}')
|
||||||
|
|
||||||
|
|
||||||
|
@task(pre=[install, migrate, translate, clean_settings])
|
||||||
|
def update(c):
|
||||||
|
"""
|
||||||
|
Update InvenTree installation.
|
||||||
|
|
||||||
|
This command should be invoked after source code has been updated,
|
||||||
|
e.g. downloading new code from GitHub.
|
||||||
|
|
||||||
|
The following tasks are performed, in order:
|
||||||
|
|
||||||
|
- install
|
||||||
|
- migrate
|
||||||
|
- translate
|
||||||
|
- clean_settings
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
@task
|
@task
|
||||||
def style(c):
|
def style(c):
|
||||||
@ -217,6 +218,7 @@ def style(c):
|
|||||||
print("Running PEP style checks...")
|
print("Running PEP style checks...")
|
||||||
c.run('flake8 InvenTree')
|
c.run('flake8 InvenTree')
|
||||||
|
|
||||||
|
|
||||||
@task
|
@task
|
||||||
def test(c, database=None):
|
def test(c, database=None):
|
||||||
"""
|
"""
|
||||||
@ -228,6 +230,7 @@ def test(c, database=None):
|
|||||||
# Run coverage tests
|
# Run coverage tests
|
||||||
manage(c, 'test', pty=True)
|
manage(c, 'test', pty=True)
|
||||||
|
|
||||||
|
|
||||||
@task
|
@task
|
||||||
def coverage(c):
|
def coverage(c):
|
||||||
"""
|
"""
|
||||||
|
Loading…
Reference in New Issue
Block a user