""" Django views for interacting with Stock app """ # -*- coding: utf-8 -*- from __future__ import unicode_literals from django.core.exceptions import ValidationError from django.views.generic.edit import FormMixin from django.views.generic import DetailView, ListView, UpdateView from django.forms.models import model_to_dict from django.forms import HiddenInput from django.urls import reverse from django.contrib.auth import get_user_model from django.contrib.auth.models import Group from django.utils.translation import ugettext as _ from moneyed import CURRENCIES from InvenTree.views import AjaxView from InvenTree.views import AjaxUpdateView, AjaxDeleteView, AjaxCreateView from InvenTree.views import QRCodeView from InvenTree.views import InvenTreeRoleMixin from InvenTree.forms import ConfirmForm from InvenTree.helpers import str2bool, DownloadFile, GetExportFormats from InvenTree.helpers import extract_serial_numbers from decimal import Decimal, InvalidOperation from datetime import datetime, timedelta from company.models import Company, SupplierPart from part.models import Part from .models import StockItem, StockLocation, StockItemTracking, StockItemAttachment, StockItemTestResult import common.settings from common.models import InvenTreeSetting from users.models import Owner from .admin import StockItemResource from . import forms as StockForms class StockIndex(InvenTreeRoleMixin, ListView): """ StockIndex view loads all StockLocation and StockItem object """ model = StockItem template_name = 'stock/location.html' context_obect_name = 'locations' role_required = 'stock.view' def get_context_data(self, **kwargs): context = super(StockIndex, self).get_context_data(**kwargs).copy() # Return all top-level locations locations = StockLocation.objects.filter(parent=None) context['locations'] = locations context['items'] = StockItem.objects.all() context['loc_count'] = StockLocation.objects.count() context['stock_count'] = StockItem.objects.count() return context class StockLocationDetail(InvenTreeRoleMixin, DetailView): """ Detailed view of a single StockLocation object """ context_object_name = 'location' template_name = 'stock/location.html' queryset = StockLocation.objects.all() model = StockLocation role_required = ['stock_location.view', 'stock.view'] class StockItemDetail(InvenTreeRoleMixin, DetailView): """ Detailed view of a single StockItem object """ context_object_name = 'item' template_name = 'stock/item.html' queryset = StockItem.objects.all() model = StockItem role_required = 'stock.view' class StockItemNotes(InvenTreeRoleMixin, UpdateView): """ View for editing the 'notes' field of a StockItem object """ context_object_name = 'item' template_name = 'stock/item_notes.html' model = StockItem role_required = 'stock.view' fields = ['notes'] def get_success_url(self): return reverse('stock-item-notes', kwargs={'pk': self.get_object().id}) def get_context_data(self, **kwargs): ctx = super().get_context_data(**kwargs) ctx['editing'] = str2bool(self.request.GET.get('edit', '')) return ctx 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 """ model = StockLocation form_class = StockForms.EditStockLocationForm context_object_name = 'location' ajax_template_name = 'modal_form.html' ajax_form_title = _('Edit Stock Location') role_required = 'stock_location.change' def get_form(self): """ Customize form data for StockLocation editing. Limit the choices for 'parent' field to those which make sense. If ownership control is enabled and location has parent, disable owner field. """ form = super(AjaxUpdateView, self).get_form() location = self.get_object() # Remove any invalid choices for the 'parent' field parent_choices = StockLocation.objects.all() parent_choices = parent_choices.exclude(id__in=location.getUniqueChildren()) form.fields['parent'].queryset = parent_choices # Is ownership control enabled? stock_ownership_control = InvenTreeSetting.get_setting('STOCK_OWNERSHIP_CONTROL') if not stock_ownership_control: # Hide owner field form.fields['owner'].widget = HiddenInput() else: # Get location's owner location_owner = location.owner if location_owner: if location.parent: try: # If location has parent and owner: automatically select parent's owner parent_owner = location.parent.owner form.fields['owner'].initial = parent_owner except AttributeError: pass else: # If current owner exists: automatically select it form.fields['owner'].initial = location_owner # Update queryset or disable field (only if not admin) if not self.request.user.is_superuser: if type(location_owner.owner) is Group: user_as_owner = Owner.get_owner(self.request.user) queryset = location_owner.get_related_owners(include_group=True) if user_as_owner not in queryset: # Only owners or admin can change current owner form.fields['owner'].disabled = True else: form.fields['owner'].queryset = queryset return form def save(self, object, form, **kwargs): """ If location has children and ownership control is enabled: - update owner of all children location of this location - update owner for all stock items at this location """ self.object = form.save() # Is ownership control enabled? stock_ownership_control = InvenTreeSetting.get_setting('STOCK_OWNERSHIP_CONTROL') if stock_ownership_control: # Get authorized users authorized_owners = self.object.owner.get_related_owners() # Update children locations children_locations = self.object.get_children() for child in children_locations: # Check if current owner is subset of new owner if child.owner and authorized_owners: if child.owner in authorized_owners: continue child.owner = self.object.owner child.save() # Update stock items stock_items = self.object.get_stock_items() for stock_item in stock_items: # Check if current owner is subset of new owner if stock_item.owner and authorized_owners: if stock_item.owner in authorized_owners: continue stock_item.owner = self.object.owner stock_item.save() return self.object def validate(self, item, form): """ Check that owner is set if stock ownership control is enabled """ parent = form.cleaned_data.get('parent', None) owner = form.cleaned_data.get('owner', None) # Is ownership control enabled? stock_ownership_control = InvenTreeSetting.get_setting('STOCK_OWNERSHIP_CONTROL') if stock_ownership_control: if not owner and not self.request.user.is_superuser: form.add_error('owner', _('Owner is required (ownership control is enabled)')) else: try: if parent.owner: if parent.owner != owner: error = f'Owner requires to be equivalent to parent\'s owner ({parent.owner})' form.add_error('owner', error) except AttributeError: # No parent pass class StockLocationQRCode(QRCodeView): """ View for displaying a QR code for a StockLocation object """ ajax_form_title = _("Stock Location QR code") role_required = ['stock_location.view', 'stock.view'] def get_qr_data(self): """ Generate QR code data for the StockLocation """ try: loc = StockLocation.objects.get(id=self.pk) return loc.format_barcode() except StockLocation.DoesNotExist: return None class StockItemAttachmentCreate(AjaxCreateView): """ View for adding a new attachment for a StockItem """ model = StockItemAttachment form_class = StockForms.EditStockItemAttachmentForm ajax_form_title = _("Add Stock Item Attachment") ajax_template_name = "modal_form.html" role_required = 'stock.add' def save(self, form, **kwargs): """ Record the user that uploaded the attachment """ attachment = form.save(commit=False) attachment.user = self.request.user attachment.save() def get_data(self): return { 'success': _("Added attachment") } def get_initial(self): """ Get initial data for the new StockItem attachment object. - Client must provide a valid StockItem ID """ initials = super().get_initial() try: initials['stock_item'] = StockItem.objects.get(id=self.request.GET.get('item', None)) except (ValueError, StockItem.DoesNotExist): pass return initials def get_form(self): form = super().get_form() form.fields['stock_item'].widget = HiddenInput() return form class StockItemAttachmentEdit(AjaxUpdateView): """ View for editing a StockItemAttachment object. """ model = StockItemAttachment form_class = StockForms.EditStockItemAttachmentForm ajax_form_title = _("Edit Stock Item Attachment") role_required = 'stock.change' def get_form(self): form = super().get_form() form.fields['stock_item'].widget = HiddenInput() return form class StockItemAttachmentDelete(AjaxDeleteView): """ View for deleting a StockItemAttachment object. """ model = StockItemAttachment ajax_form_title = _("Delete Stock Item Attachment") ajax_template_name = "attachment_delete.html" context_object_name = "attachment" role_required = 'stock.delete' def get_data(self): return { 'danger': _("Deleted attachment"), } class StockItemAssignToCustomer(AjaxUpdateView): """ View for manually assigning a StockItem to a Customer """ model = StockItem ajax_form_title = _("Assign to Customer") context_object_name = "item" form_class = StockForms.AssignStockItemToCustomerForm role_required = 'stock.change' def validate(self, item, form, **kwargs): customer = form.cleaned_data.get('customer', None) if not customer: form.add_error('customer', _('Customer must be specified')) def save(self, item, form, **kwargs): """ Assign the stock item to the customer. """ customer = form.cleaned_data.get('customer', None) if customer: item = item.allocateToCustomer( customer, user=self.request.user ) item.clearAllocations() class StockItemReturnToStock(AjaxUpdateView): """ View for returning a stock item (which is assigned to a customer) to stock. """ model = StockItem ajax_form_title = _("Return to Stock") context_object_name = "item" form_class = StockForms.ReturnStockItemForm role_required = 'stock.change' def validate(self, item, form, **kwargs): location = form.cleaned_data.get('location', None) if not location: form.add_error('location', _('Specify a valid location')) def save(self, item, form, **kwargs): location = form.cleaned_data.get('location', None) if location: item.returnFromCustomer(location, self.request.user) def get_data(self): return { 'success': _('Stock item returned from customer') } class StockItemDeleteTestData(AjaxUpdateView): """ View for deleting all test data """ model = StockItem form_class = ConfirmForm ajax_form_title = _("Delete All Test Data") role_required = ['stock.change', 'stock.delete'] def get_form(self): return ConfirmForm() def post(self, request, *args, **kwargs): valid = False stock_item = StockItem.objects.get(pk=self.kwargs['pk']) form = self.get_form() confirm = str2bool(request.POST.get('confirm', False)) if confirm is not True: form.add_error('confirm', _('Confirm test data deletion')) form.add_error(None, _('Check the confirmation box')) else: stock_item.test_results.all().delete() valid = True data = { 'form_valid': valid, } return self.renderJsonResponse(request, form, data) class StockItemTestResultCreate(AjaxCreateView): """ View for adding a new StockItemTestResult """ model = StockItemTestResult form_class = StockForms.EditStockItemTestResultForm ajax_form_title = _("Add Test Result") role_required = 'stock.add' def save(self, form, **kwargs): """ Record the user that uploaded the test result """ result = form.save(commit=False) result.user = self.request.user result.save() def get_initial(self): initials = super().get_initial() try: stock_id = self.request.GET.get('stock_item', None) initials['stock_item'] = StockItem.objects.get(pk=stock_id) except (ValueError, StockItem.DoesNotExist): pass initials['test'] = self.request.GET.get('test', '') return initials def get_form(self): form = super().get_form() form.fields['stock_item'].widget = HiddenInput() return form class StockItemTestResultEdit(AjaxUpdateView): """ View for editing a StockItemTestResult """ model = StockItemTestResult form_class = StockForms.EditStockItemTestResultForm ajax_form_title = _("Edit Test Result") role_required = 'stock.change' def get_form(self): form = super().get_form() form.fields['stock_item'].widget = HiddenInput() return form class StockItemTestResultDelete(AjaxDeleteView): """ View for deleting a StockItemTestResult """ model = StockItemTestResult ajax_form_title = _("Delete Test Result") context_object_name = "result" role_required = 'stock.delete' class StockExportOptions(AjaxView): """ Form for selecting StockExport options """ model = StockLocation ajax_form_title = _('Stock Export Options') form_class = StockForms.ExportOptionsForm role_required = 'stock.view' def post(self, request, *args, **kwargs): self.request = request fmt = request.POST.get('file_format', 'csv').lower() cascade = str2bool(request.POST.get('include_sublocations', False)) # Format a URL to redirect to url = reverse('stock-export') url += '?format=' + fmt url += '&cascade=' + str(cascade) data = { 'form_valid': True, 'format': fmt, 'cascade': cascade } return self.renderJsonResponse(self.request, self.form_class(), data=data) def get(self, request, *args, **kwargs): return self.renderJsonResponse(request, self.form_class()) class StockExport(AjaxView): """ Export stock data from a particular location. Returns a file containing stock information for that location. """ model = StockItem role_required = 'stock.view' def get(self, request, *args, **kwargs): export_format = request.GET.get('format', 'csv').lower() # Check if a particular location was specified loc_id = request.GET.get('location', None) location = None if loc_id: try: location = StockLocation.objects.get(pk=loc_id) except (ValueError, StockLocation.DoesNotExist): pass # Check if a particular supplier was specified sup_id = request.GET.get('supplier', None) supplier = None if sup_id: try: supplier = Company.objects.get(pk=sup_id) except (ValueError, Company.DoesNotExist): pass # Check if a particular supplier_part was specified sup_part_id = request.GET.get('supplier_part', None) supplier_part = None if sup_part_id: try: supplier_part = SupplierPart.objects.get(pk=sup_part_id) except (ValueError, SupplierPart.DoesNotExist): pass # Check if a particular part was specified part_id = request.GET.get('part', None) part = None if part_id: try: part = Part.objects.get(pk=part_id) except (ValueError, Part.DoesNotExist): pass if export_format not in GetExportFormats(): export_format = 'csv' filename = 'InvenTree_Stocktake_{date}.{fmt}'.format( date=datetime.now().strftime("%d-%b-%Y"), fmt=export_format ) if location: # CHeck if locations should be cascading cascade = str2bool(request.GET.get('cascade', True)) stock_items = location.get_stock_items(cascade) else: cascade = True stock_items = StockItem.objects.all() if part: stock_items = stock_items.filter(part=part) if supplier: stock_items = stock_items.filter(supplier_part__supplier=supplier) if supplier_part: stock_items = stock_items.filter(supplier_part=supplier_part) # Filter out stock items that are not 'in stock' stock_items = stock_items.filter(StockItem.IN_STOCK_FILTER) # Pre-fetch related fields to reduce DB queries stock_items = stock_items.prefetch_related('part', 'supplier_part__supplier', 'location', 'purchase_order', 'build') dataset = StockItemResource().export(queryset=stock_items) filedata = dataset.export(export_format) return DownloadFile(filedata, filename) class StockItemQRCode(QRCodeView): """ View for displaying a QR code for a StockItem object """ ajax_form_title = _("Stock Item QR Code") role_required = 'stock.view' def get_qr_data(self): """ Generate QR code data for the StockItem """ try: item = StockItem.objects.get(id=self.pk) return item.format_barcode() except StockItem.DoesNotExist: return None class StockItemInstall(AjaxUpdateView): """ View for manually installing stock items into a particular stock item. In contrast to the StockItemUninstall view, only a single stock item can be installed at once. The "part" to be installed must be provided in the GET query parameters. """ model = StockItem form_class = StockForms.InstallStockForm ajax_form_title = _('Install Stock Item') ajax_template_name = "stock/item_install.html" role_required = 'stock.change' part = None def get_stock_items(self): """ Return a list of stock items suitable for displaying to the user. Requirements: - Items must be in stock Filters: - Items can be filtered by Part reference """ items = StockItem.objects.filter(StockItem.IN_STOCK_FILTER) # Filter by Part association # Look at GET params part_id = self.request.GET.get('part', None) if part_id is None: # Look at POST params part_id = self.request.POST.get('part', None) try: self.part = Part.objects.get(pk=part_id) items = items.filter(part=self.part) except (ValueError, Part.DoesNotExist): self.part = None return items def get_initial(self): initials = super().get_initial() items = self.get_stock_items() # If there is a single stock item available, we can use it! if items.count() == 1: item = items.first() initials['stock_item'] = item.pk initials['quantity_to_install'] = item.quantity if self.part: initials['part'] = self.part return initials def get_form(self): form = super().get_form() form.fields['stock_item'].queryset = self.get_stock_items() return form def post(self, request, *args, **kwargs): form = self.get_form() valid = form.is_valid() if valid: # We assume by this point that we have a valid stock_item and quantity values data = form.cleaned_data other_stock_item = data['stock_item'] quantity = data['quantity_to_install'] notes = data['notes'] # Install the other stock item into this one this_stock_item = self.get_object() this_stock_item.installStockItem(other_stock_item, quantity, request.user, notes) data = { 'form_valid': valid, } return self.renderJsonResponse(request, form, data=data) class StockItemUninstall(AjaxView, FormMixin): """ View for uninstalling one or more StockItems, which are installed in another stock item. Stock items are uninstalled into a location, defaulting to the location that they were "in" before they were installed. If multiple default locations are detected, leave the final location up to the user. """ ajax_template_name = 'stock/stock_uninstall.html' ajax_form_title = _('Uninstall Stock Items') form_class = StockForms.UninstallStockForm role_required = 'stock.change' # List of stock items to uninstall (initially empty) stock_items = [] def get_stock_items(self): return self.stock_items def get_initial(self): initials = super().get_initial().copy() # Keep track of the current locations of stock items current_locations = set() # Keep track of the default locations for stock items default_locations = set() for item in self.stock_items: if item.location: current_locations.add(item.location) if item.part.default_location: default_locations.add(item.part.default_location) if len(current_locations) == 1: # If the selected stock items are currently in a single location, # select that location as the destination. initials['location'] = next(iter(current_locations)) elif len(current_locations) == 0: # There are no current locations set if len(default_locations) == 1: # Select the single default location initials['location'] = next(iter(default_locations)) return initials def get(self, request, *args, **kwargs): """ Extract list of stock items, which are supplied as a list, e.g. items[]=1,2,3 """ if 'items[]' in request.GET: self.stock_items = StockItem.objects.filter(id__in=request.GET.getlist('items[]')) else: self.stock_items = [] return self.renderJsonResponse(request, self.get_form()) def post(self, request, *args, **kwargs): """ Extract a list of stock items which are included as hidden inputs in the form data. """ items = [] for item in self.request.POST: if item.startswith('stock-item-'): pk = item.replace('stock-item-', '') try: stock_item = StockItem.objects.get(pk=pk) items.append(stock_item) except (ValueError, StockItem.DoesNotExist): pass self.stock_items = items # Assume the form is valid, until it isn't! valid = True confirmed = str2bool(request.POST.get('confirm')) note = request.POST.get('note', '') location = request.POST.get('location', None) if location: try: location = StockLocation.objects.get(pk=location) except (ValueError, StockLocation.DoesNotExist): location = None if not location: # Location is required! valid = False form = self.get_form() if not confirmed: valid = False form.add_error('confirm', _('Confirm stock adjustment')) data = { 'form_valid': valid, } if valid: # Ok, now let's actually uninstall the stock items for item in self.stock_items: item.uninstallIntoLocation(location, request.user, note) data['success'] = _('Uninstalled stock items') return self.renderJsonResponse(request, form=form, data=data) def get_context_data(self): context = super().get_context_data() context['stock_items'] = self.get_stock_items() return context class StockAdjust(AjaxView, FormMixin): """ View for enacting simple stock adjustments: - Take items from stock - Add items to stock - Count items - Move stock - Delete stock items """ ajax_template_name = 'stock/stock_adjust.html' ajax_form_title = _('Adjust Stock') form_class = StockForms.AdjustStockForm stock_items = [] role_required = 'stock.change' def get_GET_items(self): """ Return list of stock items initally requested using GET. Items can be retrieved by: a) List of stock ID - stock[]=1,2,3,4,5 b) Parent part - part=3 c) Parent location - location=78 d) Single item - item=2 """ # Start with all 'in stock' items items = StockItem.objects.filter(StockItem.IN_STOCK_FILTER) # Client provides a list of individual stock items if 'stock[]' in self.request.GET: items = items.filter(id__in=self.request.GET.getlist('stock[]')) # Client provides a PART reference elif 'part' in self.request.GET: items = items.filter(part=self.request.GET.get('part')) # Client provides a LOCATION reference elif 'location' in self.request.GET: items = items.filter(location=self.request.GET.get('location')) # Client provides a single StockItem lookup elif 'item' in self.request.GET: items = [StockItem.objects.get(id=self.request.GET.get('item'))] # Unsupported query (no items) else: items = [] for item in items: # Initialize quantity to zero for addition/removal if self.stock_action in ['take', 'add']: item.new_quantity = 0 # Initialize quantity at full amount for counting or moving else: item.new_quantity = item.quantity return items def get_POST_items(self): """ Return list of stock items sent back by client on a POST request """ items = [] for item in self.request.POST: if item.startswith('stock-id-'): pk = item.replace('stock-id-', '') q = self.request.POST[item] try: stock_item = StockItem.objects.get(pk=pk) except StockItem.DoesNotExist: continue stock_item.new_quantity = q items.append(stock_item) return items def get_context_data(self): context = super().get_context_data() context['stock_items'] = self.stock_items context['stock_action'] = self.stock_action.strip().lower() context['stock_action_title'] = self.stock_action.capitalize() # Quantity column will be read-only in some circumstances context['edit_quantity'] = not self.stock_action == 'delete' return context def get_form(self): form = super().get_form() if not self.stock_action == 'move': form.fields.pop('destination') form.fields.pop('set_loc') return form def get(self, request, *args, **kwargs): self.request = request # Action self.stock_action = request.GET.get('action', '').lower() # Pick a default action... if self.stock_action not in ['move', 'count', 'take', 'add', 'delete']: self.stock_action = 'count' # Choose the form title based on the action titles = { 'move': _('Move Stock Items'), 'count': _('Count Stock Items'), 'take': _('Remove From Stock'), 'add': _('Add Stock Items'), 'delete': _('Delete Stock Items') } self.ajax_form_title = titles[self.stock_action] # Save list of items! self.stock_items = self.get_GET_items() return self.renderJsonResponse(request, self.get_form()) def post(self, request, *args, **kwargs): self.request = request self.stock_action = request.POST.get('stock_action', 'invalid').strip().lower() # Update list of stock items self.stock_items = self.get_POST_items() form = self.get_form() valid = form.is_valid() for item in self.stock_items: try: item.new_quantity = Decimal(item.new_quantity) except ValueError: item.error = _('Must enter integer value') valid = False continue if item.new_quantity < 0: item.error = _('Quantity must be positive') valid = False continue if self.stock_action in ['move', 'take']: if item.new_quantity > item.quantity: item.error = _('Quantity must not exceed {x}'.format(x=item.quantity)) valid = False continue confirmed = str2bool(request.POST.get('confirm')) if not confirmed: valid = False form.add_error('confirm', _('Confirm stock adjustment')) data = { 'form_valid': valid, } if valid: result = self.do_action() data['success'] = result # Special case - Single Stock Item # If we deplete the stock item, we MUST redirect to a new view single_item = len(self.stock_items) == 1 if result and single_item: # Was the entire stock taken? item = self.stock_items[0] if item.quantity == 0: # Instruct the form to redirect data['url'] = reverse('stock-index') return self.renderJsonResponse(request, form, data=data) def do_action(self): """ Perform stock adjustment action """ if self.stock_action == 'move': destination = None set_default_loc = str2bool(self.request.POST.get('set_loc', False)) try: destination = StockLocation.objects.get(id=self.request.POST.get('destination')) except StockLocation.DoesNotExist: pass except ValueError: pass return self.do_move(destination, set_default_loc) elif self.stock_action == 'add': return self.do_add() elif self.stock_action == 'take': return self.do_take() elif self.stock_action == 'count': return self.do_count() elif self.stock_action == 'delete': return self.do_delete() else: return _('No action performed') def do_add(self): count = 0 note = self.request.POST['note'] for item in self.stock_items: if item.new_quantity <= 0: continue item.add_stock(item.new_quantity, self.request.user, notes=note) count += 1 return f"{_('Added stock to ')} {count} {_('items')}" def do_take(self): count = 0 note = self.request.POST['note'] for item in self.stock_items: if item.new_quantity <= 0: continue item.take_stock(item.new_quantity, self.request.user, notes=note) count += 1 return f"{_('Removed stock from ')} {count} {_('items')}" def do_count(self): count = 0 note = self.request.POST['note'] for item in self.stock_items: item.stocktake(item.new_quantity, self.request.user, notes=note) count += 1 return _("Counted stock for {n} items".format(n=count)) def do_move(self, destination, set_loc=None): """ Perform actual stock movement """ count = 0 note = self.request.POST['note'] for item in self.stock_items: # Avoid moving zero quantity if item.new_quantity <= 0: continue # If we wish to set the destination location to the default one if set_loc: item.part.default_location = destination item.part.save() # Do not move to the same location (unless the quantity is different) if destination == item.location and item.new_quantity == item.quantity: continue item.move(destination, note, self.request.user, quantity=item.new_quantity) count += 1 # Is ownership control enabled? stock_ownership_control = InvenTreeSetting.get_setting('STOCK_OWNERSHIP_CONTROL') if stock_ownership_control: # Fetch destination owner destination_owner = destination.owner if destination_owner: # Update owner item.owner = destination_owner item.save() if count == 0: return _('No items were moved') else: return _('Moved {n} items to {dest}'.format( n=count, dest=destination.pathstring)) def do_delete(self): """ Delete multiple stock items """ count = 0 # note = self.request.POST['note'] for item in self.stock_items: # TODO - In the future, StockItems should not be 'deleted' # TODO - Instead, they should be marked as "inactive" item.delete() count += 1 return _("Deleted {n} stock items".format(n=count)) class StockItemEdit(AjaxUpdateView): """ View for editing details of a single StockItem """ model = StockItem form_class = StockForms.EditStockItemForm context_object_name = 'item' ajax_template_name = 'modal_form.html' ajax_form_title = _('Edit Stock Item') role_required = 'stock.change' def get_form(self): """ Get form for StockItem editing. Limit the choices for supplier_part """ form = super(AjaxUpdateView, self).get_form() # Hide the "expiry date" field if the feature is not enabled if not common.settings.stock_expiry_enabled(): form.fields.pop('expiry_date') item = self.get_object() # If the part cannot be purchased, hide the supplier_part field if not item.part.purchaseable: form.fields['supplier_part'].widget = HiddenInput() form.fields.pop('purchase_price') else: query = form.fields['supplier_part'].queryset query = query.filter(part=item.part.id) form.fields['supplier_part'].queryset = query # Hide the serial number field if it is not required if not item.part.trackable and not item.serialized: form.fields['serial'].widget = HiddenInput() location = item.location # Is ownership control enabled? stock_ownership_control = InvenTreeSetting.get_setting('STOCK_OWNERSHIP_CONTROL') if not stock_ownership_control: form.fields['owner'].widget = HiddenInput() else: try: location_owner = location.owner except AttributeError: location_owner = None # Check if location has owner if location_owner: form.fields['owner'].initial = location_owner # Check location's owner type and filter potential owners if type(location_owner.owner) is Group: user_as_owner = Owner.get_owner(self.request.user) queryset = location_owner.get_related_owners(include_group=True) if user_as_owner in queryset: form.fields['owner'].initial = user_as_owner form.fields['owner'].queryset = queryset elif type(location_owner.owner) is get_user_model(): # If location's owner is a user: automatically set owner field and disable it form.fields['owner'].disabled = True form.fields['owner'].initial = location_owner try: item_owner = item.owner except AttributeError: item_owner = None # Check if item has owner if item_owner: form.fields['owner'].initial = item_owner # Check item's owner type and filter potential owners if type(item_owner.owner) is Group: user_as_owner = Owner.get_owner(self.request.user) queryset = item_owner.get_related_owners(include_group=True) if user_as_owner in queryset: form.fields['owner'].initial = user_as_owner form.fields['owner'].queryset = queryset elif type(item_owner.owner) is get_user_model(): # If item's owner is a user: automatically set owner field and disable it form.fields['owner'].disabled = True form.fields['owner'].initial = item_owner return form def validate(self, item, form): """ Check that owner is set if stock ownership control is enabled """ owner = form.cleaned_data.get('owner', None) # Is ownership control enabled? stock_ownership_control = InvenTreeSetting.get_setting('STOCK_OWNERSHIP_CONTROL') if stock_ownership_control: if not owner and not self.request.user.is_superuser: form.add_error('owner', _('Owner is required (ownership control is enabled)')) class StockItemConvert(AjaxUpdateView): """ View for 'converting' a StockItem to a variant of its current part. """ model = StockItem form_class = StockForms.ConvertStockItemForm ajax_form_title = _('Convert Stock Item') ajax_template_name = 'stock/stockitem_convert.html' context_object_name = 'item' role_required = 'stock.change' def get_form(self): """ Filter the available parts. """ form = super().get_form() item = self.get_object() form.fields['part'].queryset = item.part.get_all_variants() return form class StockLocationCreate(AjaxCreateView): """ View for creating a new StockLocation A parent location (another StockLocation object) can be passed as a query parameter """ model = StockLocation form_class = StockForms.EditStockLocationForm context_object_name = 'location' ajax_template_name = 'modal_form.html' ajax_form_title = _('Create new Stock Location') role_required = 'stock_location.add' def get_initial(self): initials = super(StockLocationCreate, self).get_initial().copy() loc_id = self.request.GET.get('location', None) if loc_id: try: initials['parent'] = StockLocation.objects.get(pk=loc_id) except StockLocation.DoesNotExist: pass return initials def get_form(self): """ Disable owner field when: - creating child location - and stock ownership control is enable """ form = super().get_form() # Is ownership control enabled? stock_ownership_control = InvenTreeSetting.get_setting('STOCK_OWNERSHIP_CONTROL') if not stock_ownership_control: # Hide owner field form.fields['owner'].widget = HiddenInput() else: # If user did not selected owner: automatically match to parent's owner if not form['owner'].data: try: parent_id = form['parent'].value() parent = StockLocation.objects.get(pk=parent_id) if parent: form.fields['owner'].initial = parent.owner if not self.request.user.is_superuser: form.fields['owner'].disabled = True except StockLocation.DoesNotExist: pass except ValueError: pass return form def save(self, form): """ If parent location exists then use it to set the owner """ self.object = form.save(commit=False) parent = form.cleaned_data.get('parent', None) if parent: # Select parent's owner self.object.owner = parent.owner self.object.save() return self.object def validate(self, item, form): """ Check that owner is set if stock ownership control is enabled """ parent = form.cleaned_data.get('parent', None) owner = form.cleaned_data.get('owner', None) # Is ownership control enabled? stock_ownership_control = InvenTreeSetting.get_setting('STOCK_OWNERSHIP_CONTROL') if stock_ownership_control: if not owner and not self.request.user.is_superuser: form.add_error('owner', _('Owner is required (ownership control is enabled)')) else: try: if parent.owner: if parent.owner != owner: error = f'Owner requires to be equivalent to parent\'s owner ({parent.owner})' form.add_error('owner', error) except AttributeError: # No parent 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 role_required = 'stock.change' 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 Parameters can be pre-filled by passing query items: - part: The part of which the new StockItem is an instance - location: The location of the new StockItem If the parent part is a "tracked" part, provide an option to create uniquely serialized items rather than a bulk quantity of stock items """ model = StockItem form_class = StockForms.CreateStockItemForm context_object_name = 'item' ajax_template_name = 'modal_form.html' ajax_form_title = _('Create new Stock Item') role_required = 'stock.add' def get_part(self, form=None): """ Attempt to get the "part" associted with this new stockitem. - May be passed to the form as a query parameter (e.g. ?part=) - May be passed via the form field itself. """ # Try to extract from the URL query part_id = self.request.GET.get('part', None) if part_id: try: part = Part.objects.get(pk=part_id) return part except (Part.DoesNotExist, ValueError): pass # Try to get from the form if form: try: part_id = form['part'].value() part = Part.objects.get(pk=part_id) return part except (Part.DoesNotExist, ValueError): pass # Could not extract a part object return None def get_form(self): """ Get form for StockItem creation. Overrides the default get_form() method to intelligently limit ForeignKey choices based on other selections """ form = super().get_form() # Hide the "expiry date" field if the feature is not enabled if not common.settings.stock_expiry_enabled(): form.fields.pop('expiry_date') part = self.get_part(form=form) if part is not None: # Add placeholder text for the serial number field form.field_placeholder['serial_numbers'] = part.getSerialNumberString() form.rebuild_layout() if not part.purchaseable: form.fields.pop('purchase_price') # Hide the 'part' field (as a valid part is selected) # form.fields['part'].widget = HiddenInput() # Trackable parts get special consideration: if part.trackable: form.fields['delete_on_deplete'].disabled = True else: form.fields['serial_numbers'].disabled = True # If the part is NOT purchaseable, hide the supplier_part field if not part.purchaseable: form.fields['supplier_part'].widget = HiddenInput() else: # Pre-select the allowable SupplierPart options parts = form.fields['supplier_part'].queryset parts = parts.filter(part=part.id) form.fields['supplier_part'].queryset = parts # If there is one (and only one) supplier part available, pre-select it all_parts = parts.all() if len(all_parts) == 1: # TODO - This does NOT work for some reason? Ref build.views.BuildItemCreate form.fields['supplier_part'].initial = all_parts[0].id else: # No Part has been selected! # We must not provide *any* options for SupplierPart form.fields['supplier_part'].queryset = SupplierPart.objects.none() form.fields['serial_numbers'].disabled = True # Otherwise if the user has selected a SupplierPart, we know what Part they meant! if form['supplier_part'].value() is not None: pass location = None try: loc_id = form['location'].value() location = StockLocation.objects.get(pk=loc_id) except StockLocation.DoesNotExist: pass except ValueError: pass # Is ownership control enabled? stock_ownership_control = InvenTreeSetting.get_setting('STOCK_OWNERSHIP_CONTROL') if not stock_ownership_control: form.fields['owner'].widget = HiddenInput() else: try: location_owner = location.owner except AttributeError: location_owner = None if location_owner: # Check location's owner type and filter potential owners if type(location_owner.owner) is Group: user_as_owner = Owner.get_owner(self.request.user) queryset = location_owner.get_related_owners() if user_as_owner in queryset: form.fields['owner'].initial = user_as_owner form.fields['owner'].queryset = queryset elif type(location_owner.owner) is get_user_model(): # If location's owner is a user: automatically set owner field and disable it form.fields['owner'].disabled = True form.fields['owner'].initial = location_owner return form def get_initial(self): """ Provide initial data to create a new StockItem object """ # Is the client attempting to copy an existing stock item? item_to_copy = self.request.GET.get('copy', None) if item_to_copy: try: original = StockItem.objects.get(pk=item_to_copy) initials = model_to_dict(original) self.ajax_form_title = _("Duplicate Stock Item") except StockItem.DoesNotExist: initials = super(StockItemCreate, self).get_initial().copy() else: initials = super(StockItemCreate, self).get_initial().copy() part = self.get_part() loc_id = self.request.GET.get('location', None) sup_part_id = self.request.GET.get('supplier_part', None) location = None supplier_part = None if part is not None: initials['part'] = part initials['location'] = part.get_default_location() initials['supplier_part'] = part.default_supplier # If the part has a defined expiry period, extrapolate! if part.default_expiry > 0: expiry_date = datetime.now().date() + timedelta(days=part.default_expiry) initials['expiry_date'] = expiry_date currency_code = common.settings.currency_code_default() # SupplierPart field has been specified # It must match the Part, if that has been supplied if sup_part_id: try: supplier_part = SupplierPart.objects.get(pk=sup_part_id) if part is None or supplier_part.part == part: initials['supplier_part'] = supplier_part currency_code = supplier_part.supplier.currency_code except (ValueError, SupplierPart.DoesNotExist): pass # Location has been specified if loc_id: try: location = StockLocation.objects.get(pk=loc_id) initials['location'] = location except (ValueError, StockLocation.DoesNotExist): pass currency = CURRENCIES.get(currency_code, None) if currency: initials['purchase_price'] = (None, currency) return initials def validate(self, item, form): """ Extra form validation steps """ data = form.cleaned_data part = data.get('part', None) quantity = data.get('quantity', None) owner = data.get('owner', None) if not part: return if not quantity: return try: quantity = Decimal(quantity) except (ValueError, InvalidOperation): form.add_error('quantity', _('Invalid quantity provided')) return if quantity < 0: form.add_error('quantity', _('Quantity cannot be negative')) # Trackable parts are treated differently if part.trackable: sn = data.get('serial_numbers', '') sn = str(sn).strip() if len(sn) > 0: try: serials = extract_serial_numbers(sn, quantity) except ValidationError as e: serials = None form.add_error('serial_numbers', e.messages) if serials is not None: existing = part.find_conflicting_serial_numbers(serials) if len(existing) > 0: exists = ','.join([str(x) for x in existing]) form.add_error( 'serial_numbers', _('Serial numbers already exist') + ': ' + exists ) # Is ownership control enabled? stock_ownership_control = InvenTreeSetting.get_setting('STOCK_OWNERSHIP_CONTROL') if stock_ownership_control: # Check if owner is set if not owner and not self.request.user.is_superuser: form.add_error('owner', _('Owner is required (ownership control is enabled)')) return def save(self, form, **kwargs): """ Create a new StockItem based on the provided form data. """ data = form.cleaned_data part = data['part'] quantity = data['quantity'] if part.trackable: sn = data.get('serial_numbers', '') sn = str(sn).strip() # Create a single stock item for each provided serial number if len(sn) > 0: serials = extract_serial_numbers(sn, quantity) for serial in serials: item = StockItem( part=part, quantity=1, serial=serial, supplier_part=data.get('supplier_part', None), location=data.get('location', None), batch=data.get('batch', None), delete_on_deplete=False, status=data.get('status'), link=data.get('link', ''), ) item.save(user=self.request.user) # Create a single StockItem of the specified quantity else: form._post_clean() item = form.save(commit=False) item.user = self.request.user item.save(user=self.request.user) return item # Non-trackable part else: form._post_clean() item = form.save(commit=False) item.user = self.request.user item.save(user=self.request.user) return item class StockLocationDelete(AjaxDeleteView): """ View to delete a StockLocation Presents a deletion confirmation form to the user """ model = StockLocation success_url = '/stock' ajax_template_name = 'stock/location_delete.html' context_object_name = 'location' ajax_form_title = _('Delete Stock Location') role_required = 'stock_location.delete' class StockItemDelete(AjaxDeleteView): """ View to delete a StockItem Presents a deletion confirmation form to the user """ model = StockItem success_url = '/stock/' ajax_template_name = 'stock/item_delete.html' context_object_name = 'item' ajax_form_title = _('Delete Stock Item') role_required = 'stock.delete' class StockItemTrackingDelete(AjaxDeleteView): """ View to delete a StockItemTracking object Presents a deletion confirmation form to the user """ model = StockItemTracking ajax_template_name = 'stock/tracking_delete.html' ajax_form_title = _('Delete Stock Tracking Entry') role_required = 'stock.delete' class StockTrackingIndex(InvenTreeRoleMixin, ListView): """ StockTrackingIndex provides a page to display StockItemTracking objects """ model = StockItemTracking template_name = 'stock/tracking.html' context_object_name = 'items' role_required = 'stock.view' class StockItemTrackingEdit(AjaxUpdateView): """ View for editing a StockItemTracking object """ model = StockItemTracking ajax_form_title = _('Edit Stock Tracking Entry') form_class = StockForms.TrackingEntryForm role_required = 'stock.change' class StockItemTrackingCreate(AjaxCreateView): """ View for creating a new StockItemTracking object. """ model = StockItemTracking ajax_form_title = _("Add Stock Tracking Entry") form_class = StockForms.TrackingEntryForm role_required = 'stock.add' def post(self, request, *args, **kwargs): self.request = request self.form = self.get_form() valid = False if self.form.is_valid(): stock_id = self.kwargs['pk'] if stock_id: try: stock_item = StockItem.objects.get(id=stock_id) # Save new tracking information tracking = self.form.save(commit=False) tracking.item = stock_item tracking.user = self.request.user tracking.quantity = stock_item.quantity tracking.date = datetime.now().date() tracking.system = False tracking.save() valid = True except (StockItem.DoesNotExist, ValueError): pass data = { 'form_valid': valid } return self.renderJsonResponse(request, self.form, data=data)