Merge remote-tracking branch 'inventree/master'

This commit is contained in:
Oliver Walters 2021-05-01 09:24:28 +10:00
commit a9f0936cec
27 changed files with 223 additions and 118 deletions

View File

@ -357,6 +357,8 @@ def extract_serial_numbers(serials, expected_quantity):
- Serial numbers must be positive - Serial numbers must be positive
- Serial numbers can be split by whitespace / newline / commma chars - Serial numbers can be split by whitespace / newline / commma chars
- Serial numbers can be supplied as an inclusive range using hyphen char e.g. 10-20 - Serial numbers can be supplied as an inclusive range using hyphen char e.g. 10-20
- Serial numbers can be supplied as <start>+ for getting all expecteded numbers starting from <start>
- Serial numbers can be supplied as <start>+<length> for getting <length> numbers starting from <start>
Args: Args:
expected_quantity: The number of (unique) serial numbers we expect expected_quantity: The number of (unique) serial numbers we expect
@ -369,6 +371,13 @@ def extract_serial_numbers(serials, expected_quantity):
numbers = [] numbers = []
errors = [] errors = []
# helpers
def number_add(n):
if n in numbers:
errors.append(_('Duplicate serial: {n}').format(n=n))
else:
numbers.append(n)
try: try:
expected_quantity = int(expected_quantity) expected_quantity = int(expected_quantity)
except ValueError: except ValueError:
@ -395,10 +404,7 @@ def extract_serial_numbers(serials, expected_quantity):
if a < b: if a < b:
for n in range(a, b + 1): for n in range(a, b + 1):
if n in numbers: number_add(n)
errors.append(_('Duplicate serial: {n}').format(n=n))
else:
numbers.append(n)
else: else:
errors.append(_("Invalid group: {g}").format(g=group)) errors.append(_("Invalid group: {g}").format(g=group))
@ -409,6 +415,31 @@ def extract_serial_numbers(serials, expected_quantity):
errors.append(_("Invalid group: {g}").format(g=group)) errors.append(_("Invalid group: {g}").format(g=group))
continue continue
# plus signals either
# 1: 'start+': expected number of serials, starting at start
# 2: 'start+number': number of serials, starting at start
elif '+' in group:
items = group.split('+')
# case 1, 2
if len(items) == 2:
start = int(items[0])
# case 2
if bool(items[1]):
end = start + int(items[1]) + 1
# case 1
else:
end = start + expected_quantity
for n in range(start, end):
number_add(n)
# no case
else:
errors.append(_("Invalid group: {g}").format(g=group))
continue
else: else:
if group in numbers: if group in numbers:
errors.append(_("Duplicate serial: {g}".format(g=group))) errors.append(_("Duplicate serial: {g}".format(g=group)))

View File

@ -491,7 +491,7 @@ LANGUAGES = [
('en', _('English')), ('en', _('English')),
('fr', _('French')), ('fr', _('French')),
('de', _('German')), ('de', _('German')),
('pk', _('Polish')), ('pl', _('Polish')),
('tr', _('Turkish')), ('tr', _('Turkish')),
] ]

View File

@ -244,6 +244,14 @@ class TestSerialNumberExtraction(TestCase):
self.assertIn(3, sn) self.assertIn(3, sn)
self.assertIn(13, sn) self.assertIn(13, sn)
sn = e("1+", 10)
self.assertEqual(len(sn), 10)
self.assertEqual(sn, [_ for _ in range(1, 11)])
sn = e("4, 1+2", 4)
self.assertEqual(len(sn), 4)
self.assertEqual(sn, ["4", 1, 2, 3])
def test_failures(self): def test_failures(self):
e = helpers.extract_serial_numbers e = helpers.extract_serial_numbers

View File

