mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +00:00
Merge remote-tracking branch 'inventree/master'
This commit is contained in:
commit
29ab493cbe
@ -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
|
||||||
|
@ -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 = [
|
||||||
|
@ -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')},
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
44
InvenTree/build/migrations/0002_auto_20190520_2204.py
Normal file
44
InvenTree/build/migrations/0002_auto_20190520_2204.py
Normal 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')},
|
||||||
|
),
|
||||||
|
]
|
@ -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,
|
||||||
|
@ -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
|
||||||
|
@ -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 = [
|
@ -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:
|
||||||
|
@ -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,
|
||||||
]
|
]
|
||||||
|
@ -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
|
||||||
|
@ -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 = [
|
@ -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)
|
|
||||||
|
@ -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',
|
||||||
]
|
]
|
||||||
|
@ -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'>
|
||||||
|
@ -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",
|
||||||
{
|
{
|
||||||
|
@ -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>
|
||||||
|
@ -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'
|
||||||
|
|
||||||
|
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
|
@ -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',
|
||||||
|
@ -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')
|
||||||
|
@ -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 = [
|
||||||
|
Loading…
Reference in New Issue
Block a user