2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-06-17 12:35:46 +00:00

Merge remote-tracking branch 'inventree/master'

This commit is contained in:
Oliver Walters
2022-10-28 22:58:25 +11:00
44 changed files with 13499 additions and 12410 deletions

View File

@ -2,11 +2,14 @@
# InvenTree API version
INVENTREE_API_VERSION = 77
INVENTREE_API_VERSION = 78
"""
Increment this API version number whenever there is a significant change to the API that any clients need to know about
v78 -> 2022-10-25 : https://github.com/inventree/InvenTree/pull/3854
- Make PartCategory to be filtered by name and description
v77 -> 2022-10-12 : https://github.com/inventree/InvenTree/pull/3772
- Adds model permission checks for barcode assignment actions

View File

@ -6,6 +6,7 @@ from urllib.parse import urlencode
from django import forms
from django.conf import settings
from django.contrib.auth.models import Group, User
from django.contrib.sites.models import Site
from django.http import HttpResponseRedirect
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
@ -227,7 +228,16 @@ class RegistratonMixin:
return user
class CustomAccountAdapter(RegistratonMixin, OTPAdapter, DefaultAccountAdapter):
class CustomUrlMixin:
"""Mixin to set urls."""
def get_email_confirmation_url(self, request, emailconfirmation):
"""Custom email confirmation (activation) url."""
url = reverse("account_confirm_email", args=[emailconfirmation.key])
return Site.objects.get_current().domain + url
class CustomAccountAdapter(CustomUrlMixin, RegistratonMixin, OTPAdapter, DefaultAccountAdapter):
"""Override of adapter to use dynamic settings."""
def send_mail(self, template_prefix, email, context):
"""Only send mail if backend configured."""
@ -236,7 +246,7 @@ class CustomAccountAdapter(RegistratonMixin, OTPAdapter, DefaultAccountAdapter):
return False
class CustomSocialAccountAdapter(RegistratonMixin, DefaultSocialAccountAdapter):
class CustomSocialAccountAdapter(CustomUrlMixin, RegistratonMixin, DefaultSocialAccountAdapter):
"""Override of adapter to use dynamic settings."""
def is_auto_signup_allowed(self, request, sociallogin):

View File

@ -629,6 +629,13 @@ def extract_serial_numbers(input_string, expected_quantity: int, starting_value=
def add_serial(serial):
"""Helper function to check for duplicated values"""
serial = serial.strip()
# Ignore blank / emtpy serials
if len(serial) == 0:
return
if serial in serials:
add_error(_("Duplicate serial") + f": {serial}")
else:
@ -645,6 +652,10 @@ def extract_serial_numbers(input_string, expected_quantity: int, starting_value=
return serials
for group in groups:
# Calculate the "remaining" quantity of serial numbers
remaining = expected_quantity - len(serials)
group = group.strip()
if '-' in group:
@ -680,20 +691,21 @@ def extract_serial_numbers(input_string, expected_quantity: int, starting_value=
group_items.append(b)
break
elif count > expected_quantity:
elif count > remaining:
# More than the allowed number of items
break
elif a_next is None:
break
if len(group_items) > 0 and group_items[0] == a and group_items[-1] == b:
if len(group_items) > remaining:
add_error(_("Group range {g} exceeds allowed quantity ({q})".format(g=group, q=expected_quantity)))
elif len(group_items) > 0 and group_items[0] == a and group_items[-1] == b:
# In this case, the range extraction looks like it has worked
for item in group_items:
add_serial(item)
else:
add_serial(group)
# add_error(_("Invalid group range: {g}").format(g=group))
add_error(_("Invalid group range: {g}").format(g=group))
else:
# In the case of a different number of hyphens, simply add the entire group
@ -715,7 +727,7 @@ def extract_serial_numbers(input_string, expected_quantity: int, starting_value=
continue
elif len(items) == 2:
try:
if items[1] not in ['', None]:
if items[1]:
sequence_count = int(items[1]) + 1
except ValueError:
add_error(_("Invalid group sequence: {g}").format(g=group))
@ -745,7 +757,7 @@ def extract_serial_numbers(input_string, expected_quantity: int, starting_value=
if len(serials) == 0:
raise ValidationError([_("No serial numbers found")])
if len(serials) != expected_quantity:
if len(errors) == 0 and len(serials) != expected_quantity:
raise ValidationError([_("Number of unique serial numbers ({s}) must match quantity ({q})").format(s=len(serials), q=expected_quantity)])
return serials

View File

@ -258,7 +258,7 @@ class TestHelpers(TestCase):
def test_download_image(self):
"""Test function for downloading image from remote URL"""
# Run check with a sequency of bad URLs
# Run check with a sequence of bad URLs
for url in [
"blog",
"htp://test.com/?",
@ -268,17 +268,35 @@ class TestHelpers(TestCase):
with self.assertRaises(django_exceptions.ValidationError):
helpers.download_image_from_url(url)
def dl_helper(url, expected_error, timeout=2.5, retries=3):
"""Helper function for unit testing downloads.
As the httpstat.us service occassionaly refuses a connection,
we will simply try multiple times
"""
with self.assertRaises(expected_error):
while retries > 0:
try:
helpers.download_image_from_url(url, timeout=timeout)
break
except Exception as exc:
if type(exc) is expected_error:
# Re-throw this error
raise exc
time.sleep(30)
retries -= 1
# Attempt to download an image which throws a 404
with self.assertRaises(requests.exceptions.HTTPError):
helpers.download_image_from_url("https://httpstat.us/404", timeout=10)
dl_helper("https://httpstat.us/404", requests.exceptions.HTTPError, timeout=10)
# Attempt to download, but timeout
with self.assertRaises(requests.exceptions.Timeout):
helpers.download_image_from_url("https://httpstat.us/200?sleep=5000")
dl_helper("https://httpstat.us/200?sleep=5000", requests.exceptions.ReadTimeout, timeout=1)
# Attempt to download, but not a valid image
with self.assertRaises(TypeError):
helpers.download_image_from_url("https://httpstat.us/200", timeout=10)
dl_helper("https://httpstat.us/200", TypeError, timeout=10)
large_img = "https://github.com/inventree/InvenTree/raw/master/InvenTree/InvenTree/static/img/paper_splash_large.jpg"
@ -429,11 +447,15 @@ class TestSerialNumberExtraction(TestCase):
"""Test simple serial numbers."""
e = helpers.extract_serial_numbers
# Test a range of numbers
sn = e("1-5", 5, 1)
self.assertEqual(len(sn), 5, 1)
self.assertEqual(len(sn), 5)
for i in range(1, 6):
self.assertIn(str(i), sn)
sn = e("11-30", 20, 1)
self.assertEqual(len(sn), 20)
sn = e("1, 2, 3, 4, 5", 5, 1)
self.assertEqual(len(sn), 5)
@ -452,11 +474,6 @@ class TestSerialNumberExtraction(TestCase):
self.assertEqual(len(sn), 5)
self.assertEqual(sn, ['1', '2', "TG-4SR-92", '4', '5'])
# Test groups are not interpolated with alpha characters
sn = e("1, A-2, 3+", 5, 1)
self.assertEqual(len(sn), 5)
self.assertEqual(sn, ['1', "A-2", '3', '4', '5'])
# Test multiple placeholders
sn = e("1 2 ~ ~ ~", 5, 2)
self.assertEqual(len(sn), 5)
@ -521,6 +538,16 @@ class TestSerialNumberExtraction(TestCase):
with self.assertRaises(ValidationError):
e("1, 2, 3, E-5", 5, 1)
# Extract a range of values with a smaller range
with self.assertRaises(ValidationError) as exc:
e("11-50", 10, 1)
self.assertIn('Range quantity exceeds 10', str(exc))
# Test groups are not interpolated with alpha characters
with self.assertRaises(ValidationError) as exc:
e("1, A-2, 3+", 5, 1)
self.assertIn('Invalid group range: A-2', str(exc))
def test_combinations(self):
"""Test complex serial number combinations."""
e = helpers.extract_serial_numbers
@ -541,6 +568,24 @@ class TestSerialNumberExtraction(TestCase):
self.assertEqual(len(sn), 2)
self.assertEqual(sn, ['14', '15'])
# Test multiple increment groups
sn = e("~+4, 20+4, 30+4", 15, 10)
self.assertEqual(len(sn), 15)
for v in [14, 24, 34]:
self.assertIn(str(v), sn)
# Test multiple range groups
sn = e("11-20, 41-50, 91-100", 30, 1)
self.assertEqual(len(sn), 30)
for v in range(11, 21):
self.assertIn(str(v), sn)
for v in range(41, 51):
self.assertIn(str(v), sn)
for v in range(91, 101):
self.assertIn(str(v), sn)
class TestVersionNumber(TestCase):
"""Unit tests for version number functions."""

View File

@ -8,6 +8,7 @@ from django.core import validators
from django.core.exceptions import FieldDoesNotExist, ValidationError
from django.utils.translation import gettext_lazy as _
from jinja2 import Template
from moneyed import CURRENCIES
import common.models
@ -158,14 +159,19 @@ def validate_overage(value):
)
def validate_part_name_format(self):
def validate_part_name_format(value):
"""Validate part name format.
Make sure that each template container has a field of Part Model
"""
# Make sure that the field_name exists in Part model
from part.models import Part
jinja_template_regex = re.compile('{{.*?}}')
field_name_regex = re.compile('(?<=part\\.)[A-z]+')
for jinja_template in jinja_template_regex.findall(str(self)):
for jinja_template in jinja_template_regex.findall(str(value)):
# make sure at least one and only one field is present inside the parser
field_names = field_name_regex.findall(jinja_template)
if len(field_names) < 1:
@ -173,9 +179,6 @@ def validate_part_name_format(self):
'value': 'At least one field must be present inside a jinja template container i.e {{}}'
})
# Make sure that the field_name exists in Part model
from part.models import Part
for field_name in field_names:
try:
Part._meta.get_field(field_name)
@ -184,4 +187,14 @@ def validate_part_name_format(self):
'value': f'{field_name} does not exist in Part Model'
})
# Attempt to render the template with a dummy Part instance
p = Part(name='test part', description='some test part')
try:
Template(value).render({'part': p})
except Exception as exc:
raise ValidationError({
'value': str(exc)
})
return True

View File

@ -1612,6 +1612,13 @@ class InvenTreeUserSetting(BaseInvenTreeSetting):
'validator': bool,
},
'SEARCH_PREVIEW_SHOW_BUILD_ORDERS': {
'name': _('Search Build Orders'),
'description': _('Display build orders in search preview window'),
'default': True,
'validator': bool,
},
'SEARCH_PREVIEW_SHOW_PURCHASE_ORDERS': {
'name': _('Search Purchase Orders'),
'description': _('Display purchase orders in search preview window'),

View File

@ -165,8 +165,10 @@ 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
# The header name should be prefixed by `HTTP`. Please read the docs for more details
# https://docs.djangoproject.com/en/stable/ref/request-response/#django.http.HttpRequest.META
remote_login_enabled: False
remote_login_header: REMOTE_USER
remote_login_header: HTTP_REMOTE_USER
# Permit custom authentication backends
#authentication_backends:

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -153,6 +153,8 @@ class CategoryList(ListCreateAPI):
]
filterset_fields = [
'name',
'description'
]
ordering_fields = [

View File

@ -671,7 +671,7 @@ class Part(InvenTreeBarcodeMixin, MetadataMixin, MPTTModel):
return full_name
except AttributeError as attr_err:
except Exception as attr_err:
logger.warning(f"exception while trying to create full name for part {self.name}", attr_err)

View File

@ -48,8 +48,10 @@ class BarcodeScan(APIView):
"""
data = request.data
if 'barcode' not in data:
raise ValidationError({'barcode': _('Must provide barcode_data parameter')})
barcode_data = data.get('barcode', None)
if not barcode_data:
raise ValidationError({'barcode': _('Missing barcode data')})
# Ensure that the default barcode handlers are run first
plugins = [
@ -57,7 +59,6 @@ class BarcodeScan(APIView):
InvenTreeExternalBarcodePlugin(),
] + registry.with_mixin('barcode')
barcode_data = data.get('barcode')
barcode_hash = hash_barcode(barcode_data)
# Look for a barcode plugin which knows how to deal with this barcode
@ -106,10 +107,10 @@ class BarcodeAssign(APIView):
data = request.data
if 'barcode' not in data:
raise ValidationError({'barcode': _('Must provide barcode_data parameter')})
barcode_data = data.get('barcode', None)
barcode_data = data['barcode']
if not barcode_data:
raise ValidationError({'barcode': _('Missing barcode data')})
# Here we only check against 'InvenTree' plugins
plugins = [

View File

@ -55,7 +55,8 @@ class BarcodeAPITest(InvenTreeAPITestCase):
self.assertEqual(response.status_code, 400)
data = response.data
self.assertIn('error', data)
self.assertIn('barcode', data)
self.assertIn('Missing barcode data', str(response.data['barcode']))
def test_find_part(self):
"""Test that we can lookup a part based on ID."""

View File

@ -23,6 +23,7 @@
{% include "InvenTree/settings/setting.html" with key="SEARCH_PREVIEW_HIDE_UNAVAILABLE_STOCK" user_setting=True icon='fa-eye-slash' %}
{% include "InvenTree/settings/setting.html" with key="SEARCH_PREVIEW_SHOW_LOCATIONS" user_setting=True icon='fa-sitemap' %}
{% include "InvenTree/settings/setting.html" with key="SEARCH_PREVIEW_SHOW_COMPANIES" user_setting=True icon='fa-building' %}
{% include "InvenTree/settings/setting.html" with key="SEARCH_PREVIEW_SHOW_BUILD_ORDERS" user_setting=True icon='fa-tools' %}
{% include "InvenTree/settings/setting.html" with key="SEARCH_PREVIEW_SHOW_PURCHASE_ORDERS" user_setting=True icon='fa-shopping-cart' %}
{% include "InvenTree/settings/setting.html" with key="SEARCH_PREVIEW_EXCLUDE_INACTIVE_PURCHASE_ORDERS" user_setting=True icon='fa-eye-slash' %}
{% include "InvenTree/settings/setting.html" with key="SEARCH_PREVIEW_SHOW_SALES_ORDERS" user_setting=True icon='fa-truck' %}

View File

@ -45,7 +45,7 @@
<tr>
<td><span class='fas fa-code'></span></td>
<td>{% trans "API Version" %}</td>
<td>{% inventree_api_version %}{% include "clip.html" %}</td>
<td><a href="/api-doc/">{% inventree_api_version %}</a></td>
</tr>
<tr>
<td><span class='fas fa-hashtag'></span></td>

View File

@ -1134,7 +1134,7 @@ function loadBuildOutputTable(build_info, options={}) {
data.push(row);
});
$(table).bootstrapTable('load', row);
$(table).bootstrapTable('load', data);
}
}
);

