Merge remote-tracking branch 'inventree/master'

This commit is contained in:
Oliver Walters 2020-04-10 01:12:11 +10:00
commit ba2b1ce581
17 changed files with 1061 additions and 766 deletions

View File

@ -14,6 +14,41 @@ from django.core.exceptions import ValidationError
from django.utils.translation import ugettext as _
from .version import inventreeVersion, inventreeInstanceName
from .settings import MEDIA_URL, STATIC_URL
def getMediaUrl(filename):
"""
Return the qualified access path for the given file,
under the media directory.
"""
return os.path.join(MEDIA_URL, str(filename))
def getStaticUrl(filename):
"""
Return the qualified access path for the given file,
under the static media directory.
"""
return os.path.join(STATIC_URL, str(filename))
def getBlankImage():
"""
Return the qualified path for the 'blank image' placeholder.
"""
return getStaticUrl("img/blank_image.png")
def getBlankThumbnail():
"""
Return the qualified path for the 'blank image' thumbnail placeholder.
"""
return getStaticUrl("img/blank_image.thumbnail.png")
def TestIfImage(img):
@ -66,7 +101,7 @@ def isNull(text):
True if the text looks like a null value
"""
return str(text).strip().lower() in ['top', 'null', 'none', 'empty', 'false', '-1']
return str(text).strip().lower() in ['top', 'null', 'none', 'empty', 'false', '-1', '']
def decimal2string(d):

View File

@ -1,3 +1,4 @@
from django.test import TestCase
import django.core.exceptions as django_exceptions
from django.core.exceptions import ValidationError
@ -7,6 +8,8 @@ from . import helpers
from mptt.exceptions import InvalidMove
from decimal import Decimal
from stock.models import StockLocation
@ -72,6 +75,29 @@ class TestHelpers(TestCase):
self.assertFalse(helpers.str2bool(s))
self.assertFalse(helpers.str2bool(s, test=False))
def test_isnull(self):
for s in ['null', 'none', '', '-1', 'false']:
self.assertTrue(helpers.isNull(s))
for s in ['yes', 'frog', 'llama', 'true']:
self.assertFalse(helpers.isNull(s))
def testStaticUrl(self):
self.assertEqual(helpers.getStaticUrl('test.jpg'), '/static/test.jpg')
self.assertEqual(helpers.getBlankImage(), '/static/img/blank_image.png')
self.assertEqual(helpers.getBlankThumbnail(), '/static/img/blank_image.thumbnail.png')
def testMediaUrl(self):
self.assertEqual(helpers.getMediaUrl('xx/yy.png'), '/media/xx/yy.png')
def testDecimal2String(self):
self.assertEqual(helpers.decimal2string(Decimal('1.2345000')), '1.2345')
self.assertEqual(helpers.decimal2string('test'), 'test')
class TestQuoteWrap(TestCase):
""" Tests for string wrapping """

View File

@ -5,7 +5,7 @@ Provides information on the current InvenTree version
import subprocess
from common.models import InvenTreeSetting
INVENTREE_SW_VERSION = "0.0.10"
INVENTREE_SW_VERSION = "0.0.11_pre"
def inventreeInstanceName():

View File

@ -1,7 +1,36 @@
from __future__ import unicode_literals
import os
from django.apps import AppConfig
from django.db.utils import OperationalError, ProgrammingError
from django.conf import settings
class CompanyConfig(AppConfig):
name = 'company'
def ready(self):
"""
This function is called whenever the Company app is loaded.
"""
self.generate_company_thumbs()
def generate_company_thumbs(self):
from .models import Company
print("InvenTree: Checking Company image thumbnails")
try:
for company in Company.objects.all():
if company.image:
url = company.image.thumbnail.name
loc = os.path.join(settings.MEDIA_ROOT, url)
if not os.path.exists(loc):
print("InvenTree: Generating thumbnail for Company '{c}'".format(c=company.name))
company.image.render_variations(replace=False)
except (OperationalError, ProgrammingError):
print("Could not generate Company thumbnails")

View File

@ -0,0 +1,20 @@
# Generated by Django 2.2.10 on 2020-04-07 01:16
import company.models
from django.db import migrations
import stdimage.models
class Migration(migrations.Migration):
dependencies = [
('company', '0013_auto_20200406_0131'),
]
operations = [
migrations.AlterField(
model_name='company',
name='image',
field=stdimage.models.StdImageField(blank=True, null=True, upload_to=company.models.rename_company_image),
),
]

View File

@ -17,10 +17,12 @@ from django.db.models import Sum
from django.apps import apps
from django.urls import reverse
from django.conf import settings
from markdownx.models import MarkdownxField
from stdimage.models import StdImageField
from InvenTree.helpers import getMediaUrl, getBlankImage, getBlankThumbnail
from InvenTree.fields import InvenTreeURLField, RoundingDecimalField
from InvenTree.status_codes import OrderStatus
from common.models import Currency
@ -90,7 +92,13 @@ class Company(models.Model):
link = InvenTreeURLField(blank=True, help_text=_('Link to external company information'))
image = models.ImageField(upload_to=rename_company_image, max_length=255, null=True, blank=True)
image = StdImageField(
upload_to=rename_company_image,
null=True,
blank=True,
variations={'thumbnail': (128, 128)},
delete_orphans=True,
)
notes = MarkdownxField(blank=True)
@ -110,9 +118,17 @@ class Company(models.Model):
""" Return the URL of the image for this company """
if self.image:
return os.path.join(settings.MEDIA_URL, str(self.image.url))
return getMediaUrl(self.image.url)
else:
return os.path.join(settings.STATIC_URL, 'img/blank_image.png')
return getBlankImage()
def get_thumbnail_url(self):
""" Return the URL for the thumbnail image for this Company """
if self.image:
return getMediaUrl(self.image.thumbnail.url)
else:
return getBlankThumbnail()
@property
def part_count(self):

View File

@ -32,7 +32,7 @@ class CompanySerializer(InvenTreeModelSerializer):
url = serializers.CharField(source='get_absolute_url', read_only=True)
part_count = serializers.CharField(read_only=True)
image = serializers.CharField(source='get_image_url', read_only=True)
image = serializers.CharField(source='get_thumbnail_url', read_only=True)
class Meta:
model = Company
@ -64,7 +64,7 @@ class SupplierPartSerializer(InvenTreeModelSerializer):
part_detail = PartBriefSerializer(source='part', many=False, read_only=True)
supplier_name = serializers.CharField(source='supplier.name', read_only=True)
supplier_logo = serializers.CharField(source='supplier.get_image_url', read_only=True)
supplier_logo = serializers.CharField(source='supplier.get_thumbnail_url', read_only=True)
pricing = serializers.CharField(source='unit_pricing', read_only=True)

View File

@ -46,7 +46,7 @@ InvenTree | {% trans "Company" %} - {{ company.name }}
<col width='25'>
{% if company.website %}
<tr>
<td><span class='fas fa-link'></span></td>
<td><span class='fas fa-globe'></span></td>
<td>{% trans "Website" %}</td>
<td><a href="{{ company.website }}">{{ company.website }}</a></td>
</tr>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -1,7 +1,35 @@
from __future__ import unicode_literals
import os
from django.db.utils import OperationalError, ProgrammingError
from django.apps import AppConfig
from django.conf import settings
class PartConfig(AppConfig):
name = 'part'
def ready(self):
"""
This function is called whenever the Part app is loaded.
"""
self.generate_part_thumbnails()
def generate_part_thumbnails(self):
from .models import Part
print("InvenTree: Checking Part image thumbnails")
try:
for part in Part.objects.all():
if part.image:
url = part.image.thumbnail.name
loc = os.path.join(settings.MEDIA_ROOT, url)
if not os.path.exists(loc):
print("InvenTree: Generating thumbnail for Part '{p}'".format(p=part.name))
part.image.render_variations(replace=False)
except (OperationalError, ProgrammingError):
print("Could not generate Part thumbnails")

View File

@ -10,7 +10,6 @@ import os
from django.utils.translation import gettext_lazy as _
from django.core.exceptions import ValidationError
from django.urls import reverse
from django.conf import settings
from django.db import models, transaction
from django.db.models import Sum
@ -300,9 +299,9 @@ class Part(models.Model):
""" Return the URL of the image for this part """
if self.image:
return os.path.join(settings.MEDIA_URL, str(self.image.url))
return helpers.getMediaUrl(self.image.url)
else:
return os.path.join(settings.STATIC_URL, 'img/blank_image.png')
return helpers.getBlankImage()
def get_thumbnail_url(self):
"""
@ -310,9 +309,9 @@ class Part(models.Model):
"""
if self.image:
return os.path.join(settings.MEDIA_URL, str(self.image.thumbnail.url))
return helpers.getMediaUrl(self.image.thumbnail.url)
else:
return os.path.join(settings.STATIC_URL, 'img/blank_image.thumbnail.png')
return helpers.getBlankThumbnail()
def validate_unique(self, exclude=None):
""" Validate that a part is 'unique'.