@ -39,7 +39,7 @@ from rest_framework.documentation import include_docs_urls
from .views import IndexView, SearchView, DatabaseStatsView from .views import IndexView, SearchView, DatabaseStatsView
from .views import SettingsView, EditUserView, SetPasswordView from .views import SettingsView, EditUserView, SetPasswordView
from .views import ColorThemeSelectView, SettingCategorySelectView from .views import AppearanceSelectView, SettingCategorySelectView
from .views import DynamicJsView from .views import DynamicJsView
from common.views import SettingEdit from common.views import SettingEdit
@ -79,7 +79,8 @@ apipatterns = [
settings_urls = [ settings_urls = [
url(r'^user/?', SettingsView.as_view(template_name='InvenTree/settings/user.html'), name='settings-user'), url(r'^user/?', SettingsView.as_view(template_name='InvenTree/settings/user.html'), name='settings-user'),
url(r'^theme/?', ColorThemeSelectView.as_view(), name='settings-theme'), url(r'^appearance/?', AppearanceSelectView.as_view(), name='settings-appearance'),
url(r'^i18n/?', include('django.conf.urls.i18n')),
url(r'^global/?', SettingsView.as_view(template_name='InvenTree/settings/global.html'), name='settings-global'), url(r'^global/?', SettingsView.as_view(template_name='InvenTree/settings/global.html'), name='settings-global'),
url(r'^report/?', SettingsView.as_view(template_name='InvenTree/settings/report.html'), name='settings-report'), url(r'^report/?', SettingsView.as_view(template_name='InvenTree/settings/report.html'), name='settings-report'),

View File

@ -769,12 +769,12 @@ class SettingsView(TemplateView):
return ctx return ctx
class ColorThemeSelectView(FormView): class AppearanceSelectView(FormView):
""" View for selecting a color theme """ """ View for selecting a color theme """
form_class = ColorThemeSelectForm form_class = ColorThemeSelectForm
success_url = reverse_lazy('settings-theme') success_url = reverse_lazy('settings-appearance')
template_name = "InvenTree/settings/theme.html" template_name = "InvenTree/settings/appearance.html"
def get_user_theme(self): def get_user_theme(self):
""" Get current user color theme """ """ Get current user color theme """
@ -788,7 +788,7 @@ class ColorThemeSelectView(FormView):
def get_initial(self): def get_initial(self):
""" Select current user color theme as initial choice """ """ Select current user color theme as initial choice """
initial = super(ColorThemeSelectView, self).get_initial() initial = super(AppearanceSelectView, self).get_initial()
user_theme = self.get_user_theme() user_theme = self.get_user_theme()
if user_theme: if user_theme:

View File

@ -996,14 +996,28 @@ class Build(MPTTModel):
@property @property
def required_parts(self): def required_parts(self):
""" Returns a dict of parts required to build this part (BOM) """ """ Returns a list of parts required to build this part (BOM) """
parts = [] parts = []
for item in self.part.bom_items.all().prefetch_related('sub_part'): for item in self.bom_items:
parts.append(item.sub_part) parts.append(item.sub_part)
return parts return parts
@property
def required_parts_to_complete_build(self):
""" Returns a list of parts required to complete the full build """
parts = []
for bom_item in self.bom_items:
# Get remaining quantity needed
required_quantity_to_complete_build = self.remaining * bom_item.quantity
# Compare to net stock
if bom_item.sub_part.net_stock < required_quantity_to_complete_build:
parts.append(bom_item.sub_part)
return parts
def availableStockItems(self, part, output): def availableStockItems(self, part, output):
""" """
Returns stock items which are available for allocation to this build. Returns stock items which are available for allocation to this build.

View File

@ -40,8 +40,8 @@
<div class='panel-heading'> <div class='panel-heading'>
{% trans "The following items will be created" %} {% trans "The following items will be created" %}
</div> </div>
<div class='panel-content'> <div class='panel-content' style='padding-bottom:16px'>
{% include "hover_image.html" with image=build.part.image hover=True %} {% include "hover_image.html" with image=build.part.image %}
{% if output.serialized %} {% if output.serialized %}
{{ output.part.full_name }} - {% trans "Serial Number" %} {{ output.serial }} {{ output.part.full_name }} - {% trans "Serial Number" %} {{ output.serial }}
{% else %} {% else %}

View File

@ -157,6 +157,17 @@ class BuildOutputCreate(AjaxUpdateView):
quantity = form.cleaned_data.get('output_quantity', None) quantity = form.cleaned_data.get('output_quantity', None)
serials = form.cleaned_data.get('serial_numbers', None) serials = form.cleaned_data.get('serial_numbers', None)
if quantity:
build = self.get_object()
# Check that requested output don't exceed build remaining quantity
maximum_output = int(build.remaining - build.incomplete_count)
if quantity > maximum_output:
form.add_error(
'output_quantity',
_('Maximum output quantity is ') + str(maximum_output),
)
# Check that the serial numbers are valid # Check that the serial numbers are valid
if serials: if serials:
try: try:
@ -212,7 +223,7 @@ class BuildOutputCreate(AjaxUpdateView):
# Calculate the required quantity # Calculate the required quantity
quantity = max(0, build.remaining - build.incomplete_count) quantity = max(0, build.remaining - build.incomplete_count)
initials['output_quantity'] = quantity initials['output_quantity'] = int(quantity)
return initials return initials

View File

@ -131,7 +131,7 @@ class ManufacturerPartList(generics.ListCreateAPIView):
params = self.request.query_params params = self.request.query_params
# Filter by manufacturer # Filter by manufacturer
manufacturer = params.get('company', None) manufacturer = params.get('manufacturer', None)
if manufacturer is not None: if manufacturer is not None:
queryset = queryset.filter(manufacturer=manufacturer) queryset = queryset.filter(manufacturer=manufacturer)

View File

@ -675,4 +675,4 @@ class SupplierPriceBreak(common.models.PriceBreak):
db_table = 'part_supplierpricebreak' db_table = 'part_supplierpricebreak'
def __str__(self): def __str__(self):
return f'{self.part.MPN} - {self.price} @ {self.quantity}' return f'{self.part.SKU} - {self.price} @ {self.quantity}'

View File

@ -192,10 +192,11 @@ class SupplierPartSerializer(InvenTreeModelSerializer):
manufacturer_id = self.initial_data.get('manufacturer', None) manufacturer_id = self.initial_data.get('manufacturer', None)
MPN = self.initial_data.get('MPN', None) MPN = self.initial_data.get('MPN', None)
if manufacturer_id or MPN: if manufacturer_id and MPN:
kwargs = {'manufacturer': manufacturer_id, kwargs = {
'MPN': MPN, 'manufacturer': manufacturer_id,
} 'MPN': MPN,
}
supplier_part.save(**kwargs) supplier_part.save(**kwargs)
return supplier_part return supplier_part

View File

@ -100,7 +100,7 @@ class ManufacturerTest(InvenTreeAPITestCase):
self.assertEqual(response.data['MPN'], 'MPN_TEST') self.assertEqual(response.data['MPN'], 'MPN_TEST')
# Filter by manufacturer # Filter by manufacturer
data = {'company': 7} data = {'manufacturer': 7}
response = self.get(url, data) response = self.get(url, data)
self.assertEqual(len(response.data), 3) self.assertEqual(len(response.data), 3)

View File

@ -337,14 +337,16 @@ class PurchaseOrder(Order):
raise ValidationError({"status": _("Lines can only be received against an order marked as 'Placed'")}) raise ValidationError({"status": _("Lines can only be received against an order marked as 'Placed'")})
try: try:
if not (quantity % 1 == 0):
raise ValidationError({"quantity": _("Quantity must be an integer")})
if quantity < 0:
raise ValidationError({"quantity": _("Quantity must be a positive number")})
quantity = int(quantity) quantity = int(quantity)
if quantity <= 0: except (ValueError, TypeError):
raise ValidationError({"quantity": _("Quantity must be greater than zero")})
except ValueError:
raise ValidationError({"quantity": _("Invalid quantity provided")}) raise ValidationError({"quantity": _("Invalid quantity provided")})
# Create a new stock item # Create a new stock item
if line.part: if line.part and quantity > 0:
stock = stock_models.StockItem( stock = stock_models.StockItem(
part=line.part.part, part=line.part.part,
supplier_part=line.part, supplier_part=line.part,

View File

@ -171,11 +171,35 @@ $("#edit-order").click(function() {
); );
}); });
$("#receive-order").click(function() {
launchModalForm("{% url 'po-receive' order.id %}", {
reload: true,
secondary: [
{
field: 'location',
label: '{% trans "New Location" %}',
title: '{% trans "Create new stock location" %}',
url: "{% url 'stock-location-create' %}",
},
]
});
});
$("#complete-order").click(function() {
launchModalForm("{% url 'po-complete' order.id %}", {
reload: true,
});
});
$("#cancel-order").click(function() { $("#cancel-order").click(function() {
launchModalForm("{% url 'po-cancel' order.id %}", { launchModalForm("{% url 'po-cancel' order.id %}", {
reload: true, reload: true,
}); });
}); });
$("#export-order").click(function() {
location.href = "{% url 'po-export' order.id %}";
});
{% endblock %} {% endblock %}

View File

@ -4,6 +4,8 @@
{% block pre_form_content %} {% block pre_form_content %}
{% trans "Cancelling this order means that the order will no longer be editable." %} <div class='alert alert-danger alert-block'>
{% trans "Cancelling this order means that the order and line items will no longer be editable." %}
</div>
{% endblock %} {% endblock %}

View File

@ -6,9 +6,9 @@
{% trans 'Mark this order as complete?' %} {% trans 'Mark this order as complete?' %}
{% if not order.is_complete %} {% if not order.is_complete %}
<div class='alert alert-warning alert-block'> <div class='alert alert-warning alert-block' style='margin-top:12px'>
{% trans 'This order has line items which have not been marked as received.' %} {% trans 'This order has line items which have not been marked as received.' %}</br>
{% trans 'Marking this order as complete will remove these line items.' %} {% trans 'Completing this order means that the order and line items will no longer be editable.' %}
</div> </div>
{% endif %} {% endif %}

View File

@ -4,6 +4,8 @@
{% block pre_form_content %} {% block pre_form_content %}
{% trans 'After placing this purchase order, line items will no longer be editable.' %} <div class='alert alert-warning alert-block'>
{% trans 'After placing this purchase order, line items will no longer be editable.' %}
</div>
{% endblock %} {% endblock %}

View File

@ -35,31 +35,6 @@
{{ block.super }} {{ block.super }}
$("#receive-order").click(function() {
launchModalForm("{% url 'po-receive' order.id %}", {
reload: true,
secondary: [
{
field: 'location',
label: '{% trans "New Location" %}',
title: '{% trans "Create new stock location" %}',
url: "{% url 'stock-location-create' %}",
},
]
});
});
$("#complete-order").click(function() {
launchModalForm("{% url 'po-complete' order.id %}", {
reload: true,
});
});
$("#export-order").click(function() {
location.href = "{% url 'po-export' order.id %}";
});
{% if order.status == PurchaseOrderStatus.PENDING %} {% if order.status == PurchaseOrderStatus.PENDING %}
$('#new-po-line').click(function() { $('#new-po-line').click(function() {
launchModalForm("{% url 'po-line-item-create' %}", launchModalForm("{% url 'po-line-item-create' %}",
@ -261,5 +236,4 @@ $("#po-table").inventreeTable({
] ]
}); });
{% endblock %} {% endblock %}

View File

@ -7,7 +7,7 @@ from __future__ import unicode_literals
from django_filters.rest_framework import DjangoFilterBackend from django_filters.rest_framework import DjangoFilterBackend
from django.http import JsonResponse from django.http import JsonResponse
from django.db.models import Q, F, Count, Prefetch, Sum from django.db.models import Q, F, Count
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from rest_framework import status from rest_framework import status
@ -635,29 +635,15 @@ class PartList(generics.ListCreateAPIView):
# TODO: Need to figure out a cheaper way of making this filter query # TODO: Need to figure out a cheaper way of making this filter query
if stock_to_build is not None: if stock_to_build is not None:
# Filter only active parts # Get active builds
queryset = queryset.filter(active=True) builds = Build.objects.filter(status__in=BuildStatus.ACTIVE_CODES)
# Prefetch current active builds
build_active_queryset = Build.objects.filter(status__in=BuildStatus.ACTIVE_CODES)
build_active_prefetch = Prefetch('builds',
queryset=build_active_queryset,
to_attr='current_builds')
parts = queryset.prefetch_related(build_active_prefetch)
# Store parts with builds needing stock # Store parts with builds needing stock
parts_need_stock = [] parts_needed_to_complete_builds = []
# Filter required parts
for build in builds:
parts_needed_to_complete_builds += [part.pk for part in build.required_parts_to_complete_build]
# Find parts with active builds queryset = queryset.filter(pk__in=parts_needed_to_complete_builds)
# where any subpart's stock is lower than quantity being built
for part in parts:
if part.current_builds:
builds_ids = [build.id for build in part.current_builds]
total_build_quantity = build_active_queryset.filter(pk__in=builds_ids).aggregate(quantity=Sum('quantity'))['quantity']
if part.can_build < total_build_quantity:
parts_need_stock.append(part.pk)
queryset = queryset.filter(pk__in=parts_need_stock)
# Optionally limit the maximum number of returned results # Optionally limit the maximum number of returned results
# e.g. for displaying "recent part" list # e.g. for displaying "recent part" list

View File

@ -116,6 +116,12 @@ def inventree_docs_url(*args, **kwargs):
return "https://inventree.readthedocs.io/" return "https://inventree.readthedocs.io/"
@register.simple_tag()
def inventree_credits_url(*args, **kwargs):
""" Return URL for InvenTree credits site """
return "https://inventree.readthedocs.io/en/latest/credits/"
@register.simple_tag() @register.simple_tag()
def setting_object(key, *args, **kwargs): def setting_object(key, *args, **kwargs):
""" """

View File

@ -0,0 +1,67 @@
{% extends "InvenTree/settings/settings.html" %}
{% load i18n %}
{% load inventree_extras %}
{% block tabs %}
{% include "InvenTree/settings/tabs.html" with tab='theme' %}
{% endblock %}
{% block subtitle %}
{% trans "Theme Settings" %}
{% endblock %}
{% block settings %}
<div class='row'>
<div class='col-sm-6'>
<h4>{% trans "Color Themes" %}</h4>
</div>
</div>
<form action="{% url 'settings-appearance' %}" method="post">
{% csrf_token %}
{% load crispy_forms_tags %}
{% crispy form %}
</form>
{% if invalid_color_theme %}
<div class="alert alert-danger alert-block" role="alert" style="display: inline-block;">
{% blocktrans %}
The CSS sheet "{{invalid_color_theme}}.css" for the currently selected color theme was not found.<br>
Please select another color theme :)
{% endblocktrans %}
</div>
{% endif %}
<div class='row'>
<div class='col-sm-6'>
<h4>{% trans "Language" %}</h4>
</div>
</div>
<div class="row">
<form action="{% url 'set_language' %}" method="post">{% csrf_token %}
<input name="next" type="hidden" value="{% url 'settings-appearance' %}">
<div class="col-sm-6" style="width: 200px;"><div id="div_id_name" class="form-group"><div class="controls ">
<select name="language" class="select form-control">
{% get_current_language as LANGUAGE_CODE %}
{% get_available_languages as LANGUAGES %}
{% get_language_info_list for LANGUAGES as languages %}
{% for language in languages %}
<option value="{{ language.code }}"{% if language.code == LANGUAGE_CODE %} selected{% endif %}>
{{ language.name_local }} ({{ language.code }})
</option>
{% endfor %}
</select>
</div></div></div>
<div class="col-sm-6" style="width: auto;">
<input type="submit" value="{% trans 'Set Language' %}" class="btn btn btn-primary">
</div>
</form>
</div>
{% endblock %}

View File

@ -6,7 +6,7 @@
<a href="{% url 'settings-user' %}"><span class='fas fa-user'></span> {% trans "Account" %}</a> <a href="{% url 'settings-user' %}"><span class='fas fa-user'></span> {% trans "Account" %}</a>
</li> </li>
<li{% ifequal tab 'theme' %} class='active'{% endifequal %}> <li{% ifequal tab 'theme' %} class='active'{% endifequal %}>
<a href="{% url 'settings-theme' %}"><span class='fas fa-fill'></span> {% trans "Theme" %}</a> <a href="{% url 'settings-appearance' %}"><span class='fas fa-fill'></span> {% trans "Appearance" %}</a>
</li> </li>
</ul> </ul>
{% if user.is_staff %} {% if user.is_staff %}

View File

@ -1,36 +0,0 @@
{% extends "InvenTree/settings/settings.html" %}
{% load i18n %}
{% load inventree_extras %}
{% block tabs %}
{% include "InvenTree/settings/tabs.html" with tab='theme' %}
{% endblock %}
{% block subtitle %}
{% trans "Theme Settings" %}
{% endblock %}
{% block settings %}
<div class='row'>
<div class='col-sm-6'>
<h4>{% trans "Color Themes" %}</h4>
</div>
</div>
<form action="{% url 'settings-theme' %}" method="post">
{% csrf_token %}
{% load crispy_forms_tags %}
{% crispy form %}
</form>
{% if invalid_color_theme %}
<div class="alert alert-danger alert-block" role="alert" style="display: inline-block;">
{% blocktrans %}
The CSS sheet "{{invalid_color_theme}}.css" for the currently selected color theme was not found.<br>
Please select another color theme :)
{% endblocktrans %}
</div>
{% endif %}
{% endblock %}

View File

@ -58,6 +58,11 @@
<td>{% trans "View Code on GitHub" %}</td> <td>{% trans "View Code on GitHub" %}</td>
<td><a href="{% inventree_github_url %}">{% inventree_github_url %}</a></td> <td><a href="{% inventree_github_url %}">{% inventree_github_url %}</a></td>
</tr> </tr>
<tr>
<td><span class='fas fa-balance-scale'></span></td>
<td>{% trans "Credits" %}</td>
<td><a href="{% inventree_credits_url %}">{% inventree_credits_url %}</a></td>
</tr>
<tr> <tr>
<td><span class='fas fa-mobile-alt'></span></td> <td><span class='fas fa-mobile-alt'></span></td>
<td>{% trans "Mobile App" %}</td> <td>{% trans "Mobile App" %}</td>

View File

@ -16,7 +16,7 @@
</button> </button>
{% if owner_control.value == "True" and user in owners or user.is_superuser or owner_control.value == "False" %} {% if owner_control.value == "True" and user in owners or user.is_superuser or owner_control.value == "False" %}
{% if roles.stock.add %} {% if not read_only and roles.stock.add %}
<button class="btn btn-success" id='item-create' title='{% trans "New Stock Item" %}'> <button class="btn btn-success" id='item-create' title='{% trans "New Stock Item" %}'>
<span class='fas fa-plus-circle'></span> <span class='fas fa-plus-circle'></span>
</button> </button>
@ -44,6 +44,7 @@
{% endif %} {% endif %}
</ul> </ul>
</div> </div>
{% if not read_only %}
{% if roles.stock.change or roles.stock.delete %} {% if roles.stock.change or roles.stock.delete %}
<div class="btn-group"> <div class="btn-group">
<button id='stock-options' class="btn btn-primary dropdown-toggle" type="button" data-toggle="dropdown" title='{% trans "Stock Options" %}'> <button id='stock-options' class="btn btn-primary dropdown-toggle" type="button" data-toggle="dropdown" title='{% trans "Stock Options" %}'>
@ -65,6 +66,7 @@
</div> </div>
{% endif %} {% endif %}
{% endif %} {% endif %}
{% endif %}
<div class='filter-list' id='filter-list-stock'> <div class='filter-list' id='filter-list-stock'>
<!-- An empty div in which the filter list will be constructed --> <!-- An empty div in which the filter list will be constructed -->
</div> </div>

View File

@ -51,6 +51,10 @@ To contribute to the translation effort, navigate to the [InvenTree crowdin proj
For InvenTree documentation, refer to the [InvenTree documentation website](https://inventree.readthedocs.io/en/latest/). For InvenTree documentation, refer to the [InvenTree documentation website](https://inventree.readthedocs.io/en/latest/).
# Credits
The credits for all used packages are part of the [InvenTree documentation website](https://inventree.readthedocs.io/en/latest/credits/).
## Getting Started ## Getting Started
Refer to the [getting started guide](https://inventree.readthedocs.io/en/latest/start/install/) for installation and setup instructions. Refer to the [getting started guide](https://inventree.readthedocs.io/en/latest/start/install/) for installation and setup instructions.

View File

@ -248,6 +248,7 @@ def content_excludes():
"django_q.schedule", "django_q.schedule",
"django_q.task", "django_q.task",
"django_q.ormq", "django_q.ormq",
"users.owner",
] ]
output = "" output = ""