mirror of
https://github.com/inventree/InvenTree.git
synced 2025-12-17 20:35:01 -06:00
Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
93b44ad8e6 | ||
|
|
9b5e828b87 | ||
|
|
cf5d637678 | ||
|
|
feb2acf668 | ||
|
|
0017570dd3 | ||
|
|
4c41a50bb1 | ||
|
|
eab3fdcf2c | ||
|
|
c59eee7359 | ||
|
|
4a5ebf8f01 | ||
|
|
698798fee7 | ||
|
|
2660889879 | ||
|
|
01aaf95a0e |
@@ -195,8 +195,8 @@ class InvenTreeConfig(AppConfig):
|
||||
else:
|
||||
new_user = user.objects.create_superuser(add_user, add_email, add_password)
|
||||
logger.info(f'User {str(new_user)} was created!')
|
||||
except IntegrityError as _e:
|
||||
logger.warning(f'The user "{add_user}" could not be created due to the following error:\n{str(_e)}')
|
||||
except IntegrityError:
|
||||
logger.warning(f'The user "{add_user}" could not be created')
|
||||
|
||||
# do not try again
|
||||
settings.USER_ADDED = True
|
||||
|
||||
@@ -91,7 +91,7 @@ def convert_physical_value(value: str, unit: str = None):
|
||||
# At this point we *should* have a valid pint value
|
||||
# To double check, look at the maginitude
|
||||
float(val.magnitude)
|
||||
except (TypeError, ValueError):
|
||||
except (TypeError, ValueError, AttributeError):
|
||||
error = _('Provided value is not a valid number')
|
||||
except (pint.errors.UndefinedUnitError, pint.errors.DefinitionSyntaxError):
|
||||
error = _('Provided value has an invalid unit')
|
||||
|
||||
@@ -56,6 +56,23 @@ class ConversionTest(TestCase):
|
||||
q = InvenTree.conversion.convert_physical_value(val).to_base_units()
|
||||
self.assertEqual(q.magnitude, expected)
|
||||
|
||||
def test_invalid_values(self):
|
||||
"""Test conversion of invalid inputs"""
|
||||
|
||||
inputs = [
|
||||
'-',
|
||||
';;',
|
||||
'-x',
|
||||
'?',
|
||||
'--',
|
||||
'+',
|
||||
'++',
|
||||
]
|
||||
|
||||
for val in inputs:
|
||||
with self.assertRaises(ValidationError):
|
||||
InvenTree.conversion.convert_physical_value(val)
|
||||
|
||||
|
||||
class ValidatorTest(TestCase):
|
||||
"""Simple tests for custom field validators."""
|
||||
|
||||
@@ -18,7 +18,7 @@ from dulwich.repo import NotGitRepository, Repo
|
||||
from .api_version import INVENTREE_API_VERSION
|
||||
|
||||
# InvenTree software version
|
||||
INVENTREE_SW_VERSION = "0.12.0"
|
||||
INVENTREE_SW_VERSION = "0.12.1"
|
||||
|
||||
# Discover git
|
||||
try:
|
||||
|
||||
@@ -361,6 +361,11 @@ class Build(MPTTModel, InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models.
|
||||
|
||||
return self.build_lines.filter(bom_item__sub_part__trackable=False)
|
||||
|
||||
@property
|
||||
def are_untracked_parts_allocated(self):
|
||||
"""Returns True if all untracked parts are allocated for this BuildOrder."""
|
||||
return self.is_fully_allocated(tracked=False)
|
||||
|
||||
def has_untracked_line_items(self):
|
||||
"""Returns True if this BuildOrder has non trackable BomItems."""
|
||||
return self.has_untracked_line_items.count() > 0
|
||||
|
||||
@@ -630,7 +630,7 @@ class BuildCompleteSerializer(serializers.Serializer):
|
||||
|
||||
return {
|
||||
'overallocated': build.is_overallocated(),
|
||||
'allocated': build.is_fully_allocated(),
|
||||
'allocated': build.are_untracked_parts_allocated,
|
||||
'remaining': build.remaining,
|
||||
'incomplete': build.incomplete_count,
|
||||
}
|
||||
@@ -663,7 +663,7 @@ class BuildCompleteSerializer(serializers.Serializer):
|
||||
"""Check if the 'accept_unallocated' field is required"""
|
||||
build = self.context['build']
|
||||
|
||||
if not build.is_fully_allocated() and not value:
|
||||
if not build.are_untracked_parts_allocated and not value:
|
||||
raise ValidationError(_('Required stock has not been fully allocated'))
|
||||
|
||||
return value
|
||||
|
||||
@@ -13,6 +13,25 @@ from InvenTree.serializers import (InvenTreeImageSerializerField,
|
||||
InvenTreeModelSerializer)
|
||||
|
||||
|
||||
class SettingsValueField(serializers.Field):
|
||||
"""Custom serializer field for a settings value."""
|
||||
|
||||
def get_attribute(self, instance):
|
||||
"""Return the object instance, not the attribute value."""
|
||||
return instance
|
||||
|
||||
def to_representation(self, instance):
|
||||
"""Return the value of the setting:
|
||||
|
||||
- Protected settings are returned as '***'
|
||||
"""
|
||||
return '***' if instance.protected else str(instance.value)
|
||||
|
||||
def to_internal_value(self, data):
|
||||
"""Return the internal value of the setting"""
|
||||
return str(data)
|
||||
|
||||
|
||||
class SettingsSerializer(InvenTreeModelSerializer):
|
||||
"""Base serializer for a settings object."""
|
||||
|
||||
@@ -30,6 +49,8 @@ class SettingsSerializer(InvenTreeModelSerializer):
|
||||
|
||||
api_url = serializers.CharField(read_only=True)
|
||||
|
||||
value = SettingsValueField()
|
||||
|
||||
def get_choices(self, obj):
|
||||
"""Returns the choices available for a given item."""
|
||||
results = []
|
||||
@@ -45,16 +66,6 @@ class SettingsSerializer(InvenTreeModelSerializer):
|
||||
|
||||
return results
|
||||
|
||||
def get_value(self, obj):
|
||||
"""Make sure protected values are not returned."""
|
||||
# never return protected values
|
||||
if obj.protected:
|
||||
result = '***'
|
||||
else:
|
||||
result = obj.value
|
||||
|
||||
return result
|
||||
|
||||
|
||||
class GlobalSettingsSerializer(SettingsSerializer):
|
||||
"""Serializer for the InvenTreeSetting model."""
|
||||
|
||||
@@ -182,13 +182,15 @@ class LabelConfig(AppConfig):
|
||||
|
||||
logger.info(f"Creating entry for {model} '{label['name']}'")
|
||||
|
||||
model.objects.create(
|
||||
name=label['name'],
|
||||
description=label['description'],
|
||||
label=filename,
|
||||
filters='',
|
||||
enabled=True,
|
||||
width=label['width'],
|
||||
height=label['height'],
|
||||
)
|
||||
return
|
||||
try:
|
||||
model.objects.create(
|
||||
name=label['name'],
|
||||
description=label['description'],
|
||||
label=filename,
|
||||
filters='',
|
||||
enabled=True,
|
||||
width=label['width'],
|
||||
height=label['height'],
|
||||
)
|
||||
except Exception:
|
||||
logger.warning(f"Failed to create label '{label['name']}'")
|
||||
|
||||
@@ -72,6 +72,12 @@ class SampleIntegrationPlugin(AppMixin, SettingsMixin, UrlsMixin, NavigationMixi
|
||||
'description': 'Select a part object from the database',
|
||||
'model': 'part.part',
|
||||
},
|
||||
'PROTECTED_SETTING': {
|
||||
'name': 'Protected Setting',
|
||||
'description': 'A protected setting, hidden from the UI',
|
||||
'default': 'ABC-123',
|
||||
'protected': True,
|
||||
}
|
||||
}
|
||||
|
||||
NAVIGATION = [
|
||||
|
||||
@@ -193,3 +193,76 @@ class PluginDetailAPITest(PluginMixin, InvenTreeAPITestCase):
|
||||
with self.assertRaises(NotFound) as exc:
|
||||
check_plugin(plugin_slug=None, plugin_pk='123')
|
||||
self.assertEqual(str(exc.exception.detail), "Plugin '123' not installed")
|
||||
|
||||
def test_plugin_settings(self):
|
||||
"""Test plugin settings access via the API"""
|
||||
|
||||
# Ensure we have superuser permissions
|
||||
self.user.is_superuser = True
|
||||
self.user.save()
|
||||
|
||||
# Activate the 'sample' plugin via the API
|
||||
cfg = PluginConfig.objects.filter(key='sample').first()
|
||||
url = reverse('api-plugin-detail-activate', kwargs={'pk': cfg.pk})
|
||||
self.client.patch(url, {}, expected_code=200)
|
||||
|
||||
# Valid plugin settings endpoints
|
||||
valid_settings = [
|
||||
'SELECT_PART',
|
||||
'API_KEY',
|
||||
'NUMERICAL_SETTING',
|
||||
]
|
||||
|
||||
for key in valid_settings:
|
||||
response = self.get(
|
||||
reverse('api-plugin-setting-detail', kwargs={
|
||||
'plugin': 'sample',
|
||||
'key': key
|
||||
}))
|
||||
|
||||
self.assertEqual(response.data['key'], key)
|
||||
|
||||
# Test that an invalid setting key raises a 404 error
|
||||
response = self.get(
|
||||
reverse('api-plugin-setting-detail', kwargs={
|
||||
'plugin': 'sample',
|
||||
'key': 'INVALID_SETTING'
|
||||
}),
|
||||
expected_code=404
|
||||
)
|
||||
|
||||
# Test that a protected setting returns hidden value
|
||||
response = self.get(
|
||||
reverse('api-plugin-setting-detail', kwargs={
|
||||
'plugin': 'sample',
|
||||
'key': 'PROTECTED_SETTING'
|
||||
}),
|
||||
expected_code=200
|
||||
)
|
||||
|
||||
self.assertEqual(response.data['value'], '***')
|
||||
|
||||
# Test that we can update a setting value
|
||||
response = self.patch(
|
||||
reverse('api-plugin-setting-detail', kwargs={
|
||||
'plugin': 'sample',
|
||||
'key': 'NUMERICAL_SETTING'
|
||||
}),
|
||||
{
|
||||
'value': 456
|
||||
},
|
||||
expected_code=200
|
||||
)
|
||||
|
||||
self.assertEqual(response.data['value'], '456')
|
||||
|
||||
# Retrieve the value again
|
||||
response = self.get(
|
||||
reverse('api-plugin-setting-detail', kwargs={
|
||||
'plugin': 'sample',
|
||||
'key': 'NUMERICAL_SETTING'
|
||||
}),
|
||||
expected_code=200
|
||||
)
|
||||
|
||||
self.assertEqual(response.data['value'], '456')
|
||||
|
||||
@@ -22,7 +22,9 @@
|
||||
{{ setting.description }}
|
||||
</td>
|
||||
<td>
|
||||
{% if setting.is_bool %}
|
||||
{% if setting.protected %}
|
||||
<span style='color: red;'>***</span> <span class='fas fa-lock icon-red'></span>
|
||||
{% elif setting.is_bool %}
|
||||
{% include "InvenTree/settings/setting_boolean.html" %}
|
||||
{% else %}
|
||||
<div id='setting-{{ setting.pk }}'>
|
||||
|
||||
@@ -281,10 +281,20 @@ function loadAttachmentTable(url, options) {
|
||||
sidePagination: 'server',
|
||||
onPostBody: function() {
|
||||
|
||||
// Add callback for 'delete' button
|
||||
if (permissions.delete) {
|
||||
$(table).find('.button-attachment-delete').click(function() {
|
||||
let pk = $(this).attr('pk');
|
||||
let attachments = $(table).bootstrapTable('getRowByUniqueId', pk);
|
||||
|
||||
deleteAttachments([attachments], url, options);
|
||||
});
|
||||
}
|
||||
|
||||
// Add callback for 'edit' button
|
||||
if (permissions.change) {
|
||||
$(table).find('.button-attachment-edit').click(function() {
|
||||
var pk = $(this).attr('pk');
|
||||
let pk = $(this).attr('pk');
|
||||
|
||||
constructForm(`${url}${pk}/`, {
|
||||
fields: {
|
||||
|
||||
@@ -2173,9 +2173,6 @@ function loadBuildTable(table, options) {
|
||||
customView: function(data) {
|
||||
return `<div id='build-order-calendar'></div>`;
|
||||
},
|
||||
onRefresh: function() {
|
||||
loadBuildTable(table, options);
|
||||
},
|
||||
onLoadSuccess: function() {
|
||||
|
||||
if (tree_enable) {
|
||||
@@ -2255,16 +2252,16 @@ function renderBuildLineAllocationTable(element, build_line, options={}) {
|
||||
{
|
||||
field: 'part',
|
||||
title: '{% trans "Part" %}',
|
||||
formatter: function(value, row) {
|
||||
formatter: function(_value, row) {
|
||||
let html = imageHoverIcon(row.part_detail.thumbnail);
|
||||
html += renderLink(row.part_detail.full_name, `/part/${value}/`);
|
||||
html += renderLink(row.part_detail.full_name, `/part/${row.part_detail.pk}/`);
|
||||
return html;
|
||||
}
|
||||
},
|
||||
{
|
||||
field: 'quantity',
|
||||
title: '{% trans "Allocated Quantity" %}',
|
||||
formatter: function(value, row) {
|
||||
formatter: function(_value, row) {
|
||||
let text = '';
|
||||
let url = '';
|
||||
let serial = row.serial;
|
||||
@@ -2294,8 +2291,8 @@ function renderBuildLineAllocationTable(element, build_line, options={}) {
|
||||
title: '{% trans "Location" %}',
|
||||
formatter: function(value, row) {
|
||||
if (row.location_detail) {
|
||||
var text = shortenString(row.location_detail.pathstring);
|
||||
var url = `/stock/location/${row.location}/`;
|
||||
let text = shortenString(row.location_detail.pathstring);
|
||||
let url = `/stock/location/${row.location_detail.pk}/`;
|
||||
|
||||
return renderLink(text, url);
|
||||
} else {
|
||||
|
||||
@@ -1404,6 +1404,7 @@ function createPartParameter(part_id, options={}) {
|
||||
function editPartParameter(param_id, options={}) {
|
||||
options.fields = partParameterFields();
|
||||
options.title = '{% trans "Edit Parameter" %}';
|
||||
options.focus = 'data';
|
||||
|
||||
options.processBeforeUpload = function(data) {
|
||||
// Convert data to string
|
||||
@@ -2367,6 +2368,38 @@ function loadPartTable(table, url, options={}) {
|
||||
});
|
||||
|
||||
return text;
|
||||
},
|
||||
footerFormatter: function(data) {
|
||||
// Display "total" stock quantity of all rendered rows
|
||||
// Requires that all parts have the same base units!
|
||||
|
||||
let total = 0;
|
||||
let units = new Set();
|
||||
|
||||
data.forEach(function(row) {
|
||||
units.add(row.units || null);
|
||||
if (row.total_in_stock != null) {
|
||||
total += row.total_in_stock;
|
||||
}
|
||||
});
|
||||
|
||||
if (data.length == 0) {
|
||||
return '-';
|
||||
} else if (units.size > 1) {
|
||||
return '-';
|
||||
} else {
|
||||
let output = `${total}`;
|
||||
|
||||
if (units.size == 1) {
|
||||
let unit = units.values().next().value;
|
||||
|
||||
if (unit) {
|
||||
output += ` [${unit}]`;
|
||||
}
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -2442,6 +2475,7 @@ function loadPartTable(table, url, options={}) {
|
||||
showColumns: true,
|
||||
showCustomView: grid_view,
|
||||
showCustomViewButton: false,
|
||||
showFooter: true,
|
||||
onPostBody: function() {
|
||||
grid_view = inventreeLoad('part-grid-view') == 1;
|
||||
if (grid_view) {
|
||||
|
||||
@@ -1759,9 +1759,6 @@ function loadPurchaseOrderTable(table, options) {
|
||||
customView: function(data) {
|
||||
return `<div id='purchase-order-calendar'></div>`;
|
||||
},
|
||||
onRefresh: function() {
|
||||
loadPurchaseOrderTable(table, options);
|
||||
},
|
||||
onLoadSuccess: function() {
|
||||
|
||||
if (display_mode == 'calendar') {
|
||||
|
||||
@@ -262,9 +262,6 @@ function loadReturnOrderTable(table, options={}) {
|
||||
formatNoMatches: function() {
|
||||
return '{% trans "No return orders found" %}';
|
||||
},
|
||||
onRefresh: function() {
|
||||
loadReturnOrderTable(table, options);
|
||||
},
|
||||
onLoadSuccess: function() {
|
||||
// TODO
|
||||
},
|
||||
|
||||
@@ -735,9 +735,6 @@ function loadSalesOrderTable(table, options) {
|
||||
customView: function(data) {
|
||||
return `<div id='purchase-order-calendar'></div>`;
|
||||
},
|
||||
onRefresh: function() {
|
||||
loadSalesOrderTable(table, options);
|
||||
},
|
||||
onLoadSuccess: function() {
|
||||
|
||||
if (display_mode == 'calendar') {
|
||||
|
||||
@@ -2068,13 +2068,36 @@ function loadStockTable(table, options) {
|
||||
// Display "total" stock quantity of all rendered rows
|
||||
let total = 0;
|
||||
|
||||
// Keep track of the whether all units are the same
|
||||
// If different units are found, we cannot aggregate the quantities
|
||||
let units = new Set();
|
||||
|
||||
data.forEach(function(row) {
|
||||
|
||||
units.add(row.part_detail.units || null);
|
||||
|
||||
if (row.quantity != null) {
|
||||
total += row.quantity;
|
||||
}
|
||||
});
|
||||
|
||||
return total;
|
||||
if (data.length == 0) {
|
||||
return '-';
|
||||
} else if (units.size > 1) {
|
||||
return '-';
|
||||
} else {
|
||||
let output = `${total}`;
|
||||
|
||||
if (units.size == 1) {
|
||||
let unit = units.values().next().value;
|
||||
|
||||
if (unit) {
|
||||
output += ` [${unit}]`;
|
||||
}
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -2618,7 +2641,7 @@ function loadStockLocationTable(table, options) {
|
||||
} else {
|
||||
html += `
|
||||
<a href='#' pk='${row.pk}' class='load-sub-location'>
|
||||
<span class='fas fa-sync-alt' title='{% trans "Load Subloactions" %}'></span>
|
||||
<span class='fas fa-sync-alt' title='{% trans "Load Sublocations" %}'></span>
|
||||
</a> `;
|
||||
}
|
||||
}
|
||||
@@ -2809,7 +2832,7 @@ function loadStockTrackingTable(table, options) {
|
||||
if (details.salesorder_detail) {
|
||||
html += renderLink(
|
||||
details.salesorder_detail.reference,
|
||||
`/order/sales-order/${details.salesorder}`
|
||||
`/order/sales-order/${details.salesorder}/`
|
||||
);
|
||||
} else {
|
||||
html += `<em>{% trans "Sales Order no longer exists" %}</em>`;
|
||||
|
||||
@@ -42,7 +42,7 @@ INVENTREE_DB_PORT=5432
|
||||
#INVENTREE_CACHE_PORT=6379
|
||||
|
||||
# Options for gunicorn server
|
||||
INVENTREE_GUNICORN_TIMEOUT=30
|
||||
INVENTREE_GUNICORN_TIMEOUT=90
|
||||
|
||||
# Enable custom plugins?
|
||||
INVENTREE_PLUGINS_ENABLED=False
|
||||
|
||||
Reference in New Issue
Block a user