From 58efc952dbea42d9a71eed9f402cab54ea2d3140 Mon Sep 17 00:00:00 2001 From: eeintech Date: Mon, 5 Jul 2021 14:57:45 -0400 Subject: [PATCH 01/45] Converted BOM import to new multi-step form framework --- InvenTree/part/bom.py | 176 ----- InvenTree/part/forms.py | 31 +- InvenTree/part/templates/part/bom.html | 7 + .../part/bom_upload/match_fields.html | 99 +++ .../part/bom_upload/match_parts.html | 127 ++++ .../part/bom_upload/select_fields.html | 94 --- .../part/bom_upload/select_parts.html | 121 ---- .../part/bom_upload/upload_file.html | 32 +- InvenTree/part/views.py | 667 ++++-------------- 9 files changed, 423 insertions(+), 931 deletions(-) create mode 100644 InvenTree/part/templates/part/bom_upload/match_fields.html create mode 100644 InvenTree/part/templates/part/bom_upload/match_parts.html delete mode 100644 InvenTree/part/templates/part/bom_upload/select_fields.html delete mode 100644 InvenTree/part/templates/part/bom_upload/select_parts.html diff --git a/InvenTree/part/bom.py b/InvenTree/part/bom.py index 42f49f9dde..f52b295235 100644 --- a/InvenTree/part/bom.py +++ b/InvenTree/part/bom.py @@ -3,14 +3,9 @@ Functionality for Bill of Material (BOM) management. Primarily BOM upload tools. """ -from rapidfuzz import fuzz -import tablib -import os - from collections import OrderedDict from django.utils.translation import gettext_lazy as _ -from django.core.exceptions import ValidationError from InvenTree.helpers import DownloadFile, GetExportFormats @@ -326,174 +321,3 @@ def ExportBom(part, fmt='csv', cascade=False, max_levels=None, parameter_data=Fa filename = '{n}_BOM.{fmt}'.format(n=part.full_name, fmt=fmt) return DownloadFile(data, filename) - - -class BomUploadManager: - """ Class for managing an uploaded BOM file """ - - # Fields which are absolutely necessary for valid upload - REQUIRED_HEADERS = [ - 'Quantity' - ] - - # Fields which are used for part matching (only one of them is needed) - PART_MATCH_HEADERS = [ - 'Part_Name', - 'Part_IPN', - 'Part_ID', - ] - - # Fields which would be helpful but are not required - OPTIONAL_HEADERS = [ - 'Reference', - 'Note', - 'Overage', - ] - - EDITABLE_HEADERS = [ - 'Reference', - 'Note', - 'Overage' - ] - - HEADERS = REQUIRED_HEADERS + PART_MATCH_HEADERS + OPTIONAL_HEADERS - - def __init__(self, bom_file): - """ Initialize the BomUpload class with a user-uploaded file object """ - - self.process(bom_file) - - def process(self, bom_file): - """ Process a BOM file """ - - self.data = None - - ext = os.path.splitext(bom_file.name)[-1].lower() - - if ext in ['.csv', '.tsv', ]: - # These file formats need string decoding - raw_data = bom_file.read().decode('utf-8') - elif ext in ['.xls', '.xlsx']: - raw_data = bom_file.read() - else: - raise ValidationError({'bom_file': _('Unsupported file format: {f}').format(f=ext)}) - - try: - self.data = tablib.Dataset().load(raw_data) - except tablib.UnsupportedFormat: - raise ValidationError({'bom_file': _('Error reading BOM file (invalid data)')}) - except tablib.core.InvalidDimensions: - raise ValidationError({'bom_file': _('Error reading BOM file (incorrect row size)')}) - - def guess_header(self, header, threshold=80): - """ Try to match a header (from the file) to a list of known headers - - Args: - header - Header name to look for - threshold - Match threshold for fuzzy search - """ - - # Try for an exact match - for h in self.HEADERS: - if h == header: - return h - - # Try for a case-insensitive match - for h in self.HEADERS: - if h.lower() == header.lower(): - return h - - # Try for a case-insensitive match with space replacement - for h in self.HEADERS: - if h.lower() == header.lower().replace(' ', '_'): - return h - - # Finally, look for a close match using fuzzy matching - matches = [] - - for h in self.HEADERS: - ratio = fuzz.partial_ratio(header, h) - if ratio > threshold: - matches.append({'header': h, 'match': ratio}) - - if len(matches) > 0: - matches = sorted(matches, key=lambda item: item['match'], reverse=True) - return matches[0]['header'] - - return None - - def columns(self): - """ Return a list of headers for the thingy """ - headers = [] - - for header in self.data.headers: - headers.append({ - 'name': header, - 'guess': self.guess_header(header) - }) - - return headers - - def col_count(self): - if self.data is None: - return 0 - - return len(self.data.headers) - - def row_count(self): - """ Return the number of rows in the file. """ - - if self.data is None: - return 0 - - return len(self.data) - - def rows(self): - """ Return a list of all rows """ - rows = [] - - for i in range(self.row_count()): - - data = [item for item in self.get_row_data(i)] - - # Is the row completely empty? Skip! - empty = True - - for idx, item in enumerate(data): - if len(str(item).strip()) > 0: - empty = False - - try: - # Excel import casts number-looking-items into floats, which is annoying - if item == int(item) and not str(item) == str(int(item)): - data[idx] = int(item) - except ValueError: - pass - - # Skip empty rows - if empty: - continue - - row = { - 'data': data, - 'index': i - } - - rows.append(row) - - return rows - - def get_row_data(self, index): - """ Retrieve row data at a particular index """ - if self.data is None or index >= len(self.data): - return None - - return self.data[index] - - def get_row_dict(self, index): - """ Retrieve a dict object representing the data row at a particular offset """ - - if self.data is None or index >= len(self.data): - return None - - return self.data.dict[index] diff --git a/InvenTree/part/forms.py b/InvenTree/part/forms.py index 36a49006b0..3b60567695 100644 --- a/InvenTree/part/forms.py +++ b/InvenTree/part/forms.py @@ -11,10 +11,11 @@ from django.utils.translation import ugettext_lazy as _ from mptt.fields import TreeNodeChoiceField from InvenTree.forms import HelperForm -from InvenTree.helpers import GetExportFormats +from InvenTree.helpers import GetExportFormats, clean_decimal from InvenTree.fields import RoundingDecimalFormField import common.models +from common.forms import MatchItemForm from .models import Part, PartCategory, PartRelated from .models import BomItem @@ -143,16 +144,28 @@ class BomValidateForm(HelperForm): ] -class BomUploadSelectFile(HelperForm): - """ Form for importing a BOM. Provides a file input box for upload """ +class BomMatchItemForm(MatchItemForm): + """ Override MatchItemForm fields """ - bom_file = forms.FileField(label=_('BOM file'), required=True, help_text=_("Select BOM file to upload")) + def get_special_field(self, col_guess, row, file_manager): + """ Set special fields """ - class Meta: - model = Part - fields = [ - 'bom_file', - ] + # set quantity field + if 'quantity' in col_guess.lower(): + return forms.CharField( + required=False, + widget=forms.NumberInput(attrs={ + 'name': 'quantity' + str(row['index']), + 'class': 'numberinput', + 'type': 'number', + 'min': '0', + 'step': 'any', + 'value': clean_decimal(row.get('quantity', '')), + }) + ) + + # return default + return super().get_special_field(col_guess, row, file_manager) class CreatePartRelatedForm(HelperForm): diff --git a/InvenTree/part/templates/part/bom.html b/InvenTree/part/templates/part/bom.html index ba3e9be2b9..048b98fc01 100644 --- a/InvenTree/part/templates/part/bom.html +++ b/InvenTree/part/templates/part/bom.html @@ -11,6 +11,12 @@ {% endblock %} {% block details %} + +{% if roles.part.change != True and editing_enabled %} +
+ {% trans "You do not have permission to edit the BOM." %} +
+{% else %} {% if part.bom_checked_date %} {% if part.is_bom_valid %}
@@ -72,6 +78,7 @@
+{% endif %} {% endblock %} diff --git a/InvenTree/part/templates/part/bom_upload/match_fields.html b/InvenTree/part/templates/part/bom_upload/match_fields.html new file mode 100644 index 0000000000..d1f325aaee --- /dev/null +++ b/InvenTree/part/templates/part/bom_upload/match_fields.html @@ -0,0 +1,99 @@ +{% extends "part/bom_upload/upload_file.html" %} +{% load inventree_extras %} +{% load i18n %} +{% load static %} + +{% block form_alert %} +{% if missing_columns and missing_columns|length > 0 %} + +{% endif %} +{% if duplicates and duplicates|length > 0 %} + +{% endif %} +{% endblock form_alert %} + +{% block form_buttons_top %} + {% if wizard.steps.prev %} + + {% endif %} + +{% endblock form_buttons_top %} + +{% block form_content %} + + + {% trans "File Fields" %} + + {% for col in form %} + +
+ + {{ col.name }} + +
+ + {% endfor %} + + + + + {% trans "Match Fields" %} + + {% for col in form %} + + {{ col }} + {% for duplicate in duplicates %} + {% if duplicate == col.value %} + + {% endif %} + {% endfor %} + + {% endfor %} + + {% for row in rows %} + {% with forloop.counter as row_index %} + + + + + {{ row_index }} + {% for item in row.data %} + + + {{ item }} + + {% endfor %} + + {% endwith %} + {% endfor %} + +{% endblock form_content %} + +{% block form_buttons_bottom %} +{% endblock form_buttons_bottom %} + +{% block js_ready %} +{{ block.super }} + +$('.fieldselect').select2({ + width: '100%', + matcher: partialMatcher, +}); + +{% endblock %} \ No newline at end of file diff --git a/InvenTree/part/templates/part/bom_upload/match_parts.html b/InvenTree/part/templates/part/bom_upload/match_parts.html new file mode 100644 index 0000000000..078ae8122f --- /dev/null +++ b/InvenTree/part/templates/part/bom_upload/match_parts.html @@ -0,0 +1,127 @@ +{% extends "part/bom_upload/upload_file.html" %} +{% load inventree_extras %} +{% load i18n %} +{% load static %} +{% load crispy_forms_tags %} + +{% block form_alert %} +{% if form.errors %} +{% endif %} +{% if form_errors %} + +{% endif %} +{% endblock form_alert %} + +{% block form_buttons_top %} + {% if wizard.steps.prev %} + + {% endif %} + +{% endblock form_buttons_top %} + +{% block form_content %} + + + + {% trans "Row" %} + {% trans "Select Part" %} + {% trans "Reference" %} + {% trans "Quantity" %} + {% for col in columns %} + {% if col.guess != 'Quantity' %} + + + + {% if col.guess %} + {{ col.guess }} + {% else %} + {{ col.name }} + {% endif %} + + {% endif %} + {% endfor %} + + + + {% comment %} Dummy row for javascript del_row method {% endcomment %} + {% for row in rows %} + + + + + + {% add row.index 1 %} + + + {% for field in form.visible_fields %} + {% if field.name == row.item_select %} + {{ field }} + {% endif %} + {% endfor %} + {% if row.errors.part %} +

{{ row.errors.part }}

+ {% endif %} + + + {% for field in form.visible_fields %} + {% if field.name == row.reference %} + {{ field|as_crispy_field }} + {% endif %} + {% endfor %} + {% if row.errors.reference %} +

{{ row.errors.reference }}

+ {% endif %} + + + {% for field in form.visible_fields %} + {% if field.name == row.quantity %} + {{ field|as_crispy_field }} + {% endif %} + {% endfor %} + {% if row.errors.quantity %} +

{{ row.errors.quantity }}

+ {% endif %} + + {% for item in row.data %} + {% if item.column.guess != 'Quantity' %} + + {% if item.column.guess == 'Overage' %} + {% for field in form.visible_fields %} + {% if field.name == row.overage %} + {{ field|as_crispy_field }} + {% endif %} + {% endfor %} + {% elif item.column.guess == 'Note' %} + {% for field in form.visible_fields %} + {% if field.name == row.note %} + {{ field|as_crispy_field }} + {% endif %} + {% endfor %} + {% else %} + {{ item.cell }} + {% endif %} + + + {% endif %} + {% endfor %} + + {% endfor %} + +{% endblock form_content %} + +{% block form_buttons_bottom %} +{% endblock form_buttons_bottom %} + +{% block js_ready %} +{{ block.super }} + +$('.bomselect').select2({ + dropdownAutoWidth: true, + matcher: partialMatcher, +}); + +{% endblock %} \ No newline at end of file diff --git a/InvenTree/part/templates/part/bom_upload/select_fields.html b/InvenTree/part/templates/part/bom_upload/select_fields.html deleted file mode 100644 index e82223da88..0000000000 --- a/InvenTree/part/templates/part/bom_upload/select_fields.html +++ /dev/null @@ -1,94 +0,0 @@ -{% extends "part/part_base.html" %} -{% load static %} -{% load i18n %} -{% load inventree_extras %} - -{% block menubar %} -{% include "part/navbar.html" with tab='bom' %} -{% endblock %} - -{% block heading %} -{% trans "Upload Bill of Materials" %} -{% endblock %} - -{% block details %} - -

{% trans "Step 2 - Select Fields" %}

-
- -{% if missing_columns and missing_columns|length > 0 %} - -{% endif %} - -
- - {% csrf_token %} - - - - - - - - - {% for col in bom_columns %} - - {% endfor %} - - - - - - - {% for col in bom_columns %} - - {% endfor %} - - {% for row in bom_rows %} - - - - {% for item in row.data %} - - {% endfor %} - - {% endfor %} - -
{% trans "File Fields" %} -
- - {{ col.name }} - -
-
{% trans "Match Fields" %} - - {% if col.duplicate %} -

{% trans "Duplicate column selection" %}

- {% endif %} -
- - {{ forloop.counter }} - - {{ item.cell }} -
- -
- -{% endblock %} \ No newline at end of file diff --git a/InvenTree/part/templates/part/bom_upload/select_parts.html b/InvenTree/part/templates/part/bom_upload/select_parts.html deleted file mode 100644 index 41530e3c55..0000000000 --- a/InvenTree/part/templates/part/bom_upload/select_parts.html +++ /dev/null @@ -1,121 +0,0 @@ -{% extends "part/part_base.html" %} -{% load static %} -{% load i18n %} -{% load inventree_extras %} - -{% block menubar %} -{% include "part/navbar.html" with tab="bom" %} -{% endblock %} - -{% block heading %} -{% trans "Upload Bill of Materials" %} -{% endblock %} - -{% block details %} - -

{% trans "Step 3 - Select Parts" %}

-
- -{% if form_errors %} - -{% endif %} - -
- - - - {% csrf_token %} - {% load crispy_forms_tags %} - - - - - - - - - - - {% for col in bom_columns %} - - {% endfor %} - - - - {% for row in bom_rows %} - - - - - - {% for item in row.data %} - - {% endfor %} - - {% endfor %} - -
{% trans "Row" %}{% trans "Select Part" %} - - - {% if col.guess %} - {{ col.guess }} - {% else %} - {{ col.name }} - {% endif %} -
- - {% add row.index 1 %} - - - {% if row.errors.part %} -

{{ row.errors.part }}

- {% endif %} -
- {% if item.column.guess == 'Part' %} - {{ item.cell }} - {% if row.errors.part %} -

{{ row.errors.part }}

- {% endif %} - {% elif item.column.guess == 'Quantity' %} - - {% if row.errors.quantity %} -

{{ row.errors.quantity }}

- {% endif %} - {% elif item.column.guess == 'Reference' %} - - {% elif item.column.guess == 'Note' %} - - {% elif item.column.guess == 'Overage' %} - - {% else %} - {{ item.cell }} - {% endif %} - -
- -
- -{% endblock %} - -{% block js_ready %} -{{ block.super }} - -$('.bomselect').select2({ - dropdownAutoWidth: true, - matcher: partialMatcher, -}); - -{% endblock %} \ No newline at end of file diff --git a/InvenTree/part/templates/part/bom_upload/upload_file.html b/InvenTree/part/templates/part/bom_upload/upload_file.html index 148d32f5da..88592c5ffc 100644 --- a/InvenTree/part/templates/part/bom_upload/upload_file.html +++ b/InvenTree/part/templates/part/bom_upload/upload_file.html @@ -8,13 +8,12 @@ {% endblock %} {% block heading %} -{% trans "Upload Bill of Materials" %} +{% trans "Upload BOM File" %} {% endblock %} {% block details %} -

{% trans "Step 1 - Select BOM File" %}

- +{% block form_alert %}
{% trans "Requirements for BOM upload" %}:
    @@ -22,16 +21,31 @@
  • {% trans "Each part must already exist in the database" %}
+{% endblock %} -
- - {% csrf_token %} - {% load crispy_forms_tags %} +

{% blocktrans with step=wizard.steps.step1 count=wizard.steps.count %}Step {{step}} of {{count}}{% endblocktrans %} +{% if description %}- {{ description }}{% endif %}

- + +{% csrf_token %} +{% load crispy_forms_tags %} - {% crispy form %} +{% block form_buttons_top %} +{% endblock form_buttons_top %} + +{{ wizard.management_form }} +{% block form_content %} +{% crispy wizard.form %} +{% endblock form_content %} +
+ +{% block form_buttons_bottom %} +{% if wizard.steps.prev %} + +{% endif %} +
+{% endblock form_buttons_bottom %} {% endblock %} \ No newline at end of file diff --git a/InvenTree/part/views.py b/InvenTree/part/views.py index d9f79262d1..343b2a6310 100644 --- a/InvenTree/part/views.py +++ b/InvenTree/part/views.py @@ -13,7 +13,7 @@ from django.shortcuts import get_object_or_404 from django.shortcuts import HttpResponseRedirect from django.utils.translation import gettext_lazy as _ from django.urls import reverse, reverse_lazy -from django.views.generic import DetailView, ListView, FormView, UpdateView +from django.views.generic import DetailView, ListView, UpdateView from django.forms.models import model_to_dict from django.forms import HiddenInput, CheckboxInput from django.conf import settings @@ -42,13 +42,14 @@ from common.models import InvenTreeSetting from company.models import SupplierPart from common.files import FileManager from common.views import FileManagementFormView, FileManagementAjaxView +from common.forms import UploadFileForm, MatchFieldForm from stock.models import StockLocation import common.settings as inventree_settings from . import forms as part_forms -from .bom import MakeBomTemplate, BomUploadManager, ExportBom, IsValidBOMFormat +from .bom import MakeBomTemplate, ExportBom, IsValidBOMFormat from order.models import PurchaseOrderLineItem from .admin import PartResource @@ -1245,7 +1246,7 @@ class BomValidate(AjaxUpdateView): } -class BomUpload(InvenTreeRoleMixin, FormView): +class BomUpload(InvenTreeRoleMixin, FileManagementFormView): """ View for uploading a BOM file, and handling BOM data importing. The BOM upload process is as follows: @@ -1272,184 +1273,116 @@ class BomUpload(InvenTreeRoleMixin, FormView): During these steps, data are passed between the server/client as JSON objects. """ - template_name = 'part/bom_upload/upload_file.html' - - # Context data passed to the forms (initially empty, extracted from uploaded file) - bom_headers = [] - bom_columns = [] - bom_rows = [] - missing_columns = [] - allowed_parts = [] - role_required = ('part.change', 'part.add') - def get_success_url(self): - part = self.get_object() - return reverse('upload-bom', kwargs={'pk': part.id}) + class BomFileManager(FileManager): + # Fields which are absolutely necessary for valid upload + REQUIRED_HEADERS = [ + 'Quantity' + ] - def get_form_class(self): + # Fields which are used for part matching (only one of them is needed) + ITEM_MATCH_HEADERS = [ + 'Part_Name', + 'Part_IPN', + 'Part_ID', + ] - # Default form is the starting point - return part_forms.BomUploadSelectFile + # Fields which would be helpful but are not required + OPTIONAL_HEADERS = [ + 'Reference', + 'Note', + 'Overage', + ] - def get_context_data(self, *args, **kwargs): + EDITABLE_HEADERS = [ + 'Reference', + 'Note', + 'Overage' + ] - ctx = super().get_context_data(*args, **kwargs) + name = 'order' + form_list = [ + ('upload', UploadFileForm), + ('fields', MatchFieldForm), + ('items', part_forms.BomMatchItemForm), + ] + form_steps_template = [ + 'part/bom_upload/upload_file.html', + 'part/bom_upload/match_fields.html', + 'part/bom_upload/match_parts.html', + ] + form_steps_description = [ + _("Upload File"), + _("Match Fields"), + _("Match Parts"), + ] + form_field_map = { + 'item_select': 'part', + 'quantity': 'quantity', + 'overage': 'overage', + 'reference': 'reference', + 'note': 'note', + } + file_manager_class = BomFileManager - # Give each row item access to the column it is in - # This provides for much simpler template rendering + def get_part(self): + """ Get part or return 404 """ - rows = [] - for row in self.bom_rows: - row_data = row['data'] + return get_object_or_404(Part, pk=self.kwargs['pk']) - data = [] + def get_context_data(self, form, **kwargs): + """ Handle context data for order """ - for idx, item in enumerate(row_data): + context = super().get_context_data(form=form, **kwargs) - data.append({ - 'cell': item, - 'idx': idx, - 'column': self.bom_columns[idx] - }) + part = self.get_part() - rows.append({ - 'index': row.get('index', -1), - 'data': data, - 'part_match': row.get('part_match', None), - 'part_options': row.get('part_options', self.allowed_parts), + context.update({'part': part}) - # User-input (passed between client and server) - 'quantity': row.get('quantity', None), - 'description': row.get('description', ''), - 'part_name': row.get('part_name', ''), - 'part': row.get('part', None), - 'reference': row.get('reference', ''), - 'notes': row.get('notes', ''), - 'errors': row.get('errors', ''), - }) + return context - ctx['part'] = self.part - ctx['bom_headers'] = BomUploadManager.HEADERS - ctx['bom_columns'] = self.bom_columns - ctx['bom_rows'] = rows - ctx['missing_columns'] = self.missing_columns - ctx['allowed_parts_list'] = self.allowed_parts - - return ctx - - def getAllowedParts(self): + def get_allowed_parts(self): """ Return a queryset of parts which are allowed to be added to this BOM. """ - return self.part.get_allowed_bom_items() + return self.get_part().get_allowed_bom_items() - def get(self, request, *args, **kwargs): - """ Perform the initial 'GET' request. - - Initially returns a form for file upload """ - - self.request = request - - # A valid Part object must be supplied. This is the 'parent' part for the BOM - self.part = get_object_or_404(Part, pk=self.kwargs['pk']) - - self.form = self.get_form() - - form_class = self.get_form_class() - form = self.get_form(form_class) - return self.render_to_response(self.get_context_data(form=form)) - - def handleBomFileUpload(self): - """ Process a BOM file upload form. - - This function validates that the uploaded file was valid, - and contains tabulated data that can be extracted. - If the file does not satisfy these requirements, - the "upload file" form is again shown to the user. - """ - - bom_file = self.request.FILES.get('bom_file', None) - - manager = None - bom_file_valid = False - - if bom_file is None: - self.form.add_error('bom_file', _('No BOM file provided')) - else: - # Create a BomUploadManager object - will perform initial data validation - # (and raise a ValidationError if there is something wrong with the file) - try: - manager = BomUploadManager(bom_file) - bom_file_valid = True - except ValidationError as e: - errors = e.error_dict - - for k, v in errors.items(): - self.form.add_error(k, v) - - if bom_file_valid: - # BOM file is valid? Proceed to the next step! - form = None - self.template_name = 'part/bom_upload/select_fields.html' - - self.extractDataFromFile(manager) - else: - form = self.form - - return self.render_to_response(self.get_context_data(form=form)) - - def getColumnIndex(self, name): - """ Return the index of the column with the given name. - It named column is not found, return -1 - """ - - try: - idx = list(self.column_selections.values()).index(name) - except ValueError: - idx = -1 - - return idx - - def preFillSelections(self): + def get_field_selection(self): """ Once data columns have been selected, attempt to pre-select the proper data from the database. This function is called once the field selection has been validated. The pre-fill data are then passed through to the part selection form. """ + self.allowed_items = self.get_allowed_parts() + # Fields prefixed with "Part_" can be used to do "smart matching" against Part objects in the database - k_idx = self.getColumnIndex('Part_ID') - p_idx = self.getColumnIndex('Part_Name') - i_idx = self.getColumnIndex('Part_IPN') + k_idx = self.get_column_index('Part_ID') + p_idx = self.get_column_index('Part_Name') + i_idx = self.get_column_index('Part_IPN') - q_idx = self.getColumnIndex('Quantity') - r_idx = self.getColumnIndex('Reference') - o_idx = self.getColumnIndex('Overage') - n_idx = self.getColumnIndex('Note') + q_idx = self.get_column_index('Quantity') + r_idx = self.get_column_index('Reference') + o_idx = self.get_column_index('Overage') + n_idx = self.get_column_index('Note') - for row in self.bom_rows: + for row in self.rows: """ - Iterate through each row in the uploaded data, and see if we can match the row to a "Part" object in the database. - There are three potential ways to match, based on the uploaded data: - a) Use the PK (primary key) field for the part, uploaded in the "Part_ID" field b) Use the IPN (internal part number) field for the part, uploaded in the "Part_IPN" field c) Use the name of the part, uploaded in the "Part_Name" field - Notes: - If using the Part_ID field, we can do an exact match against the PK field - If using the Part_IPN field, we can do an exact match against the IPN field - If using the Part_Name field, we can use fuzzy string matching to match "close" values - We also extract other information from the row, for the other non-matched fields: - Quantity - Reference - Overage - Note - """ # Initially use a quantity of zero @@ -1459,42 +1392,55 @@ class BomUpload(InvenTreeRoleMixin, FormView): exact_match_part = None # A list of potential Part matches - part_options = self.allowed_parts + part_options = self.allowed_items # Check if there is a column corresponding to "quantity" if q_idx >= 0: - q_val = row['data'][q_idx] + q_val = row['data'][q_idx]['cell'] if q_val: + # Delete commas + q_val = q_val.replace(',', '') + try: # Attempt to extract a valid quantity from the field quantity = Decimal(q_val) + # Store the 'quantity' value + row['quantity'] = quantity except (ValueError, InvalidOperation): pass - # Store the 'quantity' value - row['quantity'] = quantity - # Check if there is a column corresponding to "PK" if k_idx >= 0: - pk = row['data'][k_idx] + pk = row['data'][k_idx]['cell'] if pk: try: # Attempt Part lookup based on PK value - exact_match_part = Part.objects.get(pk=pk) + exact_match_part = self.allowed_items.get(pk=pk) except (ValueError, Part.DoesNotExist): exact_match_part = None - # Check if there is a column corresponding to "Part Name" - if p_idx >= 0: - part_name = row['data'][p_idx] + # Check if there is a column corresponding to "Part IPN" and no exact match found yet + if i_idx >= 0 and not exact_match_part: + part_ipn = row['data'][i_idx]['cell'] + + if part_ipn: + part_matches = [part for part in self.allowed_items if part.IPN and part_ipn.lower() == str(part.IPN.lower())] + + # Check for single match + if len(part_matches) == 1: + exact_match_part = part_matches[0] + + # Check if there is a column corresponding to "Part Name" and no exact match found yet + if p_idx >= 0 and not exact_match_part: + part_name = row['data'][p_idx]['cell'] row['part_name'] = part_name matches = [] - for part in self.allowed_parts: + for part in self.allowed_items: ratio = fuzz.partial_ratio(part.name + part.description, part_name) matches.append({'part': part, 'match': ratio}) @@ -1503,390 +1449,67 @@ class BomUpload(InvenTreeRoleMixin, FormView): matches = sorted(matches, key=lambda item: item['match'], reverse=True) part_options = [m['part'] for m in matches] + + # Supply list of part options for each row, sorted by how closely they match the part name + row['item_options'] = part_options - # Check if there is a column corresponding to "Part IPN" - if i_idx >= 0: - row['part_ipn'] = row['data'][i_idx] + # Unless found, the 'item_match' is blank + row['item_match'] = None + + if exact_match_part: + # If there is an exact match based on PK or IPN, use that + row['item_match'] = exact_match_part # Check if there is a column corresponding to "Overage" field if o_idx >= 0: - row['overage'] = row['data'][o_idx] + row['overage'] = row['data'][o_idx]['cell'] # Check if there is a column corresponding to "Reference" field if r_idx >= 0: - row['reference'] = row['data'][r_idx] + row['reference'] = row['data'][r_idx]['cell'] # Check if there is a column corresponding to "Note" field if n_idx >= 0: - row['note'] = row['data'][n_idx] - - # Supply list of part options for each row, sorted by how closely they match the part name - row['part_options'] = part_options - - # Unless found, the 'part_match' is blank - row['part_match'] = None - - if exact_match_part: - # If there is an exact match based on PK, use that - row['part_match'] = exact_match_part - else: - # Otherwise, check to see if there is a matching IPN - try: - if row['part_ipn']: - part_matches = [part for part in self.allowed_parts if part.IPN and row['part_ipn'].lower() == str(part.IPN.lower())] - - # Check for single match - if len(part_matches) == 1: - row['part_match'] = part_matches[0] - - continue - except KeyError: - pass - - def extractDataFromFile(self, bom): - """ Read data from the BOM file """ - - self.bom_columns = bom.columns() - self.bom_rows = bom.rows() - - def getTableDataFromPost(self): - """ Extract table cell data from POST request. - These data are used to maintain state between sessions. - - Table data keys are as follows: - - col_name_ - Column name at idx as provided in the uploaded file - col_guess_ - Column guess at idx as selected in the BOM - row__col - Cell data as provided in the uploaded file - - """ - - # Map the columns - self.column_names = {} - self.column_selections = {} - - self.row_data = {} - - for item in self.request.POST: - value = self.request.POST[item] - - # Column names as passed as col_name_ where idx is an integer - - # Extract the column names - if item.startswith('col_name_'): - try: - col_id = int(item.replace('col_name_', '')) - except ValueError: - continue - col_name = value - - self.column_names[col_id] = col_name - - # Extract the column selections (in the 'select fields' view) - if item.startswith('col_guess_'): - - try: - col_id = int(item.replace('col_guess_', '')) - except ValueError: - continue - - col_name = value - - self.column_selections[col_id] = value - - # Extract the row data - if item.startswith('row_'): - # Item should be of the format row__col_ - s = item.split('_') - - if len(s) < 4: - continue - - # Ignore row/col IDs which are not correct numeric values - try: - row_id = int(s[1]) - col_id = int(s[3]) - except ValueError: - continue - - if row_id not in self.row_data: - self.row_data[row_id] = {} - - self.row_data[row_id][col_id] = value - - self.col_ids = sorted(self.column_names.keys()) - - # Re-construct the data table - self.bom_rows = [] - - for row_idx in sorted(self.row_data.keys()): - row = self.row_data[row_idx] - items = [] - - for col_idx in sorted(row.keys()): - - value = row[col_idx] - items.append(value) - - self.bom_rows.append({ - 'index': row_idx, - 'data': items, - 'errors': {}, - }) - - # Construct the column data - self.bom_columns = [] - - # Track any duplicate column selections - self.duplicates = False - - for col in self.col_ids: - - if col in self.column_selections: - guess = self.column_selections[col] - else: - guess = None - - header = ({ - 'name': self.column_names[col], - 'guess': guess - }) - - if guess: - n = list(self.column_selections.values()).count(self.column_selections[col]) - if n > 1: - header['duplicate'] = True - self.duplicates = True - - self.bom_columns.append(header) - - # Are there any missing columns? - self.missing_columns = [] - - # Check that all required fields are present - for col in BomUploadManager.REQUIRED_HEADERS: - if col not in self.column_selections.values(): - self.missing_columns.append(col) - - # Check that at least one of the part match field is present - part_match_found = False - for col in BomUploadManager.PART_MATCH_HEADERS: - if col in self.column_selections.values(): - part_match_found = True - break - - # If not, notify user - if not part_match_found: - for col in BomUploadManager.PART_MATCH_HEADERS: - self.missing_columns.append(col) - - def handleFieldSelection(self): - """ Handle the output of the field selection form. - Here the user is presented with the raw data and must select the - column names and which rows to process. - """ - - # Extract POST data - self.getTableDataFromPost() - - valid = len(self.missing_columns) == 0 and not self.duplicates - - if valid: - # Try to extract meaningful data - self.preFillSelections() - self.template_name = 'part/bom_upload/select_parts.html' - else: - self.template_name = 'part/bom_upload/select_fields.html' - - return self.render_to_response(self.get_context_data(form=None)) - - def handlePartSelection(self): - - # Extract basic table data from POST request - self.getTableDataFromPost() - - # Keep track of the parts that have been selected - parts = {} - - # Extract other data (part selections, etc) - for key in self.request.POST: - value = self.request.POST[key] - - # Extract quantity from each row - if key.startswith('quantity_'): - try: - row_id = int(key.replace('quantity_', '')) - - row = self.getRowByIndex(row_id) - - if row is None: - continue - - q = Decimal(1) - - try: - q = Decimal(value) - if q < 0: - row['errors']['quantity'] = _('Quantity must be greater than zero') - - if 'part' in row.keys(): - if row['part'].trackable: - # Trackable parts must use integer quantities - if not q == int(q): - row['errors']['quantity'] = _('Quantity must be integer value for trackable parts') - - except (ValueError, InvalidOperation): - row['errors']['quantity'] = _('Enter a valid quantity') - - row['quantity'] = q - - except ValueError: - continue - - # Extract part from each row - if key.startswith('part_'): - - try: - row_id = int(key.replace('part_', '')) - - row = self.getRowByIndex(row_id) - - if row is None: - continue - except ValueError: - # Row ID non integer value - continue - - try: - part_id = int(value) - part = Part.objects.get(id=part_id) - except ValueError: - row['errors']['part'] = _('Select valid part') - continue - except Part.DoesNotExist: - row['errors']['part'] = _('Select valid part') - continue - - # Keep track of how many of each part we have seen - if part_id in parts: - parts[part_id]['quantity'] += 1 - row['errors']['part'] = _('Duplicate part selected') - else: - parts[part_id] = { - 'part': part, - 'quantity': 1, - } - - row['part'] = part - - if part.trackable: - # For trackable parts, ensure the quantity is an integer value! - if 'quantity' in row.keys(): - q = row['quantity'] - - if not q == int(q): - row['errors']['quantity'] = _('Quantity must be integer value for trackable parts') - - # Extract other fields which do not require further validation - for field in ['reference', 'notes']: - if key.startswith(field + '_'): - try: - row_id = int(key.replace(field + '_', '')) - - row = self.getRowByIndex(row_id) - - if row: - row[field] = value - except: - continue - - # Are there any errors after form handling? - valid = True - - for row in self.bom_rows: - # Has a part been selected for the given row? - part = row.get('part', None) - - if part is None: - row['errors']['part'] = _('Select a part') - else: - # Will the selected part result in a recursive BOM? - try: - part.checkAddToBOM(self.part) - except ValidationError: - row['errors']['part'] = _('Selected part creates a circular BOM') - - # Has a quantity been specified? - if row.get('quantity', None) is None: - row['errors']['quantity'] = _('Specify quantity') - - errors = row.get('errors', []) - - if len(errors) > 0: - valid = False - - self.template_name = 'part/bom_upload/select_parts.html' - - ctx = self.get_context_data(form=None) - - if valid: - self.part.clear_bom() - - # Generate new BOM items - for row in self.bom_rows: - part = row.get('part') - quantity = row.get('quantity') - reference = row.get('reference', '') - notes = row.get('notes', '') - - # Create a new BOM item! - item = BomItem( - part=self.part, - sub_part=part, - quantity=quantity, - reference=reference, - note=notes - ) - + row['note'] = row['data'][n_idx]['cell'] + + def done(self, form_list, **kwargs): + """ Once all the data is in, process it to add BomItem instances to the part """ + + self.part = self.get_part() + items = self.get_clean_items() + + # Clear BOM + self.part.clear_bom() + + # Generate new BOM items + for bom_item in items.values(): + try: + part = Part.objects.get(pk=int(bom_item.get('part'))) + except (ValueError, Part.DoesNotExist): + continue + + quantity = bom_item.get('quantity') + overage = bom_item.get('overage', '') + reference = bom_item.get('reference', '') + note = bom_item.get('note', '') + + # Create a new BOM item + item = BomItem( + part=self.part, + sub_part=part, + quantity=quantity, + overage=overage, + reference=reference, + note=note, + ) + + try: item.save() + except IntegrityError: + # BomItem already exists + pass - # Redirect to the BOM view - return HttpResponseRedirect(reverse('part-bom', kwargs={'pk': self.part.id})) - else: - ctx['form_errors'] = True - - return self.render_to_response(ctx) - - def getRowByIndex(self, idx): - - for row in self.bom_rows: - if row['index'] == idx: - return row - - return None - - def post(self, request, *args, **kwargs): - """ Perform the various 'POST' requests required. - """ - - self.request = request - - self.part = get_object_or_404(Part, pk=self.kwargs['pk']) - self.allowed_parts = self.getAllowedParts() - self.form = self.get_form(self.get_form_class()) - - # Did the user POST a file named bom_file? - - form_step = request.POST.get('form_step', None) - - if form_step == 'select_file': - return self.handleBomFileUpload() - elif form_step == 'select_fields': - return self.handleFieldSelection() - elif form_step == 'select_parts': - return self.handlePartSelection() - - return self.render_to_response(self.get_context_data(form=self.form)) + return HttpResponseRedirect(reverse('part-bom', kwargs={'pk': self.kwargs['pk']})) class PartExport(AjaxView): From 6b73e7a408c4698d09232fb39ada8c493513f342 Mon Sep 17 00:00:00 2001 From: Oliver Date: Fri, 9 Jul 2021 14:28:32 +1000 Subject: [PATCH 02/45] Add more fields to ManufacturerPart and SupplierPart tables --- InvenTree/company/serializers.py | 19 +++++++++++-------- InvenTree/templates/js/company.js | 21 +++++++++++++++++++++ 2 files changed, 32 insertions(+), 8 deletions(-) diff --git a/InvenTree/company/serializers.py b/InvenTree/company/serializers.py index 276225624f..6e5ef08d6e 100644 --- a/InvenTree/company/serializers.py +++ b/InvenTree/company/serializers.py @@ -206,24 +206,27 @@ class SupplierPartSerializer(InvenTreeModelSerializer): MPN = serializers.StringRelatedField(source='manufacturer_part.MPN') - manufacturer_part = ManufacturerPartSerializer(read_only=True) + manufacturer_part_detail = ManufacturerPartSerializer(source='manufacturer_part', read_only=True) class Meta: model = SupplierPart fields = [ + 'description', + 'link', + 'manufacturer', + 'manufacturer_detail', + 'manufacturer_part', + 'manufacturer_part_detail', + 'MPN', + 'note', 'pk', + 'packaging', 'part', 'part_detail', 'pretty_name', + 'SKU', 'supplier', 'supplier_detail', - 'SKU', - 'manufacturer', - 'MPN', - 'manufacturer_detail', - 'manufacturer_part', - 'description', - 'link', ] def create(self, validated_data): diff --git a/InvenTree/templates/js/company.js b/InvenTree/templates/js/company.js index d28bca5547..18d7408b23 100644 --- a/InvenTree/templates/js/company.js +++ b/InvenTree/templates/js/company.js @@ -318,6 +318,12 @@ function loadManufacturerPartTable(table, url, options) { } } }, + { + field: 'description', + title: '{% trans "Description" %}', + sortable: false, + switchable: true, + } ], }); } @@ -550,6 +556,21 @@ function loadSupplierPartTable(table, url, options) { } } }, + { + field: 'description', + title: '{% trans "Description" %}', + sortable: false, + }, + { + field: 'note', + title: '{% trans "Notes" %}', + sortable: false, + }, + { + field: 'packaging', + title: '{% trans "Packaging" %}', + sortable: false, + } ], }); } \ No newline at end of file From c694c9467b5ed4d8755386d9f9c1f43161cc258b Mon Sep 17 00:00:00 2001 From: Oliver Date: Fri, 9 Jul 2021 14:39:08 +1000 Subject: [PATCH 03/45] sessionStorage -> localStorage OMG --- .../static/script/inventree/sidenav.js | 45 ++++++++++--------- 1 file changed, 24 insertions(+), 21 deletions(-) diff --git a/InvenTree/InvenTree/static/script/inventree/sidenav.js b/InvenTree/InvenTree/static/script/inventree/sidenav.js index ce1214abc4..6b5d190538 100644 --- a/InvenTree/InvenTree/static/script/inventree/sidenav.js +++ b/InvenTree/InvenTree/static/script/inventree/sidenav.js @@ -35,8 +35,8 @@ function loadTree(url, tree, options={}) { showTags: true, }); - if (sessionStorage.getItem(key)) { - var saved_exp = sessionStorage.getItem(key).split(","); + if (localStorage.getItem(key)) { + var saved_exp = localStorage.getItem(key).split(","); // Automatically expand the desired notes for (var q = 0; q < saved_exp.length; q++) { @@ -57,7 +57,7 @@ function loadTree(url, tree, options={}) { } // Save the expanded nodes - sessionStorage.setItem(key, exp); + localStorage.setItem(key, exp); }); } }, @@ -106,17 +106,17 @@ function initNavTree(options) { width: '0px' }, 50); - sessionStorage.setItem(stateLabel, 'closed'); + localStorage.setItem(stateLabel, 'closed'); } else { - sessionStorage.setItem(stateLabel, 'open'); - sessionStorage.setItem(widthLabel, `${width}px`); + localStorage.setItem(stateLabel, 'open'); + localStorage.setItem(widthLabel, `${width}px`); } } }); } - var state = sessionStorage.getItem(stateLabel); - var width = sessionStorage.getItem(widthLabel) || '300px'; + var state = localStorage.getItem(stateLabel); + var width = localStorage.getItem(widthLabel) || '300px'; if (state && state == 'open') { @@ -131,21 +131,21 @@ function initNavTree(options) { $(toggleId).click(function() { - var state = sessionStorage.getItem(stateLabel) || 'closed'; - var width = sessionStorage.getItem(widthLabel) || '300px'; + var state = localStorage.getItem(stateLabel) || 'closed'; + var width = localStorage.getItem(widthLabel) || '300px'; if (state == 'open') { $(treeId).animate({ width: '0px' }, 50); - sessionStorage.setItem(stateLabel, 'closed'); + localStorage.setItem(stateLabel, 'closed'); } else { $(treeId).animate({ width: width, }, 50); - sessionStorage.setItem(stateLabel, 'open'); + localStorage.setItem(stateLabel, 'open'); } }); } @@ -198,17 +198,20 @@ function enableNavbar(options) { width: '45px' }, 50); - sessionStorage.setItem(stateLabel, 'closed'); + localStorage.setItem(stateLabel, 'closed'); } else { - sessionStorage.setItem(widthLabel, `${width}px`); - sessionStorage.setItem(stateLabel, 'open'); + localStorage.setItem(widthLabel, `${width}px`); + localStorage.setItem(stateLabel, 'open'); } } }); } - var state = sessionStorage.getItem(stateLabel); - var width = sessionStorage.getItem(widthLabel) || '250px'; + var state = localStorage.getItem(stateLabel); + + console.log(stateLabel, '->', state); + + var width = localStorage.getItem(widthLabel) || '250px'; if (state && state == 'open') { @@ -224,8 +227,8 @@ function enableNavbar(options) { $(toggleId).click(function() { - var state = sessionStorage.getItem(stateLabel) || 'closed'; - var width = sessionStorage.getItem(widthLabel) || '250px'; + var state = localStorage.getItem(stateLabel) || 'closed'; + var width = localStorage.getItem(widthLabel) || '250px'; if (state == 'open') { $(navId).animate({ @@ -233,7 +236,7 @@ function enableNavbar(options) { minWidth: '45px', }, 50); - sessionStorage.setItem(stateLabel, 'closed'); + localStorage.setItem(stateLabel, 'closed'); } else { @@ -241,7 +244,7 @@ function enableNavbar(options) { 'width': width }, 50); - sessionStorage.setItem(stateLabel, 'open'); + localStorage.setItem(stateLabel, 'open'); } }); } From 47a56f7f5d2f8becb009d9df1a2548f5d2cda178 Mon Sep 17 00:00:00 2001 From: Oliver Date: Fri, 9 Jul 2021 14:54:03 +1000 Subject: [PATCH 04/45] Fixes for unit tests --- InvenTree/company/test_api.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/InvenTree/company/test_api.py b/InvenTree/company/test_api.py index 40176c7634..2da6d29198 100644 --- a/InvenTree/company/test_api.py +++ b/InvenTree/company/test_api.py @@ -230,7 +230,7 @@ class ManufacturerTest(InvenTreeAPITestCase): self.assertEqual(response.status_code, status.HTTP_201_CREATED) # Check manufacturer part - manufacturer_part_id = int(response.data['manufacturer_part']['pk']) + manufacturer_part_id = int(response.data['manufacturer_part_detail']['pk']) url = reverse('api-manufacturer-part-detail', kwargs={'pk': manufacturer_part_id}) response = self.get(url) self.assertEqual(response.data['MPN'], 'PART_NUMBER') From 6eec6a0599dce3321f5769faa066cccbc5b75497 Mon Sep 17 00:00:00 2001 From: Oliver Date: Fri, 9 Jul 2021 16:29:58 +1000 Subject: [PATCH 05/45] Consolidate Manufacturers and Suppliers views --- .../part/templates/part/manufacturer.html | 84 ------------------- InvenTree/part/templates/part/navbar.html | 8 +- InvenTree/part/templates/part/supplier.html | 77 ++++++++++++++++- 3 files changed, 75 insertions(+), 94 deletions(-) delete mode 100644 InvenTree/part/templates/part/manufacturer.html diff --git a/InvenTree/part/templates/part/manufacturer.html b/InvenTree/part/templates/part/manufacturer.html deleted file mode 100644 index ba708d70e8..0000000000 --- a/InvenTree/part/templates/part/manufacturer.html +++ /dev/null @@ -1,84 +0,0 @@ -{% extends "part/part_base.html" %} -{% load static %} -{% load i18n %} -{% load inventree_extras %} - -{% block menubar %} -{% include 'part/navbar.html' with tab='manufacturers' %} -{% endblock %} - -{% block heading %} -{% trans "Part Manufacturers" %} -{% endblock %} - -{% block details %} - -
-
- -
- - -
-
-
- - -
- -{% endblock %} - -{% block js_load %} -{{ block.super }} -{% endblock %} -{% block js_ready %} - {{ block.super }} - - $('#manufacturer-create').click(function () { - launchModalForm( - "{% url 'manufacturer-part-create' %}", - { - reload: true, - data: { - part: {{ part.id }} - }, - secondary: [ - { - field: 'manufacturer', - label: '{% trans "New Manufacturer" %}', - title: '{% trans "Create new manufacturer" %}', - } - ] - }); - }); - - $("#manufacturer-part-delete").click(function() { - - var selections = $("#manufacturer-table").bootstrapTable("getSelections"); - - deleteManufacturerParts(selections, { - onSuccess: function() { - $("#manufacturer-table").bootstrapTable("refresh"); - } - }); - }); - - loadManufacturerPartTable( - "#manufacturer-table", - "{% url 'api-manufacturer-part-list' %}", - { - params: { - part: {{ part.id }}, - part_detail: true, - manufacturer_detail: true, - }, - } - ); - - linkButtonsToSelection($("#manufacturer-table"), ['#manufacturer-part-options']) - -{% endblock %} \ No newline at end of file diff --git a/InvenTree/part/templates/part/navbar.html b/InvenTree/part/templates/part/navbar.html index b4c943dcfa..6266675aa8 100644 --- a/InvenTree/part/templates/part/navbar.html +++ b/InvenTree/part/templates/part/navbar.html @@ -78,12 +78,6 @@ {% if part.purchaseable and roles.purchase_order.view %} -
  • - - - {% trans "Manufacturers" %} - -
  • @@ -109,7 +103,7 @@
  • - {% trans "Tests" %} + {% trans "Test Templates" %}
  • {% endif %} diff --git a/InvenTree/part/templates/part/supplier.html b/InvenTree/part/templates/part/supplier.html index c0486cc42a..28b6407b33 100644 --- a/InvenTree/part/templates/part/supplier.html +++ b/InvenTree/part/templates/part/supplier.html @@ -6,12 +6,13 @@ {% include 'part/navbar.html' with tab='suppliers' %} {% endblock %} + {% block heading %} {% trans "Part Suppliers" %} {% endblock %} {% block details %} -
    +
    - +
    {% endblock %} +{% block post_content_panel %} +
    +
    + +
    + + +
    +
    +
    +
    +
    +

    + {% trans "Part Manufacturers" %} +

    +
    +
    +
    +
    +
    + +{% endblock %} + {% block js_load %} {{ block.super }} {% endblock %} @@ -90,6 +118,49 @@ } ); - linkButtonsToSelection($("#supplier-table"), ['#supplier-part-options']) + linkButtonsToSelection($("#supplier-table"), ['#supplier-part-options']); + + loadManufacturerPartTable( + '#manufacturer-table', + "{% url 'api-manufacturer-part-list' %}", + { + params: { + part: {{ part.id }}, + part_detail: true, + manufacturer_detail: true, + }, + } + ); + + linkButtonsToSelection($("#manufacturer-table"), ['#manufacturer-part-options']); + + $("#manufacturer-part-delete").click(function() { + + var selections = $("#manufacturer-table").bootstrapTable("getSelections"); + + deleteManufacturerParts(selections, { + onSuccess: function() { + $("#manufacturer-table").bootstrapTable("refresh"); + } + }); + }); + + $('#manufacturer-create').click(function () { + launchModalForm( + "{% url 'manufacturer-part-create' %}", + { + reload: true, + data: { + part: {{ part.id }} + }, + secondary: [ + { + field: 'manufacturer', + label: '{% trans "New Manufacturer" %}', + title: '{% trans "Create new manufacturer" %}', + } + ] + }); + }); {% endblock %} \ No newline at end of file From ff92210b25c366fda14b2b3b606bc14f67a5a592 Mon Sep 17 00:00:00 2001 From: Oliver Date: Fri, 9 Jul 2021 16:31:29 +1000 Subject: [PATCH 06/45] Fix URLs --- InvenTree/company/templates/company/manufacturer_part_base.html | 2 +- .../company/templates/company/manufacturer_part_detail.html | 2 +- InvenTree/part/urls.py | 1 - 3 files changed, 2 insertions(+), 3 deletions(-) diff --git a/InvenTree/company/templates/company/manufacturer_part_base.html b/InvenTree/company/templates/company/manufacturer_part_base.html index addd9265b8..8fdcd79f71 100644 --- a/InvenTree/company/templates/company/manufacturer_part_base.html +++ b/InvenTree/company/templates/company/manufacturer_part_base.html @@ -62,7 +62,7 @@ src="{% static 'img/blank_image.png' %}" {% trans "Internal Part" %} {% if part.part %} - {{ part.part.full_name }}{% include "clip.html"%} + {{ part.part.full_name }}{% include "clip.html"%} {% endif %} diff --git a/InvenTree/company/templates/company/manufacturer_part_detail.html b/InvenTree/company/templates/company/manufacturer_part_detail.html index 430072a834..3bc789e6b9 100644 --- a/InvenTree/company/templates/company/manufacturer_part_detail.html +++ b/InvenTree/company/templates/company/manufacturer_part_detail.html @@ -18,7 +18,7 @@ {% trans "Internal Part" %} {% if part.part %} - {{ part.part.full_name }} + {{ part.part.full_name }} {% endif %} diff --git a/InvenTree/part/urls.py b/InvenTree/part/urls.py index 6bd8d02601..b45294d565 100644 --- a/InvenTree/part/urls.py +++ b/InvenTree/part/urls.py @@ -56,7 +56,6 @@ part_detail_urls = [ url(r'^build/?', views.PartDetail.as_view(template_name='part/build.html'), name='part-build'), url(r'^used/?', views.PartDetail.as_view(template_name='part/used_in.html'), name='part-used-in'), url(r'^prices/', views.PartPricingView.as_view(template_name='part/prices.html'), name='part-prices'), - url(r'^manufacturers/?', views.PartDetail.as_view(template_name='part/manufacturer.html'), name='part-manufacturers'), url(r'^suppliers/?', views.PartDetail.as_view(template_name='part/supplier.html'), name='part-suppliers'), url(r'^orders/?', views.PartDetail.as_view(template_name='part/orders.html'), name='part-orders'), url(r'^sales-orders/', views.PartDetail.as_view(template_name='part/sales_orders.html'), name='part-sales-orders'), From 9ae5c241f5efbdd12ceb9b5275343f68985d2e69 Mon Sep 17 00:00:00 2001 From: Oliver Date: Fri, 9 Jul 2021 16:34:47 +1000 Subject: [PATCH 07/45] Move part parameters onto the part details page --- InvenTree/part/templates/part/detail.html | 74 +++++++++++++++++++ InvenTree/part/templates/part/navbar.html | 6 -- InvenTree/part/templates/part/params.html | 81 --------------------- InvenTree/part/templates/part/supplier.html | 28 +++---- InvenTree/part/urls.py | 1 - 5 files changed, 89 insertions(+), 101 deletions(-) delete mode 100644 InvenTree/part/templates/part/params.html diff --git a/InvenTree/part/templates/part/detail.html b/InvenTree/part/templates/part/detail.html index e077e5af01..5132721fec 100644 --- a/InvenTree/part/templates/part/detail.html +++ b/InvenTree/part/templates/part/detail.html @@ -238,6 +238,30 @@ {% endblock %} +{% block post_content_panel %} + +
    +
    +

    + {% trans "Part Parameters" %} +

    +
    +
    +
    +
    + {% if roles.part.add %} + + {% endif %} +
    +
    +
    +
    +
    + +{% endblock %} + {% block js_load %} {{ block.super }} {% endblock %} @@ -263,4 +287,54 @@ ); }); + loadPartParameterTable( + '#parameter-table', + '{% url "api-part-parameter-list" %}', + { + params: { + part: {{ part.pk }}, + } + } + ); + + $('#param-table').inventreeTable({ + }); + + {% if roles.part.add %} + $('#param-create').click(function() { + + constructForm('{% url "api-part-parameter-list" %}', { + method: 'POST', + fields: { + part: { + value: {{ part.pk }}, + hidden: true, + }, + template: {}, + data: {}, + }, + title: '{% trans "Add Parameter" %}', + onSuccess: function() { + $('#parameter-table').bootstrapTable('refresh'); + } + }); + }); + {% endif %} + + $('.param-edit').click(function() { + var button = $(this); + + launchModalForm(button.attr('url'), { + reload: true, + }); + }); + + $('.param-delete').click(function() { + var button = $(this); + + launchModalForm(button.attr('url'), { + reload: true, + }); + }); + {% endblock %} diff --git a/InvenTree/part/templates/part/navbar.html b/InvenTree/part/templates/part/navbar.html index 6266675aa8..afe4fee374 100644 --- a/InvenTree/part/templates/part/navbar.html +++ b/InvenTree/part/templates/part/navbar.html @@ -19,12 +19,6 @@ -
  • - - - {% trans "Parameters" %} - -
  • {% if part.is_template %}
  • diff --git a/InvenTree/part/templates/part/params.html b/InvenTree/part/templates/part/params.html deleted file mode 100644 index 365003b052..0000000000 --- a/InvenTree/part/templates/part/params.html +++ /dev/null @@ -1,81 +0,0 @@ -{% extends "part/part_base.html" %} -{% load static %} -{% load i18n %} - -{% block menubar %} -{% include "part/navbar.html" with tab='params' %} -{% endblock %} - -{% block heading %} -{% trans "Part Parameters" %} -{% endblock %} - -{% block details %} -
    -
    - {% if roles.part.add %} - - {% endif %} -
    -
    - -
    - -{% endblock %} - -{% block js_ready %} -{{ block.super }} - - loadPartParameterTable( - '#parameter-table', - '{% url "api-part-parameter-list" %}', - { - params: { - part: {{ part.pk }}, - } - } - ); - - $('#param-table').inventreeTable({ - }); - - {% if roles.part.add %} - $('#param-create').click(function() { - - constructForm('{% url "api-part-parameter-list" %}', { - method: 'POST', - fields: { - part: { - value: {{ part.pk }}, - hidden: true, - }, - template: {}, - data: {}, - }, - title: '{% trans "Add Parameter" %}', - onSuccess: function() { - $('#parameter-table').bootstrapTable('refresh'); - } - }); - }); - {% endif %} - - $('.param-edit').click(function() { - var button = $(this); - - launchModalForm(button.attr('url'), { - reload: true, - }); - }); - - $('.param-delete').click(function() { - var button = $(this); - - launchModalForm(button.attr('url'), { - reload: true, - }); - }); - -{% endblock %} \ No newline at end of file diff --git a/InvenTree/part/templates/part/supplier.html b/InvenTree/part/templates/part/supplier.html index 28b6407b33..8c01243541 100644 --- a/InvenTree/part/templates/part/supplier.html +++ b/InvenTree/part/templates/part/supplier.html @@ -32,26 +32,28 @@ {% endblock %} {% block post_content_panel %} -
    +

    {% trans "Part Manufacturers" %}

    +
    +
    +
    + +
    + + +
    +
    +
    diff --git a/InvenTree/part/urls.py b/InvenTree/part/urls.py index b45294d565..8843a839a1 100644 --- a/InvenTree/part/urls.py +++ b/InvenTree/part/urls.py @@ -48,7 +48,6 @@ part_detail_urls = [ url(r'^bom-upload/?', views.BomUpload.as_view(), name='upload-bom'), url(r'^bom-duplicate/?', views.BomDuplicate.as_view(), name='duplicate-bom'), - url(r'^params/', views.PartDetail.as_view(template_name='part/params.html'), name='part-params'), url(r'^variants/?', views.PartDetail.as_view(template_name='part/variants.html'), name='part-variants'), url(r'^stock/?', views.PartDetail.as_view(template_name='part/stock.html'), name='part-stock'), url(r'^allocation/?', views.PartDetail.as_view(template_name='part/allocation.html'), name='part-allocation'), From bf2774eb210902dd5e96a55f2192c00a465a8324 Mon Sep 17 00:00:00 2001 From: Oliver Date: Sat, 10 Jul 2021 13:48:44 +1000 Subject: [PATCH 08/45] Add numerical validation step for BomItem --- InvenTree/part/models.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/InvenTree/part/models.py b/InvenTree/part/models.py index 8fe5744f06..5ef3a855c0 100644 --- a/InvenTree/part/models.py +++ b/InvenTree/part/models.py @@ -30,7 +30,7 @@ from mptt.models import TreeForeignKey, MPTTModel from stdimage.models import StdImageField -from decimal import Decimal +from decimal import Decimal, InvalidOperation from datetime import datetime from rapidfuzz import fuzz import hashlib @@ -2418,6 +2418,15 @@ class BomItem(models.Model): - If the "sub_part" is trackable, then the "part" must be trackable too! """ + super().clean() + + try: + self.quantity = Decimal(self.quantity) + except InvalidOperation: + raise ValidationError({ + 'quantity': _('Must be a valid number') + }) + try: # Check for circular BOM references if self.sub_part: From cf66a386ea631ce798ceedd1333b151a26a44a72 Mon Sep 17 00:00:00 2001 From: Oliver Date: Sat, 10 Jul 2021 14:13:46 +1000 Subject: [PATCH 09/45] Bug fix --- InvenTree/part/bom.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/InvenTree/part/bom.py b/InvenTree/part/bom.py index f52b295235..81a0a4eb00 100644 --- a/InvenTree/part/bom.py +++ b/InvenTree/part/bom.py @@ -5,7 +5,7 @@ Primarily BOM upload tools. from collections import OrderedDict -from django.utils.translation import gettext_lazy as _ +from django.utils.translation import gettext as _ from InvenTree.helpers import DownloadFile, GetExportFormats @@ -140,11 +140,16 @@ def ExportBom(part, fmt='csv', cascade=False, max_levels=None, parameter_data=Fa stock_data = [] # Get part default location try: - stock_data.append(bom_item.sub_part.get_default_location().name) + loc = bom_item.sub_part.get_default_location() + + if loc is not None: + stock_data.append(str(loc.name)) + else: + stock_data.append('') except AttributeError: stock_data.append('') # Get part current stock - stock_data.append(bom_item.sub_part.available_stock) + stock_data.append(str(bom_item.sub_part.available_stock)) for s_idx, header in enumerate(stock_headers): try: @@ -318,6 +323,6 @@ def ExportBom(part, fmt='csv', cascade=False, max_levels=None, parameter_data=Fa data = dataset.export(fmt) - filename = '{n}_BOM.{fmt}'.format(n=part.full_name, fmt=fmt) + filename = f"{part.full_name}_BOM.{fmt}" return DownloadFile(data, filename) From 73e03636a2267774019b56b523f0cdbede6bdda6 Mon Sep 17 00:00:00 2001 From: Oliver Date: Sat, 10 Jul 2021 23:04:20 +1000 Subject: [PATCH 10/45] Add unit tests --- .../static/script/inventree/sidenav.js | 2 - InvenTree/part/test_bom_export.py | 133 ++++++++++++++++++ 2 files changed, 133 insertions(+), 2 deletions(-) create mode 100644 InvenTree/part/test_bom_export.py diff --git a/InvenTree/InvenTree/static/script/inventree/sidenav.js b/InvenTree/InvenTree/static/script/inventree/sidenav.js index 6b5d190538..eca19076f2 100644 --- a/InvenTree/InvenTree/static/script/inventree/sidenav.js +++ b/InvenTree/InvenTree/static/script/inventree/sidenav.js @@ -209,8 +209,6 @@ function enableNavbar(options) { var state = localStorage.getItem(stateLabel); - console.log(stateLabel, '->', state); - var width = localStorage.getItem(widthLabel) || '250px'; if (state && state == 'open') { diff --git a/InvenTree/part/test_bom_export.py b/InvenTree/part/test_bom_export.py new file mode 100644 index 0000000000..29ee588621 --- /dev/null +++ b/InvenTree/part/test_bom_export.py @@ -0,0 +1,133 @@ +""" +Unit testing for BOM export functionality +""" + +from django.test import TestCase + +from django.urls import reverse +from django.contrib.auth import get_user_model +from django.contrib.auth.models import Group + +from .models import Part, BomItem + + +class BomExportTest(TestCase): + + fixtures = [ + 'category', + 'part', + 'location', + 'bom', + ] + + def setUp(self): + super().setUp() + + # Create a user + user = get_user_model() + + self.user = user.objects.create_user( + username='username', + email='user@email.com', + password='password' + ) + + # Put the user into a group with the correct permissions + group = Group.objects.create(name='mygroup') + self.user.groups.add(group) + + # Give the group *all* the permissions! + for rule in group.rule_sets.all(): + rule.can_view = True + rule.can_change = True + rule.can_add = True + rule.can_delete = True + + rule.save() + + self.client.login(username='username', password='password') + + self.url = reverse('bom-download', kwargs={'pk': 100}) + + def test_export_csv(self): + """ + Test BOM download in CSV format + """ + + print("URL", self.url) + + params = { + 'file_format': 'csv', + 'cascade': True, + 'parameter_data': True, + 'stock_data': True, + 'supplier_data': True, + 'manufacturer_data': True, + } + + response = self.client.get(self.url, data=params) + + self.assertEqual(response.status_code, 200) + + content = response.headers['Content-Disposition'] + self.assertEqual(content, 'attachment; filename="BOB | Bob | A2_BOM.csv"') + + def test_export_xls(self): + """ + Test BOM download in XLS format + """ + + params = { + 'file_format': 'xls', + 'cascade': True, + 'parameter_data': True, + 'stock_data': True, + 'supplier_data': True, + 'manufacturer_data': True, + } + + response = self.client.get(self.url, data=params) + + self.assertEqual(response.status_code, 200) + + content = response.headers['Content-Disposition'] + self.assertEqual(content, 'attachment; filename="BOB | Bob | A2_BOM.xls"') + + def test_export_xlsx(self): + """ + Test BOM download in XLSX format + """ + + params = { + 'file_format': 'xlsx', + 'cascade': True, + 'parameter_data': True, + 'stock_data': True, + 'supplier_data': True, + 'manufacturer_data': True, + } + + response = self.client.get(self.url, data=params) + + self.assertEqual(response.status_code, 200) + + def test_export_json(self): + """ + Test BOM download in JSON format + """ + + params = { + 'file_format': 'json', + 'cascade': True, + 'parameter_data': True, + 'stock_data': True, + 'supplier_data': True, + 'manufacturer_data': True, + } + + response = self.client.get(self.url, data=params) + + self.assertEqual(response.status_code, 200) + + content = response.headers['Content-Disposition'] + self.assertEqual(content, 'attachment; filename="BOB | Bob | A2_BOM.json"') From 7cc1063114f46d06b56a2847b804bda772497445 Mon Sep 17 00:00:00 2001 From: Oliver Date: Sat, 10 Jul 2021 23:04:34 +1000 Subject: [PATCH 11/45] PEP --- InvenTree/part/test_bom_export.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/InvenTree/part/test_bom_export.py b/InvenTree/part/test_bom_export.py index 29ee588621..13ec3a179e 100644 --- a/InvenTree/part/test_bom_export.py +++ b/InvenTree/part/test_bom_export.py @@ -8,8 +8,6 @@ from django.urls import reverse from django.contrib.auth import get_user_model from django.contrib.auth.models import Group -from .models import Part, BomItem - class BomExportTest(TestCase): From 0507e8a3bc9805231c391b2430cb177fac82df4c Mon Sep 17 00:00:00 2001 From: Oliver Date: Sat, 10 Jul 2021 23:59:35 +1000 Subject: [PATCH 12/45] Building stock adjustment modal --- InvenTree/templates/js/stock.js | 94 +++++++++++++++++++++++++++++++++ 1 file changed, 94 insertions(+) diff --git a/InvenTree/templates/js/stock.js b/InvenTree/templates/js/stock.js index 524b116743..efa3c30ada 100644 --- a/InvenTree/templates/js/stock.js +++ b/InvenTree/templates/js/stock.js @@ -750,6 +750,100 @@ function loadStockTable(table, options) { stock.push(item.pk); }); + var title = 'Form title'; + + switch (action) { + case 'move': + title = '{% trans "Transfer Stock" %}'; + break; + case 'count': + title = '{% trans "Count Stock" %}'; + break; + case 'take': + title = '{% trans "Remove Stock" %}'; + break; + case 'add': + title = '{% trans "Add Stock" %}'; + break; + case 'delete': + title = '{% trans "Delete Stock" %}'; + break; + default: + break; + } + + var modal = createNewModal({ + title: title, + }); + + // Generate content for the modal + + var html = ` + + + + + + + + + + + `; + + items.forEach(function(item) { + + var pk = item.pk; + + var image = item.part_detail.thumbnail || item.part_detail.image || blankImage(); + + var status = stockStatusDisplay(item.status, { + classes: 'float-right' + }); + + var quantity = item.quantity; + + if (item.serial != null) { + quantity = `#${item.serial}`; + } + + var buttons = `
    `; + + buttons += makeIconButton( + 'fa-trash-alt icon-red', + 'button-stock-item-remove', + pk, + '{% trans "Remove stock item" %}', + ); + + buttons += `
    `; + + html += ` + + + + + + `; + + }); + + html += `
    {% trans "Part" %}{% trans "Stock" %}{% trans "Location" %}
    ${item.part_detail.full_name}${status}${quantity}${item.location_detail.pathstring}${buttons}
    `; + + $(modal).find('.modal-form-content').html(html); + + // Add a "confirm" button + insertConfirmButton({ + modal: modal, + }); + + attachToggle(modal); + + $(modal + ' .select2-container').addClass('select-full-width'); + $(modal + ' .select2-container').css('width', '100%'); + + return; + // Buttons for launching secondary modals var secondary = []; From 14ab1bef14370b330215cb15cc87a6ac499b3041 Mon Sep 17 00:00:00 2001 From: Oliver Date: Sun, 11 Jul 2021 00:15:46 +1000 Subject: [PATCH 13/45] Callback to remove row --- InvenTree/templates/js/stock.js | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/InvenTree/templates/js/stock.js b/InvenTree/templates/js/stock.js index efa3c30ada..2413d36089 100644 --- a/InvenTree/templates/js/stock.js +++ b/InvenTree/templates/js/stock.js @@ -832,6 +832,13 @@ function loadStockTable(table, options) { $(modal).find('.modal-form-content').html(html); + // Attach callbacks for the action buttons + $(modal).find('.button-stock-item-remove').click(function() { + var pk = $(this).attr('pk'); + + $(modal).find(`#stock_item_${pk}`).remove(); + }); + // Add a "confirm" button insertConfirmButton({ modal: modal, From 03fb6e5c611adc9d75c9b6a18f6bf5b473ed8f0e Mon Sep 17 00:00:00 2001 From: Matthias Date: Sat, 10 Jul 2021 17:54:17 +0200 Subject: [PATCH 14/45] support leading zeros in serial numbers --- InvenTree/stock/views.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/InvenTree/stock/views.py b/InvenTree/stock/views.py index c74b0bb2fc..c763afe38f 100644 --- a/InvenTree/stock/views.py +++ b/InvenTree/stock/views.py @@ -91,20 +91,20 @@ class StockItemDetail(InvenTreeRoleMixin, DetailView): data = super().get_context_data(**kwargs) if self.object.serialized: - serial_elem = {a.serial: a for a in self.object.part.stock_items.all() if a.serialized} - serials = [int(a) for a in serial_elem.keys()] + serial_elem = {int(a.serial): a for a in self.object.part.stock_items.all() if a.serialized} + serials = serial_elem.keys() current = int(self.object.serial) # previous for nbr in range(current - 1, -1, -1): if nbr in serials: - data['previous'] = serial_elem.get(str(nbr), None) + data['previous'] = serial_elem.get(nbr, None) break # next for nbr in range(current + 1, max(serials) + 1): if nbr in serials: - data['next'] = serial_elem.get(str(nbr), None) + data['next'] = serial_elem.get(nbr, None) break return data From c045a3b6f67f05200df674e1a2920127ed781cef Mon Sep 17 00:00:00 2001 From: Oliver Date: Sun, 11 Jul 2021 20:36:52 +1000 Subject: [PATCH 15/45] Refactorin' --- InvenTree/stock/api.py | 13 +- InvenTree/templates/js/forms.js | 12 +- InvenTree/templates/js/stock.js | 340 ++++++++++++++++++-------------- 3 files changed, 205 insertions(+), 160 deletions(-) diff --git a/InvenTree/stock/api.py b/InvenTree/stock/api.py index 4a6e7111e8..64be7c885f 100644 --- a/InvenTree/stock/api.py +++ b/InvenTree/stock/api.py @@ -143,7 +143,7 @@ class StockAdjust(APIView): elif 'items' in request.data: _items = request.data['items'] else: - raise ValidationError({'items': 'Request must contain list of stock items'}) + raise ValidationError({'items': _('Request must contain list of stock items')}) # List of validated items self.items = [] @@ -151,13 +151,14 @@ class StockAdjust(APIView): for entry in _items: if not type(entry) == dict: - raise ValidationError({'error': 'Improperly formatted data'}) + raise ValidationError({'error': _('Improperly formatted data')}) try: - pk = entry.get('pk', None) + # Look for 'pk' value first, with 'id' as a backup + pk = entry.get('pk', entry.get('id', None)) item = StockItem.objects.get(pk=pk) except (ValueError, StockItem.DoesNotExist): - raise ValidationError({'pk': 'Each entry must contain a valid pk field'}) + raise ValidationError({'pk': _('Each entry must contain a valid pk field')}) if self.allow_missing_quantity and 'quantity' not in entry: entry['quantity'] = item.quantity @@ -165,10 +166,10 @@ class StockAdjust(APIView): try: quantity = Decimal(str(entry.get('quantity', None))) except (ValueError, TypeError, InvalidOperation): - raise ValidationError({'quantity': "Each entry must contain a valid quantity value"}) + raise ValidationError({'quantity': _("Each entry must contain a valid quantity value")}) if quantity < 0: - raise ValidationError({'quantity': 'Quantity field must not be less than zero'}) + raise ValidationError({'quantity': _('Quantity field must not be less than zero')}) self.items.append({ 'item': item, diff --git a/InvenTree/templates/js/forms.js b/InvenTree/templates/js/forms.js index b7af665393..ffe3868746 100644 --- a/InvenTree/templates/js/forms.js +++ b/InvenTree/templates/js/forms.js @@ -441,7 +441,17 @@ function constructFormBody(fields, options) { modalEnable(modal, true); // Insert generated form content - $(modal).find('.modal-form-content').html(html); + $(modal).find('#form-content').html(html); + + if (options.preFormContent) { + console.log('pre form content', options.preFormContent); + $(modal).find('#pre-form-content').html(options.preFormContent); + } + + if (options.postFormContent) { + console.log('post form content', options.postFormContent); + $(modal).find('#post-form-content').html(options.postFormContent); + } // Clear any existing buttons from the modal $(modal).find('#modal-footer-buttons').html(''); diff --git a/InvenTree/templates/js/stock.js b/InvenTree/templates/js/stock.js index 2413d36089..42cbc74a68 100644 --- a/InvenTree/templates/js/stock.js +++ b/InvenTree/templates/js/stock.js @@ -20,6 +20,138 @@ function stockStatusCodes() { } +/** + * Perform stock adjustments + */ +function adjustStock(items, options={}) { + + var formTitle = 'Form Title Here'; + var actionTitle = null; + + switch (options.action) { + case 'move': + formTitle = '{% trans "Transfer Stock" %}'; + actionTitle = '{% trans "Move" %}'; + break; + case 'count': + formTitle = '{% trans "Count Stock" %}'; + actionTitle = '{% trans "Count" %}'; + break; + case 'take': + formTitle = '{% trans "Remove Stock" %}'; + actionTitle = '{% trans "Take" %}'; + break; + case 'add': + formTitle = '{% trans "Add Stock" %}'; + actionTitle = '{% trans "Add" %}'; + break; + case 'delete': + formTitle = '{% trans "Delete Stock" %}'; + break; + default: + break; + } + + // Generate modal HTML content + var html = ` + + + + + + + + + + + `; + + items.forEach(function(item) { + + var pk = item.pk; + + var image = item.part_detail.thumbnail || item.part_detail.image || blankImage(); + + var status = stockStatusDisplay(item.status, { + classes: 'float-right' + }); + + var quantity = item.quantity; + + var location = locationDetail(item, false); + + if (item.location_detail) { + location = item.location_detail.pathstring; + } + + if (item.serial != null) { + quantity = `#${item.serial}`; + } + + var actionInput = ''; + + if (actionTitle != null) { + actionInput = constructNumberInput( + item.pk, + { + value: item.quantity, + min_value: 0, + } + ) + }; + + var buttons = `
    `; + + buttons += makeIconButton( + 'fa-trash-alt icon-red', + 'button-stock-item-remove', + pk, + '{% trans "Remove stock item" %}', + ); + + buttons += `
    `; + + html += ` + + + + + + `; + + }); + + html += `
    {% trans "Part" %}{% trans "Stock" %}{% trans "Location" %}${actionTitle || ''}
    ${item.part_detail.full_name}${quantity}${status}${location} +
    + ${actionInput} + ${buttons} +
    +
    `; + + var modal = createNewModal({ + title: formTitle, + }); + + constructFormBody({}, { + preFormContent: html, + confirm: true, + modal: modal, + }); + + // Attach callbacks for the action buttons + $(modal).find('.button-stock-item-remove').click(function() { + var pk = $(this).attr('pk'); + + $(modal).find(`#stock_item_${pk}`).remove(); + }); + + attachToggle(modal); + + $(modal + ' .select2-container').addClass('select-full-width'); + $(modal + ' .select2-container').css('width', '100%'); +} + + function removeStockRow(e) { // Remove a selected row from a stock modal form @@ -228,6 +360,58 @@ function loadStockTestResultsTable(table, options) { } + +function locationDetail(row, showLink=true) { + /* + * Function to display a "location" of a StockItem. + * + * Complicating factors: A StockItem may not actually *be* in a location! + * - Could be at a customer + * - Could be installed in another stock item + * - Could be assigned to a sales order + * - Could be currently in production! + * + * So, instead of being naive, we'll check! + */ + + // Display text + var text = ''; + + // URL (optional) + var url = ''; + + if (row.is_building && row.build) { + // StockItem is currently being built! + text = '{% trans "In production" %}'; + url = `/build/${row.build}/`; + } else if (row.belongs_to) { + // StockItem is installed inside a different StockItem + text = `{% trans "Installed in Stock Item" %} ${row.belongs_to}`; + url = `/stock/item/${row.belongs_to}/installed/`; + } else if (row.customer) { + // StockItem has been assigned to a customer + text = '{% trans "Shipped to customer" %}'; + url = `/company/${row.customer}/assigned-stock/`; + } else if (row.sales_order) { + // StockItem has been assigned to a sales order + text = '{% trans "Assigned to Sales Order" %}'; + url = `/order/sales-order/${row.sales_order}/`; + } else if (row.location) { + text = row.location_detail.pathstring; + url = `/stock/location/${row.location}/`; + } else { + text = '{% trans "No stock location set" %}'; + url = ''; + } + + if (showLink && url) { + return renderLink(text, url); + } else { + return text; + } +} + + function loadStockTable(table, options) { /* Load data into a stock table with adjustable options. * Fetches data (via AJAX) and loads into a bootstrap table. @@ -271,56 +455,6 @@ function loadStockTable(table, options) { filters[key] = params[key]; } - function locationDetail(row) { - /* - * Function to display a "location" of a StockItem. - * - * Complicating factors: A StockItem may not actually *be* in a location! - * - Could be at a customer - * - Could be installed in another stock item - * - Could be assigned to a sales order - * - Could be currently in production! - * - * So, instead of being naive, we'll check! - */ - - // Display text - var text = ''; - - // URL (optional) - var url = ''; - - if (row.is_building && row.build) { - // StockItem is currently being built! - text = '{% trans "In production" %}'; - url = `/build/${row.build}/`; - } else if (row.belongs_to) { - // StockItem is installed inside a different StockItem - text = `{% trans "Installed in Stock Item" %} ${row.belongs_to}`; - url = `/stock/item/${row.belongs_to}/installed/`; - } else if (row.customer) { - // StockItem has been assigned to a customer - text = '{% trans "Shipped to customer" %}'; - url = `/company/${row.customer}/assigned-stock/`; - } else if (row.sales_order) { - // StockItem has been assigned to a sales order - text = '{% trans "Assigned to Sales Order" %}'; - url = `/order/sales-order/${row.sales_order}/`; - } else if (row.location) { - text = row.location_detail.pathstring; - url = `/stock/location/${row.location}/`; - } else { - text = '{% trans "No stock location set" %}'; - url = ''; - } - - if (url) { - return renderLink(text, url); - } else { - return text; - } - } - var grouping = true; if ('grouping' in options) { @@ -741,114 +875,14 @@ function loadStockTable(table, options) { ] ); + function stockAdjustment(action) { var items = $("#stock-table").bootstrapTable("getSelections"); - var stock = []; - - items.forEach(function(item) { - stock.push(item.pk); + adjustStock(items, { + action: action, }); - var title = 'Form title'; - - switch (action) { - case 'move': - title = '{% trans "Transfer Stock" %}'; - break; - case 'count': - title = '{% trans "Count Stock" %}'; - break; - case 'take': - title = '{% trans "Remove Stock" %}'; - break; - case 'add': - title = '{% trans "Add Stock" %}'; - break; - case 'delete': - title = '{% trans "Delete Stock" %}'; - break; - default: - break; - } - - var modal = createNewModal({ - title: title, - }); - - // Generate content for the modal - - var html = ` - - - - - - - - - - - `; - - items.forEach(function(item) { - - var pk = item.pk; - - var image = item.part_detail.thumbnail || item.part_detail.image || blankImage(); - - var status = stockStatusDisplay(item.status, { - classes: 'float-right' - }); - - var quantity = item.quantity; - - if (item.serial != null) { - quantity = `#${item.serial}`; - } - - var buttons = `
    `; - - buttons += makeIconButton( - 'fa-trash-alt icon-red', - 'button-stock-item-remove', - pk, - '{% trans "Remove stock item" %}', - ); - - buttons += `
    `; - - html += ` - - - - - - `; - - }); - - html += `
    {% trans "Part" %}{% trans "Stock" %}{% trans "Location" %}
    ${item.part_detail.full_name}${status}${quantity}${item.location_detail.pathstring}${buttons}
    `; - - $(modal).find('.modal-form-content').html(html); - - // Attach callbacks for the action buttons - $(modal).find('.button-stock-item-remove').click(function() { - var pk = $(this).attr('pk'); - - $(modal).find(`#stock_item_${pk}`).remove(); - }); - - // Add a "confirm" button - insertConfirmButton({ - modal: modal, - }); - - attachToggle(modal); - - $(modal + ' .select2-container').addClass('select-full-width'); - $(modal + ' .select2-container').css('width', '100%'); - return; // Buttons for launching secondary modals From 9e4bc274cff3e13c8bad3cb64300ba3174b8d369 Mon Sep 17 00:00:00 2001 From: Oliver Date: Sun, 11 Jul 2021 20:40:27 +1000 Subject: [PATCH 16/45] Allow custom code to be run on form submission --- InvenTree/templates/js/forms.js | 22 +++++++++++++++------- InvenTree/templates/js/stock.js | 3 +++ 2 files changed, 18 insertions(+), 7 deletions(-) diff --git a/InvenTree/templates/js/forms.js b/InvenTree/templates/js/forms.js index ffe3868746..722db85d1c 100644 --- a/InvenTree/templates/js/forms.js +++ b/InvenTree/templates/js/forms.js @@ -444,12 +444,10 @@ function constructFormBody(fields, options) { $(modal).find('#form-content').html(html); if (options.preFormContent) { - console.log('pre form content', options.preFormContent); $(modal).find('#pre-form-content').html(options.preFormContent); } if (options.postFormContent) { - console.log('post form content', options.postFormContent); $(modal).find('#post-form-content').html(options.postFormContent); } @@ -484,7 +482,21 @@ function constructFormBody(fields, options) { $(modal).on('click', '#modal-form-submit', function() { - submitFormData(fields, options); + // Immediately disable the "submit" button, + // to prevent the form being submitted multiple times! + $(options.modal).find('#modal-form-submit').prop('disabled', true); + + // Run custom code before normal form submission + if (options.beforeSubmit) { + options.beforeSubmit(fields, options); + } + + // Run custom code instead of normal form submission + if (options.onSubmit) { + options.onSubmit(fields, options); + } else { + submitFormData(fields, options); + } }); } @@ -521,10 +533,6 @@ function insertConfirmButton(options) { */ function submitFormData(fields, options) { - // Immediately disable the "submit" button, - // to prevent the form being submitted multiple times! - $(options.modal).find('#modal-form-submit').prop('disabled', true); - // Form data to be uploaded to the server // Only used if file / image upload is required var form_data = new FormData(); diff --git a/InvenTree/templates/js/stock.js b/InvenTree/templates/js/stock.js index 42cbc74a68..339b0c4f43 100644 --- a/InvenTree/templates/js/stock.js +++ b/InvenTree/templates/js/stock.js @@ -136,6 +136,9 @@ function adjustStock(items, options={}) { preFormContent: html, confirm: true, modal: modal, + onSubmit: function(fields, options) { + console.log("submit!"); + } }); // Attach callbacks for the action buttons From ca5d3a57de0804997634852ca8d74b4b6307dc4a Mon Sep 17 00:00:00 2001 From: Oliver Date: Sun, 11 Jul 2021 20:49:54 +1000 Subject: [PATCH 17/45] Set quantity input parameters based on action --- InvenTree/templates/js/forms.js | 10 +++++----- InvenTree/templates/js/stock.js | 33 +++++++++++++++++++++++++++++++-- 2 files changed, 36 insertions(+), 7 deletions(-) diff --git a/InvenTree/templates/js/forms.js b/InvenTree/templates/js/forms.js index 722db85d1c..f0807bda28 100644 --- a/InvenTree/templates/js/forms.js +++ b/InvenTree/templates/js/forms.js @@ -1482,21 +1482,21 @@ function constructInputOptions(name, classes, type, parameters) { opts.push(`readonly=''`); } - if (parameters.value) { + if (parameters.value != null) { // Existing value? opts.push(`value='${parameters.value}'`); - } else if (parameters.default) { + } else if (parameters.default != null) { // Otherwise, a defualt value? opts.push(`value='${parameters.default}'`); } // Maximum input length - if (parameters.max_length) { + if (parameters.max_length != null) { opts.push(`maxlength='${parameters.max_length}'`); } // Minimum input length - if (parameters.min_length) { + if (parameters.min_length != null) { opts.push(`minlength='${parameters.min_length}'`); } @@ -1516,7 +1516,7 @@ function constructInputOptions(name, classes, type, parameters) { } // Placeholder? - if (parameters.placeholder) { + if (parameters.placeholder != null) { opts.push(`placeholder='${parameters.placeholder}'`); } diff --git a/InvenTree/templates/js/stock.js b/InvenTree/templates/js/stock.js index 339b0c4f43..a2be9b4fc0 100644 --- a/InvenTree/templates/js/stock.js +++ b/InvenTree/templates/js/stock.js @@ -70,6 +70,33 @@ function adjustStock(items, options={}) { var pk = item.pk; + var readonly = (item.serial != null); + var minValue = null; + var maxValue = null; + var value = null; + + switch (options.action) { + case 'move': + minValue = 0; + maxValue = item.quantity; + value = item.quantity; + break; + case 'add': + minValue = 0; + value = 0; + break; + case 'take': + minValue = 0; + value = 0; + break; + case 'count': + minValue = 0; + value = item.quantity; + break; + default: + break; + } + var image = item.part_detail.thumbnail || item.part_detail.image || blankImage(); var status = stockStatusDisplay(item.status, { @@ -94,8 +121,10 @@ function adjustStock(items, options={}) { actionInput = constructNumberInput( item.pk, { - value: item.quantity, - min_value: 0, + value: value, + min_value: minValue, + max_value: maxValue, + readonly: readonly, } ) }; From 3efd7f7777362a3d3d8e42eb3e0e42880a364d9c Mon Sep 17 00:00:00 2001 From: Oliver Date: Sun, 11 Jul 2021 20:56:29 +1000 Subject: [PATCH 18/45] Add a "notes" field --- InvenTree/templates/js/forms.js | 9 +++++---- InvenTree/templates/js/stock.js | 7 +++++++ 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/InvenTree/templates/js/forms.js b/InvenTree/templates/js/forms.js index f0807bda28..d68ce64c45 100644 --- a/InvenTree/templates/js/forms.js +++ b/InvenTree/templates/js/forms.js @@ -395,10 +395,11 @@ function constructFormBody(fields, options) { for (var name in displayed_fields) { - // Only push names which are actually in the set of fields - if (name in fields) { - field_names.push(name); - } else { + field_names.push(name); + + // Field not specified in the API, but the client wishes to add it! + if (!(name in fields)) { + fields[name] = displayed_fields[name]; console.log(`WARNING: '${name}' does not match a valid field name.`); } } diff --git a/InvenTree/templates/js/stock.js b/InvenTree/templates/js/stock.js index a2be9b4fc0..e78e06c0ff 100644 --- a/InvenTree/templates/js/stock.js +++ b/InvenTree/templates/js/stock.js @@ -162,6 +162,13 @@ function adjustStock(items, options={}) { }); constructFormBody({}, { + fields: { + note: { + label: '{% trans "Notes" %}', + help_text: '{% trans "Stock transaction notes" %}', + type: 'string', + } + }, preFormContent: html, confirm: true, modal: modal, From 9eb1367d8047a9163f9f722d6c462976eecdbf5a Mon Sep 17 00:00:00 2001 From: Oliver Date: Sun, 11 Jul 2021 21:07:56 +1000 Subject: [PATCH 19/45] Add "location" field --- InvenTree/templates/js/forms.js | 1 - InvenTree/templates/js/stock.js | 10 +++++++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/InvenTree/templates/js/forms.js b/InvenTree/templates/js/forms.js index d68ce64c45..490b67944f 100644 --- a/InvenTree/templates/js/forms.js +++ b/InvenTree/templates/js/forms.js @@ -400,7 +400,6 @@ function constructFormBody(fields, options) { // Field not specified in the API, but the client wishes to add it! if (!(name in fields)) { fields[name] = displayed_fields[name]; - console.log(`WARNING: '${name}' does not match a valid field name.`); } } diff --git a/InvenTree/templates/js/stock.js b/InvenTree/templates/js/stock.js index e78e06c0ff..80249b2870 100644 --- a/InvenTree/templates/js/stock.js +++ b/InvenTree/templates/js/stock.js @@ -132,7 +132,7 @@ function adjustStock(items, options={}) { var buttons = `
    `; buttons += makeIconButton( - 'fa-trash-alt icon-red', + 'fa-times icon-red', 'button-stock-item-remove', pk, '{% trans "Remove stock item" %}', @@ -163,6 +163,14 @@ function adjustStock(items, options={}) { constructFormBody({}, { fields: { + location: { + label: '{% trans "Location" %}', + help_text: '{% trans "Select stock location" %}', + type: 'related field', + required: true, + api_url: `/api/stock/location/`, + model: 'stocklocation', + }, note: { label: '{% trans "Notes" %}', help_text: '{% trans "Stock transaction notes" %}', From cc90c8abbe55dad99db55bb6a662fa7a0cd5c4e7 Mon Sep 17 00:00:00 2001 From: Oliver Date: Sun, 11 Jul 2021 21:15:06 +1000 Subject: [PATCH 20/45] Move buttons to separate table column --- InvenTree/templates/js/stock.js | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/InvenTree/templates/js/stock.js b/InvenTree/templates/js/stock.js index 80249b2870..108a5e595c 100644 --- a/InvenTree/templates/js/stock.js +++ b/InvenTree/templates/js/stock.js @@ -61,6 +61,7 @@ function adjustStock(items, options={}) { {% trans "Stock" %} {% trans "Location" %} ${actionTitle || ''} + @@ -145,12 +146,8 @@ function adjustStock(items, options={}) { ${item.part_detail.full_name} ${quantity}${status} ${location} - -
    - ${actionInput} - ${buttons} -
    - + ${actionInput} + ${buttons} `; }); From 7531984c788f625d87de669b99db92f0fc36fef3 Mon Sep 17 00:00:00 2001 From: Oliver Date: Sun, 11 Jul 2021 21:17:54 +1000 Subject: [PATCH 21/45] Fix read_only attribute --- InvenTree/templates/js/stock.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/InvenTree/templates/js/stock.js b/InvenTree/templates/js/stock.js index 108a5e595c..7fa58a099f 100644 --- a/InvenTree/templates/js/stock.js +++ b/InvenTree/templates/js/stock.js @@ -125,7 +125,7 @@ function adjustStock(items, options={}) { value: value, min_value: minValue, max_value: maxValue, - readonly: readonly, + read_only: readonly, } ) }; From 747cccfa42bffcf54b58c8297293e31a8784aa50 Mon Sep 17 00:00:00 2001 From: Oliver Date: Mon, 12 Jul 2021 16:55:35 +1000 Subject: [PATCH 22/45] Refactor to use more generic forms approach --- InvenTree/templates/js/stock.js | 66 ++++++++++++++++++++++++--------- 1 file changed, 49 insertions(+), 17 deletions(-) diff --git a/InvenTree/templates/js/stock.js b/InvenTree/templates/js/stock.js index 7fa58a099f..50bc49947f 100644 --- a/InvenTree/templates/js/stock.js +++ b/InvenTree/templates/js/stock.js @@ -28,10 +28,15 @@ function adjustStock(items, options={}) { var formTitle = 'Form Title Here'; var actionTitle = null; + var specifyLocation = false; + var allowSerializedStock = false; + switch (options.action) { case 'move': formTitle = '{% trans "Transfer Stock" %}'; actionTitle = '{% trans "Move" %}'; + specifyLocation = true; + allowSerializedStock = true; break; case 'count': formTitle = '{% trans "Count Stock" %}'; @@ -47,6 +52,7 @@ function adjustStock(items, options={}) { break; case 'delete': formTitle = '{% trans "Delete Stock" %}'; + allowSerializedStock = true; break; default: break; @@ -67,7 +73,15 @@ function adjustStock(items, options={}) { `; - items.forEach(function(item) { + var itemCount = 0; + + for (var idx = 0; idx < items.length; idx++) { + + var item = items[idx]; + + if ((item.serial != null) && !allowSerializedStock) { + continue; + } var pk = item.pk; @@ -150,7 +164,17 @@ function adjustStock(items, options={}) { ${buttons} `; - }); + itemCount += 1; + } + + if (itemCount == 0) { + showAlertDialog( + '{% trans "Select Stock Items" %}', + '{% trans "You must select at least one available stock item" %}', + ); + + return; + } html += ``; @@ -158,24 +182,32 @@ function adjustStock(items, options={}) { title: formTitle, }); - constructFormBody({}, { - fields: { - location: { - label: '{% trans "Location" %}', - help_text: '{% trans "Select stock location" %}', - type: 'related field', - required: true, - api_url: `/api/stock/location/`, - model: 'stocklocation', - }, - note: { - label: '{% trans "Notes" %}', - help_text: '{% trans "Stock transaction notes" %}', - type: 'string', - } + // Extra fields + var extraFields = { + location: { + label: '{% trans "Location" %}', + help_text: '{% trans "Select destination stock location" %}', + type: 'related field', + required: true, + api_url: `/api/stock/location/`, + model: 'stocklocation', }, + note: { + label: '{% trans "Notes" %}', + help_text: '{% trans "Stock transaction notes" %}', + type: 'string', + } + }; + + if (!specifyLocation) { + delete extraFields.location; + } + + constructFormBody({}, { preFormContent: html, + fields: extraFields, confirm: true, + confirmMessage: '{% trans "Confirm stock adjustment" %}', modal: modal, onSubmit: function(fields, options) { console.log("submit!"); From e3f85414fa8d083d5a2a305a699453865b1a4549 Mon Sep 17 00:00:00 2001 From: Oliver Date: Mon, 12 Jul 2021 17:32:06 +1000 Subject: [PATCH 23/45] Stock API URL cleanup --- InvenTree/stock/api.py | 36 +++++++++++++++--------------------- 1 file changed, 15 insertions(+), 21 deletions(-) diff --git a/InvenTree/stock/api.py b/InvenTree/stock/api.py index 64be7c885f..b002484798 100644 --- a/InvenTree/stock/api.py +++ b/InvenTree/stock/api.py @@ -1093,47 +1093,41 @@ class LocationDetail(generics.RetrieveUpdateDestroyAPIView): serializer_class = LocationSerializer -stock_endpoints = [ - url(r'^$', StockDetail.as_view(), name='api-stock-detail'), -] - -location_endpoints = [ - url(r'^(?P\d+)/', LocationDetail.as_view(), name='api-location-detail'), - - url(r'^.*$', StockLocationList.as_view(), name='api-location-list'), -] - stock_api_urls = [ - url(r'location/', include(location_endpoints)), + url(r'^location/', include([ + url(r'^(?P\d+)/', LocationDetail.as_view(), name='api-location-detail'), + url(r'^.*$', StockLocationList.as_view(), name='api-location-list'), + ])), - # These JSON endpoints have been replaced (for now) with server-side form rendering - 02/06/2019 - # TODO: Remove server-side forms for stock adjustment!!! - url(r'count/?', StockCount.as_view(), name='api-stock-count'), - url(r'add/?', StockAdd.as_view(), name='api-stock-add'), - url(r'remove/?', StockRemove.as_view(), name='api-stock-remove'), - url(r'transfer/?', StockTransfer.as_view(), name='api-stock-transfer'), + # Endpoints for bulk stock adjustment actions + url(r'^count/', StockCount.as_view(), name='api-stock-count'), + url(r'^add/', StockAdd.as_view(), name='api-stock-add'), + url(r'^remove/', StockRemove.as_view(), name='api-stock-remove'), + url(r'^transfer/', StockTransfer.as_view(), name='api-stock-transfer'), - # Base URL for StockItemAttachment API endpoints + # StockItemAttachment API endpoints url(r'^attachment/', include([ url(r'^(?P\d+)/', StockAttachmentDetail.as_view(), name='api-stock-attachment-detail'), url(r'^$', StockAttachmentList.as_view(), name='api-stock-attachment-list'), ])), - # Base URL for StockItemTestResult API endpoints + # StockItemTestResult API endpoints url(r'^test/', include([ url(r'^(?P\d+)/', StockItemTestResultDetail.as_view(), name='api-stock-test-result-detail'), url(r'^.*$', StockItemTestResultList.as_view(), name='api-stock-test-result-list'), ])), + # StockItemTracking API endpoints url(r'^track/', include([ url(r'^(?P\d+)/', StockTrackingDetail.as_view(), name='api-stock-tracking-detail'), url(r'^.*$', StockTrackingList.as_view(), name='api-stock-tracking-list'), ])), - url(r'^tree/?', StockCategoryTree.as_view(), name='api-stock-tree'), + url(r'^tree/', StockCategoryTree.as_view(), name='api-stock-tree'), # Detail for a single stock item - url(r'^(?P\d+)/', include(stock_endpoints)), + url(r'^(?P\d+)/', StockDetail.as_view(), name='api-stock-detail'), + # Anything else url(r'^.*$', StockList.as_view(), name='api-stock-list'), ] From 0c41cc7c775164ce4ef57f4dcd3ec4b1577b8659 Mon Sep 17 00:00:00 2001 From: Oliver Date: Mon, 12 Jul 2021 18:13:06 +1000 Subject: [PATCH 24/45] Handle form submissions --- InvenTree/templates/js/forms.js | 4 +++ InvenTree/templates/js/stock.js | 58 ++++++++++++++++++++++++++++++--- 2 files changed, 58 insertions(+), 4 deletions(-) diff --git a/InvenTree/templates/js/forms.js b/InvenTree/templates/js/forms.js index 490b67944f..65c1a11b44 100644 --- a/InvenTree/templates/js/forms.js +++ b/InvenTree/templates/js/forms.js @@ -715,6 +715,10 @@ function getFormFieldValue(name, field, options) { // Find the HTML element var el = $(options.modal).find(`#id_${name}`); + if (!el) { + return null; + } + var value = null; switch (field.type) { diff --git a/InvenTree/templates/js/stock.js b/InvenTree/templates/js/stock.js index 50bc49947f..8612e2758a 100644 --- a/InvenTree/templates/js/stock.js +++ b/InvenTree/templates/js/stock.js @@ -28,6 +28,9 @@ function adjustStock(items, options={}) { var formTitle = 'Form Title Here'; var actionTitle = null; + // API url + var url = null; + var specifyLocation = false; var allowSerializedStock = false; @@ -37,18 +40,22 @@ function adjustStock(items, options={}) { actionTitle = '{% trans "Move" %}'; specifyLocation = true; allowSerializedStock = true; + url = '{% url "api-stock-transfer" %}'; break; case 'count': formTitle = '{% trans "Count Stock" %}'; actionTitle = '{% trans "Count" %}'; + url = '{% url "api-stock-count" %}'; break; case 'take': formTitle = '{% trans "Remove Stock" %}'; actionTitle = '{% trans "Take" %}'; + url = '{% url "api-stock-remove" %}'; break; case 'add': formTitle = '{% trans "Add Stock" %}'; actionTitle = '{% trans "Add" %}'; + url = '{% url "api-stock-add" %}'; break; case 'delete': formTitle = '{% trans "Delete Stock" %}'; @@ -156,7 +163,7 @@ function adjustStock(items, options={}) { buttons += `
    `; html += ` - + ${item.part_detail.full_name} ${quantity}${status} ${location} @@ -192,7 +199,7 @@ function adjustStock(items, options={}) { api_url: `/api/stock/location/`, model: 'stocklocation', }, - note: { + notes: { label: '{% trans "Notes" %}', help_text: '{% trans "Stock transaction notes" %}', type: 'string', @@ -209,8 +216,48 @@ function adjustStock(items, options={}) { confirm: true, confirmMessage: '{% trans "Confirm stock adjustment" %}', modal: modal, - onSubmit: function(fields, options) { - console.log("submit!"); + onSubmit: function(fields, opts) { + + // Data to transmit + var data = { + items: [], + }; + + // Add values for each selected stock item + items.forEach(function(item) { + + var q = getFormFieldValue(item.pk, {}, {modal: modal}); + + data.items.push({pk: item.pk, quantity: q}) + }); + + // Add in extra field data + for (field_name in extraFields) { + data[field_name] = getFormFieldValue( + field_name, + fields[field_name], + { + modal: modal, + } + ); + } + + inventreePut( + url, + data, + { + method: 'POST', + success: function(response, status) { + + // Destroy the modal window + $(modal).modal('hide'); + + if (options.onSuccess) { + options.onSuccess(); + } + } + } + ); } }); @@ -957,6 +1004,9 @@ function loadStockTable(table, options) { adjustStock(items, { action: action, + onSuccess: function() { + $('#stock-table').bootstrapTable('refresh'); + } }); return; From e04828214a5dbedf3901612049dbff21362d861c Mon Sep 17 00:00:00 2001 From: Oliver Date: Mon, 12 Jul 2021 19:20:29 +1000 Subject: [PATCH 25/45] Refactor showApiError() function --- InvenTree/stock/api.py | 6 ++-- InvenTree/templates/js/api.js | 48 ++++++++++++++++++++++++++++++++ InvenTree/templates/js/forms.js | 44 ++--------------------------- InvenTree/templates/js/modals.js | 7 +++-- InvenTree/templates/js/stock.js | 22 +++++++++++++++ 5 files changed, 79 insertions(+), 48 deletions(-) diff --git a/InvenTree/stock/api.py b/InvenTree/stock/api.py index b002484798..384e5d1d71 100644 --- a/InvenTree/stock/api.py +++ b/InvenTree/stock/api.py @@ -244,17 +244,17 @@ class StockTransfer(StockAdjust): def post(self, request, *args, **kwargs): - self.get_items(request) - data = request.data try: location = StockLocation.objects.get(pk=data.get('location', None)) except (ValueError, StockLocation.DoesNotExist): - raise ValidationError({'location': 'Valid location must be specified'}) + raise ValidationError({'location': [_('Valid location must be specified')]}) n = 0 + self.get_items(request) + for item in self.items: # If quantity is not specified, move the entire stock diff --git a/InvenTree/templates/js/api.js b/InvenTree/templates/js/api.js index 5e8905a1dd..93fa5a41e4 100644 --- a/InvenTree/templates/js/api.js +++ b/InvenTree/templates/js/api.js @@ -1,3 +1,6 @@ +{% load i18n %} +{% load inventree_extras %} + var jQuery = window.$; // using jQuery @@ -138,4 +141,49 @@ function inventreeDelete(url, options={}) { inventreePut(url, {}, options); +} + + +function showApiError(xhr) { + + var title = null; + var message = null; + + switch (xhr.status) { + case 0: // No response + title = '{% trans "No Response" %}'; + message = '{% trans "No response from the InvenTree server" %}'; + break; + case 400: // Bad request + // Note: Normally error code 400 is handled separately, + // and should now be shown here! + title = '{% trans "Error 400: Bad request" %}'; + message = '{% trans "API request returned error code 400" %}'; + break; + case 401: // Not authenticated + title = '{% trans "Error 401: Not Authenticated" %}'; + message = '{% trans "Authentication credentials not supplied" %}'; + break; + case 403: // Permission denied + title = '{% trans "Error 403: Permission Denied" %}'; + message = '{% trans "You do not have the required permissions to access this function" %}'; + break; + case 404: // Resource not found + title = '{% trans "Error 404: Resource Not Found" %}'; + message = '{% trans "The requested resource could not be located on the server" %}'; + break; + case 408: // Timeout + title = '{% trans "Error 408: Timeout" %}'; + message = '{% trans "Connection timeout while requesting data from server" %}'; + break; + default: + title = '{% trans "Unhandled Error Code" %}'; + message = `{% trans "Error code" %}: ${xhr.status}`; + break; + } + + message += "
    "; + message += renderErrorMessage(xhr); + + showAlertDialog(title, message); } \ No newline at end of file diff --git a/InvenTree/templates/js/forms.js b/InvenTree/templates/js/forms.js index 65c1a11b44..103ba26572 100644 --- a/InvenTree/templates/js/forms.js +++ b/InvenTree/templates/js/forms.js @@ -422,10 +422,8 @@ function constructFormBody(fields, options) { default: break; } - - var f = constructField(name, field, options); - html += f; + html += constructField(name, field, options); } // TODO: Dynamically create the modals, @@ -599,47 +597,9 @@ function submitFormData(fields, options) { case 400: // Bad request handleFormErrors(xhr.responseJSON, fields, options); break; - case 0: // No response - $(options.modal).modal('hide'); - showAlertDialog( - '{% trans "No Response" %}', - '{% trans "No response from the InvenTree server" %}', - ); - break; - case 401: // Not authenticated - $(options.modal).modal('hide'); - showAlertDialog( - '{% trans "Error 401: Not Authenticated" %}', - '{% trans "Authentication credentials not supplied" %}', - ); - break; - case 403: // Permission denied - $(options.modal).modal('hide'); - showAlertDialog( - '{% trans "Error 403: Permission Denied" %}', - '{% trans "You do not have the required permissions to access this function" %}', - ); - break; - case 404: // Resource not found - $(options.modal).modal('hide'); - showAlertDialog( - '{% trans "Error 404: Resource Not Found" %}', - '{% trans "The requested resource could not be located on the server" %}', - ); - break; - case 408: // Timeout - $(options.modal).modal('hide'); - showAlertDialog( - '{% trans "Error 408: Timeout" %}', - '{% trans "Connection timeout while requesting data from server" %}', - ); - break; default: $(options.modal).modal('hide'); - - showAlertDialog('{% trans "Error requesting form data" %}', renderErrorMessage(xhr)); - - console.log(`WARNING: Unhandled response code - ${xhr.status}`); + showApiError(xhr); break; } } diff --git a/InvenTree/templates/js/modals.js b/InvenTree/templates/js/modals.js index b613ed81f6..b49d7fadfc 100644 --- a/InvenTree/templates/js/modals.js +++ b/InvenTree/templates/js/modals.js @@ -39,12 +39,13 @@ function createNewModal(options={}) {
  • {% endif %} @@ -221,8 +227,8 @@ }); {% if location %} - $("#location-count").click(function() { + function adjustLocationStock(action) { inventreeGet( '{% url "api-stock-list" %}', { @@ -233,7 +239,7 @@ }, { success: function(items) { - adjustStock('count', items, { + adjustStock(action, items, { onSuccess: function() { location.reload(); } @@ -241,6 +247,14 @@ } } ); + } + + $("#location-count").click(function() { + adjustLocationStock('count'); + }); + + $("#location-move").click(function() { + adjustLocationStock('move'); }); $('#print-label').click(function() { From a1579eecfd719235e6174bc1099369f804f30901 Mon Sep 17 00:00:00 2001 From: Oliver Date: Mon, 12 Jul 2021 20:55:28 +1000 Subject: [PATCH 34/45] Refactor "showAlertDialog" function --- InvenTree/templates/js/modals.js | 27 ++++++++++++++------------- InvenTree/templates/modals.html | 23 +---------------------- 2 files changed, 15 insertions(+), 35 deletions(-) diff --git a/InvenTree/templates/js/modals.js b/InvenTree/templates/js/modals.js index b49d7fadfc..1b685cb2a8 100644 --- a/InvenTree/templates/js/modals.js +++ b/InvenTree/templates/js/modals.js @@ -103,6 +103,14 @@ function createNewModal(options={}) { modalSetSubmitText(modal_name, options.submitText || '{% trans "Submit" %}'); modalSetCloseText(modal_name, options.cancelText || '{% trans "Cancel" %}'); + if (options.hideSubmitButton) { + $(modal_name).find('#modal-form-submit').hide(); + } + + if (options.hideCloseButton) { + $(modal_name).find('#modal-form-cancel').hide(); + } + // Return the "name" of the modal return modal_name; } @@ -552,25 +560,18 @@ function showAlertDialog(title, content, options={}) { * * title - Title text * content - HTML content of the dialog window - * options: - * modal - modal form to use (default = '#modal-alert-dialog') */ - var modal = options.modal || '#modal-alert-dialog'; - $(modal).on('shown.bs.modal', function() { - $(modal + ' .modal-form-content').scrollTop(0); + var modal = createNewModal({ + title: title, + cancelText: '{% trans "Close" %}', + hideSubmitButton: true, }); - modalSetTitle(modal, title); - modalSetContent(modal, content); + modalSetContent(modal, content); - $(modal).modal({ - backdrop: 'static', - keyboard: false, - }); - - $(modal).modal('show'); + $(modal).modal('show'); } diff --git a/InvenTree/templates/modals.html b/InvenTree/templates/modals.html index e2bd44554c..7b1c54bfb7 100644 --- a/InvenTree/templates/modals.html +++ b/InvenTree/templates/modals.html @@ -77,25 +77,4 @@
    - - - \ No newline at end of file + \ No newline at end of file From edf4aab063d875d21b229db451481afda634f7ba Mon Sep 17 00:00:00 2001 From: Oliver Date: Mon, 12 Jul 2021 21:03:01 +1000 Subject: [PATCH 35/45] Refactor "showQuestionDialog" function --- InvenTree/templates/js/modals.js | 26 +++++--------------------- InvenTree/templates/modals.html | 21 --------------------- 2 files changed, 5 insertions(+), 42 deletions(-) diff --git a/InvenTree/templates/js/modals.js b/InvenTree/templates/js/modals.js index 1b685cb2a8..b404af364c 100644 --- a/InvenTree/templates/js/modals.js +++ b/InvenTree/templates/js/modals.js @@ -12,7 +12,6 @@ */ function createNewModal(options={}) { - var id = 1; // Check out what modal forms are already being displayed @@ -588,22 +587,15 @@ function showQuestionDialog(title, content, options={}) { * cancel - Functino to run if the user presses 'Cancel' */ - var modal = options.modal || '#modal-question-dialog'; - - $(modal).on('shown.bs.modal', function() { - $(modal + ' .modal-form-content').scrollTop(0); + var modal = createNewModal({ + title: title, + submitText: options.accept_text || '{% trans "Accept" %}', + cancelText: options.cancel_text || '{% trans "Cancel" %}', }); - modalSetTitle(modal, title); modalSetContent(modal, content); - var accept_text = options.accept_text || '{% trans "Accept" %}'; - var cancel_text = options.cancel_text || '{% trans "Cancel" %}'; - - $(modal).find('#modal-form-cancel').html(cancel_text); - $(modal).find('#modal-form-accept').html(accept_text); - - $(modal).on('click', '#modal-form-accept', function() { + $(modal).on('click', "#modal-form-submit", function() { $(modal).modal('hide'); if (options.accept) { @@ -611,14 +603,6 @@ function showQuestionDialog(title, content, options={}) { } }); - $(modal).on('click', 'modal-form-cancel', function() { - $(modal).modal('hide'); - - if (options.cancel) { - options.cancel(); - } - }); - $(modal).modal('show'); } diff --git a/InvenTree/templates/modals.html b/InvenTree/templates/modals.html index 7b1c54bfb7..11ddc40938 100644 --- a/InvenTree/templates/modals.html +++ b/InvenTree/templates/modals.html @@ -56,25 +56,4 @@ - - - - \ No newline at end of file From 52eedef82009c3d267fc3b4b33ec33a3542ec894 Mon Sep 17 00:00:00 2001 From: Oliver Date: Mon, 12 Jul 2021 21:03:28 +1000 Subject: [PATCH 36/45] remove old StockAdjust view --- InvenTree/stock/urls.py | 2 - InvenTree/stock/views.py | 337 --------------------------------------- 2 files changed, 339 deletions(-) diff --git a/InvenTree/stock/urls.py b/InvenTree/stock/urls.py index ac9474f805..67101c1f3b 100644 --- a/InvenTree/stock/urls.py +++ b/InvenTree/stock/urls.py @@ -64,8 +64,6 @@ stock_urls = [ url(r'^track/', include(stock_tracking_urls)), - url(r'^adjust/?', views.StockAdjust.as_view(), name='stock-adjust'), - url(r'^export-options/?', views.StockExportOptions.as_view(), name='stock-export-options'), url(r'^export/?', views.StockExport.as_view(), name='stock-export'), diff --git a/InvenTree/stock/views.py b/InvenTree/stock/views.py index c74b0bb2fc..6713909a2a 100644 --- a/InvenTree/stock/views.py +++ b/InvenTree/stock/views.py @@ -749,343 +749,6 @@ class StockItemUninstall(AjaxView, FormMixin): 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_stock_action_titles(self): - - # Choose form title and action column based on the action - titles = { - 'move': [_('Move Stock Items'), _('Move')], - 'count': [_('Count Stock Items'), _('Count')], - 'take': [_('Remove From Stock'), _('Take')], - 'add': [_('Add Stock Items'), _('Add')], - 'delete': [_('Delete Stock Items'), _('Delete')], - } - - self.ajax_form_title = titles[self.stock_action][0] - self.stock_action_title = titles[self.stock_action][1] - - def get_context_data(self): - - context = super().get_context_data() - - context['stock_items'] = self.stock_items - - context['stock_action'] = self.stock_action.strip().lower() - - self.get_stock_action_titles() - context['stock_action_title'] = self.stock_action_title - - # 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' - - # 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(note=form.cleaned_data['note']) - - 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, context=self.get_context_data()) - - def do_action(self, note=None): - """ 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, note=note) - - elif self.stock_action == 'add': - return self.do_add(note=note) - - elif self.stock_action == 'take': - return self.do_take(note=note) - - elif self.stock_action == 'count': - return self.do_count(note=note) - - elif self.stock_action == 'delete': - return self.do_delete(note=note) - - else: - return _('No action performed') - - def do_add(self, note=None): - - count = 0 - - 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 _('Added stock to {n} items').format(n=count) - - def do_take(self, note=None): - - count = 0 - - 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 _('Removed stock from {n} items').format(n=count) - - def do_count(self, note=None): - - count = 0 - - 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, note=None): - """ Perform actual stock movement """ - - count = 0 - - 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 From 77cfadad42fbcaccac680dc6d168e406b2f087ee Mon Sep 17 00:00:00 2001 From: Oliver Date: Mon, 12 Jul 2021 21:11:29 +1000 Subject: [PATCH 37/45] Add 'title' option for contsructed fields --- InvenTree/stock/api.py | 4 ---- InvenTree/templates/js/forms.js | 5 +++++ InvenTree/templates/js/stock.js | 1 + 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/InvenTree/stock/api.py b/InvenTree/stock/api.py index 91810dddc6..08e948607a 100644 --- a/InvenTree/stock/api.py +++ b/InvenTree/stock/api.py @@ -120,9 +120,6 @@ class StockAdjust(APIView): - StockAdd: add stock items - StockRemove: remove stock items - StockTransfer: transfer stock items - - # TODO - This needs serious refactoring!!! - """ queryset = StockItem.objects.none() @@ -502,7 +499,6 @@ class StockList(generics.ListCreateAPIView): serializer = self.get_serializer(data=request.data) serializer.is_valid(raise_exception=True) - # TODO - Save the user who created this item item = serializer.save() # A location was *not* specified - try to infer it diff --git a/InvenTree/templates/js/forms.js b/InvenTree/templates/js/forms.js index a9747d8f7d..b71551747c 100644 --- a/InvenTree/templates/js/forms.js +++ b/InvenTree/templates/js/forms.js @@ -1473,6 +1473,11 @@ function constructInputOptions(name, classes, type, parameters) { opts.push(`required=''`); } + // Custom mouseover title? + if (parameters.title != null) { + opts.push(`title='${parameters.title}'`); + } + // Placeholder? if (parameters.placeholder != null) { opts.push(`placeholder='${parameters.placeholder}'`); diff --git a/InvenTree/templates/js/stock.js b/InvenTree/templates/js/stock.js index 6ce4c3aae4..f173f868f7 100644 --- a/InvenTree/templates/js/stock.js +++ b/InvenTree/templates/js/stock.js @@ -147,6 +147,7 @@ function adjustStock(action, items, options={}) { min_value: minValue, max_value: maxValue, read_only: readonly, + title: readonly ? '{% trans "Quantity cannot be adjusted for serialized stock" %}' : '{% trans "Specify stock quantity" %}', } ) }; From ccf17bf4c5c0d954f2fec6ae4c25cbcfa718c62e Mon Sep 17 00:00:00 2001 From: Oliver Date: Mon, 12 Jul 2021 21:19:00 +1000 Subject: [PATCH 38/45] Fix dodgy CSS file --- .../static/select2/css/select2-bootstrap.css | 4052 ----------------- InvenTree/templates/base.html | 1 - 2 files changed, 4053 deletions(-) delete mode 100644 InvenTree/InvenTree/static/select2/css/select2-bootstrap.css diff --git a/InvenTree/InvenTree/static/select2/css/select2-bootstrap.css b/InvenTree/InvenTree/static/select2/css/select2-bootstrap.css deleted file mode 100644 index 50c94c4e1f..0000000000 --- a/InvenTree/InvenTree/static/select2/css/select2-bootstrap.css +++ /dev/null @@ -1,4052 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - select2-bootstrap-theme/select2-bootstrap.css at master · select2/select2-bootstrap-theme - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    - Skip to content -
    - - - - - - - - - - - -
    - -
    - -
    - -
    - - - -
    -
    -
    - - - - - - - - - - - - -
    -
    - -
      - - - -
    • - -
      - -
      - - - Watch - - -
      - Notifications -
      -
      - - - - - - - - -
      -
      -
      - -
      -
    • - -
    • -
      -
      - - -
      -
      - - -
      - -
    • - -
    • -
      - - Fork - -
      - -

      Fork select2-bootstrap-theme

      -
      -
      - -
      -

      If this dialog fails to load, you can visit the fork page directly.

      -
      -
      -
      -
      - - -
    • -
    - -

    - - /select2-bootstrap-theme - -

    - -
    - - - - -
    -
    -
    - - - - - - - - Permalink - - - - - -
    - - -
    - - Branch: - master - - - - - - - -
    - -
    - - Find file - - - Copy path - -
    -
    - - -
    - - Find file - - - Copy path - -
    -
    - - - - -
    -
    - - @fk - fk - - 0.1.0-beta.10 - - - - 87f8621 - Mar 30, 2017 - -
    - -
    -
    - - 1 contributor - - -
    - -

    - Users who have contributed to this file -

    -
    - - -
    -
    -
    -
    - - - - - -
    - -
    - -
    - 722 lines (625 sloc) - - 22.6 KB -
    - -
    - -
    - Raw - Blame - History -
    - - -
    - - - - -
    - -
    -
    - -
    -
    -
    - - - -
    - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    /*!
    * Select2 Bootstrap Theme v0.1.0-beta.10 (https://select2.github.io/select2-bootstrap-theme)
    * Copyright 2015-2017 Florian Kissling and contributors (https://github.com/select2/select2-bootstrap-theme/graphs/contributors)
    * Licensed under MIT (https://github.com/select2/select2-bootstrap-theme/blob/master/LICENSE)
    */
    -
    .select2-container--bootstrap {
    display: block;
    /*------------------------------------* #COMMON STYLES
    \*------------------------------------*/
    /**
    * Search field in the Select2 dropdown.
    */
    /**
    * No outline for all search fields - in the dropdown
    * and inline in multi Select2s.
    */
    /**
    * Adjust Select2's choices hover and selected styles to match
    * Bootstrap 3's default dropdown styles.
    *
    * @see http://getbootstrap.com/components/#dropdowns
    */
    /**
    * Clear the selection.
    */
    /**
    * Address disabled Select2 styles.
    *
    * @see https://select2.github.io/examples.html#disabled
    * @see http://getbootstrap.com/css/#forms-control-disabled
    */
    /*------------------------------------* #DROPDOWN
    \*------------------------------------*/
    /**
    * Dropdown border color and box-shadow.
    */
    /**
    * Limit the dropdown height.
    */
    /*------------------------------------* #SINGLE SELECT2
    \*------------------------------------*/
    /*------------------------------------* #MULTIPLE SELECT2
    \*------------------------------------*/
    /**
    * Address Bootstrap control sizing classes
    *
    * 1. Reset Bootstrap defaults.
    * 2. Adjust the dropdown arrow button icon position.
    *
    * @see http://getbootstrap.com/css/#forms-control-sizes
    */
    /* 1 */
    /*------------------------------------* #RTL SUPPORT
    \*------------------------------------*/
    }
    -
    .select2-container--bootstrap .select2-selection {
    -webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075);
    box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075);
    background-color: #fff;
    border: 1px solid #ccc;
    border-radius: 4px;
    color: #555555;
    font-size: 14px;
    outline: 0;
    }
    -
    .select2-container--bootstrap .select2-selection.form-control {
    border-radius: 4px;
    }
    -
    .select2-container--bootstrap .select2-search--dropdown .select2-search__field {
    -webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075);
    box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075);
    background-color: #fff;
    border: 1px solid #ccc;
    border-radius: 4px;
    color: #555555;
    font-size: 14px;
    }
    -
    .select2-container--bootstrap .select2-search__field {
    outline: 0;
    /* Firefox 18- */
    /**
    * Firefox 19+
    *
    * @see http://stackoverflow.com/questions/24236240/color-for-styled-placeholder-text-is-muted-in-firefox
    */
    }
    -
    .select2-container--bootstrap .select2-search__field::-webkit-input-placeholder {
    color: #999;
    }
    -
    .select2-container--bootstrap .select2-search__field:-moz-placeholder {
    color: #999;
    }
    -
    .select2-container--bootstrap .select2-search__field::-moz-placeholder {
    color: #999;
    opacity: 1;
    }
    -
    .select2-container--bootstrap .select2-search__field:-ms-input-placeholder {
    color: #999;
    }
    -
    .select2-container--bootstrap .select2-results__option {
    padding: 6px 12px;
    /**
    * Disabled results.
    *
    * @see https://select2.github.io/examples.html#disabled-results
    */
    /**
    * Hover state.
    */
    /**
    * Selected state.
    */
    }
    -
    .select2-container--bootstrap .select2-results__option[role=group] {
    padding: 0;
    }
    -
    .select2-container--bootstrap .select2-results__option[aria-disabled=true] {
    color: #777777;
    cursor: not-allowed;
    }
    -
    .select2-container--bootstrap .select2-results__option[aria-selected=true] {
    background-color: #f5f5f5;
    color: #262626;
    }
    -
    .select2-container--bootstrap .select2-results__option--highlighted[aria-selected] {
    background-color: #337ab7;
    color: #fff;
    }
    -
    .select2-container--bootstrap .select2-results__option .select2-results__option {
    padding: 6px 12px;
    }
    -
    .select2-container--bootstrap .select2-results__option .select2-results__option .select2-results__group {
    padding-left: 0;
    }
    -
    .select2-container--bootstrap .select2-results__option .select2-results__option .select2-results__option {
    margin-left: -12px;
    padding-left: 24px;
    }
    -
    .select2-container--bootstrap .select2-results__option .select2-results__option .select2-results__option .select2-results__option {
    margin-left: -24px;
    padding-left: 36px;
    }
    -
    .select2-container--bootstrap .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option {
    margin-left: -36px;
    padding-left: 48px;
    }
    -
    .select2-container--bootstrap .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option {
    margin-left: -48px;
    padding-left: 60px;
    }
    -
    .select2-container--bootstrap .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option {
    margin-left: -60px;
    padding-left: 72px;
    }
    -
    .select2-container--bootstrap .select2-results__group {
    color: #777777;
    display: block;
    padding: 6px 12px;
    font-size: 12px;
    line-height: 1.42857143;
    white-space: nowrap;
    }
    -
    .select2-container--bootstrap.select2-container--focus .select2-selection, .select2-container--bootstrap.select2-container--open .select2-selection {
    -webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075), 0 0 8px rgba(102, 175, 233, 0.6);
    box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075), 0 0 8px rgba(102, 175, 233, 0.6);
    -webkit-transition: border-color ease-in-out 0.15s, box-shadow ease-in-out 0.15s;
    -o-transition: border-color ease-in-out 0.15s, box-shadow ease-in-out 0.15s;
    -webkit-transition: border-color ease-in-out 0.15s, -webkit-box-shadow ease-in-out 0.15s;
    transition: border-color ease-in-out 0.15s, -webkit-box-shadow ease-in-out 0.15s;
    transition: border-color ease-in-out 0.15s, box-shadow ease-in-out 0.15s;
    transition: border-color ease-in-out 0.15s, box-shadow ease-in-out 0.15s, -webkit-box-shadow ease-in-out 0.15s;
    border-color: #66afe9;
    }
    -
    .select2-container--bootstrap.select2-container--open {
    /**
    * Make the dropdown arrow point up while the dropdown is visible.
    */
    /**
    * Handle border radii of the container when the dropdown is showing.
    */
    }
    -
    .select2-container--bootstrap.select2-container--open .select2-selection .select2-selection__arrow b {
    border-color: transparent transparent #999 transparent;
    border-width: 0 4px 4px 4px;
    }
    -
    .select2-container--bootstrap.select2-container--open.select2-container--below .select2-selection {
    border-bottom-right-radius: 0;
    border-bottom-left-radius: 0;
    border-bottom-color: transparent;
    }
    -
    .select2-container--bootstrap.select2-container--open.select2-container--above .select2-selection {
    border-top-right-radius: 0;
    border-top-left-radius: 0;
    border-top-color: transparent;
    }
    -
    .select2-container--bootstrap .select2-selection__clear {
    color: #999;
    cursor: pointer;
    float: right;
    font-weight: bold;
    margin-right: 10px;
    }
    -
    .select2-container--bootstrap .select2-selection__clear:hover {
    color: #333;
    }
    -
    .select2-container--bootstrap.select2-container--disabled .select2-selection {
    border-color: #ccc;
    -webkit-box-shadow: none;
    box-shadow: none;
    }
    -
    .select2-container--bootstrap.select2-container--disabled .select2-selection,
    .select2-container--bootstrap.select2-container--disabled .select2-search__field {
    cursor: not-allowed;
    }
    -
    .select2-container--bootstrap.select2-container--disabled .select2-selection,
    .select2-container--bootstrap.select2-container--disabled .select2-selection--multiple .select2-selection__choice {
    background-color: #eeeeee;
    }
    -
    .select2-container--bootstrap.select2-container--disabled .select2-selection__clear,
    .select2-container--bootstrap.select2-container--disabled .select2-selection--multiple .select2-selection__choice__remove {
    display: none;
    }
    -
    .select2-container--bootstrap .select2-dropdown {
    -webkit-box-shadow: 0 6px 12px rgba(0, 0, 0, 0.175);
    box-shadow: 0 6px 12px rgba(0, 0, 0, 0.175);
    border-color: #66afe9;
    overflow-x: hidden;
    margin-top: -1px;
    }
    -
    .select2-container--bootstrap .select2-dropdown--above {
    -webkit-box-shadow: 0px -6px 12px rgba(0, 0, 0, 0.175);
    box-shadow: 0px -6px 12px rgba(0, 0, 0, 0.175);
    margin-top: 1px;
    }
    -
    .select2-container--bootstrap .select2-results > .select2-results__options {
    max-height: 200px;
    overflow-y: auto;
    }
    -
    .select2-container--bootstrap .select2-selection--single {
    height: 34px;
    line-height: 1.42857143;
    padding: 6px 24px 6px 12px;
    /**
    * Adjust the single Select2's dropdown arrow button appearance.
    */
    }
    -
    .select2-container--bootstrap .select2-selection--single .select2-selection__arrow {
    position: absolute;
    bottom: 0;
    right: 12px;
    top: 0;
    width: 4px;
    }
    -
    .select2-container--bootstrap .select2-selection--single .select2-selection__arrow b {
    border-color: #999 transparent transparent transparent;
    border-style: solid;
    border-width: 4px 4px 0 4px;
    height: 0;
    left: 0;
    margin-left: -4px;
    margin-top: -2px;
    position: absolute;
    top: 50%;
    width: 0;
    }
    -
    .select2-container--bootstrap .select2-selection--single .select2-selection__rendered {
    color: #555555;
    padding: 0;
    }
    -
    .select2-container--bootstrap .select2-selection--single .select2-selection__placeholder {
    color: #999;
    }
    -
    .select2-container--bootstrap .select2-selection--multiple {
    min-height: 34px;
    padding: 0;
    height: auto;
    /**
    * Make Multi Select2's choices match Bootstrap 3's default button styles.
    */
    /**
    * Minus 2px borders.
    */
    /**
    * Clear the selection.
    */
    }
    -
    .select2-container--bootstrap .select2-selection--multiple .select2-selection__rendered {
    -webkit-box-sizing: border-box;
    -moz-box-sizing: border-box;
    box-sizing: border-box;
    display: block;
    line-height: 1.42857143;
    list-style: none;
    margin: 0;
    overflow: hidden;
    padding: 0;
    width: 100%;
    text-overflow: ellipsis;
    white-space: nowrap;
    }
    -
    .select2-container--bootstrap .select2-selection--multiple .select2-selection__placeholder {
    color: #999;
    float: left;
    margin-top: 5px;
    }
    -
    .select2-container--bootstrap .select2-selection--multiple .select2-selection__choice {
    color: #555555;
    background: #fff;
    border: 1px solid #ccc;
    border-radius: 4px;
    cursor: default;
    float: left;
    margin: 5px 0 0 6px;
    padding: 0 6px;
    }
    -
    .select2-container--bootstrap .select2-selection--multiple .select2-search--inline .select2-search__field {
    background: transparent;
    padding: 0 12px;
    height: 32px;
    line-height: 1.42857143;
    margin-top: 0;
    min-width: 5em;
    }
    -
    .select2-container--bootstrap .select2-selection--multiple .select2-selection__choice__remove {
    color: #999;
    cursor: pointer;
    display: inline-block;
    font-weight: bold;
    margin-right: 3px;
    }
    -
    .select2-container--bootstrap .select2-selection--multiple .select2-selection__choice__remove:hover {
    color: #333;
    }
    -
    .select2-container--bootstrap .select2-selection--multiple .select2-selection__clear {
    margin-top: 6px;
    }
    -
    .select2-container--bootstrap .select2-selection--single.input-sm,
    .input-group-sm .select2-container--bootstrap .select2-selection--single,
    .form-group-sm .select2-container--bootstrap .select2-selection--single {
    border-radius: 3px;
    font-size: 12px;
    height: 30px;
    line-height: 1.5;
    padding: 5px 22px 5px 10px;
    /* 2 */
    }
    -
    .select2-container--bootstrap .select2-selection--single.input-sm .select2-selection__arrow b,
    .input-group-sm .select2-container--bootstrap .select2-selection--single .select2-selection__arrow b,
    .form-group-sm .select2-container--bootstrap .select2-selection--single .select2-selection__arrow b {
    margin-left: -5px;
    }
    -
    .select2-container--bootstrap .select2-selection--multiple.input-sm,
    .input-group-sm .select2-container--bootstrap .select2-selection--multiple,
    .form-group-sm .select2-container--bootstrap .select2-selection--multiple {
    min-height: 30px;
    border-radius: 3px;
    }
    -
    .select2-container--bootstrap .select2-selection--multiple.input-sm .select2-selection__choice,
    .input-group-sm .select2-container--bootstrap .select2-selection--multiple .select2-selection__choice,
    .form-group-sm .select2-container--bootstrap .select2-selection--multiple .select2-selection__choice {
    font-size: 12px;
    line-height: 1.5;
    margin: 4px 0 0 5px;
    padding: 0 5px;
    }
    -
    .select2-container--bootstrap .select2-selection--multiple.input-sm .select2-search--inline .select2-search__field,
    .input-group-sm .select2-container--bootstrap .select2-selection--multiple .select2-search--inline .select2-search__field,
    .form-group-sm .select2-container--bootstrap .select2-selection--multiple .select2-search--inline .select2-search__field {
    padding: 0 10px;
    font-size: 12px;
    height: 28px;
    line-height: 1.5;
    }
    -
    .select2-container--bootstrap .select2-selection--multiple.input-sm .select2-selection__clear,
    .input-group-sm .select2-container--bootstrap .select2-selection--multiple .select2-selection__clear,
    .form-group-sm .select2-container--bootstrap .select2-selection--multiple .select2-selection__clear {
    margin-top: 5px;
    }
    -
    .select2-container--bootstrap .select2-selection--single.input-lg,
    .input-group-lg .select2-container--bootstrap .select2-selection--single,
    .form-group-lg .select2-container--bootstrap .select2-selection--single {
    border-radius: 6px;
    font-size: 18px;
    height: 46px;
    line-height: 1.3333333;
    padding: 10px 31px 10px 16px;
    /* 1 */
    }
    -
    .select2-container--bootstrap .select2-selection--single.input-lg .select2-selection__arrow,
    .input-group-lg .select2-container--bootstrap .select2-selection--single .select2-selection__arrow,
    .form-group-lg .select2-container--bootstrap .select2-selection--single .select2-selection__arrow {
    width: 5px;
    }
    -
    .select2-container--bootstrap .select2-selection--single.input-lg .select2-selection__arrow b,
    .input-group-lg .select2-container--bootstrap .select2-selection--single .select2-selection__arrow b,
    .form-group-lg .select2-container--bootstrap .select2-selection--single .select2-selection__arrow b {
    border-width: 5px 5px 0 5px;
    margin-left: -5px;
    margin-left: -10px;
    margin-top: -2.5px;
    }
    -
    .select2-container--bootstrap .select2-selection--multiple.input-lg,
    .input-group-lg .select2-container--bootstrap .select2-selection--multiple,
    .form-group-lg .select2-container--bootstrap .select2-selection--multiple {
    min-height: 46px;
    border-radius: 6px;
    }
    -
    .select2-container--bootstrap .select2-selection--multiple.input-lg .select2-selection__choice,
    .input-group-lg .select2-container--bootstrap .select2-selection--multiple .select2-selection__choice,
    .form-group-lg .select2-container--bootstrap .select2-selection--multiple .select2-selection__choice {
    font-size: 18px;
    line-height: 1.3333333;
    border-radius: 4px;
    margin: 9px 0 0 8px;
    padding: 0 10px;
    }
    -
    .select2-container--bootstrap .select2-selection--multiple.input-lg .select2-search--inline .select2-search__field,
    .input-group-lg .select2-container--bootstrap .select2-selection--multiple .select2-search--inline .select2-search__field,
    .form-group-lg .select2-container--bootstrap .select2-selection--multiple .select2-search--inline .select2-search__field {
    padding: 0 16px;
    font-size: 18px;
    height: 44px;
    line-height: 1.3333333;
    }
    -
    .select2-container--bootstrap .select2-selection--multiple.input-lg .select2-selection__clear,
    .input-group-lg .select2-container--bootstrap .select2-selection--multiple .select2-selection__clear,
    .form-group-lg .select2-container--bootstrap .select2-selection--multiple .select2-selection__clear {
    margin-top: 10px;
    }
    -
    .select2-container--bootstrap .select2-selection.input-lg.select2-container--open .select2-selection--single {
    /**
    * Make the dropdown arrow point up while the dropdown is visible.
    */
    }
    -
    .select2-container--bootstrap .select2-selection.input-lg.select2-container--open .select2-selection--single .select2-selection__arrow b {
    border-color: transparent transparent #999 transparent;
    border-width: 0 5px 5px 5px;
    }
    -
    .input-group-lg .select2-container--bootstrap .select2-selection.select2-container--open .select2-selection--single {
    /**
    * Make the dropdown arrow point up while the dropdown is visible.
    */
    }
    -
    .input-group-lg .select2-container--bootstrap .select2-selection.select2-container--open .select2-selection--single .select2-selection__arrow b {
    border-color: transparent transparent #999 transparent;
    border-width: 0 5px 5px 5px;
    }
    -
    .select2-container--bootstrap[dir="rtl"] {
    /**
    * Single Select2
    *
    * 1. Makes sure that .select2-selection__placeholder is positioned
    * correctly.
    */
    /**
    * Multiple Select2
    */
    }
    -
    .select2-container--bootstrap[dir="rtl"] .select2-selection--single {
    padding-left: 24px;
    padding-right: 12px;
    }
    -
    .select2-container--bootstrap[dir="rtl"] .select2-selection--single .select2-selection__rendered {
    padding-right: 0;
    padding-left: 0;
    text-align: right;
    /* 1 */
    }
    -
    .select2-container--bootstrap[dir="rtl"] .select2-selection--single .select2-selection__clear {
    float: left;
    }
    -
    .select2-container--bootstrap[dir="rtl"] .select2-selection--single .select2-selection__arrow {
    left: 12px;
    right: auto;
    }
    -
    .select2-container--bootstrap[dir="rtl"] .select2-selection--single .select2-selection__arrow b {
    margin-left: 0;
    }
    -
    .select2-container--bootstrap[dir="rtl"] .select2-selection--multiple .select2-selection__choice,
    .select2-container--bootstrap[dir="rtl"] .select2-selection--multiple .select2-selection__placeholder,
    .select2-container--bootstrap[dir="rtl"] .select2-selection--multiple .select2-search--inline {
    float: right;
    }
    -
    .select2-container--bootstrap[dir="rtl"] .select2-selection--multiple .select2-selection__choice {
    margin-left: 0;
    margin-right: 6px;
    }
    -
    .select2-container--bootstrap[dir="rtl"] .select2-selection--multiple .select2-selection__choice__remove {
    margin-left: 2px;
    margin-right: auto;
    }
    -
    /*------------------------------------* #ADDITIONAL GOODIES
    \*------------------------------------*/
    /**
    * Address Bootstrap's validation states
    *
    * If a Select2 widget parent has one of Bootstrap's validation state modifier
    * classes, adjust Select2's border colors and focus states accordingly.
    * You may apply said classes to the Select2 dropdown (body > .select2-container)
    * via JavaScript match Bootstraps' to make its styles match.
    *
    * @see http://getbootstrap.com/css/#forms-control-validation
    */
    .has-warning .select2-dropdown,
    .has-warning .select2-selection {
    border-color: #8a6d3b;
    }
    -
    .has-warning .select2-container--focus .select2-selection,
    .has-warning .select2-container--open .select2-selection {
    -webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075), 0 0 6px #c0a16b;
    box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075), 0 0 6px #c0a16b;
    border-color: #66512c;
    }
    -
    .has-warning.select2-drop-active {
    border-color: #66512c;
    }
    -
    .has-warning.select2-drop-active.select2-drop.select2-drop-above {
    border-top-color: #66512c;
    }
    -
    .has-error .select2-dropdown,
    .has-error .select2-selection {
    border-color: #a94442;
    }
    -
    .has-error .select2-container--focus .select2-selection,
    .has-error .select2-container--open .select2-selection {
    -webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075), 0 0 6px #ce8483;
    box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075), 0 0 6px #ce8483;
    border-color: #843534;
    }
    -
    .has-error.select2-drop-active {
    border-color: #843534;
    }
    -
    .has-error.select2-drop-active.select2-drop.select2-drop-above {
    border-top-color: #843534;
    }
    -
    .has-success .select2-dropdown,
    .has-success .select2-selection {
    border-color: #3c763d;
    }
    -
    .has-success .select2-container--focus .select2-selection,
    .has-success .select2-container--open .select2-selection {
    -webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075), 0 0 6px #67b168;
    box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075), 0 0 6px #67b168;
    border-color: #2b542c;
    }
    -
    .has-success.select2-drop-active {
    border-color: #2b542c;
    }
    -
    .has-success.select2-drop-active.select2-drop.select2-drop-above {
    border-top-color: #2b542c;
    }
    -
    /**
    * Select2 widgets in Bootstrap Input Groups
    *
    * @see http://getbootstrap.com/components/#input-groups
    * @see https://github.com/twbs/bootstrap/blob/master/less/input-groups.less
    */
    /**
    * Reset rounded corners
    */
    .input-group > .select2-hidden-accessible:first-child + .select2-container--bootstrap > .selection > .select2-selection,
    .input-group > .select2-hidden-accessible:first-child + .select2-container--bootstrap > .selection > .select2-selection.form-control {
    border-bottom-right-radius: 0;
    border-top-right-radius: 0;
    }
    -
    .input-group > .select2-hidden-accessible:not(:first-child) + .select2-container--bootstrap:not(:last-child) > .selection > .select2-selection,
    .input-group > .select2-hidden-accessible:not(:first-child) + .select2-container--bootstrap:not(:last-child) > .selection > .select2-selection.form-control {
    border-radius: 0;
    }
    -
    .input-group > .select2-hidden-accessible:not(:first-child):not(:last-child) + .select2-container--bootstrap:last-child > .selection > .select2-selection,
    .input-group > .select2-hidden-accessible:not(:first-child):not(:last-child) + .select2-container--bootstrap:last-child > .selection > .select2-selection.form-control {
    border-bottom-left-radius: 0;
    border-top-left-radius: 0;
    }
    -
    .input-group > .select2-container--bootstrap {
    display: table;
    table-layout: fixed;
    position: relative;
    z-index: 2;
    width: 100%;
    margin-bottom: 0;
    /**
    * Adjust z-index like Bootstrap does to show the focus-box-shadow
    * above appended buttons in .input-group and .form-group.
    */
    /**
    * Adjust alignment of Bootstrap buttons in Bootstrap Input Groups to address
    * Multi Select2's height which - depending on how many elements have been selected -
    * may grow taller than its initial size.
    *
    * @see http://getbootstrap.com/components/#input-groups
    */
    }
    -
    .input-group > .select2-container--bootstrap > .selection > .select2-selection.form-control {
    float: none;
    }
    -
    .input-group > .select2-container--bootstrap.select2-container--open, .input-group > .select2-container--bootstrap.select2-container--focus {
    z-index: 3;
    }
    -
    .input-group > .select2-container--bootstrap,
    .input-group > .select2-container--bootstrap .input-group-btn,
    .input-group > .select2-container--bootstrap .input-group-btn .btn {
    vertical-align: top;
    }
    -
    /**
    * Temporary fix for https://github.com/select2/select2-bootstrap-theme/issues/9
    *
    * Provides `!important` for certain properties of the class applied to the
    * original `<select>` element to hide it.
    *
    * @see https://github.com/select2/select2/pull/3301
    * @see https://github.com/fk/select2/commit/31830c7b32cb3d8e1b12d5b434dee40a6e753ada
    */
    .form-control.select2-hidden-accessible {
    position: absolute !important;
    width: 1px !important;
    }
    -
    /**
    * Display override for inline forms
    */
    @media (min-width: 768px) {
    .form-inline .select2-container--bootstrap {
    display: inline-block;
    }
    }
    - - - -
    - -
    - - - -
    - - -
    - - -
    -
    - - - -
    - -
    - -
    -
    - - -
    - - - - - - -
    - - - You can’t perform that action at this time. -
    - - - - - - - - - - - - - - -
    - - - - diff --git a/InvenTree/templates/base.html b/InvenTree/templates/base.html index 76104d8fe2..4818fda1c6 100644 --- a/InvenTree/templates/base.html +++ b/InvenTree/templates/base.html @@ -41,7 +41,6 @@ - From c3b0593abadc3772ab675bce761dd6a1a09de6ca Mon Sep 17 00:00:00 2001 From: Oliver Date: Mon, 12 Jul 2021 21:32:09 +1000 Subject: [PATCH 39/45] Refactor ManufacturerPartCreate form --- .../company/detail_manufacturer_part.html | 38 +++++------ InvenTree/company/test_views.py | 39 ----------- InvenTree/company/urls.py | 3 +- InvenTree/company/views.py | 68 ------------------- InvenTree/part/templates/part/supplier.html | 31 +++++---- 5 files changed, 34 insertions(+), 145 deletions(-) diff --git a/InvenTree/company/templates/company/detail_manufacturer_part.html b/InvenTree/company/templates/company/detail_manufacturer_part.html index 41eb005b2f..4990874059 100644 --- a/InvenTree/company/templates/company/detail_manufacturer_part.html +++ b/InvenTree/company/templates/company/detail_manufacturer_part.html @@ -53,29 +53,23 @@ {{ block.super }} $("#manufacturer-part-create").click(function () { - launchModalForm( - "{% url 'manufacturer-part-create' %}", - { - data: { - manufacturer: {{ company.id }}, + + constructForm('{% url "api-manufacturer-part-list" %}', { + fields: { + part: {}, + manufacturer: { + value: {{ company.pk }}, }, - success: function() { - $("#part-table").bootstrapTable("refresh"); - }, - secondary: [ - { - field: 'part', - label: '{% trans "New Part" %}', - title: '{% trans "Create new Part" %}', - url: "{% url 'part-create' %}" - }, - { - field: 'manufacturer', - label: '{% trans "New Manufacturer" %}', - title: '{% trans "Create new Manufacturer" %}', - }, - ] - }); + MPN: {}, + description: {}, + link: {}, + }, + method: 'POST', + title: '{% trans "Add Manufacturer Part" %}', + onSuccess: function() { + $("#part-table").bootstrapTable("refresh"); + } + }); }); loadManufacturerPartTable( diff --git a/InvenTree/company/test_views.py b/InvenTree/company/test_views.py index 4c53bbb8c4..6fc4281f2b 100644 --- a/InvenTree/company/test_views.py +++ b/InvenTree/company/test_views.py @@ -194,45 +194,6 @@ class ManufacturerPartViewTests(CompanyViewTestBase): Tests for the ManufacturerPart views. """ - def test_manufacturer_part_create(self): - """ - Test the ManufacturerPartCreate view. - """ - - url = reverse('manufacturer-part-create') - - # First check that we can GET the form - response = self.client.get(url, HTTP_X_REQUESTED_WITH='XMLHttpRequest') - self.assertEqual(response.status_code, 200) - - # How many manufaturer parts are already in the database? - n = ManufacturerPart.objects.all().count() - - data = { - 'part': 1, - 'manufacturer': 6, - } - - # MPN is required! (form should fail) - (response, errors) = self.post(url, data, valid=False) - - self.assertIsNotNone(errors.get('MPN', None)) - - data['MPN'] = 'TEST-ME-123' - - (response, errors) = self.post(url, data, valid=True) - - # Check that the ManufacturerPart was created! - self.assertEqual(n + 1, ManufacturerPart.objects.all().count()) - - # Try to create duplicate ManufacturerPart - (response, errors) = self.post(url, data, valid=False) - - self.assertIsNotNone(errors.get('__all__', None)) - - # Check that the ManufacturerPart count stayed the same - self.assertEqual(n + 1, ManufacturerPart.objects.all().count()) - def test_supplier_part_create(self): """ Test that the SupplierPartCreate view creates Manufacturer Part. diff --git a/InvenTree/company/urls.py b/InvenTree/company/urls.py index 4fe0519ea9..e9e125d9e4 100644 --- a/InvenTree/company/urls.py +++ b/InvenTree/company/urls.py @@ -38,8 +38,7 @@ company_urls = [ ] manufacturer_part_urls = [ - url(r'^new/?', views.ManufacturerPartCreate.as_view(), name='manufacturer-part-create'), - + url(r'^(?P\d+)/', include([ url(r'^suppliers/', views.ManufacturerPartDetail.as_view(template_name='company/manufacturer_part_suppliers.html'), name='manufacturer-part-suppliers'), url('^.*$', views.ManufacturerPartDetail.as_view(template_name='company/manufacturer_part_suppliers.html'), name='manufacturer-part-detail'), diff --git a/InvenTree/company/views.py b/InvenTree/company/views.py index 60c37a392a..afdf2b87dc 100644 --- a/InvenTree/company/views.py +++ b/InvenTree/company/views.py @@ -242,74 +242,6 @@ class ManufacturerPartDetail(DetailView): return ctx -class ManufacturerPartCreate(AjaxCreateView): - """ Create view for making new ManufacturerPart """ - - model = ManufacturerPart - form_class = EditManufacturerPartForm - ajax_template_name = 'company/manufacturer_part_create.html' - ajax_form_title = _('Create New Manufacturer Part') - context_object_name = 'part' - - def get_context_data(self): - """ - Supply context data to the form - """ - - ctx = super().get_context_data() - - # Add 'part' object - form = self.get_form() - - part = form['part'].value() - - try: - part = Part.objects.get(pk=part) - except (ValueError, Part.DoesNotExist): - part = None - - ctx['part'] = part - - return ctx - - def get_form(self): - """ Create Form instance to create a new ManufacturerPart object. - Hide some fields if they are not appropriate in context - """ - form = super(AjaxCreateView, self).get_form() - - if form.initial.get('part', None): - # Hide the part field - form.fields['part'].widget = HiddenInput() - - return form - - def get_initial(self): - """ Provide initial data for new ManufacturerPart: - - - If 'manufacturer_id' provided, pre-fill manufacturer field - - If 'part_id' provided, pre-fill part field - """ - initials = super(ManufacturerPartCreate, self).get_initial().copy() - - manufacturer_id = self.get_param('manufacturer') - part_id = self.get_param('part') - - if manufacturer_id: - try: - initials['manufacturer'] = Company.objects.get(pk=manufacturer_id) - except (ValueError, Company.DoesNotExist): - pass - - if part_id: - try: - initials['part'] = Part.objects.get(pk=part_id) - except (ValueError, Part.DoesNotExist): - pass - - return initials - - class SupplierPartDetail(DetailView): """ Detail view for SupplierPart """ model = SupplierPart diff --git a/InvenTree/part/templates/part/supplier.html b/InvenTree/part/templates/part/supplier.html index 8c01243541..8ae73cd07c 100644 --- a/InvenTree/part/templates/part/supplier.html +++ b/InvenTree/part/templates/part/supplier.html @@ -148,21 +148,24 @@ }); $('#manufacturer-create').click(function () { - launchModalForm( - "{% url 'manufacturer-part-create' %}", - { - reload: true, - data: { - part: {{ part.id }} + + constructForm('{% url "api-manufacturer-part-list" %}', { + fields: { + part: { + value: {{ part.pk }}, + hidden: true, }, - secondary: [ - { - field: 'manufacturer', - label: '{% trans "New Manufacturer" %}', - title: '{% trans "Create new manufacturer" %}', - } - ] - }); + manufacturer: {}, + MPN: {}, + description: {}, + link: {}, + }, + method: 'POST', + title: '{% trans "Add Manufacturer Part" %}', + onSuccess: function() { + $("#manufacturer-table").bootstrapTable("refresh"); + } + }); }); {% endblock %} \ No newline at end of file From 30fd3c8841f0e35020e126565d114d2b0e0dd131 Mon Sep 17 00:00:00 2001 From: Oliver Date: Mon, 12 Jul 2021 22:03:36 +1000 Subject: [PATCH 40/45] Unit test fixes --- InvenTree/stock/test_api.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/InvenTree/stock/test_api.py b/InvenTree/stock/test_api.py index 729bf25a9b..74f9505c4a 100644 --- a/InvenTree/stock/test_api.py +++ b/InvenTree/stock/test_api.py @@ -7,8 +7,8 @@ from __future__ import unicode_literals from datetime import datetime, timedelta -from rest_framework import status from django.urls import reverse +from rest_framework import status from InvenTree.status_codes import StockStatus from InvenTree.api_tester import InvenTreeAPITestCase @@ -456,30 +456,32 @@ class StocktakeTest(StockAPITestCase): # POST without a PK response = self.post(url, data) - self.assertContains(response, 'must contain a valid pk', status_code=status.HTTP_400_BAD_REQUEST) + self.assertContains(response, 'must contain a valid integer primary-key', status_code=status.HTTP_400_BAD_REQUEST) - # POST with a PK but no quantity + # POST with an invalid PK data['items'] = [{ 'pk': 10 }] response = self.post(url, data) - self.assertContains(response, 'must contain a valid pk', status_code=status.HTTP_400_BAD_REQUEST) + self.assertContains(response, 'does not match valid stock item', status_code=status.HTTP_400_BAD_REQUEST) + # POST with missing quantity value data['items'] = [{ 'pk': 1234 }] response = self.post(url, data) - self.assertContains(response, 'must contain a valid quantity', status_code=status.HTTP_400_BAD_REQUEST) + self.assertContains(response, 'Invalid quantity value', status_code=status.HTTP_400_BAD_REQUEST) + # POST with an invalid quantity value data['items'] = [{ 'pk': 1234, 'quantity': '10x0d' }] response = self.post(url, data) - self.assertContains(response, 'must contain a valid quantity', status_code=status.HTTP_400_BAD_REQUEST) + self.assertContains(response, 'Invalid quantity value', status_code=status.HTTP_400_BAD_REQUEST) data['items'] = [{ 'pk': 1234, From 7c807674143fe26ce80446f9607b83a606582b00 Mon Sep 17 00:00:00 2001 From: Oliver Date: Mon, 12 Jul 2021 22:06:03 +1000 Subject: [PATCH 41/45] PEP fixes --- InvenTree/company/forms.py | 20 ------------------- .../company/detail_manufacturer_part.html | 8 ++++++-- .../company/manufacturer_part_base.html | 8 ++++++-- InvenTree/company/views.py | 1 - 4 files changed, 12 insertions(+), 25 deletions(-) diff --git a/InvenTree/company/forms.py b/InvenTree/company/forms.py index 15f380ae44..079e871b84 100644 --- a/InvenTree/company/forms.py +++ b/InvenTree/company/forms.py @@ -12,7 +12,6 @@ from django.utils.translation import ugettext_lazy as _ import django.forms from .models import Company -from .models import ManufacturerPart from .models import SupplierPart from .models import SupplierPriceBreak @@ -35,25 +34,6 @@ class CompanyImageDownloadForm(HelperForm): ] -class EditManufacturerPartForm(HelperForm): - """ Form for editing a ManufacturerPart object """ - - field_prefix = { - 'link': 'fa-link', - 'MPN': 'fa-hashtag', - } - - class Meta: - model = ManufacturerPart - fields = [ - 'part', - 'manufacturer', - 'MPN', - 'description', - 'link', - ] - - class EditSupplierPartForm(HelperForm): """ Form for editing a SupplierPart object """ diff --git a/InvenTree/company/templates/company/detail_manufacturer_part.html b/InvenTree/company/templates/company/detail_manufacturer_part.html index 4990874059..0ff261ec67 100644 --- a/InvenTree/company/templates/company/detail_manufacturer_part.html +++ b/InvenTree/company/templates/company/detail_manufacturer_part.html @@ -60,9 +60,13 @@ manufacturer: { value: {{ company.pk }}, }, - MPN: {}, + MPN: { + icon: 'fa-hashtag', + }, description: {}, - link: {}, + link: { + icon: 'fa-link', + }, }, method: 'POST', title: '{% trans "Add Manufacturer Part" %}', diff --git a/InvenTree/company/templates/company/manufacturer_part_base.html b/InvenTree/company/templates/company/manufacturer_part_base.html index 8fdcd79f71..ed1612ea76 100644 --- a/InvenTree/company/templates/company/manufacturer_part_base.html +++ b/InvenTree/company/templates/company/manufacturer_part_base.html @@ -118,9 +118,13 @@ $('#edit-part').click(function () { fields: { part: {}, manufacturer: {}, - MPN: {}, + MPN: { + icon: 'fa-hashtag', + }, description: {}, - link: {}, + link: { + icon: 'fa-link', + }, }, title: '{% trans "Edit Manufacturer Part" %}', reload: true, diff --git a/InvenTree/company/views.py b/InvenTree/company/views.py index afdf2b87dc..03fe03d411 100644 --- a/InvenTree/company/views.py +++ b/InvenTree/company/views.py @@ -29,7 +29,6 @@ from .models import SupplierPart from part.models import Part -from .forms import EditManufacturerPartForm from .forms import EditSupplierPartForm from .forms import CompanyImageDownloadForm From 0cb2b4933361ecac611755ff3fd857315c1273ea Mon Sep 17 00:00:00 2001 From: Oliver Date: Mon, 12 Jul 2021 22:23:08 +1000 Subject: [PATCH 42/45] Remove unused forms --- InvenTree/part/forms.py | 10 --------- InvenTree/stock/forms.py | 44 ---------------------------------------- 2 files changed, 54 deletions(-) diff --git a/InvenTree/part/forms.py b/InvenTree/part/forms.py index 3b60567695..f04f512045 100644 --- a/InvenTree/part/forms.py +++ b/InvenTree/part/forms.py @@ -56,16 +56,6 @@ class PartImageDownloadForm(HelperForm): ] -class PartImageForm(HelperForm): - """ Form for uploading a Part image """ - - class Meta: - model = Part - fields = [ - 'image', - ] - - class BomExportForm(forms.Form): """ Simple form to let user set BOM export options, before exporting a BOM (bill of materials) file. diff --git a/InvenTree/stock/forms.py b/InvenTree/stock/forms.py index ec3eee09d5..0061bbb984 100644 --- a/InvenTree/stock/forms.py +++ b/InvenTree/stock/forms.py @@ -328,50 +328,6 @@ class UninstallStockForm(forms.ModelForm): ] -class AdjustStockForm(forms.ModelForm): - """ Form for performing simple stock adjustments. - - - Add stock - - Remove stock - - Count stock - - Move stock - - This form is used for managing stock adjuments for single or multiple stock items. - """ - - destination = TreeNodeChoiceField(queryset=StockLocation.objects.all(), label=_('Destination'), required=True, help_text=_('Destination stock location')) - - note = forms.CharField(label=_('Notes'), required=True, help_text=_('Add note (required)')) - - # transaction = forms.BooleanField(required=False, initial=False, label='Create Transaction', help_text='Create a stock transaction for these parts') - - confirm = forms.BooleanField(required=False, initial=False, label=_('Confirm stock adjustment'), help_text=_('Confirm movement of stock items')) - - set_loc = forms.BooleanField(required=False, initial=False, label=_('Set Default Location'), help_text=_('Set the destination as the default location for selected parts')) - - class Meta: - model = StockItem - - fields = [ - 'destination', - 'note', - # 'transaction', - 'confirm', - ] - - -class EditStockItemStatusForm(HelperForm): - """ - Simple form for editing StockItem status field - """ - - class Meta: - model = StockItem - fields = [ - 'status', - ] - - class EditStockItemForm(HelperForm): """ Form for editing a StockItem object. Note that not all fields can be edited here (even if they can be specified during creation. From 77d80f5c0fabab48b193a0770d264e2e6f5b25fb Mon Sep 17 00:00:00 2001 From: Oliver Date: Mon, 12 Jul 2021 23:27:11 +1000 Subject: [PATCH 43/45] Move "attachments" and "notes" to "Part Detail" page (cherry picked from commit daf0a082dc04c04cfd68cab70148a7d7cf28460f) --- InvenTree/InvenTree/static/css/inventree.css | 11 ++ InvenTree/part/templates/part/detail.html | 148 +++++++++++++++++-- InvenTree/templates/js/forms.js | 10 +- 3 files changed, 152 insertions(+), 17 deletions(-) diff --git a/InvenTree/InvenTree/static/css/inventree.css b/InvenTree/InvenTree/static/css/inventree.css index 61815f0587..ac7cf1f4e1 100644 --- a/InvenTree/InvenTree/static/css/inventree.css +++ b/InvenTree/InvenTree/static/css/inventree.css @@ -837,6 +837,12 @@ input[type="submit"] { pointer-events: none; /* Prevent this div from blocking links underneath */ } +.notes { + border-radius: 5px; + background-color: #fafafa; + padding: 5px; +} + .alert { display: none; border-radius: 5px; @@ -853,6 +859,11 @@ input[type="submit"] { margin-right: 2px; } +.btn-small { + padding: 3px; + padding-left: 5px; +} + .btn-remove { padding: 3px; padding-left: 5px; diff --git a/InvenTree/part/templates/part/detail.html b/InvenTree/part/templates/part/detail.html index 5132721fec..8a78eb85a5 100644 --- a/InvenTree/part/templates/part/detail.html +++ b/InvenTree/part/templates/part/detail.html @@ -1,6 +1,7 @@ {% extends "part/part_base.html" %} {% load static %} {% load i18n %} +{% load markdownify %} {% block menubar %} @@ -135,11 +136,38 @@ {% endif %} {% if part.responsible %} - d + {% trans "Responsible User" %} {{ part.responsible }} {% endif %} + + + + + + + {% trans "Notes" %} + + +
    + +
    + + + + + {% if part.notes %} +
    + {{ part.notes | markdownify }} +
    + {% endif %} + + +
    @@ -240,23 +268,35 @@ {% block post_content_panel %} -
    -
    -

    - {% trans "Part Parameters" %} -

    -
    -
    -
    -
    - {% if roles.part.add %} - - {% endif %} +
    +
    +
    +
    +

    {% trans "Parameters" %}

    +
    +
    +
    +
    + {% if roles.part.add %} + + {% endif %} +
    +
    +
    +
    +
    +
    +
    +
    +
    +

    {% trans "Attachments" %}

    +
    +
    + {% include "attachment_table.html" %}
    -
    @@ -269,6 +309,18 @@ {% block js_ready %} {{ block.super }} + $('#edit-notes').click(function() { + constructForm('{% url "api-part-detail" part.pk %}', { + fields: { + notes: { + multiline: true, + } + }, + title: '{% trans "Edit Part Notes" %}', + reload: true, + }); + }); + $(".slidey").change(function() { var field = $(this).attr('fieldname'); @@ -337,4 +389,68 @@ }); }); + loadAttachmentTable( + '{% url "api-part-attachment-list" %}', + { + filters: { + part: {{ part.pk }}, + }, + onEdit: function(pk) { + var url = `/api/part/attachment/${pk}/`; + + constructForm(url, { + fields: { + comment: {}, + }, + title: '{% trans "Edit Attachment" %}', + onSuccess: reloadAttachmentTable, + }); + }, + onDelete: function(pk) { + var url = `/api/part/attachment/${pk}/`; + + constructForm(url, { + method: 'DELETE', + confirmMessage: '{% trans "Confirm Delete Operation" %}', + title: '{% trans "Delete Attachment" %}', + onSuccess: reloadAttachmentTable, + }); + } + } + ); + + enableDragAndDrop( + '#attachment-dropzone', + '{% url "api-part-attachment-list" %}', + { + data: { + part: {{ part.id }}, + }, + label: 'attachment', + success: function(data, status, xhr) { + reloadAttachmentTable(); + } + } + ); + + $("#new-attachment").click(function() { + + constructForm( + '{% url "api-part-attachment-list" %}', + { + method: 'POST', + fields: { + attachment: {}, + comment: {}, + part: { + value: {{ part.pk }}, + hidden: true, + } + }, + onSuccess: reloadAttachmentTable, + title: '{% trans "Add Attachment" %}', + } + ) + }); + {% endblock %} diff --git a/InvenTree/templates/js/forms.js b/InvenTree/templates/js/forms.js index b71551747c..6dd7dbd968 100644 --- a/InvenTree/templates/js/forms.js +++ b/InvenTree/templates/js/forms.js @@ -353,12 +353,16 @@ function constructFormBody(fields, options) { // Override existing query filters (if provided!) fields[field].filters = Object.assign(fields[field].filters || {}, field_options.filters); + // TODO: Refactor the following code with Object.assign (see above) + // Secondary modal options fields[field].secondary = field_options.secondary; // Edit callback fields[field].onEdit = field_options.onEdit; + fields[field].multiline = field_options.multiline; + // Custom help_text if (field_options.help_text) { fields[field].help_text = field_options.help_text; @@ -1483,7 +1487,11 @@ function constructInputOptions(name, classes, type, parameters) { opts.push(`placeholder='${parameters.placeholder}'`); } - return ``; + if (parameters.multiline) { + return ``; + } else { + return ``; + } } From b1af07c8cb717f85a6353d379bd25e1cc99a4c0c Mon Sep 17 00:00:00 2001 From: Oliver Date: Mon, 12 Jul 2021 23:31:10 +1000 Subject: [PATCH 44/45] Remove stale pages --- .../part/templates/part/attachments.html | 86 ------------------- InvenTree/part/templates/part/navbar.html | 12 --- InvenTree/part/templates/part/notes.html | 57 ------------ InvenTree/part/urls.py | 2 - InvenTree/part/views.py | 34 -------- 5 files changed, 191 deletions(-) delete mode 100644 InvenTree/part/templates/part/attachments.html delete mode 100644 InvenTree/part/templates/part/notes.html diff --git a/InvenTree/part/templates/part/attachments.html b/InvenTree/part/templates/part/attachments.html deleted file mode 100644 index 7128980472..0000000000 --- a/InvenTree/part/templates/part/attachments.html +++ /dev/null @@ -1,86 +0,0 @@ -{% extends "part/part_base.html" %} -{% load static %} -{% load i18n %} - -{% block menubar %} -{% include 'part/navbar.html' with tab='attachments' %} -{% endblock %} - -{% block heading %} -{% trans "Part Attachments" %} -{% endblock %} - -{% block details %} - -{% include "attachment_table.html" with attachments=part.part_attachments %} - -{% endblock %} - -{% block js_ready %} -{{ block.super }} - - loadAttachmentTable( - '{% url "api-part-attachment-list" %}', - { - filters: { - part: {{ part.pk }}, - }, - onEdit: function(pk) { - var url = `/api/part/attachment/${pk}/`; - - constructForm(url, { - fields: { - comment: {}, - }, - title: '{% trans "Edit Attachment" %}', - onSuccess: reloadAttachmentTable, - }); - }, - onDelete: function(pk) { - var url = `/api/part/attachment/${pk}/`; - - constructForm(url, { - method: 'DELETE', - confirmMessage: '{% trans "Confirm Delete Operation" %}', - title: '{% trans "Delete Attachment" %}', - onSuccess: reloadAttachmentTable, - }); - } - } - ); - - enableDragAndDrop( - '#attachment-dropzone', - '{% url "api-part-attachment-list" %}', - { - data: { - part: {{ part.id }}, - }, - label: 'attachment', - success: function(data, status, xhr) { - reloadAttachmentTable(); - } - } - ); - - $("#new-attachment").click(function() { - - constructForm( - '{% url "api-part-attachment-list" %}', - { - method: 'POST', - fields: { - attachment: {}, - comment: {}, - part: { - value: {{ part.pk }}, - hidden: true, - } - }, - onSuccess: reloadAttachmentTable, - title: '{% trans "Add Attachment" %}', - } - ) - }); - -{% endblock %} \ No newline at end of file diff --git a/InvenTree/part/templates/part/navbar.html b/InvenTree/part/templates/part/navbar.html index afe4fee374..e8617dc677 100644 --- a/InvenTree/part/templates/part/navbar.html +++ b/InvenTree/part/templates/part/navbar.html @@ -109,16 +109,4 @@ {% endif %} -
  • - - - {% trans "Attachments" %} - -
  • -
  • - - - {% trans "Notes" %} - -
  • diff --git a/InvenTree/part/templates/part/notes.html b/InvenTree/part/templates/part/notes.html deleted file mode 100644 index 63e0190d48..0000000000 --- a/InvenTree/part/templates/part/notes.html +++ /dev/null @@ -1,57 +0,0 @@ -{% extends "part/part_base.html" %} -{% load static %} -{% load crispy_forms_tags %} -{% load i18n %} -{% load markdownify %} - -{% block menubar %} -{% include 'part/navbar.html' with tab='notes' %} -{% endblock %} - -{% block heading %} -{% trans "Part Notes" %} -{% if roles.part.change and not editing %} - -{% endif %} -{% endblock %} - -{% block details %} - -{% if editing %} -
    - {% csrf_token %} - - {{ form }} -
    - - - -
    - -{{ form.media }} - -{% else %} - -
    - {% if part.notes %} -
    - {{ part.notes | markdownify }} -
    - {% endif %} -
    - -{% endif %} - -{% endblock %} - -{% block js_ready %} -{{ block.super }} - -{% if editing %} -{% else %} -$("#edit-notes").click(function() { - location.href = "{% url 'part-notes' part.id %}?edit=1"; -}); -{% endif %} - -{% endblock %} \ No newline at end of file diff --git a/InvenTree/part/urls.py b/InvenTree/part/urls.py index 8843a839a1..1fa7227f5e 100644 --- a/InvenTree/part/urls.py +++ b/InvenTree/part/urls.py @@ -61,8 +61,6 @@ part_detail_urls = [ url(r'^tests/', views.PartDetail.as_view(template_name='part/part_tests.html'), name='part-test-templates'), url(r'^track/?', views.PartDetail.as_view(template_name='part/track.html'), name='part-track'), url(r'^related-parts/?', views.PartDetail.as_view(template_name='part/related.html'), name='part-related'), - url(r'^attachments/?', views.PartDetail.as_view(template_name='part/attachments.html'), name='part-attachments'), - url(r'^notes/?', views.PartNotes.as_view(), name='part-notes'), url(r'^qr_code/?', views.PartQRCode.as_view(), name='part-qr'), diff --git a/InvenTree/part/views.py b/InvenTree/part/views.py index 561b9be0fb..dcf40bd186 100644 --- a/InvenTree/part/views.py +++ b/InvenTree/part/views.py @@ -747,40 +747,6 @@ class PartImportAjax(FileManagementAjaxView, PartImport): return PartImport.validate(self, self.steps.current, form, **kwargs) -class PartNotes(UpdateView): - """ View for editing the 'notes' field of a Part object. - Presents a live markdown editor. - """ - - context_object_name = 'part' - # form_class = part_forms.EditNotesForm - template_name = 'part/notes.html' - model = Part - - role_required = 'part.change' - - fields = ['notes'] - - def get_success_url(self): - """ Return the success URL for this form """ - - return reverse('part-notes', kwargs={'pk': self.get_object().id}) - - def get_context_data(self, **kwargs): - - part = self.get_object() - - context = super().get_context_data(**kwargs) - - context['editing'] = str2bool(self.request.GET.get('edit', '')) - - ctx = part.get_context_data(self.request) - - context.update(ctx) - - return context - - class PartDetail(InvenTreeRoleMixin, DetailView): """ Detail view for Part object """ From cf23fb6fe8a6cbb3a2523443f8e2620971bb0485 Mon Sep 17 00:00:00 2001 From: Oliver Date: Mon, 12 Jul 2021 23:32:08 +1000 Subject: [PATCH 45/45] PEP fixes --- InvenTree/part/views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/InvenTree/part/views.py b/InvenTree/part/views.py index dcf40bd186..4acf5fcdb6 100644 --- a/InvenTree/part/views.py +++ b/InvenTree/part/views.py @@ -13,7 +13,7 @@ from django.shortcuts import get_object_or_404 from django.shortcuts import HttpResponseRedirect from django.utils.translation import gettext_lazy as _ from django.urls import reverse, reverse_lazy -from django.views.generic import DetailView, ListView, UpdateView +from django.views.generic import DetailView, ListView from django.forms.models import model_to_dict from django.forms import HiddenInput, CheckboxInput from django.conf import settings