Merge branch 'inventree:master' into matmair/issue2385

This commit is contained in:
Matthias Mair 2022-04-27 17:46:57 +02:00 committed by GitHub
commit 37e74074dd
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
72 changed files with 38230 additions and 26790 deletions

View File

@ -4,11 +4,21 @@ InvenTree API version information
# InvenTree API version
INVENTREE_API_VERSION = 40
INVENTREE_API_VERSION = 43
"""
Increment this API version number whenever there is a significant change to the API that any clients need to know about
v43 -> 2022-04-26 : https://github.com/inventree/InvenTree/pull/2875
- Adds API detail endpoint for PartSalePrice model
- Adds API detail endpoint for PartInternalPrice model
v42 -> 2022-04-26 : https://github.com/inventree/InvenTree/pull/2833
- Adds variant stock information to the Part and BomItem serializers
v41 -> 2022-04-26
- Fixes 'variant_of' filter for Part list endpoint
v40 -> 2022-04-19
- Adds ability to filter StockItem list by "tracked" parameter
- This checks the serial number or batch code fields

View File

@ -1,11 +1,11 @@
from django.shortcuts import HttpResponseRedirect
from django.urls import reverse_lazy, Resolver404
from django.db import connection
from django.shortcuts import redirect
from django.conf.urls import include, url
from django.conf import settings
from django.contrib.auth.middleware import PersistentRemoteUserMiddleware
import logging
import time
import operator
from rest_framework.authtoken.models import Token
from allauth_2fa.middleware import BaseRequire2FAMiddleware, AllauthTwoFactorMiddleware
@ -92,67 +92,6 @@ class AuthRequiredMiddleware(object):
return response
class QueryCountMiddleware(object):
"""
This middleware will log the number of queries run
and the total time taken for each request (with a
status code of 200). It does not currently support
multi-db setups.
To enable this middleware, set 'log_queries: True' in the local InvenTree config file.
Reference: https://www.dabapps.com/blog/logging-sql-queries-django-13/
Note: 2020-08-15 - This is no longer used, instead we now rely on the django-debug-toolbar addon
"""
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
t_start = time.time()
response = self.get_response(request)
t_stop = time.time()
if response.status_code == 200:
total_time = 0
if len(connection.queries) > 0:
queries = {}
for query in connection.queries:
query_time = query.get('time')
sql = query.get('sql').split('.')[0]
if sql in queries:
queries[sql] += 1
else:
queries[sql] = 1
if query_time is None:
# django-debug-toolbar monkeypatches the connection
# cursor wrapper and adds extra information in each
# item in connection.queries. The query time is stored
# under the key "duration" rather than "time" and is
# in milliseconds, not seconds.
query_time = float(query.get('duration', 0))
total_time += float(query_time)
logger.debug('{n} queries run, {a:.3f}s / {b:.3f}s'.format(
n=len(connection.queries),
a=total_time,
b=(t_stop - t_start)))
for x in sorted(queries.items(), key=operator.itemgetter(1), reverse=True):
print(x[0], ':', x[1])
return response
url_matcher = url('', include(frontendpatterns))
@ -176,3 +115,16 @@ class CustomAllauthTwoFactorMiddleware(AllauthTwoFactorMiddleware):
super().process_request(request)
except Resolver404:
pass
class InvenTreeRemoteUserMiddleware(PersistentRemoteUserMiddleware):
"""
Middleware to check if HTTP-header based auth is enabled and to set it up
"""
header = settings.REMOTE_LOGIN_HEADER
def process_request(self, request):
if not settings.REMOTE_LOGIN:
return
return super().process_request(request)

View File

@ -25,6 +25,7 @@ import moneyed
import yaml
from django.utils.translation import gettext_lazy as _
from django.contrib.messages import constants as messages
from django.core.files.storage import default_storage
import django.conf.locale
from .config import get_base_dir, get_config_file, get_plugin_file, get_setting
@ -289,6 +290,7 @@ MIDDLEWARE = CONFIG.get('middleware', [
'django.middleware.csrf.CsrfViewMiddleware',
'corsheaders.middleware.CorsMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'InvenTree.middleware.InvenTreeRemoteUserMiddleware', # Remote / proxy auth
'django_otp.middleware.OTPMiddleware', # MFA support
'InvenTree.middleware.CustomAllauthTwoFactorMiddleware', # Flow control for allauth
'django.contrib.messages.middleware.MessageMiddleware',
@ -302,6 +304,7 @@ MIDDLEWARE = CONFIG.get('middleware', [
MIDDLEWARE.append('error_report.middleware.ExceptionProcessor')
AUTHENTICATION_BACKENDS = CONFIG.get('authentication_backends', [
'django.contrib.auth.backends.RemoteUserBackend', # proxy login
'django.contrib.auth.backends.ModelBackend',
'allauth.account.auth_backends.AuthenticationBackend', # SSO login via external providers
])
@ -687,7 +690,8 @@ LANGUAGES = [
('nl', _('Dutch')),
('no', _('Norwegian')),
('pl', _('Polish')),
('pt', _('Portugese')),
('pt', _('Portuguese')),
('pt-BR', _('Portuguese (Brazilian)')),
('ru', _('Russian')),
('sv', _('Swedish')),
('th', _('Thai')),
@ -853,6 +857,10 @@ ACCOUNT_FORMS = {
SOCIALACCOUNT_ADAPTER = 'InvenTree.forms.CustomSocialAccountAdapter'
ACCOUNT_ADAPTER = 'InvenTree.forms.CustomAccountAdapter'
# login settings
REMOTE_LOGIN = get_setting('INVENTREE_REMOTE_LOGIN', CONFIG.get('remote_login', False))
REMOTE_LOGIN_HEADER = get_setting('INVENTREE_REMOTE_LOGIN_HEADER', CONFIG.get('remote_login_header', 'REMOTE_USER'))
# Markdownx configuration
# Ref: https://neutronx.github.io/django-markdownx/customization/
MARKDOWNX_MEDIA_PATH = datetime.now().strftime('markdownx/%Y/%m/%d')
@ -912,3 +920,20 @@ PLUGIN_TESTING = get_setting('PLUGIN_TESTING', TESTING) # are plugins beeing te
PLUGIN_TESTING_SETUP = get_setting('PLUGIN_TESTING_SETUP', False) # load plugins from setup hooks in testing?
PLUGIN_RETRY = get_setting('PLUGIN_RETRY', 5) # how often should plugin loading be tried?
PLUGIN_FILE_CHECKED = False # Was the plugin file checked?
# user interface customization values
CUSTOMIZE = get_setting(
'INVENTREE_CUSTOMIZE',
CONFIG.get('customize', {}),
{}
)
CUSTOM_LOGO = get_setting(
'INVENTREE_CUSTOM_LOGO',
CUSTOMIZE.get('logo', False)
)
# check that the logo-file exsists in media
if CUSTOM_LOGO and not default_storage.exists(CUSTOM_LOGO):
CUSTOM_LOGO = False
logger.warning("The custom logo file could not be found in the default media storage")

View File

@ -871,6 +871,9 @@ class Build(MPTTModel, ReferenceIndexingMixin):
part__in=[p for p in available_parts],
)
# Filter out "serialized" stock items, these cannot be auto-allocated
available_stock = available_stock.filter(Q(serial=None) | Q(serial=''))
if location:
# Filter only stock items located "below" the specified location
sublocations = location.get_descendants(include_self=True)

View File

@ -771,7 +771,7 @@ class InvenTreeSetting(BaseInvenTreeSetting):
},
'INVENTREE_INSTANCE': {
'name': _('InvenTree Instance Name'),
'name': _('Server Instance Name'),
'default': 'InvenTree server',
'description': _('String descriptor for the server instance'),
},
@ -783,6 +783,13 @@ class InvenTreeSetting(BaseInvenTreeSetting):
'default': False,
},
'INVENTREE_RESTRICT_ABOUT': {
'name': _('Restrict showing `about`'),
'description': _('Show the `about` modal only to superusers'),
'validator': bool,
'default': False,
},
'INVENTREE_COMPANY_NAME': {
'name': _('Company name'),
'description': _('Internal company name'),
@ -1019,6 +1026,12 @@ class InvenTreeSetting(BaseInvenTreeSetting):
'validator': bool,
},
'STOCK_BATCH_CODE_TEMPLATE': {
'name': _('Batch Code Template'),
'description': _('Template for generating default batch codes for stock items'),
'default': '',
},
'STOCK_ENABLE_EXPIRY': {
'name': _('Stock Expiry'),
'description': _('Enable stock expiry functionality'),
@ -1426,7 +1439,7 @@ class InvenTreeUserSetting(BaseInvenTreeSetting):
'STICKY_HEADER': {
'name': _('Fixed Navbar'),
'description': _('InvenTree navbar position is fixed to the top of the screen'),
'description': _('The navbar position is fixed to the top of the screen'),
'default': False,
'validator': bool,
},

View File

@ -63,8 +63,8 @@ class SettingsTest(TestCase):
report_test_obj = InvenTreeSetting.get_setting_object('REPORT_ENABLE_TEST_REPORT')
# check settings base fields
self.assertEqual(instance_obj.name, 'InvenTree Instance Name')
self.assertEqual(instance_obj.get_setting_name(instance_ref), 'InvenTree Instance Name')
self.assertEqual(instance_obj.name, 'Server Instance Name')
self.assertEqual(instance_obj.get_setting_name(instance_ref), 'Server Instance Name')
self.assertEqual(instance_obj.description, 'String descriptor for the server instance')
self.assertEqual(instance_obj.get_setting_description(instance_ref), 'String descriptor for the server instance')

View File

@ -1,9 +1,10 @@
{% extends "page_base.html" %}
{% load static %}
{% load i18n %}
{% load inventree_extras %}
{% block page_title %}
InvenTree | {% trans "Manufacturer Part" %}
{% inventree_title %} | {% trans "Manufacturer Part" %}
{% endblock %}
{% block sidebar %}

View File

@ -154,6 +154,14 @@ static_root: '/home/inventree/data/static'
# Use environment variable INVENTREE_LOGIN_ATTEMPTS
#login_attempts: 5
# Remote / proxy login
# These settings can introduce security problems if configured incorrectly. Please read
# https://docs.djangoproject.com/en/4.0/howto/auth-remote-user/ for more details
# Use environment variable INVENTREE_REMOTE_LOGIN
# remote_login: True
# Use environment variable INVENTREE_REMOTE_LOGIN_HEADER
# remote_login_header: REMOTE_USER
# Add new user on first startup
#admin_user: admin
#admin_email: info@example.com
@ -186,3 +194,10 @@ static_root: '/home/inventree/data/static'
# KEYCLOAK_URL: 'https://keycloak.custom/auth'
# KEYCLOAK_REALM: 'master'
# Customization options
# Add custom messages to the login page or main interface navbar or exchange the logo
# Use environment variable INVENTREE_CUSTOMIZE or INVENTREE_CUSTOM_LOGO
# customize:
# login_message: InvenTree demo instance - <a href='https://inventree.readthedocs.io/en/latest/demo/'> Click here for login details</a>
# navbar_message: <h6>InvenTree demo mode <a href='https://inventree.readthedocs.io/en/latest/demo/'><span class='fas fa-info-circle'></span></a></h6>
# logo: logo.png

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

Binary file not shown.

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -262,6 +262,15 @@ class CategoryTree(generics.ListAPIView):
ordering = ['level', 'name']
class PartSalePriceDetail(generics.RetrieveUpdateDestroyAPIView):
"""
Detail endpoint for PartSellPriceBreak model
"""
queryset = PartSellPriceBreak.objects.all()
serializer_class = part_serializers.PartSalePriceSerializer
class PartSalePriceList(generics.ListCreateAPIView):
"""
API endpoint for list view of PartSalePriceBreak model
@ -279,6 +288,15 @@ class PartSalePriceList(generics.ListCreateAPIView):
]
class PartInternalPriceDetail(generics.RetrieveUpdateDestroyAPIView):
"""
Detail endpoint for PartInternalPriceBreak model
"""
queryset = PartInternalPriceBreak.objects.all()
serializer_class = part_serializers.PartInternalPriceSerializer
class PartInternalPriceList(generics.ListCreateAPIView):
"""
API endpoint for list view of PartInternalPriceBreak model
@ -1175,6 +1193,18 @@ class PartList(generics.ListCreateAPIView):
except (ValueError, Part.DoesNotExist):
pass
# Filter by 'variant_of'
# Note that this is subtly different from 'ancestor' filter (above)
variant_of = params.get('variant_of', None)
if variant_of is not None:
try:
template = Part.objects.get(pk=variant_of)
variants = template.get_children()
queryset = queryset.filter(pk__in=[v.pk for v in variants])
except (ValueError, Part.DoesNotExist):
pass
# Filter only parts which are in the "BOM" for a given part
in_bom_for = params.get('in_bom_for', None)
@ -1339,10 +1369,6 @@ class PartList(generics.ListCreateAPIView):
filters.OrderingFilter,
]
filter_fields = [
'variant_of',
]
ordering_fields = [
'name',
'creation_date',
@ -1912,11 +1938,13 @@ part_api_urls = [
# Base URL for part sale pricing
url(r'^sale-price/', include([
url(r'^(?P<pk>\d+)/', PartSalePriceDetail.as_view(), name='api-part-sale-price-detail'),
url(r'^.*$', PartSalePriceList.as_view(), name='api-part-sale-price-list'),
])),
# Base URL for part internal pricing
url(r'^internal-price/', include([
url(r'^(?P<pk>\d+)/', PartInternalPriceDetail.as_view(), name='api-part-internal-price-detail'),
url(r'^.*$', PartInternalPriceList.as_view(), name='api-part-internal-price-list'),
])),

View File

@ -7,6 +7,7 @@
part: 100
sub_part: 1
quantity: 10
allow_variants: True
# 40 x R_2K2_0805
- model: part.bomitem

View File

@ -177,6 +177,7 @@
fields:
name: 'Green chair variant'
variant_of: 10003
is_template: true
category: 7
trackable: true
tree_id: 1

View File

@ -2732,7 +2732,21 @@ class BomItem(models.Model, DataImportMixin):
for sub in self.substitutes.all():
parts.add(sub.part)
return parts
valid_parts = []
for p in parts:
# Inactive parts cannot be 'auto allocated'
if not p.active:
continue
# Trackable parts cannot be 'auto allocated'
if p.trackable:
continue
valid_parts.append(p)
return valid_parts
def is_stock_item_valid(self, stock_item):
"""

