Merge branch 'inventree:master' into bpm-purchase-price

This commit is contained in:
Matthias Mair 2021-08-20 00:42:50 +02:00 committed by GitHub
commit e24a158919
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
47 changed files with 1187 additions and 455 deletions

1
.gitignore vendored
View File

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

View File

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

View File

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

View File

@ -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():
try:
setattr(instance, attr, value) 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,

View File

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

View File

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

View File

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

View File

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

View File

@ -369,6 +369,7 @@ loadAttachmentTable(
constructForm(url, { constructForm(url, {
fields: { fields: {
filename: {},
comment: {}, comment: {},
}, },
onSuccess: reloadAttachmentTable, onSuccess: reloadAttachmentTable,

View File

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

View File

@ -24,21 +24,19 @@
</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-part-order' title='{% trans "Order parts" %}'>{% trans "Order Parts" %}</a></li> <li><a href='#' id='multi-supplier-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-part-delete' title='{% trans "Delete parts" %}'>{% trans "Delete Parts" %}</a></li> <li><a href='#' id='multi-supplier-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) -->
</div> </div>
@ -59,35 +57,33 @@
{% 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-part-order' title='{% trans "Order parts" %}'>{% trans "Order Parts" %}</a></li> <li><a href='#' id='multi-manufacturer-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-part-delete' title='{% trans "Delete parts" %}'>{% trans "Delete Parts" %}</a></li> <li><a href='#' id='multi-manufacturer-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) -->
</div> </div>
</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'

View File

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

View File

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

View File

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

View File

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

View File

@ -115,7 +115,7 @@
{{ block.super }} {{ block.super }}
$('.bomselect').select2({ $('.bomselect').select2({
dropdownAutoWidth: true, width: '100%',
matcher: partialMatcher, matcher: partialMatcher,
}); });

View File

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

View File

@ -112,6 +112,7 @@
constructForm(url, { constructForm(url, {
fields: { fields: {
filename: {},
comment: {}, comment: {},
}, },
onSuccess: reloadAttachmentTable, onSuccess: reloadAttachmentTable,

View File

@ -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:
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( stock_item = StockItem(
part=part, part=part,
quantity=initial_stock, quantity=initial_stock_quantity,
location=part.default_location, location=initial_stock_location,
) )
stock_item.save(user=request.user) 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: except:
pass 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,
manufacturer=manufacturer,
MPN=mpn
)
else:
# No manufacturer part data specified
manufacturer_part = None
if supplier or sku:
if not supplier:
raise ValidationError({
'supplier': [_("This field is required")]
})
if not sku:
raise ValidationError({
'SKU': [_("This field is required")]
})
SupplierPart.objects.create(
part=part,
supplier=supplier,
SKU=sku,
manufacturer_part=manufacturer_part,
)
headers = self.get_success_headers(serializer.data) headers = self.get_success_headers(serializer.data)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -215,6 +215,7 @@
constructForm(url, { constructForm(url, {
fields: { fields: {
filename: {},
comment: {}, comment: {},
}, },
title: '{% trans "Edit Attachment" %}', title: '{% trans "Edit Attachment" %}',

View File

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

View File

@ -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);
} }
}, },
{ {

View File

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

View File

@ -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,14 +1446,59 @@ 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) {
html += parameters.before; html += 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;
} }

View File

@ -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 = { if (global_settings.PART_CREATE_INITIAL) {
type: 'candy',
html: `<hr><h4><i>{% trans "Part Creation Options" %}</i></h4><hr>`, fields.initial_stock = {
type: 'boolean',
label: '{% trans "Create Initial Stock" %}',
help_text: '{% trans "Create an initial stock item for this part" %}',
group: 'create',
}; };
if (global_settings.PART_CREATE_INITIAL) { fields.initial_stock_quantity = {
fields.initial_stock = {
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");

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,9 +1,3 @@
# 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

View File

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