2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-06-18 21:15:41 +00:00

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
47 changed files with 1187 additions and 455 deletions

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