View File

@ -7,7 +7,9 @@ from decimal import Decimal
from django.urls import reverse_lazy
from django.db import models, transaction
from django.db.models import ExpressionWrapper, F, Q
from django.db.models import ExpressionWrapper, F, Q, Func
from django.db.models import Subquery, OuterRef, FloatField
from django.db.models.functions import Coalesce
from django.utils.translation import ugettext_lazy as _
@ -15,6 +17,8 @@ from rest_framework import serializers
from sql_util.utils import SubqueryCount, SubquerySum
from djmoney.contrib.django_rest_framework import MoneyField
from common.settings import currency_code_default, currency_code_mappings
from InvenTree.serializers import (DataFileUploadSerializer,
DataFileExtractSerializer,
InvenTreeAttachmentSerializerField,
@ -146,6 +150,13 @@ class PartSalePriceSerializer(InvenTreeModelSerializer):
allow_null=True
)
price_currency = serializers.ChoiceField(
choices=currency_code_mappings(),
default=currency_code_default,
label=_('Currency'),
help_text=_('Purchase currency of this stock item'),
)
price_string = serializers.CharField(source='price', read_only=True)
class Meta:
@ -155,6 +166,7 @@ class PartSalePriceSerializer(InvenTreeModelSerializer):
'part',
'quantity',
'price',
'price_currency',
'price_string',
]
@ -170,6 +182,13 @@ class PartInternalPriceSerializer(InvenTreeModelSerializer):
allow_null=True
)
price_currency = serializers.ChoiceField(
choices=currency_code_mappings(),
default=currency_code_default,
label=_('Currency'),
help_text=_('Purchase currency of this stock item'),
)
price_string = serializers.CharField(source='price', read_only=True)
class Meta:
@ -179,6 +198,7 @@ class PartInternalPriceSerializer(InvenTreeModelSerializer):
'part',
'quantity',
'price',
'price_currency',
'price_string',
]
@ -308,9 +328,6 @@ class PartSerializer(InvenTreeModelSerializer):
to reduce database trips.
"""
# TODO: Update the "in_stock" annotation to include stock for variants of the part
# Ref: https://github.com/inventree/InvenTree/issues/2240
# Annotate with the total 'in stock' quantity
queryset = queryset.annotate(
in_stock=Coalesce(
@ -325,6 +342,24 @@ class PartSerializer(InvenTreeModelSerializer):
stock_item_count=SubqueryCount('stock_items')
)
# Annotate with the total variant stock quantity
variant_query = StockItem.objects.filter(
part__tree_id=OuterRef('tree_id'),
part__lft__gt=OuterRef('lft'),
part__rght__lt=OuterRef('rght'),
).filter(StockItem.IN_STOCK_FILTER)
queryset = queryset.annotate(
variant_stock=Coalesce(
Subquery(
variant_query.annotate(
total=Func(F('quantity'), function='SUM', output_field=FloatField())
).values('total')),
0,
output_field=FloatField(),
)
)
# Filter to limit builds to "active"
build_filter = Q(
status__in=BuildStatus.ACTIVE_CODES
@ -429,6 +464,7 @@ class PartSerializer(InvenTreeModelSerializer):
unallocated_stock = serializers.FloatField(read_only=True)
building = serializers.FloatField(read_only=True)
in_stock = serializers.FloatField(read_only=True)
variant_stock = serializers.FloatField(read_only=True)
ordering = serializers.FloatField(read_only=True)
stock_item_count = serializers.IntegerField(read_only=True)
suppliers = serializers.IntegerField(read_only=True)
@ -463,6 +499,7 @@ class PartSerializer(InvenTreeModelSerializer):
'full_name',
'image',
'in_stock',
'variant_stock',
'ordering',
'building',
'IPN',
@ -577,9 +614,10 @@ class BomItemSerializer(InvenTreeModelSerializer):
purchase_price_range = serializers.SerializerMethodField()
# Annotated fields
# Annotated fields for available stock
available_stock = serializers.FloatField(read_only=True)
available_substitute_stock = serializers.FloatField(read_only=True)
available_variant_stock = serializers.FloatField(read_only=True)
def __init__(self, *args, **kwargs):
# part_detail and sub_part_detail serializers are only included if requested.
@ -613,11 +651,18 @@ class BomItemSerializer(InvenTreeModelSerializer):
queryset = queryset.prefetch_related('sub_part')
queryset = queryset.prefetch_related('sub_part__category')
queryset = queryset.prefetch_related(
'sub_part__stock_items',
'sub_part__stock_items__allocations',
'sub_part__stock_items__sales_order_allocations',
)
queryset = queryset.prefetch_related(
'substitutes',
'substitutes__part__stock_items',
)
queryset = queryset.prefetch_related('sub_part__supplier_parts__pricebreaks')
return queryset
@ -707,7 +752,7 @@ class BomItemSerializer(InvenTreeModelSerializer):
),
)
# Calculate 'available_variant_stock' field
# Calculate 'available_substitute_stock' field
queryset = queryset.annotate(
available_substitute_stock=ExpressionWrapper(
F('substitute_stock') - F('substitute_build_allocations') - F('substitute_sales_allocations'),
@ -715,6 +760,47 @@ class BomItemSerializer(InvenTreeModelSerializer):
)
)
# Annotate the queryset with 'available variant stock' information
variant_stock_query = StockItem.objects.filter(
part__tree_id=OuterRef('sub_part__tree_id'),
part__lft__gt=OuterRef('sub_part__lft'),
part__rght__lt=OuterRef('sub_part__rght'),
).filter(StockItem.IN_STOCK_FILTER)
queryset = queryset.alias(
variant_stock_total=Coalesce(
Subquery(
variant_stock_query.annotate(
total=Func(F('quantity'), function='SUM', output_field=FloatField())
).values('total')),
0,
output_field=FloatField()
),
variant_stock_build_order_allocations=Coalesce(
Subquery(
variant_stock_query.annotate(
total=Func(F('sales_order_allocations__quantity'), function='SUM', output_field=FloatField()),
).values('total')),
0,
output_field=FloatField(),
),
variant_stock_sales_order_allocations=Coalesce(
Subquery(
variant_stock_query.annotate(
total=Func(F('allocations__quantity'), function='SUM', output_field=FloatField()),
).values('total')),
0,
output_field=FloatField(),
)
)
queryset = queryset.annotate(
available_variant_stock=ExpressionWrapper(
F('variant_stock_total') - F('variant_stock_build_order_allocations') - F('variant_stock_sales_order_allocations'),
output_field=FloatField(),
)
)
return queryset
def get_purchase_price_range(self, obj):
@ -790,6 +876,8 @@ class BomItemSerializer(InvenTreeModelSerializer):
# Annotated fields describing available quantity
'available_stock',
'available_substitute_stock',
'available_variant_stock',
]

View File

@ -124,8 +124,7 @@
</div>
</div>
{% settings_value "PART_SHOW_PRICE_HISTORY" as show_price_history %}
{% if show_price_history %}
{% if part.purchaseable or part.salable %}
<div class='panel panel-hidden' id='panel-pricing'>
{% include "part/prices.html" %}
</div>
@ -1009,7 +1008,7 @@
pb_url_slug: 'internal-price',
pb_url: '{% url 'api-part-internal-price-list' %}',
pb_new_btn: $('#new-internal-price-break'),
pb_new_url: '{% url 'internal-price-break-create' %}',
pb_new_url: '{% url 'api-part-internal-price-list' %}',
linkedGraph: $('#InternalPriceBreakChart'),
},
);
@ -1025,7 +1024,7 @@
pb_url_slug: 'sale-price',
pb_url: "{% url 'api-part-sale-price-list' %}",
pb_new_btn: $('#new-price-break'),
pb_new_url: '{% url 'sale-price-break-create' %}',
pb_new_url: '{% url 'api-part-sale-price-list' %}',
linkedGraph: $('#SalePriceBreakChart'),
},
);

View File

@ -211,44 +211,18 @@
{% if part.component %}
{% if required_build_order_quantity > 0 %}
<tr>
<td><span class='fas fa-clipboard-list'></span></td>
<td>{% trans "Required for Build Orders" %}</td>
<td>{% decimal required_build_order_quantity %}</td>
</tr>
<tr>
<td><span class='fas fa-dolly'></span></td>
<td><span class='fas fa-tools'></span></td>
<td>{% trans "Allocated to Build Orders" %}</td>
<td>
{% decimal allocated_build_order_quantity %}
{% if allocated_build_order_quantity < required_build_order_quantity %}
<span class='fas fa-times-circle icon-red float-right' title='{% trans "Required quantity has not been allocated" %}'></span>
{% else %}
<span class='fas fa-check-circle icon-green float-right' title='{% trans "Required quantity has been allocated" %}'></span>
{% endif %}
</td>
<td>{% progress_bar allocated_build_order_quantity required_build_order_quantity id='build-order-allocated' max_width='150px' %}</td>
</tr>
{% endif %}
{% endif %}
{% if part.salable %}
{% if required_sales_order_quantity > 0 %}
<tr>
<td><span class='fas fa-clipboard-list'></span></td>
<td>{% trans "Required for Sales Orders" %}</td>
<td>
{% decimal required_sales_order_quantity %}
</td>
</tr>
<tr>
<td><span class='fas fa-dolly'></span></td>
<td><span class='fas fa-truck'></span></td>
<td>{% trans "Allocated to Sales Orders" %}</td>
<td>
{% decimal allocated_sales_order_quantity %}
{% if allocated_sales_order_quantity < required_sales_order_quantity %}
<span class='fas fa-times-circle icon-red float-right' title='{% trans "Required quantity has not been allocated" %}'></span>
{% else %}
<span class='fas fa-check-circle icon-green float-right' title='{% trans "Required quantity has been allocated" %}'></span>
{% endif %}
</td>
<td>{% progress_bar allocated_sales_order_quantity required_sales_order_quantity id='sales-order-allocated' max_width='150px' %}</td>
</tr>
{% endif %}
{% endif %}

View File

@ -4,7 +4,6 @@
{% settings_value "PART_INTERNAL_PRICE" as show_internal_price %}
{% settings_value 'PART_SHOW_RELATED' as show_related %}
{% settings_value "PART_SHOW_PRICE_HISTORY" as show_price_history %}
{% trans "Parameters" as text %}
{% include "sidebar_item.html" with label="part-parameters" text=text icon="fa-th-list" %}
@ -28,7 +27,7 @@
{% trans "Used In" as text %}
{% include "sidebar_item.html" with label="used-in" text=text icon="fa-layer-group" %}
{% endif %}
{% if show_price_history %}
{% if part.purchaseable or part.salable %}
{% trans "Pricing" as text %}
{% include "sidebar_item.html" with label="pricing" text=text icon="fa-dollar-sign" %}
{% endif %}

View File

@ -3,6 +3,9 @@
{% load crispy_forms_tags %}
{% load inventree_extras %}
{% settings_value "PART_INTERNAL_PRICE" as show_internal_price %}
{% if show_price_history %}
<div class='panel-heading'>
<h4>{% trans "Pricing Information" %}</h4>
</div>
@ -43,7 +46,7 @@
{% endif %}
{% endif %}
{% if part.bom_count > 0 %}
{% if part.assembly and part.bom_count > 0 %}
{% if min_total_bom_price %}
<tr>
<td><strong>{% trans 'BOM Pricing' %}</strong>
@ -147,7 +150,7 @@
</div>
</div>
{% settings_value "PART_INTERNAL_PRICE" as show_internal_price %}
{% endif %}
{% if part.purchaseable and roles.purchase_order.view %}
<a class="anchor" id="supplier-cost"></a>
@ -170,7 +173,7 @@
</div>
</div>
{% if price_history %}
{% if show_price_history %}
<a class="anchor" id="purchase-price"></a>
<div class='panel-heading'>
<h4>{% trans "Purchase Price" %}
@ -279,6 +282,7 @@
</div>
</div>
{% if show_price_history %}
<a class="anchor" id="sale-price"></a>
<div class='panel-heading'>
<h4>{% trans "Sale Price" %}
@ -298,3 +302,5 @@
{% endif %}
</div>
{% endif %}
{% endif %}

View File

@ -18,7 +18,8 @@ from django.conf import settings as djangosettings
from django import template
from django.urls import reverse
from django.utils.safestring import mark_safe
from django.templatetags.static import StaticNode
from django.templatetags.static import StaticNode, static
from django.core.files.storage import default_storage
from InvenTree import version, settings
@ -166,6 +167,14 @@ def inventree_demo_mode(*args, **kwargs):
return djangosettings.DEMO_MODE
@register.simple_tag()
def inventree_show_about(user, *args, **kwargs):
""" Return True if the about modal should be shown """
if InvenTreeSetting.get_setting('INVENTREE_RESTRICT_ABOUT') and not user.is_superuser:
return False
return True
@register.simple_tag()
def inventree_docker_mode(*args, **kwargs):
""" Return True if the server is running as a Docker image """
@ -220,8 +229,13 @@ def python_version(*args, **kwargs):
@register.simple_tag()
def inventree_version(*args, **kwargs):
def inventree_version(shortstring=False, *args, **kwargs):
""" Return InvenTree version string """
if shortstring:
return _("{title} v{version}".format(
title=version.inventreeInstanceTitle(),
version=version.inventreeVersion()
))
return version.inventreeVersion()
@ -355,21 +369,24 @@ def visible_global_settings(*args, **kwargs):
@register.simple_tag()
def progress_bar(val, max, *args, **kwargs):
def progress_bar(val, max_val, *args, **kwargs):
"""
Render a progress bar element
"""
item_id = kwargs.get('id', 'progress-bar')
if val > max:
val = InvenTree.helpers.normalize(val)
max_val = InvenTree.helpers.normalize(max_val)
if val > max_val:
style = 'progress-bar-over'
elif val < max:
elif val < max_val:
style = 'progress-bar-under'
else:
style = ''
percent = float(val / max) * 100
percent = float(val / max_val) * 100
if percent > 100:
percent = 100
@ -386,7 +403,7 @@ def progress_bar(val, max, *args, **kwargs):
html = f"""
<div id='{item_id}' class='progress' style='{" ".join(style_tags)}'>
<div class='progress-bar {style}' role='progressbar' aria-valuemin='0' aria-valuemax='100' style='width:{percent}%'></div>
<div class='progress-value'>{val} / {max}</div>
<div class='progress-value'>{val} / {max_val}</div>
</div>
"""
@ -512,6 +529,22 @@ def mail_configured():
return bool(settings.EMAIL_HOST)
@register.simple_tag()
def inventree_customize(reference, *args, **kwargs):
""" Return customization values for the user interface """
return djangosettings.CUSTOMIZE.get(reference, '')
@register.simple_tag()
def inventree_logo(*args, **kwargs):
""" Return the path to the logo-file """
if settings.CUSTOM_LOGO:
return default_storage.url(settings.CUSTOM_LOGO)
return static('img/inventree.png')
class I18nStaticNode(StaticNode):
"""
custom StaticNode

