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
f417ddb8e0
@ -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 """
|
||||
|
@ -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.
|
||||
|
||||
|
@ -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)
|
||||
|
@ -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?
|
||||
|
19
InvenTree/part/migrations/0008_auto_20190618_0042.py
Normal file
19
InvenTree/part/migrations/0008_auto_20190618_0042.py
Normal 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]),
|
||||
),
|
||||
]
|
@ -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):
|
||||
|
@ -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 """
|
||||
|
@ -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>";
|
||||
|
@ -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
|
||||
|
19
InvenTree/stock/migrations/0007_auto_20190618_0042.py
Normal file
19
InvenTree/stock/migrations/0007_auto_20190618_0042.py
Normal 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]),
|
||||
),
|
||||
]
|
@ -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')
|
||||
|
Loading…
Reference in New Issue
Block a user