Merge branch 'master' of github.com:inventree/InvenTree into manufacturer_part

This commit is contained in:
eeintech 2021-03-30 11:30:47 -04:00
commit e78455085f
12 changed files with 368 additions and 12 deletions

25
.github/workflows/style.yaml vendored Normal file
View File

@ -0,0 +1,25 @@
name: Style Checks
on: push
jobs:
pep:
runs-on: ubuntu-latest
strategy:
max-parallel: 4
matrix:
python-version: [3.7]
steps:
- name: Checkout code
uses: actions/checkout@v2
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v2
with:
python-version: ${{ matrix.python-version }}
- name: Install deps
run: |
pip install flake8==3.8.3
pip install pep8-naming==0.11.1
flake8 InvenTree

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

@ -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

@ -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

@ -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):
"""
@ -347,7 +352,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

@ -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