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