diff --git a/.gitignore b/.gitignore index bfbaf7c285..25ae56db0a 100644 --- a/.gitignore +++ b/.gitignore @@ -37,8 +37,9 @@ InvenTree/media # Key file secret_key.txt -# Ignore python IDE project configuration +# IDE / development files .idea/ +*.code-workspace # Coverage reports .coverage diff --git a/.travis.yml b/.travis.yml index 91d1431f2f..00d049b7a1 100644 --- a/.travis.yml +++ b/.travis.yml @@ -2,7 +2,8 @@ dist: xenial language: python python: - - 3.5 + - 3.6 + - 3.7 addons: apt-packages: diff --git a/InvenTree/InvenTree/settings.py b/InvenTree/InvenTree/settings.py index bb50ed04f4..83549d70e2 100644 --- a/InvenTree/InvenTree/settings.py +++ b/InvenTree/InvenTree/settings.py @@ -153,14 +153,15 @@ DATABASES = { 'ENGINE': 'django.db.backends.sqlite3', 'NAME': os.path.join(BASE_DIR, 'inventree_db.sqlite3'), }, - 'postgresql': { - 'ENGINE': 'django.db.backends.postgresql', - 'NAME': 'inventree', - 'USER': 'inventreeuser', - 'PASSWORD': 'inventree', - 'HOST': 'localhost', - 'PORT': '', - } + # TODO - Uncomment this when postgresql support is re-integrated + # 'postgresql': { + # 'ENGINE': 'django.db.backends.postgresql', + # 'NAME': 'inventree', + # 'USER': 'inventreeuser', + # 'PASSWORD': 'inventree', + # 'HOST': 'localhost', + # 'PORT': '', + # } } CACHES = { diff --git a/InvenTree/build/api.py b/InvenTree/build/api.py index 699671642f..dc6e484ec0 100644 --- a/InvenTree/build/api.py +++ b/InvenTree/build/api.py @@ -26,7 +26,7 @@ class BuildList(generics.ListCreateAPIView): serializer_class = BuildSerializer permission_classes = [ - permissions.IsAuthenticatedOrReadOnly, + permissions.IsAuthenticated, ] filter_backends = [ @@ -47,7 +47,7 @@ class BuildDetail(generics.RetrieveUpdateAPIView): serializer_class = BuildSerializer permission_classes = [ - permissions.IsAuthenticatedOrReadOnly, + permissions.IsAuthenticated, ] @@ -80,7 +80,7 @@ class BuildItemList(generics.ListCreateAPIView): return query permission_classes = [ - permissions.IsAuthenticatedOrReadOnly, + permissions.IsAuthenticated, ] filter_backends = [ diff --git a/InvenTree/company/api.py b/InvenTree/company/api.py index 6e1e5c162d..e1b02a76fa 100644 --- a/InvenTree/company/api.py +++ b/InvenTree/company/api.py @@ -32,7 +32,7 @@ class CompanyList(generics.ListCreateAPIView): serializer_class = CompanySerializer queryset = Company.objects.all() permission_classes = [ - permissions.IsAuthenticatedOrReadOnly, + permissions.IsAuthenticated, ] filter_backends = [ @@ -66,7 +66,7 @@ class CompanyDetail(generics.RetrieveUpdateDestroyAPIView): serializer_class = CompanySerializer permission_classes = [ - permissions.IsAuthenticatedOrReadOnly, + permissions.IsAuthenticated, ] @@ -89,7 +89,10 @@ class SupplierPartList(generics.ListCreateAPIView): def get_serializer(self, *args, **kwargs): # Do we wish to include extra detail? - part_detail = str2bool(self.request.GET.get('part_detail', None)) + try: + part_detail = str2bool(self.request.GET.get('part_detail', None)) + except AttributeError: + part_detail = None kwargs['part_detail'] = part_detail kwargs['context'] = self.get_serializer_context() @@ -99,7 +102,7 @@ class SupplierPartList(generics.ListCreateAPIView): serializer_class = SupplierPartSerializer permission_classes = [ - permissions.IsAuthenticatedOrReadOnly, + permissions.IsAuthenticated, ] filter_backends = [ @@ -132,7 +135,7 @@ class SupplierPartDetail(generics.RetrieveUpdateDestroyAPIView): queryset = SupplierPart.objects.all() serializer_class = SupplierPartSerializer - permission_classes = (permissions.IsAuthenticatedOrReadOnly,) + permission_classes = (permissions.IsAuthenticated,) read_only_fields = [ ] @@ -149,7 +152,7 @@ class SupplierPriceBreakList(generics.ListCreateAPIView): serializer_class = SupplierPriceBreakSerializer permission_classes = [ - permissions.IsAuthenticatedOrReadOnly, + permissions.IsAuthenticated, ] filter_backends = [ diff --git a/InvenTree/company/templates/company/partdetail.html b/InvenTree/company/templates/company/partdetail.html index 681ba25074..d945ec31c4 100644 --- a/InvenTree/company/templates/company/partdetail.html +++ b/InvenTree/company/templates/company/partdetail.html @@ -10,19 +10,26 @@ InvenTree | {{ company.name }} - Parts

Supplier Part

-

{{ part.supplier.name }} - {{ part.SKU }}

+
+
+ + +
+
-

- -

+
+ +
@@ -30,17 +37,18 @@ InvenTree | {{ company.name }} - Parts
+

Supplier Part Details

- - + + {% if part.URL %} {% endif %} @@ -58,10 +66,8 @@ InvenTree | {{ company.name }} - Parts
+

