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
dummy_image.*
_tmp.csv
# Sphinx files
docs/_build

View File

@ -32,27 +32,37 @@ class InvenTreeConfig(AppConfig):
logger.info("Starting background tasks...")
# Remove successful task results from the database
InvenTree.tasks.schedule_task(
'InvenTree.tasks.delete_successful_tasks',
schedule_type=Schedule.DAILY,
)
# Check for InvenTree updates
InvenTree.tasks.schedule_task(
'InvenTree.tasks.check_for_updates',
schedule_type=Schedule.DAILY
)
# Heartbeat to let the server know the background worker is running
InvenTree.tasks.schedule_task(
'InvenTree.tasks.heartbeat',
schedule_type=Schedule.MINUTES,
minutes=15
)
# Keep exchange rates up to date
InvenTree.tasks.schedule_task(
'InvenTree.tasks.update_exchange_rates',
schedule_type=Schedule.DAILY,
)
# Remove expired sessions
InvenTree.tasks.schedule_task(
'InvenTree.tasks.delete_expired_sessions',
schedule_type=Schedule.DAILY,
)
def update_exchange_rates(self):
"""
Update exchange rates each time the server is started, *if*:

View File

@ -5,8 +5,10 @@ Generic models which provide extra functionality over base Django model types.
from __future__ import unicode_literals
import os
import logging
from django.db import models
from django.conf import settings
from django.contrib.auth.models import User
from django.contrib.contenttypes.models import ContentType
from django.utils.translation import gettext_lazy as _
@ -21,6 +23,9 @@ from mptt.exceptions import InvalidMove
from .validators import validate_tree_name
logger = logging.getLogger('inventree')
def rename_attachment(instance, filename):
"""
Function for renaming an attachment file.
@ -77,6 +82,72 @@ class InvenTreeAttachment(models.Model):
def basename(self):
return os.path.basename(self.attachment.name)
@basename.setter
def basename(self, fn):
"""
Function to rename the attachment file.
- Filename cannot be empty
- Filename cannot contain illegal characters
- Filename must specify an extension
- Filename cannot match an existing file
"""
fn = fn.strip()
if len(fn) == 0:
raise ValidationError(_('Filename must not be empty'))
attachment_dir = os.path.join(
settings.MEDIA_ROOT,
self.getSubdir()
)
old_file = os.path.join(
settings.MEDIA_ROOT,
self.attachment.name
)
new_file = os.path.join(
settings.MEDIA_ROOT,
self.getSubdir(),
fn
)
new_file = os.path.abspath(new_file)
# Check that there are no directory tricks going on...
if not os.path.dirname(new_file) == attachment_dir:
logger.error(f"Attempted to rename attachment outside valid directory: '{new_file}'")
raise ValidationError(_("Invalid attachment directory"))
# Ignore further checks if the filename is not actually being renamed
if new_file == old_file:
return
forbidden = ["'", '"', "#", "@", "!", "&", "^", "<", ">", ":", ";", "/", "\\", "|", "?", "*", "%", "~", "`"]
for c in forbidden:
if c in fn:
raise ValidationError(_(f"Filename contains illegal character '{c}'"))
if len(fn.split('.')) < 2:
raise ValidationError(_("Filename missing extension"))
if not os.path.exists(old_file):
logger.error(f"Trying to rename attachment '{old_file}' which does not exist")
return
if os.path.exists(new_file):
raise ValidationError(_("Attachment with this filename already exists"))
try:
os.rename(old_file, new_file)
self.attachment.name = os.path.join(self.getSubdir(), fn)
self.save()
except:
raise ValidationError(_("Error renaming file"))
class Meta:
abstract = True

View File

@ -167,6 +167,18 @@ class InvenTreeModelSerializer(serializers.ModelSerializer):
return self.instance
def update(self, instance, validated_data):
"""
Catch any django ValidationError, and re-throw as a DRF ValidationError
"""
try:
instance = super().update(instance, validated_data)
except (ValidationError, DjangoValidationError) as exc:
raise ValidationError(detail=serializers.as_serializer_error(exc))
return instance
def run_validation(self, data=empty):
"""
Perform serializer validation.
@ -188,7 +200,10 @@ class InvenTreeModelSerializer(serializers.ModelSerializer):
# Update instance fields
for attr, value in data.items():
setattr(instance, attr, value)
try:
setattr(instance, attr, value)
except (ValidationError, DjangoValidationError) as exc:
raise ValidationError(detail=serializers.as_serializer_error(exc))
# Run a 'full_clean' on the model.
# Note that by default, DRF does *not* perform full model validation!
@ -208,6 +223,22 @@ class InvenTreeModelSerializer(serializers.ModelSerializer):
return data
class InvenTreeAttachmentSerializer(InvenTreeModelSerializer):
"""
Special case of an InvenTreeModelSerializer, which handles an "attachment" model.
The only real addition here is that we support "renaming" of the attachment file.
"""
# The 'filename' field must be present in the serializer
filename = serializers.CharField(
label=_('Filename'),
required=False,
source='basename',
allow_blank=False,
)
class InvenTreeAttachmentSerializerField(serializers.FileField):
"""
Override the DRF native FileField serializer,

View File

@ -169,6 +169,30 @@ else:
logger.exception(f"Couldn't load keyfile {key_file}")
sys.exit(-1)
# The filesystem location for served static files
STATIC_ROOT = os.path.abspath(
get_setting(
'INVENTREE_STATIC_ROOT',
CONFIG.get('static_root', None)
)
)
if STATIC_ROOT is None:
print("ERROR: INVENTREE_STATIC_ROOT directory not defined")
sys.exit(1)
# The filesystem location for served static files
MEDIA_ROOT = os.path.abspath(
get_setting(
'INVENTREE_MEDIA_ROOT',
CONFIG.get('media_root', None)
)
)
if MEDIA_ROOT is None:
print("ERROR: INVENTREE_MEDIA_ROOT directory is not defined")
sys.exit(1)
# List of allowed hosts (default = allow all)
ALLOWED_HOSTS = CONFIG.get('allowed_hosts', ['*'])
@ -189,22 +213,12 @@ if cors_opt:
# Web URL endpoint for served static files
STATIC_URL = '/static/'
# The filesystem location for served static files
STATIC_ROOT = os.path.abspath(
get_setting(
'INVENTREE_STATIC_ROOT',
CONFIG.get('static_root', '/home/inventree/data/static')
)
)
STATICFILES_DIRS = [
os.path.join(BASE_DIR, 'InvenTree', 'static'),
]
STATICFILES_DIRS = []
# Translated Template settings
STATICFILES_I18_PREFIX = 'i18n'
STATICFILES_I18_SRC = os.path.join(BASE_DIR, 'templates', 'js', 'translated')
STATICFILES_I18_TRG = STATICFILES_DIRS[0] + '_' + STATICFILES_I18_PREFIX
STATICFILES_I18_TRG = os.path.join(BASE_DIR, 'InvenTree', 'static_i18n')
STATICFILES_DIRS.append(STATICFILES_I18_TRG)
STATICFILES_I18_TRG = os.path.join(STATICFILES_I18_TRG, STATICFILES_I18_PREFIX)
@ -218,19 +232,11 @@ STATIC_COLOR_THEMES_DIR = os.path.join(STATIC_ROOT, 'css', 'color-themes')
# Web URL endpoint for served media files
MEDIA_URL = '/media/'
# The filesystem location for served static files
MEDIA_ROOT = os.path.abspath(
get_setting(
'INVENTREE_MEDIA_ROOT',
CONFIG.get('media_root', '/home/inventree/data/media')
)
)
if DEBUG:
logger.info("InvenTree running in DEBUG mode")
logger.info(f"MEDIA_ROOT: '{MEDIA_ROOT}'")
logger.info(f"STATIC_ROOT: '{STATIC_ROOT}'")
logger.debug(f"MEDIA_ROOT: '{MEDIA_ROOT}'")
logger.debug(f"STATIC_ROOT: '{STATIC_ROOT}'")
# Application definition
@ -320,6 +326,7 @@ TEMPLATES = [
'django.template.context_processors.i18n',
'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages',
# Custom InvenTree context processors
'InvenTree.context.health_status',
'InvenTree.context.status_codes',
'InvenTree.context.user_roles',
@ -413,7 +420,7 @@ Configure the database backend based on the user-specified values.
- The following code lets the user "mix and match" database configuration
"""
logger.info("Configuring database backend:")
logger.debug("Configuring database backend:")
# Extract database configuration from the config.yaml file
db_config = CONFIG.get('database', {})
@ -467,11 +474,9 @@ if db_engine in ['sqlite3', 'postgresql', 'mysql']:
db_name = db_config['NAME']
db_host = db_config.get('HOST', "''")
print("InvenTree Database Configuration")
print("================================")
print(f"ENGINE: {db_engine}")
print(f"NAME: {db_name}")
print(f"HOST: {db_host}")
logger.info(f"DB_ENGINE: {db_engine}")
logger.info(f"DB_NAME: {db_name}")
logger.info(f"DB_HOST: {db_host}")
DATABASES['default'] = db_config

View File

@ -640,6 +640,11 @@
z-index: 9999;
}
.modal-error {
border: 2px #FCC solid;
background-color: #f5f0f0;
}
.modal-header {
border-bottom: 1px solid #ddd;
}
@ -730,6 +735,13 @@
padding: 10px;
}
.form-panel {
border-radius: 5px;
border: 1px solid #ccc;
padding: 5px;
}
.modal input {
width: 100%;
}
@ -1037,6 +1049,11 @@ a.anchor {
height: 30px;
}
/* Force minimum width of number input fields to show at least ~5 digits */
input[type='number']{
min-width: 80px;
}
.search-menu {
padding-top: 2rem;
}

View File

@ -36,7 +36,7 @@ def schedule_task(taskname, **kwargs):
# If this task is already scheduled, don't schedule it again
# Instead, update the scheduling parameters
if Schedule.objects.filter(func=taskname).exists():
logger.info(f"Scheduled task '{taskname}' already exists - updating!")
logger.debug(f"Scheduled task '{taskname}' already exists - updating!")
Schedule.objects.filter(func=taskname).update(**kwargs)
else:
@ -204,6 +204,25 @@ def check_for_updates():
)
def delete_expired_sessions():
"""
Remove any expired user sessions from the database
"""
try:
from django.contrib.sessions.models import Session
# Delete any sessions that expired more than a day ago
expired = Session.objects.filter(expire_date__lt=timezone.now() - timedelta(days=1))
if True or expired.count() > 0:
logger.info(f"Deleting {expired.count()} expired sessions.")
expired.delete()
except AppRegistryNotReady:
logger.info("Could not perform 'delete_expired_sessions' - App registry not ready")
def update_exchange_rates():
"""
Update currency exchange rates

View File

@ -10,7 +10,8 @@ from django.db.models import BooleanField
from rest_framework import serializers
from InvenTree.serializers import InvenTreeModelSerializer, InvenTreeAttachmentSerializerField, UserSerializerBrief
from InvenTree.serializers import InvenTreeModelSerializer, InvenTreeAttachmentSerializer
from InvenTree.serializers import InvenTreeAttachmentSerializerField, UserSerializerBrief
from stock.serializers import StockItemSerializerBrief
from stock.serializers import LocationSerializer
@ -158,7 +159,7 @@ class BuildItemSerializer(InvenTreeModelSerializer):
]
class BuildAttachmentSerializer(InvenTreeModelSerializer):
class BuildAttachmentSerializer(InvenTreeAttachmentSerializer):
"""
Serializer for a BuildAttachment
"""
@ -172,6 +173,7 @@ class BuildAttachmentSerializer(InvenTreeModelSerializer):
'pk',
'build',
'attachment',
'filename',
'comment',
'upload_date',
]