View File

@ -205,9 +205,6 @@ function constructChangeForm(fields, options) {
},
success: function(data) {
// Ensure the data are fully sanitized before we operate on it
data = sanitizeData(data);
// An optional function can be provided to process the returned results,
// before they are rendered to the form
if (options.processResults) {

View File

@ -185,14 +185,14 @@ function makeProgressBar(value, maximum, opts={}) {
var options = opts || {};
value = parseFloat(value);
value = formatDecimal(parseFloat(value));
var percent = 100;
// Prevent div-by-zero or null value
if (maximum && maximum > 0) {
maximum = parseFloat(maximum);
percent = parseInt(value / maximum * 100);
maximum = formatDecimal(parseFloat(maximum));
percent = formatDecimal(parseInt(value / maximum * 100));
}
if (percent > 100) {

View File

@ -247,6 +247,22 @@ function updateSearch() {
);
}
if (checkPermission('build') && user_settings.SEARCH_PREVIEW_SHOW_BUILD_ORDERS) {
// Search for matching build orders
addSearchQuery(
'build',
'{% trans "Build Orders" %}',
'{% url "api-build-list" %}',
{
part_detail: true,
},
renderBuild,
{
url: '/build',
}
);
}
if ((checkPermission('sales_order') || checkPermission('purchase_order')) && user_settings.SEARCH_PREVIEW_SHOW_COMPANIES) {
// Search for matching companies
addSearchQuery(

View File

@ -104,6 +104,7 @@ InvenTree is designed to be **extensible**, and provides multiple options for **
<li><a href="https://hub.docker.com/r/inventree/inventree">Docker</a></li>
<li><a href="https://crowdin.com/project/inventree">Crowdin</a></li>
<li><a href="https://coveralls.io/github/inventree/InvenTree">Coveralls</a></li>
<li><a href="https://packager.io/gh/inventree/InvenTree">Packager.io</a></li>
</ul>
</details>
@ -136,7 +137,7 @@ There are several options to deploy InvenTree.
<a href="https://inventree.readthedocs.io/en/latest/start/install/">Bare Metal</a>
</h4></div>
Single line install:
Single line install - read [the docs](https://inventree.readthedocs.io/en/latest/start/installer/) for supported distros and details about the function:
```bash
curl https://raw.githubusercontent.com/InvenTree/InvenTree/master/contrib/install.sh | sh
```