Merge remote-tracking branch 'inventree/master'

This commit is contained in:
Oliver Walters 2019-05-21 00:58:19 +10:00
commit 29ab493cbe
22 changed files with 300 additions and 233 deletions

View File

@ -1,5 +1,11 @@
from django.shortcuts import HttpResponseRedirect from django.shortcuts import HttpResponseRedirect
from django.urls import reverse_lazy from django.urls import reverse_lazy
from django.db import connection
import logging
import time
import operator
logger = logging.getLogger(__name__)
class AuthRequiredMiddleware(object): class AuthRequiredMiddleware(object):
@ -24,3 +30,60 @@ class AuthRequiredMiddleware(object):
# the view is called. # the view is called.
return response return response
class QueryCountMiddleware(object):
"""
This middleware will log the number of queries run
and the total time taken for each request (with a
status code of 200). It does not currently support
multi-db setups.
Reference: https://www.dabapps.com/blog/logging-sql-queries-django-13/
"""
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
t_start = time.time()
response = self.get_response(request)
t_stop = time.time()
if response.status_code == 200:
total_time = 0
if len(connection.queries) > 0:
queries = {}
for query in connection.queries:
query_time = query.get('time')
sql = query.get('sql').split('.')[0]
if sql in queries:
queries[sql] += 1
else:
queries[sql] = 1
if query_time is None:
# django-debug-toolbar monkeypatches the connection
# cursor wrapper and adds extra information in each
# item in connection.queries. The query time is stored
# under the key "duration" rather than "time" and is
# in milliseconds, not seconds.
query_time = float(query.get('duration', 0))
total_time += float(query_time)
logger.debug('{n} queries run, {a:.3f}s / {b:.3f}s'.format(
n=len(connection.queries),
a=total_time,
b=(t_stop - t_start)))
for x in sorted(queries.items(), key=operator.itemgetter(1), reverse=True):
print(x[0], ':', x[1])
return response

View File

@ -74,6 +74,26 @@ INSTALLED_APPS = [
'qr_code', # Generate QR codes 'qr_code', # Generate QR codes
] ]
LOGGING = {
'version': 1,
'disable_existing_loggers': False,
'handlers': {
'console': {
'level': 'DEBUG',
'class': 'logging.StreamHandler',
},
},
# 'loggers': {
# 'ddjango.db.backends': {
# 'level': 'DEBUG',
# 'handlers': ['console',],
# 'propagate': True
# },
# },
}
MIDDLEWARE = [ MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware', 'django.middleware.security.SecurityMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware',
@ -83,9 +103,13 @@ MIDDLEWARE = [
'django.contrib.auth.middleware.AuthenticationMiddleware', 'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware', 'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware', 'django.middleware.clickjacking.XFrameOptionsMiddleware',
'InvenTree.middleware.AuthRequiredMiddleware' 'InvenTree.middleware.AuthRequiredMiddleware',
'InvenTree.middleware.QueryCountMiddleware',
] ]
if DEBUG:
MIDDLEWARE.append('InvenTree.middleware.QueryCountMiddleware')
ROOT_URLCONF = 'InvenTree.urls' ROOT_URLCONF = 'InvenTree.urls'
TEMPLATES = [ TEMPLATES = [

View File

@ -1,6 +1,5 @@
# Generated by Django 2.2 on 2019-05-18 14:04 # Generated by Django 2.2 on 2019-05-20 12:04
from django.conf import settings
import django.core.validators import django.core.validators
from django.db import migrations, models from django.db import migrations, models
import django.db.models.deletion import django.db.models.deletion
@ -11,9 +10,6 @@ class Migration(migrations.Migration):
initial = True initial = True
dependencies = [ dependencies = [
('part', '0002_auto_20190519_0004'),
('stock', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
] ]
operations = [ operations = [
@ -29,9 +25,6 @@ class Migration(migrations.Migration):
('completion_date', models.DateField(blank=True, null=True)), ('completion_date', models.DateField(blank=True, null=True)),
('URL', models.URLField(blank=True, help_text='Link to external URL')), ('URL', models.URLField(blank=True, help_text='Link to external URL')),
('notes', models.TextField(blank=True, help_text='Extra build notes')), ('notes', models.TextField(blank=True, help_text='Extra build notes')),
('completed_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='builds_completed', to=settings.AUTH_USER_MODEL)),
('part', models.ForeignKey(help_text='Select part to build', limit_choices_to={'active': True, 'buildable': True}, on_delete=django.db.models.deletion.CASCADE, related_name='builds', to='part.Part')),
('take_from', models.ForeignKey(blank=True, help_text='Select location to take stock from for this build (leave blank to take from any stock location)', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='sourcing_builds', to='stock.StockLocation')),
], ],
), ),
migrations.CreateModel( migrations.CreateModel(
@ -40,10 +33,6 @@ class Migration(migrations.Migration):
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('quantity', models.PositiveIntegerField(default=1, help_text='Stock quantity to allocate to build', validators=[django.core.validators.MinValueValidator(1)])), ('quantity', models.PositiveIntegerField(default=1, help_text='Stock quantity to allocate to build', validators=[django.core.validators.MinValueValidator(1)])),
('build', models.ForeignKey(help_text='Build to allocate parts', on_delete=django.db.models.deletion.CASCADE, related_name='allocated_stock', to='build.Build')), ('build', models.ForeignKey(help_text='Build to allocate parts', on_delete=django.db.models.deletion.CASCADE, related_name='allocated_stock', to='build.Build')),
('stock_item', models.ForeignKey(help_text='Stock Item to allocate to build', on_delete=django.db.models.deletion.CASCADE, related_name='allocations', to='stock.StockItem')),
], ],
options={
'unique_together': {('build', 'stock_item')},
},
), ),
] ]