Pricing Information

Supplier{{ part.supplier.name }}
SKU{{ part.SKU }}
Internal Part - {% if part.part %} - {{ part.part.full_name }} - {% endif %} + {% if part.part %} + {{ part.part.full_name }} + {% endif %}
Supplier{{ part.supplier.name }}
SKU{{ part.SKU }}
URL{{ part.URL }}
- - - {% if part.base_cost > 0 %} diff --git a/InvenTree/part/api.py b/InvenTree/part/api.py index 4b1e449c08..1c6678f2d3 100644 --- a/InvenTree/part/api.py +++ b/InvenTree/part/api.py @@ -54,7 +54,7 @@ class CategoryList(generics.ListCreateAPIView): serializer_class = CategorySerializer permission_classes = [ - permissions.IsAuthenticatedOrReadOnly, + permissions.IsAuthenticated, ] filter_backends = [ @@ -91,7 +91,7 @@ class PartDetail(generics.RetrieveUpdateAPIView): serializer_class = PartSerializer permission_classes = [ - permissions.IsAuthenticatedOrReadOnly, + permissions.IsAuthenticated, ] @@ -178,7 +178,7 @@ class PartList(generics.ListCreateAPIView): return parts_list permission_classes = [ - permissions.IsAuthenticatedOrReadOnly, + permissions.IsAuthenticated, ] filter_backends = [ @@ -243,7 +243,7 @@ class PartStarList(generics.ListCreateAPIView): return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers) permission_classes = [ - permissions.IsAuthenticatedOrReadOnly, + permissions.IsAuthenticated, ] filter_backends = [ @@ -273,8 +273,12 @@ class BomList(generics.ListCreateAPIView): def get_serializer(self, *args, **kwargs): # Do we wish to include extra detail? - part_detail = str2bool(self.request.GET.get('part_detail', None)) - sub_part_detail = str2bool(self.request.GET.get('sub_part_detail', None)) + try: + part_detail = str2bool(self.request.GET.get('part_detail', None)) + sub_part_detail = str2bool(self.request.GET.get('sub_part_detail', None)) + except AttributeError: + part_detail = None + sub_part_detail = None kwargs['part_detail'] = part_detail kwargs['sub_part_detail'] = sub_part_detail @@ -288,7 +292,7 @@ class BomList(generics.ListCreateAPIView): return queryset permission_classes = [ - permissions.IsAuthenticatedOrReadOnly, + permissions.IsAuthenticated, ] filter_backends = [ @@ -310,7 +314,7 @@ class BomDetail(generics.RetrieveUpdateDestroyAPIView): serializer_class = BomItemSerializer permission_classes = [ - permissions.IsAuthenticatedOrReadOnly, + permissions.IsAuthenticated, ] diff --git a/InvenTree/part/bom.py b/InvenTree/part/bom.py new file mode 100644 index 0000000000..a96c331a4b --- /dev/null +++ b/InvenTree/part/bom.py @@ -0,0 +1,210 @@ +""" +Functionality for Bill of Material (BOM) management. +Primarily BOM upload tools. +""" + +from fuzzywuzzy import fuzz +import tablib +import os + +from django.utils.translation import gettext_lazy as _ +from django.core.exceptions import ValidationError + +from InvenTree.helpers import DownloadFile + + +def IsValidBOMFormat(fmt): + """ Test if a file format specifier is in the valid list of BOM file formats """ + + return fmt.strip().lower() in ['csv', 'xls', 'xlsx', 'tsv'] + + +def MakeBomTemplate(fmt): + """ Generate a Bill of Materials upload template file (for user download) """ + + fmt = fmt.strip().lower() + + if not IsValidBOMFormat(fmt): + fmt = 'csv' + + fields = [ + 'Part', + 'Quantity', + 'Overage', + 'Reference', + 'Notes' + ] + + data = tablib.Dataset(headers=fields).export(fmt) + + filename = 'InvenTree_BOM_Template.' + fmt + + return DownloadFile(data, filename) + + +class BomUploadManager: + """ Class for managing an uploaded BOM file """ + + # Fields which are absolutely necessary for valid upload + REQUIRED_HEADERS = [ + 'Part', + 'Quantity' + ] + + # Fields which would be helpful but are not required + OPTIONAL_HEADERS = [ + 'Reference', + 'Notes', + 'Overage', + 'Description', + 'Category', + 'Supplier', + 'Manufacturer', + 'MPN', + 'IPN', + ] + + EDITABLE_HEADERS = [ + 'Reference', + 'Notes' + ] + + HEADERS = REQUIRED_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 + + # 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. + Ignored the top rows as indicated by 'starting row' + """ + + 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)): + print("converting", item, "to", int(item)) + data[idx] = int(item) + except ValueError: + pass + + if empty: + print("Empty - continuing") + 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 1564c16316..38280bbc1c 100644 --- a/InvenTree/part/forms.py +++ b/InvenTree/part/forms.py @@ -8,6 +8,7 @@ from __future__ import unicode_literals from InvenTree.forms import HelperForm from django import forms +from django.core.validators import MinValueValidator from .models import Part, PartCategory, PartAttachment from .models import BomItem @@ -38,24 +39,27 @@ class BomValidateForm(HelperForm): ] -class BomExportForm(HelperForm): +class BomUploadSelectFile(HelperForm): + """ Form for importing a BOM. Provides a file input box for upload """ - # TODO - Define these choices somewhere else, and import them here - format_choices = ( - ('csv', 'CSV'), - ('pdf', 'PDF'), - ('xml', 'XML'), - ('xlsx', 'XLSX'), - ('html', 'HTML') - ) - - # Select export type - format = forms.CharField(label='Format', widget=forms.Select(choices=format_choices), required='true', help_text='Select export format') + bom_file = forms.FileField(label='BOM file', required=True, help_text="Select BOM file to upload") class Meta: model = Part fields = [ - 'format', + 'bom_file', + ] + + +class BomUploadSelectFields(HelperForm): + """ Form for selecting BOM fields """ + + starting_row = forms.IntegerField(required=True, initial=2, help_text='Index of starting row', validators=[MinValueValidator(1)]) + + class Meta: + model = Part + fields = [ + 'starting_row', ] @@ -130,6 +134,7 @@ class EditBomItemForm(HelperForm): 'part', 'sub_part', 'quantity', + 'reference', 'overage', 'note' ] diff --git a/InvenTree/part/migrations/0012_auto_20190627_2144.py b/InvenTree/part/migrations/0012_auto_20190627_2144.py new file mode 100644 index 0000000000..ffd574b61d --- /dev/null +++ b/InvenTree/part/migrations/0012_auto_20190627_2144.py @@ -0,0 +1,23 @@ +# Generated by Django 2.2.2 on 2019-06-27 11:44 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('part', '0011_part_revision'), + ] + + operations = [ + migrations.AddField( + model_name='bomitem', + name='reference', + field=models.CharField(blank=True, help_text='BOM item reference', max_length=500), + ), + migrations.AlterField( + model_name='bomitem', + name='note', + field=models.CharField(blank=True, help_text='BOM item notes', max_length=500), + ), + ] diff --git a/InvenTree/part/migrations/0013_auto_20190628_0951.py b/InvenTree/part/migrations/0013_auto_20190628_0951.py new file mode 100644 index 0000000000..df9f8fdb14 --- /dev/null +++ b/InvenTree/part/migrations/0013_auto_20190628_0951.py @@ -0,0 +1,24 @@ +# Generated by Django 2.2.2 on 2019-06-27 23:51 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('part', '0012_auto_20190627_2144'), + ] + + operations = [ + migrations.AlterField( + model_name='bomitem', + name='part', + field=models.ForeignKey(help_text='Select parent part', limit_choices_to={'assembly': True}, on_delete=django.db.models.deletion.CASCADE, related_name='bom_items', to='part.Part'), + ), + migrations.AlterField( + model_name='bomitem', + name='sub_part', + field=models.ForeignKey(help_text='Select part to be used in BOM', limit_choices_to={'component': True}, on_delete=django.db.models.deletion.CASCADE, related_name='used_in', to='part.Part'), + ), + ] diff --git a/InvenTree/part/models.py b/InvenTree/part/models.py index 48e8dc7906..e56466c832 100644 --- a/InvenTree/part/models.py +++ b/InvenTree/part/models.py @@ -671,6 +671,13 @@ class Part(models.Model): self.save() + @transaction.atomic + def clear_bom(self): + """ Clear the BOM items for the part (delete all BOM lines). + """ + + self.bom_items.all().delete() + def required_parts(self): """ Return a list of parts required to make this part (list of BOM items) """ parts = [] @@ -678,6 +685,18 @@ class Part(models.Model): parts.append(bom.sub_part) return parts + def get_allowed_bom_items(self): + """ Return a list of parts which can be added to a BOM for this part. + + - Exclude parts which are not 'component' parts + - Exclude parts which this part is in the BOM for + """ + + parts = Part.objects.filter(component=True).exclude(id=self.id) + parts = parts.exclude(id__in=[part.id for part in self.used_in.all()]) + + return parts + @property def supplier_count(self): """ Return the number of supplier parts available for this part """ @@ -843,15 +862,19 @@ class Part(models.Model): 'Part', 'Description', 'Quantity', + 'Overage', + 'Reference', 'Note', ]) - for it in self.bom_items.all(): + for it in self.bom_items.all().order_by('id'): line = [] line.append(it.sub_part.full_name) line.append(it.sub_part.description) line.append(it.quantity) + line.append(it.overage) + line.append(it.reference) line.append(it.note) data.append(line) @@ -969,6 +992,7 @@ class BomItem(models.Model): part: Link to the parent part (the part that will be produced) sub_part: Link to the child part (the part that will be consumed) quantity: Number of 'sub_parts' consumed to produce one 'part' + reference: BOM reference field (e.g. part designators) overage: Estimated losses for a Build. Can be expressed as absolute value (e.g. '7') or a percentage (e.g. '2%') note: Note field for this BOM item """ @@ -982,7 +1006,6 @@ class BomItem(models.Model): help_text='Select parent part', limit_choices_to={ 'assembly': True, - 'active': True, }) # A link to the child item (sub-part) @@ -991,7 +1014,6 @@ class BomItem(models.Model): help_text='Select part to be used in BOM', limit_choices_to={ 'component': True, - 'active': True }) # Quantity required @@ -1001,8 +1023,10 @@ class BomItem(models.Model): help_text='Estimated build wastage quantity (absolute or percentage)' ) + reference = models.CharField(max_length=500, blank=True, help_text='BOM item reference') + # Note attached to this BOM line item - note = models.CharField(max_length=100, blank=True, help_text='BOM item notes') + note = models.CharField(max_length=500, blank=True, help_text='BOM item notes') def clean(self): """ Check validity of the BomItem model. diff --git a/InvenTree/part/serializers.py b/InvenTree/part/serializers.py index eaea7ecebc..47b34b292f 100644 --- a/InvenTree/part/serializers.py +++ b/InvenTree/part/serializers.py @@ -53,6 +53,7 @@ class PartBriefSerializer(InvenTreeModelSerializer): 'total_stock', 'available_stock', 'image_url', + 'active', ] @@ -166,6 +167,7 @@ class BomItemSerializer(InvenTreeModelSerializer): 'sub_part', 'sub_part_detail', 'quantity', + 'reference', 'price_range', 'overage', 'note', diff --git a/InvenTree/part/templates/part/bom.html b/InvenTree/part/templates/part/bom.html index dee1b0f140..3d61e24e2a 100644 --- a/InvenTree/part/templates/part/bom.html +++ b/InvenTree/part/templates/part/bom.html @@ -31,25 +31,28 @@ {% endif %} -
+
{% if editing_enabled %} -
- - -
- {% else %} - @@ -59,11 +62,6 @@ {% endblock %} -{% block js_load %} -{{ block.super }} - -{% endblock %} - {% block js_ready %} {{ block.super }} @@ -76,6 +74,12 @@ sub_part_detail: true, }); + linkButtonsToSelection($("#bom-table"), + [ + "#bom-item-delete", + ] + ); + {% if editing_enabled %} $("#editing-finished").click(function() { location.href = "{% url 'part-bom' part.id %}"; @@ -112,15 +116,11 @@ }); $("#edit-bom").click(function () { - location.href = "{% url 'part-bom' part.id %}?edit=True"; + location.href = "{% url 'part-bom' part.id %}?edit=1"; }); - $("#export-bom").click(function () { - downloadBom({ - modal: '#modal-form', - url: "{% url 'bom-export' part.id %}" - }); - + $(".download-bom").click(function () { + location.href = "{% url 'bom-export' part.id %}?format=" + $(this).attr('format'); }); {% endif %} diff --git a/InvenTree/part/templates/part/bom_upload/select_fields.html b/InvenTree/part/templates/part/bom_upload/select_fields.html new file mode 100644 index 0000000000..50e55f40da --- /dev/null +++ b/InvenTree/part/templates/part/bom_upload/select_fields.html @@ -0,0 +1,90 @@ +{% extends "part/part_base.html" %} +{% load static %} +{% load inventree_extras %} + +{% block details %} +{% include "part/tabs.html" with tab='bom' %} +

Upload Bill of Materials

+ +

Step 2 - Select Fields

+
+ +{% if missing_columns and missing_columns|length > 0 %} + +{% endif %} + +
+ + {% csrf_token %} + {% load crispy_forms_tags %} + + {% crispy form %} + + + +
Pricing
Order Multiple{{ part.multiple }}
Base Price (Flat Fee){{ part.base_cost }}
+ + + + + {% for col in bom_columns %} + + {% endfor %} + + + + + + + {% for col in bom_columns %} + + {% endfor %} + + {% for row in bom_rows %} + + + + {% for item in row.data %} + + {% endfor %} + + {% endfor %} + +
Row +
+ + {{ col.name }} + +
+
+ + {% if col.duplicate %} +

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 new file mode 100644 index 0000000000..7cc9cd8364 --- /dev/null +++ b/InvenTree/part/templates/part/bom_upload/select_parts.html @@ -0,0 +1,108 @@ +{% extends "part/part_base.html" %} +{% load static %} +{% load inventree_extras %} + +{% block details %} +{% include "part/tabs.html" with tab="bom" %} +

Upload Bill of Materials

+ +

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 %} + +
Row + + + {% if col.guess %} + {{ col.guess }} + {% else %} + {{ col.name }} + {% endif %} +
+ + + {% add row.index 1 %} + {% 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 == 'Notes' %} + + {% 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 new file mode 100644 index 0000000000..37c5801b5c --- /dev/null +++ b/InvenTree/part/templates/part/bom_upload/upload_file.html @@ -0,0 +1,29 @@ +{% extends "part/part_base.html" %} +{% load static %} +{% load inventree_extras %} + +{% block details %} + +{% include "part/tabs.html" with tab='bom' %} + +

Upload Bill of Materials

+
+ +

Step 1 - Select BOM File

+ +
+

The BOM file must contain the required named columns as provided in the BOM Upload Template

+
+ +
+ + {% csrf_token %} + {% load crispy_forms_tags %} + + + + {% crispy form %} + +
+ +{% endblock %} \ No newline at end of file diff --git a/InvenTree/part/templates/part/part_base.html b/InvenTree/part/templates/part/part_base.html index 7339b5afb7..b650f5b33b 100644 --- a/InvenTree/part/templates/part/part_base.html +++ b/InvenTree/part/templates/part/part_base.html @@ -99,14 +99,16 @@
- + + {% if not part.is_template %} {% if part.allocation_count > 0 %} @@ -119,14 +121,12 @@ {% endif %} - - - - - {% if part.assembly %} + {% endif %} + {% if not part.is_template %} + {% if part.assembly %} @@ -139,6 +139,7 @@ {% endif %} + {% endif %} {% endif %}
-

Stock Status

+
+

Available Stock

{{ part.net_stock }} {{ part.units }}

In Stock {{ part.total_stock }}
Allocated{{ part.on_order }}
Total Available{{ part.net_stock }}
-

Build Status

+ Build Status
{{ part.quantity_being_built }}
diff --git a/InvenTree/part/templates/part/used_in.html b/InvenTree/part/templates/part/used_in.html index fb7e76e7e9..665d7ed58f 100644 --- a/InvenTree/part/templates/part/used_in.html +++ b/InvenTree/part/templates/part/used_in.html @@ -35,15 +35,24 @@ { field: 'part_detail', title: 'Part', + sortable: true, formatter: function(value, row, index, field) { - return imageHoverIcon(row.part_detail.image_url) + renderLink(value.full_name, value.url + 'bom/'); + var html = imageHoverIcon(row.part_detail.image_url) + renderLink(value.full_name, value.url + 'bom/'); + + if (!row.part_detail.active) { + html += "INACTIVE"; + } + + return html; } }, { field: 'part_detail.description', title: 'Description', + sortable: true, }, { + sortable: true, field: 'quantity', title: 'Uses', } diff --git a/InvenTree/part/templates/part/variants.html b/InvenTree/part/templates/part/variants.html index d2114a050c..fd55029ba5 100644 --- a/InvenTree/part/templates/part/variants.html +++ b/InvenTree/part/templates/part/variants.html @@ -24,9 +24,9 @@ - - - + + + @@ -35,6 +35,9 @@ diff --git a/InvenTree/part/templatetags/inventree_extras.py b/InvenTree/part/templatetags/inventree_extras.py index b17c5bf9e8..c51d07fdd5 100644 --- a/InvenTree/part/templatetags/inventree_extras.py +++ b/InvenTree/part/templatetags/inventree_extras.py @@ -8,12 +8,24 @@ from InvenTree import version register = template.Library() +@register.simple_tag() +def inrange(n, *args, **kwargs): + """ Return range(n) for iterating through a numeric quantity """ + return range(n) + + @register.simple_tag() def multiply(x, y, *args, **kwargs): """ Multiply two numbers together """ return x * y +@register.simple_tag() +def add(x, y, *args, **kwargs): + """ Add two numbers together """ + return x + y + + @register.simple_tag() def part_allocation_count(build, part, *args, **kwargs): """ Return the total number of allocated to """ diff --git a/InvenTree/part/urls.py b/InvenTree/part/urls.py index 7dc53372cd..e39375f032 100644 --- a/InvenTree/part/urls.py +++ b/InvenTree/part/urls.py @@ -27,6 +27,8 @@ part_detail_urls = [ url(r'^make-variant/', views.MakePartVariant.as_view(), name='make-part-variant'), url(r'^pricing/', views.PartPricing.as_view(), name='part-pricing'), + url(r'^bom-upload/?', views.BomUpload.as_view(), name='upload-bom'), + 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'), @@ -73,6 +75,9 @@ part_urls = [ # Create a new BOM item url(r'^bom/new/?', views.BomItemCreate.as_view(), name='bom-item-create'), + # Download a BOM upload template + url(r'^bom_template/?', views.BomUploadTemplate.as_view(), name='bom-upload-template'), + # Individual part url(r'^(?P\d+)/', include(part_detail_urls)), diff --git a/InvenTree/part/views.py b/InvenTree/part/views.py index 83989b1dde..fae82bfc72 100644 --- a/InvenTree/part/views.py +++ b/InvenTree/part/views.py @@ -5,13 +5,17 @@ Django views for interacting with Part app # -*- coding: utf-8 -*- from __future__ import unicode_literals +from django.core.exceptions import ValidationError 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_lazy -from django.views.generic import DetailView, ListView +from django.urls import reverse, reverse_lazy +from django.views.generic import DetailView, ListView, FormView from django.forms.models import model_to_dict from django.forms import HiddenInput, CheckboxInput +from fuzzywuzzy import fuzz + from .models import PartCategory, Part, PartAttachment from .models import BomItem from .models import match_part_names @@ -19,6 +23,7 @@ from .models import match_part_names from company.models import SupplierPart from . import forms as part_forms +from .bom import MakeBomTemplate, BomUploadManager from InvenTree.views import AjaxView, AjaxCreateView, AjaxUpdateView, AjaxDeleteView from InvenTree.views import QRCodeView @@ -489,6 +494,11 @@ class PartCreate(AjaxCreateView): initials['keywords'] = category.default_keywords except PartCategory.DoesNotExist: pass + + # Allow initial data to be passed through as arguments + for label in ['name', 'IPN', 'description', 'revision', 'keywords']: + if label in self.request.GET: + initials[label] = self.request.GET.get(label) return initials @@ -508,14 +518,15 @@ class PartDetail(DetailView): - If '?editing=True', set 'editing_enabled' context variable """ context = super(PartDetail, self).get_context_data(**kwargs) + + part = self.get_object() if str2bool(self.request.GET.get('edit', '')): - context['editing_enabled'] = 1 + # Allow BOM editing if the part is active + context['editing_enabled'] = 1 if part.active else 0 else: context['editing_enabled'] = 0 - part = self.get_object() - context['starred'] = part.isStarredBy(self.request.user) context['disabled'] = not part.active @@ -616,36 +627,549 @@ class BomValidate(AjaxUpdateView): return self.renderJsonResponse(request, form, data, context=self.get_context()) -class BomExport(AjaxView): +class BomUpload(FormView): + """ View for uploading a BOM file, and handling BOM data importing. - model = Part - ajax_form_title = 'Export BOM' - ajax_template_name = 'part/bom_export.html' - form_class = part_forms.BomExportForm + The BOM upload process is as follows: - def get_object(self): - return get_object_or_404(Part, pk=self.kwargs['pk']) + 1. (Client) Select and upload BOM file + 2. (Server) Verify that supplied file is a file compatible with tablib library + 3. (Server) Introspect data file, try to find sensible columns / values / etc + 4. (Server) Send suggestions back to the client + 5. (Client) Makes choices based on suggestions: + - Accept automatic matching to parts found in database + - Accept suggestions for 'partial' or 'fuzzy' matches + - Create new parts in case of parts not being available + 6. (Client) Sends updated dataset back to server + 7. (Server) Check POST data for validity, sanity checking, etc. + 8. (Server) Respond to POST request + - If data are valid, proceed to 9. + - If data not valid, return to 4. + 9. (Server) Send confirmation form to user + - Display the actions which will occur + - Provide final "CONFIRM" button + 10. (Client) Confirm final changes + 11. (Server) Apply changes to database, update BOM items. + + 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 = [] + + def get_success_url(self): + part = self.get_object() + return reverse('upload-bom', kwargs={'pk': part.id}) + + def get_form_class(self): + + form_step = self.request.POST.get('form_step', None) + + if form_step == 'select_fields': + return part_forms.BomUploadSelectFields + else: + # Default form is the starting point + return part_forms.BomUploadSelectFile + + def get_context_data(self, *args, **kwargs): + + ctx = super().get_context_data(*args, **kwargs) + + # Give each row item access to the column it is in + # This provides for much simpler template rendering + + rows = [] + for row in self.bom_rows: + row_data = row['data'] + + data = [] + + for idx, item in enumerate(row_data): + + data.append({ + 'cell': item, + 'idx': idx, + 'column': self.bom_columns[idx] + }) + + rows.append({ + 'index': row.get('index', -1), + 'data': data, + 'part_options': row.get('part_options', self.allowed_parts), + + # 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', ''), + }) + + 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): + """ Return a queryset of parts which are allowed to be added to this BOM. + """ + + return self.part.get_allowed_bom_items() def get(self, request, *args, **kwargs): - form = self.form_class() + """ Perform the initial 'GET' request. - return self.renderJsonResponse(request, form) + 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.errors['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.errors[k] = v + + if bom_file_valid: + # BOM file is valid? Proceed to the next step! + form = part_forms.BomUploadSelectFields + 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): + """ 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. + """ + + q_idx = self.getColumnIndex('Quantity') + p_idx = self.getColumnIndex('Part') + d_idx = self.getColumnIndex('Description') + r_idx = self.getColumnIndex('Reference') + n_idx = self.getColumnIndex('Notes') + + for row in self.bom_rows: + + quantity = 0 + part = None + + if q_idx >= 0: + q_val = row['data'][q_idx] + + try: + quantity = int(q_val) + except ValueError: + pass + + if p_idx >= 0: + part_name = row['data'][p_idx] + + row['part_name'] = part_name + + # Fuzzy match the values and see what happends + matches = [] + + for part in self.allowed_parts: + ratio = fuzz.partial_ratio(part.name + part.description, part_name) + matches.append({'part': part, 'match': ratio}) + + if len(matches) > 0: + matches = sorted(matches, key=lambda item: item['match'], reverse=True) + + if d_idx >= 0: + row['description'] = row['data'][d_idx] + + if r_idx >= 0: + row['reference'] = row['data'][r_idx] + + if n_idx >= 0: + row['notes'] = row['data'][n_idx] + + row['quantity'] = quantity + row['part_options'] = [m['part'] for m in matches] + + 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 = [] + + for col in BomUploadManager.REQUIRED_HEADERS: + if col not in self.column_selections.values(): + 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 + + form = part_forms.BomUploadSelectFields + + if valid: + # Try to extract meaningful data + self.preFillSelections() + form = None + 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=form)) + + 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 = 1 + + try: + q = int(value) + if q <= 0: + row['errors']['quantity'] = _('Quantity must be greater than zero') + except ValueError: + 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 + + # 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? + if row.get('part', None) is None: + row['errors']['part'] = _('Select a part') + + # 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 + ) + + item.save() + + # 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): - """ - User has now submitted the BOM export data + """ Perform the various 'POST' requests required. """ - # part = self.get_object() + self.request = request - return super(AjaxView, self).post(request, *args, **kwargs) + 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()) - def get_data(self): - return { - # 'form_valid': True, - # 'redirect': '/' - # 'redirect': reverse('bom-download', kwargs={'pk': self.request.GET.get('pk')}) - } + # 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)) + + +class BomUploadTemplate(AjaxView): + """ + Provide a BOM upload template file for download. + - Generates a template file in the provided format e.g. ?format=csv + """ + + def get(self, request, *args, **kwargs): + + export_format = request.GET.get('format', 'csv') + + return MakeBomTemplate(export_format) class BomDownload(AjaxView): @@ -654,8 +1178,6 @@ class BomDownload(AjaxView): - File format should be passed as a query param e.g. ?format=csv """ - # TODO - This should no longer extend an AjaxView! - model = Part def get(self, request, *args, **kwargs): @@ -908,16 +1430,24 @@ class BomItemCreate(AjaxCreateView): try: part = Part.objects.get(id=part_id) + # Only allow active parts to be selected + query = form.fields['part'].queryset.filter(active=True) + form.fields['part'].queryset = query + # Don't allow selection of sub_part objects which are already added to the Bom! query = form.fields['sub_part'].queryset # Don't allow a part to be added to its own BOM query = query.exclude(id=part.id) + query = query.filter(active=True) # Eliminate any options that are already in the BOM! query = query.exclude(id__in=[item.id for item in part.required_parts()]) form.fields['sub_part'].queryset = query + + form.fields['part'].widget = HiddenInput() + except Part.DoesNotExist: pass diff --git a/InvenTree/static/css/inventree.css b/InvenTree/static/css/inventree.css index 28c304485b..3f64633062 100644 --- a/InvenTree/static/css/inventree.css +++ b/InvenTree/static/css/inventree.css @@ -77,6 +77,10 @@ width: 100%; } +.bomselect { + max-width: 250px; +} + /* Part image icons with full-display on mouse hover */ .hover-img-thumb { diff --git a/InvenTree/static/script/inventree/bom.js b/InvenTree/static/script/inventree/bom.js index 28b6dc4b73..450fdeb503 100644 --- a/InvenTree/static/script/inventree/bom.js +++ b/InvenTree/static/script/inventree/bom.js @@ -12,47 +12,79 @@ function reloadBomTable(table, options) { } -function downloadBom(options = {}) { +function removeRowFromBomWizard(e) { + /* Remove a row from BOM upload wizard + */ - var modal = options.modal || "#modal-form"; - - var content = ` - Select file format
-
- -
- `; + e = e || window.event; - openModal({ - modal: modal, - title: "Export Bill of Materials", - submit_text: "Download", - close_text: "Cancel", + var src = e.target || e.srcElement; + + var table = $(src).closest('table'); + + // Which column was clicked? + var row = $(src).closest('tr'); + + row.remove(); + + var rowNum = 1; + var colNum = 0; + + table.find('tr').each(function() { + + colNum++; + + if (colNum >= 3) { + var cell = $(this).find('td:eq(1)'); + cell.text(rowNum++); + console.log("Row: " + rowNum); + } }); +} - modalSetContent(modal, content); - modalEnable(modal, true); +function removeColFromBomWizard(e) { + /* Remove a column from BOM upload wizard + */ - $(modal).on('click', '#modal-form-submit', function() { - $(modal).modal('hide'); + e = e || window.event; - var format = $(modal).find('#bom-format :selected').val(); + var src = e.target || e.srcElement; - if (options.url) { - var url = options.url + "?format=" + format; + // Which column was clicked? + var col = $(src).closest('th').index(); - location.href = url; + var table = $(src).closest('table'); + + table.find('tr').each(function() { + this.removeChild(this.cells[col]); + }); +} + + +function newPartFromBomWizard(e) { + /* Create a new part directly from the BOM wizard. + */ + + e = e || window.event; + + var src = e.target || e.srcElement; + + var row = $(src).closest('tr'); + + launchModalForm('/part/new/', { + data: { + 'description': row.attr('part-description'), + 'name': row.attr('part-name'), + }, + success: function(response) { + /* A new part has been created! Push it as an option. + */ + + var select = row.attr('part-select'); + + var option = new Option(response.text, response.pk, true, true); + $(select).append(option).trigger('change'); } }); } @@ -78,13 +110,16 @@ function loadBomTable(table, options) { title: 'ID', visible: false, }, - { + ]; + + if (options.editable) { + cols.push({ checkbox: true, title: 'Select', searchable: false, sortable: false, - }, - ]; + }); + } // Part column cols.push( @@ -106,33 +141,39 @@ function loadBomTable(table, options) { } ); + // Part reference + cols.push({ + field: 'reference', + title: 'Reference', + searchable: true, + sortable: true, + }); + // Part quantity - cols.push( - { - field: 'quantity', - title: 'Required', - searchable: false, - sortable: true, - formatter: function(value, row, index, field) { - var text = value; + cols.push({ + field: 'quantity', + title: 'Quantity', + searchable: false, + sortable: true, + formatter: function(value, row, index, field) { + var text = value; - if (row.overage) { - text += " (+" + row.overage + ") "; - } + if (row.overage) { + text += " (+" + row.overage + ") "; + } - return text; - }, - footerFormatter: function(data) { - var quantity = 0; + return text; + }, + footerFormatter: function(data) { + var quantity = 0; - data.forEach(function(item) { - quantity += item.quantity; - }); + data.forEach(function(item) { + quantity += item.quantity; + }); - return quantity; - }, - } - ); + return quantity; + }, + }); if (!options.editable) { cols.push( @@ -192,7 +233,7 @@ function loadBomTable(table, options) { var bEdit = ""; var bDelt = ""; - return "
" + bEdit + bDelt + "
"; + return "
" + bEdit + bDelt + "
"; } }); } diff --git a/InvenTree/static/script/inventree/stock.js b/InvenTree/static/script/inventree/stock.js index baa9c2ee87..268b8ed42f 100644 --- a/InvenTree/static/script/inventree/stock.js +++ b/InvenTree/static/script/inventree/stock.js @@ -129,6 +129,11 @@ function loadStockTable(table, options) { } name += row.part__name; + + if (row.part__revision) { + name += " | "; + name += row.part__revision; + } return imageHoverIcon(row.part__image) + renderLink(name, '/part/' + row.part + '/stock/'); } diff --git a/InvenTree/stock/api.py b/InvenTree/stock/api.py index 8240c6b837..b691041bde 100644 --- a/InvenTree/stock/api.py +++ b/InvenTree/stock/api.py @@ -57,7 +57,7 @@ class StockDetail(generics.RetrieveUpdateDestroyAPIView): queryset = StockItem.objects.all() serializer_class = StockItemSerializer - permission_classes = (permissions.IsAuthenticatedOrReadOnly,) + permission_classes = (permissions.IsAuthenticated,) class StockFilter(FilterSet): @@ -83,7 +83,7 @@ class StockStocktake(APIView): """ permission_classes = [ - permissions.IsAuthenticatedOrReadOnly, + permissions.IsAuthenticated, ] def post(self, request, *args, **kwargs): @@ -153,7 +153,7 @@ class StockMove(APIView): """ API endpoint for performing stock movements """ permission_classes = [ - permissions.IsAuthenticatedOrReadOnly, + permissions.IsAuthenticated, ] def post(self, request, *args, **kwargs): @@ -227,7 +227,7 @@ class StockLocationList(generics.ListCreateAPIView): serializer_class = LocationSerializer permission_classes = [ - permissions.IsAuthenticatedOrReadOnly, + permissions.IsAuthenticated, ] filter_backends = [ @@ -261,8 +261,12 @@ class StockList(generics.ListCreateAPIView): def get_serializer(self, *args, **kwargs): - part_detail = str2bool(self.request.GET.get('part_detail', None)) - location_detail = str2bool(self.request.GET.get('location_detail', None)) + try: + part_detail = str2bool(self.request.GET.get('part_detail', None)) + location_detail = str2bool(self.request.GET.get('location_detail', None)) + except AttributeError: + part_detail = None + location_detail = None kwargs['part_detail'] = part_detail kwargs['location_detail'] = location_detail @@ -291,6 +295,7 @@ class StockList(generics.ListCreateAPIView): 'part', 'part__IPN', 'part__name', + 'part__revision', 'part__description', 'part__image', 'part__category', @@ -386,7 +391,7 @@ class StockList(generics.ListCreateAPIView): serializer_class = StockItemSerializer permission_classes = [ - permissions.IsAuthenticatedOrReadOnly, + permissions.IsAuthenticated, ] filter_backends = [ @@ -408,7 +413,7 @@ class StockStocktakeEndpoint(generics.UpdateAPIView): queryset = StockItem.objects.all() serializer_class = StockQuantitySerializer - permission_classes = (permissions.IsAuthenticatedOrReadOnly,) + permission_classes = (permissions.IsAuthenticated,) def update(self, request, *args, **kwargs): object = self.get_object() @@ -430,7 +435,7 @@ class StockTrackingList(generics.ListCreateAPIView): queryset = StockItemTracking.objects.all() serializer_class = StockTrackingSerializer - permission_classes = [permissions.IsAuthenticatedOrReadOnly] + permission_classes = [permissions.IsAuthenticated] filter_backends = [ DjangoFilterBackend, @@ -465,7 +470,7 @@ class LocationDetail(generics.RetrieveUpdateDestroyAPIView): queryset = StockLocation.objects.all() serializer_class = LocationSerializer - permission_classes = (permissions.IsAuthenticatedOrReadOnly,) + permission_classes = (permissions.IsAuthenticated,) stock_endpoints = [ diff --git a/InvenTree/templates/base.html b/InvenTree/templates/base.html index fee348c7e5..51f396df8b 100644 --- a/InvenTree/templates/base.html +++ b/InvenTree/templates/base.html @@ -99,6 +99,7 @@ InvenTree + diff --git a/InvenTree/users/views.py b/InvenTree/users/views.py index e10fe2c615..a4920b717e 100644 --- a/InvenTree/users/views.py +++ b/InvenTree/users/views.py @@ -12,7 +12,7 @@ class UserDetail(generics.RetrieveAPIView): queryset = User.objects.all() serializer_class = UserSerializer - permission_classes = (permissions.IsAuthenticatedOrReadOnly,) + permission_classes = (permissions.IsAuthenticated,) class UserList(generics.ListAPIView): @@ -20,7 +20,7 @@ class UserList(generics.ListAPIView): queryset = User.objects.all() serializer_class = UserSerializer - permission_classes = (permissions.IsAuthenticatedOrReadOnly,) + permission_classes = (permissions.IsAuthenticated,) class GetAuthToken(ObtainAuthToken): diff --git a/docs/start.rst b/docs/start.rst index 869f7d42b7..eab27e407f 100644 --- a/docs/start.rst +++ b/docs/start.rst @@ -33,7 +33,7 @@ Run ``make superuser`` to create a superuser account, required for initial syste Run Development Server ---------------------- -Run ``python InvenTree/manage.py runserver`` to launch a development server. This will launch the InvenTree web interface at ``127.0.0.1:8000``. For other options refer to the `django docs `_. +Run ``python3 InvenTree/manage.py runserver`` to launch a development server. This will launch the InvenTree web interface at ``127.0.0.1:8000``. For other options refer to the `django docs `_. Database Migrations ------------------- diff --git a/requirements.txt b/requirements.txt index 8d8acfb510..2b82a25383 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ -Django==2.2.2 # Django package -psycopg2>=2.8.1 # PostgreSQL package +Django==2.2.3 # Django package +# psycopg2>=2.8.1 # PostgreSQL package pillow>=5.0.0 # Image manipulation djangorestframework>=3.6.2 # DRF framework django-cors-headers>=2.5.3 # CORS headers extension for DRF
VariantDescriptionStockVariantDescriptionStock
{% include "hover_image.html" with image=variant.image hover=True %} {{ variant.full_name }} + {% if not variant.active %} + INACTIVE + {% endif %} {{ variant.description }} {{ variant.total_stock }}