Merge remote-tracking branch 'inventree/master'

This commit is contained in:
Oliver Walters 2019-06-18 01:50:50 +10:00
commit f417ddb8e0
11 changed files with 199 additions and 63 deletions

View File

@ -11,6 +11,8 @@ from rest_framework.exceptions import ValidationError
from django.db.models.signals import pre_delete from django.db.models.signals import pre_delete
from django.dispatch import receiver from django.dispatch import receiver
from .validators import validate_tree_name
class InvenTreeTree(models.Model): class InvenTreeTree(models.Model):
""" Provides an abstracted self-referencing tree model for data categories. """ Provides an abstracted self-referencing tree model for data categories.
@ -31,7 +33,8 @@ class InvenTreeTree(models.Model):
name = models.CharField( name = models.CharField(
blank=False, blank=False,
max_length=100, max_length=100,
unique=True unique=True,
validators=[validate_tree_name]
) )
description = models.CharField( description = models.CharField(
@ -62,17 +65,22 @@ class InvenTreeTree(models.Model):
If any parents are repeated (which would be very bad!), the process is halted If any parents are repeated (which would be very bad!), the process is halted
""" """
if unique is None: item = self
unique = set()
else:
unique.add(self.id)
if self.parent and self.parent.id not in unique: # Prevent infinite regression
self.parent.getUniqueParents(unique) max_parents = 500
unique = set()
while item.parent and max_parents > 0:
max_parents -= 1
unique.add(item.parent.id)
item = item.parent
return unique return unique
def getUniqueChildren(self, unique=None): def getUniqueChildren(self, unique=None, include_self=True):
""" Return a flat set of all child items that exist under this node. """ Return a flat set of all child items that exist under this node.
If any child items are repeated, the repetitions are omitted. If any child items are repeated, the repetitions are omitted.
""" """
@ -83,6 +91,7 @@ class InvenTreeTree(models.Model):
if self.id in unique: if self.id in unique:
return unique return unique
if include_self:
unique.add(self.id) unique.add(self.id)
# Some magic to get around the limitations of abstract models # Some magic to get around the limitations of abstract models
@ -99,14 +108,6 @@ class InvenTreeTree(models.Model):
""" True if there are any children under this item """ """ True if there are any children under this item """
return self.children.count() > 0 return self.children.count() > 0
@property
def children(self):
""" Return the children of this item """
contents = ContentType.objects.get_for_model(type(self))
childs = contents.get_all_objects_for_this_type(parent=self.id)
return childs
def getAcceptableParents(self): def getAcceptableParents(self):
""" Returns a list of acceptable parent items within this model """ Returns a list of acceptable parent items within this model
Acceptable parents are ones which are not underneath this item. Acceptable parents are ones which are not underneath this item.
@ -159,8 +160,8 @@ class InvenTreeTree(models.Model):
""" """
return '/'.join([item.name for item in self.path]) return '/'.join([item.name for item in self.path])
def __setattr__(self, attrname, val): def clean(self):
""" Custom Attribute Setting function """ Custom cleaning
Parent: Parent:
Setting the parent of an item to its own child results in an infinite loop. Setting the parent of an item to its own child results in an infinite loop.
@ -172,29 +173,19 @@ class InvenTreeTree(models.Model):
Tree node names are limited to a reduced character set Tree node names are limited to a reduced character set
""" """
if attrname == 'parent_id': super().clean()
# If current ID is None, continue
# - This object is just being created
if self.id is None:
pass
# Parent cannot be set to same ID (this would cause looping) # Parent cannot be set to same ID (this would cause looping)
elif val == self.id: try:
if self.parent.id == self.id:
raise ValidationError("Category cannot set itself as parent") raise ValidationError("Category cannot set itself as parent")
# Null parent is OK except:
elif val is None:
pass pass
# Ensure that the new parent is not already a child # Ensure that the new parent is not already a child
else: if self.id in self.getUniqueChildren(include_self=False):
kids = self.getUniqueChildren()
if val in kids:
raise ValidationError("Category cannot set a child as parent") raise ValidationError("Category cannot set a child as parent")
# Prohibit certain characters from tree node names
elif attrname == 'name':
val = val.translate({ord(c): None for c in "!@#$%^&*'\"\\/[]{}<>,|+=~`"})
super(InvenTreeTree, self).__setattr__(attrname, val)
def __str__(self): def __str__(self):
""" String representation of a category is the full path to that category """ """ String representation of a category is the full path to that category """

View File

@ -17,6 +17,14 @@ def validate_part_name(value):
) )
def validate_tree_name(value):
""" Prevent illegal characters in tree item names """
for c in "!@#$%^&*'\"\\/[]{}<>,|+=~`\"":
if c in str(value):
raise ValidationError({'name': _('Illegal character in name')})
def validate_overage(value): def validate_overage(value):
""" Validate that a BOM overage string is properly formatted. """ Validate that a BOM overage string is properly formatted.