View File

@ -0,0 +1,44 @@
# Generated by Django 2.2 on 2019-05-20 12:04
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
initial = True
dependencies = [
('part', '0001_initial'),
('build', '0001_initial'),
('stock', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddField(
model_name='builditem',
name='stock_item',
field=models.ForeignKey(help_text='Stock Item to allocate to build', on_delete=django.db.models.deletion.CASCADE, related_name='allocations', to='stock.StockItem'),
),
migrations.AddField(
model_name='build',
name='completed_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='builds_completed', to=settings.AUTH_USER_MODEL),
),
migrations.AddField(
model_name='build',
name='part',
field=models.ForeignKey(help_text='Select part to build', limit_choices_to={'active': True, 'buildable': True}, on_delete=django.db.models.deletion.CASCADE, related_name='builds', to='part.Part'),
),
migrations.AddField(
model_name='build',
name='take_from',
field=models.ForeignKey(blank=True, help_text='Select location to take stock from for this build (leave blank to take from any stock location)', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='sourcing_builds', to='stock.StockLocation'),
),
migrations.AlterUniqueTogether(
name='builditem',
unique_together={('build', 'stock_item')},
),
]

View File

@ -139,7 +139,7 @@ class Build(models.Model):
allocations = [] allocations = []
for item in self.part.bom_items.all(): for item in self.part.bom_items.all().prefetch_related('sub_part'):
# How many parts required for this build? # How many parts required for this build?
q_required = item.quantity * self.quantity q_required = item.quantity * self.quantity
@ -216,7 +216,7 @@ class Build(models.Model):
- Delete pending BuildItem objects - Delete pending BuildItem objects
""" """
for item in self.allocated_stock.all(): for item in self.allocated_stock.all().prefetch_related('stock_item'):
# Subtract stock from the item # Subtract stock from the item
item.stock_item.take_stock( item.stock_item.take_stock(
@ -295,7 +295,7 @@ class Build(models.Model):
""" Returns a dict of parts required to build this part (BOM) """ """ Returns a dict of parts required to build this part (BOM) """
parts = [] parts = []
for item in self.part.bom_items.all(): for item in self.part.bom_items.all().prefetch_related('sub_part'):
part = {'part': item.sub_part, part = {'part': item.sub_part,
'per_build': item.quantity, 'per_build': item.quantity,
'quantity': item.quantity * self.quantity, 'quantity': item.quantity * self.quantity,

View File

@ -1,4 +1,4 @@
# Generated by Django 2.2 on 2019-05-18 14:04 # Generated by Django 2.2 on 2019-05-20 12:04
import company.models import company.models
import django.core.validators import django.core.validators

View File

@ -1,4 +1,4 @@
# Generated by Django 2.2 on 2019-05-18 14:04 # Generated by Django 2.2 on 2019-05-20 12:04
from django.db import migrations, models from django.db import migrations, models
import django.db.models.deletion import django.db.models.deletion
@ -9,8 +9,8 @@ class Migration(migrations.Migration):
initial = True initial = True
dependencies = [ dependencies = [
('company', '0001_initial'),
('part', '0001_initial'), ('part', '0001_initial'),
('company', '0001_initial'),
] ]
operations = [ operations = [

View File

@ -250,7 +250,7 @@ class SupplierPart(models.Model):
- If order multiples are to be observed, then we need to calculate based on that, too - If order multiples are to be observed, then we need to calculate based on that, too
""" """
price_breaks = self.price_breaks.all() price_breaks = self.price_breaks.filter(quantity__lte=quantity)
# No price break information available? # No price break information available?
if len(price_breaks) == 0: if len(price_breaks) == 0:

View File

@ -110,6 +110,9 @@ class PartList(generics.ListCreateAPIView):
except PartCategory.DoesNotExist: except PartCategory.DoesNotExist:
pass pass
# Ensure that related models are pre-loaded to reduce DB trips
parts_list = self.get_serializer_class().setup_eager_loading(parts_list)
return parts_list return parts_list
permission_classes = [ permission_classes = [
@ -201,9 +204,13 @@ class BomList(generics.ListCreateAPIView):
- POST: Create a new BomItem object - POST: Create a new BomItem object
""" """
queryset = BomItem.objects.all()
serializer_class = BomItemSerializer serializer_class = BomItemSerializer
def get_queryset(self):
queryset = BomItem.objects.all()
queryset = self.get_serializer_class().setup_eager_loading(queryset)
return queryset
permission_classes = [ permission_classes = [
permissions.IsAuthenticatedOrReadOnly, permissions.IsAuthenticatedOrReadOnly,
] ]

View File

@ -1,4 +1,4 @@
# Generated by Django 2.2 on 2019-05-18 14:04 # Generated by Django 2.2 on 2019-05-20 12:04
import InvenTree.validators import InvenTree.validators
from django.conf import settings from django.conf import settings

View File

@ -1,4 +1,4 @@
# Generated by Django 2.2 on 2019-05-18 14:04 # Generated by Django 2.2 on 2019-05-20 12:04
from django.conf import settings from django.conf import settings
from django.db import migrations, models from django.db import migrations, models
@ -10,10 +10,10 @@ class Migration(migrations.Migration):
initial = True initial = True
dependencies = [ dependencies = [
('company', '0002_auto_20190519_0004'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('stock', '0001_initial'),
('part', '0001_initial'), ('part', '0001_initial'),
('company', '0002_auto_20190520_2204'),
('stock', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
] ]
operations = [ operations = [

View File

@ -16,6 +16,7 @@ from django.conf import settings
from django.core.files.base import ContentFile from django.core.files.base import ContentFile
from django.db import models, transaction from django.db import models, transaction
from django.db.models import Sum
from django.core.validators import MinValueValidator from django.core.validators import MinValueValidator
from django.contrib.staticfiles.templatetags.staticfiles import static from django.contrib.staticfiles.templatetags.staticfiles import static
@ -410,7 +411,7 @@ class Part(models.Model):
total = None total = None
# Calculate the minimum number of parts that can be built using each sub-part # Calculate the minimum number of parts that can be built using each sub-part
for item in self.bom_items.all(): for item in self.bom_items.all().select_related('sub_part'):
stock = item.sub_part.available_stock stock = item.sub_part.available_stock
n = int(1.0 * stock / item.quantity) n = int(1.0 * stock / item.quantity)
@ -448,7 +449,7 @@ class Part(models.Model):
builds = [] builds = []
for item in self.used_in.all(): for item in self.used_in.all().prefetch_related('part'):
for build in item.part.active_builds: for build in item.part.active_builds:
b = {} b = {}
@ -462,7 +463,7 @@ class Part(models.Model):
@property @property
def allocated_build_count(self): def allocated_build_count(self):
""" Return the total number of this that are allocated for builds """ Return the total number of this part that are allocated for builds
""" """
return sum([a['quantity'] for a in self.build_allocation]) return sum([a['quantity'] for a in self.build_allocation])
@ -481,7 +482,13 @@ class Part(models.Model):
@property @property
def stock_entries(self): def stock_entries(self):
return [loc for loc in self.locations.all() if loc.in_stock] """ Return all 'in stock' items. To be in stock:
- customer is None
- belongs_to is None
"""
return self.stock_items.filter(customer=None, belongs_to=None)
@property @property
def total_stock(self): def total_stock(self):
@ -489,7 +496,12 @@ class Part(models.Model):
Part may be stored in multiple locations Part may be stored in multiple locations
""" """
return sum([loc.quantity for loc in self.stock_entries]) total = self.stock_entries.aggregate(total=Sum('quantity'))['total']
if total:
return total
else:
return 0
@property @property
def has_bom(self): def has_bom(self):
@ -518,9 +530,9 @@ class Part(models.Model):
returns a string representation of a hash object which can be compared with a stored value returns a string representation of a hash object which can be compared with a stored value
""" """
hash = hashlib.md5('bom seed'.encode()) hash = hashlib.md5(str(self.id).encode())
for item in self.bom_items.all(): for item in self.bom_items.all().prefetch_related('sub_part'):
hash.update(str(item.sub_part.id).encode()) hash.update(str(item.sub_part.id).encode())
hash.update(str(item.sub_part.full_name).encode()) hash.update(str(item.sub_part.full_name).encode())
hash.update(str(item.quantity).encode()) hash.update(str(item.quantity).encode())
@ -552,7 +564,7 @@ class Part(models.Model):
def required_parts(self): def required_parts(self):
""" Return a list of parts required to make this part (list of BOM items) """ """ Return a list of parts required to make this part (list of BOM items) """
parts = [] parts = []
for bom in self.bom_items.all(): for bom in self.bom_items.all().select_related('sub_part'):
parts.append(bom.sub_part) parts.append(bom.sub_part)
return parts return parts
@ -561,43 +573,21 @@ class Part(models.Model):
""" Return the number of supplier parts available for this part """ """ Return the number of supplier parts available for this part """
return self.supplier_parts.count() return self.supplier_parts.count()
@property
def min_single_price(self):
return self.get_min_supplier_price(1)
@property
def max_single_price(self):
return self.get_max_supplier_price(1)
@property
def min_bom_price(self):
return self.get_min_bom_price(1)
@property
def max_bom_price(self):
return self.get_max_bom_price(1)
@property @property
def has_pricing_info(self): def has_pricing_info(self):
""" Return true if there is pricing information for this part """ """ Return true if there is pricing information for this part """
return self.get_min_price() is not None return self.get_price_range() is not None
@property @property
def has_complete_bom_pricing(self): def has_complete_bom_pricing(self):
""" Return true if there is pricing information for each item in the BOM. """ """ Return true if there is pricing information for each item in the BOM. """
for item in self.bom_items.all(): for item in self.bom_items.all().select_related('sub_part'):
if not item.sub_part.has_pricing_info: if not item.sub_part.has_pricing_info:
return False return False
return True return True
@property
def single_price_info(self):
""" Return a simplified pricing string for this part at single quantity """
return self.get_price_info()
def get_price_info(self, quantity=1, buy=True, bom=True): def get_price_info(self, quantity=1, buy=True, bom=True):
""" Return a simplified pricing string for this part """ Return a simplified pricing string for this part
@ -607,71 +597,43 @@ class Part(models.Model):
bom: Include BOM pricing (default = True) bom: Include BOM pricing (default = True)
""" """
min_price = self.get_min_price(quantity, buy, bom) price_range = self.get_price_range(quantity, buy, bom)
max_price = self.get_max_price(quantity, buy, bom)
if min_price is None: if price_range is None:
return None return None
min_price, max_price = price_range
if min_price == max_price: if min_price == max_price:
return min_price return min_price
return "{a} to {b}".format(a=min_price, b=max_price) return "{a} - {b}".format(a=min_price, b=max_price)
def get_min_supplier_price(self, quantity=1): def get_supplier_price_range(self, quantity=1):
""" Return the minimum price of this part from all available suppliers.
Args:
quantity: Number of units we wish to purchase (default = 1)
Returns:
Numerical price if pricing is available, else None
"""
min_price = None min_price = None
for supplier_part in self.supplier_parts.all():
supplier_price = supplier_part.get_price(quantity)
if supplier_price is None:
continue
if min_price is None or supplier_price < min_price:
min_price = supplier_price
if min_price is None:
return None
else:
return min_price
def get_max_supplier_price(self, quantity=1):
""" Return the maximum price of this part from all available suppliers.
Args:
quantity: Number of units we wish to purchase (default = 1)
Returns:
Numerical price if pricing is available, else None
"""
max_price = None max_price = None
for supplier_part in self.supplier_parts.all(): for supplier in self.supplier_parts.all():
supplier_price = supplier_part.get_price(quantity)
if supplier_price is None: price = supplier.get_price(quantity)
if price is None:
continue continue
if max_price is None or supplier_price > max_price: if min_price is None or price < min_price:
max_price = supplier_price min_price = price
if max_price is None: if max_price is None or price > max_price:
max_price = price
if min_price is None or max_price is None:
return None return None
else:
return max_price
def get_min_bom_price(self, quantity=1): return (min_price, max_price)
""" Return the minimum price of the BOM for this part.
def get_bom_price_range(self, quantity=1):
""" Return the price range of the BOM for this part.
Adds the minimum price for all components in the BOM. Adds the minimum price for all components in the BOM.
Note: If the BOM contains items without pricing information, Note: If the BOM contains items without pricing information,
@ -679,45 +641,33 @@ class Part(models.Model):
""" """
min_price = None min_price = None
max_price = None
for item in self.bom_items.all(): for item in self.bom_items.all().select_related('sub_part'):
price = item.sub_part.get_min_price(quantity * item.quantity) prices = item.sub_part.get_price_range(quantity * item.quantity)
if price is None: if prices is None:
continue continue
low, high = prices
if min_price is None: if min_price is None:
min_price = 0 min_price = 0
min_price += price
return min_price
def get_max_bom_price(self, quantity=1):
""" Return the maximum price of the BOM for this part.
Adds the maximum price for all components in the BOM.
Note: If the BOM contains items without pricing information,
these items cannot be included in the BOM!
"""
max_price = None
for item in self.bom_items.all():
price = item.sub_part.get_max_price(quantity * item.quantity)
if price is None:
continue
if max_price is None: if max_price is None:
max_price = 0 max_price = 0
max_price += price min_price += low
max_price += high
return max_price if min_price is None or max_price is None:
return None
def get_min_price(self, quantity=1, buy=True, bom=True): return (min_price, max_price)
""" Return the minimum price for this part. This price can be either:
def get_price_range(self, quantity=1, buy=True, bom=True):
""" Return the price range for this part. This price can be either:
- Supplier price (if purchased from suppliers) - Supplier price (if purchased from suppliers)
- BOM price (if built from other parts) - BOM price (if built from other parts)
@ -726,37 +676,20 @@ class Part(models.Model):
Minimum of the supplier price or BOM price. If no pricing available, returns None Minimum of the supplier price or BOM price. If no pricing available, returns None
""" """
buy_price = self.get_min_supplier_price(quantity) if buy else None buy_price_range = self.get_supplier_price_range(quantity) if buy else None
bom_price = self.get_min_bom_price(quantity) if bom else None bom_price_range = self.get_bom_price_range(quantity) if bom else None
if buy_price is None: if buy_price_range is None:
return bom_price return bom_price_range
if bom_price is None: elif bom_price_range is None:
return buy_price return buy_price_range
return min(buy_price, bom_price) else:
return (
def get_max_price(self, quantity=1, buy=True, bom=True): min(buy_price_range[0], bom_price_range[0]),
""" Return the maximum price for this part. This price can be either: max(buy_price_range[1], bom_price_range[1])
)
- Supplier price (if purchsed from suppliers)
- BOM price (if built from other parts)
Returns:
Maximum of the supplier price or BOM price. If no pricing available, returns None
"""
buy_price = self.get_max_supplier_price(quantity) if buy else None
bom_price = self.get_max_bom_price(quantity) if bom else None
if buy_price is None:
return bom_price
if bom_price is None:
return buy_price
return max(buy_price, bom_price)
def deepCopy(self, other, **kwargs): def deepCopy(self, other, **kwargs):
""" Duplicates non-field data from another part. """ Duplicates non-field data from another part.
@ -995,8 +928,3 @@ class BomItem(models.Model):
base_quantity = self.quantity * build_quantity base_quantity = self.quantity * build_quantity
return base_quantity + self.get_overage_quantity(base_quantity) return base_quantity + self.get_overage_quantity(base_quantity)
@property
def price_info(self):
""" Return the price for this item in the BOM """
return self.sub_part.get_price_info(self.quantity)

View File

@ -34,7 +34,14 @@ class PartBriefSerializer(serializers.ModelSerializer):
url = serializers.CharField(source='get_absolute_url', read_only=True) url = serializers.CharField(source='get_absolute_url', read_only=True)
image_url = serializers.CharField(source='get_image_url', read_only=True) image_url = serializers.CharField(source='get_image_url', read_only=True)
single_price_info = serializers.CharField(read_only=True)
@staticmethod
def setup_eager_loading(queryset):
queryset = queryset.prefetch_related('category')
queryset = queryset.prefetch_related('stock_items')
queryset = queryset.prefetch_related('bom_items')
queryset = queryset.prefetch_related('builds')
return queryset
class Meta: class Meta:
model = Part model = Part
@ -44,7 +51,6 @@ class PartBriefSerializer(serializers.ModelSerializer):
'full_name', 'full_name',
'description', 'description',
'available_stock', 'available_stock',
'single_price_info',
'image_url', 'image_url',
] ]
@ -58,6 +64,14 @@ class PartSerializer(serializers.ModelSerializer):
image_url = serializers.CharField(source='get_image_url', read_only=True) image_url = serializers.CharField(source='get_image_url', read_only=True)
category_name = serializers.CharField(source='category_path', read_only=True) category_name = serializers.CharField(source='category_path', read_only=True)
@staticmethod
def setup_eager_loading(queryset):
queryset = queryset.prefetch_related('category')
queryset = queryset.prefetch_related('stock_items')
queryset = queryset.prefetch_related('bom_items')
queryset = queryset.prefetch_related('builds')
return queryset
class Meta: class Meta:
model = Part model = Part
partial = True partial = True
@ -108,7 +122,16 @@ class BomItemSerializer(InvenTreeModelSerializer):
part_detail = PartBriefSerializer(source='part', many=False, read_only=True) part_detail = PartBriefSerializer(source='part', many=False, read_only=True)
sub_part_detail = PartBriefSerializer(source='sub_part', many=False, read_only=True) sub_part_detail = PartBriefSerializer(source='sub_part', many=False, read_only=True)
price_info = serializers.CharField(read_only=True)
@staticmethod
def setup_eager_loading(queryset):
queryset = queryset.prefetch_related('part')
queryset = queryset.prefetch_related('part__category')
queryset = queryset.prefetch_related('part__stock_items')
queryset = queryset.prefetch_related('sub_part')
queryset = queryset.prefetch_related('sub_part__category')
queryset = queryset.prefetch_related('sub_part__stock_items')
return queryset
class Meta: class Meta:
model = BomItem model = BomItem
@ -119,7 +142,6 @@ class BomItemSerializer(InvenTreeModelSerializer):
'sub_part', 'sub_part',
'sub_part_detail', 'sub_part_detail',
'quantity', 'quantity',
'price_info',
'overage', 'overage',
'note', 'note',
] ]

View File

@ -16,9 +16,6 @@
The BOM for <i>{{ part.full_name }}</i> does not have complete pricing information The BOM for <i>{{ part.full_name }}</i> does not have complete pricing information
</div> </div>
{% endif %} {% endif %}
<div class='panel panel-default'>
Single BOM Price: {{ part.min_bom_price }} to {{ part.max_bom_price }}
</div>
{% if part.bom_checked_date %} {% if part.bom_checked_date %}
{% if part.is_bom_valid %} {% if part.is_bom_valid %}
<div class='alert alert-block alert-info'> <div class='alert alert-block alert-info'>

View File

@ -34,6 +34,7 @@ InvenTree | Part List
{% block js_ready %} {% block js_ready %}
{{ block.super }} {{ block.super }}
loadTree("{% url 'api-part-tree' %}", loadTree("{% url 'api-part-tree' %}",
"#part-tree", "#part-tree",
{ {

View File

@ -85,25 +85,6 @@
<td>{{ part.allocation_count }}</td> <td>{{ part.allocation_count }}</td>
</tr> </tr>
{% endif %} {% endif %}
{% if part.supplier_count > 0 %}
<tr>
<td>
Price
</td>
<td>
{% if part.min_single_price %}
{% if part.min_single_price == part.max_single_price %}
{{ part.min_single_price }}
{% else %}
{{ part.min_single_price }} to {{ part.max_single_price }}
{% endif %}
from {{ part.supplier_count }} suppliers.
{% else %}
<span class='warning-msg'><i>No pricing data avilable</i></span>
{% endif %}
</td>
</tr>
{% endif %}
</table> </table>
</div> </div>
</div> </div>

View File

@ -34,7 +34,7 @@ class PartIndex(ListView):
context_object_name = 'parts' context_object_name = 'parts'
def get_queryset(self): def get_queryset(self):
return Part.objects.all() # filter(category=None) return Part.objects.all().select_related('category')
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
@ -355,7 +355,7 @@ class PartDetail(DetailView):
""" """
context_object_name = 'part' context_object_name = 'part'
queryset = Part.objects.all() queryset = Part.objects.all().select_related('category')
template_name = 'part/detail.html' template_name = 'part/detail.html'
# Add in some extra context information based on query params # Add in some extra context information based on query params
@ -567,6 +567,14 @@ class PartPricing(AjaxView):
def get_pricing(self, quantity=1): def get_pricing(self, quantity=1):
try:
quantity = int(quantity)
except ValueError:
quantity = 1
if quantity < 1:
quantity = 1
part = self.get_part() part = self.get_part()
ctx = { ctx = {
@ -579,8 +587,10 @@ class PartPricing(AjaxView):
# Supplier pricing information # Supplier pricing information
if part.supplier_count > 0: if part.supplier_count > 0:
min_buy_price = part.get_min_supplier_price(quantity) buy_price = part.get_supplier_price_range(quantity)
max_buy_price = part.get_max_supplier_price(quantity)
if buy_price is not None:
min_buy_price, max_buy_price = buy_price
if min_buy_price: if min_buy_price:
ctx['min_total_buy_price'] = min_buy_price ctx['min_total_buy_price'] = min_buy_price
@ -593,8 +603,10 @@ class PartPricing(AjaxView):
# BOM pricing information # BOM pricing information
if part.bom_count > 0: if part.bom_count > 0:
min_bom_price = part.get_min_bom_price(quantity) bom_price = part.get_bom_price_range(quantity)
max_bom_price = part.get_max_bom_price(quantity)
if bom_price is not None:
min_bom_price, max_bom_price = bom_price
if min_bom_price: if min_bom_price:
ctx['min_total_bom_price'] = min_bom_price ctx['min_total_bom_price'] = min_bom_price
@ -629,7 +641,7 @@ class CategoryDetail(DetailView):
""" Detail view for PartCategory """ """ Detail view for PartCategory """
model = PartCategory model = PartCategory
context_object_name = 'category' context_object_name = 'category'
queryset = PartCategory.objects.all() queryset = PartCategory.objects.all().prefetch_related('children')
template_name = 'part/category.html' template_name = 'part/category.html'

View File

@ -151,19 +151,6 @@ function loadBomTable(table, options) {
} }
} }
); );
cols.push({
field: 'price_info',
title: 'Price',
sortable: true,
formatter: function(value, row, index, field) {
if (value) {
return value;
} else {
return "<span class='warning-msg'><i>No pricing information</i></span>";
}
},
});
} }
// Part notes // Part notes

View File

@ -282,6 +282,9 @@ class StockList(generics.ListCreateAPIView):
if supplier_id: if supplier_id:
stock_list = stock_list.filter(supplier_part__supplier=supplier_id) stock_list = stock_list.filter(supplier_part__supplier=supplier_id)
# Pre-fetch related objects for better response time
stock_list = self.get_serializer_class().setup_eager_loading(stock_list)
return stock_list return stock_list
serializer_class = StockItemSerializer serializer_class = StockItemSerializer

View File

@ -1,4 +1,4 @@
# Generated by Django 2.2 on 2019-05-18 14:04 # Generated by Django 2.2 on 2019-05-20 12:04
from django.conf import settings from django.conf import settings
import django.core.validators import django.core.validators
@ -11,9 +11,9 @@ class Migration(migrations.Migration):
initial = True initial = True
dependencies = [ dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('company', '0001_initial'),
('part', '0001_initial'), ('part', '0001_initial'),
('company', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
] ]
operations = [ operations = [
@ -70,7 +70,7 @@ class Migration(migrations.Migration):
migrations.AddField( migrations.AddField(
model_name='stockitem', model_name='stockitem',
name='part', name='part',
field=models.ForeignKey(help_text='Base part', on_delete=django.db.models.deletion.CASCADE, related_name='locations', to='part.Part'), field=models.ForeignKey(help_text='Base part', on_delete=django.db.models.deletion.CASCADE, related_name='stock_items', to='part.Part'),
), ),
migrations.AddField( migrations.AddField(
model_name='stockitem', model_name='stockitem',

View File

@ -186,7 +186,7 @@ class StockItem(models.Model):
} }
) )
part = models.ForeignKey('part.Part', on_delete=models.CASCADE, related_name='locations', help_text='Base part') part = models.ForeignKey('part.Part', on_delete=models.CASCADE, related_name='stock_items', help_text='Base part')
supplier_part = models.ForeignKey('company.SupplierPart', blank=True, null=True, on_delete=models.SET_NULL, supplier_part = models.ForeignKey('company.SupplierPart', blank=True, null=True, on_delete=models.SET_NULL,
help_text='Select a matching supplier part for this stock item') help_text='Select a matching supplier part for this stock item')

View File

@ -60,6 +60,15 @@ class StockItemSerializer(serializers.ModelSerializer):
location = LocationBriefSerializer(many=False, read_only=True) location = LocationBriefSerializer(many=False, read_only=True)
status_text = serializers.CharField(source='get_status_display', read_only=True) status_text = serializers.CharField(source='get_status_display', read_only=True)
@staticmethod
def setup_eager_loading(queryset):
queryset = queryset.prefetch_related('part')
queryset = queryset.prefetch_related('part__stock_items')
queryset = queryset.prefetch_related('part__category')
queryset = queryset.prefetch_related('location')
return queryset
class Meta: class Meta:
model = StockItem model = StockItem
fields = [ fields = [