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
|
||||
dummy_image.*
|
||||
_tmp.csv
|
||||
|
||||
# Sphinx files
|
||||
docs/_build
|
||||
|
@ -32,27 +32,37 @@ class InvenTreeConfig(AppConfig):
|
||||
|
||||
logger.info("Starting background tasks...")
|
||||
|
||||
# Remove successful task results from the database
|
||||
InvenTree.tasks.schedule_task(
|
||||
'InvenTree.tasks.delete_successful_tasks',
|
||||
schedule_type=Schedule.DAILY,
|
||||
)
|
||||
|
||||
# Check for InvenTree updates
|
||||
InvenTree.tasks.schedule_task(
|
||||
'InvenTree.tasks.check_for_updates',
|
||||
schedule_type=Schedule.DAILY
|
||||
)
|
||||
|
||||
# Heartbeat to let the server know the background worker is running
|
||||
InvenTree.tasks.schedule_task(
|
||||
'InvenTree.tasks.heartbeat',
|
||||
schedule_type=Schedule.MINUTES,
|
||||
minutes=15
|
||||
)
|
||||
|
||||
# Keep exchange rates up to date
|
||||
InvenTree.tasks.schedule_task(
|
||||
'InvenTree.tasks.update_exchange_rates',
|
||||
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):
|
||||
"""
|
||||
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
|
||||
|
||||
import os
|
||||
import logging
|
||||
|
||||
from django.db import models
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import User
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
@ -21,6 +23,9 @@ from mptt.exceptions import InvalidMove
|
||||
from .validators import validate_tree_name
|
||||
|
||||
|
||||
logger = logging.getLogger('inventree')
|
||||
|
||||
|
||||
def rename_attachment(instance, filename):
|
||||
"""
|
||||
Function for renaming an attachment file.
|
||||
@ -77,6 +82,72 @@ class InvenTreeAttachment(models.Model):
|
||||
def basename(self):
|
||||
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:
|
||||
abstract = True
|
||||
|
||||
|
@ -167,6 +167,18 @@ class InvenTreeModelSerializer(serializers.ModelSerializer):
|
||||
|
||||
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):
|
||||
"""
|
||||
Perform serializer validation.
|
||||
@ -188,7 +200,10 @@ class InvenTreeModelSerializer(serializers.ModelSerializer):
|
||||
|
||||
# Update instance fields
|
||||
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.
|
||||
# Note that by default, DRF does *not* perform full model validation!
|
||||
@ -208,6 +223,22 @@ class InvenTreeModelSerializer(serializers.ModelSerializer):
|
||||
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):
|
||||
"""
|
||||
Override the DRF native FileField serializer,
|
||||
|
@ -169,6 +169,30 @@ else:
|
||||
logger.exception(f"Couldn't load keyfile {key_file}")
|
||||
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)
|
||||
ALLOWED_HOSTS = CONFIG.get('allowed_hosts', ['*'])
|
||||
|
||||
@ -189,22 +213,12 @@ if cors_opt:
|
||||
# Web URL endpoint for served static files
|
||||
STATIC_URL = '/static/'
|
||||
|
||||
# The filesystem location for served static files
|
||||
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'),
|
||||
]
|
||||
STATICFILES_DIRS = []
|
||||
|
||||
# Translated Template settings
|
||||
STATICFILES_I18_PREFIX = 'i18n'
|
||||
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_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
|
||||
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:
|
||||
logger.info("InvenTree running in DEBUG mode")
|
||||
|
||||
logger.info(f"MEDIA_ROOT: '{MEDIA_ROOT}'")
|
||||
logger.info(f"STATIC_ROOT: '{STATIC_ROOT}'")
|
||||
logger.debug(f"MEDIA_ROOT: '{MEDIA_ROOT}'")
|
||||
logger.debug(f"STATIC_ROOT: '{STATIC_ROOT}'")
|
||||
|
||||
# Application definition
|
||||
|
||||
@ -320,6 +326,7 @@ TEMPLATES = [
|
||||
'django.template.context_processors.i18n',
|
||||
'django.contrib.auth.context_processors.auth',
|
||||
'django.contrib.messages.context_processors.messages',
|
||||
# Custom InvenTree context processors
|
||||
'InvenTree.context.health_status',
|
||||
'InvenTree.context.status_codes',
|
||||
'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
|
||||
"""
|
||||
|
||||
logger.info("Configuring database backend:")
|
||||
logger.debug("Configuring database backend:")
|
||||
|
||||
# Extract database configuration from the config.yaml file
|
||||
db_config = CONFIG.get('database', {})
|
||||
@ -467,11 +474,9 @@ if db_engine in ['sqlite3', 'postgresql', 'mysql']:
|
||||
db_name = db_config['NAME']
|
||||
db_host = db_config.get('HOST', "''")
|
||||
|
||||
print("InvenTree Database Configuration")
|
||||
print("================================")
|
||||
print(f"ENGINE: {db_engine}")
|
||||
print(f"NAME: {db_name}")
|
||||
print(f"HOST: {db_host}")
|
||||
logger.info(f"DB_ENGINE: {db_engine}")
|
||||
logger.info(f"DB_NAME: {db_name}")
|
||||
logger.info(f"DB_HOST: {db_host}")
|
||||
|
||||
DATABASES['default'] = db_config
|
||||
|
||||
|
@ -640,6 +640,11 @@
|
||||
z-index: 9999;
|
||||
}
|
||||
|
||||
.modal-error {
|
||||
border: 2px #FCC solid;
|
||||
background-color: #f5f0f0;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
border-bottom: 1px solid #ddd;
|
||||
}
|
||||
@ -730,6 +735,13 @@
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.form-panel {
|
||||
border-radius: 5px;
|
||||
border: 1px solid #ccc;
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
|
||||
.modal input {
|
||||
width: 100%;
|
||||
}
|
||||
@ -1037,6 +1049,11 @@ a.anchor {
|
||||
height: 30px;
|
||||
}
|
||||
|
||||
/* Force minimum width of number input fields to show at least ~5 digits */
|
||||
input[type='number']{
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
.search-menu {
|
||||
padding-top: 2rem;
|
||||
}
|
||||
|
@ -36,7 +36,7 @@ def schedule_task(taskname, **kwargs):
|
||||
# If this task is already scheduled, don't schedule it again
|
||||
# Instead, update the scheduling parameters
|
||||
if Schedule.objects.filter(func=taskname).exists():
|
||||
logger.info(f"Scheduled task '{taskname}' already exists - updating!")
|
||||
logger.debug(f"Scheduled task '{taskname}' already exists - updating!")
|
||||
|
||||
Schedule.objects.filter(func=taskname).update(**kwargs)
|
||||
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():
|
||||
"""
|
||||
Update currency exchange rates
|
||||
|
@ -10,7 +10,8 @@ from django.db.models import BooleanField
|
||||
|
||||
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 LocationSerializer
|
||||
@ -158,7 +159,7 @@ class BuildItemSerializer(InvenTreeModelSerializer):
|
||||
]
|
||||
|
||||
|
||||
class BuildAttachmentSerializer(InvenTreeModelSerializer):
|
||||
class BuildAttachmentSerializer(InvenTreeAttachmentSerializer):
|
||||
"""
|
||||
Serializer for a BuildAttachment
|
||||
"""
|
||||
@ -172,6 +173,7 @@ class BuildAttachmentSerializer(InvenTreeModelSerializer):
|
||||
'pk',
|
||||
'build',
|
||||
'attachment',
|
||||
'filename',
|
||||
'comment',
|
||||
'upload_date',
|
||||
]
|
||||
|
@ -369,6 +369,7 @@ loadAttachmentTable(
|
||||
|
||||
constructForm(url, {
|
||||
fields: {
|
||||
filename: {},
|
||||
comment: {},
|
||||
},
|
||||
onSuccess: reloadAttachmentTable,
|
||||
|
@ -20,7 +20,6 @@ from djmoney.contrib.exchange.models import convert_money
|
||||
from djmoney.contrib.exchange.exceptions import MissingRate
|
||||
|
||||
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.exceptions import ValidationError
|
||||
|
||||
@ -49,55 +48,37 @@ class BaseInvenTreeSetting(models.Model):
|
||||
are assigned their default values
|
||||
"""
|
||||
|
||||
keys = set()
|
||||
settings = []
|
||||
|
||||
results = cls.objects.all()
|
||||
|
||||
if user is not None:
|
||||
results = results.filter(user=user)
|
||||
|
||||
# Query the database
|
||||
settings = {}
|
||||
|
||||
for setting in results:
|
||||
if setting.key:
|
||||
settings.append({
|
||||
"key": setting.key.upper(),
|
||||
"value": setting.value
|
||||
})
|
||||
|
||||
keys.add(setting.key.upper())
|
||||
settings[setting.key.upper()] = setting.value
|
||||
|
||||
# Specify any "default" values which are not in the database
|
||||
for key in cls.GLOBAL_SETTINGS.keys():
|
||||
|
||||
if key.upper() not in keys:
|
||||
if key.upper() not in settings:
|
||||
|
||||
settings.append({
|
||||
"key": key.upper(),
|
||||
"value": cls.get_setting_default(key)
|
||||
})
|
||||
|
||||
# Enforce javascript formatting
|
||||
for idx, setting in enumerate(settings):
|
||||
|
||||
key = setting['key']
|
||||
value = setting['value']
|
||||
settings[key.upper()] = cls.get_setting_default(key)
|
||||
|
||||
for key, value in settings.items():
|
||||
validator = cls.get_setting_validator(key)
|
||||
|
||||
# Convert to javascript compatible booleans
|
||||
if cls.validator_is_bool(validator):
|
||||
value = str(value).lower()
|
||||
|
||||
# Numerical values remain the same
|
||||
value = InvenTree.helpers.str2bool(value)
|
||||
elif cls.validator_is_int(validator):
|
||||
pass
|
||||
try:
|
||||
value = int(value)
|
||||
except ValueError:
|
||||
value = cls.get_setting_default(key)
|
||||
|
||||
# Wrap strings with quotes
|
||||
else:
|
||||
value = format_html("'{}'", value)
|
||||
|
||||
setting["value"] = value
|
||||
settings[key] = value
|
||||
|
||||
return settings
|
||||
|
||||
|
@ -24,19 +24,17 @@
|
||||
</button>
|
||||
{% endif %}
|
||||
<div class='btn-group'>
|
||||
<div class="dropdown" style="float: right;">
|
||||
<button class="btn btn-primary dropdown-toggle" type="button" data-toggle="dropdown">{% trans "Options" %}
|
||||
<span class="caret"></span>
|
||||
</button>
|
||||
<ul class="dropdown-menu">
|
||||
{% if roles.purchase_order.add %}
|
||||
<li><a href='#' id='multi-part-order' title='{% trans "Order parts" %}'>{% trans "Order Parts" %}</a></li>
|
||||
{% endif %}
|
||||
{% if roles.purchase_order.delete %}
|
||||
<li><a href='#' id='multi-part-delete' title='{% trans "Delete parts" %}'>{% trans "Delete Parts" %}</a></li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</div>
|
||||
<button class="btn btn-primary dropdown-toggle" id='supplier-table-options' type="button" data-toggle="dropdown">{% trans "Options" %}
|
||||
<span class="caret"></span>
|
||||
</button>
|
||||
<ul class="dropdown-menu">
|
||||
{% if roles.purchase_order.add %}
|
||||
<li><a href='#' id='multi-supplier-part-order' title='{% trans "Order parts" %}'>{% trans "Order Parts" %}</a></li>
|
||||
{% endif %}
|
||||
{% if roles.purchase_order.delete %}
|
||||
<li><a href='#' id='multi-supplier-part-delete' title='{% trans "Delete parts" %}'>{% trans "Delete Parts" %}</a></li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class='filter-list' id='filter-list-supplier-part'>
|
||||
@ -59,27 +57,25 @@
|
||||
{% if roles.purchase_order.change %}
|
||||
<div id='manufacturer-part-button-toolbar'>
|
||||
<div class='button-toolbar container-fluid'>
|
||||
<div class='btn-group role='group'>
|
||||
<div class='btn-group' role='group'>
|
||||
{% if roles.purchase_order.add %}
|
||||
<button class="btn btn-success" id='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" %}
|
||||
</button>
|
||||
{% endif %}
|
||||
<div class='btn-group'>
|
||||
<div class="dropdown" style="float: right;">
|
||||
<button class="btn btn-primary dropdown-toggle" id='table-options', type="button" data-toggle="dropdown">{% trans "Options" %}
|
||||
<span class="caret"></span>
|
||||
</button>
|
||||
<ul class="dropdown-menu">
|
||||
{% if roles.purchase_order.add %}
|
||||
<li><a href='#' id='multi-part-order' title='{% trans "Order parts" %}'>{% trans "Order Parts" %}</a></li>
|
||||
{% endif %}
|
||||
{% if roles.purchase_order.delete %}
|
||||
<li><a href='#' id='multi-part-delete' title='{% trans "Delete parts" %}'>{% trans "Delete Parts" %}</a></li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class='btn-group' role='group'>
|
||||
<button class="btn btn-primary dropdown-toggle" id='manufacturer-table-options' type="button" data-toggle="dropdown">{% trans "Options" %}
|
||||
<span class="caret"></span>
|
||||
</button>
|
||||
<ul class="dropdown-menu">
|
||||
{% if roles.purchase_order.add %}
|
||||
<li><a href='#' id='multi-manufacturer-part-order' title='{% trans "Order parts" %}'>{% trans "Order Parts" %}</a></li>
|
||||
{% endif %}
|
||||
{% if roles.purchase_order.delete %}
|
||||
<li><a href='#' id='multi-manufacturer-part-delete' title='{% trans "Delete parts" %}'>{% trans "Delete Parts" %}</a></li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class='filter-list' id='filter-list-supplier-part'>
|
||||
<!-- Empty div (will be filled out with available BOM filters) -->
|
||||
@ -87,7 +83,7 @@
|
||||
</div>
|
||||
</div>
|
||||
{% 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>
|
||||
</div>
|
||||
</div>
|
||||
@ -274,6 +270,10 @@
|
||||
|
||||
{% if company.is_manufacturer %}
|
||||
|
||||
function reloadManufacturerPartTable() {
|
||||
$('#manufacturer-part-table').bootstrapTable('refresh');
|
||||
}
|
||||
|
||||
$("#manufacturer-part-create").click(function () {
|
||||
|
||||
createManufacturerPart({
|
||||
@ -285,7 +285,7 @@
|
||||
});
|
||||
|
||||
loadManufacturerPartTable(
|
||||
"#part-table",
|
||||
"#manufacturer-part-table",
|
||||
"{% url 'api-manufacturer-part-list' %}",
|
||||
{
|
||||
params: {
|
||||
@ -296,20 +296,20 @@
|
||||
}
|
||||
);
|
||||
|
||||
linkButtonsToSelection($("#manufacturer-table"), ['#table-options']);
|
||||
linkButtonsToSelection($("#manufacturer-part-table"), ['#manufacturer-table-options']);
|
||||
|
||||
$("#multi-part-delete").click(function() {
|
||||
var selections = $("#part-table").bootstrapTable("getSelections");
|
||||
$("#multi-manufacturer-part-delete").click(function() {
|
||||
var selections = $("#manufacturer-part-table").bootstrapTable("getSelections");
|
||||
|
||||
deleteManufacturerParts(selections, {
|
||||
onSuccess: function() {
|
||||
$("#part-table").bootstrapTable("refresh");
|
||||
$("#manufacturer-part-table").bootstrapTable("refresh");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
$("#multi-part-order").click(function() {
|
||||
var selections = $("#part-table").bootstrapTable("getSelections");
|
||||
$("#multi-manufacturer-part-order").click(function() {
|
||||
var selections = $("#manufacturer-part-table").bootstrapTable("getSelections");
|
||||
|
||||
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 requests = [];
|
||||
@ -379,8 +379,8 @@
|
||||
);
|
||||
});
|
||||
|
||||
$("#multi-part-order").click(function() {
|
||||
var selections = $("#part-table").bootstrapTable("getSelections");
|
||||
$("#multi-supplier-part-order").click(function() {
|
||||
var selections = $("#supplier-part-table").bootstrapTable("getSelections");
|
||||
|
||||
var parts = [];
|
||||
|
||||
@ -395,6 +395,8 @@
|
||||
});
|
||||
});
|
||||
|
||||
{% endif %}
|
||||
|
||||
attachNavCallbacks({
|
||||
name: 'company',
|
||||
default: 'company-stock'
|
||||
|
@ -109,7 +109,7 @@ src="{% static 'img/blank_image.png' %}"
|
||||
<span class='fas fa-plus-circle'></span> {% trans "New Supplier Part" %}
|
||||
</button>
|
||||
<div id='opt-dropdown' class="btn-group">
|
||||
<button id='supplier-part-options' class="btn btn-primary dropdown-toggle" type="button" data-toggle="dropdown">{% trans "Options" %}<span class="caret"></span></button>
|
||||
<button id='supplier-part-options' class="btn btn-primary dropdown-toggle" type="button" data-toggle="dropdown">{% trans "Options" %} <span class="caret"></span></button>
|
||||
<ul class="dropdown-menu">
|
||||
<li><a href='#' id='supplier-part-delete' title='{% trans "Delete supplier parts" %}'>{% trans "Delete" %}</a></li>
|
||||
</ul>
|
||||
@ -133,7 +133,7 @@ src="{% static 'img/blank_image.png' %}"
|
||||
<span class='fas fa-plus-circle'></span> {% trans "New Parameter" %}
|
||||
</button>
|
||||
<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">
|
||||
<li><a href='#' id='multi-parameter-delete' title='{% trans "Delete parameters" %}'>{% trans "Delete" %}</a></li>
|
||||
</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:
|
||||
unique_together = (
|
||||
('order', 'part')
|
||||
('order', 'part', 'quantity', 'purchase_price')
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
|
@ -14,6 +14,7 @@ from rest_framework import serializers
|
||||
from sql_util.utils import SubqueryCount
|
||||
|
||||
from InvenTree.serializers import InvenTreeModelSerializer
|
||||
from InvenTree.serializers import InvenTreeAttachmentSerializer
|
||||
from InvenTree.serializers import InvenTreeMoneySerializer
|
||||
from InvenTree.serializers import InvenTreeAttachmentSerializerField
|
||||
|
||||
@ -160,7 +161,7 @@ class POLineItemSerializer(InvenTreeModelSerializer):
|
||||
]
|
||||
|
||||
|
||||
class POAttachmentSerializer(InvenTreeModelSerializer):
|
||||
class POAttachmentSerializer(InvenTreeAttachmentSerializer):
|
||||
"""
|
||||
Serializers for the PurchaseOrderAttachment model
|
||||
"""
|
||||
@ -174,6 +175,7 @@ class POAttachmentSerializer(InvenTreeModelSerializer):
|
||||
'pk',
|
||||
'order',
|
||||
'attachment',
|
||||
'filename',
|
||||
'comment',
|
||||
'upload_date',
|
||||
]
|
||||
@ -381,7 +383,7 @@ class SOLineItemSerializer(InvenTreeModelSerializer):
|
||||
]
|
||||
|
||||
|
||||
class SOAttachmentSerializer(InvenTreeModelSerializer):
|
||||
class SOAttachmentSerializer(InvenTreeAttachmentSerializer):
|
||||
"""
|
||||
Serializers for the SalesOrderAttachment model
|
||||
"""
|
||||
@ -395,6 +397,7 @@ class SOAttachmentSerializer(InvenTreeModelSerializer):
|
||||
'pk',
|
||||
'order',
|
||||
'attachment',
|
||||
'filename',
|
||||
'comment',
|
||||
'upload_date',
|
||||
]
|
||||
|
@ -115,7 +115,7 @@
|
||||
{{ block.super }}
|
||||
|
||||
$('.bomselect').select2({
|
||||
dropdownAutoWidth: true,
|
||||
width: '100%',
|
||||
matcher: partialMatcher,
|
||||
});
|
||||
|
||||
|
@ -122,6 +122,7 @@
|
||||
|
||||
constructForm(url, {
|
||||
fields: {
|
||||
filename: {},
|
||||
comment: {},
|
||||
},
|
||||
onSuccess: reloadAttachmentTable,
|
||||
@ -327,7 +328,7 @@ $("#po-table").inventreeTable({
|
||||
{
|
||||
sortable: true,
|
||||
sortName: 'part__MPN',
|
||||
field: 'supplier_part_detail.MPN',
|
||||
field: 'supplier_part_detail.manufacturer_part_detail.MPN',
|
||||
title: '{% trans "MPN" %}',
|
||||
formatter: function(value, row, index, field) {
|
||||
if (row.supplier_part_detail && row.supplier_part_detail.manufacturer_part) {
|
||||
|
@ -112,6 +112,7 @@
|
||||
|
||||
constructForm(url, {
|
||||
fields: {
|
||||
filename: {},
|
||||
comment: {},
|
||||
},
|
||||
onSuccess: reloadAttachmentTable,
|
||||
|
@ -9,12 +9,14 @@ from django.conf.urls import url, include
|
||||
from django.urls import reverse
|
||||
from django.http import JsonResponse
|
||||
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 rest_framework import status
|
||||
from rest_framework.response import Response
|
||||
from rest_framework import filters, serializers
|
||||
from rest_framework import generics
|
||||
from rest_framework.exceptions import ValidationError
|
||||
|
||||
from django_filters.rest_framework import DjangoFilterBackend
|
||||
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.exceptions import MissingRate
|
||||
|
||||
from decimal import Decimal
|
||||
from decimal import Decimal, InvalidOperation
|
||||
|
||||
from .models import Part, PartCategory, BomItem
|
||||
from .models import PartParameter, PartParameterTemplate
|
||||
@ -31,7 +33,10 @@ from .models import PartAttachment, PartTestTemplate
|
||||
from .models import PartSellPriceBreak, PartInternalPriceBreak
|
||||
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 build.models import Build
|
||||
|
||||
@ -630,6 +635,7 @@ class PartList(generics.ListCreateAPIView):
|
||||
else:
|
||||
return Response(data)
|
||||
|
||||
@transaction.atomic
|
||||
def create(self, request, *args, **kwargs):
|
||||
"""
|
||||
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
|
||||
"""
|
||||
|
||||
# TODO: Unit tests for this function!
|
||||
|
||||
serializer = self.get_serializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
@ -680,21 +688,97 @@ class PartList(generics.ListCreateAPIView):
|
||||
pass
|
||||
|
||||
# Optionally create initial stock item
|
||||
try:
|
||||
initial_stock = Decimal(request.data.get('initial_stock', 0))
|
||||
initial_stock = str2bool(request.data.get('initial_stock', False))
|
||||
|
||||
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,
|
||||
quantity=initial_stock,
|
||||
location=part.default_location,
|
||||
manufacturer=manufacturer,
|
||||
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:
|
||||
pass
|
||||
SupplierPart.objects.create(
|
||||
part=part,
|
||||
supplier=supplier,
|
||||
SKU=sku,
|
||||
manufacturer_part=manufacturer_part,
|
||||
)
|
||||
|
||||
headers = self.get_success_headers(serializer.data)
|
||||
|
||||
|
@ -1,6 +1,7 @@
|
||||
"""
|
||||
JSON serializers for Part app
|
||||
"""
|
||||
|
||||
import imghdr
|
||||
from decimal import Decimal
|
||||
|
||||
@ -16,7 +17,9 @@ from djmoney.contrib.django_rest_framework import MoneyField
|
||||
from InvenTree.serializers import (InvenTreeAttachmentSerializerField,
|
||||
InvenTreeImageSerializerField,
|
||||
InvenTreeModelSerializer,
|
||||
InvenTreeAttachmentSerializer,
|
||||
InvenTreeMoneySerializer)
|
||||
|
||||
from InvenTree.status_codes import BuildStatus, PurchaseOrderStatus
|
||||
from stock.models import StockItem
|
||||
|
||||
@ -51,7 +54,7 @@ class CategorySerializer(InvenTreeModelSerializer):
|
||||
]
|
||||
|
||||
|
||||
class PartAttachmentSerializer(InvenTreeModelSerializer):
|
||||
class PartAttachmentSerializer(InvenTreeAttachmentSerializer):
|
||||
"""
|
||||
Serializer for the PartAttachment class
|
||||
"""
|
||||
@ -65,6 +68,7 @@ class PartAttachmentSerializer(InvenTreeModelSerializer):
|
||||
'pk',
|
||||
'part',
|
||||
'attachment',
|
||||
'filename',
|
||||
'comment',
|
||||
'upload_date',
|
||||
]
|
||||
|
@ -132,12 +132,13 @@
|
||||
</button>
|
||||
{% endif %}
|
||||
<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'>
|
||||
{% if roles.part.change %}
|
||||
<li><a href='#' id='multi-part-category' title='{% trans "Set category" %}'>{% trans "Set Category" %}</a></li>
|
||||
{% endif %}
|
||||
<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>
|
||||
</ul>
|
||||
</div>
|
||||
@ -276,6 +277,7 @@
|
||||
constructForm('{% url "api-part-list" %}', {
|
||||
method: 'POST',
|
||||
fields: fields,
|
||||
groups: partGroups(),
|
||||
title: '{% trans "Create Part" %}',
|
||||
onSuccess: function(data) {
|
||||
// Follow the new part
|
||||
@ -336,4 +338,4 @@
|
||||
default: 'part-stock'
|
||||
});
|
||||
|
||||
{% endblock %}
|
||||
{% endblock %}
|
||||
|
@ -289,7 +289,7 @@
|
||||
<span class='fas fa-plus-circle'></span> {% trans "New Supplier Part" %}
|
||||
</button>
|
||||
<div id='opt-dropdown' class="btn-group">
|
||||
<button id='supplier-part-options' class="btn btn-primary dropdown-toggle" type="button" data-toggle="dropdown">{% trans "Options" %}<span class="caret"></span></button>
|
||||
<button id='supplier-part-options' class="btn btn-primary dropdown-toggle" type="button" data-toggle="dropdown">{% trans "Options" %} <span class="caret"></span></button>
|
||||
<ul class="dropdown-menu">
|
||||
<li><a href='#' id='supplier-part-delete' title='{% trans "Delete supplier parts" %}'>{% trans "Delete" %}</a></li>
|
||||
</ul>
|
||||
@ -312,7 +312,7 @@
|
||||
<span class='fas fa-plus-circle'></span> {% trans "New Manufacturer Part" %}
|
||||
</button>
|
||||
<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">
|
||||
<li><a href='#' id='manufacturer-part-delete' title='{% trans "Delete manufacturer parts" %}'>{% trans "Delete" %}</a></li>
|
||||
</ul>
|
||||
@ -868,6 +868,7 @@
|
||||
|
||||
constructForm(url, {
|
||||
fields: {
|
||||
filename: {},
|
||||
comment: {},
|
||||
},
|
||||
title: '{% trans "Edit Attachment" %}',
|
||||
|
@ -1,8 +1,24 @@
|
||||
{% extends "base.html" %}
|
||||
{% extends "part/part_app_base.html" %}
|
||||
{% load inventree_extras %}
|
||||
{% load i18n %}
|
||||
{% 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 %}
|
||||
<div class='panel panel-default panel-inventree'>
|
||||
<div class='panel-heading'>
|
||||
@ -54,4 +70,9 @@
|
||||
{% block js_ready %}
|
||||
{{ block.super }}
|
||||
|
||||
enableNavbar({
|
||||
label: 'part',
|
||||
toggleId: '#part-menu-toggle',
|
||||
});
|
||||
|
||||
{% endblock %}
|
@ -6,6 +6,7 @@ over and above the built-in Django tags.
|
||||
|
||||
import os
|
||||
import sys
|
||||
from django.utils.html import format_html
|
||||
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from django.conf import settings as djangosettings
|
||||
@ -262,6 +263,26 @@ def get_available_themes(*args, **kwargs):
|
||||
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
|
||||
def keyvalue(dict, key):
|
||||
"""
|
||||
|
@ -129,6 +129,7 @@ class PartAPITest(InvenTreeAPITestCase):
|
||||
'location',
|
||||
'bom',
|
||||
'test_templates',
|
||||
'company',
|
||||
]
|
||||
|
||||
roles = [
|
||||
@ -465,6 +466,128 @@ class PartAPITest(InvenTreeAPITestCase):
|
||||
self.assertFalse(response.data['active'])
|
||||
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):
|
||||
"""
|
||||
|
@ -2,6 +2,8 @@
|
||||
Unit testing for BOM export functionality
|
||||
"""
|
||||
|
||||
import csv
|
||||
|
||||
from django.test import TestCase
|
||||
|
||||
from django.urls import reverse
|
||||
@ -47,13 +49,63 @@ class BomExportTest(TestCase):
|
||||
|
||||
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):
|
||||
"""
|
||||
Test BOM download in CSV format
|
||||
"""
|
||||
|
||||
print("URL", self.url)
|
||||
|
||||
params = {
|
||||
'file_format': 'csv',
|
||||
'cascade': True,
|
||||
@ -70,6 +122,47 @@ class BomExportTest(TestCase):
|
||||
content = response.headers['Content-Disposition']
|
||||
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):
|
||||
"""
|
||||
Test BOM download in XLS format
|
||||
|
@ -25,7 +25,7 @@ import common.models
|
||||
from company.serializers import SupplierPartSerializer
|
||||
from part.serializers import PartBriefSerializer
|
||||
from InvenTree.serializers import UserSerializerBrief, InvenTreeModelSerializer
|
||||
from InvenTree.serializers import InvenTreeAttachmentSerializerField
|
||||
from InvenTree.serializers import InvenTreeAttachmentSerializer, InvenTreeAttachmentSerializerField
|
||||
|
||||
|
||||
class LocationBriefSerializer(InvenTreeModelSerializer):
|
||||
@ -253,7 +253,7 @@ class LocationSerializer(InvenTreeModelSerializer):
|
||||
]
|
||||
|
||||
|
||||
class StockItemAttachmentSerializer(InvenTreeModelSerializer):
|
||||
class StockItemAttachmentSerializer(InvenTreeAttachmentSerializer):
|
||||
""" Serializer for StockItemAttachment model """
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
@ -277,6 +277,7 @@ class StockItemAttachmentSerializer(InvenTreeModelSerializer):
|
||||
'pk',
|
||||
'stock_item',
|
||||
'attachment',
|
||||
'filename',
|
||||
'comment',
|
||||
'upload_date',
|
||||
'user',
|
||||
|
@ -215,6 +215,7 @@
|
||||
|
||||
constructForm(url, {
|
||||
fields: {
|
||||
filename: {},
|
||||
comment: {},
|
||||
},
|
||||
title: '{% trans "Edit Attachment" %}',
|
||||
|
@ -2,16 +2,17 @@
|
||||
// InvenTree settings
|
||||
|
||||
{% user_settings request.user as USER_SETTINGS %}
|
||||
{% global_settings as GLOBAL_SETTINGS %}
|
||||
|
||||
var user_settings = {
|
||||
{% for setting in USER_SETTINGS %}
|
||||
{{ setting.key }}: {{ setting.value }},
|
||||
{% for key, value in USER_SETTINGS.items %}
|
||||
{{ key }}: {% primitive_to_javascript value %},
|
||||
{% endfor %}
|
||||
};
|
||||
|
||||
{% global_settings as GLOBAL_SETTINGS %}
|
||||
|
||||
var global_settings = {
|
||||
{% for setting in GLOBAL_SETTINGS %}
|
||||
{{ setting.key }}: {{ setting.value }},
|
||||
{% for key, value in GLOBAL_SETTINGS.items %}
|
||||
{{ key }}: {% primitive_to_javascript value %},
|
||||
{% endfor %}
|
||||
};
|
@ -42,9 +42,32 @@ function loadAttachmentTable(url, options) {
|
||||
title: '{% trans "File" %}',
|
||||
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,
|
||||
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;
|
||||
|
||||
if (value == null || value <= 0) {
|
||||
|
@ -264,6 +264,10 @@ function constructForm(url, options) {
|
||||
// Default HTTP method
|
||||
options.method = options.method || 'PATCH';
|
||||
|
||||
// Default "groups" definition
|
||||
options.groups = options.groups || {};
|
||||
options.current_group = null;
|
||||
|
||||
// Construct an "empty" data object if not provided
|
||||
if (!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
|
||||
for(field in fields) {
|
||||
fields[field].name = field;
|
||||
@ -379,52 +390,18 @@ function constructFormBody(fields, options) {
|
||||
// Override existing query filters (if provided!)
|
||||
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
|
||||
fields[field].before = field_options.before;
|
||||
fields[field].after = field_options.after;
|
||||
var val = field_options[opt];
|
||||
|
||||
// Secondary modal options
|
||||
fields[field].secondary = field_options.secondary;
|
||||
|
||||
// Edit callback
|
||||
fields[field].onEdit = field_options.onEdit;
|
||||
|
||||
fields[field].multiline = field_options.multiline;
|
||||
|
||||
// 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;
|
||||
if (opt == 'filters') {
|
||||
// ignore filters (see above)
|
||||
} else if (opt == 'icon') {
|
||||
// Specify custom icon
|
||||
fields[field].prefix = `<span class='fas ${val}'></span>`;
|
||||
} else {
|
||||
fields[field][opt] = field_options[opt];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -465,8 +442,10 @@ function constructFormBody(fields, options) {
|
||||
html += constructField(name, field, options);
|
||||
}
|
||||
|
||||
// TODO: Dynamically create the modals,
|
||||
// so that we can have an infinite number of stacks!
|
||||
if (options.current_group) {
|
||||
// Close out the current group
|
||||
html += `</div></div>`;
|
||||
}
|
||||
|
||||
// Create a new modal if one does not exists
|
||||
if (!options.modal) {
|
||||
@ -535,6 +514,11 @@ function constructFormBody(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');
|
||||
|
||||
// TODO: Display the JSON error text when hovering over the "info" icon
|
||||
non_field_errors.append(
|
||||
`<div class='alert alert-block alert-danger'>
|
||||
<b>{% trans "Form errors exist" %}</b>
|
||||
<span id='form-errors-info' class='float-right fas fa-info-circle icon-red'>
|
||||
</span>
|
||||
</div>`
|
||||
);
|
||||
|
||||
@ -883,6 +870,8 @@ function handleFormErrors(errors, fields, options) {
|
||||
}
|
||||
}
|
||||
|
||||
var first_error_field = null;
|
||||
|
||||
for (field_name in errors) {
|
||||
|
||||
// Add the 'has-error' class
|
||||
@ -892,6 +881,10 @@ function handleFormErrors(errors, fields, options) {
|
||||
|
||||
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
|
||||
for (var idx = field_errors.length-1; idx >= 0; idx--) {
|
||||
|
||||
@ -905,6 +898,24 @@ function handleFormErrors(errors, fields, options) {
|
||||
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) {
|
||||
|
||||
$(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) {
|
||||
|
||||
var field_names = options.field_names;
|
||||
@ -1353,6 +1432,8 @@ function renderModelData(name, model, data, parameters, options) {
|
||||
*/
|
||||
function constructField(name, parameters, options) {
|
||||
|
||||
var html = '';
|
||||
|
||||
// Shortcut for simple visual fields
|
||||
if (parameters.type == 'candy') {
|
||||
return constructCandyInput(name, parameters, options);
|
||||
@ -1365,13 +1446,58 @@ function constructField(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';
|
||||
|
||||
if (parameters.errors) {
|
||||
form_classes += ' has-error';
|
||||
}
|
||||
|
||||
var html = '';
|
||||
|
||||
// Optional content to render before the field
|
||||
if (parameters.before) {
|
||||
@ -1428,13 +1554,14 @@ function constructField(name, parameters, options) {
|
||||
html += `</div>`; // input-group
|
||||
}
|
||||
|
||||
// Div for error messages
|
||||
html += `<div id='errors-${name}'></div>`;
|
||||
|
||||
if (parameters.help_text) {
|
||||
html += constructHelpText(name, parameters, options);
|
||||
}
|
||||
|
||||
// Div for error messages
|
||||
html += `<div id='errors-${name}'></div>`;
|
||||
|
||||
|
||||
html += `</div>`; // controls
|
||||
html += `</div>`; // form-group
|
||||
|
||||
@ -1599,6 +1726,10 @@ function constructInputOptions(name, classes, type, parameters) {
|
||||
opts.push(`placeholder='${parameters.placeholder}'`);
|
||||
}
|
||||
|
||||
if (parameters.type == 'boolean') {
|
||||
opts.push(`style='display: inline-block; width: 20px; margin-right: 20px;'`);
|
||||
}
|
||||
|
||||
if (parameters.multiline) {
|
||||
return `<textarea ${opts.join(' ')}></textarea>`;
|
||||
} else {
|
||||
@ -1772,7 +1903,13 @@ function constructCandyInput(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;
|
||||
}
|
@ -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
|
||||
function partFields(options={}) {
|
||||
|
||||
@ -48,36 +73,44 @@ function partFields(options={}) {
|
||||
minimum_stock: {
|
||||
icon: 'fa-boxes',
|
||||
},
|
||||
attributes: {
|
||||
type: 'candy',
|
||||
html: `<hr><h4><i>{% trans "Part Attributes" %}</i></h4><hr>`
|
||||
},
|
||||
component: {
|
||||
value: global_settings.PART_COMPONENT,
|
||||
group: 'attributes',
|
||||
},
|
||||
assembly: {
|
||||
value: global_settings.PART_ASSEMBLY,
|
||||
group: 'attributes',
|
||||
},
|
||||
is_template: {
|
||||
value: global_settings.PART_TEMPLATE,
|
||||
group: 'attributes',
|
||||
},
|
||||
trackable: {
|
||||
value: global_settings.PART_TRACKABLE,
|
||||
group: 'attributes',
|
||||
},
|
||||
purchaseable: {
|
||||
value: global_settings.PART_PURCHASEABLE,
|
||||
group: 'attributes',
|
||||
onEdit: function(value, name, field, options) {
|
||||
setFormGroupVisibility('supplier', value, options);
|
||||
}
|
||||
},
|
||||
salable: {
|
||||
value: global_settings.PART_SALABLE,
|
||||
group: 'attributes',
|
||||
},
|
||||
virtual: {
|
||||
value: global_settings.PART_VIRTUAL,
|
||||
group: 'attributes',
|
||||
},
|
||||
};
|
||||
|
||||
// If editing a part, we can set the "active" status
|
||||
if (options.edit) {
|
||||
fields.active = {};
|
||||
fields.active = {
|
||||
group: 'attributes'
|
||||
};
|
||||
}
|
||||
|
||||
// Pop expiry field
|
||||
@ -91,16 +124,32 @@ function partFields(options={}) {
|
||||
// No supplier parts available yet
|
||||
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) {
|
||||
|
||||
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',
|
||||
value: 1,
|
||||
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" %}',
|
||||
help_text: '{% trans "Copy parameter templates from selected part category" %}',
|
||||
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
|
||||
if (options.duplicate) {
|
||||
|
||||
fields.duplicate = {
|
||||
type: 'candy',
|
||||
html: `<hr><h4><i>{% trans "Part Duplication Options" %}</i></h4><hr>`,
|
||||
};
|
||||
|
||||
fields.copy_from = {
|
||||
type: 'integer',
|
||||
hidden: true,
|
||||
value: options.duplicate,
|
||||
group: 'duplicate',
|
||||
},
|
||||
|
||||
fields.copy_image = {
|
||||
@ -131,6 +224,7 @@ function partFields(options={}) {
|
||||
label: '{% trans "Copy Image" %}',
|
||||
help_text: '{% trans "Copy image from original part" %}',
|
||||
value: true,
|
||||
group: 'duplicate',
|
||||
},
|
||||
|
||||
fields.copy_bom = {
|
||||
@ -138,6 +232,7 @@ function partFields(options={}) {
|
||||
label: '{% trans "Copy BOM" %}',
|
||||
help_text: '{% trans "Copy bill of materials from original part" %}',
|
||||
value: global_settings.PART_COPY_BOM,
|
||||
group: 'duplicate',
|
||||
};
|
||||
|
||||
fields.copy_parameters = {
|
||||
@ -145,6 +240,7 @@ function partFields(options={}) {
|
||||
label: '{% trans "Copy Parameters" %}',
|
||||
help_text: '{% trans "Copy parameter data from original part" %}',
|
||||
value: global_settings.PART_COPY_PARAMETERS,
|
||||
group: 'duplicate',
|
||||
};
|
||||
}
|
||||
|
||||
@ -191,8 +287,11 @@ function editPart(pk, options={}) {
|
||||
edit: true
|
||||
});
|
||||
|
||||
var groups = partGroups({});
|
||||
|
||||
constructForm(url, {
|
||||
fields: fields,
|
||||
groups: partGroups(),
|
||||
title: '{% trans "Edit Part" %}',
|
||||
reload: true,
|
||||
});
|
||||
@ -221,6 +320,7 @@ function duplicatePart(pk, options={}) {
|
||||
constructForm('{% url "api-part-list" %}', {
|
||||
method: 'POST',
|
||||
fields: fields,
|
||||
groups: partGroups(),
|
||||
title: '{% trans "Duplicate Part" %}',
|
||||
data: data,
|
||||
onSuccess: function(data) {
|
||||
@ -400,7 +500,7 @@ function loadPartVariantTable(table, partId, options={}) {
|
||||
field: 'in_stock',
|
||||
title: '{% trans "Stock" %}',
|
||||
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() {
|
||||
var selections = $(table).bootstrapTable("getSelections");
|
||||
|
||||
|
@ -1066,7 +1066,7 @@ function loadStockTable(table, options) {
|
||||
return '-';
|
||||
}
|
||||
|
||||
var link = `/supplier-part/${row.supplier_part}/stock/`;
|
||||
var link = `/supplier-part/${row.supplier_part}/?display=stock`;
|
||||
|
||||
var text = '';
|
||||
|
||||
|
@ -35,51 +35,48 @@ ENV INVENTREE_SECRET_KEY_FILE="${INVENTREE_DATA_DIR}/secret_key.txt"
|
||||
ENV INVENTREE_GUNICORN_WORKERS="4"
|
||||
ENV INVENTREE_BACKGROUND_WORKERS="4"
|
||||
|
||||
# Default web server port is 8000
|
||||
ENV INVENTREE_WEB_PORT="8000"
|
||||
# Default web server address:port
|
||||
ENV INVENTREE_WEB_ADDR=0.0.0.0
|
||||
ENV INVENTREE_WEB_PORT=8000
|
||||
|
||||
LABEL org.label-schema.schema-version="1.0" \
|
||||
org.label-schema.build-date=${DATE} \
|
||||
org.label-schema.vendor="inventree" \
|
||||
org.label-schema.name="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_REPO} \
|
||||
org.label-schema.vcs-branch=${BRANCH} \
|
||||
org.label-schema.vcs-ref=${COMMIT}
|
||||
org.label-schema.vcs-url=${INVENTREE_GIT_REPO} \
|
||||
org.label-schema.vcs-branch=${INVENTREE_GIT_BRANCH} \
|
||||
org.label-schema.vcs-ref=${INVENTREE_GIT_TAG}
|
||||
|
||||
# Create user account
|
||||
RUN addgroup -S inventreegroup && adduser -S inventree -G inventreegroup
|
||||
|
||||
WORKDIR ${INVENTREE_HOME}
|
||||
|
||||
# Install required system packages
|
||||
RUN apk add --no-cache git make bash \
|
||||
gcc libgcc g++ libstdc++ \
|
||||
libjpeg-turbo libjpeg-turbo-dev jpeg jpeg-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)
|
||||
RUN apk add --no-cache cairo cairo-dev pango pango-dev
|
||||
RUN apk add --no-cache fontconfig ttf-droid ttf-liberation ttf-dejavu ttf-opensans ttf-ubuntu-font-family font-croscore font-noto
|
||||
|
||||
# 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
|
||||
# Install required base-level python packages
|
||||
COPY requirements.txt requirements.txt
|
||||
RUN pip install --no-cache-dir -U -r requirements.txt
|
||||
|
||||
# Production code (pulled from tagged github release)
|
||||
FROM base as production
|
||||
|
||||
# Clone source code
|
||||
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
|
||||
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
|
||||
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
|
||||
COPY gunicorn.conf.py ${INVENTREE_HOME}/gunicorn.conf.py
|
||||
# Need to be running from within this directory
|
||||
WORKDIR ${INVENTREE_MNG_DIR}
|
||||
|
||||
# Copy startup scripts
|
||||
COPY start_prod_server.sh ${INVENTREE_HOME}/start_prod_server.sh
|
||||
COPY start_prod_worker.sh ${INVENTREE_HOME}/start_prod_worker.sh
|
||||
# Server init entrypoint
|
||||
ENTRYPOINT ["/bin/bash", "../docker/init.sh"]
|
||||
|
||||
RUN chmod 755 ${INVENTREE_HOME}/start_prod_server.sh
|
||||
RUN chmod 755 ${INVENTREE_HOME}/start_prod_worker.sh
|
||||
|
||||
WORKDIR ${INVENTREE_HOME}
|
||||
|
||||
# Let us begin
|
||||
CMD ["bash", "./start_prod_server.sh"]
|
||||
# Launch the production server
|
||||
# TODO: Work out why environment variables cannot be interpolated in this command
|
||||
# TODO: e.g. -b ${INVENTREE_WEB_ADDR}:${INVENTREE_WEB_PORT} fails here
|
||||
CMD gunicorn -c ./docker/gunicorn.conf.py InvenTree.wsgi -b 0.0.0.0:8000 --chdir ./InvenTree
|
||||
|
||||
FROM base as dev
|
||||
|
||||
# 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
|
||||
|
||||
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
|
||||
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}
|
||||
|
||||
# Entrypoint ensures that we are running in the python virtual environment
|
||||
ENTRYPOINT ["/bin/bash", "./docker/init.sh"]
|
||||
|
||||
# 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_NAME=/home/inventree/dev/inventree_db.sqlite3
|
||||
INVENTREE_MEDIA_ROOT=/home/inventree/dev/media
|
||||
INVENTREE_STATIC_ROOT=/home/inventree/dev/static
|
||||
INVENTREE_CONFIG_FILE=/home/inventree/dev/config.yaml
|
||||
INVENTREE_SECRET_KEY_FILE=/home/inventree/dev/secret_key.txt
|
||||
INVENTREE_DEBUG=true
|
||||
INVENTREE_WEB_ADDR=0.0.0.0
|
||||
INVENTREE_WEB_PORT=8000
|
||||
# INVENTREE_DB_HOST=hostaddress
|
||||
# INVENTREE_DB_PORT=5432
|
||||
# INVENTREE_DB_USERNAME=dbuser
|
||||
# INVENTREE_DB_PASSWEORD=dbpassword
|
||||
|
@ -19,6 +19,7 @@ services:
|
||||
context: .
|
||||
target: dev
|
||||
ports:
|
||||
# Expose web server on port 8000
|
||||
- 8000:8000
|
||||
volumes:
|
||||
# Ensure you specify the location of the 'src' directory at the end of this file
|
||||
@ -26,7 +27,6 @@ services:
|
||||
env_file:
|
||||
# Environment variables required for the dev server are configured in dev-config.env
|
||||
- dev-config.env
|
||||
|
||||
restart: unless-stopped
|
||||
|
||||
# Background worker process handles long-running or periodic tasks
|
||||
@ -35,7 +35,7 @@ services:
|
||||
build:
|
||||
context: .
|
||||
target: dev
|
||||
entrypoint: /home/inventree/docker/start_dev_worker.sh
|
||||
command: invoke worker
|
||||
depends_on:
|
||||
- inventree-dev-server
|
||||
volumes:
|
||||
|
@ -21,12 +21,13 @@ services:
|
||||
# just make sure that you change the INVENTREE_DB_xxx vars below
|
||||
inventree-db:
|
||||
container_name: inventree-db
|
||||
image: postgres
|
||||
image: postgres:13
|
||||
ports:
|
||||
- 5432/tcp
|
||||
environment:
|
||||
- PGDATA=/var/lib/postgresql/data/pgdb
|
||||
# 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_PASSWORD=pgpassword
|
||||
volumes:
|
||||
@ -38,6 +39,8 @@ services:
|
||||
# Uses gunicorn as the web server
|
||||
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
|
||||
expose:
|
||||
- 8000
|
||||
@ -46,39 +49,27 @@ services:
|
||||
volumes:
|
||||
# Data volume must map to /home/inventree/data
|
||||
- data:/home/inventree/data
|
||||
environment:
|
||||
# Default environment variables are configured to match the 'db' container
|
||||
# Note: If you change the database image, these will need to be adjusted
|
||||
# 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
|
||||
env_file:
|
||||
# Environment variables required for the production server are configured in prod-config.env
|
||||
- prod-config.env
|
||||
restart: unless-stopped
|
||||
|
||||
# Background worker process handles long-running or periodic tasks
|
||||
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
|
||||
entrypoint: ./start_prod_worker.sh
|
||||
command: invoke worker
|
||||
depends_on:
|
||||
- inventree-db
|
||||
- inventree-server
|
||||
volumes:
|
||||
# Data volume must map to /home/inventree/data
|
||||
- data:/home/inventree/data
|
||||
environment:
|
||||
# Default environment variables are configured to match the 'db' container
|
||||
# Note: If you change the database image, these will need to be adjusted
|
||||
# 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
|
||||
env_file:
|
||||
# Environment variables required for the production server are configured in prod-config.env
|
||||
- prod-config.env
|
||||
restart: unless-stopped
|
||||
|
||||
# nginx acts as a reverse proxy
|
||||
@ -88,7 +79,7 @@ services:
|
||||
# NOTE: You will need to provide a working nginx.conf file!
|
||||
inventree-proxy:
|
||||
container_name: inventree-proxy
|
||||
image: nginx
|
||||
image: nginx:stable
|
||||
depends_on:
|
||||
- inventree-server
|
||||
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==3.2.4 # Django package
|
||||
Django==3.2.4 # Django package
|
||||
|
||||
pillow==8.2.0 # Image manipulation
|
||||
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
|
||||
"""
|
||||
|
||||
c.run('cd "{path}" && python3 manage.py {cmd}'.format(
|
||||
result = c.run('cd "{path}" && python3 manage.py {cmd}'.format(
|
||||
path=managePyDir(),
|
||||
cmd=cmd
|
||||
), pty=pty)
|
||||
@ -80,14 +80,6 @@ def install(c):
|
||||
# Install required Python packages with PIP
|
||||
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
|
||||
def shell(c):
|
||||
@ -97,13 +89,6 @@ def shell(c):
|
||||
|
||||
manage(c, 'shell', pty=True)
|
||||
|
||||
@task
|
||||
def worker(c):
|
||||
"""
|
||||
Run the InvenTree background worker process
|
||||
"""
|
||||
|
||||
manage(c, 'qcluster', pty=True)
|
||||
|
||||
@task
|
||||
def superuser(c):
|
||||
@ -113,6 +98,7 @@ def superuser(c):
|
||||
|
||||
manage(c, 'createsuperuser', pty=True)
|
||||
|
||||
|
||||
@task
|
||||
def check(c):
|
||||
"""
|
||||
@ -121,13 +107,24 @@ def check(c):
|
||||
|
||||
manage(c, "check")
|
||||
|
||||
|
||||
@task
|
||||
def wait(c):
|
||||
"""
|
||||
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
|
||||
def rebuild(c):
|
||||
@ -137,6 +134,7 @@ def rebuild(c):
|
||||
|
||||
manage(c, "rebuild_models")
|
||||
|
||||
|
||||
@task
|
||||
def clean_settings(c):
|
||||
"""
|
||||
@ -145,7 +143,7 @@ def clean_settings(c):
|
||||
|
||||
manage(c, "clean_settings")
|
||||
|
||||
@task
|
||||
@task(post=[rebuild])
|
||||
def migrate(c):
|
||||
"""
|
||||
Performs database migrations.
|
||||
@ -156,7 +154,7 @@ def migrate(c):
|
||||
print("========================================")
|
||||
|
||||
manage(c, "makemigrations")
|
||||
manage(c, "migrate")
|
||||
manage(c, "migrate --noinput")
|
||||
manage(c, "migrate --run-syncdb")
|
||||
manage(c, "check")
|
||||
|
||||
@ -175,22 +173,6 @@ def static(c):
|
||||
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])
|
||||
def translate(c):
|
||||
"""
|
||||
@ -206,7 +188,26 @@ def translate(c):
|
||||
|
||||
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
|
||||
def style(c):
|
||||
@ -217,6 +218,7 @@ def style(c):
|
||||
print("Running PEP style checks...")
|
||||
c.run('flake8 InvenTree')
|
||||
|
||||
|
||||
@task
|
||||
def test(c, database=None):
|
||||
"""
|
||||
@ -228,6 +230,7 @@ def test(c, database=None):
|
||||
# Run coverage tests
|
||||
manage(c, 'test', pty=True)
|
||||
|
||||
|
||||
@task
|
||||
def coverage(c):
|
||||
"""
|
||||
|
Loading…
Reference in New Issue
Block a user