Merge branch 'master' into variant-available

# Conflicts:
#	InvenTree/InvenTree/api_version.py
This commit is contained in:
Oliver 2022-04-26 16:21:53 +10:00
commit d4fc4bb8bd
13 changed files with 8010 additions and 193 deletions

View File

@ -4,14 +4,17 @@ InvenTree API version information
# InvenTree API version # InvenTree API version
INVENTREE_API_VERSION = 41 INVENTREE_API_VERSION = 42
""" """
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
v41 -> 2022-04-21 : https://github.com/inventree/InvenTree/pull/2833 v42 -> 2022-04-26 : https://github.com/inventree/InvenTree/pull/2833
- Adds variant stock information to the Part and BomItem serializers - 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

View File

@ -1,11 +1,8 @@
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
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 +89,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))

View File

@ -546,11 +546,19 @@ if "sqlite" in db_engine:
# Provide OPTIONS dict back to the database configuration dict # Provide OPTIONS dict back to the database configuration dict
db_config['OPTIONS'] = db_options db_config['OPTIONS'] = db_options
# Set testing options for the database
db_config['TEST'] = {
'CHARSET': 'utf8',
}
# Set collation option for mysql test database
if 'mysql' in db_engine:
db_config['TEST']['COLLATION'] = 'utf8_general_ci'
DATABASES = { DATABASES = {
'default': db_config 'default': db_config
} }
_cache_config = CONFIG.get("cache", {}) _cache_config = CONFIG.get("cache", {})
_cache_host = _cache_config.get("host", os.getenv("INVENTREE_CACHE_HOST")) _cache_host = _cache_config.get("host", os.getenv("INVENTREE_CACHE_HOST"))
_cache_port = _cache_config.get( _cache_port = _cache_config.get(
@ -679,7 +687,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')),

Binary file not shown.

File diff suppressed because it is too large Load Diff

View File

@ -1175,6 +1175,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 +1351,6 @@ class PartList(generics.ListCreateAPIView):
filters.OrderingFilter, filters.OrderingFilter,
] ]
filter_fields = [
'variant_of',
]
ordering_fields = [ ordering_fields = [
'name', 'name',
'creation_date', 'creation_date',

View File

@ -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

View File

@ -777,7 +777,8 @@ class Part(MPTTModel):
# User can decide whether duplicate IPN (Internal Part Number) values are allowed # User can decide whether duplicate IPN (Internal Part Number) values are allowed
allow_duplicate_ipn = common.models.InvenTreeSetting.get_setting('PART_ALLOW_DUPLICATE_IPN') allow_duplicate_ipn = common.models.InvenTreeSetting.get_setting('PART_ALLOW_DUPLICATE_IPN')
if self.IPN is not None and not allow_duplicate_ipn: # Raise an error if an IPN is set, and it is a duplicate
if self.IPN and not allow_duplicate_ipn:
parts = Part.objects.filter(IPN__iexact=self.IPN) parts = Part.objects.filter(IPN__iexact=self.IPN)
parts = parts.exclude(pk=self.pk) parts = parts.exclude(pk=self.pk)
@ -798,6 +799,10 @@ class Part(MPTTModel):
super().clean() super().clean()
# Strip IPN field
if type(self.IPN) is str:
self.IPN = self.IPN.strip()
if self.trackable: if self.trackable:
for part in self.get_used_in().all(): for part in self.get_used_in().all():

View File

@ -567,6 +567,97 @@ 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)
class PartDetailTests(InvenTreeAPITestCase): class PartDetailTests(InvenTreeAPITestCase):
""" """

View File

@ -349,6 +349,26 @@ class PartSettingsTest(TestCase):
part = Part(name='Hello', description='A thing', IPN='IPN123', revision='C') part = Part(name='Hello', description='A thing', IPN='IPN123', revision='C')
part.full_clean() part.full_clean()
# Any duplicate IPN should raise an error
Part.objects.create(name='xyz', revision='1', description='A part', IPN='UNIQUE')
# Case insensitive, so variations on spelling should throw an error
for ipn in ['UNiquE', 'uniQuE', 'unique']:
with self.assertRaises(ValidationError):
Part.objects.create(name='xyz', revision='2', description='A part', IPN=ipn)
with self.assertRaises(ValidationError):
Part.objects.create(name='zyx', description='A part', IPN='UNIQUE')
# However, *blank* / empty IPN values should be allowed, even if duplicates are not
# Note that leading / trailling whitespace characters are trimmed, too
Part.objects.create(name='abc', revision='1', description='A part', IPN=None)
Part.objects.create(name='abc', revision='2', description='A part', IPN='')
Part.objects.create(name='abc', revision='3', description='A part', IPN=None)
Part.objects.create(name='abc', revision='4', description='A part', IPN=' ')
Part.objects.create(name='abc', revision='5', description='A part', IPN=' ')
Part.objects.create(name='abc', revision='6', description='A part', IPN=' ')
class PartSubscriptionTests(TestCase): class PartSubscriptionTests(TestCase):

View File

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

View File

@ -453,10 +453,12 @@ class StockItem(MPTTModel):
super().clean() super().clean()
if self.serial is not None and type(self.serial) is str: # Strip serial number field
if type(self.serial) is str:
self.serial = self.serial.strip() self.serial = self.serial.strip()
if self.batch is not None and type(self.batch) is str: # Strip batch code field
if type(self.batch) is str:
self.batch = self.batch.strip() self.batch = self.batch.strip()
try: try:

View File

@ -110,8 +110,7 @@ RUN pip3 install --user --no-cache-dir --disable-pip-version-check -r ${INVENTRE
WORKDIR ${INVENTREE_MNG_DIR} WORKDIR ${INVENTREE_MNG_DIR}
# Server init entrypoint # Server init entrypoint
COPY init.sh ${INVENTREE_HOME}/init.sh ENTRYPOINT ["/bin/bash", "../docker/init.sh"]
ENTRYPOINT ["/bin/bash", "${INVENTREE_HOME}/init.sh"]
# Launch the production server # Launch the production server
# TODO: Work out why environment variables cannot be interpolated in this command # TODO: Work out why environment variables cannot be interpolated in this command