View File

@ -14,7 +14,7 @@ from .models import StockItemTracking
from part.models import Part, PartCategory
from .serializers import StockItemSerializer, StockQuantitySerializer
from .serializers import StockItemSerializer
from .serializers import LocationSerializer
from .serializers import StockTrackingSerializer
@ -23,11 +23,12 @@ from InvenTree.helpers import str2bool, isNull
from InvenTree.status_codes import StockStatus
import os
from decimal import Decimal, InvalidOperation
from rest_framework.serializers import ValidationError
from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework import generics, response, filters, permissions
from rest_framework import generics, filters, permissions
class StockCategoryTree(TreeSerializer):
@ -95,144 +96,154 @@ class StockFilter(FilterSet):
fields = ['quantity', 'part', 'location']
class StockStocktake(APIView):
""" Stocktake API endpoint provides stock update of multiple items simultaneously.
The 'action' field tells the type of stock action to perform:
- stocktake: Count the stock item(s)
- remove: Remove the quantity provided from stock
- add: Add the quantity provided from stock
class StockAdjust(APIView):
"""
A generic class for handling stocktake actions.
Subclasses exist for:
- StockCount: count stock items
- StockAdd: add stock items
- StockRemove: remove stock items
- StockTransfer: transfer stock items
"""
permission_classes = [
permissions.IsAuthenticated,
]
def get_items(self, request):
"""
Return a list of items posted to the endpoint.
Will raise validation errors if the items are not
correctly formatted.
"""
_items = []
if 'item' in request.data:
_items = [request.data['item']]
elif 'items' in request.data:
_items = request.data['items']
else:
raise ValidationError({'items': 'Request must contain list of stock items'})
# List of validated items
self.items = []
for entry in _items:
if not type(entry) == dict:
raise ValidationError({'error': 'Improperly formatted data'})
try:
pk = entry.get('pk', None)
item = StockItem.objects.get(pk=pk)
except (ValueError, StockItem.DoesNotExist):
raise ValidationError({'pk': 'Each entry must contain a valid pk field'})
try:
quantity = Decimal(str(entry.get('quantity', None)))
except (ValueError, TypeError, InvalidOperation):
raise ValidationError({'quantity': 'Each entry must contain a valid quantity field'})
if quantity < 0:
raise ValidationError({'quantity': 'Quantity field must not be less than zero'})
self.items.append({
'item': item,
'quantity': quantity
})
self.notes = str(request.data.get('notes', ''))
class StockCount(StockAdjust):
"""
Endpoint for counting stock (performing a stocktake).
"""
def post(self, request, *args, **kwargs):
if 'action' not in request.data:
raise ValidationError({'action': 'Stocktake action must be provided'})
action = request.data['action']
ACTIONS = ['stocktake', 'remove', 'add']
if action not in ACTIONS:
raise ValidationError({'action': 'Action must be one of ' + ','.join(ACTIONS)})
elif 'items[]' not in request.data:
raise ValidationError({'items[]:' 'Request must contain list of items'})
items = []
# Ensure each entry is valid
for entry in request.data['items[]']:
if 'pk' not in entry:
raise ValidationError({'pk': 'Each entry must contain pk field'})
elif 'quantity' not in entry:
raise ValidationError({'quantity': 'Each entry must contain quantity field'})
item = {}
try:
item['item'] = StockItem.objects.get(pk=entry['pk'])
except StockItem.DoesNotExist:
raise ValidationError({'pk': 'No matching StockItem found for pk={pk}'.format(pk=entry['pk'])})
try:
item['quantity'] = int(entry['quantity'])
except ValueError:
raise ValidationError({'quantity': 'Quantity must be an integer'})
if item['quantity'] < 0:
raise ValidationError({'quantity': 'Quantity must be >= 0'})
items.append(item)
# Stocktake notes
notes = ''
if 'notes' in request.data:
notes = request.data['notes']
self.get_items(request)
n = 0
for item in items:
quantity = int(item['quantity'])
for item in self.items:
if action == u'stocktake':
if item['item'].stocktake(quantity, request.user, notes=notes):
n += 1
elif action == u'remove':
if item['item'].take_stock(quantity, request.user, notes=notes):
n += 1
elif action == u'add':
if item['item'].add_stock(quantity, request.user, notes=notes):
if item['item'].stocktake(item['quantity'], request.user, notes=self.notes):
n += 1
return Response({'success': 'Updated stock for {n} items'.format(n=n)})
class StockMove(APIView):
""" API endpoint for performing stock movements """
permission_classes = [
permissions.IsAuthenticated,
]
class StockAdd(StockAdjust):
"""
Endpoint for adding stock
"""
def post(self, request, *args, **kwargs):
self.get_items(request)
n = 0
for item in self.items:
if item['item'].add_stock(item['quantity'], request.user, notes=self.notes):
n += 1
return Response({"success": "Added stock for {n} items".format(n=n)})
class StockRemove(StockAdjust):
"""
Endpoint for removing stock.
"""
def post(self, request, *args, **kwargs):
self.get_items(request)
n = 0
for item in self.items:
if item['item'].take_stock(item['quantity'], request.user, notes=self.notes):
n += 1
return Response({"success": "Removed stock for {n} items".format(n=n)})
class StockTransfer(StockAdjust):
"""
API endpoint for performing stock movements
"""
def post(self, request, *args, **kwargs):
self.get_items(request)
data = request.data
if 'location' not in data:
raise ValidationError({'location': 'Destination must be specified'})
try:
loc_id = int(data.get('location'))
except ValueError:
raise ValidationError({'location': 'Integer ID required'})
location = StockLocation.objects.get(pk=data.get('location', None))
except (ValueError, StockLocation.DoesNotExist):
raise ValidationError({'location': 'Valid location must be specified'})
try:
location = StockLocation.objects.get(pk=loc_id)
except StockLocation.DoesNotExist:
raise ValidationError({'location': 'Location does not exist'})
n = 0
if 'stock' not in data:
raise ValidationError({'stock': 'Stock list must be specified'})
for item in self.items:
stock_list = data.get('stock')
# If quantity is not specified, move the entire stock
if item['quantity'] in [0, None]:
item['quantity'] = item['item'].quantity
if type(stock_list) is not list:
raise ValidationError({'stock': 'Stock must be supplied as a list'})
if item['item'].move(location, self.notes, request.user, quantity=item['quantity']):
n += 1
if 'notes' not in data:
raise ValidationError({'notes': 'Notes field must be supplied'})
for item in stock_list:
try:
stock_id = int(item['pk'])
if 'quantity' in item:
quantity = int(item['quantity'])
else:
# If quantity not supplied, we'll move the entire stock
quantity = None
except ValueError:
# Ignore this one
continue
# Ignore a zero quantity movement
if quantity <= 0:
continue
try:
stock = StockItem.objects.get(pk=stock_id)
except StockItem.DoesNotExist:
continue
if quantity is None:
quantity = stock.quantity
stock.move(location, data.get('notes'), request.user, quantity=quantity)
return Response({'success': 'Moved parts to {loc}'.format(
loc=str(location)
return Response({'success': 'Moved {n} parts to {loc}'.format(
n=n,
loc=str(location),
)})
@ -512,22 +523,6 @@ class StockList(generics.ListCreateAPIView):
]
class StockStocktakeEndpoint(generics.UpdateAPIView):
""" API endpoint for performing stocktake """
queryset = StockItem.objects.all()
serializer_class = StockQuantitySerializer
permission_classes = (permissions.IsAuthenticated,)
def update(self, request, *args, **kwargs):
object = self.get_object()
object.stocktake(request.data['quantity'], request.user)
serializer = self.get_serializer(object)
return response.Response(serializer.data)
class StockTrackingList(generics.ListCreateAPIView):
""" API endpoint for list view of StockItemTracking objects.
@ -591,8 +586,10 @@ stock_api_urls = [
url(r'location/', include(location_endpoints)),
# These JSON endpoints have been replaced (for now) with server-side form rendering - 02/06/2019
# url(r'stocktake/?', StockStocktake.as_view(), name='api-stock-stocktake'),
# url(r'move/?', StockMove.as_view(), name='api-stock-move'),
url(r'count/?', StockCount.as_view(), name='api-stock-count'),
url(r'add/?', StockAdd.as_view(), name='api-stock-add'),
url(r'remove/?', StockRemove.as_view(), name='api-stock-remove'),
url(r'transfer/?', StockTransfer.as_view(), name='api-stock-transfer'),
url(r'track/?', StockTrackingList.as_view(), name='api-stock-track'),

View File

@ -11,7 +11,7 @@
{% if item.serialized %}
<p><i>{{ item.part.full_name}} # {{ item.serial }}</i></p>
{% else %}
<p><i>{{ item.quantity }} &times {{ item.part.full_name }}</i></p>
<p><i>{% decimal item.quantity %} &times {{ item.part.full_name }}</i></p>
{% endif %}
<p>
<div class='btn-group'>

View File

@ -63,3 +63,119 @@ class StockItemTest(APITestCase):
def test_get_stock_list(self):
response = self.client.get(self.list_url, format='json')
self.assertEqual(response.status_code, status.HTTP_200_OK)
class StocktakeTest(APITestCase):
"""
Series of tests for the Stocktake API
"""
fixtures = [
'category',
'part',
'company',
'location',
'supplier_part',
'stock',
]
def setUp(self):
User = get_user_model()
User.objects.create_user('testuser', 'test@testing.com', 'password')
self.client.login(username='testuser', password='password')
def doPost(self, url, data={}):
response = self.client.post(url, data=data, format='json')
return response
def test_action(self):
"""
Test each stocktake action endpoint,
for validation
"""
for endpoint in ['api-stock-count', 'api-stock-add', 'api-stock-remove']:
url = reverse(endpoint)
data = {}
# POST with a valid action
response = self.doPost(url, data)
self.assertContains(response, "must contain list", status_code=status.HTTP_400_BAD_REQUEST)
data['items'] = [{
'no': 'aa'
}]
# POST without a PK
response = self.doPost(url, data)
self.assertContains(response, 'must contain a valid pk', status_code=status.HTTP_400_BAD_REQUEST)
# POST with a PK but no quantity
data['items'] = [{
'pk': 10
}]
response = self.doPost(url, data)
self.assertContains(response, 'must contain a valid pk', status_code=status.HTTP_400_BAD_REQUEST)
data['items'] = [{
'pk': 1234
}]
response = self.doPost(url, data)
self.assertContains(response, 'must contain a valid quantity', status_code=status.HTTP_400_BAD_REQUEST)
data['items'] = [{
'pk': 1234,
'quantity': '10x0d'
}]
response = self.doPost(url, data)
self.assertContains(response, 'must contain a valid quantity', status_code=status.HTTP_400_BAD_REQUEST)
data['items'] = [{
'pk': 1234,
'quantity': "-1.234"
}]
response = self.doPost(url, data)
self.assertContains(response, 'must not be less than zero', status_code=status.HTTP_400_BAD_REQUEST)
# Test with a single item
data = {
'item': {
'pk': 1234,
'quantity': '10',
}
}
response = self.doPost(url, data)
self.assertEqual(response.status_code, status.HTTP_200_OK)
def test_transfer(self):
"""
Test stock transfers
"""
data = {
'item': {
'pk': 1234,
'quantity': 10,
},
'location': 1,
'notes': "Moving to a new location"
}
url = reverse('api-stock-transfer')
response = self.doPost(url, data)
self.assertContains(response, "Moved 1 parts to", status_code=status.HTTP_200_OK)
# Now try one which will fail due to a bad location
data['location'] = 'not a location'
response = self.doPost(url, data)
self.assertContains(response, 'Valid location must be specified', status_code=status.HTTP_400_BAD_REQUEST)