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.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 """
|
||||||
|
|
||||||
|
@ -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.
|
||||||
|
|
||||||
|
@ -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)
|
||||||
|
@ -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?
|
||||||
|
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
|
@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):
|
||||||
|
@ -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 """
|
||||||
|
@ -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>";
|
||||||
|
@ -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
|
||||||
|
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, 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')
|
||||||
|
Loading…
Reference in New Issue
Block a user