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
ba2b1ce581
@ -14,6 +14,41 @@ from django.core.exceptions import ValidationError
|
|||||||
from django.utils.translation import ugettext as _
|
from django.utils.translation import ugettext as _
|
||||||
|
|
||||||
from .version import inventreeVersion, inventreeInstanceName
|
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):
|
def TestIfImage(img):
|
||||||
@ -66,7 +101,7 @@ def isNull(text):
|
|||||||
True if the text looks like a null value
|
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):
|
def decimal2string(d):
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
import django.core.exceptions as django_exceptions
|
import django.core.exceptions as django_exceptions
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
@ -7,6 +8,8 @@ from . import helpers
|
|||||||
|
|
||||||
from mptt.exceptions import InvalidMove
|
from mptt.exceptions import InvalidMove
|
||||||
|
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
from stock.models import StockLocation
|
from stock.models import StockLocation
|
||||||
|
|
||||||
|
|
||||||
@ -72,6 +75,29 @@ class TestHelpers(TestCase):
|
|||||||
self.assertFalse(helpers.str2bool(s))
|
self.assertFalse(helpers.str2bool(s))
|
||||||
self.assertFalse(helpers.str2bool(s, test=False))
|
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):
|
class TestQuoteWrap(TestCase):
|
||||||
""" Tests for string wrapping """
|
""" Tests for string wrapping """
|
||||||
|
@ -5,7 +5,7 @@ Provides information on the current InvenTree version
|
|||||||
import subprocess
|
import subprocess
|
||||||
from common.models import InvenTreeSetting
|
from common.models import InvenTreeSetting
|
||||||
|
|
||||||
INVENTREE_SW_VERSION = "0.0.10"
|
INVENTREE_SW_VERSION = "0.0.11_pre"
|
||||||
|
|
||||||
|
|
||||||
def inventreeInstanceName():
|
def inventreeInstanceName():
|
||||||
|
@ -1,7 +1,36 @@
|
|||||||
from __future__ import unicode_literals
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
from django.apps import AppConfig
|
from django.apps import AppConfig
|
||||||
|
from django.db.utils import OperationalError, ProgrammingError
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
|
||||||
class CompanyConfig(AppConfig):
|
class CompanyConfig(AppConfig):
|
||||||
name = 'company'
|
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")
|
||||||
|
20
InvenTree/company/migrations/0014_auto_20200407_0116.py
Normal file
20
InvenTree/company/migrations/0014_auto_20200407_0116.py
Normal 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),
|
||||||
|
),
|
||||||
|
]
|
@ -17,10 +17,12 @@ from django.db.models import Sum
|
|||||||
|
|
||||||
from django.apps import apps
|
from django.apps import apps
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.conf import settings
|
|
||||||
|
|
||||||
from markdownx.models import MarkdownxField
|
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.fields import InvenTreeURLField, RoundingDecimalField
|
||||||
from InvenTree.status_codes import OrderStatus
|
from InvenTree.status_codes import OrderStatus
|
||||||
from common.models import Currency
|
from common.models import Currency
|
||||||
@ -90,7 +92,13 @@ class Company(models.Model):
|
|||||||
|
|
||||||
link = InvenTreeURLField(blank=True, help_text=_('Link to external company information'))
|
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)
|
notes = MarkdownxField(blank=True)
|
||||||
|
|
||||||
@ -110,9 +118,17 @@ class Company(models.Model):
|
|||||||
""" Return the URL of the image for this company """
|
""" Return the URL of the image for this company """
|
||||||
|
|
||||||
if self.image:
|
if self.image:
|
||||||
return os.path.join(settings.MEDIA_URL, str(self.image.url))
|
return getMediaUrl(self.image.url)
|
||||||
else:
|
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
|
@property
|
||||||
def part_count(self):
|
def part_count(self):
|
||||||
|
@ -32,7 +32,7 @@ class CompanySerializer(InvenTreeModelSerializer):
|
|||||||
url = serializers.CharField(source='get_absolute_url', read_only=True)
|
url = serializers.CharField(source='get_absolute_url', read_only=True)
|
||||||
part_count = serializers.CharField(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:
|
class Meta:
|
||||||
model = Company
|
model = Company
|
||||||
@ -64,7 +64,7 @@ class SupplierPartSerializer(InvenTreeModelSerializer):
|
|||||||
part_detail = PartBriefSerializer(source='part', many=False, read_only=True)
|
part_detail = PartBriefSerializer(source='part', many=False, read_only=True)
|
||||||
|
|
||||||
supplier_name = serializers.CharField(source='supplier.name', 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)
|
pricing = serializers.CharField(source='unit_pricing', read_only=True)
|
||||||
|
|
||||||
|
@ -46,7 +46,7 @@ InvenTree | {% trans "Company" %} - {{ company.name }}
|
|||||||
<col width='25'>
|
<col width='25'>
|
||||||
{% if company.website %}
|
{% if company.website %}
|
||||||
<tr>
|
<tr>
|
||||||
<td><span class='fas fa-link'></span></td>
|
<td><span class='fas fa-globe'></span></td>
|
||||||
<td>{% trans "Website" %}</td>
|
<td>{% trans "Website" %}</td>
|
||||||
<td><a href="{{ company.website }}">{{ company.website }}</a></td>
|
<td><a href="{{ company.website }}">{{ company.website }}</a></td>
|
||||||
</tr>
|
</tr>
|
||||||
|
Binary file not shown.
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
@ -1,7 +1,35 @@
|
|||||||
from __future__ import unicode_literals
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
from django.db.utils import OperationalError, ProgrammingError
|
||||||
from django.apps import AppConfig
|
from django.apps import AppConfig
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
|
||||||
class PartConfig(AppConfig):
|
class PartConfig(AppConfig):
|
||||||
name = 'part'
|
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")
|
||||||
|
@ -10,7 +10,6 @@ import os
|
|||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.conf import settings
|
|
||||||
|
|
||||||
from django.db import models, transaction
|
from django.db import models, transaction
|
||||||
from django.db.models import Sum
|
from django.db.models import Sum
|
||||||
@ -300,9 +299,9 @@ class Part(models.Model):
|
|||||||
""" Return the URL of the image for this part """
|
""" Return the URL of the image for this part """
|
||||||
|
|
||||||
if self.image:
|
if self.image:
|
||||||
return os.path.join(settings.MEDIA_URL, str(self.image.url))
|
return helpers.getMediaUrl(self.image.url)
|
||||||
else:
|
else:
|
||||||
return os.path.join(settings.STATIC_URL, 'img/blank_image.png')
|
return helpers.getBlankImage()
|
||||||
|
|
||||||
def get_thumbnail_url(self):
|
def get_thumbnail_url(self):
|
||||||
"""
|
"""
|
||||||
@ -310,9 +309,9 @@ class Part(models.Model):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
if self.image:
|
if self.image:
|
||||||
return os.path.join(settings.MEDIA_URL, str(self.image.thumbnail.url))
|
return helpers.getMediaUrl(self.image.thumbnail.url)
|
||||||
else:
|
else:
|
||||||
return os.path.join(settings.STATIC_URL, 'img/blank_image.thumbnail.png')
|
return helpers.getBlankThumbnail()
|
||||||
|
|
||||||
def validate_unique(self, exclude=None):
|
def validate_unique(self, exclude=None):
|
||||||
""" Validate that a part is 'unique'.
|
""" Validate that a part is 'unique'.
|
||||||
|
@ -14,7 +14,7 @@ from .models import StockItemTracking
|
|||||||
|
|
||||||
from part.models import Part, PartCategory
|
from part.models import Part, PartCategory
|
||||||
|
|
||||||
from .serializers import StockItemSerializer, StockQuantitySerializer
|
from .serializers import StockItemSerializer
|
||||||
from .serializers import LocationSerializer
|
from .serializers import LocationSerializer
|
||||||
from .serializers import StockTrackingSerializer
|
from .serializers import StockTrackingSerializer
|
||||||
|
|
||||||
@ -23,11 +23,12 @@ from InvenTree.helpers import str2bool, isNull
|
|||||||
from InvenTree.status_codes import StockStatus
|
from InvenTree.status_codes import StockStatus
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
from decimal import Decimal, InvalidOperation
|
||||||
|
|
||||||
from rest_framework.serializers import ValidationError
|
from rest_framework.serializers import ValidationError
|
||||||
from rest_framework.views import APIView
|
from rest_framework.views import APIView
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
from rest_framework import generics, response, filters, permissions
|
from rest_framework import generics, filters, permissions
|
||||||
|
|
||||||
|
|
||||||
class StockCategoryTree(TreeSerializer):
|
class StockCategoryTree(TreeSerializer):
|
||||||
@ -95,144 +96,154 @@ class StockFilter(FilterSet):
|
|||||||
fields = ['quantity', 'part', 'location']
|
fields = ['quantity', 'part', 'location']
|
||||||
|
|
||||||
|
|
||||||
class StockStocktake(APIView):
|
class StockAdjust(APIView):
|
||||||
""" Stocktake API endpoint provides stock update of multiple items simultaneously.
|
"""
|
||||||
The 'action' field tells the type of stock action to perform:
|
A generic class for handling stocktake actions.
|
||||||
- stocktake: Count the stock item(s)
|
|
||||||
- remove: Remove the quantity provided from stock
|
Subclasses exist for:
|
||||||
- add: Add the quantity provided from stock
|
|
||||||
|
- StockCount: count stock items
|
||||||
|
- StockAdd: add stock items
|
||||||
|
- StockRemove: remove stock items
|
||||||
|
- StockTransfer: transfer stock items
|
||||||
"""
|
"""
|
||||||
|
|
||||||
permission_classes = [
|
permission_classes = [
|
||||||
permissions.IsAuthenticated,
|
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):
|
def post(self, request, *args, **kwargs):
|
||||||
|
|
||||||
if 'action' not in request.data:
|
self.get_items(request)
|
||||||
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']
|
|
||||||
|
|
||||||
n = 0
|
n = 0
|
||||||
|
|
||||||
for item in items:
|
for item in self.items:
|
||||||
quantity = int(item['quantity'])
|
|
||||||
|
|
||||||
if action == u'stocktake':
|
if item['item'].stocktake(item['quantity'], request.user, notes=self.notes):
|
||||||
if item['item'].stocktake(quantity, request.user, notes=notes):
|
n += 1
|
||||||
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):
|
|
||||||
n += 1
|
|
||||||
|
|
||||||
return Response({'success': 'Updated stock for {n} items'.format(n=n)})
|
return Response({'success': 'Updated stock for {n} items'.format(n=n)})
|
||||||
|
|
||||||
|
|
||||||
class StockMove(APIView):
|
class StockAdd(StockAdjust):
|
||||||
""" API endpoint for performing stock movements """
|
"""
|
||||||
|
Endpoint for adding stock
|
||||||
permission_classes = [
|
"""
|
||||||
permissions.IsAuthenticated,
|
|
||||||
]
|
|
||||||
|
|
||||||
def post(self, request, *args, **kwargs):
|
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
|
data = request.data
|
||||||
|
|
||||||
if 'location' not in data:
|
|
||||||
raise ValidationError({'location': 'Destination must be specified'})
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
loc_id = int(data.get('location'))
|
location = StockLocation.objects.get(pk=data.get('location', None))
|
||||||
except ValueError:
|
except (ValueError, StockLocation.DoesNotExist):
|
||||||
raise ValidationError({'location': 'Integer ID required'})
|
raise ValidationError({'location': 'Valid location must be specified'})
|
||||||
|
|
||||||
try:
|
n = 0
|
||||||
location = StockLocation.objects.get(pk=loc_id)
|
|
||||||
except StockLocation.DoesNotExist:
|
|
||||||
raise ValidationError({'location': 'Location does not exist'})
|
|
||||||
|
|
||||||
if 'stock' not in data:
|
for item in self.items:
|
||||||
raise ValidationError({'stock': 'Stock list must be specified'})
|
|
||||||
|
|
||||||
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:
|
if item['item'].move(location, self.notes, request.user, quantity=item['quantity']):
|
||||||
raise ValidationError({'stock': 'Stock must be supplied as a list'})
|
n += 1
|
||||||
|
|
||||||
if 'notes' not in data:
|
return Response({'success': 'Moved {n} parts to {loc}'.format(
|
||||||
raise ValidationError({'notes': 'Notes field must be supplied'})
|
n=n,
|
||||||
|
loc=str(location),
|
||||||
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)
|
|
||||||
)})
|
)})
|
||||||
|
|
||||||
|
|
||||||
@ -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):
|
class StockTrackingList(generics.ListCreateAPIView):
|
||||||
""" API endpoint for list view of StockItemTracking objects.
|
""" API endpoint for list view of StockItemTracking objects.
|
||||||
|
|
||||||
@ -591,8 +586,10 @@ stock_api_urls = [
|
|||||||
url(r'location/', include(location_endpoints)),
|
url(r'location/', include(location_endpoints)),
|
||||||
|
|
||||||
# These JSON endpoints have been replaced (for now) with server-side form rendering - 02/06/2019
|
# 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'count/?', StockCount.as_view(), name='api-stock-count'),
|
||||||
# url(r'move/?', StockMove.as_view(), name='api-stock-move'),
|
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'),
|
url(r'track/?', StockTrackingList.as_view(), name='api-stock-track'),
|
||||||
|
|
||||||
|
@ -11,7 +11,7 @@
|
|||||||
{% if item.serialized %}
|
{% if item.serialized %}
|
||||||
<p><i>{{ item.part.full_name}} # {{ item.serial }}</i></p>
|
<p><i>{{ item.part.full_name}} # {{ item.serial }}</i></p>
|
||||||
{% else %}
|
{% else %}
|
||||||
<p><i>{{ item.quantity }} × {{ item.part.full_name }}</i></p>
|
<p><i>{% decimal item.quantity %} × {{ item.part.full_name }}</i></p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<p>
|
<p>
|
||||||
<div class='btn-group'>
|
<div class='btn-group'>
|
||||||
|
@ -63,3 +63,119 @@ class StockItemTest(APITestCase):
|
|||||||
def test_get_stock_list(self):
|
def test_get_stock_list(self):
|
||||||
response = self.client.get(self.list_url, format='json')
|
response = self.client.get(self.list_url, format='json')
|
||||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
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)
|
||||||
|
Loading…
Reference in New Issue
Block a user