diff --git a/InvenTree/InvenTree/models.py b/InvenTree/InvenTree/models.py index a7fc93bf3f..646515ce70 100644 --- a/InvenTree/InvenTree/models.py +++ b/InvenTree/InvenTree/models.py @@ -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 """ diff --git a/InvenTree/InvenTree/validators.py b/InvenTree/InvenTree/validators.py index 0e1622a49a..36eda4d451 100644 --- a/InvenTree/InvenTree/validators.py +++ b/InvenTree/InvenTree/validators.py @@ -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. diff --git a/InvenTree/InvenTree/views.py b/InvenTree/InvenTree/views.py index 15f93c9133..a564705ac2 100644 --- a/InvenTree/InvenTree/views.py +++ b/InvenTree/InvenTree/views.py @@ -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) diff --git a/InvenTree/part/api.py b/InvenTree/part/api.py index d0beee54ce..22f371c379 100644 --- a/InvenTree/part/api.py +++ b/InvenTree/part/api.py @@ -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? diff --git a/InvenTree/part/migrations/0008_auto_20190618_0042.py b/InvenTree/part/migrations/0008_auto_20190618_0042.py new file mode 100644 index 0000000000..c9e0699f9b --- /dev/null +++ b/InvenTree/part/migrations/0008_auto_20190618_0042.py @@ -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]), + ), + ] diff --git a/InvenTree/part/models.py b/InvenTree/part/models.py index 38acae3360..7d5476db5d 100644 --- a/InvenTree/part/models.py +++ b/InvenTree/part/models.py @@ -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): diff --git a/InvenTree/part/test_category.py b/InvenTree/part/test_category.py index ea52396342..3b3fe36b08 100644 --- a/InvenTree/part/test_category.py +++ b/InvenTree/part/test_category.py @@ -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 """ diff --git a/InvenTree/static/script/inventree/part.js b/InvenTree/static/script/inventree/part.js index d56a8d4769..83d21aa074 100644 --- a/InvenTree/static/script/inventree/part.js +++ b/InvenTree/static/script/inventree/part.js @@ -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 = '' + value + ''; + 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 = '' + name + ''; + } + + var display = imageHoverIcon(row.image) + renderLink(name, '/part/' + row.pk + '/'); if (!row.active) { display = display + "INACTIVE"; @@ -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 "No Stock"; diff --git a/InvenTree/stock/api.py b/InvenTree/stock/api.py index 092724407f..8240c6b837 100644 --- a/InvenTree/stock/api.py +++ b/InvenTree/stock/api.py @@ -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 diff --git a/InvenTree/stock/migrations/0007_auto_20190618_0042.py b/InvenTree/stock/migrations/0007_auto_20190618_0042.py new file mode 100644 index 0000000000..862ea94273 --- /dev/null +++ b/InvenTree/stock/migrations/0007_auto_20190618_0042.py @@ -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]), + ), + ] diff --git a/InvenTree/stock/models.py b/InvenTree/stock/models.py index a53e61f86d..e992b4eb9f 100644 --- a/InvenTree/stock/models.py +++ b/InvenTree/stock/models.py @@ -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')