View File

@ -59,19 +59,29 @@ class TreeSerializer(views.APIView):
return data return data
def get(self, request, *args, **kwargs): def get_items(self):
top_items = self.model.objects.filter(parent=None).order_by('name') return self.model.objects.all()
def generate_tree(self):
nodes = [] nodes = []
items = self.get_items()
# Construct the top-level items
top_items = [i for i in items if i.parent is None]
top_count = 0 top_count = 0
# Construct the top-level items
top_items = [i for i in items if i.parent is None]
for item in top_items: for item in top_items:
nodes.append(self.itemToJson(item)) nodes.append(self.itemToJson(item))
top_count += item.item_count top_count += item.item_count
top = { self.tree = {
'pk': None, 'pk': None,
'text': self.title, 'text': self.title,
'href': self.root_url, 'href': self.root_url,
@ -79,8 +89,13 @@ class TreeSerializer(views.APIView):
'tags': [top_count], 'tags': [top_count],
} }
def get(self, request, *args, **kwargs):
""" Respond to a GET request for the Tree """
self.generate_tree()
response = { response = {
'tree': [top] 'tree': [self.tree]
} }
return JsonResponse(response, safe=False) return JsonResponse(response, safe=False)

View File

@ -6,6 +6,9 @@ Provides a JSON API for the Part app
from __future__ import unicode_literals from __future__ import unicode_literals
from django_filters.rest_framework import DjangoFilterBackend from django_filters.rest_framework import DjangoFilterBackend
from django.conf import settings
from django.db.models import Sum
from rest_framework import status from rest_framework import status
from rest_framework.response import Response from rest_framework.response import Response
@ -15,6 +18,8 @@ from rest_framework import generics, permissions
from django.conf.urls import url, include from django.conf.urls import url, include
from django.urls import reverse from django.urls import reverse
import os
from .models import Part, PartCategory, BomItem, PartStar from .models import Part, PartCategory, BomItem, PartStar
from .serializers import PartSerializer, BomItemSerializer from .serializers import PartSerializer, BomItemSerializer
@ -34,6 +39,9 @@ class PartCategoryTree(TreeSerializer):
def root_url(self): def root_url(self):
return reverse('part-index') return reverse('part-index')
def get_items(self):
return PartCategory.objects.all().prefetch_related('parts', 'children')
class CategoryList(generics.ListCreateAPIView): class CategoryList(generics.ListCreateAPIView):
""" API endpoint for accessing a list of PartCategory objects. """ API endpoint for accessing a list of PartCategory objects.
@ -96,6 +104,58 @@ class PartList(generics.ListCreateAPIView):
serializer_class = PartSerializer serializer_class = PartSerializer
def list(self, request, *args, **kwargs):
"""
Instead of using the DRF serialiser to LIST,
we serialize the objects manuually.
This turns out to be significantly faster.
"""
queryset = self.filter_queryset(self.get_queryset())
data = queryset.values(
'pk',
'category',
'image',
'name',
'IPN',
'description',
'keywords',
'is_template',
'URL',
'units',
'trackable',
'assembly',
'component',
'salable',
'active',
).annotate(
in_stock=Sum('stock_items__quantity'),
)
# TODO - Annotate total being built
# TODO - Annotate total on order
# Reduce the number of lookups we need to do for the part categories
categories = {}
for item in data:
if item['image']:
item['image'] = os.path.join(settings.MEDIA_URL, item['image'])
cat_id = item['category']
if cat_id:
if cat_id not in categories:
categories[cat_id] = PartCategory.objects.get(pk=cat_id).pathstring
item['category__name'] = categories[cat_id]
else:
item['category__name'] = None
return Response(data)
def get_queryset(self): def get_queryset(self):
# Does the user wish to filter by category? # Does the user wish to filter by category?

View File

@ -0,0 +1,19 @@
# Generated by Django 2.2.2 on 2019-06-17 14:42
import InvenTree.validators
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('part', '0007_auto_20190602_1944'),
]
operations = [
migrations.AlterField(
model_name='partcategory',
name='name',
field=models.CharField(max_length=100, unique=True, validators=[InvenTree.validators.validate_tree_name]),
),
]

View File

@ -66,16 +66,24 @@ class PartCategory(InvenTreeTree):
@property @property
def item_count(self): def item_count(self):
return self.partcount return self.partcount()
@property def partcount(self, cascade=True, active=True):
def partcount(self):
""" Return the total part count under this category """ Return the total part count under this category
(including children of child categories) (including children of child categories)
""" """
return len(Part.objects.filter(category__in=self.getUniqueChildren(), cats = [self.id]
active=True))
if cascade:
cats += [cat for cat in self.getUniqueChildren()]
query = Part.objects.filter(category__in=cats)
if active:
query = query.filter(active=True)
return query.count()
@property @property
def has_parts(self): def has_parts(self):

