mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +00:00
Merge pull request #2198 from SchrodingersGat/stock-item-forms
Stock item forms
This commit is contained in:
commit
083967b156
@ -1,7 +1,7 @@
|
|||||||
/*
|
/*
|
||||||
* Add a cached alert message to sesion storage
|
* Add a cached alert message to sesion storage
|
||||||
*/
|
*/
|
||||||
function addCachedAlert(message, style) {
|
function addCachedAlert(message, options={}) {
|
||||||
|
|
||||||
var alerts = sessionStorage.getItem('inventree-alerts');
|
var alerts = sessionStorage.getItem('inventree-alerts');
|
||||||
|
|
||||||
@ -13,7 +13,8 @@ function addCachedAlert(message, style) {
|
|||||||
|
|
||||||
alerts.push({
|
alerts.push({
|
||||||
message: message,
|
message: message,
|
||||||
style: style
|
style: options.style || 'success',
|
||||||
|
icon: options.icon,
|
||||||
});
|
});
|
||||||
|
|
||||||
sessionStorage.setItem('inventree-alerts', JSON.stringify(alerts));
|
sessionStorage.setItem('inventree-alerts', JSON.stringify(alerts));
|
||||||
@ -31,13 +32,13 @@ function clearCachedAlerts() {
|
|||||||
/*
|
/*
|
||||||
* Display an alert, or cache to display on reload
|
* Display an alert, or cache to display on reload
|
||||||
*/
|
*/
|
||||||
function showAlertOrCache(message, style, cache=false) {
|
function showAlertOrCache(message, cache, options={}) {
|
||||||
|
|
||||||
if (cache) {
|
if (cache) {
|
||||||
addCachedAlert(message, style);
|
addCachedAlert(message, options);
|
||||||
} else {
|
} else {
|
||||||
|
|
||||||
showMessage(message, {style: style});
|
showMessage(message, options);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -50,7 +51,13 @@ function showCachedAlerts() {
|
|||||||
var alerts = JSON.parse(sessionStorage.getItem('inventree-alerts')) || [];
|
var alerts = JSON.parse(sessionStorage.getItem('inventree-alerts')) || [];
|
||||||
|
|
||||||
alerts.forEach(function(alert) {
|
alerts.forEach(function(alert) {
|
||||||
showMessage(alert.message, {style: alert.style});
|
showMessage(
|
||||||
|
alert.message,
|
||||||
|
{
|
||||||
|
style: alert.style || 'success',
|
||||||
|
icon: alert.icon,
|
||||||
|
}
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
clearCachedAlerts();
|
clearCachedAlerts();
|
||||||
|
@ -134,7 +134,15 @@ src="{% static 'img/blank_image.png' %}"
|
|||||||
|
|
||||||
<div class='panel panel-hidden' id='panel-stock'>
|
<div class='panel panel-hidden' id='panel-stock'>
|
||||||
<div class='panel-heading'>
|
<div class='panel-heading'>
|
||||||
<h4>{% trans "Supplier Part Stock" %}</h4>
|
<span class='d-flex flex-wrap'>
|
||||||
|
<h4>{% trans "Supplier Part Stock" %}</h4>
|
||||||
|
{% include "spacer.html" %}
|
||||||
|
<div class='btn-group' role='group'>
|
||||||
|
<button type='button' class='btn btn-success' id='item-create' title='{% trans "Create new stock item" %}'>
|
||||||
|
<span class='fas fa-plus-circle'></span> {% trans "New Stock Item" %}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class='panel-content'>
|
<div class='panel-content'>
|
||||||
{% include "stock_table.html" %}
|
{% include "stock_table.html" %}
|
||||||
@ -314,7 +322,6 @@ $("#item-create").click(function() {
|
|||||||
part: {{ part.part.id }},
|
part: {{ part.part.id }},
|
||||||
supplier_part: {{ part.id }},
|
supplier_part: {{ part.id }},
|
||||||
},
|
},
|
||||||
reload: true,
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -50,7 +50,7 @@
|
|||||||
<h4>{% trans "Received Items" %}</h4>
|
<h4>{% trans "Received Items" %}</h4>
|
||||||
</div>
|
</div>
|
||||||
<div class='panel-content'>
|
<div class='panel-content'>
|
||||||
{% include "stock_table.html" with prevent_new_stock=True %}
|
{% include "stock_table.html" %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@ -120,7 +120,15 @@
|
|||||||
|
|
||||||
<div class='panel panel-hidden' id='panel-part-stock'>
|
<div class='panel panel-hidden' id='panel-part-stock'>
|
||||||
<div class='panel-heading'>
|
<div class='panel-heading'>
|
||||||
<h4>{% trans "Part Stock" %}</h4>
|
<div class='d-flex flex-wrap'>
|
||||||
|
<h4>{% trans "Part Stock" %}</h4>
|
||||||
|
{% include "spacer.html" %}
|
||||||
|
<div class='btn-group' role='group'>
|
||||||
|
<button type='button' class='btn btn-success' id='new-stock-item' title='{% trans "Create new stock item" %}'>
|
||||||
|
<span class='fas fa-plus-circle'></span> {% trans "New Stock Item" %}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class='panel-content'>
|
<div class='panel-content'>
|
||||||
{% if part.is_template %}
|
{% if part.is_template %}
|
||||||
@ -876,11 +884,13 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
onPanelLoad("part-stock", function() {
|
onPanelLoad("part-stock", function() {
|
||||||
$('#add-stock-item').click(function () {
|
$('#new-stock-item').click(function () {
|
||||||
createNewStockItem({
|
createNewStockItem({
|
||||||
reload: true,
|
|
||||||
data: {
|
data: {
|
||||||
part: {{ part.id }},
|
part: {{ part.id }},
|
||||||
|
{% if part.default_location %}
|
||||||
|
location: {{ part.default_location.pk }},
|
||||||
|
{% endif %}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@ -908,7 +918,6 @@
|
|||||||
|
|
||||||
$('#item-create').click(function () {
|
$('#item-create').click(function () {
|
||||||
createNewStockItem({
|
createNewStockItem({
|
||||||
reload: true,
|
|
||||||
data: {
|
data: {
|
||||||
part: {{ part.id }},
|
part: {{ part.id }},
|
||||||
}
|
}
|
||||||
|
@ -7,42 +7,44 @@ from __future__ import unicode_literals
|
|||||||
|
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
from django.core.exceptions import ValidationError as DjangoValidationError
|
||||||
from django.conf.urls import url, include
|
from django.conf.urls import url, include
|
||||||
from django.http import JsonResponse
|
from django.http import JsonResponse
|
||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
|
from django.db import transaction
|
||||||
|
from django.utils.translation import ugettext_lazy as _
|
||||||
|
|
||||||
|
from django_filters.rest_framework import DjangoFilterBackend
|
||||||
|
from django_filters import rest_framework as rest_filters
|
||||||
|
|
||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
from rest_framework.serializers import ValidationError
|
from rest_framework.serializers import ValidationError
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
from rest_framework import generics, filters
|
from rest_framework import generics, filters
|
||||||
|
|
||||||
from django_filters.rest_framework import DjangoFilterBackend
|
import common.settings
|
||||||
from django_filters import rest_framework as rest_filters
|
import common.models
|
||||||
|
|
||||||
from .models import StockLocation, StockItem
|
|
||||||
from .models import StockItemTracking
|
|
||||||
from .models import StockItemAttachment
|
|
||||||
from .models import StockItemTestResult
|
|
||||||
|
|
||||||
from part.models import BomItem, Part, PartCategory
|
|
||||||
from part.serializers import PartBriefSerializer
|
|
||||||
|
|
||||||
from company.models import Company, SupplierPart
|
from company.models import Company, SupplierPart
|
||||||
from company.serializers import CompanySerializer, SupplierPartSerializer
|
from company.serializers import CompanySerializer, SupplierPartSerializer
|
||||||
|
|
||||||
|
from InvenTree.helpers import str2bool, isNull, extract_serial_numbers
|
||||||
|
from InvenTree.api import AttachmentMixin
|
||||||
|
from InvenTree.filters import InvenTreeOrderingFilter
|
||||||
|
|
||||||
from order.models import PurchaseOrder
|
from order.models import PurchaseOrder
|
||||||
from order.models import SalesOrder, SalesOrderAllocation
|
from order.models import SalesOrder, SalesOrderAllocation
|
||||||
from order.serializers import POSerializer
|
from order.serializers import POSerializer
|
||||||
|
|
||||||
import common.settings
|
from part.models import BomItem, Part, PartCategory
|
||||||
import common.models
|
from part.serializers import PartBriefSerializer
|
||||||
|
|
||||||
|
from stock.models import StockLocation, StockItem
|
||||||
|
from stock.models import StockItemTracking
|
||||||
|
from stock.models import StockItemAttachment
|
||||||
|
from stock.models import StockItemTestResult
|
||||||
import stock.serializers as StockSerializers
|
import stock.serializers as StockSerializers
|
||||||
|
|
||||||
from InvenTree.helpers import str2bool, isNull
|
|
||||||
from InvenTree.api import AttachmentMixin
|
|
||||||
from InvenTree.filters import InvenTreeOrderingFilter
|
|
||||||
|
|
||||||
|
|
||||||
class StockDetail(generics.RetrieveUpdateDestroyAPIView):
|
class StockDetail(generics.RetrieveUpdateDestroyAPIView):
|
||||||
""" API detail endpoint for Stock object
|
""" API detail endpoint for Stock object
|
||||||
@ -99,6 +101,27 @@ class StockDetail(generics.RetrieveUpdateDestroyAPIView):
|
|||||||
instance.mark_for_deletion()
|
instance.mark_for_deletion()
|
||||||
|
|
||||||
|
|
||||||
|
class StockItemSerialize(generics.CreateAPIView):
|
||||||
|
"""
|
||||||
|
API endpoint for serializing a stock item
|
||||||
|
"""
|
||||||
|
|
||||||
|
queryset = StockItem.objects.none()
|
||||||
|
serializer_class = StockSerializers.SerializeStockItemSerializer
|
||||||
|
|
||||||
|
def get_serializer_context(self):
|
||||||
|
|
||||||
|
context = super().get_serializer_context()
|
||||||
|
context['request'] = self.request
|
||||||
|
|
||||||
|
try:
|
||||||
|
context['item'] = StockItem.objects.get(pk=self.kwargs.get('pk', None))
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return context
|
||||||
|
|
||||||
|
|
||||||
class StockAdjustView(generics.CreateAPIView):
|
class StockAdjustView(generics.CreateAPIView):
|
||||||
"""
|
"""
|
||||||
A generic class for handling stocktake actions.
|
A generic class for handling stocktake actions.
|
||||||
@ -380,28 +403,91 @@ class StockList(generics.ListCreateAPIView):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
user = request.user
|
user = request.user
|
||||||
|
data = request.data
|
||||||
|
|
||||||
serializer = self.get_serializer(data=request.data)
|
serializer = self.get_serializer(data=data)
|
||||||
serializer.is_valid(raise_exception=True)
|
serializer.is_valid(raise_exception=True)
|
||||||
|
|
||||||
item = serializer.save()
|
# Check if a set of serial numbers was provided
|
||||||
|
serial_numbers = data.get('serial_numbers', '')
|
||||||
|
|
||||||
# A location was *not* specified - try to infer it
|
quantity = data.get('quantity', None)
|
||||||
if 'location' not in request.data:
|
|
||||||
item.location = item.part.get_default_location()
|
|
||||||
|
|
||||||
# An expiry date was *not* specified - try to infer it!
|
if quantity is None:
|
||||||
if 'expiry_date' not in request.data:
|
raise ValidationError({
|
||||||
|
'quantity': _('Quantity is required'),
|
||||||
|
})
|
||||||
|
|
||||||
if item.part.default_expiry > 0:
|
notes = data.get('notes', '')
|
||||||
item.expiry_date = datetime.now().date() + timedelta(days=item.part.default_expiry)
|
|
||||||
|
|
||||||
# Finally, save the item
|
serials = None
|
||||||
item.save(user=user)
|
|
||||||
|
|
||||||
# Return a response
|
if serial_numbers:
|
||||||
headers = self.get_success_headers(serializer.data)
|
# If serial numbers are specified, check that they match!
|
||||||
return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
|
try:
|
||||||
|
serials = extract_serial_numbers(serial_numbers, data['quantity'])
|
||||||
|
except DjangoValidationError as e:
|
||||||
|
raise ValidationError({
|
||||||
|
'quantity': e.messages,
|
||||||
|
'serial_numbers': e.messages,
|
||||||
|
})
|
||||||
|
|
||||||
|
with transaction.atomic():
|
||||||
|
|
||||||
|
# Create an initial stock item
|
||||||
|
item = serializer.save()
|
||||||
|
|
||||||
|
# A location was *not* specified - try to infer it
|
||||||
|
if 'location' not in data:
|
||||||
|
item.location = item.part.get_default_location()
|
||||||
|
|
||||||
|
# An expiry date was *not* specified - try to infer it!
|
||||||
|
if 'expiry_date' not in data:
|
||||||
|
|
||||||
|
if item.part.default_expiry > 0:
|
||||||
|
item.expiry_date = datetime.now().date() + timedelta(days=item.part.default_expiry)
|
||||||
|
|
||||||
|
# Finally, save the item (with user information)
|
||||||
|
item.save(user=user)
|
||||||
|
|
||||||
|
if serials:
|
||||||
|
"""
|
||||||
|
Serialize the stock, if required
|
||||||
|
|
||||||
|
- Note that the "original" stock item needs to be created first, so it can be serialized
|
||||||
|
- It is then immediately deleted
|
||||||
|
"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
item.serializeStock(
|
||||||
|
quantity,
|
||||||
|
serials,
|
||||||
|
user,
|
||||||
|
notes=notes,
|
||||||
|
location=item.location,
|
||||||
|
)
|
||||||
|
|
||||||
|
headers = self.get_success_headers(serializer.data)
|
||||||
|
|
||||||
|
# Delete the original item
|
||||||
|
item.delete()
|
||||||
|
|
||||||
|
response_data = {
|
||||||
|
'quantity': quantity,
|
||||||
|
'serial_numbers': serials,
|
||||||
|
}
|
||||||
|
|
||||||
|
return Response(response_data, status=status.HTTP_201_CREATED, headers=headers)
|
||||||
|
|
||||||
|
except DjangoValidationError as e:
|
||||||
|
raise ValidationError({
|
||||||
|
'quantity': e.messages,
|
||||||
|
'serial_numbers': e.messages,
|
||||||
|
})
|
||||||
|
|
||||||
|
# Return a response
|
||||||
|
headers = self.get_success_headers(serializer.data)
|
||||||
|
return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
|
||||||
|
|
||||||
def list(self, request, *args, **kwargs):
|
def list(self, request, *args, **kwargs):
|
||||||
"""
|
"""
|
||||||
@ -1085,8 +1171,11 @@ stock_api_urls = [
|
|||||||
url(r'^.*$', StockTrackingList.as_view(), name='api-stock-tracking-list'),
|
url(r'^.*$', StockTrackingList.as_view(), name='api-stock-tracking-list'),
|
||||||
])),
|
])),
|
||||||
|
|
||||||
# Detail for a single stock item
|
# Detail views for a single stock item
|
||||||
url(r'^(?P<pk>\d+)/', StockDetail.as_view(), name='api-stock-detail'),
|
url(r'^(?P<pk>\d+)/', include([
|
||||||
|
url(r'^serialize/', StockItemSerialize.as_view(), name='api-stock-item-serialize'),
|
||||||
|
url(r'^.*$', StockDetail.as_view(), name='api-stock-detail'),
|
||||||
|
])),
|
||||||
|
|
||||||
# Anything else
|
# Anything else
|
||||||
url(r'^.*$', StockList.as_view(), name='api-stock-list'),
|
url(r'^.*$', StockList.as_view(), name='api-stock-list'),
|
||||||
|
20
InvenTree/stock/migrations/0067_alter_stockitem_part.py
Normal file
20
InvenTree/stock/migrations/0067_alter_stockitem_part.py
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
# Generated by Django 3.2.5 on 2021-11-04 12:40
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('part', '0074_partcategorystar'),
|
||||||
|
('stock', '0066_stockitem_scheduled_for_deletion'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='stockitem',
|
||||||
|
name='part',
|
||||||
|
field=models.ForeignKey(help_text='Base part', limit_choices_to={'virtual': False}, on_delete=django.db.models.deletion.CASCADE, related_name='stock_items', to='part.part', verbose_name='Base Part'),
|
||||||
|
),
|
||||||
|
]
|
@ -456,7 +456,6 @@ class StockItem(MPTTModel):
|
|||||||
verbose_name=_('Base Part'),
|
verbose_name=_('Base Part'),
|
||||||
related_name='stock_items', help_text=_('Base part'),
|
related_name='stock_items', help_text=_('Base part'),
|
||||||
limit_choices_to={
|
limit_choices_to={
|
||||||
'active': True,
|
|
||||||
'virtual': False
|
'virtual': False
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -9,6 +9,7 @@ from decimal import Decimal
|
|||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
|
|
||||||
|
from django.core.exceptions import ValidationError as DjangoValidationError
|
||||||
from django.utils.translation import ugettext_lazy as _
|
from django.utils.translation import ugettext_lazy as _
|
||||||
from django.db.models.functions import Coalesce
|
from django.db.models.functions import Coalesce
|
||||||
from django.db.models import Case, When, Value
|
from django.db.models import Case, When, Value
|
||||||
@ -27,14 +28,15 @@ from .models import StockItemTestResult
|
|||||||
|
|
||||||
import common.models
|
import common.models
|
||||||
from common.settings import currency_code_default, currency_code_mappings
|
from common.settings import currency_code_default, currency_code_mappings
|
||||||
|
|
||||||
from company.serializers import SupplierPartSerializer
|
from company.serializers import SupplierPartSerializer
|
||||||
|
|
||||||
|
import InvenTree.helpers
|
||||||
|
import InvenTree.serializers
|
||||||
|
|
||||||
from part.serializers import PartBriefSerializer
|
from part.serializers import PartBriefSerializer
|
||||||
from InvenTree.serializers import UserSerializerBrief, InvenTreeModelSerializer, InvenTreeMoneySerializer
|
|
||||||
from InvenTree.serializers import InvenTreeAttachmentSerializer, InvenTreeAttachmentSerializerField
|
|
||||||
|
|
||||||
|
|
||||||
class LocationBriefSerializer(InvenTreeModelSerializer):
|
class LocationBriefSerializer(InvenTree.serializers.InvenTreeModelSerializer):
|
||||||
"""
|
"""
|
||||||
Provides a brief serializer for a StockLocation object
|
Provides a brief serializer for a StockLocation object
|
||||||
"""
|
"""
|
||||||
@ -48,7 +50,7 @@ class LocationBriefSerializer(InvenTreeModelSerializer):
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
class StockItemSerializerBrief(InvenTreeModelSerializer):
|
class StockItemSerializerBrief(InvenTree.serializers.InvenTreeModelSerializer):
|
||||||
""" Brief serializers for a StockItem """
|
""" Brief serializers for a StockItem """
|
||||||
|
|
||||||
location_name = serializers.CharField(source='location', read_only=True)
|
location_name = serializers.CharField(source='location', read_only=True)
|
||||||
@ -58,19 +60,19 @@ class StockItemSerializerBrief(InvenTreeModelSerializer):
|
|||||||
class Meta:
|
class Meta:
|
||||||
model = StockItem
|
model = StockItem
|
||||||
fields = [
|
fields = [
|
||||||
'pk',
|
|
||||||
'uid',
|
|
||||||
'part',
|
'part',
|
||||||
'part_name',
|
'part_name',
|
||||||
'supplier_part',
|
'pk',
|
||||||
'location',
|
'location',
|
||||||
'location_name',
|
'location_name',
|
||||||
'quantity',
|
'quantity',
|
||||||
'serial',
|
'serial',
|
||||||
|
'supplier_part',
|
||||||
|
'uid',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
class StockItemSerializer(InvenTreeModelSerializer):
|
class StockItemSerializer(InvenTree.serializers.InvenTreeModelSerializer):
|
||||||
""" Serializer for a StockItem:
|
""" Serializer for a StockItem:
|
||||||
|
|
||||||
- Includes serialization for the linked part
|
- Includes serialization for the linked part
|
||||||
@ -134,7 +136,7 @@ class StockItemSerializer(InvenTreeModelSerializer):
|
|||||||
|
|
||||||
tracking_items = serializers.IntegerField(source='tracking_info_count', read_only=True, required=False)
|
tracking_items = serializers.IntegerField(source='tracking_info_count', read_only=True, required=False)
|
||||||
|
|
||||||
quantity = serializers.FloatField()
|
# quantity = serializers.FloatField()
|
||||||
|
|
||||||
allocated = serializers.FloatField(source='allocation_count', required=False)
|
allocated = serializers.FloatField(source='allocation_count', required=False)
|
||||||
|
|
||||||
@ -142,19 +144,22 @@ class StockItemSerializer(InvenTreeModelSerializer):
|
|||||||
|
|
||||||
stale = serializers.BooleanField(required=False, read_only=True)
|
stale = serializers.BooleanField(required=False, read_only=True)
|
||||||
|
|
||||||
serial = serializers.CharField(required=False)
|
# serial = serializers.CharField(required=False)
|
||||||
|
|
||||||
required_tests = serializers.IntegerField(source='required_test_count', read_only=True, required=False)
|
required_tests = serializers.IntegerField(source='required_test_count', read_only=True, required=False)
|
||||||
|
|
||||||
purchase_price = InvenTreeMoneySerializer(
|
purchase_price = InvenTree.serializers.InvenTreeMoneySerializer(
|
||||||
label=_('Purchase Price'),
|
label=_('Purchase Price'),
|
||||||
allow_null=True
|
max_digits=19, decimal_places=4,
|
||||||
|
allow_null=True,
|
||||||
|
help_text=_('Purchase price of this stock item'),
|
||||||
)
|
)
|
||||||
|
|
||||||
purchase_price_currency = serializers.ChoiceField(
|
purchase_price_currency = serializers.ChoiceField(
|
||||||
choices=currency_code_mappings(),
|
choices=currency_code_mappings(),
|
||||||
default=currency_code_default,
|
default=currency_code_default,
|
||||||
label=_('Currency'),
|
label=_('Currency'),
|
||||||
|
help_text=_('Purchase currency of this stock item'),
|
||||||
)
|
)
|
||||||
|
|
||||||
purchase_price_string = serializers.SerializerMethodField()
|
purchase_price_string = serializers.SerializerMethodField()
|
||||||
@ -196,6 +201,7 @@ class StockItemSerializer(InvenTreeModelSerializer):
|
|||||||
'belongs_to',
|
'belongs_to',
|
||||||
'build',
|
'build',
|
||||||
'customer',
|
'customer',
|
||||||
|
'delete_on_deplete',
|
||||||
'expired',
|
'expired',
|
||||||
'expiry_date',
|
'expiry_date',
|
||||||
'in_stock',
|
'in_stock',
|
||||||
@ -204,6 +210,7 @@ class StockItemSerializer(InvenTreeModelSerializer):
|
|||||||
'location',
|
'location',
|
||||||
'location_detail',
|
'location_detail',
|
||||||
'notes',
|
'notes',
|
||||||
|
'owner',
|
||||||
'packaging',
|
'packaging',
|
||||||
'part',
|
'part',
|
||||||
'part_detail',
|
'part_detail',
|
||||||
@ -242,14 +249,130 @@ class StockItemSerializer(InvenTreeModelSerializer):
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
class StockQuantitySerializer(InvenTreeModelSerializer):
|
class SerializeStockItemSerializer(serializers.Serializer):
|
||||||
|
"""
|
||||||
|
A DRF serializer for "serializing" a StockItem.
|
||||||
|
|
||||||
|
(Sorry for the confusing naming...)
|
||||||
|
|
||||||
|
Here, "serializing" means splitting out a single StockItem,
|
||||||
|
into multiple single-quantity items with an assigned serial number
|
||||||
|
|
||||||
|
Note: The base StockItem object is provided to the serializer context
|
||||||
|
"""
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = StockItem
|
fields = [
|
||||||
fields = ('quantity',)
|
'quantity',
|
||||||
|
'serial_numbers',
|
||||||
|
'destination',
|
||||||
|
'notes',
|
||||||
|
]
|
||||||
|
|
||||||
|
quantity = serializers.IntegerField(
|
||||||
|
min_value=0,
|
||||||
|
required=True,
|
||||||
|
label=_('Quantity'),
|
||||||
|
help_text=_('Enter number of stock items to serialize'),
|
||||||
|
)
|
||||||
|
|
||||||
|
def validate_quantity(self, quantity):
|
||||||
|
"""
|
||||||
|
Validate that the quantity value is correct
|
||||||
|
"""
|
||||||
|
|
||||||
|
item = self.context['item']
|
||||||
|
|
||||||
|
if quantity < 0:
|
||||||
|
raise ValidationError(_("Quantity must be greater than zero"))
|
||||||
|
|
||||||
|
if quantity > item.quantity:
|
||||||
|
q = item.quantity
|
||||||
|
raise ValidationError(_(f"Quantity must not exceed available stock quantity ({q})"))
|
||||||
|
|
||||||
|
return quantity
|
||||||
|
|
||||||
|
serial_numbers = serializers.CharField(
|
||||||
|
label=_('Serial Numbers'),
|
||||||
|
help_text=_('Enter serial numbers for new items'),
|
||||||
|
allow_blank=False,
|
||||||
|
required=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
destination = serializers.PrimaryKeyRelatedField(
|
||||||
|
queryset=StockLocation.objects.all(),
|
||||||
|
many=False,
|
||||||
|
required=True,
|
||||||
|
allow_null=False,
|
||||||
|
label=_('Location'),
|
||||||
|
help_text=_('Destination stock location'),
|
||||||
|
)
|
||||||
|
|
||||||
|
notes = serializers.CharField(
|
||||||
|
required=False,
|
||||||
|
allow_blank=True,
|
||||||
|
label=_("Notes"),
|
||||||
|
help_text=_("Optional note field")
|
||||||
|
)
|
||||||
|
|
||||||
|
def validate(self, data):
|
||||||
|
"""
|
||||||
|
Check that the supplied serial numbers are valid
|
||||||
|
"""
|
||||||
|
|
||||||
|
data = super().validate(data)
|
||||||
|
|
||||||
|
item = self.context['item']
|
||||||
|
|
||||||
|
if not item.part.trackable:
|
||||||
|
raise ValidationError(_("Serial numbers cannot be assigned to this part"))
|
||||||
|
|
||||||
|
# Ensure the serial numbers are valid!
|
||||||
|
quantity = data['quantity']
|
||||||
|
serial_numbers = data['serial_numbers']
|
||||||
|
|
||||||
|
try:
|
||||||
|
serials = InvenTree.helpers.extract_serial_numbers(serial_numbers, quantity)
|
||||||
|
except DjangoValidationError as e:
|
||||||
|
raise ValidationError({
|
||||||
|
'serial_numbers': e.messages,
|
||||||
|
})
|
||||||
|
|
||||||
|
existing = item.part.find_conflicting_serial_numbers(serials)
|
||||||
|
|
||||||
|
if len(existing) > 0:
|
||||||
|
exists = ','.join([str(x) for x in existing])
|
||||||
|
error = _('Serial numbers already exist') + ": " + exists
|
||||||
|
|
||||||
|
raise ValidationError({
|
||||||
|
'serial_numbers': error,
|
||||||
|
})
|
||||||
|
|
||||||
|
return data
|
||||||
|
|
||||||
|
def save(self):
|
||||||
|
|
||||||
|
item = self.context['item']
|
||||||
|
request = self.context['request']
|
||||||
|
user = request.user
|
||||||
|
|
||||||
|
data = self.validated_data
|
||||||
|
|
||||||
|
serials = InvenTree.helpers.extract_serial_numbers(
|
||||||
|
data['serial_numbers'],
|
||||||
|
data['quantity'],
|
||||||
|
)
|
||||||
|
|
||||||
|
item.serializeStock(
|
||||||
|
data['quantity'],
|
||||||
|
serials,
|
||||||
|
user,
|
||||||
|
notes=data.get('notes', ''),
|
||||||
|
location=data['destination'],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class LocationSerializer(InvenTreeModelSerializer):
|
class LocationSerializer(InvenTree.serializers.InvenTreeModelSerializer):
|
||||||
""" Detailed information about a stock location
|
""" Detailed information about a stock location
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@ -273,7 +396,7 @@ class LocationSerializer(InvenTreeModelSerializer):
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
class StockItemAttachmentSerializer(InvenTreeAttachmentSerializer):
|
class StockItemAttachmentSerializer(InvenTree.serializers.InvenTreeAttachmentSerializer):
|
||||||
""" Serializer for StockItemAttachment model """
|
""" Serializer for StockItemAttachment model """
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
@ -284,9 +407,9 @@ class StockItemAttachmentSerializer(InvenTreeAttachmentSerializer):
|
|||||||
if user_detail is not True:
|
if user_detail is not True:
|
||||||
self.fields.pop('user_detail')
|
self.fields.pop('user_detail')
|
||||||
|
|
||||||
user_detail = UserSerializerBrief(source='user', read_only=True)
|
user_detail = InvenTree.serializers.UserSerializerBrief(source='user', read_only=True)
|
||||||
|
|
||||||
attachment = InvenTreeAttachmentSerializerField(required=True)
|
attachment = InvenTree.serializers.InvenTreeAttachmentSerializerField(required=True)
|
||||||
|
|
||||||
# TODO: Record the uploading user when creating or updating an attachment!
|
# TODO: Record the uploading user when creating or updating an attachment!
|
||||||
|
|
||||||
@ -311,14 +434,14 @@ class StockItemAttachmentSerializer(InvenTreeAttachmentSerializer):
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
class StockItemTestResultSerializer(InvenTreeModelSerializer):
|
class StockItemTestResultSerializer(InvenTree.serializers.InvenTreeModelSerializer):
|
||||||
""" Serializer for the StockItemTestResult model """
|
""" Serializer for the StockItemTestResult model """
|
||||||
|
|
||||||
user_detail = UserSerializerBrief(source='user', read_only=True)
|
user_detail = InvenTree.serializers.UserSerializerBrief(source='user', read_only=True)
|
||||||
|
|
||||||
key = serializers.CharField(read_only=True)
|
key = serializers.CharField(read_only=True)
|
||||||
|
|
||||||
attachment = InvenTreeAttachmentSerializerField(required=False)
|
attachment = InvenTree.serializers.InvenTreeAttachmentSerializerField(required=False)
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
user_detail = kwargs.pop('user_detail', False)
|
user_detail = kwargs.pop('user_detail', False)
|
||||||
@ -352,7 +475,7 @@ class StockItemTestResultSerializer(InvenTreeModelSerializer):
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
class StockTrackingSerializer(InvenTreeModelSerializer):
|
class StockTrackingSerializer(InvenTree.serializers.InvenTreeModelSerializer):
|
||||||
""" Serializer for StockItemTracking model """
|
""" Serializer for StockItemTracking model """
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
@ -372,7 +495,7 @@ class StockTrackingSerializer(InvenTreeModelSerializer):
|
|||||||
|
|
||||||
item_detail = StockItemSerializerBrief(source='item', many=False, read_only=True)
|
item_detail = StockItemSerializerBrief(source='item', many=False, read_only=True)
|
||||||
|
|
||||||
user_detail = UserSerializerBrief(source='user', many=False, read_only=True)
|
user_detail = InvenTree.serializers.UserSerializerBrief(source='user', many=False, read_only=True)
|
||||||
|
|
||||||
deltas = serializers.JSONField(read_only=True)
|
deltas = serializers.JSONField(read_only=True)
|
||||||
|
|
||||||
|
@ -410,20 +410,33 @@
|
|||||||
<td>{{ item.requiredTestStatus.passed }} / {{ item.requiredTestStatus.total }}</td>
|
<td>{{ item.requiredTestStatus.passed }} / {{ item.requiredTestStatus.total }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% if item.owner %}
|
||||||
|
<tr>
|
||||||
|
<td><span class='fas fa-users'></span></td>
|
||||||
|
<td>{% trans "Owner" %}</td>
|
||||||
|
<td>{{ item.owner }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endif %}
|
||||||
</table>
|
</table>
|
||||||
{% endblock %}
|
{% endblock details_right %}
|
||||||
|
|
||||||
|
|
||||||
{% block js_ready %}
|
{% block js_ready %}
|
||||||
{{ block.super }}
|
{{ block.super }}
|
||||||
|
|
||||||
$("#stock-serialize").click(function() {
|
$("#stock-serialize").click(function() {
|
||||||
launchModalForm(
|
|
||||||
"{% url 'stock-item-serialize' item.id %}",
|
serializeStockItem({{ item.pk }}, {
|
||||||
{
|
reload: true,
|
||||||
reload: true,
|
data: {
|
||||||
|
quantity: {{ item.quantity }},
|
||||||
|
{% if item.location %}
|
||||||
|
destination: {{ item.location.pk }},
|
||||||
|
{% elif item.part.default_location %}
|
||||||
|
destination: {{ item.part.default_location.pk }},
|
||||||
|
{% endif %}
|
||||||
}
|
}
|
||||||
);
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
$('#stock-install-in').click(function() {
|
$('#stock-install-in').click(function() {
|
||||||
@ -463,22 +476,16 @@ $("#print-label").click(function() {
|
|||||||
|
|
||||||
{% if roles.stock.change %}
|
{% if roles.stock.change %}
|
||||||
$("#stock-duplicate").click(function() {
|
$("#stock-duplicate").click(function() {
|
||||||
createNewStockItem({
|
// Duplicate a stock item
|
||||||
|
duplicateStockItem({{ item.pk }}, {
|
||||||
follow: true,
|
follow: true,
|
||||||
data: {
|
|
||||||
copy: {{ item.id }},
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
$("#stock-edit").click(function () {
|
$('#stock-edit').click(function() {
|
||||||
launchModalForm(
|
editStockItem({{ item.pk }}, {
|
||||||
"{% url 'stock-item-edit' item.id %}",
|
reload: true,
|
||||||
{
|
});
|
||||||
reload: true,
|
|
||||||
submit_text: '{% trans "Save" %}',
|
|
||||||
}
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
$('#stock-edit-status').click(function () {
|
$('#stock-edit-status').click(function () {
|
||||||
|
@ -140,7 +140,15 @@
|
|||||||
|
|
||||||
<div class='panel panel-hidden' id='panel-stock'>
|
<div class='panel panel-hidden' id='panel-stock'>
|
||||||
<div class='panel-heading'>
|
<div class='panel-heading'>
|
||||||
<h4>{% trans "Stock Items" %}</h4>
|
<div class='d-flex flex-wrap'>
|
||||||
|
<h4>{% trans "Stock Items" %}</h4>
|
||||||
|
{% include "spacer.html" %}
|
||||||
|
<div class='btn-group' role='group'>
|
||||||
|
<button type='button' class='btn btn-success' id='item-create' title='{% trans "Create new stock item" %}'>
|
||||||
|
<span class='fas fa-plus-circle'></span> {% trans "New Stock Item" %}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class='panel-content'>
|
<div class='panel-content'>
|
||||||
{% include "stock_table.html" %}
|
{% include "stock_table.html" %}
|
||||||
@ -223,33 +231,21 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
$('#location-create').click(function () {
|
$('#location-create').click(function () {
|
||||||
launchModalForm("{% url 'stock-location-create' %}",
|
|
||||||
{
|
createStockLocation({
|
||||||
data: {
|
{% if location %}
|
||||||
{% if location %}
|
parent: {{ location.pk }},
|
||||||
location: {{ location.id }}
|
{% endif %}
|
||||||
{% endif %}
|
follow: true,
|
||||||
},
|
});
|
||||||
follow: true,
|
|
||||||
secondary: [
|
|
||||||
{
|
|
||||||
field: 'parent',
|
|
||||||
label: '{% trans "New Location" %}',
|
|
||||||
title: '{% trans "Create new location" %}',
|
|
||||||
url: "{% url 'stock-location-create' %}",
|
|
||||||
},
|
|
||||||
]
|
|
||||||
});
|
|
||||||
return false;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
{% if location %}
|
{% if location %}
|
||||||
|
|
||||||
$('#location-edit').click(function() {
|
$('#location-edit').click(function() {
|
||||||
launchModalForm("{% url 'stock-location-edit' location.id %}",
|
editStockLocation({{ location.id }}, {
|
||||||
{
|
reload: true,
|
||||||
reload: true
|
});
|
||||||
});
|
|
||||||
return false;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
$('#location-delete').click(function() {
|
$('#location-delete').click(function() {
|
||||||
@ -312,12 +308,11 @@
|
|||||||
|
|
||||||
$('#item-create').click(function () {
|
$('#item-create').click(function () {
|
||||||
createNewStockItem({
|
createNewStockItem({
|
||||||
follow: true,
|
|
||||||
data: {
|
data: {
|
||||||
{% if location %}
|
{% if location %}
|
||||||
location: {{ location.id }}
|
location: {{ location.id }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -364,24 +364,22 @@ class StockItemTest(StockAPITestCase):
|
|||||||
'part': 1,
|
'part': 1,
|
||||||
'location': 1,
|
'location': 1,
|
||||||
},
|
},
|
||||||
expected_code=201,
|
expected_code=400
|
||||||
)
|
)
|
||||||
|
|
||||||
# Item should have been created with default quantity
|
self.assertIn('Quantity is required', str(response.data))
|
||||||
self.assertEqual(response.data['quantity'], 1)
|
|
||||||
|
|
||||||
# POST with quantity and part and location
|
# POST with quantity and part and location
|
||||||
response = self.client.post(
|
response = self.post(
|
||||||
self.list_url,
|
self.list_url,
|
||||||
data={
|
data={
|
||||||
'part': 1,
|
'part': 1,
|
||||||
'location': 1,
|
'location': 1,
|
||||||
'quantity': 10,
|
'quantity': 10,
|
||||||
}
|
},
|
||||||
|
expected_code=201
|
||||||
)
|
)
|
||||||
|
|
||||||
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
|
|
||||||
|
|
||||||
def test_default_expiry(self):
|
def test_default_expiry(self):
|
||||||
"""
|
"""
|
||||||
Test that the "default_expiry" functionality works via the API.
|
Test that the "default_expiry" functionality works via the API.
|
||||||
|
@ -7,11 +7,6 @@ from django.contrib.auth.models import Group
|
|||||||
|
|
||||||
from common.models import InvenTreeSetting
|
from common.models import InvenTreeSetting
|
||||||
|
|
||||||
import json
|
|
||||||
from datetime import datetime, timedelta
|
|
||||||
|
|
||||||
from InvenTree.status_codes import StockStatus
|
|
||||||
|
|
||||||
|
|
||||||
class StockViewTestCase(TestCase):
|
class StockViewTestCase(TestCase):
|
||||||
|
|
||||||
@ -63,149 +58,6 @@ class StockListTest(StockViewTestCase):
|
|||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
|
||||||
class StockLocationTest(StockViewTestCase):
|
|
||||||
""" Tests for StockLocation views """
|
|
||||||
|
|
||||||
def test_location_edit(self):
|
|
||||||
response = self.client.get(reverse('stock-location-edit', args=(1,)), HTTP_X_REQUESTED_WITH='XMLHttpRequest')
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
|
|
||||||
def test_qr_code(self):
|
|
||||||
# Request the StockLocation QR view
|
|
||||||
response = self.client.get(reverse('stock-location-qr', args=(1,)), HTTP_X_REQUESTED_WITH='XMLHttpRequest')
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
|
|
||||||
# Test for an invalid StockLocation
|
|
||||||
response = self.client.get(reverse('stock-location-qr', args=(999,)), HTTP_X_REQUESTED_WITH='XMLHttpRequest')
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
|
|
||||||
def test_create(self):
|
|
||||||
# Test StockLocation creation view
|
|
||||||
response = self.client.get(reverse('stock-location-create'), HTTP_X_REQUESTED_WITH='XMLHttpRequest')
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
|
|
||||||
# Create with a parent
|
|
||||||
response = self.client.get(reverse('stock-location-create'), {'location': 1}, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
|
|
||||||
# Create with an invalid parent
|
|
||||||
response = self.client.get(reverse('stock-location-create'), {'location': 999}, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
|
|
||||||
|
|
||||||
class StockItemTest(StockViewTestCase):
|
|
||||||
"""" Tests for StockItem views """
|
|
||||||
|
|
||||||
def test_qr_code(self):
|
|
||||||
# QR code for a valid item
|
|
||||||
response = self.client.get(reverse('stock-item-qr', args=(1,)), HTTP_X_REQUESTED_WITH='XMLHttpRequest')
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
|
|
||||||
# QR code for an invalid item
|
|
||||||
response = self.client.get(reverse('stock-item-qr', args=(9999,)), HTTP_X_REQUESTED_WITH='XMLHttpRequest')
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
|
|
||||||
def test_edit_item(self):
|
|
||||||
# Test edit view for StockItem
|
|
||||||
response = self.client.get(reverse('stock-item-edit', args=(1,)), HTTP_X_REQUESTED_WITH='XMLHttpRequest')
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
|
|
||||||
# Test with a non-purchaseable part
|
|
||||||
response = self.client.get(reverse('stock-item-edit', args=(100,)), HTTP_X_REQUESTED_WITH='XMLHttpRequest')
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
|
|
||||||
def test_create_item(self):
|
|
||||||
"""
|
|
||||||
Test creation of StockItem
|
|
||||||
"""
|
|
||||||
|
|
||||||
url = reverse('stock-item-create')
|
|
||||||
|
|
||||||
response = self.client.get(url, {'part': 1}, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
|
|
||||||
response = self.client.get(url, {'part': 999}, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
|
|
||||||
# Copy from a valid item, valid location
|
|
||||||
response = self.client.get(url, {'location': 1, 'copy': 1}, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
|
|
||||||
# Copy from an invalid item, invalid location
|
|
||||||
response = self.client.get(url, {'location': 999, 'copy': 9999}, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
|
|
||||||
def test_create_stock_with_expiry(self):
|
|
||||||
"""
|
|
||||||
Test creation of stock item of a part with an expiry date.
|
|
||||||
The initial value for the "expiry_date" field should be pre-filled,
|
|
||||||
and should be in the future!
|
|
||||||
"""
|
|
||||||
|
|
||||||
# First, ensure that the expiry date feature is enabled!
|
|
||||||
InvenTreeSetting.set_setting('STOCK_ENABLE_EXPIRY', True, self.user)
|
|
||||||
|
|
||||||
url = reverse('stock-item-create')
|
|
||||||
|
|
||||||
response = self.client.get(url, {'part': 25}, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
|
|
||||||
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
|
|
||||||
# We are expecting 10 days in the future
|
|
||||||
expiry = datetime.now().date() + timedelta(10)
|
|
||||||
|
|
||||||
expected = f'name=\\\\"expiry_date\\\\" value=\\\\"{expiry.isoformat()}\\\\"'
|
|
||||||
|
|
||||||
self.assertIn(expected, str(response.content))
|
|
||||||
|
|
||||||
# Now check with a part which does *not* have a default expiry period
|
|
||||||
response = self.client.get(url, {'part': 1}, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
|
|
||||||
|
|
||||||
expected = 'name=\\\\"expiry_date\\\\" placeholder=\\\\"\\\\"'
|
|
||||||
|
|
||||||
self.assertIn(expected, str(response.content))
|
|
||||||
|
|
||||||
def test_serialize_item(self):
|
|
||||||
# Test the serialization view
|
|
||||||
|
|
||||||
url = reverse('stock-item-serialize', args=(100,))
|
|
||||||
|
|
||||||
# GET the form
|
|
||||||
response = self.client.get(url, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
|
|
||||||
data_valid = {
|
|
||||||
'quantity': 5,
|
|
||||||
'serial_numbers': '1-5',
|
|
||||||
'destination': 4,
|
|
||||||
'notes': 'Serializing stock test'
|
|
||||||
}
|
|
||||||
|
|
||||||
data_invalid = {
|
|
||||||
'quantity': 4,
|
|
||||||
'serial_numbers': 'dd-23-adf',
|
|
||||||
'destination': 'blorg'
|
|
||||||
}
|
|
||||||
|
|
||||||
# POST
|
|
||||||
response = self.client.post(url, data_valid, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
data = json.loads(response.content)
|
|
||||||
self.assertTrue(data['form_valid'])
|
|
||||||
|
|
||||||
# Try again to serialize with the same numbers
|
|
||||||
response = self.client.post(url, data_valid, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
data = json.loads(response.content)
|
|
||||||
self.assertFalse(data['form_valid'])
|
|
||||||
|
|
||||||
# POST with invalid data
|
|
||||||
response = self.client.post(url, data_invalid, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
data = json.loads(response.content)
|
|
||||||
self.assertFalse(data['form_valid'])
|
|
||||||
|
|
||||||
|
|
||||||
class StockOwnershipTest(StockViewTestCase):
|
class StockOwnershipTest(StockViewTestCase):
|
||||||
""" Tests for stock ownership views """
|
""" Tests for stock ownership views """
|
||||||
|
|
||||||
@ -248,52 +100,39 @@ class StockOwnershipTest(StockViewTestCase):
|
|||||||
InvenTreeSetting.set_setting('STOCK_OWNERSHIP_CONTROL', True, self.user)
|
InvenTreeSetting.set_setting('STOCK_OWNERSHIP_CONTROL', True, self.user)
|
||||||
self.assertEqual(True, InvenTreeSetting.get_setting('STOCK_OWNERSHIP_CONTROL'))
|
self.assertEqual(True, InvenTreeSetting.get_setting('STOCK_OWNERSHIP_CONTROL'))
|
||||||
|
|
||||||
|
"""
|
||||||
|
TODO: Refactor this following test to use the new API form
|
||||||
def test_owner_control(self):
|
def test_owner_control(self):
|
||||||
# Test stock location and item ownership
|
# Test stock location and item ownership
|
||||||
from .models import StockLocation, StockItem
|
from .models import StockLocation
|
||||||
from users.models import Owner
|
from users.models import Owner
|
||||||
|
|
||||||
user_group = self.user.groups.all()[0]
|
|
||||||
user_group_owner = Owner.get_owner(user_group)
|
|
||||||
new_user_group = self.new_user.groups.all()[0]
|
new_user_group = self.new_user.groups.all()[0]
|
||||||
new_user_group_owner = Owner.get_owner(new_user_group)
|
new_user_group_owner = Owner.get_owner(new_user_group)
|
||||||
|
|
||||||
user_as_owner = Owner.get_owner(self.user)
|
user_as_owner = Owner.get_owner(self.user)
|
||||||
new_user_as_owner = Owner.get_owner(self.new_user)
|
new_user_as_owner = Owner.get_owner(self.new_user)
|
||||||
|
|
||||||
test_location_id = 4
|
|
||||||
test_item_id = 11
|
|
||||||
|
|
||||||
# Enable ownership control
|
# Enable ownership control
|
||||||
self.enable_ownership()
|
self.enable_ownership()
|
||||||
|
|
||||||
# Set ownership on existing location
|
test_location_id = 4
|
||||||
response = self.client.post(reverse('stock-location-edit', args=(test_location_id,)),
|
test_item_id = 11
|
||||||
{'name': 'Office', 'owner': user_group_owner.pk},
|
|
||||||
HTTP_X_REQUESTED_WITH='XMLHttpRequest')
|
|
||||||
self.assertContains(response, '"form_valid": true', status_code=200)
|
|
||||||
|
|
||||||
# Set ownership on existing item (and change location)
|
# Set ownership on existing item (and change location)
|
||||||
response = self.client.post(reverse('stock-item-edit', args=(test_item_id,)),
|
response = self.client.post(reverse('stock-item-edit', args=(test_item_id,)),
|
||||||
{'part': 1, 'status': StockStatus.OK, 'owner': user_as_owner.pk},
|
{'part': 1, 'status': StockStatus.OK, 'owner': user_as_owner.pk},
|
||||||
HTTP_X_REQUESTED_WITH='XMLHttpRequest')
|
HTTP_X_REQUESTED_WITH='XMLHttpRequest')
|
||||||
|
|
||||||
self.assertContains(response, '"form_valid": true', status_code=200)
|
self.assertContains(response, '"form_valid": true', status_code=200)
|
||||||
|
|
||||||
|
|
||||||
# Logout
|
# Logout
|
||||||
self.client.logout()
|
self.client.logout()
|
||||||
|
|
||||||
# Login with new user
|
# Login with new user
|
||||||
self.client.login(username='john', password='custom123')
|
self.client.login(username='john', password='custom123')
|
||||||
|
|
||||||
# Test location edit
|
# TODO: Refactor this following test to use the new API form
|
||||||
response = self.client.post(reverse('stock-location-edit', args=(test_location_id,)),
|
|
||||||
{'name': 'Office', 'owner': new_user_group_owner.pk},
|
|
||||||
HTTP_X_REQUESTED_WITH='XMLHttpRequest')
|
|
||||||
|
|
||||||
# Make sure the location's owner is unchanged
|
|
||||||
location = StockLocation.objects.get(pk=test_location_id)
|
|
||||||
self.assertEqual(location.owner, user_group_owner)
|
|
||||||
|
|
||||||
# Test item edit
|
# Test item edit
|
||||||
response = self.client.post(reverse('stock-item-edit', args=(test_item_id,)),
|
response = self.client.post(reverse('stock-item-edit', args=(test_item_id,)),
|
||||||
{'part': 1, 'status': StockStatus.OK, 'owner': new_user_as_owner.pk},
|
{'part': 1, 'status': StockStatus.OK, 'owner': new_user_as_owner.pk},
|
||||||
@ -310,38 +149,6 @@ class StockOwnershipTest(StockViewTestCase):
|
|||||||
'owner': new_user_group_owner.pk,
|
'owner': new_user_group_owner.pk,
|
||||||
}
|
}
|
||||||
|
|
||||||
# Create new parent location
|
|
||||||
response = self.client.post(reverse('stock-location-create'),
|
|
||||||
parent_location, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
|
|
||||||
self.assertContains(response, '"form_valid": true', status_code=200)
|
|
||||||
|
|
||||||
# Retrieve created location
|
|
||||||
parent_location = StockLocation.objects.get(name=parent_location['name'])
|
|
||||||
|
|
||||||
# Create new child location
|
|
||||||
new_location = {
|
|
||||||
'name': 'Upper Left Drawer',
|
|
||||||
'description': 'John\'s desk - Upper left drawer',
|
|
||||||
}
|
|
||||||
|
|
||||||
# Try to create new location with neither parent or owner
|
|
||||||
response = self.client.post(reverse('stock-location-create'),
|
|
||||||
new_location, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
|
|
||||||
self.assertContains(response, '"form_valid": false', status_code=200)
|
|
||||||
|
|
||||||
# Try to create new location with invalid owner
|
|
||||||
new_location['parent'] = parent_location.id
|
|
||||||
new_location['owner'] = user_group_owner.pk
|
|
||||||
response = self.client.post(reverse('stock-location-create'),
|
|
||||||
new_location, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
|
|
||||||
self.assertContains(response, '"form_valid": false', status_code=200)
|
|
||||||
|
|
||||||
# Try to create new location with valid owner
|
|
||||||
new_location['owner'] = new_user_group_owner.pk
|
|
||||||
response = self.client.post(reverse('stock-location-create'),
|
|
||||||
new_location, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
|
|
||||||
self.assertContains(response, '"form_valid": true', status_code=200)
|
|
||||||
|
|
||||||
# Retrieve created location
|
# Retrieve created location
|
||||||
location_created = StockLocation.objects.get(name=new_location['name'])
|
location_created = StockLocation.objects.get(name=new_location['name'])
|
||||||
|
|
||||||
@ -372,16 +179,4 @@ class StockOwnershipTest(StockViewTestCase):
|
|||||||
|
|
||||||
# Logout
|
# Logout
|
||||||
self.client.logout()
|
self.client.logout()
|
||||||
|
"""
|
||||||
# Login with admin
|
|
||||||
self.client.login(username='username', password='password')
|
|
||||||
|
|
||||||
# Switch owner of location
|
|
||||||
response = self.client.post(reverse('stock-location-edit', args=(location_created.pk,)),
|
|
||||||
{'name': new_location['name'], 'owner': user_group_owner.pk},
|
|
||||||
HTTP_X_REQUESTED_WITH='XMLHttpRequest')
|
|
||||||
self.assertContains(response, '"form_valid": true', status_code=200)
|
|
||||||
|
|
||||||
# Check that owner was updated for item in this location
|
|
||||||
stock_item = StockItem.objects.all().last()
|
|
||||||
self.assertEqual(stock_item.owner, user_group_owner)
|
|
||||||
|
@ -8,10 +8,7 @@ from stock import views
|
|||||||
|
|
||||||
location_urls = [
|
location_urls = [
|
||||||
|
|
||||||
url(r'^new/', views.StockLocationCreate.as_view(), name='stock-location-create'),
|
|
||||||
|
|
||||||
url(r'^(?P<pk>\d+)/', include([
|
url(r'^(?P<pk>\d+)/', include([
|
||||||
url(r'^edit/?', views.StockLocationEdit.as_view(), name='stock-location-edit'),
|
|
||||||
url(r'^delete/?', views.StockLocationDelete.as_view(), name='stock-location-delete'),
|
url(r'^delete/?', views.StockLocationDelete.as_view(), name='stock-location-delete'),
|
||||||
url(r'^qr_code/?', views.StockLocationQRCode.as_view(), name='stock-location-qr'),
|
url(r'^qr_code/?', views.StockLocationQRCode.as_view(), name='stock-location-qr'),
|
||||||
|
|
||||||
@ -22,9 +19,7 @@ location_urls = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
stock_item_detail_urls = [
|
stock_item_detail_urls = [
|
||||||
url(r'^edit/', views.StockItemEdit.as_view(), name='stock-item-edit'),
|
|
||||||
url(r'^convert/', views.StockItemConvert.as_view(), name='stock-item-convert'),
|
url(r'^convert/', views.StockItemConvert.as_view(), name='stock-item-convert'),
|
||||||
url(r'^serialize/', views.StockItemSerialize.as_view(), name='stock-item-serialize'),
|
|
||||||
url(r'^delete/', views.StockItemDelete.as_view(), name='stock-item-delete'),
|
url(r'^delete/', views.StockItemDelete.as_view(), name='stock-item-delete'),
|
||||||
url(r'^qr_code/', views.StockItemQRCode.as_view(), name='stock-item-qr'),
|
url(r'^qr_code/', views.StockItemQRCode.as_view(), name='stock-item-qr'),
|
||||||
url(r'^delete_test_data/', views.StockItemDeleteTestData.as_view(), name='stock-item-delete-test-data'),
|
url(r'^delete_test_data/', views.StockItemDeleteTestData.as_view(), name='stock-item-delete-test-data'),
|
||||||
@ -50,8 +45,6 @@ stock_urls = [
|
|||||||
# Stock location
|
# Stock location
|
||||||
url(r'^location/', include(location_urls)),
|
url(r'^location/', include(location_urls)),
|
||||||
|
|
||||||
url(r'^item/new/?', views.StockItemCreate.as_view(), name='stock-item-create'),
|
|
||||||
|
|
||||||
url(r'^item/uninstall/', views.StockItemUninstall.as_view(), name='stock-item-uninstall'),
|
url(r'^item/uninstall/', views.StockItemUninstall.as_view(), name='stock-item-uninstall'),
|
||||||
|
|
||||||
url(r'^track/', include(stock_tracking_urls)),
|
url(r'^track/', include(stock_tracking_urls)),
|
||||||
|
@ -149,6 +149,10 @@ class StockLocationEdit(AjaxUpdateView):
|
|||||||
"""
|
"""
|
||||||
View for editing details of a StockLocation.
|
View for editing details of a StockLocation.
|
||||||
This view is used with the EditStockLocationForm to deliver a modal form to the web view
|
This view is used with the EditStockLocationForm to deliver a modal form to the web view
|
||||||
|
|
||||||
|
TODO: Remove this code as location editing has been migrated to the API forms
|
||||||
|
- Have to still validate that all form functionality (as below) as been ported
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
model = StockLocation
|
model = StockLocation
|
||||||
@ -927,6 +931,10 @@ class StockLocationCreate(AjaxCreateView):
|
|||||||
"""
|
"""
|
||||||
View for creating a new StockLocation
|
View for creating a new StockLocation
|
||||||
A parent location (another StockLocation object) can be passed as a query parameter
|
A parent location (another StockLocation object) can be passed as a query parameter
|
||||||
|
|
||||||
|
TODO: Remove this class entirely, as it has been migrated to the API forms
|
||||||
|
- Still need to check that all the functionality (as below) has been implemented
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
model = StockLocation
|
model = StockLocation
|
||||||
@ -1019,89 +1027,6 @@ class StockLocationCreate(AjaxCreateView):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class StockItemSerialize(AjaxUpdateView):
|
|
||||||
""" View for manually serializing a StockItem """
|
|
||||||
|
|
||||||
model = StockItem
|
|
||||||
ajax_template_name = 'stock/item_serialize.html'
|
|
||||||
ajax_form_title = _('Serialize Stock')
|
|
||||||
form_class = StockForms.SerializeStockForm
|
|
||||||
|
|
||||||
def get_form(self):
|
|
||||||
|
|
||||||
context = self.get_form_kwargs()
|
|
||||||
|
|
||||||
# Pass the StockItem object through to the form
|
|
||||||
context['item'] = self.get_object()
|
|
||||||
|
|
||||||
form = StockForms.SerializeStockForm(**context)
|
|
||||||
|
|
||||||
return form
|
|
||||||
|
|
||||||
def get_initial(self):
|
|
||||||
|
|
||||||
initials = super().get_initial().copy()
|
|
||||||
|
|
||||||
item = self.get_object()
|
|
||||||
|
|
||||||
initials['quantity'] = item.quantity
|
|
||||||
initials['serial_numbers'] = item.part.getSerialNumberString(item.quantity)
|
|
||||||
if item.location is not None:
|
|
||||||
initials['destination'] = item.location.pk
|
|
||||||
|
|
||||||
return initials
|
|
||||||
|
|
||||||
def get(self, request, *args, **kwargs):
|
|
||||||
|
|
||||||
return super().get(request, *args, **kwargs)
|
|
||||||
|
|
||||||
def post(self, request, *args, **kwargs):
|
|
||||||
|
|
||||||
form = self.get_form()
|
|
||||||
|
|
||||||
item = self.get_object()
|
|
||||||
|
|
||||||
quantity = request.POST.get('quantity', 0)
|
|
||||||
serials = request.POST.get('serial_numbers', '')
|
|
||||||
dest_id = request.POST.get('destination', None)
|
|
||||||
notes = request.POST.get('note', '')
|
|
||||||
user = request.user
|
|
||||||
|
|
||||||
valid = True
|
|
||||||
|
|
||||||
try:
|
|
||||||
destination = StockLocation.objects.get(pk=dest_id)
|
|
||||||
except (ValueError, StockLocation.DoesNotExist):
|
|
||||||
destination = None
|
|
||||||
|
|
||||||
try:
|
|
||||||
numbers = extract_serial_numbers(serials, quantity)
|
|
||||||
except ValidationError as e:
|
|
||||||
form.add_error('serial_numbers', e.messages)
|
|
||||||
valid = False
|
|
||||||
numbers = []
|
|
||||||
|
|
||||||
if valid:
|
|
||||||
try:
|
|
||||||
item.serializeStock(quantity, numbers, user, notes=notes, location=destination)
|
|
||||||
except ValidationError as e:
|
|
||||||
messages = e.message_dict
|
|
||||||
|
|
||||||
for k in messages.keys():
|
|
||||||
if k in ['quantity', 'destination', 'serial_numbers']:
|
|
||||||
form.add_error(k, messages[k])
|
|
||||||
else:
|
|
||||||
form.add_error(None, messages[k])
|
|
||||||
|
|
||||||
valid = False
|
|
||||||
|
|
||||||
data = {
|
|
||||||
'form_valid': valid,
|
|
||||||
}
|
|
||||||
|
|
||||||
return self.renderJsonResponse(request, form, data=data)
|
|
||||||
|
|
||||||
|
|
||||||
class StockItemCreate(AjaxCreateView):
|
class StockItemCreate(AjaxCreateView):
|
||||||
"""
|
"""
|
||||||
View for creating a new StockItem
|
View for creating a new StockItem
|
||||||
|
@ -111,7 +111,13 @@ $(document).ready(function () {
|
|||||||
// notifications
|
// notifications
|
||||||
{% if messages %}
|
{% if messages %}
|
||||||
{% for message in messages %}
|
{% for message in messages %}
|
||||||
showAlertOrCache('{{ message }}', 'info', true);
|
showAlertOrCache(
|
||||||
|
'{{ message }}',
|
||||||
|
true,
|
||||||
|
{
|
||||||
|
style: 'info',
|
||||||
|
}
|
||||||
|
);
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
@ -10,7 +10,6 @@
|
|||||||
modalSetSubmitText,
|
modalSetSubmitText,
|
||||||
modalShowSubmitButton,
|
modalShowSubmitButton,
|
||||||
modalSubmit,
|
modalSubmit,
|
||||||
showAlertOrCache,
|
|
||||||
showQuestionDialog,
|
showQuestionDialog,
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@ -480,10 +479,13 @@ function barcodeCheckIn(location_id) {
|
|||||||
$(modal).modal('hide');
|
$(modal).modal('hide');
|
||||||
if (status == 'success' && 'success' in response) {
|
if (status == 'success' && 'success' in response) {
|
||||||
|
|
||||||
showAlertOrCache(response.success, 'success', true);
|
addCachedAlert(response.success);
|
||||||
location.reload();
|
location.reload();
|
||||||
} else {
|
} else {
|
||||||
showAlertOrCache('{% trans "Error transferring stock" %}', 'danger', false);
|
showMessage('{% trans "Error transferring stock" %}', {
|
||||||
|
style: 'danger',
|
||||||
|
icon: 'fas fa-times-circle',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -604,10 +606,12 @@ function scanItemsIntoLocation(item_id_list, options={}) {
|
|||||||
$(modal).modal('hide');
|
$(modal).modal('hide');
|
||||||
|
|
||||||
if (status == 'success' && 'success' in response) {
|
if (status == 'success' && 'success' in response) {
|
||||||
showAlertOrCache(response.success, 'success', true);
|
addCachedAlert(response.success);
|
||||||
location.reload();
|
location.reload();
|
||||||
} else {
|
} else {
|
||||||
showAlertOrCache('{% trans "Error transferring stock" %}', 'danger', false);
|
showMessage('{% trans "Error transferring stock" %}', {
|
||||||
|
style: 'danger',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -25,7 +25,12 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
/* exported
|
/* exported
|
||||||
setFormGroupVisibility
|
clearFormInput,
|
||||||
|
disableFormInput,
|
||||||
|
enableFormInput,
|
||||||
|
hideFormInput,
|
||||||
|
setFormGroupVisibility,
|
||||||
|
showFormInput,
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -113,6 +118,10 @@ function canDelete(OPTIONS) {
|
|||||||
*/
|
*/
|
||||||
function getApiEndpointOptions(url, callback) {
|
function getApiEndpointOptions(url, callback) {
|
||||||
|
|
||||||
|
if (!url) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Return the ajax request object
|
// Return the ajax request object
|
||||||
$.ajax({
|
$.ajax({
|
||||||
url: url,
|
url: url,
|
||||||
@ -182,6 +191,7 @@ function constructChangeForm(fields, options) {
|
|||||||
// Request existing data from the API endpoint
|
// Request existing data from the API endpoint
|
||||||
$.ajax({
|
$.ajax({
|
||||||
url: options.url,
|
url: options.url,
|
||||||
|
data: options.params || {},
|
||||||
type: 'GET',
|
type: 'GET',
|
||||||
contentType: 'application/json',
|
contentType: 'application/json',
|
||||||
dataType: 'json',
|
dataType: 'json',
|
||||||
@ -198,6 +208,17 @@ function constructChangeForm(fields, options) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// An optional function can be provided to process the returned results,
|
||||||
|
// before they are rendered to the form
|
||||||
|
if (options.processResults) {
|
||||||
|
var processed = options.processResults(data, fields, options);
|
||||||
|
|
||||||
|
// If the processResults function returns data, it will be stored
|
||||||
|
if (processed) {
|
||||||
|
data = processed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Store the entire data object
|
// Store the entire data object
|
||||||
options.instance = data;
|
options.instance = data;
|
||||||
|
|
||||||
@ -713,6 +734,8 @@ function submitFormData(fields, options) {
|
|||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
$(options.modal).modal('hide');
|
$(options.modal).modal('hide');
|
||||||
|
|
||||||
|
console.log(`upload error at ${options.url}`);
|
||||||
showApiError(xhr, options.url);
|
showApiError(xhr, options.url);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@ -890,19 +913,19 @@ function handleFormSuccess(response, options) {
|
|||||||
|
|
||||||
// Display any messages
|
// Display any messages
|
||||||
if (response && response.success) {
|
if (response && response.success) {
|
||||||
showAlertOrCache(response.success, 'success', cache);
|
showAlertOrCache(response.success, cache, {style: 'success'});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (response && response.info) {
|
if (response && response.info) {
|
||||||
showAlertOrCache(response.info, 'info', cache);
|
showAlertOrCache(response.info, cache, {style: 'info'});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (response && response.warning) {
|
if (response && response.warning) {
|
||||||
showAlertOrCache(response.warning, 'warning', cache);
|
showAlertOrCache(response.warning, cache, {style: 'warning'});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (response && response.danger) {
|
if (response && response.danger) {
|
||||||
showAlertOrCache(response.danger, 'dagner', cache);
|
showAlertOrCache(response.danger, cache, {style: 'danger'});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (options.onSuccess) {
|
if (options.onSuccess) {
|
||||||
@ -1241,6 +1264,35 @@ function initializeGroups(fields, options) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Clear a form input
|
||||||
|
function clearFormInput(name, options) {
|
||||||
|
updateFieldValue(name, null, {}, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disable a form input
|
||||||
|
function disableFormInput(name, options) {
|
||||||
|
$(options.modal).find(`#id_${name}`).prop('disabled', true);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Enable a form input
|
||||||
|
function enableFormInput(name, options) {
|
||||||
|
$(options.modal).find(`#id_${name}`).prop('disabled', false);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Hide a form input
|
||||||
|
function hideFormInput(name, options) {
|
||||||
|
$(options.modal).find(`#div_id_${name}`).hide();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Show a form input
|
||||||
|
function showFormInput(name, options) {
|
||||||
|
$(options.modal).find(`#div_id_${name}`).show();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
// Hide a form group
|
// Hide a form group
|
||||||
function hideFormGroup(group, options) {
|
function hideFormGroup(group, options) {
|
||||||
$(options.modal).find(`#form-panel-${group}`).hide();
|
$(options.modal).find(`#form-panel-${group}`).hide();
|
||||||
|
@ -399,19 +399,19 @@ function afterForm(response, options) {
|
|||||||
|
|
||||||
// Display any messages
|
// Display any messages
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
showAlertOrCache(response.success, 'success', cache);
|
showAlertOrCache(response.success, cache, {style: 'success'});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (response.info) {
|
if (response.info) {
|
||||||
showAlertOrCache(response.info, 'info', cache);
|
showAlertOrCache(response.info, cache, {style: 'info'});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (response.warning) {
|
if (response.warning) {
|
||||||
showAlertOrCache(response.warning, 'warning', cache);
|
showAlertOrCache(response.warning, cache, {style: 'warning'});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (response.danger) {
|
if (response.danger) {
|
||||||
showAlertOrCache(response.danger, 'danger', cache);
|
showAlertOrCache(response.danger, cache, {style: 'danger'});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Was a callback provided?
|
// Was a callback provided?
|
||||||
|
@ -4,9 +4,6 @@
|
|||||||
|
|
||||||
/* globals
|
/* globals
|
||||||
attachSelect,
|
attachSelect,
|
||||||
enableField,
|
|
||||||
clearField,
|
|
||||||
clearFieldOptions,
|
|
||||||
closeModal,
|
closeModal,
|
||||||
constructField,
|
constructField,
|
||||||
constructFormBody,
|
constructFormBody,
|
||||||
@ -33,10 +30,8 @@
|
|||||||
printStockItemLabels,
|
printStockItemLabels,
|
||||||
printTestReports,
|
printTestReports,
|
||||||
renderLink,
|
renderLink,
|
||||||
reloadFieldOptions,
|
|
||||||
scanItemsIntoLocation,
|
scanItemsIntoLocation,
|
||||||
showAlertDialog,
|
showAlertDialog,
|
||||||
setFieldValue,
|
|
||||||
setupFilterList,
|
setupFilterList,
|
||||||
showApiError,
|
showApiError,
|
||||||
stockStatusDisplay,
|
stockStatusDisplay,
|
||||||
@ -44,6 +39,10 @@
|
|||||||
|
|
||||||
/* exported
|
/* exported
|
||||||
createNewStockItem,
|
createNewStockItem,
|
||||||
|
createStockLocation,
|
||||||
|
duplicateStockItem,
|
||||||
|
editStockItem,
|
||||||
|
editStockLocation,
|
||||||
exportStock,
|
exportStock,
|
||||||
loadInstalledInTable,
|
loadInstalledInTable,
|
||||||
loadStockLocationTable,
|
loadStockLocationTable,
|
||||||
@ -51,20 +50,318 @@
|
|||||||
loadStockTestResultsTable,
|
loadStockTestResultsTable,
|
||||||
loadStockTrackingTable,
|
loadStockTrackingTable,
|
||||||
loadTableFilters,
|
loadTableFilters,
|
||||||
locationFields,
|
|
||||||
removeStockRow,
|
removeStockRow,
|
||||||
|
serializeStockItem,
|
||||||
|
stockItemFields,
|
||||||
|
stockLocationFields,
|
||||||
stockStatusCodes,
|
stockStatusCodes,
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
|
||||||
function locationFields() {
|
/*
|
||||||
return {
|
* Launches a modal form to serialize a particular StockItem
|
||||||
|
*/
|
||||||
|
|
||||||
|
function serializeStockItem(pk, options={}) {
|
||||||
|
|
||||||
|
var url = `/api/stock/${pk}/serialize/`;
|
||||||
|
|
||||||
|
options.method = 'POST';
|
||||||
|
options.title = '{% trans "Serialize Stock Item" %}';
|
||||||
|
|
||||||
|
options.fields = {
|
||||||
|
quantity: {},
|
||||||
|
serial_numbers: {
|
||||||
|
icon: 'fa-hashtag',
|
||||||
|
},
|
||||||
|
destination: {
|
||||||
|
icon: 'fa-sitemap',
|
||||||
|
},
|
||||||
|
notes: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
constructForm(url, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function stockLocationFields(options={}) {
|
||||||
|
var fields = {
|
||||||
parent: {
|
parent: {
|
||||||
help_text: '{% trans "Parent stock location" %}',
|
help_text: '{% trans "Parent stock location" %}',
|
||||||
},
|
},
|
||||||
name: {},
|
name: {},
|
||||||
description: {},
|
description: {},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (options.parent) {
|
||||||
|
fields.parent.value = options.parent;
|
||||||
|
}
|
||||||
|
|
||||||
|
return fields;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Launch an API form to edit a stock location
|
||||||
|
*/
|
||||||
|
function editStockLocation(pk, options={}) {
|
||||||
|
|
||||||
|
var url = `/api/stock/location/${pk}/`;
|
||||||
|
|
||||||
|
options.fields = stockLocationFields(options);
|
||||||
|
|
||||||
|
constructForm(url, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Launch an API form to create a new stock location
|
||||||
|
*/
|
||||||
|
function createStockLocation(options={}) {
|
||||||
|
|
||||||
|
var url = '{% url "api-location-list" %}';
|
||||||
|
|
||||||
|
options.method = 'POST';
|
||||||
|
options.fields = stockLocationFields(options);
|
||||||
|
options.title = '{% trans "New Stock Location" %}';
|
||||||
|
|
||||||
|
constructForm(url, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function stockItemFields(options={}) {
|
||||||
|
var fields = {
|
||||||
|
part: {
|
||||||
|
// Hide the part field unless we are "creating" a new stock item
|
||||||
|
hidden: !options.create,
|
||||||
|
onSelect: function(data, field, opts) {
|
||||||
|
// Callback when a new "part" is selected
|
||||||
|
|
||||||
|
// If we are "creating" a new stock item,
|
||||||
|
// change the available fields based on the part properties
|
||||||
|
if (options.create) {
|
||||||
|
|
||||||
|
// If a "trackable" part is selected, enable serial number field
|
||||||
|
if (data.trackable) {
|
||||||
|
enableFormInput('serial_numbers', opts);
|
||||||
|
// showFormInput('serial_numbers', opts);
|
||||||
|
} else {
|
||||||
|
clearFormInput('serial_numbers', opts);
|
||||||
|
disableFormInput('serial_numbers', opts);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enable / disable fields based on purchaseable status
|
||||||
|
if (data.purchaseable) {
|
||||||
|
enableFormInput('supplier_part', opts);
|
||||||
|
enableFormInput('purchase_price', opts);
|
||||||
|
enableFormInput('purchase_price_currency', opts);
|
||||||
|
} else {
|
||||||
|
clearFormInput('supplier_part', opts);
|
||||||
|
clearFormInput('purchase_price', opts);
|
||||||
|
|
||||||
|
disableFormInput('supplier_part', opts);
|
||||||
|
disableFormInput('purchase_price', opts);
|
||||||
|
disableFormInput('purchase_price_currency', opts);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
supplier_part: {
|
||||||
|
icon: 'fa-building',
|
||||||
|
filters: {
|
||||||
|
part_detail: true,
|
||||||
|
supplier_detail: true,
|
||||||
|
},
|
||||||
|
adjustFilters: function(query, opts) {
|
||||||
|
var part = getFormFieldValue('part', {}, opts);
|
||||||
|
|
||||||
|
if (part) {
|
||||||
|
query.part = part;
|
||||||
|
}
|
||||||
|
|
||||||
|
return query;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
location: {
|
||||||
|
icon: 'fa-sitemap',
|
||||||
|
},
|
||||||
|
quantity: {
|
||||||
|
help_text: '{% trans "Enter initial quantity for this stock item" %}',
|
||||||
|
},
|
||||||
|
serial_numbers: {
|
||||||
|
icon: 'fa-hashtag',
|
||||||
|
type: 'string',
|
||||||
|
label: '{% trans "Serial Numbers" %}',
|
||||||
|
help_text: '{% trans "Enter serial numbers for new stock (or leave blank)" %}',
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
serial: {
|
||||||
|
icon: 'fa-hashtag',
|
||||||
|
},
|
||||||
|
status: {},
|
||||||
|
expiry_date: {},
|
||||||
|
batch: {},
|
||||||
|
purchase_price: {
|
||||||
|
icon: 'fa-dollar-sign',
|
||||||
|
},
|
||||||
|
purchase_price_currency: {},
|
||||||
|
packaging: {
|
||||||
|
icon: 'fa-box',
|
||||||
|
},
|
||||||
|
link: {
|
||||||
|
icon: 'fa-link',
|
||||||
|
},
|
||||||
|
owner: {},
|
||||||
|
delete_on_deplete: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
if (options.create) {
|
||||||
|
// Use special "serial numbers" field when creating a new stock item
|
||||||
|
delete fields['serial'];
|
||||||
|
} else {
|
||||||
|
// These fields cannot be edited once the stock item has been created
|
||||||
|
delete fields['serial_numbers'];
|
||||||
|
delete fields['quantity'];
|
||||||
|
delete fields['location'];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove stock expiry fields if feature is not enabled
|
||||||
|
if (!global_settings.STOCK_ENABLE_EXPIRY) {
|
||||||
|
delete fields['expiry_date'];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove ownership field if feature is not enanbled
|
||||||
|
if (!global_settings.STOCK_OWNERSHIP_CONTROL) {
|
||||||
|
delete fields['owner'];
|
||||||
|
}
|
||||||
|
|
||||||
|
return fields;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function stockItemGroups(options={}) {
|
||||||
|
return {
|
||||||
|
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Launch a modal form to duplicate a given StockItem
|
||||||
|
*/
|
||||||
|
function duplicateStockItem(pk, options) {
|
||||||
|
|
||||||
|
// First, we need the StockItem informatino
|
||||||
|
inventreeGet(`/api/stock/${pk}/`, {}, {
|
||||||
|
success: function(data) {
|
||||||
|
|
||||||
|
// Do not duplicate the serial number
|
||||||
|
delete data['serial'];
|
||||||
|
|
||||||
|
options.data = data;
|
||||||
|
|
||||||
|
options.create = true;
|
||||||
|
options.fields = stockItemFields(options);
|
||||||
|
options.groups = stockItemGroups(options);
|
||||||
|
|
||||||
|
options.method = 'POST';
|
||||||
|
options.title = '{% trans "Duplicate Stock Item" %}';
|
||||||
|
|
||||||
|
constructForm('{% url "api-stock-list" %}', options);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Launch a modal form to edit a given StockItem
|
||||||
|
*/
|
||||||
|
function editStockItem(pk, options={}) {
|
||||||
|
|
||||||
|
var url = `/api/stock/${pk}/`;
|
||||||
|
|
||||||
|
options.create = false;
|
||||||
|
|
||||||
|
options.fields = stockItemFields(options);
|
||||||
|
options.groups = stockItemGroups(options);
|
||||||
|
|
||||||
|
options.title = '{% trans "Edit Stock Item" %}';
|
||||||
|
|
||||||
|
// Query parameters for retrieving stock item data
|
||||||
|
options.params = {
|
||||||
|
part_detail: true,
|
||||||
|
supplier_part_detail: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Augment the rendered form when we receive information about the StockItem
|
||||||
|
options.processResults = function(data, fields, options) {
|
||||||
|
if (data.part_detail.trackable) {
|
||||||
|
delete options.fields.delete_on_deplete;
|
||||||
|
} else {
|
||||||
|
// Remove serial number field if part is not trackable
|
||||||
|
delete options.fields.serial;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove pricing fields if part is not purchaseable
|
||||||
|
if (!data.part_detail.purchaseable) {
|
||||||
|
delete options.fields.supplier_part;
|
||||||
|
delete options.fields.purchase_price;
|
||||||
|
delete options.fields.purchase_price_currency;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
constructForm(url, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Launch an API form to contsruct a new stock item
|
||||||
|
*/
|
||||||
|
function createNewStockItem(options={}) {
|
||||||
|
|
||||||
|
var url = '{% url "api-stock-list" %}';
|
||||||
|
|
||||||
|
options.title = '{% trans "New Stock Item" %}';
|
||||||
|
options.method = 'POST';
|
||||||
|
|
||||||
|
options.create = true;
|
||||||
|
|
||||||
|
options.fields = stockItemFields(options);
|
||||||
|
options.groups = stockItemGroups(options);
|
||||||
|
|
||||||
|
if (!options.onSuccess) {
|
||||||
|
options.onSuccess = function(response) {
|
||||||
|
// If a single stock item has been created, follow it!
|
||||||
|
if (response.pk) {
|
||||||
|
var url = `/stock/item/${response.pk}/`;
|
||||||
|
|
||||||
|
addCachedAlert('{% trans "Created new stock item" %}', {
|
||||||
|
icon: 'fas fa-boxes',
|
||||||
|
});
|
||||||
|
|
||||||
|
window.location.href = url;
|
||||||
|
} else {
|
||||||
|
|
||||||
|
// Multiple stock items have been created (i.e. serialized stock)
|
||||||
|
var details = `
|
||||||
|
<br>{% trans "Quantity" %}: ${response.quantity}
|
||||||
|
<br>{% trans "Serial Numbers" %}: ${response.serial_numbers}
|
||||||
|
`;
|
||||||
|
|
||||||
|
showMessage('{% trans "Created multiple stock items" %}', {
|
||||||
|
icon: 'fas fa-boxes',
|
||||||
|
details: details,
|
||||||
|
});
|
||||||
|
|
||||||
|
var table = options.table || '#stock-table';
|
||||||
|
|
||||||
|
// Reload the table
|
||||||
|
$(table).bootstrapTable('refresh');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
constructForm(url, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -1810,79 +2107,6 @@ function loadStockTrackingTable(table, options) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
function createNewStockItem(options) {
|
|
||||||
/* Launch a modal form to create a new stock item.
|
|
||||||
*
|
|
||||||
* This is really just a helper function which calls launchModalForm,
|
|
||||||
* but it does get called a lot, so here we are ...
|
|
||||||
*/
|
|
||||||
|
|
||||||
// Add in some funky options
|
|
||||||
|
|
||||||
options.callback = [
|
|
||||||
{
|
|
||||||
field: 'part',
|
|
||||||
action: function(value) {
|
|
||||||
|
|
||||||
if (!value) {
|
|
||||||
// No part chosen
|
|
||||||
|
|
||||||
clearFieldOptions('supplier_part');
|
|
||||||
enableField('serial_numbers', false);
|
|
||||||
enableField('purchase_price_0', false);
|
|
||||||
enableField('purchase_price_1', false);
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reload options for supplier part
|
|
||||||
reloadFieldOptions(
|
|
||||||
'supplier_part',
|
|
||||||
{
|
|
||||||
url: '{% url "api-supplier-part-list" %}',
|
|
||||||
params: {
|
|
||||||
part: value,
|
|
||||||
pretty: true,
|
|
||||||
},
|
|
||||||
text: function(item) {
|
|
||||||
return item.pretty_name;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// Request part information from the server
|
|
||||||
inventreeGet(
|
|
||||||
`/api/part/${value}/`, {},
|
|
||||||
{
|
|
||||||
success: function(response) {
|
|
||||||
|
|
||||||
// Disable serial number field if the part is not trackable
|
|
||||||
enableField('serial_numbers', response.trackable);
|
|
||||||
clearField('serial_numbers');
|
|
||||||
|
|
||||||
enableField('purchase_price_0', response.purchaseable);
|
|
||||||
enableField('purchase_price_1', response.purchaseable);
|
|
||||||
|
|
||||||
// Populate the expiry date
|
|
||||||
if (response.default_expiry <= 0) {
|
|
||||||
// No expiry date
|
|
||||||
clearField('expiry_date');
|
|
||||||
} else {
|
|
||||||
var expiry = moment().add(response.default_expiry, 'days');
|
|
||||||
|
|
||||||
setFieldValue('expiry_date', expiry.format('YYYY-MM-DD'));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
launchModalForm('{% url "stock-item-create" %}', options);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
function loadInstalledInTable(table, options) {
|
function loadInstalledInTable(table, options) {
|
||||||
/*
|
/*
|
||||||
* Display a table showing the stock items which are installed in this stock item.
|
* Display a table showing the stock items which are installed in this stock item.
|
||||||
|
@ -10,17 +10,10 @@
|
|||||||
|
|
||||||
<div id='{{ prefix }}button-toolbar'>
|
<div id='{{ prefix }}button-toolbar'>
|
||||||
<div class='button-toolbar container-fluid' style='float: right;'>
|
<div class='button-toolbar container-fluid' style='float: right;'>
|
||||||
<div class='btn-group'>
|
<div class='btn-group' role='group'>
|
||||||
<button class='btn btn-outline-secondary' id='stock-export' title='{% trans "Export Stock Information" %}'>
|
<button class='btn btn-outline-secondary' id='stock-export' title='{% trans "Export Stock Information" %}'>
|
||||||
<span class='fas fa-download'></span>
|
<span class='fas fa-download'></span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{% if owner_control.value == "True" and user in owners or user.is_superuser or owner_control.value == "False" %}
|
|
||||||
{% if not read_only and not prevent_new_stock and roles.stock.add %}
|
|
||||||
<button class="btn btn-success" id='item-create' title='{% trans "New Stock Item" %}'>
|
|
||||||
<span class='fas fa-plus-circle'></span>
|
|
||||||
</button>
|
|
||||||
{% endif %}
|
|
||||||
{% if barcodes %}
|
{% if barcodes %}
|
||||||
<!-- Barcode actions menu -->
|
<!-- Barcode actions menu -->
|
||||||
<div class='btn-group' role='group'>
|
<div class='btn-group' role='group'>
|
||||||
@ -46,7 +39,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{% if not read_only %}
|
{% if not read_only %}
|
||||||
{% if roles.stock.change or roles.stock.delete %}
|
{% if roles.stock.change or roles.stock.delete %}
|
||||||
<div class="btn-group">
|
<div class="btn-group" role="group">
|
||||||
<button id='stock-options' class="btn btn-primary dropdown-toggle" type="button" data-bs-toggle="dropdown" title='{% trans "Stock Options" %}'>
|
<button id='stock-options' class="btn btn-primary dropdown-toggle" type="button" data-bs-toggle="dropdown" title='{% trans "Stock Options" %}'>
|
||||||
<span class='fas fa-boxes'></span> <span class="caret"></span>
|
<span class='fas fa-boxes'></span> <span class="caret"></span>
|
||||||
</button>
|
</button>
|
||||||
@ -66,7 +59,6 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endif %}
|
|
||||||
{% include "filter_list.html" with id="stock" %}
|
{% include "filter_list.html" with id="stock" %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
Loading…
Reference in New Issue
Block a user