View File

@ -567,6 +567,185 @@ class PartAPITest(InvenTreeAPITestCase):
self.assertEqual(response.data['name'], name)
self.assertEqual(response.data['description'], description)
def test_template_filters(self):
"""
Unit tests for API filters related to template parts:
- variant_of : Return children of specified part
- ancestor : Return descendants of specified part
Uses the 'chair template' part (pk=10000)
"""
# Rebuild the MPTT structure before running these tests
Part.objects.rebuild()
url = reverse('api-part-list')
response = self.get(
url,
{
'variant_of': 10000,
},
expected_code=200
)
# 3 direct children of template part
self.assertEqual(len(response.data), 3)
response = self.get(
url,
{
'ancestor': 10000,
},
expected_code=200,
)
# 4 total descendants
self.assertEqual(len(response.data), 4)
# Use the 'green chair' as our reference
response = self.get(
url,
{
'variant_of': 10003,
},
expected_code=200,
)
self.assertEqual(len(response.data), 1)
response = self.get(
url,
{
'ancestor': 10003,
},
expected_code=200,
)
self.assertEqual(len(response.data), 1)
# Add some more variants
p = Part.objects.get(pk=10004)
for i in range(100):
Part.objects.create(
name=f'Chair variant {i}',
description='A new chair variant',
variant_of=p,
)
# There should still be only one direct variant
response = self.get(
url,
{
'variant_of': 10003,
},
expected_code=200,
)
self.assertEqual(len(response.data), 1)
# However, now should be 101 descendants
response = self.get(
url,
{
'ancestor': 10003,
},
expected_code=200,
)
self.assertEqual(len(response.data), 101)
def test_variant_stock(self):
"""
Unit tests for the 'variant_stock' annotation,
which provides a stock count for *variant* parts
"""
# Ensure the MPTT structure is in a known state before running tests
Part.objects.rebuild()
# Initially, there are no "chairs" in stock,
# so each 'chair' template should report variant_stock=0
url = reverse('api-part-list')
# Look at the "detail" URL for the master chair template
response = self.get('/api/part/10000/', {}, expected_code=200)
# This part should report 'zero' as variant stock
self.assertEqual(response.data['variant_stock'], 0)
# Grab a list of all variant chairs *under* the master template
response = self.get(
url,
{
'ancestor': 10000,
},
expected_code=200,
)
# 4 total descendants
self.assertEqual(len(response.data), 4)
for variant in response.data:
self.assertEqual(variant['variant_stock'], 0)
# Now, let's make some variant stock
for variant in Part.objects.get(pk=10000).get_descendants(include_self=False):
StockItem.objects.create(
part=variant,
quantity=100,
)
response = self.get('/api/part/10000/', {}, expected_code=200)
self.assertEqual(response.data['in_stock'], 0)
self.assertEqual(response.data['variant_stock'], 400)
# Check that each variant reports the correct stock quantities
response = self.get(
url,
{
'ancestor': 10000,
},
expected_code=200,
)
expected_variant_stock = {
10001: 0,
10002: 0,
10003: 100,
10004: 0,
}
for variant in response.data:
self.assertEqual(variant['in_stock'], 100)
self.assertEqual(variant['variant_stock'], expected_variant_stock[variant['pk']])
# Add some 'sub variants' for the green chair variant
green_chair = Part.objects.get(pk=10004)
for i in range(10):
gcv = Part.objects.create(
name=f"GC Var {i}",
description="Green chair variant",
variant_of=green_chair,
)
StockItem.objects.create(
part=gcv,
quantity=50,
)
# Spot check of some values
response = self.get('/api/part/10000/', {})
self.assertEqual(response.data['variant_stock'], 900)
response = self.get('/api/part/10004/', {})
self.assertEqual(response.data['variant_stock'], 500)
class PartDetailTests(InvenTreeAPITestCase):
"""
@ -1450,6 +1629,44 @@ class BomItemTest(InvenTreeAPITestCase):
self.assertEqual(len(response.data), i)
def test_bom_variant_stock(self):
"""
Test for 'available_variant_stock' annotation
"""
Part.objects.rebuild()
# BOM item we are interested in
bom_item = BomItem.objects.get(pk=1)
response = self.get('/api/bom/1/', {}, expected_code=200)
# Initially, no variant stock available
self.assertEqual(response.data['available_variant_stock'], 0)
# Create some 'variants' of the referenced sub_part
bom_item.sub_part.is_template = True
bom_item.sub_part.save()
for i in range(10):
# Create a variant part
vp = Part.objects.create(
name=f"Var {i}",
description="Variant part",
variant_of=bom_item.sub_part,
)
# Create a stock item
StockItem.objects.create(
part=vp,
quantity=100,
)
# There should now be variant stock available
response = self.get('/api/bom/1/', {}, expected_code=200)
self.assertEqual(response.data['available_variant_stock'], 1000)
class PartParameterTest(InvenTreeAPITestCase):
"""

