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.dispatch import receiver
from .validators import validate_tree_name
class InvenTreeTree(models.Model):
""" Provides an abstracted self-referencing tree model for data categories.
@ -31,7 +33,8 @@ class InvenTreeTree(models.Model):
name = models.CharField(
blank=False,
max_length=100,
unique=True
unique=True,
validators=[validate_tree_name]
)
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 unique is None:
unique = set()
else:
unique.add(self.id)
item = self
if self.parent and self.parent.id not in unique:
self.parent.getUniqueParents(unique)
# Prevent infinite regression
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
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.
If any child items are repeated, the repetitions are omitted.
"""
@ -83,7 +91,8 @@ class InvenTreeTree(models.Model):
if self.id in unique:
return unique
unique.add(self.id)
if include_self:
unique.add(self.id)
# Some magic to get around the limitations of abstract models
contents = ContentType.objects.get_for_model(type(self))
@ -99,14 +108,6 @@ class InvenTreeTree(models.Model):
""" True if there are any children under this item """
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):
""" Returns a list of acceptable parent items within this model
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])
def __setattr__(self, attrname, val):
""" Custom Attribute Setting function
def clean(self):
""" Custom cleaning
Parent:
Setting the parent of an item to its own child results in an infinite loop.
@ -172,28 +173,18 @@ class InvenTreeTree(models.Model):
Tree node names are limited to a reduced character set
"""
if attrname == 'parent_id':
# 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)
elif val == self.id:
super().clean()
# Parent cannot be set to same ID (this would cause looping)
try:
if self.parent.id == self.id:
raise ValidationError("Category cannot set itself as parent")
# Null parent is OK
elif val is None:
pass
# Ensure that the new parent is not already a child
else:
kids = self.getUniqueChildren()
if val in kids:
raise ValidationError("Category cannot set a child as parent")
except:
pass
# 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)
# Ensure that the new parent is not already a child
if self.id in self.getUniqueChildren(include_self=False):
raise ValidationError("Category cannot set a child as parent")
def __str__(self):
""" 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):
""" Validate that a BOM overage string is properly formatted.

View File

@ -59,19 +59,29 @@ class TreeSerializer(views.APIView):
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 = []
items = self.get_items()
# Construct the top-level items
top_items = [i for i in items if i.parent is None]
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:
nodes.append(self.itemToJson(item))
top_count += item.item_count
top = {
self.tree = {
'pk': None,
'text': self.title,
'href': self.root_url,
@ -79,8 +89,13 @@ class TreeSerializer(views.APIView):
'tags': [top_count],
}
def get(self, request, *args, **kwargs):
""" Respond to a GET request for the Tree """
self.generate_tree()
response = {
'tree': [top]
'tree': [self.tree]
}
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 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.response import Response
@ -15,6 +18,8 @@ from rest_framework import generics, permissions
from django.conf.urls import url, include
from django.urls import reverse
import os
from .models import Part, PartCategory, BomItem, PartStar
from .serializers import PartSerializer, BomItemSerializer
@ -34,6 +39,9 @@ class PartCategoryTree(TreeSerializer):
def root_url(self):
return reverse('part-index')
def get_items(self):
return PartCategory.objects.all().prefetch_related('parts', 'children')
class CategoryList(generics.ListCreateAPIView):
""" API endpoint for accessing a list of PartCategory objects.
@ -96,6 +104,58 @@ class PartList(generics.ListCreateAPIView):
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):
# 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
def item_count(self):
return self.partcount
return self.partcount()
@property
def partcount(self):
def partcount(self, cascade=True, active=True):
""" Return the total part count under this category
(including children of child categories)
"""
return len(Part.objects.filter(category__in=self.getUniqueChildren(),
active=True))
cats = [self.id]
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
def has_parts(self):

View File

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

View File

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

View File

@ -38,6 +38,9 @@ class StockCategoryTree(TreeSerializer):
def root_url(self):
return reverse('stock-index')
def get_items(self):
return StockLocation.objects.all().prefetch_related('stock_items', 'children')
class StockDetail(generics.RetrieveUpdateDestroyAPIView):
""" 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):
def stock_item_count(self, cascade=True):
""" 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
def item_count(self):
""" Simply returns the number of stock items in this location.
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')