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
@@ -30,17 +37,18 @@ InvenTree | {{ company.name }} - Parts
+
Supplier Part Details
- 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 }} |
{% if part.URL %}
URL | {{ part.URL }} |
{% endif %}
@@ -58,10 +66,8 @@ InvenTree | {{ company.name }} - Parts
+
Pricing Information
-
- Pricing |
-
Order Multiple | {{ part.multiple }} |
{% if part.base_cost > 0 %}
Base Price (Flat Fee) | {{ part.base_cost }} |
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 %}
-