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