diff --git a/InvenTree/InvenTree/static/script/inventree/notification.js b/InvenTree/InvenTree/static/script/inventree/notification.js index 399ba1d359..f6bdf3bc57 100644 --- a/InvenTree/InvenTree/static/script/inventree/notification.js +++ b/InvenTree/InvenTree/static/script/inventree/notification.js @@ -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(); diff --git a/InvenTree/company/templates/company/supplier_part.html b/InvenTree/company/templates/company/supplier_part.html index 52742cf488..276a9f7ebc 100644 --- a/InvenTree/company/templates/company/supplier_part.html +++ b/InvenTree/company/templates/company/supplier_part.html @@ -134,7 +134,15 @@ src="{% static 'img/blank_image.png' %}"
-

{% trans "Supplier Part Stock" %}

+ +

{% trans "Supplier Part Stock" %}

+ {% include "spacer.html" %} +
+ +
+
{% include "stock_table.html" %} @@ -314,7 +322,6 @@ $("#item-create").click(function() { part: {{ part.part.id }}, supplier_part: {{ part.id }}, }, - reload: true, }); }); diff --git a/InvenTree/order/templates/order/purchase_order_detail.html b/InvenTree/order/templates/order/purchase_order_detail.html index b0d9d7d301..257707347a 100644 --- a/InvenTree/order/templates/order/purchase_order_detail.html +++ b/InvenTree/order/templates/order/purchase_order_detail.html @@ -50,7 +50,7 @@

{% trans "Received Items" %}

- {% include "stock_table.html" with prevent_new_stock=True %} + {% include "stock_table.html" %}
diff --git a/InvenTree/part/templates/part/detail.html b/InvenTree/part/templates/part/detail.html index 706bf5e329..f03127e996 100644 --- a/InvenTree/part/templates/part/detail.html +++ b/InvenTree/part/templates/part/detail.html @@ -120,7 +120,15 @@
-

{% trans "Part Stock" %}

+
+

{% trans "Part Stock" %}

+ {% include "spacer.html" %} +
+ +
+
{% 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 }}, } diff --git a/InvenTree/stock/api.py b/InvenTree/stock/api.py index 1207312688..e287441382 100644 --- a/InvenTree/stock/api.py +++ b/InvenTree/stock/api.py @@ -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\d+)/', StockDetail.as_view(), name='api-stock-detail'), + # Detail views for a single stock item + url(r'^(?P\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'), diff --git a/InvenTree/stock/migrations/0067_alter_stockitem_part.py b/InvenTree/stock/migrations/0067_alter_stockitem_part.py new file mode 100644 index 0000000000..7f00b8f7b1 --- /dev/null +++ b/InvenTree/stock/migrations/0067_alter_stockitem_part.py @@ -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'), + ), + ] diff --git a/InvenTree/stock/models.py b/InvenTree/stock/models.py index eb0e6aa12f..320807e0c1 100644 --- a/InvenTree/stock/models.py +++ b/InvenTree/stock/models.py @@ -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 }) diff --git a/InvenTree/stock/serializers.py b/InvenTree/stock/serializers.py index d97e81331a..850ebcea3b 100644 --- a/InvenTree/stock/serializers.py +++ b/InvenTree/stock/serializers.py @@ -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) diff --git a/InvenTree/stock/templates/stock/item_base.html b/InvenTree/stock/templates/stock/item_base.html index 8da0db0296..f64c9b0704 100644 --- a/InvenTree/stock/templates/stock/item_base.html +++ b/InvenTree/stock/templates/stock/item_base.html @@ -410,20 +410,33 @@ {{ item.requiredTestStatus.passed }} / {{ item.requiredTestStatus.total }} {% endif %} + {% if item.owner %} + + + {% trans "Owner" %} + {{ item.owner }} + + {% endif %} -{% 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 () { diff --git a/InvenTree/stock/templates/stock/location.html b/InvenTree/stock/templates/stock/location.html index 521a7bdca8..18b78b2290 100644 --- a/InvenTree/stock/templates/stock/location.html +++ b/InvenTree/stock/templates/stock/location.html @@ -140,7 +140,15 @@
-

{% trans "Stock Items" %}

+
+

{% trans "Stock Items" %}

+ {% include "spacer.html" %} +
+ +
+
{% 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 %} - } + }, }); }); diff --git a/InvenTree/stock/test_api.py b/InvenTree/stock/test_api.py index d07c35aaf7..422f9f11ab 100644 --- a/InvenTree/stock/test_api.py +++ b/InvenTree/stock/test_api.py @@ -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. diff --git a/InvenTree/stock/test_views.py b/InvenTree/stock/test_views.py index 9494598430..36042b9bc2 100644 --- a/InvenTree/stock/test_views.py +++ b/InvenTree/stock/test_views.py @@ -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) + """ diff --git a/InvenTree/stock/urls.py b/InvenTree/stock/urls.py index 434acde84e..b28104f388 100644 --- a/InvenTree/stock/urls.py +++ b/InvenTree/stock/urls.py @@ -8,10 +8,7 @@ from stock import views location_urls = [ - url(r'^new/', views.StockLocationCreate.as_view(), name='stock-location-create'), - url(r'^(?P\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)), diff --git a/InvenTree/stock/views.py b/InvenTree/stock/views.py index eb5fabcc25..ba45314dcb 100644 --- a/InvenTree/stock/views.py +++ b/InvenTree/stock/views.py @@ -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 diff --git a/InvenTree/templates/account/base.html b/InvenTree/templates/account/base.html index fa2a34f79d..7f2486bfcc 100644 --- a/InvenTree/templates/account/base.html +++ b/InvenTree/templates/account/base.html @@ -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 %} diff --git a/InvenTree/templates/js/translated/barcode.js b/InvenTree/templates/js/translated/barcode.js index 4b61249d0b..2778983341 100644 --- a/InvenTree/templates/js/translated/barcode.js +++ b/InvenTree/templates/js/translated/barcode.js @@ -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', + }); } } } diff --git a/InvenTree/templates/js/translated/forms.js b/InvenTree/templates/js/translated/forms.js index 7ca2e77a3f..a86b64d0e2 100644 --- a/InvenTree/templates/js/translated/forms.js +++ b/InvenTree/templates/js/translated/forms.js @@ -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(); diff --git a/InvenTree/templates/js/translated/modals.js b/InvenTree/templates/js/translated/modals.js index aefdba604f..4cd0be8cec 100644 --- a/InvenTree/templates/js/translated/modals.js +++ b/InvenTree/templates/js/translated/modals.js @@ -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? diff --git a/InvenTree/templates/js/translated/stock.js b/InvenTree/templates/js/translated/stock.js index a62e049651..ec785969cd 100644 --- a/InvenTree/templates/js/translated/stock.js +++ b/InvenTree/templates/js/translated/stock.js @@ -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 = ` +
{% trans "Quantity" %}: ${response.quantity} +
{% 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. diff --git a/InvenTree/templates/stock_table.html b/InvenTree/templates/stock_table.html index 912d2e16a5..71c6734818 100644 --- a/InvenTree/templates/stock_table.html +++ b/InvenTree/templates/stock_table.html @@ -10,17 +10,10 @@
-
+
- - {% 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 %} - - {% endif %} {% if barcodes %}
@@ -46,7 +39,7 @@
{% if not read_only %} {% if roles.stock.change or roles.stock.delete %} -
+
@@ -66,7 +59,6 @@
{% endif %} {% endif %} - {% endif %} {% include "filter_list.html" with id="stock" %}