Merge branch 'master' into spaces!

This commit is contained in:
Matthias Mair 2021-05-08 12:27:19 +02:00 committed by GitHub
commit e59f467c79
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
28 changed files with 479 additions and 150 deletions

View File

@ -466,6 +466,24 @@
background: #eee;
}
/* pricing table widths */
.table-price-two tr td:first-child {
width: 40%;
}
.table-price-three tr td:first-child {
width: 40%;
}
.table-price-two tr td:last-child {
width: 60%;
}
.table-price-three tr td:last-child {
width: 30%;
}
/* !pricing table widths */
.btn-glyph {
padding-left: 6px;
padding-right: 6px;

View File

@ -100,7 +100,7 @@ function makeIconButton(icon, cls, pk, title, options={}) {
if (options.disabled) {
extraProps += "disabled='true' ";
}
html += `<button pk='${pk}' id='${id}' class='${classes}' title='${title}' ${extraProps}>`;
html += `<span class='fas ${icon}'></span>`;
html += `</button>`;

View File

@ -74,7 +74,7 @@ def validate_build_order_reference(value):
match = re.search(pattern, value)
if match is None:
raise ValidationError(_('Reference must match pattern') + f" '{pattern}'")
raise ValidationError(_('Reference must match pattern {pattern}').format(pattern=pattern))
def validate_purchase_order_reference(value):
@ -88,7 +88,7 @@ def validate_purchase_order_reference(value):
match = re.search(pattern, value)
if match is None:
raise ValidationError(_('Reference must match pattern') + f" '{pattern}'")
raise ValidationError(_('Reference must match pattern {pattern}').format(pattern=pattern))
def validate_sales_order_reference(value):
@ -102,7 +102,7 @@ def validate_sales_order_reference(value):
match = re.search(pattern, value)
if match is None:
raise ValidationError(_('Reference must match pattern') + f" '{pattern}'")
raise ValidationError(_('Reference must match pattern {pattern}').format(pattern=pattern))
def validate_tree_name(value):

View File

@ -158,6 +158,8 @@ $('#view-calendar').click(function() {
$("#build-order-calendar").show();
$("#view-list").show();
calendar.render();
});
$("#view-list").click(function() {

View File

@ -7,6 +7,8 @@ These models are 'generic' and do not fit a particular business logic object.
from __future__ import unicode_literals
import os
import decimal
import math
from django.db import models, transaction
from django.db.utils import IntegrityError, OperationalError
@ -730,6 +732,72 @@ class PriceBreak(models.Model):
return converted.amount
def get_price(instance, quantity, moq=True, multiples=True, currency=None):
""" Calculate the price based on quantity price breaks.
- Don't forget to add in flat-fee cost (base_cost field)
- If MOQ (minimum order quantity) is required, bump quantity
- If order multiples are to be observed, then we need to calculate based on that, too
"""
price_breaks = instance.price_breaks.all()
# No price break information available?
if len(price_breaks) == 0:
return None
# Check if quantity is fraction and disable multiples
multiples = (quantity % 1 == 0)
# Order multiples
if multiples:
quantity = int(math.ceil(quantity / instance.multiple) * instance.multiple)
pb_found = False
pb_quantity = -1
pb_cost = 0.0
if currency is None:
# Default currency selection
currency = InvenTreeSetting.get_setting('INVENTREE_DEFAULT_CURRENCY')
pb_min = None
for pb in instance.price_breaks.all():
# Store smallest price break
if not pb_min:
pb_min = pb
# Ignore this pricebreak (quantity is too high)
if pb.quantity > quantity:
continue
pb_found = True
# If this price-break quantity is the largest so far, use it!
if pb.quantity > pb_quantity:
pb_quantity = pb.quantity
# Convert everything to the selected currency
pb_cost = pb.convert_to(currency)
# Use smallest price break
if not pb_found and pb_min:
# Update price break information
pb_quantity = pb_min.quantity
pb_cost = pb_min.convert_to(currency)
# Trigger cost calculation using smallest price break
pb_found = True
# Convert quantity to decimal.Decimal format
quantity = decimal.Decimal(f'{quantity}')
if pb_found:
cost = pb_cost * quantity
return InvenTree.helpers.normalize(cost + instance.base_cost)
else:
return None
class ColorTheme(models.Model):
""" Color Theme Setting """

View File

@ -6,8 +6,6 @@ Company database model definitions
from __future__ import unicode_literals
import os
import decimal
import math
from django.utils.translation import ugettext_lazy as _
from django.core.validators import MinValueValidator
@ -26,7 +24,6 @@ from markdownx.models import MarkdownxField
from stdimage.models import StdImageField
from InvenTree.helpers import getMediaUrl, getBlankImage, getBlankThumbnail
from InvenTree.helpers import normalize
from InvenTree.fields import InvenTreeURLField
from InvenTree.status_codes import PurchaseOrderStatus
@ -558,70 +555,8 @@ class SupplierPart(models.Model):
price=price
)
def get_price(self, quantity, moq=True, multiples=True, currency=None):
""" Calculate the supplier price based on quantity price breaks.
get_price = common.models.get_price
- Don't forget to add in flat-fee cost (base_cost field)
- If MOQ (minimum order quantity) is required, bump quantity
- If order multiples are to be observed, then we need to calculate based on that, too
"""
price_breaks = self.price_breaks.all()
# No price break information available?
if len(price_breaks) == 0:
return None
# Check if quantity is fraction and disable multiples
multiples = (quantity % 1 == 0)
# Order multiples
if multiples:
quantity = int(math.ceil(quantity / self.multiple) * self.multiple)
pb_found = False
pb_quantity = -1
pb_cost = 0.0
if currency is None:
# Default currency selection
currency = common.models.InvenTreeSetting.get_setting('INVENTREE_DEFAULT_CURRENCY')
pb_min = None
for pb in self.price_breaks.all():
# Store smallest price break
if not pb_min:
pb_min = pb
# Ignore this pricebreak (quantity is too high)
if pb.quantity > quantity:
continue
pb_found = True
# If this price-break quantity is the largest so far, use it!
if pb.quantity > pb_quantity:
pb_quantity = pb.quantity
# Convert everything to the selected currency
pb_cost = pb.convert_to(currency)
# Use smallest price break
if not pb_found and pb_min:
# Update price break information
pb_quantity = pb_min.quantity
pb_cost = pb_min.convert_to(currency)
# Trigger cost calculation using smallest price break
pb_found = True
# Convert quantity to decimal.Decimal format
quantity = decimal.Decimal(f'{quantity}')
if pb_found:
cost = pb_cost * quantity
return normalize(cost + self.base_cost)
else:
return None
def open_orders(self):
""" Return a database query for PO line items for this SupplierPart,

View File

@ -202,7 +202,7 @@ class CompanyImageDownloadFromURL(AjaxUpdateView):
# Check for valid response code
if not response.status_code == 200:
form.add_error('url', f"{_('Invalid response')}: {response.status_code}")
form.add_error('url', _('Invalid response: {code}').format(code=response.status_code))
return
response.raw.decode_content = True

View File

@ -211,6 +211,7 @@ class EditSalesOrderLineItemForm(HelperForm):
'part',
'quantity',
'reference',
'sale_price',
'notes'
]

View File

@ -0,0 +1,24 @@
# Generated by Django 3.2 on 2021-05-04 19:46
from django.db import migrations
import djmoney.models.fields
class Migration(migrations.Migration):
dependencies = [
('order', '0044_auto_20210404_2016'),
]
operations = [
migrations.AddField(
model_name='salesorderlineitem',
name='sale_price',
field=djmoney.models.fields.MoneyField(blank=True, decimal_places=4, default_currency='USD', help_text='Unit sale price', max_digits=19, null=True, verbose_name='Sale Price'),
),
migrations.AddField(
model_name='salesorderlineitem',
name='sale_price_currency',
field=djmoney.models.fields.CurrencyField(choices=[('AUD', 'Australian Dollar'), ('GBP', 'British Pound'), ('CAD', 'Canadian Dollar'), ('EUR', 'Euro'), ('JPY', 'Japanese Yen'), ('NZD', 'New Zealand Dollar'), ('USD', 'US Dollar')], default='USD', editable=False, max_length=3),
),
]

View File

@ -367,7 +367,7 @@ class PurchaseOrder(Order):
stock.save()
text = _("Received items")
note = f"{_('Received')} {quantity} {_('items against order')} {str(self)}"
note = _('Received {n} items against order {name}').format(n=quantity, name=str(self))
# Add a new transaction note to the newly created stock item
stock.addTransactionNote(text, user, note)
@ -672,12 +672,22 @@ class SalesOrderLineItem(OrderLineItem):
Attributes:
order: Link to the SalesOrder that this line item belongs to
part: Link to a Part object (may be null)
sale_price: The unit sale price for this OrderLineItem
"""
order = models.ForeignKey(SalesOrder, on_delete=models.CASCADE, related_name='lines', verbose_name=_('Order'), help_text=_('Sales Order'))
part = models.ForeignKey('part.Part', on_delete=models.SET_NULL, related_name='sales_order_line_items', null=True, verbose_name=_('Part'), help_text=_('Part'), limit_choices_to={'salable': True})
sale_price = MoneyField(
max_digits=19,
decimal_places=4,
default_currency='USD',
null=True, blank=True,
verbose_name=_('Sale Price'),
help_text=_('Unit sale price'),
)
class Meta:
unique_together = [
]

View File

@ -278,6 +278,7 @@ class SOLineItemSerializer(InvenTreeModelSerializer):
quantity = serializers.FloatField()
allocated = serializers.FloatField(source='allocated_quantity', read_only=True)
fulfilled = serializers.FloatField(source='fulfilled_quantity', read_only=True)
sale_price_string = serializers.CharField(source='sale_price', read_only=True)
class Meta:
model = SalesOrderLineItem
@ -294,6 +295,9 @@ class SOLineItemSerializer(InvenTreeModelSerializer):
'order_detail',
'part',
'part_detail',
'sale_price',
'sale_price_currency',
'sale_price_string',
]

View File

@ -146,6 +146,8 @@ $('#view-calendar').click(function() {
$("#purchase-order-calendar").show();
$("#view-list").show();
calendar.render();
});
$("#view-list").click(function() {

View File

@ -223,6 +223,14 @@ $("#so-lines-table").inventreeTable({
field: 'quantity',
title: '{% trans "Quantity" %}',
},
{
sortable: true,
field: 'sale_price',
title: '{% trans "Unit Price" %}',
formatter: function(value, row) {
return row.sale_price_string || row.sale_price;
}
},
{
field: 'allocated',
{% if order.status == SalesOrderStatus.PENDING %}
@ -279,7 +287,7 @@ $("#so-lines-table").inventreeTable({
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" %}');
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 "Purchase stock" %}');
@ -289,6 +297,7 @@ $("#so-lines-table").inventreeTable({
html += makeIconButton('fa-tools', 'button-build', row.part, '{% trans "Build stock" %}');
}
html += makeIconButton('fa-dollar-sign icon-green', 'button-price', pk, '{% trans "Calculate price" %}');
}
html += makeIconButton('fa-edit icon-blue', 'button-edit', pk, '{% trans "Edit line item" %}');
@ -388,6 +397,26 @@ function setupCallbacks() {
},
});
});
$(".button-price").click(function() {
var pk = $(this).attr('pk');
var idx = $(this).closest('tr').attr('data-index');
var row = table.bootstrapTable('getData')[idx];
launchModalForm(
"{% url 'line-pricing' %}",
{
submit_text: '{% trans "Calculate price" %}',
data: {
line_item: pk,
quantity: row.quantity,
},
buttons: [{name: 'update_price',
title: '{% trans "Update Unit Price" %}'},],
success: reloadTable,
}
);
});
}
{% endblock %}

View File

@ -144,6 +144,8 @@ $('#view-calendar').click(function() {
$("#sales-order-calendar").show();
$("#view-list").show();
calendar.render();
});
$("#view-list").click(function() {

View File

@ -31,6 +31,7 @@ purchase_order_urls = [
url(r'^new/', views.PurchaseOrderCreate.as_view(), name='po-create'),
url(r'^order-parts/', views.OrderParts.as_view(), name='order-parts'),
url(r'^pricing/', views.LineItemPricing.as_view(), name='line-pricing'),
# Display detail view for a single purchase order
url(r'^(?P<pk>\d+)/', include(purchase_order_detail_urls)),

View File

@ -6,13 +6,14 @@ Django views for interacting with Order app
from __future__ import unicode_literals
from django.db import transaction
from django.http.response import JsonResponse
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_lazy as _
from django.views.generic import DetailView, ListView, UpdateView
from django.views.generic.edit import FormMixin
from django.forms import HiddenInput
from django.forms import HiddenInput, IntegerField
import logging
from decimal import Decimal, InvalidOperation
@ -29,6 +30,7 @@ from part.models import Part
from common.models import InvenTreeSetting
from . import forms as order_forms
from part.views import PartPricing
from InvenTree.views import AjaxView, AjaxCreateView, AjaxUpdateView, AjaxDeleteView
from InvenTree.helpers import DownloadFile, str2bool
@ -1245,6 +1247,18 @@ class SOLineItemCreate(AjaxCreateView):
return initials
def save(self, form):
ret = form.save()
# check if price s set in form - else autoset
if not ret.sale_price:
price = ret.part.get_price(ret.quantity)
# only if price is avail
if price:
ret.sale_price = price / ret.quantity
ret.save()
self.object = ret
return ret
class SOLineItemEdit(AjaxUpdateView):
""" View for editing a SalesOrderLineItem """
@ -1407,7 +1421,7 @@ class SalesOrderAssignSerials(AjaxView, FormMixin):
except StockItem.DoesNotExist:
self.form.add_error(
'serials',
_('No matching item for serial') + f" '{serial}'"
_('No matching item for serial {serial}').format(serial=serial)
)
continue
@ -1417,7 +1431,7 @@ class SalesOrderAssignSerials(AjaxView, FormMixin):
if not stock_item.in_stock:
self.form.add_error(
'serials',
f"'{serial}' " + _("is not in stock")
_('{serial} is not in stock').format(serial=serial)
)
continue
@ -1425,7 +1439,7 @@ class SalesOrderAssignSerials(AjaxView, FormMixin):
if stock_item.is_allocated():
self.form.add_error(
'serials',
f"'{serial}' " + _("already allocated to an order")
_('{serial} already allocated to an order').format(serial=serial)
)
continue
@ -1571,3 +1585,101 @@ class SalesOrderAllocationDelete(AjaxDeleteView):
ajax_form_title = _("Remove allocation")
context_object_name = 'allocation'
ajax_template_name = "order/so_allocation_delete.html"
class LineItemPricing(PartPricing):
""" View for inspecting part pricing information """
class EnhancedForm(PartPricing.form_class):
pk = IntegerField(widget=HiddenInput())
so_line = IntegerField(widget=HiddenInput())
form_class = EnhancedForm
def get_part(self, id=False):
if 'line_item' in self.request.GET:
try:
part_id = self.request.GET.get('line_item')
part = SalesOrderLineItem.objects.get(id=part_id).part
except Part.DoesNotExist:
return None
elif 'pk' in self.request.POST:
try:
part_id = self.request.POST.get('pk')
part = Part.objects.get(id=part_id)
except Part.DoesNotExist:
return None
else:
return None
if id:
return part.id
return part
def get_so(self, pk=False):
so_line = self.request.GET.get('line_item', None)
if not so_line:
so_line = self.request.POST.get('so_line', None)
if so_line:
try:
sales_order = SalesOrderLineItem.objects.get(pk=so_line)
if pk:
return sales_order.pk
return sales_order
except Part.DoesNotExist:
return None
return None
def get_quantity(self):
""" Return set quantity in decimal format """
qty = Decimal(self.request.GET.get('quantity', 1))
if qty == 1:
return Decimal(self.request.POST.get('quantity', 1))
return qty
def get_initials(self):
initials = super().get_initials()
initials['pk'] = self.get_part(id=True)
initials['so_line'] = self.get_so(pk=True)
return initials
def post(self, request, *args, **kwargs):
# parse extra actions
REF = 'act-btn_'
act_btn = [a.replace(REF, '') for a in self.request.POST if REF in a]
# check if extra action was passed
if act_btn and act_btn[0] == 'update_price':
# get sales order
so_line = self.get_so()
if not so_line:
self.data = {'non_field_errors': [_('Sales order not found')]}
else:
quantity = self.get_quantity()
price = self.get_pricing(quantity).get('unit_part_price', None)
if not price:
self.data = {'non_field_errors': [_('Price not found')]}
else:
# set normal update note
note = _('Updated {part} unit-price to {price}')
# check qunatity and update if different
if so_line.quantity != quantity:
so_line.quantity = quantity
note = _('Updated {part} unit-price to {price} and quantity to {qty}')
# update sale_price
so_line.sale_price = price
so_line.save()
# parse response
data = {
'form_valid': True,
'success': note.format(part=str(so_line.part), price=str(so_line.sale_price), qty=quantity)
}
return JsonResponse(data=data)
# let the normal pricing view run
return super().post(request, *args, **kwargs)

View File

@ -0,0 +1,24 @@
# Generated by Django 3.2 on 2021-05-05 21:44
import django.core.validators
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('part', '0064_auto_20210404_2016'),
]
operations = [
migrations.AddField(
model_name='part',
name='base_cost',
field=models.DecimalField(decimal_places=3, default=0, help_text='Minimum charge (e.g. stocking fee)', max_digits=10, validators=[django.core.validators.MinValueValidator(0)], verbose_name='base cost'),
),
migrations.AddField(
model_name='part',
name='multiple',
field=models.PositiveIntegerField(default=1, help_text='Sell multiple', validators=[django.core.validators.MinValueValidator(1)], verbose_name='multiple'),
),
]

View File

@ -1611,6 +1611,44 @@ class Part(MPTTModel):
max(buy_price_range[1], bom_price_range[1])
)
base_cost = models.DecimalField(max_digits=10, decimal_places=3, default=0, validators=[MinValueValidator(0)], verbose_name=_('base cost'), help_text=_('Minimum charge (e.g. stocking fee)'))
multiple = models.PositiveIntegerField(default=1, validators=[MinValueValidator(1)], verbose_name=_('multiple'), help_text=_('Sell multiple'))
get_price = common.models.get_price
@property
def has_price_breaks(self):
return self.price_breaks.count() > 0
@property
def price_breaks(self):
""" Return the associated price breaks in the correct order """
return self.salepricebreaks.order_by('quantity').all()
@property
def unit_pricing(self):
return self.get_price(1)
def add_price_break(self, quantity, price):
"""
Create a new price break for this part
args:
quantity - Numerical quantity
price - Must be a Money object
"""
# Check if a price break at that quantity already exists...
if self.price_breaks.filter(quantity=quantity, part=self.pk).exists():
return
PartSellPriceBreak.objects.create(
part=self,
quantity=quantity,
price=price
)
@transaction.atomic
def copy_bom_from(self, other, clear=True, **kwargs):
"""

View File

@ -91,7 +91,7 @@
{% if part.salable and roles.sales_order.view %}
<li class='list-group-item {% if tab == "sales-prices" %}active{% endif %}' title='{% trans "Sales Price Information" %}'>
<a href='{% url "part-sale-prices" part.id %}'>
<span class='menu-tab-icon fas fa-dollar-sign'></span>
<span class='menu-tab-icon fas fa-dollar-sign' style='width: 20px;'></span>
{% trans "Sale Price" %}
</a>
</li>

View File

@ -4,24 +4,20 @@
{% block pre_form_content %}
<div class='alert alert-info alert-block'>
{% blocktrans %}Pricing information for:<br>{{part}}.{% endblocktrans %}
</div>
<h4>{% trans 'Quantity' %}</h4>
<table class='table table-striped table-condensed'>
<table class='table table-striped table-condensed table-price-two'>
<tr>
<td><b>{% trans 'Part' %}</b></td>
<td colspan='2'>{{ part }}</td>
<td>{{ part }}</td>
</tr>
<tr>
<td><b>{% trans 'Quantity' %}</b></td>
<td colspan='2'>{{ quantity }}</td>
<td>{{ quantity }}</td>
</tr>
</table>
{% if part.supplier_count > 0 %}
{% if part.supplier_count > 0 %}
<h4>{% trans 'Supplier Pricing' %}</h4>
<table class='table table-striped table-condensed'>
<table class='table table-striped table-condensed table-price-three'>
{% if min_total_buy_price %}
<tr>
<td><b>{% trans 'Unit Cost' %}</b></td>
@ -42,12 +38,12 @@
</td>
</tr>
{% endif %}
</table>
{% endif %}
</table>
{% endif %}
{% if part.bom_count > 0 %}
{% if part.bom_count > 0 %}
<h4>{% trans 'BOM Pricing' %}</h4>
<table class='table table-striped table-condensed'>
<table class='table table-striped table-condensed table-price-three'>
{% if min_total_bom_price %}
<tr>
<td><b>{% trans 'Unit Cost' %}</b></td>
@ -75,8 +71,22 @@
</td>
</tr>
{% endif %}
</table>
{% endif %}
</table>
{% endif %}
{% if total_part_price %}
<h4>{% trans 'Sale Price' %}</h4>
<table class='table table-striped table-condensed table-price-two'>
<tr>
<td><b>{% trans 'Unit Cost' %}</b></td>
<td>{% include "price.html" with price=unit_part_price %}</td>
</tr>
<tr>
<td><b>{% trans 'Total Cost' %}</b></td>
<td>{% include "price.html" with price=total_part_price %}</td>
</tr>
</table>
{% endif %}
{% if min_unit_buy_price or min_unit_bom_price %}
{% else %}
@ -84,7 +94,5 @@
{% trans 'No pricing information is available for this part.' %}
</div>
{% endif %}
<hr>
{% endblock %}

View File

@ -2,7 +2,7 @@
{% load static %}
{% load i18n %}
{% block menubar %}}
{% block menubar %}
{% include 'part/navbar.html' with tab='sales-prices' %}
{% endblock %}

View File

@ -200,18 +200,28 @@ class I18nStaticNode(StaticNode):
return ret
@register.tag('i18n_static')
def do_i18n_static(parser, token):
"""
Overrides normal static, adds language - lookup for prerenderd files #1485
# use the dynamic url - tag if in Debugging-Mode
if settings.DEBUG:
usage (like static):
{% i18n_static path [as varname] %}
"""
bits = token.split_contents()
loc_name = settings.STATICFILES_I18_PREFIX
@register.simple_tag()
def i18n_static(url_name):
""" simple tag to enable {% url %} functionality instead of {% static %} """
return reverse(url_name)
# change path to called ressource
bits[1] = f"'{loc_name}/{{lng}}.{bits[1][1:-1]}'"
token.contents = ' '.join(bits)
return I18nStaticNode.handle_token(parser, token)
else:
@register.tag('i18n_static')
def do_i18n_static(parser, token):
"""
Overrides normal static, adds language - lookup for prerenderd files #1485
usage (like static):
{% i18n_static path [as varname] %}
"""
bits = token.split_contents()
loc_name = settings.STATICFILES_I18_PREFIX
# change path to called ressource
bits[1] = f"'{loc_name}/{{lng}}.{bits[1][1:-1]}'"
token.contents = ' '.join(bits)
return I18nStaticNode.handle_token(parser, token)

View File

@ -30,7 +30,6 @@ sale_price_break_urls = [
]
part_parameter_urls = [
url(r'^template/new/', views.PartParameterTemplateCreate.as_view(), name='part-param-template-create'),
url(r'^template/(?P<pk>\d+)/edit/', views.PartParameterTemplateEdit.as_view(), name='part-param-template-edit'),
url(r'^template/(?P<pk>\d+)/delete/', views.PartParameterTemplateDelete.as_view(), name='part-param-template-edit'),

View File

@ -884,7 +884,7 @@ class PartImageDownloadFromURL(AjaxUpdateView):
# Check for valid response code
if not response.status_code == 200:
form.add_error('url', f"{_('Invalid response')}: {response.status_code}")
form.add_error('url', _('Invalid response: {code}').format(code=response.status_code))
return
response.raw.decode_content = True
@ -1959,7 +1959,6 @@ class PartPricing(AjaxView):
def get_quantity(self):
""" Return set quantity in decimal format """
return Decimal(self.request.POST.get('quantity', 1))
def get_part(self):
@ -1969,12 +1968,7 @@ class PartPricing(AjaxView):
return None
def get_pricing(self, quantity=1, currency=None):
# try:
# quantity = int(quantity)
# except ValueError:
# quantity = 1
""" returns context with pricing information """
if quantity <= 0:
quantity = 1
@ -2044,11 +2038,22 @@ class PartPricing(AjaxView):
ctx['max_total_bom_price'] = max_bom_price
ctx['max_unit_bom_price'] = max_unit_bom_price
# part pricing information
part_price = part.get_price(quantity)
if part_price is not None:
ctx['total_part_price'] = round(part_price, 3)
ctx['unit_part_price'] = round(part_price / quantity, 3)
return ctx
def get(self, request, *args, **kwargs):
def get_initials(self):
""" returns initials for form """
return {'quantity': self.get_quantity()}
return self.renderJsonResponse(request, self.form_class(), context=self.get_pricing())
def get(self, request, *args, **kwargs):
init = self.get_initials()
qty = self.get_quantity()
return self.renderJsonResponse(request, self.form_class(initial=init), context=self.get_pricing(qty))
def post(self, request, *args, **kwargs):
@ -2057,16 +2062,19 @@ class PartPricing(AjaxView):
quantity = self.get_quantity()
# Retain quantity value set by user
form = self.form_class()
form.fields['quantity'].initial = quantity
form = self.form_class(initial=self.get_initials())
# TODO - How to handle pricing in different currencies?
currency = None
# check if data is set
try:
data = self.data
except AttributeError:
data = {}
# Always mark the form as 'invalid' (the user may wish to keep getting pricing data)
data = {
'form_valid': False,
}
data['form_valid'] = False
return self.renderJsonResponse(request, form, data=data, context=self.get_pricing(quantity, currency))

View File

@ -56,7 +56,7 @@ class LocationAdmin(ImportExportModelAdmin):
class StockItemResource(ModelResource):
""" Class for managing StockItem data import/export """
# Custom manaegrs for ForeignKey fields
# Custom managers for ForeignKey fields
part = Field(attribute='part', widget=widgets.ForeignKeyWidget(Part))
part_name = Field(attribute='part__full_name', readonly=True)

View File

@ -198,7 +198,7 @@ class StockItem(MPTTModel):
if add_note:
note = f"{_('Created new stock item for')} {str(self.part)}"
note = _('Created new stock item for {part}').format(part=str(self.part))
# This StockItem is being saved for the first time
self.addTransactionNote(
@ -228,7 +228,7 @@ class StockItem(MPTTModel):
super(StockItem, self).validate_unique(exclude)
# If the serial number is set, make sure it is not a duplicate
if self.serial is not None:
if self.serial:
# Query to look for duplicate serial numbers
parts = PartModels.Part.objects.filter(tree_id=self.part.tree_id)
stock = StockItem.objects.filter(part__in=parts, serial=self.serial)
@ -281,7 +281,7 @@ class StockItem(MPTTModel):
if self.part is not None:
# A part with a serial number MUST have the quantity set to 1
if self.serial is not None:
if self.serial:
if self.quantity > 1:
raise ValidationError({
'quantity': _('Quantity must be 1 for item with a serial number'),
@ -613,7 +613,7 @@ class StockItem(MPTTModel):
item.addTransactionNote(
_("Assigned to Customer"),
user,
notes=_("Manually assigned to customer") + " " + customer.name,
notes=_("Manually assigned to customer {name}").format(name=customer.name),
system=True
)
@ -626,9 +626,9 @@ class StockItem(MPTTModel):
"""
self.addTransactionNote(
_("Returned from customer") + f" {self.customer.name}",
_("Returned from customer {name}").format(name=self.customer.name),
user,
notes=_("Returned to location") + f" {location.name}",
notes=_("Returned to location {loc}").format(loc=location.name),
system=True
)
@ -789,7 +789,7 @@ class StockItem(MPTTModel):
# Add a transaction note to the other item
stock_item.addTransactionNote(
_('Installed into stock item') + ' ' + str(self.pk),
_('Installed into stock item {pk}').format(str(self.pk)),
user,
notes=notes,
url=self.get_absolute_url()
@ -797,7 +797,7 @@ class StockItem(MPTTModel):
# Add a transaction note to this item
self.addTransactionNote(
_('Installed stock item') + ' ' + str(stock_item.pk),
_('Installed stock item {pk}').format(str(stock_item.pk)),
user, notes=notes,
url=stock_item.get_absolute_url()
)
@ -821,7 +821,7 @@ class StockItem(MPTTModel):
# Add a transaction note to the parent item
self.belongs_to.addTransactionNote(
_("Uninstalled stock item") + ' ' + str(self.pk),
_("Uninstalled stock item {pk}").format(pk=str(self.pk)),
user,
notes=notes,
url=self.get_absolute_url(),
@ -840,7 +840,7 @@ class StockItem(MPTTModel):
# Add a transaction note!
self.addTransactionNote(
_('Uninstalled into location') + ' ' + str(location),
_('Uninstalled into location {loc}').formaT(loc=str(location)),
user,
notes=notes,
url=url
@ -966,7 +966,7 @@ class StockItem(MPTTModel):
if len(existing) > 0:
exists = ','.join([str(x) for x in existing])
raise ValidationError({"serial_numbers": _("Serial numbers already exist") + ': ' + exists})
raise ValidationError({"serial_numbers": _("Serial numbers already exist: {exists}").format(exists=exists)})
# Create a new stock item for each unique serial number
for serial in serials:
@ -1074,7 +1074,7 @@ class StockItem(MPTTModel):
new_stock.addTransactionNote(
_("Split from existing stock"),
user,
f"{_('Split')} {helpers.normalize(quantity)} {_('items')}"
_('Split {n} items').format(n=helpers.normalize(quantity))
)
# Remove the specified quantity from THIS stock item
@ -1131,10 +1131,10 @@ class StockItem(MPTTModel):
return True
msg = f"{_('Moved to')} {str(location)}"
if self.location:
msg += f" ({_('from')} {str(self.location)})"
msg = _("Moved to {loc_new} (from {loc_old})").format(loc_new=str(location), loc_old=str(self.location))
else:
msg = _('Moved to {loc_new}').format(loc_new=str(location))
self.location = location
@ -1202,9 +1202,7 @@ class StockItem(MPTTModel):
if self.updateQuantity(count):
n = helpers.normalize(count)
text = f"{_('Counted')} {n} {_('items')}"
text = _('Counted {n} items').format(n=helpers.normalize(count))
self.addTransactionNote(
text,
@ -1236,9 +1234,7 @@ class StockItem(MPTTModel):
return False
if self.updateQuantity(self.quantity + quantity):
n = helpers.normalize(quantity)
text = f"{_('Added')} {n} {_('items')}"
text = _('Added {n} items').format(n=helpers.normalize(quantity))
self.addTransactionNote(
text,
@ -1268,8 +1264,7 @@ class StockItem(MPTTModel):
if self.updateQuantity(self.quantity - quantity):
q = helpers.normalize(quantity)
text = f"{_('Removed')} {q} {_('items')}"
text = _('Removed {n1} items').format(n1=helpers.normalize(quantity))
self.addTransactionNote(text,
user,

View File

@ -377,6 +377,15 @@ function modalSubmit(modal, callback) {
$(modal).on('click', '#modal-form-submit', function() {
callback();
});
$(modal).on('click', '.modal-form-button', function() {
// Append data to form
var name = $(this).attr('name');
var value = $(this).attr('value');
var input = '<input id="id_act-btn_' + name + '" type="hidden" name="act-btn_' + name + '" value="' + value + '">';
$('.js-modal-form').append(input);
callback();
});
}
@ -659,6 +668,25 @@ function attachSecondaries(modal, secondaries) {
}
}
function insertActionButton(modal, options) {
/* Insert a custom submition button */
var html = "<span style='float: right;'>";
html += "<button name='" + options.name + "' type='submit' class='btn btn-default modal-form-button'";
html += " value='" + options.name + "'>" + options.title + "</button>";
html += "</span>";
$(modal).find('#modal-footer-buttons').append(html);
}
function attachButtons(modal, buttons) {
/* Attach a provided list of buttons */
for (var i = 0; i < buttons.length; i++) {
insertActionButton(modal, buttons[i]);
}
}
function attachFieldCallback(modal, callback) {
/* Attach a 'callback' function to a given field in the modal form.
@ -808,6 +836,9 @@ function launchModalForm(url, options = {}) {
var submit_text = options.submit_text || '{% trans "Submit" %}';
var close_text = options.close_text || '{% trans "Close" %}';
// Clean custom action buttons
$(modal).find('#modal-footer-buttons').html('');
// Form the ajax request to retrieve the django form data
ajax_data = {
url: url,
@ -852,6 +883,10 @@ function launchModalForm(url, options = {}) {
handleModalForm(url, options);
}
if (options.buttons) {
attachButtons(modal, options.buttons);
}
} else {
$(modal).modal('hide');
showAlertDialog('{% trans "Invalid server response" %}', '{% trans "JSON response missing form data" %}');

View File

@ -25,6 +25,7 @@
</div>
</div>
<div class='modal-footer'>
<div id='modal-footer-buttons'></div>
<button type='button' class='btn btn-default' id='modal-form-close' data-dismiss='modal'>{% trans "Close" %}</button>
<button type='button' class='btn btn-primary' id='modal-form-submit'>{% trans "Submit" %}</button>
</div>
@ -49,6 +50,7 @@
</div>
</div>
<div class='modal-footer'>
<div id='modal-footer-buttons'></div>
<button type='button' class='btn btn-default' id='modal-form-close' data-dismiss='modal'>{% trans "Close" %}</button>
<button type='button' class='btn btn-primary' id='modal-form-submit'>{% trans "Submit" %}</button>
</div>
@ -69,6 +71,7 @@
<div class='modal-form-content'>
</div>
<div class='modal-footer'>
<div id='modal-footer-buttons'></div>
<button type='button' class='btn btn-default' id='modal-form-cancel' data-dismiss='modal'>{% trans "Cancel" %}</button>
<button type='button' class='btn btn-primary' id='modal-form-accept'>{% trans "Accept" %}</button>
</div>
@ -90,6 +93,7 @@
</div>
</div>
<div class='modal-footer'>
<div id='modal-footer-buttons'></div>
<button type='button' class='btn btn-default' data-dismiss='modal'>{% trans "Close" %}</button>
</div>
</div>