Merge remote-tracking branch 'inventree/master'

This commit is contained in:
Oliver Walters 2021-03-30 10:37:44 +11:00
commit 60fe54ac07
21 changed files with 2717 additions and 1763 deletions

View File

@ -10,6 +10,9 @@
{% block heading %}
{% trans "Build Notes" %}
{% if roles.build.change and not editing %}
<button title='{% trans "Edit notes" %}' class='btn btn-default' id='edit-notes'><span class='fas fa-edit'></span></button>
{% endif %}
{% endblock %}
{% block details %}
@ -20,14 +23,13 @@
{{ form }}
<hr>
<input type="submit" value='{% trans "Save" %}'/>
<button type="submit" class='btn btn-default'>{% trans "Save" %}</button>
</form>
{{ form.media }}
{% else %}
<button title='{% trans "Edit notes" %}' class='btn btn-default action-button' id='edit-notes'><span class='fas fa-edit'></span></button>
{{ build.notes | markdownify }}
{% endif %}

View File

@ -9,6 +9,9 @@
{% block heading %}
{% trans "Company Notes" %}
{% if not editing %}
<button title='{% trans "Edit notes" %}' class='btn btn-default' id='edit-notes'><span class='fas fa-edit'></span></button>
{% endif %}
{% endblock %}
{% block details %}
@ -18,7 +21,7 @@
{{ form }}
<hr>
<input type="submit" value='{% trans "Save" %}'/>
<button type="submit" class='btn btn-default'>{% trans "Save" %}</button>
</form>
@ -26,7 +29,6 @@
{% else %}
<button title='{% trans "Edit notes" %}' class='btn btn-default action-button' id='edit-notes'><span class='fas fa-edit'></span></button>
{{ company.notes | markdownify }}
{% endif %}

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

@ -14,6 +14,8 @@ from InvenTree.forms import HelperForm
from InvenTree.fields import RoundingDecimalFormField
from InvenTree.fields import DatePickerFormField
import part.models
from stock.models import StockLocation
from .models import PurchaseOrder, PurchaseOrderLineItem, PurchaseOrderAttachment
from .models import SalesOrder, SalesOrderLineItem, SalesOrderAttachment
@ -211,7 +213,65 @@ class EditSalesOrderLineItemForm(HelperForm):
]
class AllocateSerialsToSalesOrderForm(forms.Form):
"""
Form for assigning stock to a sales order,
by serial number lookup
"""
line = forms.ModelChoiceField(
queryset=SalesOrderLineItem.objects.all(),
)
part = forms.ModelChoiceField(
queryset=part.models.Part.objects.all(),
)
serials = forms.CharField(
label=_("Serial Numbers"),
required=True,
help_text=_('Enter stock item serial numbers'),
)
quantity = forms.IntegerField(
label=_('Quantity'),
required=True,
help_text=_('Enter quantity of stock items'),
initial=1,
min_value=1
)
class Meta:
fields = [
'line',
'part',
'serials',
'quantity',
]
class CreateSalesOrderAllocationForm(HelperForm):
"""
Form for creating a SalesOrderAllocation item.
"""
quantity = RoundingDecimalFormField(max_digits=10, decimal_places=5)
class Meta:
model = SalesOrderAllocation
fields = [
'line',
'item',
'quantity',
]
class EditSalesOrderAllocationForm(HelperForm):
"""
Form for editing a SalesOrderAllocation item
"""
quantity = RoundingDecimalFormField(max_digits=10, decimal_places=5)

View File

@ -0,0 +1,17 @@
# Generated by Django 3.0.7 on 2021-03-29 13:13
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('order', '0042_auto_20210310_1619'),
]
operations = [
migrations.AlterUniqueTogether(
name='salesorderlineitem',
unique_together=set(),
),
]

View File

@ -663,7 +663,6 @@ class SalesOrderLineItem(OrderLineItem):
class Meta:
unique_together = [
('order', 'part'),
]
def fulfilled_quantity(self):
@ -732,6 +731,12 @@ class SalesOrderAllocation(models.Model):
errors = {}
try:
if not self.item:
raise ValidationError({'item': _('Stock item has not been assigned')})
except stock_models.StockItem.DoesNotExist:
raise ValidationError({'item': _('Stock item has not been assigned')})
try:
if not self.line.part == self.item.part:
errors['item'] = _('Cannot allocate stock item to a line with a different part')

View File