View File

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

View File

@ -20,7 +20,6 @@ from djmoney.contrib.exchange.models import convert_money
from djmoney.contrib.exchange.exceptions import MissingRate
from django.utils.translation import ugettext_lazy as _
from django.utils.html import format_html
from django.core.validators import MinValueValidator, URLValidator
from django.core.exceptions import ValidationError
@ -49,55 +48,37 @@ class BaseInvenTreeSetting(models.Model):
are assigned their default values
"""
keys = set()
settings = []
results = cls.objects.all()
if user is not None:
results = results.filter(user=user)
# Query the database
settings = {}
for setting in results:
if setting.key:
settings.append({
"key": setting.key.upper(),
"value": setting.value
})
keys.add(setting.key.upper())
settings[setting.key.upper()] = setting.value
# Specify any "default" values which are not in the database
for key in cls.GLOBAL_SETTINGS.keys():
if key.upper() not in keys:
if key.upper() not in settings:
settings.append({
"key": key.upper(),
"value": cls.get_setting_default(key)
})
# Enforce javascript formatting
for idx, setting in enumerate(settings):
key = setting['key']
value = setting['value']
settings[key.upper()] = cls.get_setting_default(key)
for key, value in settings.items():
validator = cls.get_setting_validator(key)
# Convert to javascript compatible booleans
if cls.validator_is_bool(validator):
value = str(value).lower()
# Numerical values remain the same
value = InvenTree.helpers.str2bool(value)
elif cls.validator_is_int(validator):
pass
try:
value = int(value)
except ValueError:
value = cls.get_setting_default(key)
# Wrap strings with quotes
else:
value = format_html("'{}'", value)
setting["value"] = value
settings[key] = value
return settings

View File

@ -24,19 +24,17 @@
</button>
{% endif %}
<div class='btn-group'>
<div class="dropdown" style="float: right;">
<button class="btn btn-primary dropdown-toggle" type="button" data-toggle="dropdown">{% trans "Options" %}
<span class="caret"></span>
</button>
<ul class="dropdown-menu">
{% if roles.purchase_order.add %}
<li><a href='#' id='multi-part-order' title='{% trans "Order parts" %}'>{% trans "Order Parts" %}</a></li>
{% endif %}
{% if roles.purchase_order.delete %}
<li><a href='#' id='multi-part-delete' title='{% trans "Delete parts" %}'>{% trans "Delete Parts" %}</a></li>
{% endif %}
</ul>
</div>
<button class="btn btn-primary dropdown-toggle" id='supplier-table-options' type="button" data-toggle="dropdown">{% trans "Options" %}
<span class="caret"></span>
</button>
<ul class="dropdown-menu">
{% if roles.purchase_order.add %}
<li><a href='#' id='multi-supplier-part-order' title='{% trans "Order parts" %}'>{% trans "Order Parts" %}</a></li>
{% endif %}
{% if roles.purchase_order.delete %}
<li><a href='#' id='multi-supplier-part-delete' title='{% trans "Delete parts" %}'>{% trans "Delete Parts" %}</a></li>
{% endif %}
</ul>
</div>
</div>
<div class='filter-list' id='filter-list-supplier-part'>
@ -59,27 +57,25 @@
{% if roles.purchase_order.change %}
<div id='manufacturer-part-button-toolbar'>
<div class='button-toolbar container-fluid'>
<div class='btn-group role='group'>
<div class='btn-group' role='group'>
{% if roles.purchase_order.add %}
<button class="btn btn-success" id='manufacturer-part-create' title='{% trans "Create new manufacturer part" %}'>
<button type="button" class="btn btn-success" id='manufacturer-part-create' title='{% trans "Create new manufacturer part" %}'>
<span class='fas fa-plus-circle'></span> {% trans "New Manufacturer Part" %}
</button>
{% endif %}
<div class='btn-group'>
<div class="dropdown" style="float: right;">
<button class="btn btn-primary dropdown-toggle" id='table-options', type="button" data-toggle="dropdown">{% trans "Options" %}
<span class="caret"></span>
</button>
<ul class="dropdown-menu">
{% if roles.purchase_order.add %}
<li><a href='#' id='multi-part-order' title='{% trans "Order parts" %}'>{% trans "Order Parts" %}</a></li>
{% endif %}
{% if roles.purchase_order.delete %}
<li><a href='#' id='multi-part-delete' title='{% trans "Delete parts" %}'>{% trans "Delete Parts" %}</a></li>
{% endif %}
</ul>
</div>
</div>
<div class='btn-group' role='group'>
<button class="btn btn-primary dropdown-toggle" id='manufacturer-table-options' type="button" data-toggle="dropdown">{% trans "Options" %}
<span class="caret"></span>
</button>
<ul class="dropdown-menu">
{% if roles.purchase_order.add %}
<li><a href='#' id='multi-manufacturer-part-order' title='{% trans "Order parts" %}'>{% trans "Order Parts" %}</a></li>
{% endif %}
{% if roles.purchase_order.delete %}
<li><a href='#' id='multi-manufacturer-part-delete' title='{% trans "Delete parts" %}'>{% trans "Delete Parts" %}</a></li>
{% endif %}
</ul>
</div>
</div>
<div class='filter-list' id='filter-list-supplier-part'>
<!-- Empty div (will be filled out with available BOM filters) -->
@ -87,7 +83,7 @@
</div>
</div>
{% endif %}
<table class='table table-striped table-condensed' id='part-table' data-toolbar='#manufacturer-part-button-toolbar'>
<table class='table table-striped table-condensed' id='manufacturer-part-table' data-toolbar='#manufacturer-part-button-toolbar'>
</table>
</div>
</div>
@ -274,6 +270,10 @@
{% if company.is_manufacturer %}
function reloadManufacturerPartTable() {
$('#manufacturer-part-table').bootstrapTable('refresh');
}
$("#manufacturer-part-create").click(function () {
createManufacturerPart({
@ -285,7 +285,7 @@
});
loadManufacturerPartTable(
"#part-table",
"#manufacturer-part-table",
"{% url 'api-manufacturer-part-list' %}",
{
params: {
@ -296,20 +296,20 @@
}
);
linkButtonsToSelection($("#manufacturer-table"), ['#table-options']);
linkButtonsToSelection($("#manufacturer-part-table"), ['#manufacturer-table-options']);
$("#multi-part-delete").click(function() {
var selections = $("#part-table").bootstrapTable("getSelections");
$("#multi-manufacturer-part-delete").click(function() {
var selections = $("#manufacturer-part-table").bootstrapTable("getSelections");
deleteManufacturerParts(selections, {
onSuccess: function() {
$("#part-table").bootstrapTable("refresh");
$("#manufacturer-part-table").bootstrapTable("refresh");
}
});
});
$("#multi-part-order").click(function() {
var selections = $("#part-table").bootstrapTable("getSelections");
$("#multi-manufacturer-part-order").click(function() {
var selections = $("#manufacturer-part-table").bootstrapTable("getSelections");
var parts = [];
@ -353,9 +353,9 @@
}
);
{% endif %}
linkButtonsToSelection($("#supplier-part-table"), ['#supplier-table-options']);
$("#multi-part-delete").click(function() {
$("#multi-supplier-part-delete").click(function() {
var selections = $("#supplier-part-table").bootstrapTable("getSelections");
var requests = [];
@ -379,8 +379,8 @@
);
});
$("#multi-part-order").click(function() {
var selections = $("#part-table").bootstrapTable("getSelections");
$("#multi-supplier-part-order").click(function() {
var selections = $("#supplier-part-table").bootstrapTable("getSelections");
var parts = [];
@ -395,6 +395,8 @@
});
});
{% endif %}
attachNavCallbacks({
name: 'company',
default: 'company-stock'

View File

@ -109,7 +109,7 @@ src="{% static 'img/blank_image.png' %}"
<span class='fas fa-plus-circle'></span> {% trans "New Supplier Part" %}
</button>
<div id='opt-dropdown' class="btn-group">
<button id='supplier-part-options' class="btn btn-primary dropdown-toggle" type="button" data-toggle="dropdown">{% trans "Options" %}<span class="caret"></span></button>
<button id='supplier-part-options' class="btn btn-primary dropdown-toggle" type="button" data-toggle="dropdown">{% trans "Options" %} <span class="caret"></span></button>
<ul class="dropdown-menu">
<li><a href='#' id='supplier-part-delete' title='{% trans "Delete supplier parts" %}'>{% trans "Delete" %}</a></li>
</ul>
@ -133,7 +133,7 @@ src="{% static 'img/blank_image.png' %}"
<span class='fas fa-plus-circle'></span> {% trans "New Parameter" %}
</button>
<div id='opt-dropdown' class="btn-group">
<button id='parameter-options' class="btn btn-primary dropdown-toggle" type="button" data-toggle="dropdown">{% trans "Options" %}<span class="caret"></span></button>
<button id='parameter-options' class="btn btn-primary dropdown-toggle" type="button" data-toggle="dropdown">{% trans "Options" %} <span class="caret"></span></button>
<ul class="dropdown-menu">
<li><a href='#' id='multi-parameter-delete' title='{% trans "Delete parameters" %}'>{% trans "Delete" %}</a></li>
</ul>

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:
unique_together = (
('order', 'part')
('order', 'part', 'quantity', 'purchase_price')
)
def __str__(self):

View File

@ -14,6 +14,7 @@ from rest_framework import serializers
from sql_util.utils import SubqueryCount
from InvenTree.serializers import InvenTreeModelSerializer
from InvenTree.serializers import InvenTreeAttachmentSerializer
from InvenTree.serializers import InvenTreeMoneySerializer
from InvenTree.serializers import InvenTreeAttachmentSerializerField
@ -160,7 +161,7 @@ class POLineItemSerializer(InvenTreeModelSerializer):
]
class POAttachmentSerializer(InvenTreeModelSerializer):
class POAttachmentSerializer(InvenTreeAttachmentSerializer):
"""
Serializers for the PurchaseOrderAttachment model
"""
@ -174,6 +175,7 @@ class POAttachmentSerializer(InvenTreeModelSerializer):
'pk',
'order',
'attachment',
'filename',
'comment',
'upload_date',
]
@ -381,7 +383,7 @@ class SOLineItemSerializer(InvenTreeModelSerializer):
]
class SOAttachmentSerializer(InvenTreeModelSerializer):
class SOAttachmentSerializer(InvenTreeAttachmentSerializer):
"""
Serializers for the SalesOrderAttachment model
"""
@ -395,6 +397,7 @@ class SOAttachmentSerializer(InvenTreeModelSerializer):
'pk',
'order',
'attachment',
'filename',
'comment',
'upload_date',
]

View File

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

View File

@ -122,6 +122,7 @@
constructForm(url, {
fields: {
filename: {},
comment: {},
},
onSuccess: reloadAttachmentTable,
@ -327,7 +328,7 @@ $("#po-table").inventreeTable({
{
sortable: true,
sortName: 'part__MPN',
field: 'supplier_part_detail.MPN',
field: 'supplier_part_detail.manufacturer_part_detail.MPN',
title: '{% trans "MPN" %}',
formatter: function(value, row, index, field) {
if (row.supplier_part_detail && row.supplier_part_detail.manufacturer_part) {

View File

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

View File

@ -9,12 +9,14 @@ from django.conf.urls import url, include
from django.urls import reverse
from django.http import JsonResponse
from django.db.models import Q, F, Count, Min, Max, Avg
from django.db import transaction
from django.utils.translation import ugettext_lazy as _
from rest_framework import status
from rest_framework.response import Response
from rest_framework import filters, serializers
from rest_framework import generics
from rest_framework.exceptions import ValidationError
from django_filters.rest_framework import DjangoFilterBackend
from django_filters import rest_framework as rest_filters
@ -23,7 +25,7 @@ from djmoney.money import Money
from djmoney.contrib.exchange.models import convert_money
from djmoney.contrib.exchange.exceptions import MissingRate
from decimal import Decimal
from decimal import Decimal, InvalidOperation
from .models import Part, PartCategory, BomItem
from .models import PartParameter, PartParameterTemplate
@ -31,7 +33,10 @@ from .models import PartAttachment, PartTestTemplate
from .models import PartSellPriceBreak, PartInternalPriceBreak
from .models import PartCategoryParameterTemplate
from stock.models import StockItem
from company.models import Company, ManufacturerPart, SupplierPart
from stock.models import StockItem, StockLocation
from common.models import InvenTreeSetting
from build.models import Build
@ -630,6 +635,7 @@ class PartList(generics.ListCreateAPIView):
else:
return Response(data)
@transaction.atomic
def create(self, request, *args, **kwargs):
"""
We wish to save the user who created this part!
@ -637,6 +643,8 @@ class PartList(generics.ListCreateAPIView):
Note: Implementation copied from DRF class CreateModelMixin
"""
# TODO: Unit tests for this function!
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
@ -680,21 +688,97 @@ class PartList(generics.ListCreateAPIView):
pass
# Optionally create initial stock item
try:
initial_stock = Decimal(request.data.get('initial_stock', 0))
initial_stock = str2bool(request.data.get('initial_stock', False))
if initial_stock > 0 and part.default_location is not None:
if initial_stock:
try:
stock_item = StockItem(
initial_stock_quantity = Decimal(request.data.get('initial_stock_quantity', ''))
if initial_stock_quantity <= 0:
raise ValidationError({
'initial_stock_quantity': [_('Must be greater than zero')],
})
except (ValueError, InvalidOperation): # Invalid quantity provided
raise ValidationError({
'initial_stock_quantity': [_('Must be a valid quantity')],
})
initial_stock_location = request.data.get('initial_stock_location', None)
try:
initial_stock_location = StockLocation.objects.get(pk=initial_stock_location)
except (ValueError, StockLocation.DoesNotExist):
initial_stock_location = None
if initial_stock_location is None:
if part.default_location is not None:
initial_stock_location = part.default_location
else:
raise ValidationError({
'initial_stock_location': [_('Specify location for initial part stock')],
})
stock_item = StockItem(
part=part,
quantity=initial_stock_quantity,
location=initial_stock_location,
)
stock_item.save(user=request.user)
# Optionally add manufacturer / supplier data to the part
if part.purchaseable and str2bool(request.data.get('add_supplier_info', False)):
try:
manufacturer = Company.objects.get(pk=request.data.get('manufacturer', None))
except:
manufacturer = None
try:
supplier = Company.objects.get(pk=request.data.get('supplier', None))
except:
supplier = None
mpn = str(request.data.get('MPN', '')).strip()
sku = str(request.data.get('SKU', '')).strip()
# Construct a manufacturer part
if manufacturer or mpn:
if not manufacturer:
raise ValidationError({
'manufacturer': [_("This field is required")]
})
if not mpn:
raise ValidationError({
'MPN': [_("This field is required")]
})
manufacturer_part = ManufacturerPart.objects.create(
part=part,
quantity=initial_stock,
location=part.default_location,
manufacturer=manufacturer,
MPN=mpn
)
else:
# No manufacturer part data specified
manufacturer_part = None
stock_item.save(user=request.user)
if supplier or sku:
if not supplier:
raise ValidationError({
'supplier': [_("This field is required")]
})
if not sku:
raise ValidationError({
'SKU': [_("This field is required")]
})
except:
pass
SupplierPart.objects.create(
part=part,
supplier=supplier,
SKU=sku,
manufacturer_part=manufacturer_part,
)
headers = self.get_success_headers(serializer.data)

View File

@ -1,6 +1,7 @@
"""
JSON serializers for Part app
"""
import imghdr
from decimal import Decimal
@ -16,7 +17,9 @@ from djmoney.contrib.django_rest_framework import MoneyField
from InvenTree.serializers import (InvenTreeAttachmentSerializerField,
InvenTreeImageSerializerField,
InvenTreeModelSerializer,
InvenTreeAttachmentSerializer,
InvenTreeMoneySerializer)
from InvenTree.status_codes import BuildStatus, PurchaseOrderStatus
from stock.models import StockItem
@ -51,7 +54,7 @@ class CategorySerializer(InvenTreeModelSerializer):
]
class PartAttachmentSerializer(InvenTreeModelSerializer):
class PartAttachmentSerializer(InvenTreeAttachmentSerializer):
"""
Serializer for the PartAttachment class
"""
@ -65,6 +68,7 @@ class PartAttachmentSerializer(InvenTreeModelSerializer):
'pk',
'part',
'attachment',
'filename',
'comment',
'upload_date',
]

View File

@ -132,12 +132,13 @@
</button>
{% endif %}
<div class='btn-group'>
<button id='part-options' class='btn btn-primary dropdown-toggle' type='button' data-toggle="dropdown">{% trans "Options" %}<span class='caret'></span></button>
<button id='part-options' class='btn btn-primary dropdown-toggle' type='button' data-toggle="dropdown">{% trans "Options" %} <span class='caret'></span></button>
<ul class='dropdown-menu'>
{% if roles.part.change %}
<li><a href='#' id='multi-part-category' title='{% trans "Set category" %}'>{% trans "Set Category" %}</a></li>
{% endif %}
<li><a href='#' id='multi-part-order' title='{% trans "Order parts" %}'>{% trans "Order Parts" %}</a></li>
<li><a href='#' id='multi-part-print-label' title='{% trans "Print Labels" %}'>{% trans "Print Labels" %}</a></li>
<li><a href='#' id='multi-part-export' title='{% trans "Export" %}'>{% trans "Export Data" %}</a></li>
</ul>
</div>
@ -276,6 +277,7 @@
constructForm('{% url "api-part-list" %}', {
method: 'POST',
fields: fields,
groups: partGroups(),
title: '{% trans "Create Part" %}',
onSuccess: function(data) {
// Follow the new part
@ -336,4 +338,4 @@
default: 'part-stock'
});
{% endblock %}
{% endblock %}

View File

@ -289,7 +289,7 @@
<span class='fas fa-plus-circle'></span> {% trans "New Supplier Part" %}
</button>
<div id='opt-dropdown' class="btn-group">
<button id='supplier-part-options' class="btn btn-primary dropdown-toggle" type="button" data-toggle="dropdown">{% trans "Options" %}<span class="caret"></span></button>
<button id='supplier-part-options' class="btn btn-primary dropdown-toggle" type="button" data-toggle="dropdown">{% trans "Options" %} <span class="caret"></span></button>
<ul class="dropdown-menu">
<li><a href='#' id='supplier-part-delete' title='{% trans "Delete supplier parts" %}'>{% trans "Delete" %}</a></li>
</ul>
@ -312,7 +312,7 @@
<span class='fas fa-plus-circle'></span> {% trans "New Manufacturer Part" %}
</button>
<div id='opt-dropdown' class="btn-group">
<button id='manufacturer-part-options' class="btn btn-primary dropdown-toggle" type="button" data-toggle="dropdown">{% trans "Options" %}<span class="caret"></span></button>
<button id='manufacturer-part-options' class="btn btn-primary dropdown-toggle" type="button" data-toggle="dropdown">{% trans "Options" %} <span class="caret"></span></button>
<ul class="dropdown-menu">
<li><a href='#' id='manufacturer-part-delete' title='{% trans "Delete manufacturer parts" %}'>{% trans "Delete" %}</a></li>
</ul>
@ -868,6 +868,7 @@
constructForm(url, {
fields: {
filename: {},
comment: {},
},
title: '{% trans "Edit Attachment" %}',

View File

@ -1,8 +1,24 @@
{% extends "base.html" %}
{% extends "part/part_app_base.html" %}
{% load inventree_extras %}
{% load i18n %}
{% load static %}
{% block menubar %}
<ul class='list-group'>
<li class='list-group-item'>
<a href='#' id='part-menu-toggle'>
<span class='menu-tab-icon fas fa-expand-arrows-alt'></span>
</a>
</li>
<li class='list-group-item' title='{% trans "Return To Parts" %}'>
<a href='{% url "part-index" %}' id='select-upload-file' class='nav-toggle'>
<span class='fas fa-undo side-icon'></span>
{% trans "Return To Parts" %}
</a>
</li>
</ul>
{% endblock %}
{% block content %}
<div class='panel panel-default panel-inventree'>
<div class='panel-heading'>
@ -54,4 +70,9 @@
{% block js_ready %}
{{ block.super }}
enableNavbar({
label: 'part',
toggleId: '#part-menu-toggle',
});
{% endblock %}

View File

@ -6,6 +6,7 @@ over and above the built-in Django tags.
import os
import sys
from django.utils.html import format_html
from django.utils.translation import ugettext_lazy as _
from django.conf import settings as djangosettings
@ -262,6 +263,26 @@ def get_available_themes(*args, **kwargs):
return themes
@register.simple_tag()
def primitive_to_javascript(primitive):
"""
Convert a python primitive to a javascript primitive.
e.g. True -> true
'hello' -> '"hello"'
"""
if type(primitive) is bool:
return str(primitive).lower()
elif type(primitive) in [int, float]:
return primitive
else:
# Wrap with quotes
return format_html("'{}'", primitive)
@register.filter
def keyvalue(dict, key):
"""