View File

@ -82,10 +82,10 @@ class CategoryTest(TestCase):
self.assertTrue(self.fasteners.has_parts) self.assertTrue(self.fasteners.has_parts)
self.assertFalse(self.transceivers.has_parts) self.assertFalse(self.transceivers.has_parts)
self.assertEqual(self.fasteners.partcount, 2) self.assertEqual(self.fasteners.partcount(), 2)
self.assertEqual(self.capacitors.partcount, 1) self.assertEqual(self.capacitors.partcount(), 1)
self.assertEqual(self.electronics.partcount, 3) self.assertEqual(self.electronics.partcount(), 3)
def test_delete(self): def test_delete(self):
""" Test that category deletion moves the children properly """ """ Test that category deletion moves the children properly """

View File

@ -112,16 +112,25 @@ function loadPartTable(table, url, options={}) {
} }
columns.push({ columns.push({
field: 'full_name', field: 'name',
title: 'Part', title: 'Part',
sortable: true, sortable: true,
formatter: function(value, row, index, field) { formatter: function(value, row, index, field) {
if (row.is_template) { var name = '';
value = '<i>' + value + '</i>';
if (row.IPN) {
name += row.IPN;
name += ' | ';
} }
var display = imageHoverIcon(row.image_url) + renderLink(value, row.url); name += value;
if (row.is_template) {
name = '<i>' + name + '</i>';
}
var display = imageHoverIcon(row.image) + renderLink(name, '/part/' + row.pk + '/');
if (!row.active) { if (!row.active) {
display = display + "<span class='label label-warning' style='float: right;'>INACTIVE</span>"; display = display + "<span class='label label-warning' style='float: right;'>INACTIVE</span>";
@ -146,11 +155,11 @@ function loadPartTable(table, url, options={}) {
columns.push({ columns.push({
sortable: true, sortable: true,
field: 'category_name', field: 'category__name',
title: 'Category', title: 'Category',
formatter: function(value, row, index, field) { formatter: function(value, row, index, field) {
if (row.category) { if (row.category) {
return renderLink(row.category_name, "/part/category/" + row.category + "/"); return renderLink(row.category__name, "/part/category/" + row.category + "/");
} }
else { else {
return ''; return '';
@ -159,13 +168,13 @@ function loadPartTable(table, url, options={}) {
}); });
columns.push({ columns.push({
field: 'total_stock', field: 'in_stock',
title: 'Stock', title: 'Stock',
searchable: false, searchable: false,
sortable: true, sortable: true,
formatter: function(value, row, index, field) { formatter: function(value, row, index, field) {
if (value) { if (value) {
return renderLink(value, row.url + 'stock/'); return renderLink(value, '/part/' + row.pk + '/stock/');
} }
else { else {
return "<span class='label label-warning'>No Stock</span>"; return "<span class='label label-warning'>No Stock</span>";

View File

@ -38,6 +38,9 @@ class StockCategoryTree(TreeSerializer):
def root_url(self): def root_url(self):
return reverse('stock-index') return reverse('stock-index')
def get_items(self):
return StockLocation.objects.all().prefetch_related('stock_items', 'children')
class StockDetail(generics.RetrieveUpdateDestroyAPIView): class StockDetail(generics.RetrieveUpdateDestroyAPIView):
""" API detail endpoint for Stock object """ API detail endpoint for Stock object

View File

@ -0,0 +1,19 @@
# Generated by Django 2.2.2 on 2019-06-17 14:42
import InvenTree.validators
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('stock', '0006_stockitem_purchase_order'),
]
operations = [
migrations.AlterField(
model_name='stocklocation',
name='name',
field=models.CharField(max_length=100, unique=True, validators=[InvenTree.validators.validate_tree_name]),
),
]

View File

@ -49,19 +49,23 @@ class StockLocation(InvenTreeTree):
} }
) )
@property def stock_item_count(self, cascade=True):
def stock_item_count(self):
""" Return the number of StockItem objects which live in or under this category """ Return the number of StockItem objects which live in or under this category
""" """
return StockItem.objects.filter(location__in=self.getUniqueChildren()).count() if cascade:
query = StockItem.objects.filter(location__in=self.getUniqueChildren())
else:
query = StockItem.objects.filter(location=self)
return query.count()
@property @property
def item_count(self): def item_count(self):
""" Simply returns the number of stock items in this location. """ Simply returns the number of stock items in this location.
Required for tree view serializer. Required for tree view serializer.
""" """
return self.stock_item_count return self.stock_item_count()
@receiver(pre_delete, sender=StockLocation, dispatch_uid='stocklocation_delete_log') @receiver(pre_delete, sender=StockLocation, dispatch_uid='stocklocation_delete_log')