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 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 <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:
expected_quantity: The number of (unique) serial numbers we expect
@ -369,6 +371,13 @@ def extract_serial_numbers(serials, expected_quantity):
numbers = []
errors = []
# helpers
def number_add(n):
if n in numbers:
errors.append(_('Duplicate serial: {n}').format(n=n))
else:
numbers.append(n)
try:
expected_quantity = int(expected_quantity)
except ValueError:
@ -395,10 +404,7 @@ def extract_serial_numbers(serials, expected_quantity):
if a < b:
for n in range(a, b + 1):
if n in numbers:
errors.append(_('Duplicate serial: {n}').format(n=n))
else:
numbers.append(n)
number_add(n)
else:
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))
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:
if group in numbers:
errors.append(_("Duplicate serial: {g}".format(g=group)))

View File

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

View File

@ -244,6 +244,14 @@ class TestSerialNumberExtraction(TestCase):
self.assertIn(3, 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):
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 SettingsView, EditUserView, SetPasswordView
from .views import ColorThemeSelectView, SettingCategorySelectView
from .views import AppearanceSelectView, SettingCategorySelectView
from .views import DynamicJsView
from common.views import SettingEdit
@ -79,7 +79,8 @@ apipatterns = [
settings_urls = [
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'^report/?', SettingsView.as_view(template_name='InvenTree/settings/report.html'), name='settings-report'),

View File

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

View File

@ -996,14 +996,28 @@ class Build(MPTTModel):
@property
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 = []
for item in self.part.bom_items.all().prefetch_related('sub_part'):
for item in self.bom_items:
parts.append(item.sub_part)
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):
"""
Returns stock items which are available for allocation to this build.

View File

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

View File

@ -157,6 +157,17 @@ class BuildOutputCreate(AjaxUpdateView):
quantity = form.cleaned_data.get('output_quantity', 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
if serials:
try:
@ -212,7 +223,7 @@ class BuildOutputCreate(AjaxUpdateView):
# Calculate the required quantity
quantity = max(0, build.remaining - build.incomplete_count)
initials['output_quantity'] = quantity
initials['output_quantity'] = int(quantity)
return initials

View File

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

View File

@ -675,4 +675,4 @@ class SupplierPriceBreak(common.models.PriceBreak):
db_table = 'part_supplierpricebreak'
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)
MPN = self.initial_data.get('MPN', None)
if manufacturer_id or MPN:
kwargs = {'manufacturer': manufacturer_id,
'MPN': MPN,
}
if manufacturer_id and MPN:
kwargs = {
'manufacturer': manufacturer_id,
'MPN': MPN,
}
supplier_part.save(**kwargs)
return supplier_part

View File

@ -100,7 +100,7 @@ class ManufacturerTest(InvenTreeAPITestCase):
self.assertEqual(response.data['MPN'], 'MPN_TEST')
# Filter by manufacturer
data = {'company': 7}
data = {'manufacturer': 7}
response = self.get(url, data)
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'")})
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)
if quantity <= 0:
raise ValidationError({"quantity": _("Quantity must be greater than zero")})
except ValueError:
except (ValueError, TypeError):
raise ValidationError({"quantity": _("Invalid quantity provided")})
# Create a new stock item
if line.part:
if line.part and quantity > 0:
stock = stock_models.StockItem(
part=line.part.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() {
launchModalForm("{% url 'po-cancel' order.id %}", {
reload: true,
});
});
$("#export-order").click(function() {
location.href = "{% url 'po-export' order.id %}";
});
{% endblock %}

View File

@ -4,6 +4,8 @@
{% 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 %}

View File

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

View File

@ -4,6 +4,8 @@
{% 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 %}

View File

@ -35,31 +35,6 @@
{{ 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 %}
$('#new-po-line').click(function() {
launchModalForm("{% url 'po-line-item-create' %}",
@ -261,5 +236,4 @@ $("#po-table").inventreeTable({
]
});
{% endblock %}

View File

@ -7,7 +7,7 @@ from __future__ import unicode_literals
from django_filters.rest_framework import DjangoFilterBackend
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 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
if stock_to_build is not None:
# Filter only active parts
queryset = queryset.filter(active=True)
# 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)
# Get active builds
builds = Build.objects.filter(status__in=BuildStatus.ACTIVE_CODES)
# 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
# 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)
queryset = queryset.filter(pk__in=parts_needed_to_complete_builds)
# Optionally limit the maximum number of returned results
# e.g. for displaying "recent part" list

View File

@ -116,6 +116,12 @@ def inventree_docs_url(*args, **kwargs):
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()
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>
</li>
<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>
</ul>
{% 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><a href="{% inventree_github_url %}">{% inventree_github_url %}</a></td>
</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>
<td><span class='fas fa-mobile-alt'></span></td>
<td>{% trans "Mobile App" %}</td>

View File

@ -16,7 +16,7 @@
</button>
{% 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" %}'>
<span class='fas fa-plus-circle'></span>
</button>
@ -44,6 +44,7 @@
{% endif %}
</ul>
</div>
{% if not read_only %}
{% if roles.stock.change or roles.stock.delete %}
<div class="btn-group">
<button id='stock-options' class="btn btn-primary dropdown-toggle" type="button" data-toggle="dropdown" title='{% trans "Stock Options" %}'>
@ -65,6 +66,7 @@
</div>
{% endif %}
{% endif %}
{% endif %}
<div class='filter-list' id='filter-list-stock'>
<!-- An empty div in which the filter list will be constructed -->
</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/).
# Credits
The credits for all used packages are part of the [InvenTree documentation website](https://inventree.readthedocs.io/en/latest/credits/).
## Getting Started
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.task",
"django_q.ormq",
"users.owner",
]
output = ""