mirror of
				https://github.com/inventree/InvenTree.git
				synced 2025-11-04 07:05:41 +00:00 
			
		
		
		
	Merge pull request #1545 from matmair/issue1425
adds sales order line item price
This commit is contained in:
		@@ -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;
 | 
			
		||||
 
 | 
			
		||||
@@ -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>`;
 | 
			
		||||
 
 | 
			
		||||
@@ -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 """
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -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,7 @@ 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.
 | 
			
		||||
 | 
			
		||||
        - 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
 | 
			
		||||
    get_price = common.models.get_price
 | 
			
		||||
 | 
			
		||||
    def open_orders(self):
 | 
			
		||||
        """ Return a database query for PO line items for this SupplierPart,
 | 
			
		||||
 
 | 
			
		||||
@@ -211,6 +211,7 @@ class EditSalesOrderLineItemForm(HelperForm):
 | 
			
		||||
            'part',
 | 
			
		||||
            'quantity',
 | 
			
		||||
            'reference',
 | 
			
		||||
            'sale_price',
 | 
			
		||||
            'notes'
 | 
			
		||||
        ]
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										24
									
								
								InvenTree/order/migrations/0045_auto_20210504_1946.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										24
									
								
								InvenTree/order/migrations/0045_auto_20210504_1946.py
									
									
									
									
									
										Normal 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),
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
@@ -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 = [
 | 
			
		||||
        ]
 | 
			
		||||
 
 | 
			
		||||
@@ -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',
 | 
			
		||||
        ]
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -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" %}');
 | 
			
		||||
@@ -288,7 +296,8 @@ $("#so-lines-table").inventreeTable({
 | 
			
		||||
                    if (part.assembly) {
 | 
			
		||||
                        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 %}
 | 
			
		||||
@@ -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)),
 | 
			
		||||
 
 | 
			
		||||
@@ -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 """
 | 
			
		||||
@@ -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)
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										24
									
								
								InvenTree/part/migrations/0065_auto_20210505_2144.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										24
									
								
								InvenTree/part/migrations/0065_auto_20210505_2144.py
									
									
									
									
									
										Normal 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'),
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
@@ -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):
 | 
			
		||||
        """
 | 
			
		||||
 
 | 
			
		||||
@@ -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 %}
 | 
			
		||||
@@ -30,11 +30,10 @@ 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'),
 | 
			
		||||
    
 | 
			
		||||
 | 
			
		||||
    url(r'^new/', views.PartParameterCreate.as_view(), name='part-param-create'),
 | 
			
		||||
    url(r'^(?P<pk>\d+)/edit/', views.PartParameterEdit.as_view(), name='part-param-edit'),
 | 
			
		||||
    url(r'^(?P<pk>\d+)/delete/', views.PartParameterDelete.as_view(), name='part-param-delete'),
 | 
			
		||||
@@ -49,10 +48,10 @@ part_detail_urls = [
 | 
			
		||||
    url(r'^duplicate/', views.PartDuplicate.as_view(), name='part-duplicate'),
 | 
			
		||||
    url(r'^make-variant/', views.MakePartVariant.as_view(), name='make-part-variant'),
 | 
			
		||||
    url(r'^pricing/', views.PartPricing.as_view(), name='part-pricing'),
 | 
			
		||||
    
 | 
			
		||||
 | 
			
		||||
    url(r'^bom-upload/?', views.BomUpload.as_view(), name='upload-bom'),
 | 
			
		||||
    url(r'^bom-duplicate/?', views.BomDuplicate.as_view(), name='duplicate-bom'),
 | 
			
		||||
    
 | 
			
		||||
 | 
			
		||||
    url(r'^params/', views.PartDetail.as_view(template_name='part/params.html'), name='part-params'),
 | 
			
		||||
    url(r'^variants/?', views.PartDetail.as_view(template_name='part/variants.html'), name='part-variants'),
 | 
			
		||||
    url(r'^stock/?', views.PartDetail.as_view(template_name='part/stock.html'), name='part-stock'),
 | 
			
		||||
@@ -70,7 +69,7 @@ part_detail_urls = [
 | 
			
		||||
    url(r'^related-parts/?', views.PartDetail.as_view(template_name='part/related.html'), name='part-related'),
 | 
			
		||||
    url(r'^attachments/?', views.PartDetail.as_view(template_name='part/attachments.html'), name='part-attachments'),
 | 
			
		||||
    url(r'^notes/?', views.PartNotes.as_view(), name='part-notes'),
 | 
			
		||||
    
 | 
			
		||||
 | 
			
		||||
    url(r'^qr_code/?', views.PartQRCode.as_view(), name='part-qr'),
 | 
			
		||||
 | 
			
		||||
    # Normal thumbnail with form
 | 
			
		||||
@@ -104,7 +103,7 @@ category_urls = [
 | 
			
		||||
 | 
			
		||||
        url(r'^subcategory/', views.CategoryDetail.as_view(template_name='part/subcategory.html'), name='category-subcategory'),
 | 
			
		||||
        url(r'^parametric/', views.CategoryParametric.as_view(), name='category-parametric'),
 | 
			
		||||
    
 | 
			
		||||
 | 
			
		||||
        # Anything else
 | 
			
		||||
        url(r'^.*$', views.CategoryDetail.as_view(), name='category-detail'),
 | 
			
		||||
    ]))
 | 
			
		||||
 
 | 
			
		||||
@@ -1956,10 +1956,9 @@ class PartPricing(AjaxView):
 | 
			
		||||
    form_class = part_forms.PartPriceForm
 | 
			
		||||
 | 
			
		||||
    role_required = ['sales_order.view', 'part.view']
 | 
			
		||||
    
 | 
			
		||||
 | 
			
		||||
    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))
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -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" %}');
 | 
			
		||||
 
 | 
			
		||||
@@ -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>
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user