Merge remote-tracking branch 'inventree/master'

This commit is contained in:
Oliver Walters 2020-09-20 08:02:13 +10:00
commit c2eea91f76
44 changed files with 968 additions and 136 deletions

View File

@ -11,7 +11,7 @@ from django.core import validators
from django import forms
from decimal import Decimal
from InvenTree.helpers import normalize
import InvenTree.helpers
class InvenTreeURLFormField(FormURLField):
@ -55,7 +55,7 @@ class RoundingDecimalFormField(forms.DecimalField):
"""
if type(value) == Decimal:
return normalize(value)
return InvenTree.helpers.normalize(value)
else:
return value

View File

@ -15,7 +15,8 @@ from django.http import StreamingHttpResponse
from django.core.exceptions import ValidationError
from django.utils.translation import ugettext as _
from .version import inventreeVersion, inventreeInstanceName
import InvenTree.version
from .settings import MEDIA_URL, STATIC_URL
@ -263,8 +264,8 @@ def MakeBarcode(object_name, object_pk, object_data, **kwargs):
data[object_name] = object_pk
else:
data['tool'] = 'InvenTree'
data['version'] = inventreeVersion()
data['instance'] = inventreeInstanceName()
data['version'] = InvenTree.version.inventreeVersion()
data['instance'] = InvenTree.version.inventreeInstanceName()
# Ensure PK is included
object_data['id'] = object_pk

View File

@ -82,9 +82,10 @@
float: left;
}
.navbar-barcode-li {
#navbar-barcode-li {
border-left: none;
border-right: none;
padding-right: 5px;
}
.navbar-nav > li {

View File

@ -6,7 +6,7 @@ from django.conf import settings
from django.core.exceptions import ValidationError
from django.utils.translation import gettext_lazy as _
from common.models import InvenTreeSetting
import common.models
import re
@ -43,7 +43,7 @@ def validate_part_name(value):
def validate_part_ipn(value):
""" Validate the Part IPN against regex rule """
pattern = InvenTreeSetting.get_setting('part_ipn_regex')
pattern = common.models.InvenTreeSetting.get_setting('part_ipn_regex')
if pattern:
match = re.search(pattern, value)

View File

@ -3,15 +3,16 @@ Provides information on the current InvenTree version
"""
import subprocess
from common.models import InvenTreeSetting
import django
import common.models
INVENTREE_SW_VERSION = "0.1.3 pre"
def inventreeInstanceName():
""" Returns the InstanceName settings for the current database """
return InvenTreeSetting.get_setting("InstanceName", "")
return common.models.InvenTreeSetting.get_setting("InstanceName", "")
def inventreeVersion():

View File

@ -12,6 +12,7 @@ from rest_framework import generics, permissions
from django.conf.urls import url, include
from InvenTree.helpers import str2bool
from InvenTree.status_codes import BuildStatus
from .models import Build, BuildItem
from .serializers import BuildSerializer, BuildItemSerializer
@ -61,6 +62,17 @@ class BuildList(generics.ListCreateAPIView):
if status is not None:
queryset = queryset.filter(status=status)
# Filter by "active" status
active = self.request.query_params.get('active', None)
if active is not None:
active = str2bool(active)
if active:
queryset = queryset.filter(status__in=BuildStatus.ACTIVE_CODES)
else:
queryset = queryset.exclude(status__in=BuildStatus.ACTIVE_CODES)
# Filter by associated part?
part = self.request.query_params.get('part', None)

View File