@ -11,6 +11,9 @@
{% block heading %}
{% trans "Order Notes" %}
{% if roles.purchase_order.change and not editing %}
<button title='{% trans "Edit notes" %}' class='btn btn-default' id='edit-notes'><span class='fas fa-edit'></span></button>
{% endif %}
{% endblock %}
{% block details %}
@ -21,21 +24,19 @@
{{ form }}
<hr>
<input type='submit' value='{% trans "Save" %}'/>
<button type="submit" class='btn btn-default'>{% trans "Save" %}</button>
</form>
{{ form.media }}
{% else %}
{% if roles.purchase_order.change %}
<button title='{% trans "Edit notes" %}' class='btn btn-default action-button' id='edit-notes'><span class='fas fa-edit'></span></button>
{% endif %}
{% endif %}
<div class='panel panel-default'>
<div class='panel-content'>
{{ order.notes | markdownify }}
</div>
</div>
{% endif %}
{% endblock %}

View File

@ -275,15 +275,20 @@ $("#so-lines-table").inventreeTable({
if (row.part) {
var part = row.part_detail;
if (part.trackable) {
html += makeIconButton('fa-hashtag icon-green', 'button-add-by-sn', pk, '{% trans "Allocate serial numbers" %}');
}
html += makeIconButton('fa-sign-in-alt icon-green', 'button-add', pk, '{% trans "Allocate stock" %}');
if (part.purchaseable) {
html += makeIconButton('fa-shopping-cart', 'button-buy', row.part, '{% trans "Buy parts" %}');
html += makeIconButton('fa-shopping-cart', 'button-buy', row.part, '{% trans "Purchase stock" %}');
}
if (part.assembly) {
html += makeIconButton('fa-tools', 'button-build', row.part, '{% trans "Build parts" %}');
html += makeIconButton('fa-tools', 'button-build', row.part, '{% trans "Build stock" %}');
}
html += makeIconButton('fa-plus icon-green', 'button-add', pk, '{% trans "Allocate parts" %}');
}
html += makeIconButton('fa-edit icon-blue', 'button-edit', pk, '{% trans "Edit line item" %}');
@ -316,10 +321,28 @@ function setupCallbacks() {
var pk = $(this).attr('pk');
launchModalForm(`/order/sales-order/line/${pk}/delete/`, {
reload: true,
success: reloadTable,
});
});
table.find(".button-add-by-sn").click(function() {
var pk = $(this).attr('pk');
inventreeGet(`/api/order/so-line/${pk}/`, {},
{
success: function(response) {
launchModalForm('{% url "so-assign-serials" %}', {
success: reloadTable,
data: {
line: pk,
part: response.part,
}
});
}
}
);
});
table.find(".button-add").click(function() {
var pk = $(this).attr('pk');

View File

@ -12,6 +12,9 @@
{% block heading %}
{% trans "Sales Order Notes" %}
{% if roles.sales_order.change and not editing %}
<button title='{% trans "Edit notes" %}' class='btn btn-default' id='edit-notes'><span class='fas fa-edit'></span></button>
{% endif %}
{% endblock %}
{% block details %}
@ -23,13 +26,12 @@
{{ form }}
<hr>
<input type='submit' value='{% trans "Save" %}'/>
<button type="submit" class='btn btn-default'>{% trans "Save" %}</button>
</form>
{{ form.media }}
{% else %}
<button title='{% trans "Edit notes" %}' class='btn btn-default action-button float-right' id='edit-notes'><span class='fas fa-edit'></span></button>
<div class='panel panel-default'>
<div class='panel-content'>
{{ order.notes | markdownify }}

View File

@ -0,0 +1,12 @@
{% extends "modal_form.html" %}
{% load i18n %}
{% block pre_form_content %}
<div class='alert alert-block alert-info'>
{% include "hover_image.html" with image=part.image hover=true %}{{ part }}
<hr>
{% trans "Allocate stock items by serial number" %}
</div>
{% endblock %}

View File

@ -3,7 +3,6 @@
from django.test import TestCase
from django.core.exceptions import ValidationError
from django.db.utils import IntegrityError
from datetime import datetime, timedelta
@ -73,10 +72,10 @@ class SalesOrderTest(TestCase):
self.assertFalse(self.order.is_fully_allocated())
def test_add_duplicate_line_item(self):
# Adding a duplicate line item to a SalesOrder must throw an error
# Adding a duplicate line item to a SalesOrder is accepted
with self.assertRaises(IntegrityError):
SalesOrderLineItem.objects.create(order=self.order, part=self.part)
for ii in range(1, 5):
SalesOrderLineItem.objects.create(order=self.order, part=self.part, quantity=ii)
def allocate_stock(self, full=True):

View File

@ -81,6 +81,7 @@ sales_order_urls = [
# URLs for sales order allocations
url(r'^allocation/', include([
url(r'^new/', views.SalesOrderAllocationCreate.as_view(), name='so-allocation-create'),
url(r'^assign-serials/', views.SalesOrderAssignSerials.as_view(), name='so-assign-serials'),
url(r'(?P<pk>\d+)/', include([
url(r'^edit/', views.SalesOrderAllocationEdit.as_view(), name='so-allocation-edit'),
url(r'^delete/', views.SalesOrderAllocationDelete.as_view(), name='so-allocation-delete'),

View File

@ -7,9 +7,11 @@ from __future__ import unicode_literals
from django.db import transaction
from django.shortcuts import get_object_or_404
from django.core.exceptions import ValidationError
from django.urls import reverse
from django.utils.translation import ugettext as _
from django.views.generic import DetailView, ListView, UpdateView
from django.views.generic.edit import FormMixin
from django.forms import HiddenInput
import logging
@ -30,6 +32,7 @@ from . import forms as order_forms
from InvenTree.views import AjaxView, AjaxCreateView, AjaxUpdateView, AjaxDeleteView
from InvenTree.helpers import DownloadFile, str2bool
from InvenTree.helpers import extract_serial_numbers
from InvenTree.views import InvenTreeRoleMixin
from InvenTree.status_codes import PurchaseOrderStatus, SalesOrderStatus, StockStatus
@ -1291,11 +1294,179 @@ class SOLineItemDelete(AjaxDeleteView):
}
class SalesOrderAssignSerials(AjaxView, FormMixin):
"""
View for assigning stock items to a sales order,
by serial number lookup.
"""
model = SalesOrderAllocation
role_required = 'sales_order.change'
ajax_template_name = 'order/so_allocate_by_serial.html'
ajax_form_title = _('Allocate Serial Numbers')
form_class = order_forms.AllocateSerialsToSalesOrderForm
# Keep track of SalesOrderLineItem and Part references
line = None
part = None
def get_initial(self):
"""
Initial values are passed as query params
"""
initials = super().get_initial()
try:
self.line = SalesOrderLineItem.objects.get(pk=self.request.GET.get('line', None))
initials['line'] = self.line
except (ValueError, SalesOrderLineItem.DoesNotExist):
pass
try:
self.part = Part.objects.get(pk=self.request.GET.get('part', None))
initials['part'] = self.part
except (ValueError, Part.DoesNotExist):
pass
return initials
def post(self, request, *args, **kwargs):
self.form = self.get_form()
# Validate the form
self.form.is_valid()
self.validate()
valid = self.form.is_valid()
if valid:
self.allocate_items()
data = {
'form_valid': valid,
'form_errors': self.form.errors.as_json(),
'non_field_errors': self.form.non_field_errors().as_json(),
'success': _("Allocated") + f" {len(self.stock_items)} " + _("items")
}
return self.renderJsonResponse(request, self.form, data)
def validate(self):
data = self.form.cleaned_data
# Extract hidden fields from posted data
self.line = data.get('line', None)
self.part = data.get('part', None)
if self.line:
self.form.fields['line'].widget = HiddenInput()
else:
self.form.add_error('line', _('Select line item'))
if self.part:
self.form.fields['part'].widget = HiddenInput()
else:
self.form.add_error('part', _('Select part'))
if not self.form.is_valid():
return
# Form is otherwise valid - check serial numbers
serials = data.get('serials', '')
quantity = data.get('quantity', 1)
# Save a list of serial_numbers
self.serial_numbers = None
self.stock_items = []
try:
self.serial_numbers = extract_serial_numbers(serials, quantity)
for serial in self.serial_numbers:
try:
# Find matching stock item
stock_item = StockItem.objects.get(
part=self.part,
serial=serial
)
except StockItem.DoesNotExist:
self.form.add_error(
'serials',
_('No matching item for serial') + f" '{serial}'"
)
continue
# Now we have a valid stock item - but can it be added to the sales order?
# If not in stock, cannot be added to the order
if not stock_item.in_stock:
self.form.add_error(
'serials',
f"'{serial}' " + _("is not in stock")
)
continue
# Already allocated to an order
if stock_item.is_allocated():
self.form.add_error(
'serials',
f"'{serial}' " + _("already allocated to an order")
)
continue
# Add it to the list!
self.stock_items.append(stock_item)
except ValidationError as e:
self.form.add_error('serials', e.messages)
def allocate_items(self):
"""
Create stock item allocations for each selected serial number
"""
for stock_item in self.stock_items:
SalesOrderAllocation.objects.create(
item=stock_item,
line=self.line,
quantity=1,
)
def get_form(self):
form = super().get_form()
if self.line:
form.fields['line'].widget = HiddenInput()
if self.part:
form.fields['part'].widget = HiddenInput()
return form
def get_context_data(self):
return {
'line': self.line,
'part': self.part,
}
def get(self, request, *args, **kwargs):
return self.renderJsonResponse(
request,
self.get_form(),
context=self.get_context_data(),
)
class SalesOrderAllocationCreate(AjaxCreateView):
""" View for creating a new SalesOrderAllocation """
model = SalesOrderAllocation
form_class = order_forms.EditSalesOrderAllocationForm
form_class = order_forms.CreateSalesOrderAllocationForm
ajax_form_title = _('Allocate Stock to Order')
def get_initial(self):

View File

@ -10,35 +10,34 @@
{% block heading %}
{% trans "Part Notes" %}
{% if roles.part.change and not editing %}
<button title='{% trans "Edit notes" %}' class='btn btn-default' id='edit-notes'><span class='fas fa-edit'></span></button>
{% endif %}
{% endblock %}
{% block details %}
{% if editing %}
<form method='POST'>
{% csrf_token %}
{{ form }}
<hr>
<input type="submit" value='{% trans "Save" %}'/>
<button type="submit" class='btn btn-default'>{% trans "Save" %}</button>
</form>
{{ form.media }}
{% else %}
{% if roles.part.change %}
<button title='{% trans "Edit notes" %}' class='btn btn-default action-button' id='edit-notes'><span class='fas fa-edit'></span></button>
{% endif %}
<div class='panel panel-default'>
{% if part.notes %}
<div class='panel-content'>
{% if part.notes %}
{{ part.notes | markdownify }}
{% endif %}
</div>
{% endif %}
</div>
{% endif %}

View File

@ -155,18 +155,24 @@ InvenTree | {% trans "Stock Item" %} - {{ item }}
<div class='btn-group'>
<button id='stock-actions' title='{% trans "Stock adjustment actions" %}' class='btn btn-default dropdown-toggle' type='button' data-toggle='dropdown'><span class='fas fa-boxes'></span> <span class='caret'></span></button>
<ul class='dropdown-menu' role='menu'>
{% if item.can_adjust_location %}
{% if not item.serialized %}
{% if item.in_stock %}
<li><a href='#' id='stock-count' title='{% trans "Count stock" %}'><span class='fas fa-clipboard-list'></span> {% trans "Count stock" %}</a></li>
{% endif %}
{% if not item.customer %}
<li><a href='#' id='stock-add' title='{% trans "Add stock" %}'><span class='fas fa-plus-circle icon-green'></span> {% trans "Add stock" %}</a></li>
{% endif %}
{% if item.in_stock %}
<li><a href='#' id='stock-remove' title='{% trans "Remove stock" %}'><span class='fas fa-minus-circle icon-red'></span> {% trans "Remove stock" %}</a></li>
{% endif %}
{% if item.in_stock and item.can_adjust_location %}
<li><a href='#' id='stock-move' title='{% trans "Transfer stock" %}'><span class='fas fa-exchange-alt icon-blue'></span> {% trans "Transfer stock" %}</a></li>
{% if item.part.trackable and not item.serialized %}
{% endif %}
{% if item.in_stock and item.part.trackable %}
<li><a href='#' id='stock-serialize' title='{% trans "Serialize stock" %}'><span class='fas fa-hashtag'></span> {% trans "Serialize stock" %}</a> </li>
{% endif %}
{% endif %}
{% if item.part.salable and not item.customer %}
{% if item.in_stock and item.can_adjust_location and item.part.salable and not item.customer %}
<li><a href='#' id='stock-assign-to-customer' title='{% trans "Assign to customer" %}'><span class='fas fa-user-tie'></span> {% trans "Assign to customer" %}</a></li>
{% endif %}
{% if item.customer %}
@ -469,16 +475,6 @@ $("#barcode-scan-into-location").click(function() {
scanItemsIntoLocation([{{ item.id }}]);
});
{% if item.in_stock %}
$("#stock-assign-to-customer").click(function() {
launchModalForm("{% url 'stock-item-assign' item.id %}",
{
reload: true,
}
);
});
function itemAdjust(action) {
launchModalForm("/stock/adjust/",
{
@ -492,6 +488,29 @@ function itemAdjust(action) {
);
}
$('#stock-add').click(function() {
itemAdjust('add');
});
$("#stock-delete").click(function () {
launchModalForm(
"{% url 'stock-item-delete' item.id %}",
{
redirect: "{% url 'part-stock' item.part.id %}"
}
);
});
{% if item.in_stock %}
$("#stock-assign-to-customer").click(function() {
launchModalForm("{% url 'stock-item-assign' item.id %}",
{
reload: true,
}
);
});
{% if item.part.has_variants %}
$("#stock-convert").click(function() {
launchModalForm("{% url 'stock-item-convert' item.id %}",
@ -514,10 +533,6 @@ $('#stock-remove').click(function() {
itemAdjust('take');
});
$('#stock-add').click(function() {
itemAdjust('add');
});
{% else %}
$("#stock-return-from-customer").click(function() {
@ -530,13 +545,4 @@ $("#stock-return-from-customer").click(function() {
{% endif %}
$("#stock-delete").click(function () {
launchModalForm(
"{% url 'stock-item-delete' item.id %}",
{
redirect: "{% url 'part-stock' item.part.id %}"
}
);
});
{% endblock %}

View File

@ -11,6 +11,9 @@
{% block heading %}
{% trans "Stock Item Notes" %}
{% if roles.stock.change and not editing %}
<button title='{% trans "Edit notes" %}' class='btn btn-default' id='edit-notes'><span class='fas fa-edit'></span></button>
{% endif %}
{% endblock %}
{% block details %}
@ -20,13 +23,12 @@
{{ form }}
<hr>
<input type='submit' value='{% trans "Save" %}'/>
<button type="submit" class='btn btn-default'>{% trans "Save" %}</button>
</form>
{{ form.media }}
{% else %}
<button title='{% trans "Edit notes" %}' class='btn btn-default action-button' id='edit-notes'><span class='fas fa-edit'></span></button>
{% if item.notes %}
{{ item.notes | markdownify }}
{% endif %}

View File

@ -12,6 +12,11 @@ from django.utils.translation import gettext_lazy as _
from django.dispatch import receiver
from django.db.models.signals import post_save, post_delete
import logging
logger = logging.getLogger(__name__)
class RuleSet(models.Model):
"""
@ -345,7 +350,7 @@ def update_group_roles(group, debug=False):
content_type = ContentType.objects.get(app_label=app, model=model)
permission = Permission.objects.get(content_type=content_type, codename=perm)
except ContentType.DoesNotExist:
raise ValueError(f"Error: Could not find permission matching '{permission_string}'")
logger.warning(f"Error: Could not find permission matching '{permission_string}'")
permission = None
return permission

View File

@ -0,0 +1,38 @@
"""
Unit tests for the user model database migrations
"""
from django_test_migrations.contrib.unittest_case import MigratorTestCase
from InvenTree import helpers
class TestForwardMigrations(MigratorTestCase):
"""
Test entire schema migration sequence for the users app
"""
migrate_from = ('users', helpers.getOldestMigrationFile('users'))
migrate_to = ('users', helpers.getNewestMigrationFile('users'))
def prepare(self):
User = self.old_state.apps.get_model('auth', 'user')
User.objects.create(
username='fred',
email='fred@fred.com',
password='password'
)
User.objects.create(
username='brad',
email='brad@fred.com',
password='password'
)
def test_users_exist(self):
User = self.new_state.apps.get_model('auth', 'user')
self.assertEqual(User.objects.count(), 2)

View File

@ -1,7 +1,7 @@
wheel>=0.34.2 # Wheel
Django==3.0.7 # Django package
pillow==8.1.1 # Image manipulation
djangorestframework==3.10.3 # DRF framework
djangorestframework==3.11.2 # DRF framework
django-dbbackup==3.3.0 # Database backup / restore functionality
django-cors-headers==3.2.0 # CORS headers extension for DRF
django_filter==2.2.0 # Extended filtering options
@ -10,7 +10,7 @@ django-sql-utils==0.5.0 # Advanced query annotation / aggregation
django-markdownx==3.0.1 # Markdown form fields
django-markdownify==0.8.0 # Markdown rendering
coreapi==2.3.0 # API documentation
pygments==2.2.0 # Syntax highlighting
pygments==2.7.4 # Syntax highlighting
tablib==0.13.0 # Import / export data files
django-crispy-forms==1.8.1 # Form helpers
django-import-export==2.0.0 # Data import / export for admin interface