View File

@ -13,18 +13,6 @@ from django.conf.urls import url, include
from . import views
sale_price_break_urls = [
url(r'^new/', views.PartSalePriceBreakCreate.as_view(), name='sale-price-break-create'),
url(r'^(?P<pk>\d+)/edit/', views.PartSalePriceBreakEdit.as_view(), name='sale-price-break-edit'),
url(r'^(?P<pk>\d+)/delete/', views.PartSalePriceBreakDelete.as_view(), name='sale-price-break-delete'),
]
internal_price_break_urls = [
url(r'^new/', views.PartInternalPriceBreakCreate.as_view(), name='internal-price-break-create'),
url(r'^(?P<pk>\d+)/edit/', views.PartInternalPriceBreakEdit.as_view(), name='internal-price-break-edit'),
url(r'^(?P<pk>\d+)/delete/', views.PartInternalPriceBreakDelete.as_view(), name='internal-price-break-delete'),
]
part_parameter_urls = [
url(r'^template/new/', views.PartParameterTemplateCreate.as_view(), name='part-param-template-create'),
url(r'^template/(?P<pk>\d+)/edit/', views.PartParameterTemplateEdit.as_view(), name='part-param-template-edit'),
@ -86,12 +74,6 @@ part_urls = [
# Part category
url(r'^category/', include(category_urls)),
# Part price breaks
url(r'^sale-price/', include(sale_price_break_urls)),
# Part internal price breaks
url(r'^internal-price/', include(internal_price_break_urls)),
# Part parameters
url(r'^parameter/', include(part_parameter_urls)),

View File

@ -18,7 +18,6 @@ from django.forms import HiddenInput
from django.conf import settings
from django.contrib import messages
from moneyed import CURRENCIES
from djmoney.contrib.exchange.models import convert_money
from djmoney.contrib.exchange.exceptions import MissingRate
@ -33,7 +32,6 @@ from decimal import Decimal
from .models import PartCategory, Part
from .models import PartParameterTemplate
from .models import PartCategoryParameterTemplate
from .models import PartSellPriceBreak, PartInternalPriceBreak
from common.models import InvenTreeSetting
from company.models import SupplierPart
@ -389,8 +387,12 @@ class PartDetail(InvenTreeRoleMixin, DetailView):
context.update(**ctx)
show_price_history = InvenTreeSetting.get_setting('PART_SHOW_PRICE_HISTORY', False)
context['show_price_history'] = show_price_history
# Pricing information
if InvenTreeSetting.get_setting('PART_SHOW_PRICE_HISTORY', False):
if show_price_history:
ctx = self.get_pricing(self.get_quantity())
ctx['form'] = self.form_class(initial=self.get_initials())
@ -1226,102 +1228,3 @@ class CategoryParameterTemplateDelete(AjaxDeleteView):
return None
return self.object
class PartSalePriceBreakCreate(AjaxCreateView):
"""
View for creating a sale price break for a part
"""
model = PartSellPriceBreak
form_class = part_forms.EditPartSalePriceBreakForm
ajax_form_title = _('Add Price Break')
def get_data(self):
return {
'success': _('Added new price break')
}
def get_part(self):
try:
part = Part.objects.get(id=self.request.GET.get('part'))
except (ValueError, Part.DoesNotExist):
part = None
if part is None:
try:
part = Part.objects.get(id=self.request.POST.get('part'))
except (ValueError, Part.DoesNotExist):
part = None
return part
def get_form(self):
form = super(AjaxCreateView, self).get_form()
form.fields['part'].widget = HiddenInput()
return form
def get_initial(self):
initials = super(AjaxCreateView, self).get_initial()
initials['part'] = self.get_part()
default_currency = inventree_settings.currency_code_default()
currency = CURRENCIES.get(default_currency, None)
if currency is not None:
initials['price'] = [1.0, currency]
return initials
class PartSalePriceBreakEdit(AjaxUpdateView):
""" View for editing a sale price break """
model = PartSellPriceBreak
form_class = part_forms.EditPartSalePriceBreakForm
ajax_form_title = _('Edit Price Break')
def get_form(self):
form = super().get_form()
form.fields['part'].widget = HiddenInput()
return form
class PartSalePriceBreakDelete(AjaxDeleteView):
""" View for deleting a sale price break """
model = PartSellPriceBreak
ajax_form_title = _("Delete Price Break")
ajax_template_name = "modal_delete_form.html"
class PartInternalPriceBreakCreate(PartSalePriceBreakCreate):
""" View for creating a internal price break for a part """
model = PartInternalPriceBreak
form_class = part_forms.EditPartInternalPriceBreakForm
ajax_form_title = _('Add Internal Price Break')
permission_required = 'roles.sales_order.add'
class PartInternalPriceBreakEdit(PartSalePriceBreakEdit):
""" View for editing a internal price break """
model = PartInternalPriceBreak
form_class = part_forms.EditPartInternalPriceBreakForm
ajax_form_title = _('Edit Internal Price Break')
permission_required = 'roles.sales_order.change'
class PartInternalPriceBreakDelete(PartSalePriceBreakDelete):
""" View for deleting a internal price break """
model = PartInternalPriceBreak
ajax_form_title = _("Delete Internal Price Break")
permission_required = 'roles.sales_order.delete'

View File

@ -504,10 +504,10 @@ class APICallMixin:
@property
def api_headers(self):
return {
self.API_TOKEN: self.get_setting(self.API_TOKEN_SETTING),
'Content-Type': 'application/json'
}
headers = {'Content-Type': 'application/json'}
if getattr(self, 'API_TOKEN_SETTING'):
headers[self.API_TOKEN] = self.get_setting(self.API_TOKEN_SETTING)
return headers
def api_build_url_args(self, arguments):
groups = []
@ -515,16 +515,21 @@ class APICallMixin:
groups.append(f'{key}={",".join([str(a) for a in val])}')
return f'?{"&".join(groups)}'
def api_call(self, endpoint, method: str = 'GET', url_args=None, data=None, headers=None, simple_response: bool = True):
def api_call(self, endpoint, method: str = 'GET', url_args=None, data=None, headers=None, simple_response: bool = True, endpoint_is_url: bool = False):
if url_args:
endpoint += self.api_build_url_args(url_args)
if headers is None:
headers = self.api_headers
if endpoint_is_url:
url = endpoint
else:
url = f'{self.api_url}/{endpoint}'
# build kwargs for call
kwargs = {
'url': f'{self.api_url}/{endpoint}',
'url': url,
'headers': headers,
}
if data:

View File

@ -15,7 +15,7 @@ content: "v{{report_revision}} - {{ date.isoformat }}";
{% endblock %}
{% block bottom_center %}
content: "InvenTree v{% inventree_version %}";
content: "{% inventree_version shortstring=True %}";
{% endblock %}
{% block style %}

View File

@ -16,7 +16,7 @@ content: "v{{report_revision}} - {{ date.isoformat }}";
{% endblock %}
{% block bottom_center %}
content: "InvenTree v{% inventree_version %}";
content: "{% inventree_version shortstring=True %}";
{% endblock %}
{% block style %}

View File

@ -14,7 +14,7 @@ content: "{{ date.isoformat }}";
{% endblock %}
{% block bottom_center %}
content: "InvenTree v{% inventree_version %}";
content: "{% inventree_version shortstring=True %}";
{% endblock %}
{% block top_center %}

View File

@ -1,116 +0,0 @@
"""
This script is used to simplify the translation process.
Django provides a framework for working out which strings are "translatable",
and these strings are then dumped in a file under InvenTree/locale/<lang>/LC_MESSAGES/django.po
This script presents the translator with a list of strings which have not yet been translated,
allowing for a simpler and quicker translation process.
If a string translation needs to be updated, this will still need to be done manually,
by editing the appropriate .po file.
"""
import argparse
import os
import sys
def manually_translate_file(filename, save=False):
"""
Manually translate a .po file.
Present any missing translation strings to the translator,
and write their responses back to the file.
"""
print("Add manual translations to '{f}'".format(f=filename))
print("For each missing translation:")
print("a) Directly enter a new tranlation in the target language")
print("b) Leave empty to skip")
print("c) Press Ctrl+C to exit")
print("-------------------------")
input("Press <ENTER> to start")
print("")
with open(filename, 'r') as f:
lines = f.readlines()
out = []
# Context data
source_line = ''
msgid = ''
for num, line in enumerate(lines):
# Keep track of context data BEFORE an empty msgstr object
line = line.strip()
if line.startswith("#: "):
source_line = line.replace("#: ", "")
elif line.startswith("msgid "):
msgid = line.replace("msgid ", "")
if line.strip() == 'msgstr ""':
# We have found an empty translation!
if msgid and len(msgid) > 0 and not msgid == '""':
print("Source:", source_line)
print("Enter translation for {t}".format(t=msgid))
try:
translation = str(input(">"))
except KeyboardInterrupt:
break
if translation and len(translation) > 0:
# Update the line with the new translation
line = 'msgstr "{msg}"'.format(msg=translation)
out.append(line + "\r\n")
if save:
with open(filename, 'w') as output_file:
output_file.writelines(out)
print("Translation done: written to", filename)
print("Run 'invoke translate' to rebuild translation data")
if __name__ == '__main__':
MY_DIR = os.path.dirname(os.path.realpath(__file__))
LOCALE_DIR = os.path.join(MY_DIR, '..', 'locale')
if not os.path.exists(LOCALE_DIR):
print("Error: {d} does not exist!".format(d=LOCALE_DIR))
sys.exit(1)
parser = argparse.ArgumentParser(description="InvenTree Translation Helper")
parser.add_argument('language', help='Language code', action='store')
parser.add_argument('--fake', help="Do not save updated translations", action='store_true')
args = parser.parse_args()
language = args.language
LANGUAGE_DIR = os.path.abspath(os.path.join(LOCALE_DIR, language))
# Check that a locale directory exists for the given language!
if not os.path.exists(LANGUAGE_DIR):
print("Error: Locale directory for language '{l}' does not exist".format(l=language))
sys.exit(1)
# Check that a .po file exists for the given language!
PO_FILE = os.path.join(LANGUAGE_DIR, 'LC_MESSAGES', 'django.po')
if not os.path.exists(PO_FILE):
print("Error: File '{f}' does not exist".format(f=PO_FILE))
sys.exit(1)
# Ok, now we run the user through the translation file
manually_translate_file(PO_FILE, save=args.fake is not True)

View File

@ -0,0 +1,19 @@
# Generated by Django 3.2.12 on 2022-04-26 10:19
from django.db import migrations, models
import stock.models
class Migration(migrations.Migration):
dependencies = [
('stock', '0073_alter_stockitem_belongs_to'),
]
operations = [
migrations.AlterField(
model_name='stockitem',
name='batch',
field=models.CharField(blank=True, default=stock.models.generate_batch_code, help_text='Batch code for this stock item', max_length=100, null=True, verbose_name='Batch Code'),
),
]

View File

@ -8,6 +8,8 @@ from __future__ import unicode_literals
import os
from jinja2 import Template
from django.utils.translation import gettext_lazy as _
from django.core.exceptions import ValidationError, FieldError
from django.urls import reverse
@ -213,6 +215,32 @@ class StockItemManager(TreeManager):
)
def generate_batch_code():
"""
Generate a default 'batch code' for a new StockItem.
This uses the value of the 'STOCK_BATCH_CODE_TEMPLATE' setting (if configured),
which can be passed through a simple template.
"""
batch_template = common.models.InvenTreeSetting.get_setting('STOCK_BATCH_CODE_TEMPLATE', '')
now = datetime.now()
# Pass context data through to the template randering.
# The folowing context variables are availble for custom batch code generation
context = {
'date': now,
'year': now.year,
'month': now.month,
'day': now.day,
'hour': now.minute,
'minute': now.minute,
}
return Template(batch_template).render(context)
class StockItem(MPTTModel):
"""
A StockItem object represents a quantity of physical instances of a part.
@ -644,7 +672,8 @@ class StockItem(MPTTModel):
batch = models.CharField(
verbose_name=_('Batch Code'),
max_length=100, blank=True, null=True,
help_text=_('Batch code for this stock item')
help_text=_('Batch code for this stock item'),
default=generate_batch_code,
)
quantity = models.DecimalField(

View File

@ -1,8 +1,9 @@
{% extends "base.html" %}
{% load i18n %}
{% load inventree_extras %}
{% block page_title %}
InvenTree | {% trans "Permission Denied" %}
{% inventree_title %} | {% trans "Permission Denied" %}
{% endblock %}
{% block content %}

View File

@ -1,8 +1,9 @@
{% extends "base.html" %}
{% load i18n %}
{% load inventree_extras %}
{% block page_title %}
InvenTree | {% trans "Page Not Found" %}
{% inventree_title %} | {% trans "Page Not Found" %}
{% endblock %}
{% block content %}

View File

@ -1,8 +1,9 @@
{% extends "base.html" %}
{% load i18n %}
{% load inventree_extras %}
{% block page_title %}
InvenTree | {% trans "Internal Server Error" %}
{% inventree_title %} | {% trans "Internal Server Error" %}
{% endblock %}
{% block content %}
@ -11,7 +12,7 @@ InvenTree | {% trans "Internal Server Error" %}
<h3>{% trans "Internal Server Error" %}</h3>
<div class='alert alert-danger alert-block'>
{% trans "The InvenTree server raised an internal error" %}<br>
{% blocktrans %}The {{ inventree_title }} server raised an internal error{% endblocktrans %}<br>
{% trans "Refer to the error log in the admin interface for further details" %}
</div>
</div>

View File

@ -30,7 +30,7 @@
<div class='container-fluid'>
<div class='clearfix content-heading login-header d-flex flex-wrap'>
<img class="pull-left" src="{% static 'img/inventree.png' %}" width="60" height="60"/>
<img class="pull-left" src="{% inventree_logo %}" width="60" height="60"/>
{% include "spacer.html" %}
<span class='float-right'><h3>{% block body_title %}{% trans 'Site is in Maintenance' %}{% endblock %}</h3></span>
</div>

View File

@ -15,6 +15,7 @@
<tbody>
{% include "InvenTree/settings/setting.html" with key="INVENTREE_INSTANCE" icon="fa-info-circle" %}
{% include "InvenTree/settings/setting.html" with key="INVENTREE_INSTANCE_TITLE" icon="fa-info-circle" %}
{% include "InvenTree/settings/setting.html" with key="INVENTREE_RESTRICT_ABOUT" icon="fa-info-circle" %}
{% include "InvenTree/settings/setting.html" with key="INVENTREE_BASE_URL" icon="fa-globe" %}
{% include "InvenTree/settings/setting.html" with key="INVENTREE_COMPANY_NAME" icon="fa-building" %}
{% include "InvenTree/settings/setting.html" with key="INVENTREE_DOWNLOAD_FROM_URL" icon="fa-cloud-download-alt" %}

View File

@ -13,7 +13,7 @@
{% block content %}
<div class='alert alert-block alert-danger'>
{% trans "Changing the settings below require you to immediatly restart InvenTree. Do not change this while under active usage." %}
{% trans "Changing the settings below require you to immediatly restart the server. Do not change this while under active usage." %}
</div>
<div class='table-responsive'>

View File

@ -85,7 +85,7 @@
{% if plugin.is_package %}
{% trans "This plugin was installed as a package" %}
{% else %}
{% trans "This plugin was found in a local InvenTree path" %}
{% trans "This plugin was found in a local server path" %}
{% endif %}
</td>
</tr>

View File

@ -11,6 +11,7 @@
<table class='table table-striped table-condensed'>
<tbody>
{% include "InvenTree/settings/setting.html" with key="STOCK_BATCH_CODE_TEMPLATE" icon="fa-layer-group" %}
{% include "InvenTree/settings/setting.html" with key="STOCK_ENABLE_EXPIRY" icon="fa-stopwatch" %}
{% include "InvenTree/settings/setting.html" with key="STOCK_STALE_DAYS" icon="fa-calendar" %}
{% include "InvenTree/settings/setting.html" with key="STOCK_ALLOW_EXPIRED_SALE" icon="fa-truck" %}

View File

@ -101,7 +101,7 @@
</div>
<div class="col-sm-6">
<h4>{% trans "Help the translation efforts!" %}</h4>
<p>{% blocktrans with link="https://crowdin.com/project/inventree" %}Native language translation of the InvenTree web application is <a href="{{link}}">community contributed via crowdin</a>. Contributions are welcomed and encouraged.{% endblocktrans %}</p>
<p>{% blocktrans with link="https://crowdin.com/project/inventree" %}Native language translation of the web application is <a href="{{link}}">community contributed via crowdin</a>. Contributions are welcomed and encouraged.{% endblocktrans %}</p>
</div>
</div>

View File

@ -67,7 +67,7 @@
<div class='container-fluid'>
<div class='clearfix content-heading login-header d-flex flex-wrap'>
<img class="pull-left" src="{% static 'img/inventree.png' %}" width="60" height="60"/>
<img class="pull-left" src="{% inventree_logo %}" width="60" height="60"/>
{% include "spacer.html" %}
<span class='float-right'><h3>{% inventree_title %}</h3></span>
</div>
@ -89,7 +89,7 @@
<script type='text/javascript' src="{% static 'script/jquery-ui/jquery-ui.min.js' %}"></script>
<script type="text/javascript" src="{% static 'bootstrap/js/bootstrap.bundle.min.js' %}"></script>
<!-- general InvenTree -->
<!-- general JS -->
<script type='text/javascript' src="{% static 'script/inventree/inventree.js' %}"></script>
<script type='text/javascript' src="{% i18n_static 'notification.js' %}"></script>

View File

@ -10,6 +10,7 @@
{% settings_value 'LOGIN_ENABLE_REG' as enable_reg %}
{% settings_value 'LOGIN_ENABLE_PWD_FORGOT' as enable_pwd_forgot %}
{% settings_value 'LOGIN_ENABLE_SSO' as enable_sso %}
{% inventree_customize 'login_message' as login_message %}
{% mail_configured as mail_conf %}
{% inventree_demo_mode as demo %}
@ -35,19 +36,15 @@ for a account and sign in below:{% endblocktrans %}</p>
{% endif %}
<hr>
{% if login_message %}
<div>{{ login_message }}<hr></div>
{% endif %}
<div class="btn-group float-right" role="group">
<button class="btn btn-success" type="submit">{% trans "Sign In" %}</button>
</div>
{% if mail_conf and enable_pwd_forgot and not demo %}
<a class="" href="{% url 'account_reset_password' %}"><small>{% trans "Forgot Password?" %}</small></a>
{% endif %}
{% if demo %}
<p>
<h6>
{% trans "InvenTree demo instance" %} - <a href='https://inventree.readthedocs.io/en/latest/demo/'>{% trans "Click here for login details" %}</a>
</h6>
</p>
{% endif %}
</form>
{% if enable_sso %}

View File

@ -7,6 +7,7 @@
{% settings_value "REPORT_ENABLE" as report_enabled %}
{% settings_value "SERVER_RESTART_REQUIRED" as server_restart_required %}
{% settings_value "LABEL_ENABLE" with user=user as labels_enabled %}
{% inventree_show_about user as show_about %}
{% inventree_demo_mode as demo_mode %}
<!DOCTYPE html>
@ -130,7 +131,7 @@
</div>
{% include 'modals.html' %}
{% include 'about.html' %}
{% if show_about %}{% include 'about.html' %}{% endif %}
{% include "notifications.html" %}
{% include "search.html" %}
</div>
@ -166,7 +167,7 @@
<script type='text/javascript' src="{% static 'script/clipboard.min.js' %}"></script>
<script type='text/javascript' src="{% static 'script/randomColor.min.js' %}"></script>
<!-- general InvenTree -->
<!-- general JS -->
<script type='text/javascript' src="{% static 'script/inventree/inventree.js' %}"></script>
<!-- dynamic javascript templates -->

View File

@ -32,7 +32,7 @@
{% block footer_prefix %}
<!-- Custom footer information goes here -->
{% endblock %}
<p><em><small>{% trans "InvenTree version" %}: {% inventree_version %} - <a href='https://inventree.readthedocs.io'>inventree.readthedocs.io</a></small></em></p>
<p><em><small>{% inventree_version shortstring=True %} - <a href='https://inventree.readthedocs.io'>readthedocs.io</a></small></em></p>
{% block footer_suffix %}
<!-- Custom footer information goes here -->
{% endblock %}

View File

@ -691,8 +691,24 @@ function loadBomTable(table, options={}) {
setupFilterList('bom', $(table));
// Construct the table columns
function availableQuantity(row) {
// Base stock
var available = row.available_stock;
// Substitute stock
available += (row.available_substitute_stock || 0);
// Variant stock
if (row.allow_variants) {
available += (row.available_variant_stock || 0);
}
return available;
}
// Construct the table columns
var cols = [];
if (options.editable) {
@ -807,15 +823,27 @@ function loadBomTable(table, options={}) {
var url = `/part/${row.sub_part_detail.pk}/?display=part-stock`;
// Calculate total "available" (unallocated) quantity
var total = row.available_stock + row.available_substitute_stock;
var substitute_stock = row.available_substitute_stock || 0;
var variant_stock = row.allow_variants ? (row.available_variant_stock || 0) : 0;
var text = `${total}`;
var available_stock = availableQuantity(row);
var text = `${available_stock}`;
if (total <= 0) {
if (available_stock <= 0) {
text = `<span class='badge rounded-pill bg-danger'>{% trans "No Stock Available" %}</span>`;
} else {
if (row.available_substitute_stock > 0) {
text += `<span title='{% trans "Includes substitute stock" %}' class='fas fa-info-circle float-right icon-blue'></span>`;
var extra = '';
if ((substitute_stock > 0) && (variant_stock > 0)) {
extra = '{% trans "Includes variant and substitute stock" %}';
} else if (variant_stock > 0) {
extra = '{% trans "Includes variant stock" %}';
} else if (substitute_stock > 0) {
extra = '{% trans "Includes substitute stock" %}';
}
if (extra) {
text += `<span title='${extra}' class='fas fa-info-circle float-right icon-blue'></span>`;
}
}
@ -910,7 +938,7 @@ function loadBomTable(table, options={}) {
formatter: function(value, row) {
var can_build = 0;
var available = row.available_stock + row.available_substitute_stock;
var available = availableQuantity(row);
if (row.quantity > 0) {
can_build = available / row.quantity;
@ -924,11 +952,11 @@ function loadBomTable(table, options={}) {
var cb_b = 0;
if (rowA.quantity > 0) {
cb_a = (rowA.available_stock + rowA.available_substitute_stock) / rowA.quantity;
cb_a = availableQuantity(rowA) / rowA.quantity;
}
if (rowB.quantity > 0) {
cb_b = (rowB.available_stock + rowB.available_substitute_stock) / rowB.quantity;
cb_b = availableQuantity(rowB) / rowB.quantity;
}
return (cb_a > cb_b) ? 1 : -1;

View File

@ -1031,6 +1031,23 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
return row.required;
}
function availableQuantity(row) {
// Base stock
var available = row.available_stock;
// Substitute stock
available += (row.available_substitute_stock || 0);
// Variant stock
if (row.allow_variants) {
available += (row.available_variant_stock || 0);
}
return available;
}
function sumAllocations(row) {
// Calculat total allocations for a given row
if (!row.allocations) {
@ -1425,20 +1442,52 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
title: '{% trans "Available" %}',
sortable: true,
formatter: function(value, row) {
var total = row.available_stock + row.available_substitute_stock;
var text = `${total}`;
var url = `/part/${row.sub_part_detail.pk}/?display=part-stock`;
if (total <= 0) {
text = `<span class='badge rounded-pill bg-danger'>{% trans "No Stock Available" %}</span>`;
// Calculate total "available" (unallocated) quantity
var substitute_stock = row.available_substitute_stock || 0;
var variant_stock = row.allow_variants ? (row.available_variant_stock || 0) : 0;
var available_stock = availableQuantity(row);
var required = requiredQuantity(row);
var text = '';
if (available_stock > 0) {
text += `${available_stock}`;
}
if (available_stock < required) {
text += `<span class='fas fa-times-circle icon-red float-right' title='{% trans "Insufficient stock available" %}'></span>`;
} else {
if (row.available_substitute_stock > 0) {
text += `<span title='{% trans "Includes substitute stock" %}' class='fas fa-info-circle float-right icon-blue'></span>`;
}
text += `<span class='fas fa-check-circle icon-green float-right' title='{% trans "Sufficient stock available" %}'></span>`;
}
return text;
}
if (available_stock <= 0) {
text += `<span class='badge rounded-pill bg-danger'>{% trans "No Stock Available" %}</span>`;
} else {
var extra = '';
if ((substitute_stock > 0) && (variant_stock > 0)) {
extra = '{% trans "Includes variant and substitute stock" %}';
} else if (variant_stock > 0) {
extra = '{% trans "Includes variant stock" %}';
} else if (substitute_stock > 0) {
extra = '{% trans "Includes substitute stock" %}';
}
if (extra) {
text += `<span title='${extra}' class='fas fa-info-circle float-right icon-blue'></span>`;
}
}
return renderLink(text, url);
},
sorter: function(valA, valB, rowA, rowB) {
return availableQuantity(rowA) > availableQuantity(rowB) ? 1 : -1;
},
},
{
field: 'allocated',

View File

@ -672,7 +672,20 @@ function loadPartVariantTable(table, partId, options={}) {
field: 'in_stock',
title: '{% trans "Stock" %}',
formatter: function(value, row) {
return renderLink(value, `/part/${row.pk}/?display=part-stock`);
var base_stock = row.in_stock;
var variant_stock = row.variant_stock || 0;
var total = base_stock + variant_stock;
var text = `${total}`;
if (variant_stock > 0) {
text = `<em>${text}</em>`;
text += `<span title='{% trans "Includes variant stock" %}' class='fas fa-info-circle float-right icon-blue'></span>`;
}
return renderLink(text, `/part/${row.pk}/?display=part-stock`);
}
}
];
@ -1917,7 +1930,9 @@ function loadPriceBreakTable(table, options) {
formatNoMatches: function() {
return `{% trans "No ${human_name} information found" %}`;
},
queryParams: {part: options.part},
queryParams: {
part: options.part
},
url: options.url,
onLoadSuccess: function(tableData) {
if (linkedGraph) {
@ -2023,36 +2038,45 @@ function initPriceBreakSet(table, options) {
}
pb_new_btn.click(function() {
launchModalForm(pb_new_url,
{
success: reloadPriceBreakTable,
data: {
part: part_id,
}
}
);
constructForm(pb_new_url, {
fields: {
part: {
hidden: true,
value: part_id,
},
quantity: {},
price: {},
price_currency: {},
},
method: 'POST',
title: '{% trans "Add Price Break" %}',
onSuccess: reloadPriceBreakTable,
});
});
table.on('click', `.button-${pb_url_slug}-delete`, function() {
var pk = $(this).attr('pk');
launchModalForm(
`/part/${pb_url_slug}/${pk}/delete/`,
{
success: reloadPriceBreakTable
}
);
constructForm(`${pb_url}${pk}/`, {
method: 'DELETE',
title: '{% trans "Delete Price Break" %}',
onSuccess: reloadPriceBreakTable,
});
});
table.on('click', `.button-${pb_url_slug}-edit`, function() {
var pk = $(this).attr('pk');
launchModalForm(
`/part/${pb_url_slug}/${pk}/edit/`,
{
success: reloadPriceBreakTable
}
);
constructForm(`${pb_url}${pk}/`, {
fields: {
quantity: {},
price: {},
price_currency: {},
},
title: '{% trans "Edit Price Break" %}',
onSuccess: reloadPriceBreakTable,
});
});
}

View File

@ -7,11 +7,13 @@
{% settings_value 'STICKY_HEADER' user=request.user as sticky %}
{% navigation_enabled as plugin_nav %}
{% inventree_demo_mode as demo %}
{% inventree_show_about user as show_about %}
{% inventree_customize 'navbar_message' as navbar_message %}
<nav class="navbar {% if sticky %}fixed-top{% endif %} navbar-expand-lg navbar-light">
<div class="container-fluid">
<div class="navbar-header clearfix content-heading">
<a class="navbar-brand" id='logo' href="{% url 'index' %}" style="padding-top: 7px; padding-bottom: 5px;"><img src="{% static 'img/inventree.png' %}" width="32" height="32" style="display:block; margin: auto;"/></a>
<a class="navbar-brand" id='logo' href="{% url 'index' %}" style="padding-top: 7px; padding-bottom: 5px;"><img src="{% inventree_logo %}" width="32" height="32" style="display:block; margin: auto;"/></a>
</div>
<div class="navbar-collapse collapse" id="navbar-objects">
<ul class="navbar-nav">
@ -84,8 +86,13 @@
</ul>
</div>
{% if demo %}
{% include "navbar_demo.html" %}
{% if navbar_message %}
{% include "spacer.html" %}
<div class='flex justify-content-center'>
{{ navbar_message }}
</div>
{% include "spacer.html" %}
{% include "spacer.html" %}
{% endif %}
<ul class='navbar-nav flex-row'>
@ -144,6 +151,7 @@
{% trans "System Information" %}
</a>
</li>
{% if show_about %}
<li id='launch-about'>
<a class='dropdown-item' href='#'>
{% if up_to_date %}
@ -154,6 +162,7 @@
{% trans "About InvenTree" %}
</a>
</li>
{% endif %}
</ul>
</li>
</ul>

View File

@ -1,12 +0,0 @@
{% load i18n %}
{% include "spacer.html" %}
<div class='flex'>
<h6>
{% trans "InvenTree demo mode" %}
<a href='https://inventree.readthedocs.io/en/latest/demo/'>
<span class='fas fa-info-circle'></span>
</a>
</h6>
</div>
{% include "spacer.html" %}
{% include "spacer.html" %}

View File

@ -75,7 +75,7 @@
<script type='text/javascript' src="{% static 'script/clipboard.min.js' %}"></script>
<script type='text/javascript' src="{% static 'script/randomColor.min.js' %}"></script>
<!-- general InvenTree -->
<!-- general JS -->
<script type='text/javascript' src="{% static 'script/inventree/inventree.js' %}"></script>
<script type='text/javascript' src="{% i18n_static 'notification.js' %}"></script>
{% block body_scripts_inventree %}

View File

@ -65,8 +65,8 @@ RUN apk add --no-cache git make bash \
libjpeg-turbo libjpeg-turbo-dev jpeg jpeg-dev \
libffi libffi-dev \
zlib zlib-dev \
# Cairo deps for WeasyPrint (these will be deprecated once WeasyPrint drops cairo requirement)
cairo cairo-dev pango pango-dev \
# Special deps for WeasyPrint (these will be deprecated once WeasyPrint drops cairo requirement)
cairo cairo-dev pango pango-dev gdk-pixbuf \
# Fonts
fontconfig ttf-droid ttf-liberation ttf-dejavu ttf-opensans ttf-ubuntu-font-family font-croscore font-noto \
# Core python