View File

@ -129,6 +129,7 @@ class PartAPITest(InvenTreeAPITestCase):
'location',
'bom',
'test_templates',
'company',
]
roles = [
@ -465,6 +466,128 @@ class PartAPITest(InvenTreeAPITestCase):
self.assertFalse(response.data['active'])
self.assertFalse(response.data['purchaseable'])
def test_initial_stock(self):
"""
Tests for initial stock quantity creation
"""
url = reverse('api-part-list')
# Track how many parts exist at the start of this test
n = Part.objects.count()
# Set up required part data
data = {
'category': 1,
'name': "My lil' test part",
'description': 'A part with which to test',
}
# Signal that we want to add initial stock
data['initial_stock'] = True
# Post without a quantity
response = self.post(url, data, expected_code=400)
self.assertIn('initial_stock_quantity', response.data)
# Post with an invalid quantity
data['initial_stock_quantity'] = "ax"
response = self.post(url, data, expected_code=400)
self.assertIn('initial_stock_quantity', response.data)
# Post with a negative quantity
data['initial_stock_quantity'] = -1
response = self.post(url, data, expected_code=400)
self.assertIn('Must be greater than zero', response.data['initial_stock_quantity'])
# Post with a valid quantity
data['initial_stock_quantity'] = 12345
response = self.post(url, data, expected_code=400)
self.assertIn('initial_stock_location', response.data)
# Check that the number of parts has not increased (due to form failures)
self.assertEqual(Part.objects.count(), n)
# Now, set a location
data['initial_stock_location'] = 1
response = self.post(url, data, expected_code=201)
# Check that the part has been created
self.assertEqual(Part.objects.count(), n + 1)
pk = response.data['pk']
new_part = Part.objects.get(pk=pk)
self.assertEqual(new_part.total_stock, 12345)
def test_initial_supplier_data(self):
"""
Tests for initial creation of supplier / manufacturer data
"""
url = reverse('api-part-list')
n = Part.objects.count()
# Set up initial part data
data = {
'category': 1,
'name': 'Buy Buy Buy',
'description': 'A purchaseable part',
'purchaseable': True,
}
# Signal that we wish to create initial supplier data
data['add_supplier_info'] = True
# Specify MPN but not manufacturer
data['MPN'] = 'MPN-123'
response = self.post(url, data, expected_code=400)
self.assertIn('manufacturer', response.data)
# Specify manufacturer but not MPN
del data['MPN']
data['manufacturer'] = 1
response = self.post(url, data, expected_code=400)
self.assertIn('MPN', response.data)
# Specify SKU but not supplier
del data['manufacturer']
data['SKU'] = 'SKU-123'
response = self.post(url, data, expected_code=400)
self.assertIn('supplier', response.data)
# Specify supplier but not SKU
del data['SKU']
data['supplier'] = 1
response = self.post(url, data, expected_code=400)
self.assertIn('SKU', response.data)
# Check that no new parts have been created
self.assertEqual(Part.objects.count(), n)
# Now, fully specify the details
data['SKU'] = 'SKU-123'
data['supplier'] = 3
data['MPN'] = 'MPN-123'
data['manufacturer'] = 6
response = self.post(url, data, expected_code=201)
self.assertEqual(Part.objects.count(), n + 1)
pk = response.data['pk']
new_part = Part.objects.get(pk=pk)
# Check that there is a new manufacturer part *and* a new supplier part
self.assertEqual(new_part.supplier_parts.count(), 1)
self.assertEqual(new_part.manufacturer_parts.count(), 1)
class PartDetailTests(InvenTreeAPITestCase):
"""

View File

@ -2,6 +2,8 @@
Unit testing for BOM export functionality
"""
import csv
from django.test import TestCase
from django.urls import reverse
@ -47,13 +49,63 @@ class BomExportTest(TestCase):
self.url = reverse('bom-download', kwargs={'pk': 100})
def test_bom_template(self):
"""
Test that the BOM template can be downloaded from the server
"""
url = reverse('bom-upload-template')
# Download an XLS template
response = self.client.get(url, data={'format': 'xls'})
self.assertEqual(response.status_code, 200)
self.assertEqual(
response.headers['Content-Disposition'],
'attachment; filename="InvenTree_BOM_Template.xls"'
)
# Return a simple CSV template
response = self.client.get(url, data={'format': 'csv'})
self.assertEqual(response.status_code, 200)
self.assertEqual(
response.headers['Content-Disposition'],
'attachment; filename="InvenTree_BOM_Template.csv"'
)
filename = '_tmp.csv'
with open(filename, 'wb') as f:
f.write(response.getvalue())
with open(filename, 'r') as f:
reader = csv.reader(f, delimiter=',')
for line in reader:
headers = line
break
expected = [
'part_id',
'part_ipn',
'part_name',
'quantity',
'optional',
'overage',
'reference',
'note',
'inherited',
'allow_variants',
]
# Ensure all the expected headers are in the provided file
for header in expected:
self.assertTrue(header in headers)
def test_export_csv(self):
"""
Test BOM download in CSV format
"""
print("URL", self.url)
params = {
'file_format': 'csv',
'cascade': True,
@ -70,6 +122,47 @@ class BomExportTest(TestCase):
content = response.headers['Content-Disposition']
self.assertEqual(content, 'attachment; filename="BOB | Bob | A2_BOM.csv"')
filename = '_tmp.csv'
with open(filename, 'wb') as f:
f.write(response.getvalue())
# Read the file
with open(filename, 'r') as f:
reader = csv.reader(f, delimiter=',')
for line in reader:
headers = line
break
expected = [
'level',
'bom_id',
'parent_part_id',
'parent_part_ipn',
'parent_part_name',
'part_id',
'part_ipn',
'part_name',
'part_description',
'sub_assembly',
'quantity',
'optional',
'overage',
'reference',
'note',
'inherited',
'allow_variants',
'Default Location',
'Available Stock',
]
for header in expected:
self.assertTrue(header in headers)
for header in headers:
self.assertTrue(header in expected)
def test_export_xls(self):
"""
Test BOM download in XLS format

View File

@ -25,7 +25,7 @@ import common.models
from company.serializers import SupplierPartSerializer
from part.serializers import PartBriefSerializer
from InvenTree.serializers import UserSerializerBrief, InvenTreeModelSerializer
from InvenTree.serializers import InvenTreeAttachmentSerializerField
from InvenTree.serializers import InvenTreeAttachmentSerializer, InvenTreeAttachmentSerializerField
class LocationBriefSerializer(InvenTreeModelSerializer):
@ -253,7 +253,7 @@ class LocationSerializer(InvenTreeModelSerializer):
]
class StockItemAttachmentSerializer(InvenTreeModelSerializer):
class StockItemAttachmentSerializer(InvenTreeAttachmentSerializer):
""" Serializer for StockItemAttachment model """
def __init__(self, *args, **kwargs):
@ -277,6 +277,7 @@ class StockItemAttachmentSerializer(InvenTreeModelSerializer):
'pk',
'stock_item',
'attachment',
'filename',
'comment',
'upload_date',
'user',

View File

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

View File

@ -2,16 +2,17 @@
// InvenTree settings
{% user_settings request.user as USER_SETTINGS %}
{% global_settings as GLOBAL_SETTINGS %}
var user_settings = {
{% for setting in USER_SETTINGS %}
{{ setting.key }}: {{ setting.value }},
{% for key, value in USER_SETTINGS.items %}
{{ key }}: {% primitive_to_javascript value %},
{% endfor %}
};
{% global_settings as GLOBAL_SETTINGS %}
var global_settings = {
{% for setting in GLOBAL_SETTINGS %}
{{ setting.key }}: {{ setting.value }},
{% for key, value in GLOBAL_SETTINGS.items %}
{{ key }}: {% primitive_to_javascript value %},
{% endfor %}
};

View File

@ -42,9 +42,32 @@ function loadAttachmentTable(url, options) {
title: '{% trans "File" %}',
formatter: function(value, row) {
var split = value.split('/');
var icon = 'fa-file-alt';
return renderLink(split[split.length - 1], value);
var fn = value.toLowerCase();
if (fn.endsWith('.pdf')) {
icon = 'fa-file-pdf';
} else if (fn.endsWith('.xls') || fn.endsWith('.xlsx')) {
icon = 'fa-file-excel';
} else if (fn.endsWith('.doc') || fn.endsWith('.docx')) {
icon = 'fa-file-word';
} else {
var images = ['.png', '.jpg', '.bmp', '.gif', '.svg', '.tif'];
images.forEach(function (suffix) {
if (fn.endsWith(suffix)) {
icon = 'fa-file-image';
}
});
}
var split = value.split('/');
var filename = split[split.length - 1];
var html = `<span class='fas ${icon}'></span> ${filename}`;
return renderLink(html, value);
}
},
{

View File

@ -252,7 +252,7 @@ function loadBomTable(table, options) {
sortable: true,
formatter: function(value, row, index, field) {
var url = `/part/${row.sub_part_detail.pk}/stock/`;
var url = `/part/${row.sub_part_detail.pk}/?display=stock`;
var text = value;
if (value == null || value <= 0) {

View File

@ -264,6 +264,10 @@ function constructForm(url, options) {
// Default HTTP method
options.method = options.method || 'PATCH';
// Default "groups" definition
options.groups = options.groups || {};
options.current_group = null;
// Construct an "empty" data object if not provided
if (!options.data) {
options.data = {};
@ -362,6 +366,13 @@ function constructFormBody(fields, options) {
}
}
// Initialize an "empty" field for each specified field
for (field in displayed_fields) {
if (!(field in fields)) {
fields[field] = {};
}
}
// Provide each field object with its own name
for(field in fields) {
fields[field].name = field;
@ -379,52 +390,18 @@ function constructFormBody(fields, options) {
// Override existing query filters (if provided!)
fields[field].filters = Object.assign(fields[field].filters || {}, field_options.filters);
// TODO: Refactor the following code with Object.assign (see above)
for (var opt in field_options) {
// "before" and "after" renders
fields[field].before = field_options.before;
fields[field].after = field_options.after;
var val = field_options[opt];
// Secondary modal options
fields[field].secondary = field_options.secondary;
// Edit callback
fields[field].onEdit = field_options.onEdit;
fields[field].multiline = field_options.multiline;
// Custom help_text
if (field_options.help_text) {
fields[field].help_text = field_options.help_text;
}
// Custom label
if (field_options.label) {
fields[field].label = field_options.label;
}
// Custom placeholder
if (field_options.placeholder) {
fields[field].placeholder = field_options.placeholder;
}
// Choices
if (field_options.choices) {
fields[field].choices = field_options.choices;
}
// Field prefix
if (field_options.prefix) {
fields[field].prefix = field_options.prefix;
} else if (field_options.icon) {
// Specify icon like 'fa-user'
fields[field].prefix = `<span class='fas ${field_options.icon}'></span>`;
}
fields[field].hidden = field_options.hidden;
if (field_options.read_only != null) {
fields[field].read_only = field_options.read_only;
if (opt == 'filters') {
// ignore filters (see above)
} else if (opt == 'icon') {
// Specify custom icon
fields[field].prefix = `<span class='fas ${val}'></span>`;
} else {
fields[field][opt] = field_options[opt];
}
}
}
}
@ -465,8 +442,10 @@ function constructFormBody(fields, options) {
html += constructField(name, field, options);
}
// TODO: Dynamically create the modals,
// so that we can have an infinite number of stacks!
if (options.current_group) {
// Close out the current group
html += `</div></div>`;
}
// Create a new modal if one does not exists
if (!options.modal) {
@ -535,6 +514,11 @@ function constructFormBody(fields, options) {
submitFormData(fields, options);
}
});
initializeGroups(fields, options);
// Scroll to the top
$(options.modal).find('.modal-form-content-wrapper').scrollTop(0);
}
@ -860,9 +844,12 @@ function handleFormErrors(errors, fields, options) {
var non_field_errors = $(options.modal).find('#non-field-errors');
// TODO: Display the JSON error text when hovering over the "info" icon
non_field_errors.append(
`<div class='alert alert-block alert-danger'>
<b>{% trans "Form errors exist" %}</b>
<span id='form-errors-info' class='float-right fas fa-info-circle icon-red'>
</span>
</div>`
);
@ -883,6 +870,8 @@ function handleFormErrors(errors, fields, options) {
}
}
var first_error_field = null;
for (field_name in errors) {
// Add the 'has-error' class
@ -892,6 +881,10 @@ function handleFormErrors(errors, fields, options) {
var field_errors = errors[field_name];
if (field_errors && !first_error_field && isFieldVisible(field_name, options)) {
first_error_field = field_name;
}
// Add an entry for each returned error message
for (var idx = field_errors.length-1; idx >= 0; idx--) {
@ -905,6 +898,24 @@ function handleFormErrors(errors, fields, options) {
field_dom.append(html);
}
}
if (first_error_field) {
// Ensure that the field in question is visible
document.querySelector(`#div_id_${field_name}`).scrollIntoView({
behavior: 'smooth',
});
} else {
// Scroll to the top of the form
$(options.modal).find('.modal-form-content-wrapper').scrollTop(0);
}
$(options.modal).find('.modal-content').addClass('modal-error');
}
function isFieldVisible(field, options) {
return $(options.modal).find(`#div_id_${field}`).is(':visible');
}
@ -932,7 +943,10 @@ function addFieldCallbacks(fields, options) {
function addFieldCallback(name, field, options) {
$(options.modal).find(`#id_${name}`).change(function() {
field.onEdit(name, field, options);
var value = getFormFieldValue(name, field, options);
field.onEdit(value, name, field, options);
});
}
@ -960,6 +974,71 @@ function addClearCallback(name, field, options) {
}
// Initialize callbacks and initial states for groups
function initializeGroups(fields, options) {
var modal = options.modal;
// Callback for when the group is expanded
$(modal).find('.form-panel-content').on('show.bs.collapse', function() {
var panel = $(this).closest('.form-panel');
var group = panel.attr('group');
var icon = $(modal).find(`#group-icon-${group}`);
icon.removeClass('fa-angle-right');
icon.addClass('fa-angle-up');
});
// Callback for when the group is collapsed
$(modal).find('.form-panel-content').on('hide.bs.collapse', function() {
var panel = $(this).closest('.form-panel');
var group = panel.attr('group');
var icon = $(modal).find(`#group-icon-${group}`);
icon.removeClass('fa-angle-up');
icon.addClass('fa-angle-right');
});
// Set initial state of each specified group
for (var group in options.groups) {
var group_options = options.groups[group];
if (group_options.collapsed) {
$(modal).find(`#form-panel-content-${group}`).collapse("hide");
} else {
$(modal).find(`#form-panel-content-${group}`).collapse("show");
}
if (group_options.hidden) {
hideFormGroup(group, options);
}
}
}
// Hide a form group
function hideFormGroup(group, options) {
$(options.modal).find(`#form-panel-${group}`).hide();
}
// Show a form group
function showFormGroup(group, options) {
$(options.modal).find(`#form-panel-${group}`).show();
}
function setFormGroupVisibility(group, vis, options) {
if (vis) {
showFormGroup(group, options);
} else {
hideFormGroup(group, options);
}
}
function initializeRelatedFields(fields, options) {
var field_names = options.field_names;
@ -1353,6 +1432,8 @@ function renderModelData(name, model, data, parameters, options) {
*/
function constructField(name, parameters, options) {
var html = '';
// Shortcut for simple visual fields
if (parameters.type == 'candy') {
return constructCandyInput(name, parameters, options);
@ -1365,13 +1446,58 @@ function constructField(name, parameters, options) {
return constructHiddenInput(name, parameters, options);
}
// Are we ending a group?
if (options.current_group && parameters.group != options.current_group) {
html += `</div></div>`;
// Null out the current "group" so we can start a new one
options.current_group = null;
}
// Are we starting a new group?
if (parameters.group) {
var group = parameters.group;
var group_options = options.groups[group] || {};
// Are we starting a new group?
// Add HTML for the start of a separate panel
if (parameters.group != options.current_group) {
html += `
<div class='panel form-panel' id='form-panel-${group}' group='${group}'>
<div class='panel-heading form-panel-heading' id='form-panel-heading-${group}'>`;
if (group_options.collapsible) {
html += `
<div data-toggle='collapse' data-target='#form-panel-content-${group}'>
<a href='#'><span id='group-icon-${group}' class='fas fa-angle-up'></span>
`;
} else {
html += `<div>`;
}
html += `<h4 style='display: inline;'>${group_options.title || group}</h4>`;
if (group_options.collapsible) {
html += `</a>`;
}
html += `
</div></div>
<div class='panel-content form-panel-content' id='form-panel-content-${group}'>
`;
}
// Keep track of the group we are in
options.current_group = group;
}
var form_classes = 'form-group';
if (parameters.errors) {
form_classes += ' has-error';
}
var html = '';
// Optional content to render before the field
if (parameters.before) {
@ -1428,13 +1554,14 @@ function constructField(name, parameters, options) {
html += `</div>`; // input-group
}
// Div for error messages
html += `<div id='errors-${name}'></div>`;
if (parameters.help_text) {
html += constructHelpText(name, parameters, options);
}
// Div for error messages
html += `<div id='errors-${name}'></div>`;
html += `</div>`; // controls
html += `</div>`; // form-group
@ -1599,6 +1726,10 @@ function constructInputOptions(name, classes, type, parameters) {
opts.push(`placeholder='${parameters.placeholder}'`);
}
if (parameters.type == 'boolean') {
opts.push(`style='display: inline-block; width: 20px; margin-right: 20px;'`);
}
if (parameters.multiline) {
return `<textarea ${opts.join(' ')}></textarea>`;
} else {
@ -1772,7 +1903,13 @@ function constructCandyInput(name, parameters, options) {
*/
function constructHelpText(name, parameters, options) {
var html = `<div id='hint_id_${name}' class='help-block'><i>${parameters.help_text}</i></div>`;
var style = '';
if (parameters.type == 'boolean') {
style = `style='display: inline-block; margin-left: 25px' `;
}
var html = `<div id='hint_id_${name}' ${style}class='help-block'><i>${parameters.help_text}</i></div>`;
return html;
}

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
function partFields(options={}) {
@ -48,36 +73,44 @@ function partFields(options={}) {
minimum_stock: {
icon: 'fa-boxes',
},
attributes: {
type: 'candy',
html: `<hr><h4><i>{% trans "Part Attributes" %}</i></h4><hr>`
},
component: {
value: global_settings.PART_COMPONENT,
group: 'attributes',
},
assembly: {
value: global_settings.PART_ASSEMBLY,
group: 'attributes',
},
is_template: {
value: global_settings.PART_TEMPLATE,
group: 'attributes',
},
trackable: {
value: global_settings.PART_TRACKABLE,
group: 'attributes',
},
purchaseable: {
value: global_settings.PART_PURCHASEABLE,
group: 'attributes',
onEdit: function(value, name, field, options) {
setFormGroupVisibility('supplier', value, options);
}
},
salable: {
value: global_settings.PART_SALABLE,
group: 'attributes',
},
virtual: {
value: global_settings.PART_VIRTUAL,
group: 'attributes',
},
};
// If editing a part, we can set the "active" status
if (options.edit) {
fields.active = {};
fields.active = {
group: 'attributes'
};
}
// Pop expiry field
@ -91,16 +124,32 @@ function partFields(options={}) {
// No supplier parts available yet
delete fields["default_supplier"];
fields.create = {
type: 'candy',
html: `<hr><h4><i>{% trans "Part Creation Options" %}</i></h4><hr>`,
};
if (global_settings.PART_CREATE_INITIAL) {
fields.initial_stock = {
type: 'boolean',
label: '{% trans "Create Initial Stock" %}',
help_text: '{% trans "Create an initial stock item for this part" %}',
group: 'create',
};
fields.initial_stock_quantity = {
type: 'decimal',
value: 1,
label: '{% trans "Initial Stock Quantity" %}',
help_text: '{% trans "Initialize part stock with specified quantity" %}',
help_text: '{% trans "Specify initial stock quantity for this part" %}',
group: 'create',
};
// TODO - Allow initial location of stock to be specified
fields.initial_stock_location = {
label: '{% trans "Location" %}',
help_text: '{% trans "Select destination stock location" %}',
type: 'related field',
required: true,
api_url: `/api/stock/location/`,
model: 'stocklocation',
group: 'create',
};
}
@ -109,21 +158,65 @@ function partFields(options={}) {
label: '{% trans "Copy Category Parameters" %}',
help_text: '{% trans "Copy parameter templates from selected part category" %}',
value: global_settings.PART_CATEGORY_PARAMETERS,
group: 'create',
};
// Supplier options
fields.add_supplier_info = {
type: 'boolean',
label: '{% trans "Add Supplier Data" %}',
help_text: '{% trans "Create initial supplier data for this part" %}',
group: 'supplier',
};
fields.supplier = {
type: 'related field',
model: 'company',
label: '{% trans "Supplier" %}',
help_text: '{% trans "Select supplier" %}',
filters: {
'is_supplier': true,
},
api_url: '{% url "api-company-list" %}',
group: 'supplier',
};
fields.SKU = {
type: 'string',
label: '{% trans "SKU" %}',
help_text: '{% trans "Supplier stock keeping unit" %}',
group: 'supplier',
};
fields.manufacturer = {
type: 'related field',
model: 'company',
label: '{% trans "Manufacturer" %}',
help_text: '{% trans "Select manufacturer" %}',
filters: {
'is_manufacturer': true,
},
api_url: '{% url "api-company-list" %}',
group: 'supplier',
};
fields.MPN = {
type: 'string',
label: '{% trans "MPN" %}',
help_text: '{% trans "Manufacturer Part Number" %}',
group: 'supplier',
};
}
// Additional fields when "duplicating" a part
if (options.duplicate) {
fields.duplicate = {
type: 'candy',
html: `<hr><h4><i>{% trans "Part Duplication Options" %}</i></h4><hr>`,
};
fields.copy_from = {
type: 'integer',
hidden: true,
value: options.duplicate,
group: 'duplicate',
},
fields.copy_image = {
@ -131,6 +224,7 @@ function partFields(options={}) {
label: '{% trans "Copy Image" %}',
help_text: '{% trans "Copy image from original part" %}',
value: true,
group: 'duplicate',
},
fields.copy_bom = {
@ -138,6 +232,7 @@ function partFields(options={}) {
label: '{% trans "Copy BOM" %}',
help_text: '{% trans "Copy bill of materials from original part" %}',
value: global_settings.PART_COPY_BOM,
group: 'duplicate',
};
fields.copy_parameters = {
@ -145,6 +240,7 @@ function partFields(options={}) {
label: '{% trans "Copy Parameters" %}',
help_text: '{% trans "Copy parameter data from original part" %}',
value: global_settings.PART_COPY_PARAMETERS,
group: 'duplicate',
};
}
@ -191,8 +287,11 @@ function editPart(pk, options={}) {
edit: true
});
var groups = partGroups({});
constructForm(url, {
fields: fields,
groups: partGroups(),
title: '{% trans "Edit Part" %}',
reload: true,
});
@ -221,6 +320,7 @@ function duplicatePart(pk, options={}) {
constructForm('{% url "api-part-list" %}', {
method: 'POST',
fields: fields,
groups: partGroups(),
title: '{% trans "Duplicate Part" %}',
data: data,
onSuccess: function(data) {
@ -400,7 +500,7 @@ function loadPartVariantTable(table, partId, options={}) {
field: 'in_stock',
title: '{% trans "Stock" %}',
formatter: function(value, row) {
return renderLink(value, `/part/${row.pk}/stock/`);
return renderLink(value, `/part/${row.pk}/?display=stock`);
}
}
];
@ -903,6 +1003,18 @@ function loadPartTable(table, url, options={}) {
});
});
$('#multi-part-print-label').click(function() {
var selections = $(table).bootstrapTable('getSelections');
var items = [];
selections.forEach(function(item) {
items.push(item.pk);
});
printPartLabels(items);
});
$('#multi-part-export').click(function() {
var selections = $(table).bootstrapTable("getSelections");

View File

@ -1066,7 +1066,7 @@ function loadStockTable(table, options) {
return '-';
}
var link = `/supplier-part/${row.supplier_part}/stock/`;
var link = `/supplier-part/${row.supplier_part}/?display=stock`;
var text = '';

View File

@ -35,51 +35,48 @@ ENV INVENTREE_SECRET_KEY_FILE="${INVENTREE_DATA_DIR}/secret_key.txt"
ENV INVENTREE_GUNICORN_WORKERS="4"
ENV INVENTREE_BACKGROUND_WORKERS="4"
# Default web server port is 8000
ENV INVENTREE_WEB_PORT="8000"
# Default web server address:port
ENV INVENTREE_WEB_ADDR=0.0.0.0
ENV INVENTREE_WEB_PORT=8000
LABEL org.label-schema.schema-version="1.0" \
org.label-schema.build-date=${DATE} \
org.label-schema.vendor="inventree" \
org.label-schema.name="inventree/inventree" \
org.label-schema.url="https://hub.docker.com/r/inventree/inventree" \
org.label-schema.version=${INVENTREE_VERSION} \
org.label-schema.vcs-url=${INVENTREE_REPO} \
org.label-schema.vcs-branch=${BRANCH} \
org.label-schema.vcs-ref=${COMMIT}
org.label-schema.vcs-url=${INVENTREE_GIT_REPO} \
org.label-schema.vcs-branch=${INVENTREE_GIT_BRANCH} \
org.label-schema.vcs-ref=${INVENTREE_GIT_TAG}
# Create user account
RUN addgroup -S inventreegroup && adduser -S inventree -G inventreegroup
WORKDIR ${INVENTREE_HOME}
# Install required system packages
RUN apk add --no-cache git make bash \
gcc libgcc g++ libstdc++ \
libjpeg-turbo libjpeg-turbo-dev jpeg jpeg-dev \
libffi libffi-dev \
zlib zlib-dev
zlib zlib-dev \
# Cairo deps for WeasyPrint (these will be deprecated once WeasyPrint drops cairo requirement)
cairo cairo-dev pango pango-dev \
# Fonts
fontconfig ttf-droid ttf-liberation ttf-dejavu ttf-opensans ttf-ubuntu-font-family font-croscore font-noto \
# Core python
python3 python3-dev py3-pip \
# SQLite support
sqlite \
# PostgreSQL support
postgresql postgresql-contrib postgresql-dev libpq \
# MySQL/MariaDB support
mariadb-connector-c mariadb-dev mariadb-client
# Cairo deps for WeasyPrint (these will be deprecated once WeasyPrint drops cairo requirement)
RUN apk add --no-cache cairo cairo-dev pango pango-dev
RUN apk add --no-cache fontconfig ttf-droid ttf-liberation ttf-dejavu ttf-opensans ttf-ubuntu-font-family font-croscore font-noto
# Python
RUN apk add --no-cache python3 python3-dev py3-pip
# SQLite support
RUN apk add --no-cache sqlite
# PostgreSQL support
RUN apk add --no-cache postgresql postgresql-contrib postgresql-dev libpq
# MySQL support
RUN apk add --no-cache mariadb-connector-c mariadb-dev mariadb-client
# Install required python packages
RUN pip install --no-cache-dir -U psycopg2 mysqlclient pgcli mariadb
# Install required base-level python packages
COPY requirements.txt requirements.txt
RUN pip install --no-cache-dir -U -r requirements.txt
# Production code (pulled from tagged github release)
FROM base as production
# Clone source code
RUN echo "Downloading InvenTree from ${INVENTREE_GIT_REPO}"
@ -88,30 +85,35 @@ RUN git clone --branch ${INVENTREE_GIT_BRANCH} --depth 1 ${INVENTREE_GIT_REPO} $
# Checkout against a particular git tag
RUN if [ -n "${INVENTREE_GIT_TAG}" ] ; then cd ${INVENTREE_HOME} && git fetch --all --tags && git checkout tags/${INVENTREE_GIT_TAG} -b v${INVENTREE_GIT_TAG}-branch ; fi
RUN chown -R inventree:inventreegroup ${INVENTREE_HOME}/*
# Drop to the inventree user
USER inventree
# Install InvenTree packages
RUN pip install --no-cache-dir -U -r ${INVENTREE_HOME}/requirements.txt
RUN pip3 install --no-cache-dir --disable-pip-version-check -r ${INVENTREE_HOME}/requirements.txt
# Copy gunicorn config file
COPY gunicorn.conf.py ${INVENTREE_HOME}/gunicorn.conf.py
# Need to be running from within this directory
WORKDIR ${INVENTREE_MNG_DIR}
# Copy startup scripts
COPY start_prod_server.sh ${INVENTREE_HOME}/start_prod_server.sh
COPY start_prod_worker.sh ${INVENTREE_HOME}/start_prod_worker.sh
# Server init entrypoint
ENTRYPOINT ["/bin/bash", "../docker/init.sh"]
RUN chmod 755 ${INVENTREE_HOME}/start_prod_server.sh
RUN chmod 755 ${INVENTREE_HOME}/start_prod_worker.sh
WORKDIR ${INVENTREE_HOME}
# Let us begin
CMD ["bash", "./start_prod_server.sh"]
# Launch the production server
# TODO: Work out why environment variables cannot be interpolated in this command
# TODO: e.g. -b ${INVENTREE_WEB_ADDR}:${INVENTREE_WEB_PORT} fails here
CMD gunicorn -c ./docker/gunicorn.conf.py InvenTree.wsgi -b 0.0.0.0:8000 --chdir ./InvenTree
FROM base as dev
# The development image requires the source code to be mounted to /home/inventree/
# So from here, we don't actually "do" anything, apart from some file management
ENV INVENTREE_DEV_DIR = "${INVENTREE_HOME}/dev"
ENV INVENTREE_DEV_DIR="${INVENTREE_HOME}/dev"
# Location for python virtual environment
# If the INVENTREE_PY_ENV variable is set, the entrypoint script will use it!
ENV INVENTREE_PY_ENV="${INVENTREE_DEV_DIR}/env"
# Override default path settings
ENV INVENTREE_STATIC_ROOT="${INVENTREE_DEV_DIR}/static"
@ -121,5 +123,9 @@ ENV INVENTREE_SECRET_KEY_FILE="${INVENTREE_DEV_DIR}/secret_key.txt"
WORKDIR ${INVENTREE_HOME}
# Entrypoint ensures that we are running in the python virtual environment
ENTRYPOINT ["/bin/bash", "./docker/init.sh"]
# Launch the development server
CMD ["bash", "/home/inventree/docker/start_dev_server.sh"]
CMD ["invoke", "server", "-a", "${INVENTREE_WEB_ADDR}:${INVENTREE_WEB_PORT}"]

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_NAME=/home/inventree/dev/inventree_db.sqlite3
INVENTREE_MEDIA_ROOT=/home/inventree/dev/media
INVENTREE_STATIC_ROOT=/home/inventree/dev/static
INVENTREE_CONFIG_FILE=/home/inventree/dev/config.yaml
INVENTREE_SECRET_KEY_FILE=/home/inventree/dev/secret_key.txt
INVENTREE_DEBUG=true
INVENTREE_WEB_ADDR=0.0.0.0
INVENTREE_WEB_PORT=8000
# INVENTREE_DB_HOST=hostaddress
# INVENTREE_DB_PORT=5432
# INVENTREE_DB_USERNAME=dbuser
# INVENTREE_DB_PASSWEORD=dbpassword

View File

@ -19,6 +19,7 @@ services:
context: .
target: dev
ports:
# Expose web server on port 8000
- 8000:8000
volumes:
# Ensure you specify the location of the 'src' directory at the end of this file
@ -26,7 +27,6 @@ services:
env_file:
# Environment variables required for the dev server are configured in dev-config.env
- dev-config.env
restart: unless-stopped
# Background worker process handles long-running or periodic tasks
@ -35,7 +35,7 @@ services:
build:
context: .
target: dev
entrypoint: /home/inventree/docker/start_dev_worker.sh
command: invoke worker
depends_on:
- inventree-dev-server
volumes:

View File

@ -21,12 +21,13 @@ services:
# just make sure that you change the INVENTREE_DB_xxx vars below
inventree-db:
container_name: inventree-db
image: postgres
image: postgres:13
ports:
- 5432/tcp
environment:
- PGDATA=/var/lib/postgresql/data/pgdb
# The pguser and pgpassword values must be the same in the other containers
# Ensure that these are correctly configured in your prod-config.env file
- POSTGRES_USER=pguser
- POSTGRES_PASSWORD=pgpassword
volumes:
@ -38,6 +39,8 @@ services:
# Uses gunicorn as the web server
inventree-server:
container_name: inventree-server
# If you wish to specify a particular InvenTree version, do so here
# e.g. image: inventree/inventree:0.5.2
image: inventree/inventree:latest
expose:
- 8000
@ -46,39 +49,27 @@ services:
volumes:
# Data volume must map to /home/inventree/data
- data:/home/inventree/data
environment:
# Default environment variables are configured to match the 'db' container
# Note: If you change the database image, these will need to be adjusted
# Note: INVENTREE_DB_HOST should match the container name of the database
- INVENTREE_DB_USER=pguser
- INVENTREE_DB_PASSWORD=pgpassword
- INVENTREE_DB_ENGINE=postgresql
- INVENTREE_DB_NAME=inventree
- INVENTREE_DB_HOST=inventree-db
- INVENTREE_DB_PORT=5432
env_file:
# Environment variables required for the production server are configured in prod-config.env
- prod-config.env
restart: unless-stopped
# Background worker process handles long-running or periodic tasks
inventree-worker:
container_name: inventree-worker
# If you wish to specify a particular InvenTree version, do so here
# e.g. image: inventree/inventree:0.5.2
image: inventree/inventree:latest
entrypoint: ./start_prod_worker.sh
command: invoke worker
depends_on:
- inventree-db
- inventree-server
volumes:
# Data volume must map to /home/inventree/data
- data:/home/inventree/data
environment:
# Default environment variables are configured to match the 'db' container
# Note: If you change the database image, these will need to be adjusted
# Note: INVENTREE_DB_HOST should match the container name of the database
- INVENTREE_DB_USER=pguser
- INVENTREE_DB_PASSWORD=pgpassword
- INVENTREE_DB_ENGINE=postgresql
- INVENTREE_DB_NAME=inventree
- INVENTREE_DB_HOST=inventree-db
- INVENTREE_DB_PORT=5432
env_file:
# Environment variables required for the production server are configured in prod-config.env
- prod-config.env
restart: unless-stopped
# nginx acts as a reverse proxy
@ -88,7 +79,7 @@ services:
# NOTE: You will need to provide a working nginx.conf file!
inventree-proxy:
container_name: inventree-proxy
image: nginx
image: nginx:stable
depends_on:
- inventree-server
ports:

42
docker/init.sh Normal file
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,11 +1,5 @@
# Basic package requirements
setuptools>=57.4.0
wheel>=0.37.0
invoke>=1.4.0 # Invoke build tool
gunicorn>=20.1.0 # Gunicorn web server
# Django framework
Django==3.2.4 # Django package
Django==3.2.4 # Django package
pillow==8.2.0 # Image manipulation
djangorestframework==3.12.4 # DRF framework

View File

@ -65,7 +65,7 @@ def manage(c, cmd, pty=False):
cmd - django command to run
"""
c.run('cd "{path}" && python3 manage.py {cmd}'.format(
result = c.run('cd "{path}" && python3 manage.py {cmd}'.format(
path=managePyDir(),
cmd=cmd
), pty=pty)
@ -80,14 +80,6 @@ def install(c):
# Install required Python packages with PIP
c.run('pip3 install -U -r requirements.txt')
# If a config.yaml file does not exist, copy from the template!
CONFIG_FILE = os.path.join(localDir(), 'InvenTree', 'config.yaml')
CONFIG_TEMPLATE_FILE = os.path.join(localDir(), 'InvenTree', 'config_template.yaml')
if not os.path.exists(CONFIG_FILE):
print("Config file 'config.yaml' does not exist - copying from template.")
copyfile(CONFIG_TEMPLATE_FILE, CONFIG_FILE)
@task
def shell(c):
@ -97,13 +89,6 @@ def shell(c):
manage(c, 'shell', pty=True)
@task
def worker(c):
"""
Run the InvenTree background worker process
"""
manage(c, 'qcluster', pty=True)
@task
def superuser(c):
@ -113,6 +98,7 @@ def superuser(c):
manage(c, 'createsuperuser', pty=True)
@task
def check(c):
"""
@ -121,13 +107,24 @@ def check(c):
manage(c, "check")
@task
def wait(c):
"""
Wait until the database connection is ready
"""
manage(c, "wait_for_db")
return manage(c, "wait_for_db")
@task(pre=[wait])
def worker(c):
"""
Run the InvenTree background worker process
"""
manage(c, 'qcluster', pty=True)
@task
def rebuild(c):
@ -137,6 +134,7 @@ def rebuild(c):
manage(c, "rebuild_models")
@task
def clean_settings(c):
"""
@ -145,7 +143,7 @@ def clean_settings(c):
manage(c, "clean_settings")
@task
@task(post=[rebuild])
def migrate(c):
"""
Performs database migrations.
@ -156,7 +154,7 @@ def migrate(c):
print("========================================")
manage(c, "makemigrations")
manage(c, "migrate")
manage(c, "migrate --noinput")
manage(c, "migrate --run-syncdb")
manage(c, "check")
@ -175,22 +173,6 @@ def static(c):
manage(c, "collectstatic --no-input")
@task(pre=[install, migrate, static, clean_settings])
def update(c):
"""
Update InvenTree installation.
This command should be invoked after source code has been updated,
e.g. downloading new code from GitHub.
The following tasks are performed, in order:
- install
- migrate
- static
"""
pass
@task(post=[static])
def translate(c):
"""
@ -206,7 +188,26 @@ def translate(c):
path = os.path.join('InvenTree', 'script', 'translation_stats.py')
c.run(f'python {path}')
c.run(f'python3 {path}')
@task(pre=[install, migrate, translate, clean_settings])
def update(c):
"""
Update InvenTree installation.
This command should be invoked after source code has been updated,
e.g. downloading new code from GitHub.
The following tasks are performed, in order:
- install
- migrate
- translate
- clean_settings
"""
pass
@task
def style(c):
@ -217,6 +218,7 @@ def style(c):
print("Running PEP style checks...")
c.run('flake8 InvenTree')
@task
def test(c, database=None):
"""
@ -228,6 +230,7 @@ def test(c, database=None):
# Run coverage tests
manage(c, 'test', pty=True)
@task
def coverage(c):
"""