mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +00:00
Merge branch 'inventree:master' into matmair/issue2385
This commit is contained in:
commit
37e74074dd
@ -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
|
||||
|
@ -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)
|
||||
|
@ -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")
|
||||
|
@ -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)
|
||||
|
@ -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,
|
||||
},
|
||||
|
@ -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')
|
||||
|
||||
|
@ -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 %}
|
||||
|
@ -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
BIN
InvenTree/locale/pt_br/LC_MESSAGES/django.mo
Normal file
BIN
InvenTree/locale/pt_br/LC_MESSAGES/django.mo
Normal file
Binary file not shown.
7859
InvenTree/locale/pt_br/LC_MESSAGES/django.po
Normal file
7859
InvenTree/locale/pt_br/LC_MESSAGES/django.po
Normal file
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
@ -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'),
|
||||
])),
|
||||
|
||||
|
@ -7,6 +7,7 @@
|
||||
part: 100
|
||||
sub_part: 1
|
||||
quantity: 10
|
||||
allow_variants: True
|
||||
|
||||
# 40 x R_2K2_0805
|
||||
- model: part.bomitem
|
||||
|
@ -177,6 +177,7 @@
|
||||
fields:
|
||||
name: 'Green chair variant'
|
||||
variant_of: 10003
|
||||
is_template: true
|
||||
category: 7
|
||||
trackable: true
|
||||
tree_id: 1
|
||||
|
@ -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):
|
||||
"""
|
||||
|
@ -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',
|
||||
|
||||
]
|
||||
|
||||
|
||||
|
@ -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'),
|
||||
},
|
||||
);
|
||||
|
@ -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 %}
|
||||
|
@ -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 %}
|
||||
|
@ -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 %}
|
||||
|
@ -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
|
||||
|
@ -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):
|
||||
"""
|
||||
|
@ -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)),
|
||||
|
||||
|
@ -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'
|
||||
|
@ -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:
|
||||
|
@ -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 %}
|
||||
|
@ -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 %}
|
||||
|
@ -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 %}
|
||||
|
@ -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)
|
19
InvenTree/stock/migrations/0074_alter_stockitem_batch.py
Normal file
19
InvenTree/stock/migrations/0074_alter_stockitem_batch.py
Normal 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'),
|
||||
),
|
||||
]
|
@ -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(
|
||||
|
@ -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 %}
|
||||
|
@ -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 %}
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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" %}
|
||||
|
@ -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'>
|
||||
|
@ -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>
|
||||
|
@ -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" %}
|
||||
|
@ -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>
|
||||
|
||||
|
@ -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>
|
||||
|
||||
|
@ -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 %}
|
||||
|
@ -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 -->
|
||||
|
@ -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 %}
|
||||
|
@ -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;
|
||||
|
@ -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',
|
||||
|
@ -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,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -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>
|
||||
|
@ -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" %}
|
@ -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 %}
|
||||
|
@ -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
|
||||
|
Loading…
Reference in New Issue
Block a user