mirror of
https://github.com/inventree/InvenTree.git
synced 2026-05-07 02:03:50 +00:00
Merge branch 'inventree:master' into matmair/issue2385
This commit is contained in:
@@ -4,11 +4,21 @@ InvenTree API version information
|
|||||||
|
|
||||||
|
|
||||||
# InvenTree API version
|
# 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
|
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
|
v40 -> 2022-04-19
|
||||||
- Adds ability to filter StockItem list by "tracked" parameter
|
- Adds ability to filter StockItem list by "tracked" parameter
|
||||||
- This checks the serial number or batch code fields
|
- This checks the serial number or batch code fields
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
from django.shortcuts import HttpResponseRedirect
|
from django.shortcuts import HttpResponseRedirect
|
||||||
from django.urls import reverse_lazy, Resolver404
|
from django.urls import reverse_lazy, Resolver404
|
||||||
from django.db import connection
|
|
||||||
from django.shortcuts import redirect
|
from django.shortcuts import redirect
|
||||||
from django.conf.urls import include, url
|
from django.conf.urls import include, url
|
||||||
|
from django.conf import settings
|
||||||
|
from django.contrib.auth.middleware import PersistentRemoteUserMiddleware
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import time
|
|
||||||
import operator
|
|
||||||
|
|
||||||
from rest_framework.authtoken.models import Token
|
from rest_framework.authtoken.models import Token
|
||||||
from allauth_2fa.middleware import BaseRequire2FAMiddleware, AllauthTwoFactorMiddleware
|
from allauth_2fa.middleware import BaseRequire2FAMiddleware, AllauthTwoFactorMiddleware
|
||||||
@@ -92,67 +92,6 @@ class AuthRequiredMiddleware(object):
|
|||||||
return response
|
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))
|
url_matcher = url('', include(frontendpatterns))
|
||||||
|
|
||||||
|
|
||||||
@@ -176,3 +115,16 @@ class CustomAllauthTwoFactorMiddleware(AllauthTwoFactorMiddleware):
|
|||||||
super().process_request(request)
|
super().process_request(request)
|
||||||
except Resolver404:
|
except Resolver404:
|
||||||
pass
|
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
|
import yaml
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from django.contrib.messages import constants as messages
|
from django.contrib.messages import constants as messages
|
||||||
|
from django.core.files.storage import default_storage
|
||||||
import django.conf.locale
|
import django.conf.locale
|
||||||
|
|
||||||
from .config import get_base_dir, get_config_file, get_plugin_file, get_setting
|
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',
|
'django.middleware.csrf.CsrfViewMiddleware',
|
||||||
'corsheaders.middleware.CorsMiddleware',
|
'corsheaders.middleware.CorsMiddleware',
|
||||||
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
||||||
|
'InvenTree.middleware.InvenTreeRemoteUserMiddleware', # Remote / proxy auth
|
||||||
'django_otp.middleware.OTPMiddleware', # MFA support
|
'django_otp.middleware.OTPMiddleware', # MFA support
|
||||||
'InvenTree.middleware.CustomAllauthTwoFactorMiddleware', # Flow control for allauth
|
'InvenTree.middleware.CustomAllauthTwoFactorMiddleware', # Flow control for allauth
|
||||||
'django.contrib.messages.middleware.MessageMiddleware',
|
'django.contrib.messages.middleware.MessageMiddleware',
|
||||||
@@ -302,6 +304,7 @@ MIDDLEWARE = CONFIG.get('middleware', [
|
|||||||
MIDDLEWARE.append('error_report.middleware.ExceptionProcessor')
|
MIDDLEWARE.append('error_report.middleware.ExceptionProcessor')
|
||||||
|
|
||||||
AUTHENTICATION_BACKENDS = CONFIG.get('authentication_backends', [
|
AUTHENTICATION_BACKENDS = CONFIG.get('authentication_backends', [
|
||||||
|
'django.contrib.auth.backends.RemoteUserBackend', # proxy login
|
||||||
'django.contrib.auth.backends.ModelBackend',
|
'django.contrib.auth.backends.ModelBackend',
|
||||||
'allauth.account.auth_backends.AuthenticationBackend', # SSO login via external providers
|
'allauth.account.auth_backends.AuthenticationBackend', # SSO login via external providers
|
||||||
])
|
])
|
||||||
@@ -687,7 +690,8 @@ LANGUAGES = [
|
|||||||
('nl', _('Dutch')),
|
('nl', _('Dutch')),
|
||||||
('no', _('Norwegian')),
|
('no', _('Norwegian')),
|
||||||
('pl', _('Polish')),
|
('pl', _('Polish')),
|
||||||
('pt', _('Portugese')),
|
('pt', _('Portuguese')),
|
||||||
|
('pt-BR', _('Portuguese (Brazilian)')),
|
||||||
('ru', _('Russian')),
|
('ru', _('Russian')),
|
||||||
('sv', _('Swedish')),
|
('sv', _('Swedish')),
|
||||||
('th', _('Thai')),
|
('th', _('Thai')),
|
||||||
@@ -853,6 +857,10 @@ ACCOUNT_FORMS = {
|
|||||||
SOCIALACCOUNT_ADAPTER = 'InvenTree.forms.CustomSocialAccountAdapter'
|
SOCIALACCOUNT_ADAPTER = 'InvenTree.forms.CustomSocialAccountAdapter'
|
||||||
ACCOUNT_ADAPTER = 'InvenTree.forms.CustomAccountAdapter'
|
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
|
# Markdownx configuration
|
||||||
# Ref: https://neutronx.github.io/django-markdownx/customization/
|
# Ref: https://neutronx.github.io/django-markdownx/customization/
|
||||||
MARKDOWNX_MEDIA_PATH = datetime.now().strftime('markdownx/%Y/%m/%d')
|
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_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_RETRY = get_setting('PLUGIN_RETRY', 5) # how often should plugin loading be tried?
|
||||||
PLUGIN_FILE_CHECKED = False # Was the plugin file checked?
|
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],
|
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:
|
if location:
|
||||||
# Filter only stock items located "below" the specified location
|
# Filter only stock items located "below" the specified location
|
||||||
sublocations = location.get_descendants(include_self=True)
|
sublocations = location.get_descendants(include_self=True)
|
||||||
|
|||||||
@@ -771,7 +771,7 @@ class InvenTreeSetting(BaseInvenTreeSetting):
|
|||||||
},
|
},
|
||||||
|
|
||||||
'INVENTREE_INSTANCE': {
|
'INVENTREE_INSTANCE': {
|
||||||
'name': _('InvenTree Instance Name'),
|
'name': _('Server Instance Name'),
|
||||||
'default': 'InvenTree server',
|
'default': 'InvenTree server',
|
||||||
'description': _('String descriptor for the server instance'),
|
'description': _('String descriptor for the server instance'),
|
||||||
},
|
},
|
||||||
@@ -783,6 +783,13 @@ class InvenTreeSetting(BaseInvenTreeSetting):
|
|||||||
'default': False,
|
'default': False,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
'INVENTREE_RESTRICT_ABOUT': {
|
||||||
|
'name': _('Restrict showing `about`'),
|
||||||
|
'description': _('Show the `about` modal only to superusers'),
|
||||||
|
'validator': bool,
|
||||||
|
'default': False,
|
||||||
|
},
|
||||||
|
|
||||||
'INVENTREE_COMPANY_NAME': {
|
'INVENTREE_COMPANY_NAME': {
|
||||||
'name': _('Company name'),
|
'name': _('Company name'),
|
||||||
'description': _('Internal company name'),
|
'description': _('Internal company name'),
|
||||||
@@ -1019,6 +1026,12 @@ class InvenTreeSetting(BaseInvenTreeSetting):
|
|||||||
'validator': bool,
|
'validator': bool,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
'STOCK_BATCH_CODE_TEMPLATE': {
|
||||||
|
'name': _('Batch Code Template'),
|
||||||
|
'description': _('Template for generating default batch codes for stock items'),
|
||||||
|
'default': '',
|
||||||
|
},
|
||||||
|
|
||||||
'STOCK_ENABLE_EXPIRY': {
|
'STOCK_ENABLE_EXPIRY': {
|
||||||
'name': _('Stock Expiry'),
|
'name': _('Stock Expiry'),
|
||||||
'description': _('Enable stock expiry functionality'),
|
'description': _('Enable stock expiry functionality'),
|
||||||
@@ -1426,7 +1439,7 @@ class InvenTreeUserSetting(BaseInvenTreeSetting):
|
|||||||
|
|
||||||
'STICKY_HEADER': {
|
'STICKY_HEADER': {
|
||||||
'name': _('Fixed Navbar'),
|
'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,
|
'default': False,
|
||||||
'validator': bool,
|
'validator': bool,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -63,8 +63,8 @@ class SettingsTest(TestCase):
|
|||||||
report_test_obj = InvenTreeSetting.get_setting_object('REPORT_ENABLE_TEST_REPORT')
|
report_test_obj = InvenTreeSetting.get_setting_object('REPORT_ENABLE_TEST_REPORT')
|
||||||
|
|
||||||
# check settings base fields
|
# check settings base fields
|
||||||
self.assertEqual(instance_obj.name, 'InvenTree Instance Name')
|
self.assertEqual(instance_obj.name, 'Server Instance Name')
|
||||||
self.assertEqual(instance_obj.get_setting_name(instance_ref), 'InvenTree 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.description, 'String descriptor for the server instance')
|
||||||
self.assertEqual(instance_obj.get_setting_description(instance_ref), '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" %}
|
{% extends "page_base.html" %}
|
||||||
{% load static %}
|
{% load static %}
|
||||||
{% load i18n %}
|
{% load i18n %}
|
||||||
|
{% load inventree_extras %}
|
||||||
|
|
||||||
{% block page_title %}
|
{% block page_title %}
|
||||||
InvenTree | {% trans "Manufacturer Part" %}
|
{% inventree_title %} | {% trans "Manufacturer Part" %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block sidebar %}
|
{% block sidebar %}
|
||||||
|
|||||||
@@ -154,6 +154,14 @@ static_root: '/home/inventree/data/static'
|
|||||||
# Use environment variable INVENTREE_LOGIN_ATTEMPTS
|
# Use environment variable INVENTREE_LOGIN_ATTEMPTS
|
||||||
#login_attempts: 5
|
#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
|
# Add new user on first startup
|
||||||
#admin_user: admin
|
#admin_user: admin
|
||||||
#admin_email: info@example.com
|
#admin_email: info@example.com
|
||||||
@@ -186,3 +194,10 @@ static_root: '/home/inventree/data/static'
|
|||||||
# KEYCLOAK_URL: 'https://keycloak.custom/auth'
|
# KEYCLOAK_URL: 'https://keycloak.custom/auth'
|
||||||
# KEYCLOAK_REALM: 'master'
|
# 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
|
||||||
|
|||||||
+1091
-1076
File diff suppressed because it is too large
Load Diff
+1264
-1215
File diff suppressed because it is too large
Load Diff
+1265
-1216
File diff suppressed because it is too large
Load Diff
+1251
-1202
File diff suppressed because it is too large
Load Diff
+3340
-1056
File diff suppressed because it is too large
Load Diff
+1246
-1197
File diff suppressed because it is too large
Load Diff
+1238
-1189
File diff suppressed because it is too large
Load Diff
+1260
-1211
File diff suppressed because it is too large
Load Diff
+1265
-1216
File diff suppressed because it is too large
Load Diff
+1234
-1185
File diff suppressed because it is too large
Load Diff
+1341
-1292
File diff suppressed because it is too large
Load Diff
+1244
-1195
File diff suppressed because it is too large
Load Diff
+1305
-1256
File diff suppressed because it is too large
Load Diff
+1251
-1202
File diff suppressed because it is too large
Load Diff
+1262
-1213
File diff suppressed because it is too large
Load Diff
+1282
-1233
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
+1241
-1192
File diff suppressed because it is too large
Load Diff
+1237
-1188
File diff suppressed because it is too large
Load Diff
+1265
-1216
File diff suppressed because it is too large
Load Diff
+1238
-1189
File diff suppressed because it is too large
Load Diff
+1265
-1216
File diff suppressed because it is too large
Load Diff
+1232
-1183
File diff suppressed because it is too large
Load Diff
+32
-4
@@ -262,6 +262,15 @@ class CategoryTree(generics.ListAPIView):
|
|||||||
ordering = ['level', 'name']
|
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):
|
class PartSalePriceList(generics.ListCreateAPIView):
|
||||||
"""
|
"""
|
||||||
API endpoint for list view of PartSalePriceBreak model
|
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):
|
class PartInternalPriceList(generics.ListCreateAPIView):
|
||||||
"""
|
"""
|
||||||
API endpoint for list view of PartInternalPriceBreak model
|
API endpoint for list view of PartInternalPriceBreak model
|
||||||
@@ -1175,6 +1193,18 @@ class PartList(generics.ListCreateAPIView):
|
|||||||
except (ValueError, Part.DoesNotExist):
|
except (ValueError, Part.DoesNotExist):
|
||||||
pass
|
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
|
# Filter only parts which are in the "BOM" for a given part
|
||||||
in_bom_for = params.get('in_bom_for', None)
|
in_bom_for = params.get('in_bom_for', None)
|
||||||
|
|
||||||
@@ -1339,10 +1369,6 @@ class PartList(generics.ListCreateAPIView):
|
|||||||
filters.OrderingFilter,
|
filters.OrderingFilter,
|
||||||
]
|
]
|
||||||
|
|
||||||
filter_fields = [
|
|
||||||
'variant_of',
|
|
||||||
]
|
|
||||||
|
|
||||||
ordering_fields = [
|
ordering_fields = [
|
||||||
'name',
|
'name',
|
||||||
'creation_date',
|
'creation_date',
|
||||||
@@ -1912,11 +1938,13 @@ part_api_urls = [
|
|||||||
|
|
||||||
# Base URL for part sale pricing
|
# Base URL for part sale pricing
|
||||||
url(r'^sale-price/', include([
|
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'),
|
url(r'^.*$', PartSalePriceList.as_view(), name='api-part-sale-price-list'),
|
||||||
])),
|
])),
|
||||||
|
|
||||||
# Base URL for part internal pricing
|
# Base URL for part internal pricing
|
||||||
url(r'^internal-price/', include([
|
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'),
|
url(r'^.*$', PartInternalPriceList.as_view(), name='api-part-internal-price-list'),
|
||||||
])),
|
])),
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
part: 100
|
part: 100
|
||||||
sub_part: 1
|
sub_part: 1
|
||||||
quantity: 10
|
quantity: 10
|
||||||
|
allow_variants: True
|
||||||
|
|
||||||
# 40 x R_2K2_0805
|
# 40 x R_2K2_0805
|
||||||
- model: part.bomitem
|
- model: part.bomitem
|
||||||
|
|||||||
@@ -177,6 +177,7 @@
|
|||||||
fields:
|
fields:
|
||||||
name: 'Green chair variant'
|
name: 'Green chair variant'
|
||||||
variant_of: 10003
|
variant_of: 10003
|
||||||
|
is_template: true
|
||||||
category: 7
|
category: 7
|
||||||
trackable: true
|
trackable: true
|
||||||
tree_id: 1
|
tree_id: 1
|
||||||
|
|||||||
@@ -2732,7 +2732,21 @@ class BomItem(models.Model, DataImportMixin):
|
|||||||
for sub in self.substitutes.all():
|
for sub in self.substitutes.all():
|
||||||
parts.add(sub.part)
|
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):
|
def is_stock_item_valid(self, stock_item):
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ from decimal import Decimal
|
|||||||
|
|
||||||
from django.urls import reverse_lazy
|
from django.urls import reverse_lazy
|
||||||
from django.db import models, transaction
|
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.db.models.functions import Coalesce
|
||||||
from django.utils.translation import ugettext_lazy as _
|
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 sql_util.utils import SubqueryCount, SubquerySum
|
||||||
from djmoney.contrib.django_rest_framework import MoneyField
|
from djmoney.contrib.django_rest_framework import MoneyField
|
||||||
|
|
||||||
|
from common.settings import currency_code_default, currency_code_mappings
|
||||||
|
|
||||||
from InvenTree.serializers import (DataFileUploadSerializer,
|
from InvenTree.serializers import (DataFileUploadSerializer,
|
||||||
DataFileExtractSerializer,
|
DataFileExtractSerializer,
|
||||||
InvenTreeAttachmentSerializerField,
|
InvenTreeAttachmentSerializerField,
|
||||||
@@ -146,6 +150,13 @@ class PartSalePriceSerializer(InvenTreeModelSerializer):
|
|||||||
allow_null=True
|
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)
|
price_string = serializers.CharField(source='price', read_only=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
@@ -155,6 +166,7 @@ class PartSalePriceSerializer(InvenTreeModelSerializer):
|
|||||||
'part',
|
'part',
|
||||||
'quantity',
|
'quantity',
|
||||||
'price',
|
'price',
|
||||||
|
'price_currency',
|
||||||
'price_string',
|
'price_string',
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -170,6 +182,13 @@ class PartInternalPriceSerializer(InvenTreeModelSerializer):
|
|||||||
allow_null=True
|
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)
|
price_string = serializers.CharField(source='price', read_only=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
@@ -179,6 +198,7 @@ class PartInternalPriceSerializer(InvenTreeModelSerializer):
|
|||||||
'part',
|
'part',
|
||||||
'quantity',
|
'quantity',
|
||||||
'price',
|
'price',
|
||||||
|
'price_currency',
|
||||||
'price_string',
|
'price_string',
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -308,9 +328,6 @@ class PartSerializer(InvenTreeModelSerializer):
|
|||||||
to reduce database trips.
|
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
|
# Annotate with the total 'in stock' quantity
|
||||||
queryset = queryset.annotate(
|
queryset = queryset.annotate(
|
||||||
in_stock=Coalesce(
|
in_stock=Coalesce(
|
||||||
@@ -325,6 +342,24 @@ class PartSerializer(InvenTreeModelSerializer):
|
|||||||
stock_item_count=SubqueryCount('stock_items')
|
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"
|
# Filter to limit builds to "active"
|
||||||
build_filter = Q(
|
build_filter = Q(
|
||||||
status__in=BuildStatus.ACTIVE_CODES
|
status__in=BuildStatus.ACTIVE_CODES
|
||||||
@@ -429,6 +464,7 @@ class PartSerializer(InvenTreeModelSerializer):
|
|||||||
unallocated_stock = serializers.FloatField(read_only=True)
|
unallocated_stock = serializers.FloatField(read_only=True)
|
||||||
building = serializers.FloatField(read_only=True)
|
building = serializers.FloatField(read_only=True)
|
||||||
in_stock = 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)
|
ordering = serializers.FloatField(read_only=True)
|
||||||
stock_item_count = serializers.IntegerField(read_only=True)
|
stock_item_count = serializers.IntegerField(read_only=True)
|
||||||
suppliers = serializers.IntegerField(read_only=True)
|
suppliers = serializers.IntegerField(read_only=True)
|
||||||
@@ -463,6 +499,7 @@ class PartSerializer(InvenTreeModelSerializer):
|
|||||||
'full_name',
|
'full_name',
|
||||||
'image',
|
'image',
|
||||||
'in_stock',
|
'in_stock',
|
||||||
|
'variant_stock',
|
||||||
'ordering',
|
'ordering',
|
||||||
'building',
|
'building',
|
||||||
'IPN',
|
'IPN',
|
||||||
@@ -577,9 +614,10 @@ class BomItemSerializer(InvenTreeModelSerializer):
|
|||||||
|
|
||||||
purchase_price_range = serializers.SerializerMethodField()
|
purchase_price_range = serializers.SerializerMethodField()
|
||||||
|
|
||||||
# Annotated fields
|
# Annotated fields for available stock
|
||||||
available_stock = serializers.FloatField(read_only=True)
|
available_stock = serializers.FloatField(read_only=True)
|
||||||
available_substitute_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):
|
def __init__(self, *args, **kwargs):
|
||||||
# part_detail and sub_part_detail serializers are only included if requested.
|
# 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')
|
||||||
queryset = queryset.prefetch_related('sub_part__category')
|
queryset = queryset.prefetch_related('sub_part__category')
|
||||||
|
|
||||||
queryset = queryset.prefetch_related(
|
queryset = queryset.prefetch_related(
|
||||||
'sub_part__stock_items',
|
'sub_part__stock_items',
|
||||||
'sub_part__stock_items__allocations',
|
'sub_part__stock_items__allocations',
|
||||||
'sub_part__stock_items__sales_order_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')
|
queryset = queryset.prefetch_related('sub_part__supplier_parts__pricebreaks')
|
||||||
return queryset
|
return queryset
|
||||||
|
|
||||||
@@ -707,7 +752,7 @@ class BomItemSerializer(InvenTreeModelSerializer):
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Calculate 'available_variant_stock' field
|
# Calculate 'available_substitute_stock' field
|
||||||
queryset = queryset.annotate(
|
queryset = queryset.annotate(
|
||||||
available_substitute_stock=ExpressionWrapper(
|
available_substitute_stock=ExpressionWrapper(
|
||||||
F('substitute_stock') - F('substitute_build_allocations') - F('substitute_sales_allocations'),
|
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
|
return queryset
|
||||||
|
|
||||||
def get_purchase_price_range(self, obj):
|
def get_purchase_price_range(self, obj):
|
||||||
@@ -790,6 +876,8 @@ class BomItemSerializer(InvenTreeModelSerializer):
|
|||||||
# Annotated fields describing available quantity
|
# Annotated fields describing available quantity
|
||||||
'available_stock',
|
'available_stock',
|
||||||
'available_substitute_stock',
|
'available_substitute_stock',
|
||||||
|
'available_variant_stock',
|
||||||
|
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -124,8 +124,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% settings_value "PART_SHOW_PRICE_HISTORY" as show_price_history %}
|
{% if part.purchaseable or part.salable %}
|
||||||
{% if show_price_history %}
|
|
||||||
<div class='panel panel-hidden' id='panel-pricing'>
|
<div class='panel panel-hidden' id='panel-pricing'>
|
||||||
{% include "part/prices.html" %}
|
{% include "part/prices.html" %}
|
||||||
</div>
|
</div>
|
||||||
@@ -1009,7 +1008,7 @@
|
|||||||
pb_url_slug: 'internal-price',
|
pb_url_slug: 'internal-price',
|
||||||
pb_url: '{% url 'api-part-internal-price-list' %}',
|
pb_url: '{% url 'api-part-internal-price-list' %}',
|
||||||
pb_new_btn: $('#new-internal-price-break'),
|
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'),
|
linkedGraph: $('#InternalPriceBreakChart'),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -1025,7 +1024,7 @@
|
|||||||
pb_url_slug: 'sale-price',
|
pb_url_slug: 'sale-price',
|
||||||
pb_url: "{% url 'api-part-sale-price-list' %}",
|
pb_url: "{% url 'api-part-sale-price-list' %}",
|
||||||
pb_new_btn: $('#new-price-break'),
|
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'),
|
linkedGraph: $('#SalePriceBreakChart'),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -211,44 +211,18 @@
|
|||||||
{% if part.component %}
|
{% if part.component %}
|
||||||
{% if required_build_order_quantity > 0 %}
|
{% if required_build_order_quantity > 0 %}
|
||||||
<tr>
|
<tr>
|
||||||
<td><span class='fas fa-clipboard-list'></span></td>
|
<td><span class='fas fa-tools'></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>{% trans "Allocated to Build Orders" %}</td>
|
<td>{% trans "Allocated to Build Orders" %}</td>
|
||||||
<td>
|
<td>{% progress_bar allocated_build_order_quantity required_build_order_quantity id='build-order-allocated' max_width='150px' %}</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>
|
|
||||||
</tr>
|
</tr>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if part.salable %}
|
{% if part.salable %}
|
||||||
{% if required_sales_order_quantity > 0 %}
|
{% if required_sales_order_quantity > 0 %}
|
||||||
<tr>
|
<tr>
|
||||||
<td><span class='fas fa-clipboard-list'></span></td>
|
<td><span class='fas fa-truck'></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>{% trans "Allocated to Sales Orders" %}</td>
|
<td>{% trans "Allocated to Sales Orders" %}</td>
|
||||||
<td>
|
<td>{% progress_bar allocated_sales_order_quantity required_sales_order_quantity id='sales-order-allocated' max_width='150px' %}</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>
|
|
||||||
</tr>
|
</tr>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -4,7 +4,6 @@
|
|||||||
|
|
||||||
{% settings_value "PART_INTERNAL_PRICE" as show_internal_price %}
|
{% settings_value "PART_INTERNAL_PRICE" as show_internal_price %}
|
||||||
{% settings_value 'PART_SHOW_RELATED' as show_related %}
|
{% settings_value 'PART_SHOW_RELATED' as show_related %}
|
||||||
{% settings_value "PART_SHOW_PRICE_HISTORY" as show_price_history %}
|
|
||||||
|
|
||||||
{% trans "Parameters" as text %}
|
{% trans "Parameters" as text %}
|
||||||
{% include "sidebar_item.html" with label="part-parameters" text=text icon="fa-th-list" %}
|
{% include "sidebar_item.html" with label="part-parameters" text=text icon="fa-th-list" %}
|
||||||
@@ -28,7 +27,7 @@
|
|||||||
{% trans "Used In" as text %}
|
{% trans "Used In" as text %}
|
||||||
{% include "sidebar_item.html" with label="used-in" text=text icon="fa-layer-group" %}
|
{% include "sidebar_item.html" with label="used-in" text=text icon="fa-layer-group" %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if show_price_history %}
|
{% if part.purchaseable or part.salable %}
|
||||||
{% trans "Pricing" as text %}
|
{% trans "Pricing" as text %}
|
||||||
{% include "sidebar_item.html" with label="pricing" text=text icon="fa-dollar-sign" %}
|
{% include "sidebar_item.html" with label="pricing" text=text icon="fa-dollar-sign" %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -3,6 +3,9 @@
|
|||||||
{% load crispy_forms_tags %}
|
{% load crispy_forms_tags %}
|
||||||
{% load inventree_extras %}
|
{% load inventree_extras %}
|
||||||
|
|
||||||
|
{% settings_value "PART_INTERNAL_PRICE" as show_internal_price %}
|
||||||
|
|
||||||
|
{% if show_price_history %}
|
||||||
<div class='panel-heading'>
|
<div class='panel-heading'>
|
||||||
<h4>{% trans "Pricing Information" %}</h4>
|
<h4>{% trans "Pricing Information" %}</h4>
|
||||||
</div>
|
</div>
|
||||||
@@ -43,7 +46,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if part.bom_count > 0 %}
|
{% if part.assembly and part.bom_count > 0 %}
|
||||||
{% if min_total_bom_price %}
|
{% if min_total_bom_price %}
|
||||||
<tr>
|
<tr>
|
||||||
<td><strong>{% trans 'BOM Pricing' %}</strong>
|
<td><strong>{% trans 'BOM Pricing' %}</strong>
|
||||||
@@ -147,7 +150,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% settings_value "PART_INTERNAL_PRICE" as show_internal_price %}
|
{% endif %}
|
||||||
|
|
||||||
{% if part.purchaseable and roles.purchase_order.view %}
|
{% if part.purchaseable and roles.purchase_order.view %}
|
||||||
<a class="anchor" id="supplier-cost"></a>
|
<a class="anchor" id="supplier-cost"></a>
|
||||||
@@ -170,7 +173,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if price_history %}
|
{% if show_price_history %}
|
||||||
<a class="anchor" id="purchase-price"></a>
|
<a class="anchor" id="purchase-price"></a>
|
||||||
<div class='panel-heading'>
|
<div class='panel-heading'>
|
||||||
<h4>{% trans "Purchase Price" %}
|
<h4>{% trans "Purchase Price" %}
|
||||||
@@ -279,6 +282,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{% if show_price_history %}
|
||||||
<a class="anchor" id="sale-price"></a>
|
<a class="anchor" id="sale-price"></a>
|
||||||
<div class='panel-heading'>
|
<div class='panel-heading'>
|
||||||
<h4>{% trans "Sale Price" %}
|
<h4>{% trans "Sale Price" %}
|
||||||
@@ -298,3 +302,5 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
@@ -18,7 +18,8 @@ from django.conf import settings as djangosettings
|
|||||||
from django import template
|
from django import template
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils.safestring import mark_safe
|
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
|
from InvenTree import version, settings
|
||||||
|
|
||||||
@@ -166,6 +167,14 @@ def inventree_demo_mode(*args, **kwargs):
|
|||||||
return djangosettings.DEMO_MODE
|
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()
|
@register.simple_tag()
|
||||||
def inventree_docker_mode(*args, **kwargs):
|
def inventree_docker_mode(*args, **kwargs):
|
||||||
""" Return True if the server is running as a Docker image """
|
""" Return True if the server is running as a Docker image """
|
||||||
@@ -220,8 +229,13 @@ def python_version(*args, **kwargs):
|
|||||||
|
|
||||||
|
|
||||||
@register.simple_tag()
|
@register.simple_tag()
|
||||||
def inventree_version(*args, **kwargs):
|
def inventree_version(shortstring=False, *args, **kwargs):
|
||||||
""" Return InvenTree version string """
|
""" Return InvenTree version string """
|
||||||
|
if shortstring:
|
||||||
|
return _("{title} v{version}".format(
|
||||||
|
title=version.inventreeInstanceTitle(),
|
||||||
|
version=version.inventreeVersion()
|
||||||
|
))
|
||||||
return version.inventreeVersion()
|
return version.inventreeVersion()
|
||||||
|
|
||||||
|
|
||||||
@@ -355,21 +369,24 @@ def visible_global_settings(*args, **kwargs):
|
|||||||
|
|
||||||
|
|
||||||
@register.simple_tag()
|
@register.simple_tag()
|
||||||
def progress_bar(val, max, *args, **kwargs):
|
def progress_bar(val, max_val, *args, **kwargs):
|
||||||
"""
|
"""
|
||||||
Render a progress bar element
|
Render a progress bar element
|
||||||
"""
|
"""
|
||||||
|
|
||||||
item_id = kwargs.get('id', 'progress-bar')
|
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'
|
style = 'progress-bar-over'
|
||||||
elif val < max:
|
elif val < max_val:
|
||||||
style = 'progress-bar-under'
|
style = 'progress-bar-under'
|
||||||
else:
|
else:
|
||||||
style = ''
|
style = ''
|
||||||
|
|
||||||
percent = float(val / max) * 100
|
percent = float(val / max_val) * 100
|
||||||
|
|
||||||
if percent > 100:
|
if percent > 100:
|
||||||
percent = 100
|
percent = 100
|
||||||
@@ -386,7 +403,7 @@ def progress_bar(val, max, *args, **kwargs):
|
|||||||
html = f"""
|
html = f"""
|
||||||
<div id='{item_id}' class='progress' style='{" ".join(style_tags)}'>
|
<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-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>
|
</div>
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -512,6 +529,22 @@ def mail_configured():
|
|||||||
return bool(settings.EMAIL_HOST)
|
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):
|
class I18nStaticNode(StaticNode):
|
||||||
"""
|
"""
|
||||||
custom StaticNode
|
custom StaticNode
|
||||||
|
|||||||
@@ -567,6 +567,185 @@ class PartAPITest(InvenTreeAPITestCase):
|
|||||||
self.assertEqual(response.data['name'], name)
|
self.assertEqual(response.data['name'], name)
|
||||||
self.assertEqual(response.data['description'], description)
|
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):
|
class PartDetailTests(InvenTreeAPITestCase):
|
||||||
"""
|
"""
|
||||||
@@ -1450,6 +1629,44 @@ class BomItemTest(InvenTreeAPITestCase):
|
|||||||
|
|
||||||
self.assertEqual(len(response.data), i)
|
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):
|
class PartParameterTest(InvenTreeAPITestCase):
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -13,18 +13,6 @@ from django.conf.urls import url, include
|
|||||||
from . import views
|
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 = [
|
part_parameter_urls = [
|
||||||
url(r'^template/new/', views.PartParameterTemplateCreate.as_view(), name='part-param-template-create'),
|
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'),
|
url(r'^template/(?P<pk>\d+)/edit/', views.PartParameterTemplateEdit.as_view(), name='part-param-template-edit'),
|
||||||
@@ -86,12 +74,6 @@ part_urls = [
|
|||||||
# Part category
|
# Part category
|
||||||
url(r'^category/', include(category_urls)),
|
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
|
# Part parameters
|
||||||
url(r'^parameter/', include(part_parameter_urls)),
|
url(r'^parameter/', include(part_parameter_urls)),
|
||||||
|
|
||||||
|
|||||||
+5
-102
@@ -18,7 +18,6 @@ from django.forms import HiddenInput
|
|||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib import messages
|
from django.contrib import messages
|
||||||
|
|
||||||
from moneyed import CURRENCIES
|
|
||||||
from djmoney.contrib.exchange.models import convert_money
|
from djmoney.contrib.exchange.models import convert_money
|
||||||
from djmoney.contrib.exchange.exceptions import MissingRate
|
from djmoney.contrib.exchange.exceptions import MissingRate
|
||||||
|
|
||||||
@@ -33,7 +32,6 @@ from decimal import Decimal
|
|||||||
from .models import PartCategory, Part
|
from .models import PartCategory, Part
|
||||||
from .models import PartParameterTemplate
|
from .models import PartParameterTemplate
|
||||||
from .models import PartCategoryParameterTemplate
|
from .models import PartCategoryParameterTemplate
|
||||||
from .models import PartSellPriceBreak, PartInternalPriceBreak
|
|
||||||
|
|
||||||
from common.models import InvenTreeSetting
|
from common.models import InvenTreeSetting
|
||||||
from company.models import SupplierPart
|
from company.models import SupplierPart
|
||||||
@@ -389,8 +387,12 @@ class PartDetail(InvenTreeRoleMixin, DetailView):
|
|||||||
|
|
||||||
context.update(**ctx)
|
context.update(**ctx)
|
||||||
|
|
||||||
|
show_price_history = InvenTreeSetting.get_setting('PART_SHOW_PRICE_HISTORY', False)
|
||||||
|
|
||||||
|
context['show_price_history'] = show_price_history
|
||||||
|
|
||||||
# Pricing information
|
# Pricing information
|
||||||
if InvenTreeSetting.get_setting('PART_SHOW_PRICE_HISTORY', False):
|
if show_price_history:
|
||||||
ctx = self.get_pricing(self.get_quantity())
|
ctx = self.get_pricing(self.get_quantity())
|
||||||
ctx['form'] = self.form_class(initial=self.get_initials())
|
ctx['form'] = self.form_class(initial=self.get_initials())
|
||||||
|
|
||||||
@@ -1226,102 +1228,3 @@ class CategoryParameterTemplateDelete(AjaxDeleteView):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
return self.object
|
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
|
@property
|
||||||
def api_headers(self):
|
def api_headers(self):
|
||||||
return {
|
headers = {'Content-Type': 'application/json'}
|
||||||
self.API_TOKEN: self.get_setting(self.API_TOKEN_SETTING),
|
if getattr(self, 'API_TOKEN_SETTING'):
|
||||||
'Content-Type': 'application/json'
|
headers[self.API_TOKEN] = self.get_setting(self.API_TOKEN_SETTING)
|
||||||
}
|
return headers
|
||||||
|
|
||||||
def api_build_url_args(self, arguments):
|
def api_build_url_args(self, arguments):
|
||||||
groups = []
|
groups = []
|
||||||
@@ -515,16 +515,21 @@ class APICallMixin:
|
|||||||
groups.append(f'{key}={",".join([str(a) for a in val])}')
|
groups.append(f'{key}={",".join([str(a) for a in val])}')
|
||||||
return f'?{"&".join(groups)}'
|
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:
|
if url_args:
|
||||||
endpoint += self.api_build_url_args(url_args)
|
endpoint += self.api_build_url_args(url_args)
|
||||||
|
|
||||||
if headers is None:
|
if headers is None:
|
||||||
headers = self.api_headers
|
headers = self.api_headers
|
||||||
|
|
||||||
|
if endpoint_is_url:
|
||||||
|
url = endpoint
|
||||||
|
else:
|
||||||
|
url = f'{self.api_url}/{endpoint}'
|
||||||
|
|
||||||
# build kwargs for call
|
# build kwargs for call
|
||||||
kwargs = {
|
kwargs = {
|
||||||
'url': f'{self.api_url}/{endpoint}',
|
'url': url,
|
||||||
'headers': headers,
|
'headers': headers,
|
||||||
}
|
}
|
||||||
if data:
|
if data:
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ content: "v{{report_revision}} - {{ date.isoformat }}";
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block bottom_center %}
|
{% block bottom_center %}
|
||||||
content: "InvenTree v{% inventree_version %}";
|
content: "{% inventree_version shortstring=True %}";
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block style %}
|
{% block style %}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ content: "v{{report_revision}} - {{ date.isoformat }}";
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block bottom_center %}
|
{% block bottom_center %}
|
||||||
content: "InvenTree v{% inventree_version %}";
|
content: "{% inventree_version shortstring=True %}";
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block style %}
|
{% block style %}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ content: "{{ date.isoformat }}";
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block bottom_center %}
|
{% block bottom_center %}
|
||||||
content: "InvenTree v{% inventree_version %}";
|
content: "{% inventree_version shortstring=True %}";
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block top_center %}
|
{% 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)
|
|
||||||
@@ -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
|
import os
|
||||||
|
|
||||||
|
from jinja2 import Template
|
||||||
|
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from django.core.exceptions import ValidationError, FieldError
|
from django.core.exceptions import ValidationError, FieldError
|
||||||
from django.urls import reverse
|
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):
|
class StockItem(MPTTModel):
|
||||||
"""
|
"""
|
||||||
A StockItem object represents a quantity of physical instances of a part.
|
A StockItem object represents a quantity of physical instances of a part.
|
||||||
@@ -644,7 +672,8 @@ class StockItem(MPTTModel):
|
|||||||
batch = models.CharField(
|
batch = models.CharField(
|
||||||
verbose_name=_('Batch Code'),
|
verbose_name=_('Batch Code'),
|
||||||
max_length=100, blank=True, null=True,
|
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(
|
quantity = models.DecimalField(
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% load i18n %}
|
{% load i18n %}
|
||||||
|
{% load inventree_extras %}
|
||||||
|
|
||||||
{% block page_title %}
|
{% block page_title %}
|
||||||
InvenTree | {% trans "Permission Denied" %}
|
{% inventree_title %} | {% trans "Permission Denied" %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% load i18n %}
|
{% load i18n %}
|
||||||
|
{% load inventree_extras %}
|
||||||
|
|
||||||
{% block page_title %}
|
{% block page_title %}
|
||||||
InvenTree | {% trans "Page Not Found" %}
|
{% inventree_title %} | {% trans "Page Not Found" %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% load i18n %}
|
{% load i18n %}
|
||||||
|
{% load inventree_extras %}
|
||||||
|
|
||||||
{% block page_title %}
|
{% block page_title %}
|
||||||
InvenTree | {% trans "Internal Server Error" %}
|
{% inventree_title %} | {% trans "Internal Server Error" %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
@@ -11,7 +12,7 @@ InvenTree | {% trans "Internal Server Error" %}
|
|||||||
<h3>{% trans "Internal Server Error" %}</h3>
|
<h3>{% trans "Internal Server Error" %}</h3>
|
||||||
|
|
||||||
<div class='alert alert-danger alert-block'>
|
<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" %}
|
{% trans "Refer to the error log in the admin interface for further details" %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -30,7 +30,7 @@
|
|||||||
<div class='container-fluid'>
|
<div class='container-fluid'>
|
||||||
|
|
||||||
<div class='clearfix content-heading login-header d-flex flex-wrap'>
|
<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" %}
|
{% include "spacer.html" %}
|
||||||
<span class='float-right'><h3>{% block body_title %}{% trans 'Site is in Maintenance' %}{% endblock %}</h3></span>
|
<span class='float-right'><h3>{% block body_title %}{% trans 'Site is in Maintenance' %}{% endblock %}</h3></span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
{% include "InvenTree/settings/setting.html" with key="INVENTREE_INSTANCE" icon="fa-info-circle" %}
|
{% 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_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_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_COMPANY_NAME" icon="fa-building" %}
|
||||||
{% include "InvenTree/settings/setting.html" with key="INVENTREE_DOWNLOAD_FROM_URL" icon="fa-cloud-download-alt" %}
|
{% include "InvenTree/settings/setting.html" with key="INVENTREE_DOWNLOAD_FROM_URL" icon="fa-cloud-download-alt" %}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
{% block content %}
|
{% block content %}
|
||||||
|
|
||||||
<div class='alert alert-block alert-danger'>
|
<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>
|
||||||
|
|
||||||
<div class='table-responsive'>
|
<div class='table-responsive'>
|
||||||
|
|||||||
@@ -85,7 +85,7 @@
|
|||||||
{% if plugin.is_package %}
|
{% if plugin.is_package %}
|
||||||
{% trans "This plugin was installed as a package" %}
|
{% trans "This plugin was installed as a package" %}
|
||||||
{% else %}
|
{% else %}
|
||||||
{% trans "This plugin was found in a local InvenTree path" %}
|
{% trans "This plugin was found in a local server path" %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
|
|
||||||
<table class='table table-striped table-condensed'>
|
<table class='table table-striped table-condensed'>
|
||||||
<tbody>
|
<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_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_STALE_DAYS" icon="fa-calendar" %}
|
||||||
{% include "InvenTree/settings/setting.html" with key="STOCK_ALLOW_EXPIRED_SALE" icon="fa-truck" %}
|
{% include "InvenTree/settings/setting.html" with key="STOCK_ALLOW_EXPIRED_SALE" icon="fa-truck" %}
|
||||||
|
|||||||
@@ -101,7 +101,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="col-sm-6">
|
<div class="col-sm-6">
|
||||||
<h4>{% trans "Help the translation efforts!" %}</h4>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -67,7 +67,7 @@
|
|||||||
<div class='container-fluid'>
|
<div class='container-fluid'>
|
||||||
|
|
||||||
<div class='clearfix content-heading login-header d-flex flex-wrap'>
|
<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" %}
|
{% include "spacer.html" %}
|
||||||
<span class='float-right'><h3>{% inventree_title %}</h3></span>
|
<span class='float-right'><h3>{% inventree_title %}</h3></span>
|
||||||
</div>
|
</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 'script/jquery-ui/jquery-ui.min.js' %}"></script>
|
||||||
<script type="text/javascript" src="{% static 'bootstrap/js/bootstrap.bundle.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="{% static 'script/inventree/inventree.js' %}"></script>
|
||||||
<script type='text/javascript' src="{% i18n_static 'notification.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_REG' as enable_reg %}
|
||||||
{% settings_value 'LOGIN_ENABLE_PWD_FORGOT' as enable_pwd_forgot %}
|
{% settings_value 'LOGIN_ENABLE_PWD_FORGOT' as enable_pwd_forgot %}
|
||||||
{% settings_value 'LOGIN_ENABLE_SSO' as enable_sso %}
|
{% settings_value 'LOGIN_ENABLE_SSO' as enable_sso %}
|
||||||
|
{% inventree_customize 'login_message' as login_message %}
|
||||||
{% mail_configured as mail_conf %}
|
{% mail_configured as mail_conf %}
|
||||||
{% inventree_demo_mode as demo %}
|
{% inventree_demo_mode as demo %}
|
||||||
|
|
||||||
@@ -35,19 +36,15 @@ for a account and sign in below:{% endblocktrans %}</p>
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<hr>
|
<hr>
|
||||||
|
{% if login_message %}
|
||||||
|
<div>{{ login_message }}<hr></div>
|
||||||
|
{% endif %}
|
||||||
<div class="btn-group float-right" role="group">
|
<div class="btn-group float-right" role="group">
|
||||||
<button class="btn btn-success" type="submit">{% trans "Sign In" %}</button>
|
<button class="btn btn-success" type="submit">{% trans "Sign In" %}</button>
|
||||||
</div>
|
</div>
|
||||||
{% if mail_conf and enable_pwd_forgot and not demo %}
|
{% if mail_conf and enable_pwd_forgot and not demo %}
|
||||||
<a class="" href="{% url 'account_reset_password' %}"><small>{% trans "Forgot Password?" %}</small></a>
|
<a class="" href="{% url 'account_reset_password' %}"><small>{% trans "Forgot Password?" %}</small></a>
|
||||||
{% endif %}
|
{% 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>
|
</form>
|
||||||
|
|
||||||
{% if enable_sso %}
|
{% if enable_sso %}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
{% settings_value "REPORT_ENABLE" as report_enabled %}
|
{% settings_value "REPORT_ENABLE" as report_enabled %}
|
||||||
{% settings_value "SERVER_RESTART_REQUIRED" as server_restart_required %}
|
{% settings_value "SERVER_RESTART_REQUIRED" as server_restart_required %}
|
||||||
{% settings_value "LABEL_ENABLE" with user=user as labels_enabled %}
|
{% settings_value "LABEL_ENABLE" with user=user as labels_enabled %}
|
||||||
|
{% inventree_show_about user as show_about %}
|
||||||
{% inventree_demo_mode as demo_mode %}
|
{% inventree_demo_mode as demo_mode %}
|
||||||
|
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
@@ -130,7 +131,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% include 'modals.html' %}
|
{% include 'modals.html' %}
|
||||||
{% include 'about.html' %}
|
{% if show_about %}{% include 'about.html' %}{% endif %}
|
||||||
{% include "notifications.html" %}
|
{% include "notifications.html" %}
|
||||||
{% include "search.html" %}
|
{% include "search.html" %}
|
||||||
</div>
|
</div>
|
||||||
@@ -166,7 +167,7 @@
|
|||||||
<script type='text/javascript' src="{% static 'script/clipboard.min.js' %}"></script>
|
<script type='text/javascript' src="{% static 'script/clipboard.min.js' %}"></script>
|
||||||
<script type='text/javascript' src="{% static 'script/randomColor.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="{% static 'script/inventree/inventree.js' %}"></script>
|
||||||
|
|
||||||
<!-- dynamic javascript templates -->
|
<!-- dynamic javascript templates -->
|
||||||
|
|||||||
@@ -32,7 +32,7 @@
|
|||||||
{% block footer_prefix %}
|
{% block footer_prefix %}
|
||||||
<!-- Custom footer information goes here -->
|
<!-- Custom footer information goes here -->
|
||||||
{% endblock %}
|
{% 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 %}
|
{% block footer_suffix %}
|
||||||
<!-- Custom footer information goes here -->
|
<!-- Custom footer information goes here -->
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -691,8 +691,24 @@ function loadBomTable(table, options={}) {
|
|||||||
|
|
||||||
setupFilterList('bom', $(table));
|
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 = [];
|
var cols = [];
|
||||||
|
|
||||||
if (options.editable) {
|
if (options.editable) {
|
||||||
@@ -807,15 +823,27 @@ function loadBomTable(table, options={}) {
|
|||||||
var url = `/part/${row.sub_part_detail.pk}/?display=part-stock`;
|
var url = `/part/${row.sub_part_detail.pk}/?display=part-stock`;
|
||||||
|
|
||||||
// Calculate total "available" (unallocated) quantity
|
// 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>`;
|
text = `<span class='badge rounded-pill bg-danger'>{% trans "No Stock Available" %}</span>`;
|
||||||
} else {
|
} else {
|
||||||
if (row.available_substitute_stock > 0) {
|
var extra = '';
|
||||||
text += `<span title='{% trans "Includes substitute stock" %}' class='fas fa-info-circle float-right icon-blue'></span>`;
|
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) {
|
formatter: function(value, row) {
|
||||||
var can_build = 0;
|
var can_build = 0;
|
||||||
|
|
||||||
var available = row.available_stock + row.available_substitute_stock;
|
var available = availableQuantity(row);
|
||||||
|
|
||||||
if (row.quantity > 0) {
|
if (row.quantity > 0) {
|
||||||
can_build = available / row.quantity;
|
can_build = available / row.quantity;
|
||||||
@@ -924,11 +952,11 @@ function loadBomTable(table, options={}) {
|
|||||||
var cb_b = 0;
|
var cb_b = 0;
|
||||||
|
|
||||||
if (rowA.quantity > 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) {
|
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;
|
return (cb_a > cb_b) ? 1 : -1;
|
||||||
|
|||||||
@@ -1031,6 +1031,23 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
|
|||||||
return row.required;
|
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) {
|
function sumAllocations(row) {
|
||||||
// Calculat total allocations for a given row
|
// Calculat total allocations for a given row
|
||||||
if (!row.allocations) {
|
if (!row.allocations) {
|
||||||
@@ -1425,20 +1442,52 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
|
|||||||
title: '{% trans "Available" %}',
|
title: '{% trans "Available" %}',
|
||||||
sortable: true,
|
sortable: true,
|
||||||
formatter: function(value, row) {
|
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) {
|
// Calculate total "available" (unallocated) quantity
|
||||||
text = `<span class='badge rounded-pill bg-danger'>{% trans "No Stock Available" %}</span>`;
|
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 {
|
} else {
|
||||||
if (row.available_substitute_stock > 0) {
|
text += `<span class='fas fa-check-circle icon-green float-right' title='{% trans "Sufficient stock available" %}'></span>`;
|
||||||
text += `<span title='{% trans "Includes substitute stock" %}' class='fas fa-info-circle float-right icon-blue'></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',
|
field: 'allocated',
|
||||||
|
|||||||
@@ -672,7 +672,20 @@ function loadPartVariantTable(table, partId, options={}) {
|
|||||||
field: 'in_stock',
|
field: 'in_stock',
|
||||||
title: '{% trans "Stock" %}',
|
title: '{% trans "Stock" %}',
|
||||||
formatter: function(value, row) {
|
formatter: function(value, row) {
|
||||||
return renderLink(value, `/part/${row.pk}/?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() {
|
formatNoMatches: function() {
|
||||||
return `{% trans "No ${human_name} information found" %}`;
|
return `{% trans "No ${human_name} information found" %}`;
|
||||||
},
|
},
|
||||||
queryParams: {part: options.part},
|
queryParams: {
|
||||||
|
part: options.part
|
||||||
|
},
|
||||||
url: options.url,
|
url: options.url,
|
||||||
onLoadSuccess: function(tableData) {
|
onLoadSuccess: function(tableData) {
|
||||||
if (linkedGraph) {
|
if (linkedGraph) {
|
||||||
@@ -2023,36 +2038,45 @@ function initPriceBreakSet(table, options) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pb_new_btn.click(function() {
|
pb_new_btn.click(function() {
|
||||||
launchModalForm(pb_new_url,
|
|
||||||
{
|
constructForm(pb_new_url, {
|
||||||
success: reloadPriceBreakTable,
|
fields: {
|
||||||
data: {
|
part: {
|
||||||
part: part_id,
|
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() {
|
table.on('click', `.button-${pb_url_slug}-delete`, function() {
|
||||||
var pk = $(this).attr('pk');
|
var pk = $(this).attr('pk');
|
||||||
|
|
||||||
launchModalForm(
|
constructForm(`${pb_url}${pk}/`, {
|
||||||
`/part/${pb_url_slug}/${pk}/delete/`,
|
method: 'DELETE',
|
||||||
{
|
title: '{% trans "Delete Price Break" %}',
|
||||||
success: reloadPriceBreakTable
|
onSuccess: reloadPriceBreakTable,
|
||||||
}
|
});
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
table.on('click', `.button-${pb_url_slug}-edit`, function() {
|
table.on('click', `.button-${pb_url_slug}-edit`, function() {
|
||||||
var pk = $(this).attr('pk');
|
var pk = $(this).attr('pk');
|
||||||
|
|
||||||
launchModalForm(
|
constructForm(`${pb_url}${pk}/`, {
|
||||||
`/part/${pb_url_slug}/${pk}/edit/`,
|
fields: {
|
||||||
{
|
quantity: {},
|
||||||
success: reloadPriceBreakTable
|
price: {},
|
||||||
}
|
price_currency: {},
|
||||||
);
|
},
|
||||||
|
title: '{% trans "Edit Price Break" %}',
|
||||||
|
onSuccess: reloadPriceBreakTable,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,11 +7,13 @@
|
|||||||
{% settings_value 'STICKY_HEADER' user=request.user as sticky %}
|
{% settings_value 'STICKY_HEADER' user=request.user as sticky %}
|
||||||
{% navigation_enabled as plugin_nav %}
|
{% navigation_enabled as plugin_nav %}
|
||||||
{% inventree_demo_mode as demo %}
|
{% 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">
|
<nav class="navbar {% if sticky %}fixed-top{% endif %} navbar-expand-lg navbar-light">
|
||||||
<div class="container-fluid">
|
<div class="container-fluid">
|
||||||
<div class="navbar-header clearfix content-heading">
|
<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>
|
||||||
<div class="navbar-collapse collapse" id="navbar-objects">
|
<div class="navbar-collapse collapse" id="navbar-objects">
|
||||||
<ul class="navbar-nav">
|
<ul class="navbar-nav">
|
||||||
@@ -84,8 +86,13 @@
|
|||||||
|
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
{% if demo %}
|
{% if navbar_message %}
|
||||||
{% include "navbar_demo.html" %}
|
{% include "spacer.html" %}
|
||||||
|
<div class='flex justify-content-center'>
|
||||||
|
{{ navbar_message }}
|
||||||
|
</div>
|
||||||
|
{% include "spacer.html" %}
|
||||||
|
{% include "spacer.html" %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<ul class='navbar-nav flex-row'>
|
<ul class='navbar-nav flex-row'>
|
||||||
@@ -144,6 +151,7 @@
|
|||||||
{% trans "System Information" %}
|
{% trans "System Information" %}
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
{% if show_about %}
|
||||||
<li id='launch-about'>
|
<li id='launch-about'>
|
||||||
<a class='dropdown-item' href='#'>
|
<a class='dropdown-item' href='#'>
|
||||||
{% if up_to_date %}
|
{% if up_to_date %}
|
||||||
@@ -154,6 +162,7 @@
|
|||||||
{% trans "About InvenTree" %}
|
{% trans "About InvenTree" %}
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
{% endif %}
|
||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</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/clipboard.min.js' %}"></script>
|
||||||
<script type='text/javascript' src="{% static 'script/randomColor.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="{% static 'script/inventree/inventree.js' %}"></script>
|
||||||
<script type='text/javascript' src="{% i18n_static 'notification.js' %}"></script>
|
<script type='text/javascript' src="{% i18n_static 'notification.js' %}"></script>
|
||||||
{% block body_scripts_inventree %}
|
{% block body_scripts_inventree %}
|
||||||
|
|||||||
+2
-2
@@ -65,8 +65,8 @@ RUN apk add --no-cache git make bash \
|
|||||||
libjpeg-turbo libjpeg-turbo-dev jpeg jpeg-dev \
|
libjpeg-turbo libjpeg-turbo-dev jpeg jpeg-dev \
|
||||||
libffi libffi-dev \
|
libffi libffi-dev \
|
||||||
zlib zlib-dev \
|
zlib zlib-dev \
|
||||||
# Cairo deps for WeasyPrint (these will be deprecated once WeasyPrint drops cairo requirement)
|
# Special deps for WeasyPrint (these will be deprecated once WeasyPrint drops cairo requirement)
|
||||||
cairo cairo-dev pango pango-dev \
|
cairo cairo-dev pango pango-dev gdk-pixbuf \
|
||||||
# Fonts
|
# Fonts
|
||||||
fontconfig ttf-droid ttf-liberation ttf-dejavu ttf-opensans ttf-ubuntu-font-family font-croscore font-noto \
|
fontconfig ttf-droid ttf-liberation ttf-dejavu ttf-opensans ttf-ubuntu-font-family font-croscore font-noto \
|
||||||
# Core python
|
# Core python
|
||||||
|
|||||||
Reference in New Issue
Block a user