@ -22,8 +22,8 @@ from markdownx.models import MarkdownxField
from mptt.models import MPTTModel, TreeForeignKey
from InvenTree.status_codes import BuildStatus
from InvenTree.fields import InvenTreeURLField
from InvenTree.helpers import decimal2string
import InvenTree.fields
from stock import models as StockModels
from part import models as PartModels
@ -151,7 +151,7 @@ class Build(MPTTModel):
related_name='builds_completed'
)
link = InvenTreeURLField(
link = InvenTree.fields.InvenTreeURLField(
verbose_name=_('External Link'),
blank=True, help_text=_('Link to external URL')
)

View File

@ -50,6 +50,15 @@ InvenTree | Allocate Parts
return {{ build.quantity }} * row.quantity - sumAllocations(row);
}
function setExpandedAllocatedLocation(row) {
// Handle case when stock item does not have a location set
if (row.location_detail == null) {
return 'No stock location set';
} else {
return row.location_detail.pathstring;
}
}
function reloadTable() {
// Reload the build allocation table
buildTable.bootstrapTable('refresh');
@ -76,7 +85,7 @@ InvenTree | Allocate Parts
{
field: 'stock_item',
label: '{% trans "New Stock Item" %}',
title: '{% trans "Create new Stock Item"',
title: '{% trans "Create new Stock Item" %}',
url: '{% url "stock-item-create" %}',
data: {
part: row.sub_part,
@ -146,7 +155,7 @@ InvenTree | Allocate Parts
subTable.bootstrapTable({
data: row.allocations,
showHeader: false,
showHeader: true,
columns: [
{
width: '50%',
@ -177,7 +186,7 @@ InvenTree | Allocate Parts
title: '{% trans "Location" %}',
formatter: function(value, row, index, field) {
{% if build.status == BuildStatus.COMPLETE %}
var text = row.location_detail.pathstring;
var text = setExpandedAllocatedLocation(row);
var url = `/stock/location/${row.location}/`;
{% else %}
var text = row.stock_item_detail.location_name;

View File

@ -7,6 +7,7 @@ These models are 'generic' and do not fit a particular business logic object.
from __future__ import unicode_literals
import os
import decimal
from django.db import models
from django.conf import settings
@ -14,6 +15,8 @@ from django.utils.translation import ugettext as _
from django.core.validators import MinValueValidator, MaxValueValidator
from django.core.exceptions import ValidationError
import InvenTree.fields
class InvenTreeSetting(models.Model):
"""
@ -159,6 +162,42 @@ class Currency(models.Model):
super().save(*args, **kwargs)
class PriceBreak(models.Model):
"""
Represents a PriceBreak model
"""
class Meta:
abstract = True
quantity = InvenTree.fields.RoundingDecimalField(max_digits=15, decimal_places=5, default=1, validators=[MinValueValidator(1)])
cost = InvenTree.fields.RoundingDecimalField(max_digits=10, decimal_places=5, validators=[MinValueValidator(0)])
currency = models.ForeignKey(Currency, blank=True, null=True, on_delete=models.SET_NULL)
@property
def symbol(self):
return self.currency.symbol if self.currency else ''
@property
def suffix(self):
return self.currency.suffix if self.currency else ''
@property
def converted_cost(self):
"""
Return the cost of this price break, converted to the base currency
"""
scaler = decimal.Decimal(1.0)
if self.currency:
scaler = self.currency.value
return self.cost * scaler
class ColorTheme(models.Model):
""" Color Theme Setting """

View File

@ -8,7 +8,6 @@ from __future__ import unicode_literals
import os
import math
from decimal import Decimal
from django.utils.translation import gettext_lazy as _
from django.core.validators import MinValueValidator
@ -24,9 +23,10 @@ from stdimage.models import StdImageField
from InvenTree.helpers import getMediaUrl, getBlankImage, getBlankThumbnail
from InvenTree.helpers import normalize
from InvenTree.fields import InvenTreeURLField, RoundingDecimalField
from InvenTree.fields import InvenTreeURLField
from InvenTree.status_codes import PurchaseOrderStatus
from common.models import Currency
import common.models
def rename_company_image(instance, filename):
@ -433,7 +433,7 @@ class SupplierPart(models.Model):
return s
class SupplierPriceBreak(models.Model):
class SupplierPriceBreak(common.models.PriceBreak):
""" Represents a quantity price break for a SupplierPart.
- Suppliers can offer discounts at larger quantities
- SupplierPart(s) may have zero-or-more associated SupplierPriceBreak(s)
@ -447,23 +447,6 @@ class SupplierPriceBreak(models.Model):
part = models.ForeignKey(SupplierPart, on_delete=models.CASCADE, related_name='pricebreaks')
quantity = RoundingDecimalField(max_digits=15, decimal_places=5, default=1, validators=[MinValueValidator(1)])
cost = RoundingDecimalField(max_digits=10, decimal_places=5, validators=[MinValueValidator(0)])
currency = models.ForeignKey(Currency, blank=True, null=True, on_delete=models.SET_NULL)
@property
def converted_cost(self):
""" Return the cost of this price break, converted to the base currency """
scaler = Decimal(1.0)
if self.currency:
scaler = self.currency.value
return self.cost * scaler
class Meta:
unique_together = ("part", "quantity")

View File

@ -137,11 +137,22 @@ class SupplierPartSerializer(InvenTreeModelSerializer):
class SupplierPriceBreakSerializer(InvenTreeModelSerializer):
""" Serializer for SupplierPriceBreak object """
symbol = serializers.CharField(read_only=True)
suffix = serializers.CharField(read_only=True)
quantity = serializers.FloatField()
cost = serializers.FloatField()
class Meta:
model = SupplierPriceBreak
fields = [
'pk',
'part',
'quantity',
'cost'
'cost',
'currency',
'symbol',
'suffix',
]

View File

@ -10,45 +10,12 @@
<hr>
<h4>{% trans "Pricing Information" %}</h4>
<table class="table table-striped table-condensed">
<tr><td>{% trans "Order Multiple" %}</td><td>{{ part.multiple }}</td></tr>
{% if part.base_cost > 0 %}
<tr><td>{% trans "Base Price (Flat Fee)" %}</td><td>{{ part.base_cost }}</td></tr>
{% endif %}
<tr>
<th>{% trans "Price Breaks" %}</th>
<th>
<div style='float: right;'>
<button class='btn btn-primary' id='new-price-break' type='button'>{% trans "New Price Break" %}</button>
</div>
</th>
</tr>
<tr>
<th>{% trans "Quantity" %}</th>
<th>{% trans "Price" %}</th>
</tr>
{% if part.price_breaks.all %}
{% for pb in part.price_breaks.all %}
<tr>
<td>{% decimal pb.quantity %}</td>
<td>
{% if pb.currency %}{{ pb.currency.symbol }}{% endif %}
{% decimal pb.cost %}
{% if pb.currency %}{{ pb.currency.suffix }}{% endif %}
<div class='btn-group' style='float: right;'>
<button title='Edit Price Break' class='btn btn-default btn-sm pb-edit-button' type='button' url="{% url 'price-break-edit' pb.id %}"><span class='fas fa-edit icon-green'></span></button>
<button title='Delete Price Break' class='btn btn-default btn-sm pb-delete-button' type='button' url="{% url 'price-break-delete' pb.id %}"><span class='fas fa-trash-alt icon-red'></span></button>
</div>
</td>
</tr>
{% endfor %}
{% else %}
<tr>
<td colspan='2'>
<span class='warning-msg'><i>{% trans "No price breaks have been added for this part" %}</i></span>
</td>
</tr>
{% endif %}
<div id='price-break-toolbar' class='btn-group'>
<button class='btn btn-primary' id='new-price-break' type='button'>{% trans "Add Price Break" %}</button>
</div>
<table class='table table-striped table-condensed' id='price-break-table' data-toolbar='#price-break-toolbar'>
</table>
{% endblock %}
@ -56,7 +23,80 @@
{% block js_ready %}
{{ block.super }}
function reloadPriceBreaks() {
$("#price-break-table").bootstrapTable("refresh");
}
$('#price-break-table').inventreeTable({
name: 'buypricebreaks',
formatNoMatches: function() { return "{% trans "No price break information found" %}"; },
queryParams: {
part: {{ part.id }},
},
url: "{% url 'api-part-supplier-price' %}",
onLoadSuccess: function() {
var table = $('#price-break-table');
table.find('.button-price-break-delete').click(function() {
var pk = $(this).attr('pk');
launchModalForm(
`/price-break/${pk}/delete/`,
{
success: reloadPriceBreaks
}
);
});
table.find('.button-price-break-edit').click(function() {
var pk = $(this).attr('pk');
launchModalForm(
`/price-break/${pk}/edit/`,
{
success: reloadPriceBreaks
}
);
});
},
columns: [
{
field: 'pk',
title: 'ID',
visible: false,
switchable: false,
},
{
field: 'quantity',
title: '{% trans "Quantity" %}',
sortable: true,
},
{
field: 'cost',
title: '{% trans "Price" %}',
sortable: true,
formatter: function(value, row, index) {
var html = '';
html += row.symbol || '';
html += value;
if (row.suffix) {
html += ' ' + row.suffix || '';
}
html += `<div class='btn-group float-right' role='group'>`
html += makeIconButton('fa-edit icon-blue', 'button-price-break-edit', row.pk, '{% trans "Edit price break" %}');
html += makeIconButton('fa-trash-alt icon-red', 'button-price-break-delete', row.pk, '{% trans "Delete price break" %}');
html += `</div>`;
return html;
}
},
]
});
$('#new-price-break').click(function() {
launchModalForm("{% url 'price-break-create' %}",
@ -69,24 +109,4 @@ $('#new-price-break').click(function() {
);
});
$('.pb-edit-button').click(function() {
var button = $(this);
launchModalForm(button.attr('url'),
{
reload: true,
}
);
});
$('.pb-delete-button').click(function() {
var button = $(this);
launchModalForm(button.attr('url'),
{
reload: true,
}
);
});
{% endblock %}

View File

@ -401,7 +401,7 @@ class PriceBreakCreate(AjaxCreateView):
def get_data(self):
return {
'success': 'Added new price break'
'success': _('Added new price break')
}
def get_part(self):

View File

@ -13,6 +13,7 @@ from .models import PartAttachment, PartStar
from .models import BomItem
from .models import PartParameterTemplate, PartParameter
from .models import PartTestTemplate
from .models import PartSellPriceBreak
from stock.models import StockLocation
from company.models import SupplierPart
@ -257,6 +258,14 @@ class ParameterAdmin(ImportExportModelAdmin):
list_display = ('part', 'template', 'data')
class PartSellPriceBreakAdmin(admin.ModelAdmin):
class Meta:
model = PartSellPriceBreak
list_display = ('part', 'quantity', 'cost', 'currency')
admin.site.register(Part, PartAdmin)
admin.site.register(PartCategory, PartCategoryAdmin)
admin.site.register(PartAttachment, PartAttachmentAdmin)
@ -265,3 +274,4 @@ admin.site.register(BomItem, BomItemAdmin)
admin.site.register(PartParameterTemplate, ParameterTemplateAdmin)
admin.site.register(PartParameter, ParameterAdmin)
admin.site.register(PartTestTemplate, PartTestTemplateAdmin)
admin.site.register(PartSellPriceBreak, PartSellPriceBreakAdmin)

View File

@ -20,6 +20,7 @@ from django.urls import reverse
from .models import Part, PartCategory, BomItem, PartStar
from .models import PartParameter, PartParameterTemplate
from .models import PartAttachment, PartTestTemplate
from .models import PartSellPriceBreak
from . import serializers as part_serializers
@ -107,6 +108,27 @@ class CategoryDetail(generics.RetrieveUpdateDestroyAPIView):
queryset = PartCategory.objects.all()
class PartSalePriceList(generics.ListCreateAPIView):
"""
API endpoint for list view of PartSalePriceBreak model
"""
queryset = PartSellPriceBreak.objects.all()
serializer_class = part_serializers.PartSalePriceSerializer
permission_classes = [
permissions.IsAuthenticated,
]
filter_backends = [
DjangoFilterBackend
]
filter_fields = [
'part',
]
class PartAttachmentList(generics.ListCreateAPIView, AttachmentMixin):
"""
API endpoint for listing (and creating) a PartAttachment (file upload).
@ -405,6 +427,28 @@ class PartList(generics.ListCreateAPIView):
except (ValueError, Part.DoesNotExist):
pass
# Filter by whether the BOM has been validated (or not)
bom_valid = params.get('bom_valid', None)
# TODO: Querying bom_valid status may be quite expensive
# TODO: (It needs to be profiled!)
# TODO: It might be worth caching the bom_valid status to a database column
if bom_valid is not None:
bom_valid = str2bool(bom_valid)
# Limit queryset to active assemblies
queryset = queryset.filter(active=True, assembly=True)
pks = []
for part in queryset:
if part.is_bom_valid() == bom_valid:
pks.append(part.pk)
queryset = queryset.filter(pk__in=pks)
# Filter by 'starred' parts?
starred = params.get('starred', None)
@ -454,6 +498,7 @@ class PartList(generics.ListCreateAPIView):
# Filter by whether the part has stock
has_stock = params.get("has_stock", None)
if has_stock is not None:
has_stock = str2bool(has_stock)
@ -477,6 +522,36 @@ class PartList(generics.ListCreateAPIView):
# Filter items which have an 'in_stock' level higher than 'minimum_stock'
queryset = queryset.filter(Q(in_stock__gte=F('minimum_stock')))
# Filter by "parts which need stock to complete build"
stock_to_build = params.get('stock_to_build', None)
# TODO: This is super expensive, database query wise...
# TODO: Need to figure out a cheaper way of making this filter query
if stock_to_build is not None:
# Filter only active parts
queryset = queryset.filter(active=True)
parts_need_stock = []
# Find parts with active builds
# where any subpart's stock is lower than quantity being built
for part in queryset:
if part.active_builds and part.can_build < part.quantity_being_built:
parts_need_stock.append(part.pk)
queryset = queryset.filter(pk__in=parts_need_stock)
# Limit choices
limit = params.get('limit', None)
if limit is not None:
try:
limit = int(limit)
if limit > 0:
queryset = queryset[:limit]
except ValueError:
pass
return queryset
permission_classes = [
@ -502,6 +577,7 @@ class PartList(generics.ListCreateAPIView):
ordering_fields = [
'name',
'creation_date',
]
# Default ordering
@ -755,6 +831,11 @@ part_api_urls = [
url(r'^(?P<pk>\d+)/?', PartStarDetail.as_view(), name='api-part-star-detail'),
url(r'^$', PartStarList.as_view(), name='api-part-star-list'),
])),
# Base URL for part sale pricing
url(r'^sale-price/', include([
url(r'^.*$', PartSalePriceList.as_view(), name='api-part-sale-price-list'),
])),
# Base URL for PartParameter API endpoints
url(r'^parameter/', include([

View File

@ -17,6 +17,8 @@ from .models import Part, PartCategory, PartAttachment
from .models import BomItem
from .models import PartParameterTemplate, PartParameter
from .models import PartTestTemplate
from .models import PartSellPriceBreak
from common.models import Currency
@ -253,3 +255,22 @@ class PartPriceForm(forms.Form):
'quantity',
'currency',
]
class EditPartSalePriceBreakForm(HelperForm):
"""
Form for creating / editing a sale price for a part
"""
quantity = RoundingDecimalFormField(max_digits=10, decimal_places=5)
cost = RoundingDecimalFormField(max_digits=10, decimal_places=5)
class Meta:
model = PartSellPriceBreak
fields = [
'part',
'quantity',
'cost',
'currency',
]

View File

@ -0,0 +1,30 @@
# Generated by Django 3.0.7 on 2020-09-17 13:22
import InvenTree.fields
import django.core.validators
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('common', '0007_colortheme'),
('part', '0048_auto_20200902_1404'),
]
operations = [
migrations.CreateModel(
name='PartSellPriceBreak',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('quantity', InvenTree.fields.RoundingDecimalField(decimal_places=5, default=1, max_digits=15, validators=[django.core.validators.MinValueValidator(1)])),
('cost', InvenTree.fields.RoundingDecimalField(decimal_places=5, max_digits=10, validators=[django.core.validators.MinValueValidator(0)])),
('currency', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='common.Currency')),
('part', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='salepricebreaks', to='part.Part')),
],
options={
'unique_together': {('part', 'quantity')},
},
),
]

View File

@ -0,0 +1,19 @@
# Generated by Django 3.0.7 on 2020-09-17 23:15
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('part', '0049_partsellpricebreak'),
]
operations = [
migrations.AlterField(
model_name='partsellpricebreak',
name='part',
field=models.ForeignKey(limit_choices_to={'salable': True}, on_delete=django.db.models.deletion.CASCADE, related_name='salepricebreaks', to='part.Part'),
),
]

View File

@ -46,6 +46,8 @@ from order import models as OrderModels
from company.models import SupplierPart
from stock import models as StockModels
import common.models
class PartCategory(InvenTreeTree):
""" PartCategory provides hierarchical organization of Part objects.
@ -845,7 +847,6 @@ class Part(MPTTModel):
return str(hash.digest())
@property
def is_bom_valid(self):
""" Check if the BOM is 'valid' - if the calculated checksum matches the stored value
"""
@ -1227,6 +1228,21 @@ class PartAttachment(InvenTreeAttachment):
related_name='attachments')
class PartSellPriceBreak(common.models.PriceBreak):
"""
Represents a price break for selling this part
"""
part = models.ForeignKey(
Part, on_delete=models.CASCADE,
related_name='salepricebreaks',
limit_choices_to={'salable': True}
)
class Meta:
unique_together = ('part', 'quantity')
class PartStar(models.Model):
""" A PartStar object creates a relationship between a User and a Part.

View File

@ -12,6 +12,7 @@ from .models import BomItem
from .models import PartParameter, PartParameterTemplate
from .models import PartAttachment
from .models import PartTestTemplate
from .models import PartSellPriceBreak
from stock.models import StockItem
@ -87,6 +88,32 @@ class PartTestTemplateSerializer(InvenTreeModelSerializer):
]
class PartSalePriceSerializer(InvenTreeModelSerializer):
"""
Serializer for sale prices for Part model.
"""
symbol = serializers.CharField(read_only=True)
suffix = serializers.CharField(read_only=True)
quantity = serializers.FloatField()
cost = serializers.FloatField()
class Meta:
model = PartSellPriceBreak
fields = [
'pk',
'part',
'quantity',
'cost',
'currency',
'symbol',
'suffix',
]
class PartThumbSerializer(serializers.Serializer):
"""
Serializer for the 'image' field of the Part model.

View File

@ -0,0 +1,110 @@
{% extends "part/part_base.html" %}
{% load static %}
{% load i18n %}
{% block details %}
{% include 'part/tabs.html' with tab='sales-prices' %}
<h4>{% trans "Sale Price" %}</h4>
<hr>
<div id='price-break-toolbar' class='btn-group'>
<button class='btn btn-primary' id='new-price-break' type='button'>{% trans "Add Price Break" %}</button>
</div>
<table class='table table-striped table-condensed' id='price-break-table' data-toolbar='#price-break-toolbar'>
</table>
{% endblock %}
{% block js_ready %}
{{ block.super }}
function reloadPriceBreaks() {
$("#price-break-table").bootstrapTable("refresh");
}
$('#new-price-break').click(function() {
launchModalForm("{% url 'sale-price-break-create' %}",
{
success: reloadPriceBreaks,
data: {
part: {{ part.id }},
}
}
);
});
$('#price-break-table').inventreeTable({
name: 'saleprice',
formatNoMatches: function() { return "{% trans 'No price break information found' %}"; },
queryParams: {
part: {{ part.id }},
},
url: "{% url 'api-part-sale-price-list' %}",
onLoadSuccess: function() {
var table = $('#price-break-table');
table.find('.button-price-break-delete').click(function() {
var pk = $(this).attr('pk');
launchModalForm(
`/part/sale-price/${pk}/delete/`,
{
success: reloadPriceBreaks
}
);
});
table.find('.button-price-break-edit').click(function() {
var pk = $(this).attr('pk');
launchModalForm(
`/part/sale-price/${pk}/edit/`,
{
success: reloadPriceBreaks
}
);
});
},
columns: [
{
field: 'pk',
title: 'ID',
visible: false,
switchable: false,
},
{
field: 'quantity',
title: '{% trans "Quantity" %}',
sortable: true,
},
{
field: 'cost',
title: '{% trans "Price" %}',
sortable: true,
formatter: function(value, row, index) {
var html = '';
html += row.symbol || '';
html += value;
if (row.suffix) {
html += ' ' + row.suffix || '';
}
html += `<div class='btn-group float-right' role='group'>`
html += makeIconButton('fa-edit icon-blue', 'button-price-break-edit', row.pk, '{% trans "Edit price break" %}');
html += makeIconButton('fa-trash-alt icon-red', 'button-price-break-delete', row.pk, '{% trans "Delete price break" %}');
html += `</div>`;
return html;
}
},
]
})
{% endblock %}

View File

@ -46,6 +46,9 @@
</li>
{% endif %}
{% if part.salable %}
<li {% if tab == 'sales-prices' %}class='active'{% endif %}>
<a href="{% url 'part-sale-prices' part.id %}">{% trans "Sale Price" %}</a>
</li>
<li{% ifequal tab 'sales-orders' %} class='active'{% endifequal %}>
<a href="{% url 'part-sales-orders' part.id %}">{% trans "Sales Orders" %} <span class='badge'>{{ part.sales_orders|length }}</span></a>
</li>

View File

@ -18,6 +18,12 @@ part_attachment_urls = [
url(r'^(?P<pk>\d+)/delete/?', views.PartAttachmentDelete.as_view(), name='part-attachment-delete'),
]
sale_price_break_urls = [
url(r'^new/', views.PartSalePriceBreakCreate.as_view(), name='sale-price-break-create'),
url(r'^(?P<pk>\d+)/edit/', views.PartSalePriceBreakEdit.as_view(), name='sale-price-break-edit'),
url(r'^(?P<pk>\d+)/delete/', views.PartSalePriceBreakDelete.as_view(), name='sale-price-break-delete'),
]
part_parameter_urls = [
url(r'^template/new/', views.PartParameterTemplateCreate.as_view(), name='part-param-template-create'),
@ -27,7 +33,6 @@ part_parameter_urls = [
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'),
]
part_detail_urls = [
@ -52,6 +57,7 @@ part_detail_urls = [
url(r'^suppliers/?', views.PartDetail.as_view(template_name='part/supplier.html'), name='part-suppliers'),
url(r'^orders/?', views.PartDetail.as_view(template_name='part/orders.html'), name='part-orders'),
url(r'^sales-orders/', views.PartDetail.as_view(template_name='part/sales_orders.html'), name='part-sales-orders'),
url(r'^sale-prices/', views.PartDetail.as_view(template_name='part/sale_prices.html'), name='part-sale-prices'),
url(r'^tests/', views.PartDetail.as_view(template_name='part/part_tests.html'), name='part-test-templates'),
url(r'^track/?', views.PartDetail.as_view(template_name='part/track.html'), name='part-track'),
url(r'^attachments/?', views.PartDetail.as_view(template_name='part/attachments.html'), name='part-attachments'),
@ -108,6 +114,9 @@ part_urls = [
# Part attachments
url(r'^attachment/', include(part_attachment_urls)),
# Part price breaks
url(r'^sale-price/', include(sale_price_break_urls)),
# Part test templates
url(r'^test-template/', include([
url(r'^new/', views.PartTestTemplateCreate.as_view(), name='part-test-template-create'),

View File

@ -26,6 +26,7 @@ from .models import PartParameterTemplate, PartParameter
from .models import BomItem
from .models import match_part_names
from .models import PartTestTemplate
from .models import PartSellPriceBreak
from common.models import Currency, InvenTreeSetting
from company.models import SupplierPart
@ -2097,3 +2098,75 @@ class BomItemDelete(AjaxDeleteView):
ajax_template_name = 'part/bom-delete.html'
context_object_name = 'item'
ajax_form_title = _('Confim BOM item deletion')
class PartSalePriceBreakCreate(AjaxCreateView):
""" View for creating a sale price break for a part """
model = PartSellPriceBreak
form_class = part_forms.EditPartSalePriceBreakForm
ajax_form_title = _('Add Price Break')
def get_data(self):
return {
'success': _('Added new price break')
}
def get_part(self):
try:
part = Part.objects.get(id=self.request.GET.get('part'))
except (ValueError, Part.DoesNotExist):
part = None
if part is None:
try:
part = Part.objects.get(id=self.request.POST.get('part'))
except (ValueError, Part.DoesNotExist):
part = None
return part
def get_form(self):
form = super(AjaxCreateView, self).get_form()
form.fields['part'].widget = HiddenInput()
return form
def get_initial(self):
initials = super(AjaxCreateView, self).get_initial()
initials['part'] = self.get_part()
# Pre-select the default currency
try:
base = Currency.objects.get(base=True)
initials['currency'] = base
except Currency.DoesNotExist:
pass
return initials
class PartSalePriceBreakEdit(AjaxUpdateView):
""" View for editing a sale price break """
model = PartSellPriceBreak
form_class = part_forms.EditPartSalePriceBreakForm
ajax_form_title = _('Edit Price Break')
def get_form(self):
form = super().get_form()
form.fields['part'].widget = HiddenInput()
return form
class PartSalePriceBreakDelete(AjaxDeleteView):
""" View for deleting a sale price break """
model = PartSellPriceBreak
ajax_form_title = _("Delete Price Break")
ajax_template_name = "modal_delete_form.html"

View File

@ -440,6 +440,8 @@ class StockList(generics.ListCreateAPIView):
params = self.request.query_params
queryset = super().filter_queryset(queryset)
# Perform basic filtering:
# Note: We do not let DRF filter here, it be slow AF
@ -680,6 +682,14 @@ class StockList(generics.ListCreateAPIView):
filter_fields = [
]
search_fields = [
'serial',
'batch',
'part__name',
'part__IPN',
'part__description'
]
class StockAttachmentList(generics.ListCreateAPIView, AttachmentMixin):
"""

View File

@ -0,0 +1,15 @@
{% extends "collapse_index.html" %}
{% load i18n %}
{% block collapse_title %}
<span class='fas fa-times-circle icon-header'></span>
{% trans "BOM Waiting Validation" %}<span class='badge' id='bom-invalid-count'><span class='fas fa-spin fa-hourglass-half'></span></span>
{% endblock %}
{% block collapse_content %}
<table class='table table-striped table-condensed' id='bom-invalid-table'>
</table>
{% endblock %}

View File

@ -0,0 +1,15 @@
{% extends "collapse_index.html" %}
{% load i18n %}
{% block collapse_title %}
<span class='fas fa-cogs icon-header'></span>
{% trans "Pending Builds" %}<span class='badge' id='build-pending-count'><span class='fas fa-spin fa-hourglass-half'></span></span>
{% endblock %}
{% block collapse_content %}
<table class='table table-striped table-condensed' id='build-pending-table'>
</table>
{% endblock %}

View File

@ -7,9 +7,43 @@ InvenTree | Index
{% block content %}
<h3>InvenTree</h3>
<hr>
{% include "InvenTree/starred_parts.html" with collapse_id="starred" %}
{% include "InvenTree/low_stock.html" with collapse_id="order" %}
<div class="row">
<div class="col-sm-6">
{% include "InvenTree/latest_parts.html" with collapse_id="latest_parts" %}
</div>
<div class="col-sm-6">
{% include "InvenTree/starred_parts.html" with collapse_id="starred" %}
</div>
</div>
<div class="row">
<div class="col-sm-6">
{% include "InvenTree/bom_invalid.html" with collapse_id="bom_invalid" %}
</div>
<div class="col-sm-6">
{% include "InvenTree/build_pending.html" with collapse_id="build_pending" %}
</div>
</div>
<div class="row">
<div class="col-sm-6">
{% include "InvenTree/low_stock.html" with collapse_id="order" %}
</div>
<div class="col-sm-6">
{% include "InvenTree/required_stock_build.html" with collapse_id="stock_to_build" %}
</div>
</div>
<div class="row">
<div class="col-sm-6">
{% include "InvenTree/po_outstanding.html" with collapse_id="po_outstanding" %}
</div>
<div class="col-sm-6">
{% include "InvenTree/so_outstanding.html" with collapse_id="so_outstanding" %}
</div>
</div>
{% endblock %}
@ -21,16 +55,71 @@ InvenTree | Index
{{ block.super }}
loadPartTable("#starred-parts-table", "{% url 'api-part-list' %}", {
loadSimplePartTable("#latest-parts-table", "{% url 'api-part-list' %}", {
params: {
ordering: "-creation_date",
limit: 10,
},
name: 'latest_parts',
});
loadSimplePartTable("#starred-parts-table", "{% url 'api-part-list' %}", {
params: {
"starred": true,
},
name: 'starred_parts',
});
loadSimplePartTable("#bom-invalid-table", "{% url 'api-part-list' %}", {
params: {
"bom_valid": false,
},
name: 'bom_invalid_parts',
});
loadBuildTable("#build-pending-table", {
url: "{% url 'api-build-list' %}",
params: {
part_detail: true,
active: true,
},
disableFilters: true,
});
loadSimplePartTable("#low-stock-table", "{% url 'api-part-list' %}", {
params: {
low_stock: true,
},
name: "low_stock_parts",
});
loadSimplePartTable("#stock-to-build-table", "{% url 'api-part-list' %}", {
params: {
stock_to_build: true,
},
name: "to_build_parts",
});
loadPurchaseOrderTable("#po-outstanding-table", {
url: "{% url 'api-po-list' %}",
params: {
supplier_detail: true,
outstanding: true,
}
});
loadPartTable("#low-stock-table", "{% url 'api-part-list' %}", {
loadSalesOrderTable("#so-outstanding-table", {
url: "{% url 'api-so-list' %}",
params: {
"low_stock": true,
}
customer_detail: true,
outstanding: true,
},
});
$("#latest-parts-table").on('load-success.bs.table', function() {
var count = $("#latest-parts-table").bootstrapTable('getData').length;
$("#latest-parts-count").html(count);
});
$("#starred-parts-table").on('load-success.bs.table', function() {
@ -39,11 +128,40 @@ $("#starred-parts-table").on('load-success.bs.table', function() {
$("#starred-parts-count").html(count);
});
$("#bom-invalid-table").on('load-success.bs.table', function() {
var count = $("#bom-invalid-table").bootstrapTable('getData').length;
$("#bom-invalid-count").html(count);
});
$("#build-pending-table").on('load-success.bs.table', function() {
var count = $("#build-pending-table").bootstrapTable('getData').length;
$("#build-pending-count").html(count);
});
$("#low-stock-table").on('load-success.bs.table', function() {
var count = $("#low-stock-table").bootstrapTable('getData').length;
$("#low-stock-count").html(count);
});
$("#stock-to-build-table").on('load-success.bs.table', function() {
var count = $("#stock-to-build-table").bootstrapTable('getData').length;
$("#stock-to-build-count").html(count);
});
$("#po-outstanding-table").on('load-success.bs.table', function() {
var count = $("#po-outstanding-table").bootstrapTable('getData').length;
$("#po-outstanding-count").html(count);
});
$("#so-outstanding-table").on('load-success.bs.table', function() {
var count = $("#so-outstanding-table").bootstrapTable('getData').length;
$("#so-outstanding-count").html(count);
});
{% endblock %}

View File

@ -0,0 +1,15 @@
{% extends "collapse_index.html" %}
{% load i18n %}
{% block collapse_title %}
<span class='fas fa-newspaper icon-header'></span>
{% trans "Latest Parts" %}<span class='badge' id='latest-parts-count'><span class='fas fa-spin fa-hourglass-half'></span></span>
{% endblock %}
{% block collapse_content %}
<table class='table table-striped table-condensed' id='latest-parts-table'>
</table>
{% endblock %}

View File

@ -1,10 +1,10 @@
{% extends "collapse.html" %}
{% extends "collapse_index.html" %}
{% load i18n %}
{% block collapse_title %}
<span class='fas fa-shopping-cart icon-header'></span>
{% trans "Low Stock" %}<span class='badge' id='low-stock-count'>0</span>
{% trans "Low Stock" %}<span class='badge' id='low-stock-count'><span class='fas fa-spin fa-hourglass-half'></span></span>
{% endblock %}
{% block collapse_content %}

View File

@ -1,15 +0,0 @@
{% extends "collapse.html" %}
{% block collapse_title %}
<span class='fas fa-tools icon-header'></span>
Parts to Build<span class='badge'>{{ to_build | length }}</span>
{% endblock %}
{% block collapse_heading %}
There are {{ to_build | length }} parts which need building.
{% endblock %}
{% block collapse_content %}
{% include "required_part_table.html" with parts=to_build table_id="to-build-table" %}
{% endblock %}

View File

@ -0,0 +1,15 @@
{% extends "collapse_index.html" %}
{% load i18n %}
{% block collapse_title %}
<span class='fas fa-sign-in-alt icon-header'></span>
{% trans "Outstanding Purchase Orders" %}<span class='badge' id='po-outstanding-count'><span class='fas fa-spin fa-hourglass-half'></span></span>
{% endblock %}
{% block collapse_content %}
<table class='table table-striped table-condensed' id='po-outstanding-table'>
</table>
{% endblock %}

View File

@ -0,0 +1,15 @@
{% extends "collapse_index.html" %}
{% load i18n %}
{% block collapse_title %}
<span class='fas fa-bullhorn icon-header'></span>
{% trans "Require Stock To Complete Build" %}<span class='badge' id='stock-to-build-count'><span class='fas fa-spin fa-hourglass-half'></span></span>
{% endblock %}
{% block collapse_content %}
<table class='table table-striped table-condensed' id='stock-to-build-table'>
</table>
{% endblock %}

View File

@ -32,6 +32,8 @@ InvenTree | {% trans "Search Results" %}
{% include "InvenTree/search_stock_location.html" with collapse_id="locations" %}
{% include "InvenTree/search_stock_items.html" with collapse_id="stock" %}
{% endblock %}
{% block js_load %}
@ -42,9 +44,8 @@ InvenTree | {% trans "Search Results" %}
{% block js_ready %}
{{ block.super }}
$(".panel-group").hide();
function onSearchResults(table, output) {
$(table).on('load-success.bs.table', function() {
var panel = $(output).closest('.panel-group');
@ -56,7 +57,6 @@ InvenTree | {% trans "Search Results" %}
text = '<i>No results</i>'
$(panel).hide();
} else {
text = n + ' result';
@ -67,17 +67,23 @@ InvenTree | {% trans "Search Results" %}
$(panel).show();
var collapse = panel.find('.panel-collapse');
collapse.collapse('show');
$("#no-search-results").hide();
}
$(output).html(text);
$(output).html(`<i>${text}</i>`);
});
}
onSearchResults("#category-results-table", "#category-results-count");
onSearchResults("#location-results-table", "#location-results-count");
onSearchResults("#stock-results-table", "#stock-results-count");
onSearchResults('#part-results-table', '#part-result-count');
onSearchResults('#company-results-table', '#company-result-count');
@ -104,6 +110,85 @@ InvenTree | {% trans "Search Results" %}
],
});
$('#stock-results-table').inventreeTable({
url: "{% url 'api-stock-list' %}",
queryParams: {
search: "{{ query }}",
part_detail: true,
location_detail: true,
},
columns: [
{
field: 'part',
title: "{% trans "Part" %}",
sortable: true,
formatter: function(value, row) {
var url = `/stock/item/${row.pk}/`;
var thumb = row.part_detail.thumbnail;
var name = row.part_detail.full_name;
html = imageHoverIcon(thumb) + renderLink(name, url);
return html;
}
},
{
field: 'part_description',
title: '{% trans "Description" %}',
sortable: true,
formatter: function(value, row, index, field) {
return row.part_detail.description;
}
},
{
field: 'quantity',
title: '{% trans "Stock" %}',
sortable: true,
formatter: function(value, row, index, field) {
var val = parseFloat(value);
// If there is a single unit with a serial number, use the serial number
if (row.serial && row.quantity == 1) {
val = '# ' + row.serial;
} else {
val = +val.toFixed(5);
}
var html = renderLink(val, `/stock/item/${row.pk}/`);
return html;
}
},
{
field: 'status',
title: '{% trans "Status" %}',
sortable: 'true',
formatter: function(value, row, index, field) {
return stockStatusDisplay(value);
},
},
{
field: 'location_detail.pathstring',
title: '{% trans "Location" %}',
sortable: true,
formatter: function(value, row, index, field) {
if (value) {
return renderLink(value, `/stock/location/${row.location}/`);
}
else {
if (row.customer) {
var text = "{% trans "Shipped to customer" %}";
return renderLink(text, `/company/${row.customer}/assigned-stock/`);
} else {
return '<i>{% trans "No stock location set" %}</i>';
}
}
}
},
]
});
$("#location-results-table").inventreeTable({
url: "{% url 'api-location-list' %}",

View File

@ -0,0 +1,16 @@
{% extends "collapse.html" %}
{% load i18n %}
{% block collapse_title %}
<h4>{% trans "Stock Items" %}</h4>
{% endblock %}
{% block collapse_heading %}
<h4><span id='stock-results-count'>{% include "InvenTree/searching.html" %}</span></h4>
{% endblock %}
{% block collapse_content %}
<table class='table table-striped table-condensed' data-toolbar="#button-toolbar" id='stock-results-table'>
</table>
{% endblock %}

View File

@ -1 +1,3 @@
<span class='glyphicon glyphicon-refresh glyphicon-refresh-animate'></span> Searching
{% load i18n %}
<span class='fas fa-spin fa-hourglass-half'></span> <i>{% trans "Searching" %}</i>

View File

@ -0,0 +1,15 @@
{% extends "collapse_index.html" %}
{% load i18n %}
{% block collapse_title %}
<span class='fas fa-sign-out-alt icon-header'></span>
{% trans "Outstanding Sales Orders" %}<span class='badge' id='so-outstanding-count'><span class='fas fa-spin fa-hourglass-half'></span></span>
{% endblock %}
{% block collapse_content %}
<table class='table table-striped table-condensed' id='so-outstanding-table'>
</table>
{% endblock %}

View File

@ -1,15 +1,15 @@
{% extends "collapse.html" %}
{% extends "collapse_index.html" %}
{% load i18n %}
{% block collapse_title %}
<span class='fas fa-star icon-header'></span>
{% trans "Starred Parts" %}<span class='badge' id='starred-parts-count'>0</span>
{% trans "Starred Parts" %}<span class='badge' id='starred-parts-count'><span class='fas fa-spin fa-hourglass-half'></span></span>
{% endblock %}
{% block collapse_content %}
<table class='table tabe-striped table-condensed' id='starred-parts-table'>
<table class='table table-striped table-condensed' id='starred-parts-table'>
</table>
{% endblock %}

View File

@ -0,0 +1,19 @@
{% block collapse_preamble %}
{% endblock %}
<div class='panel-group'>
<div class='panel panel-default'>
<div {% block collapse_panel_setup %}class='panel panel-heading'{% endblock %}>
<div class='panel-title'>
<a data-toggle='collapse' href="#collapse-item-{{ collapse_id }}">{% block collapse_title %}Title{% endblock %}</a>
</div>
{% block collapse_heading %}
{% endblock %}
</div>
<div class='panel-collapse collapse' id='collapse-item-{{ collapse_id }}'>
<div class='panel-body'>
{% block collapse_content %}
{% endblock %}
</div>
</div>
</div>
</div>

View File

@ -5,7 +5,11 @@ function loadBuildTable(table, options) {
var params = options.params || {};
var filters = loadTableFilters("build");
var filters = {};
if (!options.disableFilters) {
loadTableFilters("build");
}
for (var key in params) {
filters[key] = params[key];
@ -13,7 +17,7 @@ function loadBuildTable(table, options) {
setupFilterList("build", table);
table.inventreeTable({
$(table).inventreeTable({
method: 'get',
formatNoMatches: function() {
return "{% trans "No builds matching query" %}";

View File

@ -155,6 +155,14 @@ function loadPartVariantTable(table, partId, options) {
}
function loadSimplePartTable(table, url, options={}) {
options.disableFilters = true;
loadPartTable(table, url, options);
}
function loadPartTable(table, url, options={}) {
/* Load part listing data into specified table.
*
@ -332,7 +340,7 @@ function loadPartTable(table, url, options={}) {
method: 'get',
queryParams: filters,
groupBy: false,
name: 'part',
name: options.name || 'part',
original: params,
formatNoMatches: function() { return "{% trans "No parts found" %}"; },
columns: columns,

View File

@ -26,6 +26,10 @@ function getAvailableTableFilters(tableKey) {
title: "{% trans "Serial number LTE" %}",
description: "{% trans "Serial number less than or equal to" %}",
},
serial: {
title: "{% trans "Serial number" %}",
description: "{% trans "Serial number" %}"
},
};
}
@ -66,6 +70,10 @@ function getAvailableTableFilters(tableKey) {
type: 'bool',
title: '{% trans "Is Serialized" %}',
},
serial: {
title: "{% trans "Serial number" %}",
description: "{% trans "Serial number" %}"
},
serial_gte: {
title: "{% trans "Serial number GTE" %}",
description: "{% trans "Serial number greater than or equal to" %}"
@ -109,7 +117,10 @@ function getAvailableTableFilters(tableKey) {
title: '{% trans "Build status" %}',
options: buildCodes,
},
pending: {
type: 'bool',
title: '{% trans "Pending" %}',
}
};
}

View File

@ -36,7 +36,7 @@
</ul>
<ul class="nav navbar-nav navbar-right">
{% include "search_form.html" %}
<li class ='navbar-barcode-li nav navbar-nav'>
<li id='navbar-barcode-li'>
<button id='barcode-scan' class='btn btn-default' title='{% trans "Scan Barcode" %}'>
<span class='fas fa-qrcode'></span>
</button>

View File

@ -108,6 +108,14 @@ def superuser(c):
manage(c, 'createsuperuser', pty=True)
@task
def check(c):
"""
Check validity of django codebase
"""
manage(c, "check")
@task
def migrate(c):
"""