Merge pull request #2198 from SchrodingersGat/stock-item-forms

Stock item forms
This commit is contained in:
Oliver 2021-11-05 07:37:33 +11:00 committed by GitHub
commit 083967b156
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 777 additions and 532 deletions

View File

@ -1,7 +1,7 @@
/*
* Add a cached alert message to sesion storage
*/
function addCachedAlert(message, style) {
function addCachedAlert(message, options={}) {
var alerts = sessionStorage.getItem('inventree-alerts');
@ -13,7 +13,8 @@ function addCachedAlert(message, style) {
alerts.push({
message: message,
style: style
style: options.style || 'success',
icon: options.icon,
});
sessionStorage.setItem('inventree-alerts', JSON.stringify(alerts));
@ -31,13 +32,13 @@ function clearCachedAlerts() {
/*
* Display an alert, or cache to display on reload
*/
function showAlertOrCache(message, style, cache=false) {
function showAlertOrCache(message, cache, options={}) {
if (cache) {
addCachedAlert(message, style);
addCachedAlert(message, options);
} else {
showMessage(message, {style: style});
showMessage(message, options);
}
}
@ -50,7 +51,13 @@ function showCachedAlerts() {
var alerts = JSON.parse(sessionStorage.getItem('inventree-alerts')) || [];
alerts.forEach(function(alert) {
showMessage(alert.message, {style: alert.style});
showMessage(
alert.message,
{
style: alert.style || 'success',
icon: alert.icon,
}
);
});
clearCachedAlerts();

View File

@ -134,7 +134,15 @@ src="{% static 'img/blank_image.png' %}"
<div class='panel panel-hidden' id='panel-stock'>
<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 class='panel-content'>
{% include "stock_table.html" %}
@ -314,7 +322,6 @@ $("#item-create").click(function() {
part: {{ part.part.id }},
supplier_part: {{ part.id }},
},
reload: true,
});
});

View File

@ -50,7 +50,7 @@
<h4>{% trans "Received Items" %}</h4>
</div>
<div class='panel-content'>
{% include "stock_table.html" with prevent_new_stock=True %}
{% include "stock_table.html" %}
</div>
</div>

View File

@ -120,7 +120,15 @@
<div class='panel panel-hidden' id='panel-part-stock'>
<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 class='panel-content'>
{% if part.is_template %}
@ -876,11 +884,13 @@
});
onPanelLoad("part-stock", function() {
$('#add-stock-item').click(function () {
$('#new-stock-item').click(function () {
createNewStockItem({
reload: true,
data: {
part: {{ part.id }},
{% if part.default_location %}
location: {{ part.default_location.pk }},
{% endif %}
}
});
});
@ -908,7 +918,6 @@
$('#item-create').click(function () {
createNewStockItem({
reload: true,
data: {
part: {{ part.id }},
}

View File

@ -7,42 +7,44 @@ from __future__ import unicode_literals
from datetime import datetime, timedelta
from django.core.exceptions import ValidationError as DjangoValidationError
from django.conf.urls import url, include
from django.http import JsonResponse
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.serializers import ValidationError
from rest_framework.response import Response
from rest_framework import generics, filters
from django_filters.rest_framework import DjangoFilterBackend
from django_filters import rest_framework as rest_filters
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
import common.settings
import common.models
from company.models import Company, SupplierPart
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 SalesOrder, SalesOrderAllocation
from order.serializers import POSerializer
import common.settings
import common.models
from part.models import BomItem, Part, PartCategory
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
from InvenTree.helpers import str2bool, isNull
from InvenTree.api import AttachmentMixin
from InvenTree.filters import InvenTreeOrderingFilter
class StockDetail(generics.RetrieveUpdateDestroyAPIView):
""" API detail endpoint for Stock object
@ -99,6 +101,27 @@ class StockDetail(generics.RetrieveUpdateDestroyAPIView):
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):
"""
A generic class for handling stocktake actions.
@ -380,28 +403,91 @@ class StockList(generics.ListCreateAPIView):
"""
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)
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
if 'location' not in request.data:
item.location = item.part.get_default_location()
quantity = data.get('quantity', None)
# An expiry date was *not* specified - try to infer it!
if 'expiry_date' not in request.data:
if quantity is None:
raise ValidationError({
'quantity': _('Quantity is required'),
})
if item.part.default_expiry > 0:
item.expiry_date = datetime.now().date() + timedelta(days=item.part.default_expiry)
notes = data.get('notes', '')
# Finally, save the item
item.save(user=user)
serials = None
# Return a response
headers = self.get_success_headers(serializer.data)
return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
if serial_numbers:
# If serial numbers are specified, check that they match!
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):
"""
@ -1085,8 +1171,11 @@ stock_api_urls = [
url(r'^.*$', StockTrackingList.as_view(), name='api-stock-tracking-list'),
])),
# Detail for a single stock item
url(r'^(?P<pk>\d+)/', StockDetail.as_view(), name='api-stock-detail'),
# Detail views for a single stock item
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
url(r'^.*$', StockList.as_view(), name='api-stock-list'),

View 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'),
),
]

View File

@ -456,7 +456,6 @@ class StockItem(MPTTModel):
verbose_name=_('Base Part'),
related_name='stock_items', help_text=_('Base part'),
limit_choices_to={
'active': True,
'virtual': False
})

View File

@ -9,6 +9,7 @@ from decimal import Decimal
from datetime import datetime, timedelta
from django.db import transaction
from django.core.exceptions import ValidationError as DjangoValidationError
from django.utils.translation import ugettext_lazy as _
from django.db.models.functions import Coalesce
from django.db.models import Case, When, Value
@ -27,14 +28,15 @@ from .models import StockItemTestResult
import common.models
from common.settings import currency_code_default, currency_code_mappings
from company.serializers import SupplierPartSerializer
import InvenTree.helpers
import InvenTree.serializers
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
"""
@ -48,7 +50,7 @@ class LocationBriefSerializer(InvenTreeModelSerializer):
]
class StockItemSerializerBrief(InvenTreeModelSerializer):
class StockItemSerializerBrief(InvenTree.serializers.InvenTreeModelSerializer):
""" Brief serializers for a StockItem """
location_name = serializers.CharField(source='location', read_only=True)
@ -58,19 +60,19 @@ class StockItemSerializerBrief(InvenTreeModelSerializer):
class Meta:
model = StockItem
fields = [
'pk',
'uid',
'part',
'part_name',
'supplier_part',
'pk',
'location',
'location_name',
'quantity',
'serial',
'supplier_part',
'uid',
]
class StockItemSerializer(InvenTreeModelSerializer):
class StockItemSerializer(InvenTree.serializers.InvenTreeModelSerializer):
""" Serializer for a StockItem:
- 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)
quantity = serializers.FloatField()
# quantity = serializers.FloatField()
allocated = serializers.FloatField(source='allocation_count', required=False)
@ -142,19 +144,22 @@ class StockItemSerializer(InvenTreeModelSerializer):
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)
purchase_price = InvenTreeMoneySerializer(
purchase_price = InvenTree.serializers.InvenTreeMoneySerializer(
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(
choices=currency_code_mappings(),
default=currency_code_default,
label=_('Currency'),
help_text=_('Purchase currency of this stock item'),
)
purchase_price_string = serializers.SerializerMethodField()
@ -196,6 +201,7 @@ class StockItemSerializer(InvenTreeModelSerializer):
'belongs_to',
'build',
'customer',
'delete_on_deplete',
'expired',
'expiry_date',
'in_stock',
@ -204,6 +210,7 @@ class StockItemSerializer(InvenTreeModelSerializer):
'location',
'location_detail',
'notes',
'owner',
'packaging',
'part',
'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:
model = StockItem
fields = ('quantity',)
fields = [
'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
"""
@ -273,7 +396,7 @@ class LocationSerializer(InvenTreeModelSerializer):
]
class StockItemAttachmentSerializer(InvenTreeAttachmentSerializer):
class StockItemAttachmentSerializer(InvenTree.serializers.InvenTreeAttachmentSerializer):
""" Serializer for StockItemAttachment model """
def __init__(self, *args, **kwargs):
@ -284,9 +407,9 @@ class StockItemAttachmentSerializer(InvenTreeAttachmentSerializer):
if user_detail is not True:
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!
@ -311,14 +434,14 @@ class StockItemAttachmentSerializer(InvenTreeAttachmentSerializer):
]
class StockItemTestResultSerializer(InvenTreeModelSerializer):
class StockItemTestResultSerializer(InvenTree.serializers.InvenTreeModelSerializer):
""" 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)
attachment = InvenTreeAttachmentSerializerField(required=False)
attachment = InvenTree.serializers.InvenTreeAttachmentSerializerField(required=False)
def __init__(self, *args, **kwargs):
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 """
def __init__(self, *args, **kwargs):
@ -372,7 +495,7 @@ class StockTrackingSerializer(InvenTreeModelSerializer):
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)

View File

@ -410,20 +410,33 @@
<td>{{ item.requiredTestStatus.passed }} / {{ item.requiredTestStatus.total }}</td>
</tr>
{% endif %}
{% if item.owner %}
<tr>
<td><span class='fas fa-users'></span></td>
<td>{% trans "Owner" %}</td>
<td>{{ item.owner }}</td>
</tr>
{% endif %}
</table>
{% endblock %}
{% endblock details_right %}
{% block js_ready %}
{{ block.super }}
$("#stock-serialize").click(function() {
launchModalForm(
"{% url 'stock-item-serialize' item.id %}",
{
reload: true,
serializeStockItem({{ item.pk }}, {
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() {
@ -463,22 +476,16 @@ $("#print-label").click(function() {
{% if roles.stock.change %}
$("#stock-duplicate").click(function() {
createNewStockItem({
// Duplicate a stock item
duplicateStockItem({{ item.pk }}, {
follow: true,
data: {
copy: {{ item.id }},
}
});
});
$("#stock-edit").click(function () {
launchModalForm(
"{% url 'stock-item-edit' item.id %}",
{
reload: true,
submit_text: '{% trans "Save" %}',
}
);
$('#stock-edit').click(function() {
editStockItem({{ item.pk }}, {
reload: true,
});
});
$('#stock-edit-status').click(function () {

View File

@ -140,7 +140,15 @@
<div class='panel panel-hidden' id='panel-stock'>
<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 class='panel-content'>
{% include "stock_table.html" %}
@ -223,33 +231,21 @@
});
$('#location-create').click(function () {
launchModalForm("{% url 'stock-location-create' %}",
{
data: {
{% if location %}
location: {{ location.id }}
{% endif %}
},
follow: true,
secondary: [
{
field: 'parent',
label: '{% trans "New Location" %}',
title: '{% trans "Create new location" %}',
url: "{% url 'stock-location-create' %}",
},
]
});
return false;
createStockLocation({
{% if location %}
parent: {{ location.pk }},
{% endif %}
follow: true,
});
});
{% if location %}
$('#location-edit').click(function() {
launchModalForm("{% url 'stock-location-edit' location.id %}",
{
reload: true
});
return false;
editStockLocation({{ location.id }}, {
reload: true,
});
});
$('#location-delete').click(function() {
@ -312,12 +308,11 @@
$('#item-create').click(function () {
createNewStockItem({
follow: true,
data: {
{% if location %}
location: {{ location.id }}
{% endif %}
}
},
});
});

View File

@ -364,24 +364,22 @@ class StockItemTest(StockAPITestCase):
'part': 1,
'location': 1,
},
expected_code=201,
expected_code=400
)
# Item should have been created with default quantity
self.assertEqual(response.data['quantity'], 1)
self.assertIn('Quantity is required', str(response.data))
# POST with quantity and part and location
response = self.client.post(
response = self.post(
self.list_url,
data={
'part': 1,
'location': 1,
'quantity': 10,
}
},
expected_code=201
)
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
def test_default_expiry(self):
"""
Test that the "default_expiry" functionality works via the API.

View File

@ -7,11 +7,6 @@ from django.contrib.auth.models import Group
from common.models import InvenTreeSetting
import json
from datetime import datetime, timedelta
from InvenTree.status_codes import StockStatus
class StockViewTestCase(TestCase):
@ -63,149 +58,6 @@ class StockListTest(StockViewTestCase):
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):
""" Tests for stock ownership views """
@ -248,52 +100,39 @@ class StockOwnershipTest(StockViewTestCase):
InvenTreeSetting.set_setting('STOCK_OWNERSHIP_CONTROL', True, self.user)
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):
# Test stock location and item ownership
from .models import StockLocation, StockItem
from .models import StockLocation
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_owner = Owner.get_owner(new_user_group)
user_as_owner = Owner.get_owner(self.user)
new_user_as_owner = Owner.get_owner(self.new_user)
test_location_id = 4
test_item_id = 11
# Enable ownership control
self.enable_ownership()
# Set ownership on existing location
response = self.client.post(reverse('stock-location-edit', args=(test_location_id,)),
{'name': 'Office', 'owner': user_group_owner.pk},
HTTP_X_REQUESTED_WITH='XMLHttpRequest')
self.assertContains(response, '"form_valid": true', status_code=200)
test_location_id = 4
test_item_id = 11
# Set ownership on existing item (and change location)
response = self.client.post(reverse('stock-item-edit', args=(test_item_id,)),
{'part': 1, 'status': StockStatus.OK, 'owner': user_as_owner.pk},
HTTP_X_REQUESTED_WITH='XMLHttpRequest')
self.assertContains(response, '"form_valid": true', status_code=200)
# Logout
self.client.logout()
# Login with new user
self.client.login(username='john', password='custom123')
# Test location edit
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)
# TODO: Refactor this following test to use the new API form
# Test item edit
response = self.client.post(reverse('stock-item-edit', args=(test_item_id,)),
{'part': 1, 'status': StockStatus.OK, 'owner': new_user_as_owner.pk},
@ -310,38 +149,6 @@ class StockOwnershipTest(StockViewTestCase):
'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
location_created = StockLocation.objects.get(name=new_location['name'])
@ -372,16 +179,4 @@ class StockOwnershipTest(StockViewTestCase):
# 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)
"""

View File

@ -8,10 +8,7 @@ from stock import views
location_urls = [
url(r'^new/', views.StockLocationCreate.as_view(), name='stock-location-create'),
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'^qr_code/?', views.StockLocationQRCode.as_view(), name='stock-location-qr'),
@ -22,9 +19,7 @@ location_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'^serialize/', views.StockItemSerialize.as_view(), name='stock-item-serialize'),
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'^delete_test_data/', views.StockItemDeleteTestData.as_view(), name='stock-item-delete-test-data'),
@ -50,8 +45,6 @@ stock_urls = [
# Stock location
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'^track/', include(stock_tracking_urls)),

View File

@ -149,6 +149,10 @@ class StockLocationEdit(AjaxUpdateView):
"""
View for editing details of a StockLocation.
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
@ -927,6 +931,10 @@ class StockLocationCreate(AjaxCreateView):
"""
View for creating a new StockLocation
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
@ -1019,89 +1027,6 @@ class StockLocationCreate(AjaxCreateView):
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):
"""
View for creating a new StockItem

View File

@ -111,7 +111,13 @@ $(document).ready(function () {
// notifications
{% if messages %}
{% for message in messages %}
showAlertOrCache('{{ message }}', 'info', true);
showAlertOrCache(
'{{ message }}',
true,
{
style: 'info',
}
);
{% endfor %}
{% endif %}

View File

@ -10,7 +10,6 @@
modalSetSubmitText,
modalShowSubmitButton,
modalSubmit,
showAlertOrCache,
showQuestionDialog,
*/
@ -480,10 +479,13 @@ function barcodeCheckIn(location_id) {
$(modal).modal('hide');
if (status == 'success' && 'success' in response) {
showAlertOrCache(response.success, 'success', true);
addCachedAlert(response.success);
location.reload();
} 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');
if (status == 'success' && 'success' in response) {
showAlertOrCache(response.success, 'success', true);
addCachedAlert(response.success);
location.reload();
} else {
showAlertOrCache('{% trans "Error transferring stock" %}', 'danger', false);
showMessage('{% trans "Error transferring stock" %}', {
style: 'danger',
});
}
}
}

View File

@ -25,7 +25,12 @@
*/
/* exported
setFormGroupVisibility
clearFormInput,
disableFormInput,
enableFormInput,
hideFormInput,
setFormGroupVisibility,
showFormInput,
*/
/**
@ -113,6 +118,10 @@ function canDelete(OPTIONS) {
*/
function getApiEndpointOptions(url, callback) {
if (!url) {
return;
}
// Return the ajax request object
$.ajax({
url: url,
@ -182,6 +191,7 @@ function constructChangeForm(fields, options) {
// Request existing data from the API endpoint
$.ajax({
url: options.url,
data: options.params || {},
type: 'GET',
contentType: 'application/json',
dataType: 'json',
@ -197,6 +207,17 @@ function constructChangeForm(fields, options) {
fields[field].value = data[field];
}
}
// 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
options.instance = data;
@ -713,6 +734,8 @@ function submitFormData(fields, options) {
break;
default:
$(options.modal).modal('hide');
console.log(`upload error at ${options.url}`);
showApiError(xhr, options.url);
break;
}
@ -890,19 +913,19 @@ function handleFormSuccess(response, options) {
// Display any messages
if (response && response.success) {
showAlertOrCache(response.success, 'success', cache);
showAlertOrCache(response.success, cache, {style: 'success'});
}
if (response && response.info) {
showAlertOrCache(response.info, 'info', cache);
showAlertOrCache(response.info, cache, {style: 'info'});
}
if (response && response.warning) {
showAlertOrCache(response.warning, 'warning', cache);
showAlertOrCache(response.warning, cache, {style: 'warning'});
}
if (response && response.danger) {
showAlertOrCache(response.danger, 'dagner', cache);
showAlertOrCache(response.danger, cache, {style: 'danger'});
}
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
function hideFormGroup(group, options) {
$(options.modal).find(`#form-panel-${group}`).hide();

View File

@ -399,19 +399,19 @@ function afterForm(response, options) {
// Display any messages
if (response.success) {
showAlertOrCache(response.success, 'success', cache);
showAlertOrCache(response.success, cache, {style: 'success'});
}
if (response.info) {
showAlertOrCache(response.info, 'info', cache);
showAlertOrCache(response.info, cache, {style: 'info'});
}
if (response.warning) {
showAlertOrCache(response.warning, 'warning', cache);
showAlertOrCache(response.warning, cache, {style: 'warning'});
}
if (response.danger) {
showAlertOrCache(response.danger, 'danger', cache);
showAlertOrCache(response.danger, cache, {style: 'danger'});
}
// Was a callback provided?

View File

@ -4,9 +4,6 @@
/* globals
attachSelect,
enableField,
clearField,
clearFieldOptions,
closeModal,
constructField,
constructFormBody,
@ -33,10 +30,8 @@
printStockItemLabels,
printTestReports,
renderLink,
reloadFieldOptions,
scanItemsIntoLocation,
showAlertDialog,
setFieldValue,
setupFilterList,
showApiError,
stockStatusDisplay,
@ -44,6 +39,10 @@
/* exported
createNewStockItem,
createStockLocation,
duplicateStockItem,
editStockItem,
editStockLocation,
exportStock,
loadInstalledInTable,
loadStockLocationTable,
@ -51,20 +50,318 @@
loadStockTestResultsTable,
loadStockTrackingTable,
loadTableFilters,
locationFields,
removeStockRow,
serializeStockItem,
stockItemFields,
stockLocationFields,
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: {
help_text: '{% trans "Parent stock location" %}',
},
name: {},
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) {
/*
* Display a table showing the stock items which are installed in this stock item.

View File

@ -10,17 +10,10 @@
<div id='{{ prefix }}button-toolbar'>
<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" %}'>
<span class='fas fa-download'></span>
</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 %}
<!-- Barcode actions menu -->
<div class='btn-group' role='group'>
@ -46,7 +39,7 @@
</div>
{% if not read_only %}
{% 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" %}'>
<span class='fas fa-boxes'></span> <span class="caret"></span>
</button>
@ -66,7 +59,6 @@
</div>
{% endif %}
{% endif %}
{% endif %}
{% include "filter_list.html" with id="stock" %}
</div>
</div>