Merge remote-tracking branch 'inventree/master'

This commit is contained in:
Oliver Walters 2020-04-13 20:43:00 +10:00
commit 2e1c4e9792
37 changed files with 942 additions and 311 deletions

1
.gitignore vendored
View File

@ -32,6 +32,7 @@ var/
local_settings.py
*.sqlite3
*.backup
*.old
# Sphinx files
docs/_build

View File

@ -0,0 +1,188 @@
function loadCompanyTable(table, url, options={}) {
/*
* Load company listing data into specified table.
*
* Args:
* - table: Table element on the page
* - url: Base URL for the API query
* - options: table options.
*/
// Query parameters
var params = options.params || {};
var filters = loadTableFilters("company");
for (var key in params) {
filters[key] = params[key];
}
setupFilterList("company", $(table));
$(table).inventreeTable({
url: url,
method: 'get',
queryParams: filters,
groupBy: false,
formatNoMatches: function() { return "No company information found"; },
columns: [
{
field: 'pk',
title: 'ID',
visible: false,
},
{
field: 'name',
title: 'Company',
sortable: true,
formatter: function(value, row, index, field) {
var html = imageHoverIcon(row.image) + renderLink(value, row.url);
if (row.is_customer) {
html += `<span title='Customer' class='fas fa-user-tie label-right'></span>`;
}
if (row.is_manufacturer) {
html += `<span title='Manufacturer' class='fas fa-industry label-right'></span>`;
}
if (row.is_supplier) {
html += `<span title='Supplier' class='fas fa-building label-right'></span>`;
}
return html;
}
},
{
field: 'description',
title: 'Description',
sortable: true,
},
{
field: 'website',
title: 'Website',
formatter: function(value, row, index, field) {
if (value) {
return renderLink(value, value);
}
return '';
}
},
],
});
}
function loadSupplierPartTable(table, url, options) {
/*
* Load supplier part table
*
*/
// Query parameters
var params = options.params || {};
// Load 'user' filters
var filters = loadTableFilters("supplier-part");
for (var key in params) {
filters[key] = params[key];
}
setupFilterList("supplier-part", $(table));
$(table).inventreeTable({
url: url,
method: 'get',
queryParams: filters,
groupBy: false,
formatNoMatches: function() { return "No supplier parts found"; },
columns: [
{
checkbox: true,
},
{
sortable: true,
field: 'part_detail.full_name',
title: 'Part',
formatter: function(value, row, index, field) {
var url = `/part/${row.part}/`;
var html = imageHoverIcon(row.part_detail.thumbnail) + renderLink(value, url);
if (row.part_detail.is_template) {
html += `<span class='fas fa-clone label-right' title='Template part'></span>`;
}
if (row.part_detail.assembly) {
html += `<span class='fas fa-tools label-right' title='Assembled part'></span>`;
}
if (!row.part_detail.active) {
html += `<span class='label label-warning label-right'>INACTIVE</span>`;
}
return html;
}
},
{
sortable: true,
field: 'supplier',
title: "Supplier",
formatter: function(value, row, index, field) {
if (value) {
var name = row.supplier_detail.name;
var url = `/company/${value}/`;
var html = imageHoverIcon(row.supplier_detail.image) + renderLink(name, url);
return html;
} else {
return "-";
}
},
},
{
sortable: true,
field: 'SKU',
title: "Supplier Part",
formatter: function(value, row, index, field) {
return renderLink(value, row.url);
}
},
{
sortable: true,
field: 'manufacturer',
title: 'Manufacturer',
formatter: function(value, row, index, field) {
if (value) {
var name = row.manufacturer_detail.name;
var url = `/company/${value}/`;
var html = imageHoverIcon(row.manufacturer_detail.image) + renderLink(name, url);
return html;
} else {
return "-";
}
}
},
{
sortable: true,
field: 'MPN',
title: 'MPN',
},
{
field: 'link',
title: 'Link',
formatter: function(value, row, index, field) {
if (value) {
return renderLink(value, value);
} else {
return '';
}
}
},
],
});
}

View File

@ -17,6 +17,7 @@ function defaultFilters() {
stock: "cascade=1",
build: "",
parts: "cascade=1",
company: "",
};
}
@ -72,8 +73,6 @@ function saveTableFilters(tableKey, filters) {
var filterstring = strings.join('&');
console.log(`Saving filters for table '${tableKey}' - ${filterstring}`);
inventreeSave(lookup, filterstring);
}
@ -255,12 +254,8 @@ function setupFilterList(tableKey, table, target) {
var clear = `filter-clear-${tableKey}`;
var make = `filter-make-${tableKey}`;
console.log(`Generating filter list: ${tableKey}`);
var filters = loadTableFilters(tableKey);
console.log("Filters: " + filters.count);
var element = $(target);
// One blank slate, please

View File

@ -114,7 +114,7 @@ function loadPurchaseOrderTable(table, options) {
setupFilterList("order", table);
table.inventreeTable({
$(table).inventreeTable({
url: options.url,
queryParams: filters,
groupBy: false,

View File

@ -228,8 +228,16 @@ function loadStockTable(table, options) {
name += " | ";
name += row.part__revision;
}
var url = '';
if (row.supplier_part) {
url = `/supplier-part/${row.supplier_part}/`;
} else {
url = `/part/${row.part}/`;
}
return imageHoverIcon(row.part__thumbnail) + renderLink(name, '/part/' + row.part + '/stock/');
return imageHoverIcon(row.part__thumbnail) + renderLink(name, url);
}
},
{

View File

@ -15,8 +15,6 @@ class StatusCode:
Render the value as a label.
"""
print("Rendering:", key, cls.options)
# If the key cannot be found, pass it back
if key not in cls.options.keys():
return key

View File

@ -115,9 +115,12 @@ class AjaxMixin(object):
# (this can be overridden by a child class)
ajax_template_name = 'modal_form.html'
ajax_form_action = ''
ajax_form_title = ''
def get_form_title(self):
""" Default implementation - return the ajax_form_title variable """
return self.ajax_form_title
def get_param(self, name, method='GET'):
""" Get a request query parameter value from URL e.g. ?part=3
@ -169,7 +172,7 @@ class AjaxMixin(object):
else:
context['form'] = None
data['title'] = self.ajax_form_title
data['title'] = self.get_form_title()
data['html_form'] = render_to_string(
self.ajax_template_name,

View File

@ -10,6 +10,7 @@ from rest_framework import filters
from rest_framework import generics, permissions
from django.conf.urls import url, include
from django.db.models import Q
from InvenTree.helpers import str2bool
@ -43,9 +44,10 @@ class CompanyList(generics.ListCreateAPIView):
]
filter_fields = [
'name',
'is_customer',
'is_manufacturer',
'is_supplier',
'name',
]
search_fields = [
@ -80,22 +82,40 @@ class SupplierPartList(generics.ListCreateAPIView):
queryset = SupplierPart.objects.all().prefetch_related(
'part',
'part__category',
'part__stock_items',
'part__bom_items',
'part__builds',
'supplier',
'pricebreaks')
'manufacturer'
)
def get_queryset(self):
queryset = super().get_queryset()
# Filter by EITHER manufacturer or supplier
company = self.request.query_params.get('company', None)
if company is not None:
queryset = queryset.filter(Q(manufacturer=company) | Q(supplier=company))
return queryset
def get_serializer(self, *args, **kwargs):
# Do we wish to include extra detail?
try:
part_detail = str2bool(self.request.GET.get('part_detail', None))
kwargs['part_detail'] = str2bool(self.request.query_params.get('part_detail', None))
except AttributeError:
part_detail = None
pass
try:
kwargs['supplier_detail'] = str2bool(self.request.query_params.get('supplier_detail', None))
except AttributeError:
pass
kwargs['part_detail'] = part_detail
try:
kwargs['manufacturer_detail'] = str2bool(self.request.query_params.get('manufacturer_detail', None))
except AttributeError:
pass
kwargs['context'] = self.get_serializer_context()
return self.serializer_class(*args, **kwargs)
@ -114,13 +134,14 @@ class SupplierPartList(generics.ListCreateAPIView):
filter_fields = [
'part',
'supplier'
'supplier',
'manufacturer',
]
search_fields = [
'SKU',
'supplier__name',
'manufacturer',
'manufacturer__name',
'description',
'MPN',
]
@ -170,15 +191,15 @@ supplier_part_api_urls = [
url(r'^(?P<pk>\d+)/?', SupplierPartDetail.as_view(), name='api-supplier-part-detail'),
# Catch anything else
url(r'^.*$', SupplierPartList.as_view(), name='api-part-supplier-list'),
url(r'^.*$', SupplierPartList.as_view(), name='api-supplier-part-list'),
]
company_api_urls = [
url(r'^part/?', include(supplier_part_api_urls)),
url(r'^part/', include(supplier_part_api_urls)),
url(r'^price-break/?', SupplierPriceBreakList.as_view(), name='api-part-supplier-price'),
url(r'^price-break/', SupplierPriceBreakList.as_view(), name='api-part-supplier-price'),
url(r'^(?P<pk>\d+)/?', CompanyDetail.as_view(), name='api-company-detail'),

View File

@ -26,8 +26,9 @@ class EditCompanyForm(HelperForm):
'phone',
'email',
'contact',
'is_customer',
'is_supplier',
'is_manufacturer',
'is_customer',
]
@ -58,7 +59,6 @@ class EditSupplierPartForm(HelperForm):
'base_cost',
'multiple',
'packaging',
# 'lead_time'
]

View File

@ -0,0 +1,18 @@
# Generated by Django 2.2.10 on 2020-04-12 23:21
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('company', '0014_auto_20200407_0116'),
]
operations = [
migrations.AddField(
model_name='company',
name='is_manufacturer',
field=models.BooleanField(default=False, help_text='Does this company manufacture parts?'),
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 2.2.10 on 2020-04-12 23:30
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('company', '0015_company_is_manufacturer'),
]
operations = [
migrations.AlterField(
model_name='company',
name='is_manufacturer',
field=models.BooleanField(default=False, help_text='Does this company manufacture parts?'),
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 2.2.10 on 2020-04-13 03:20
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('company', '0016_auto_20200412_2330'),
]
operations = [
migrations.RenameField(
model_name='supplierpart',
old_name='manufacturer',
new_name='manufacturer_name',
),
]

View File

@ -0,0 +1,19 @@
# Generated by Django 2.2.10 on 2020-04-13 03:29
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('company', '0017_auto_20200413_0320'),
]
operations = [
migrations.AddField(
model_name='supplierpart',
name='manufacturer',
field=models.ForeignKey(blank=True, help_text='Select manufacturer', limit_choices_to={'is_manufacturer': True}, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='manufactured_parts', to='company.Company'),
),
]

View File

@ -0,0 +1,275 @@
# Generated by Django 2.2.10 on 2020-04-13 06:42
import os
from rapidfuzz import fuzz
from django.db import migrations
from company.models import Company, SupplierPart
from django.db.utils import OperationalError, ProgrammingError
def clear():
os.system('cls' if os.name == 'nt' else 'clear')
def reverse_association(apps, schema_editor):
"""
This is the 'reverse' operation of the manufacturer reversal.
This operation is easier:
For each SupplierPart object, copy the name of the 'manufacturer' field
into the 'manufacturer_name' field.
"""
# Exit if there are no SupplierPart objects
# This crucial otherwise the unit test suite fails!
if SupplierPart.objects.count() == 0:
print("No SupplierPart objects - skipping")
return
print("Reversing migration for manufacturer association")
try:
for part in SupplierPart.objects.all():
if part.manufacturer is not None:
part.manufacturer_name = part.manufacturer.name
part.save()
except (OperationalError, ProgrammingError):
# An exception might be called if the database is empty
pass
def associate_manufacturers(apps, schema_editor):
"""
This migration is the "middle step" in migration of the "manufacturer" field for the SupplierPart model.
Previously the "manufacturer" field was a simple text field with the manufacturer name.
This is quite insufficient.
The new "manufacturer" field is a link to Company object which has the "is_manufacturer" parameter set to True
This migration requires user interaction to create new "manufacturer" Company objects,
based on the text value in the "manufacturer_name" field (which was created in the previous migration).
It uses fuzzy pattern matching to help the user out as much as possible.
"""
# Exit if there are no SupplierPart objects
# This crucial otherwise the unit test suite fails!
if SupplierPart.objects.count() == 0:
print("No SupplierPart objects - skipping")
return
# Link a 'manufacturer_name' to a 'Company'
links = {}
# Map company names to company objects
companies = {}
for company in Company.objects.all():
companies[company.name] = company
# List of parts which will need saving
parts = []
def link_part(part, name):
""" Attempt to link Part to an existing Company """
# Matches a company name directly
if name in companies.keys():
print(" -> '{n}' maps to existing manufacturer".format(n=name))
part.manufacturer = companies[name]
part.save()
return True
# Have we already mapped this
if name in links.keys():
print(" -> Mapped '{n}' -> '{c}'".format(n=name, c=links[name].name))
part.manufacturer = links[name]
part.save()
return True
# Mapping not possible
return False
def create_manufacturer(part, input_name, company_name):
""" Create a new manufacturer """
company = Company(name=company_name, description=company_name, is_manufacturer=True)
company.is_manufacturer = True
# Map both names to the same company
links[input_name] = company
links[company_name] = company
companies[company_name] = company
# Save the company BEFORE we associate the part, otherwise the PK does not exist
company.save()
# Save the manufacturer reference link
part.manufacturer = company
part.save()
print(" -> Created new manufacturer: '{name}'".format(name=company_name))
def find_matches(text, threshold=65):
"""
Attempt to match a 'name' to an existing Company.
A list of potential matches will be returned.
"""
matches = []
for name in companies.keys():
# Case-insensitive matching
ratio = fuzz.partial_ratio(name.lower(), text.lower())
if ratio > threshold:
matches.append({'name': name, 'match': ratio})
if len(matches) > 0:
return [match['name'] for match in sorted(matches, key=lambda item: item['match'], reverse=True)]
else:
return []
def map_part_to_manufacturer(part, idx, total):
name = str(part.manufacturer_name)
# Skip empty names
if not name or len(name) == 0:
return
# Can be linked to an existing manufacturer
if link_part(part, name):
return
# Find a list of potential matches
matches = find_matches(name)
clear()
# Present a list of options
print("----------------------------------")
print("Checking part {idx} of {total}".format(idx=idx+1, total=total))
print("Manufacturer name: '{n}'".format(n=name))
print("----------------------------------")
print("Select an option from the list below:")
print("0) - Create new manufacturer '{n}'".format(n=name))
print("")
for i, m in enumerate(matches[:10]):
print("{i}) - Use manufacturer '{opt}'".format(i=i+1, opt=m))
print("")
print("OR - Type a new custom manufacturer name")
while (1):
response = str(input("> ")).strip()
# Attempt to parse user response as an integer
try:
n = int(response)
# Option 0) is to create a new manufacturer with the current name
if n == 0:
create_manufacturer(part, name, name)
return
# Options 1) -> n) select an existing manufacturer
else:
n = n - 1
if n < len(matches):
# Get the company which matches the selected options
company_name = matches[n]
company = companies[company_name]
# Ensure the company is designated as a manufacturer
company.is_manufacturer = True
company.save()
# Link the company to the part
part.manufacturer = company
part.save()
# Link the name to the company
links[name] = company
links[company_name] = company
print(" -> Linked '{n}' to manufacturer '{m}'".format(n=name, m=company_name))
return
except ValueError:
# User has typed in a custom name!
if not response or len(response) == 0:
# Response cannot be empty!
print("Please select an option")
# Double-check if the typed name corresponds to an existing item
elif response in companies.keys():
link_part(part, companies[response])
return
elif response in links.keys():
link_part(part, links[response])
return
# No match, create a new manufacturer
else:
create_manufacturer(part, name, response)
return
clear()
print("")
clear()
print("---------------------------------------")
print("The SupplierPart model needs to be migrated,")
print("as the new 'manufacturer' field maps to a 'Company' reference.")
print("The existing 'manufacturer_name' field will be used to match")
print("against possible companies.")
print("This process requires user input.")
print("")
print("Note: This process MUST be completed to migrate the database.")
print("---------------------------------------")
print("")
input("Press <ENTER> to continue.")
clear()
part_count = SupplierPart.objects.count()
# Create a unique set of manufacturer names
for idx, part in enumerate(SupplierPart.objects.all()):
if part.manufacturer is not None:
print(" -> Part '{p}' already has a manufacturer associated (skipping)".format(p=part))
continue
map_part_to_manufacturer(part, idx, part_count)
parts.append(part)
print("Done!")
class Migration(migrations.Migration):
dependencies = [
('company', '0018_supplierpart_manufacturer'),
]
operations = [
migrations.RunPython(associate_manufacturers, reverse_code=reverse_association)
]

View File

@ -0,0 +1,19 @@
# Generated by Django 2.2.10 on 2020-04-13 08:39
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('company', '0019_auto_20200413_0642'),
]
operations = [
migrations.AlterField(
model_name='supplierpart',
name='supplier',
field=models.ForeignKey(help_text='Select supplier', limit_choices_to={'is_supplier': True}, on_delete=django.db.models.deletion.CASCADE, related_name='supplied_parts', to='company.Company'),
),
]

View File

@ -0,0 +1,17 @@
# Generated by Django 2.2.10 on 2020-04-13 10:24
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('company', '0020_auto_20200413_0839'),
]
operations = [
migrations.RemoveField(
model_name='supplierpart',
name='manufacturer_name',
),
]

View File

@ -13,7 +13,7 @@ from decimal import Decimal
from django.utils.translation import gettext_lazy as _
from django.core.validators import MinValueValidator
from django.db import models
from django.db.models import Sum
from django.db.models import Sum, Q
from django.apps import apps
from django.urls import reverse
@ -23,6 +23,7 @@ from markdownx.models import MarkdownxField
from stdimage.models import StdImageField
from InvenTree.helpers import getMediaUrl, getBlankImage, getBlankThumbnail
from InvenTree.helpers import normalize
from InvenTree.fields import InvenTreeURLField, RoundingDecimalField
from InvenTree.status_codes import OrderStatus
from common.models import Currency
@ -56,7 +57,12 @@ def rename_company_image(instance, filename):
class Company(models.Model):
""" A Company object represents an external company.
It may be a supplier or a customer (or both).
It may be a supplier or a customer or a manufacturer (or a combination)
- A supplier is a company from which parts can be purchased
- A customer is a company to which parts can be sold
- A manufacturer is a company which manufactures a raw good (they may or may not be a "supplier" also)
Attributes:
name: Brief name of the company
@ -70,6 +76,7 @@ class Company(models.Model):
notes: Extra notes about the company
is_customer: boolean value, is this company a customer
is_supplier: boolean value, is this company a supplier
is_manufacturer: boolean value, is this company a manufacturer
"""
name = models.CharField(max_length=100, blank=False, unique=True,
@ -106,6 +113,8 @@ class Company(models.Model):
is_supplier = models.BooleanField(default=True, help_text=_('Do you purchase items from this company?'))
is_manufacturer = models.BooleanField(default=False, help_text=_('Does this company manufacture parts?'))
def __str__(self):
""" Get string representation of a Company """
return "{n} - {d}".format(n=self.name, d=self.description)
@ -131,26 +140,48 @@ class Company(models.Model):
return getBlankThumbnail()
@property
def part_count(self):
def manufactured_part_count(self):
""" The number of parts manufactured by this company """
return self.manufactured_parts.count()
@property
def has_manufactured_parts(self):
return self.manufactured_part_count > 0
@property
def supplied_part_count(self):
""" The number of parts supplied by this company """
return self.supplied_parts.count()
@property
def has_supplied_parts(self):
""" Return True if this company supplies any parts """
return self.supplied_part_count > 0
@property
def parts(self):
""" Return SupplierPart objects which are supplied or manufactured by this company """
return SupplierPart.objects.filter(Q(supplier=self.id) | Q(manufacturer=self.id))
@property
def part_count(self):
""" The number of parts manufactured (or supplied) by this Company """
return self.parts.count()
@property
def has_parts(self):
""" Return True if this company supplies any parts """
return self.part_count > 0
@property
def stock_items(self):
""" Return a list of all stock items supplied by this company """
""" Return a list of all stock items supplied or manufactured by this company """
stock = apps.get_model('stock', 'StockItem')
return stock.objects.filter(supplier_part__supplier=self.id).all()
return stock.objects.filter(Q(supplier_part__supplier=self.id) | Q(supplier_part__manufacturer=self.id)).all()
@property
def stock_count(self):
""" Return the number of stock items supplied by this company """
stock = apps.get_model('stock', 'StockItem')
return stock.objects.filter(supplier_part__supplier=self.id).count()
""" Return the number of stock items supplied or manufactured by this company """
return self.stock_items.count()
def outstanding_purchase_orders(self):
""" Return purchase orders which are 'outstanding' """
@ -216,7 +247,7 @@ class SupplierPart(models.Model):
part: Link to the master Part
supplier: Company that supplies this SupplierPart object
SKU: Stock keeping unit (supplier part number)
manufacturer: Manufacturer name
manufacturer: Company that manufactures the SupplierPart (leave blank if it is the sample as the Supplier!)
MPN: Manufacture part number
link: Link to external website for this part
description: Descriptive notes field
@ -246,14 +277,21 @@ class SupplierPart(models.Model):
)
supplier = models.ForeignKey(Company, on_delete=models.CASCADE,
related_name='parts',
related_name='supplied_parts',
limit_choices_to={'is_supplier': True},
help_text=_('Select supplier'),
)
SKU = models.CharField(max_length=100, help_text=_('Supplier stock keeping unit'))
manufacturer = models.CharField(max_length=100, blank=True, help_text=_('Manufacturer'))
manufacturer = models.ForeignKey(
Company,
on_delete=models.SET_NULL,
related_name='manufactured_parts',
limit_choices_to={'is_manufacturer': True},
help_text=_('Select manufacturer'),
null=True, blank=True
)
MPN = models.CharField(max_length=100, blank=True, help_text=_('Manufacturer part number'))
@ -281,7 +319,7 @@ class SupplierPart(models.Model):
items = []
if self.manufacturer:
items.append(self.manufacturer)
items.append(self.manufacturer.name)
if self.MPN:
items.append(self.MPN)
@ -337,7 +375,7 @@ class SupplierPart(models.Model):
if pb_found:
cost = pb_cost * quantity
return cost + self.base_cost
return normalize(cost + self.base_cost)
else:
return None

View File

@ -17,12 +17,16 @@ class CompanyBriefSerializer(InvenTreeModelSerializer):
url = serializers.CharField(source='get_absolute_url', read_only=True)
image = serializers.CharField(source='get_thumbnail_url', read_only=True)
class Meta:
model = Company
fields = [
'pk',
'url',
'name'
'name',
'description',
'image',
]
@ -49,9 +53,10 @@ class CompanySerializer(InvenTreeModelSerializer):
'contact',
'link',
'image',
'notes',
'is_customer',
'is_manufacturer',
'is_supplier',
'notes',
'part_count'
]
@ -63,20 +68,28 @@ class SupplierPartSerializer(InvenTreeModelSerializer):
part_detail = PartBriefSerializer(source='part', many=False, read_only=True)
supplier_name = serializers.CharField(source='supplier.name', read_only=True)
supplier_logo = serializers.CharField(source='supplier.get_thumbnail_url', read_only=True)
supplier_detail = CompanyBriefSerializer(source='supplier', many=False, read_only=True)
manufacturer_detail = CompanyBriefSerializer(source='manufacturer', many=False, read_only=True)
pricing = serializers.CharField(source='unit_pricing', read_only=True)
def __init__(self, *args, **kwargs):
part_detail = kwargs.pop('part_detail', False)
supplier_detail = kwargs.pop('supplier_detail', False)
manufacturer_detail = kwargs.pop('manufacturer_detail', False)
super(SupplierPartSerializer, self).__init__(*args, **kwargs)
if part_detail is not True:
self.fields.pop('part_detail')
if supplier_detail is not True:
self.fields.pop('supplier_detail')
if manufacturer_detail is not True:
self.fields.pop('manufacturer_detail')
class Meta:
model = SupplierPart
fields = [
@ -85,10 +98,10 @@ class SupplierPartSerializer(InvenTreeModelSerializer):
'part',
'part_detail',
'supplier',
'supplier_name',
'supplier_logo',
'supplier_detail',
'SKU',
'manufacturer',
'manufacturer_detail',
'description',
'MPN',
'link',

View File

@ -6,8 +6,8 @@ Are you sure you want to delete company '{{ company.name }}'?
<br>
{% if company.part_count > 0 %}
<p>There are {{ company.part_count }} parts sourced from this company.<br>
{% if company.supplied_part_count > 0 %}
<p>There are {{ company.supplied_part_count }} parts sourced from this company.<br>
If this supplier is deleted, these supplier part entries will also be deleted.</p>
<ul class='list-group'>
{% for part in company.parts.all %}

View File

@ -12,15 +12,20 @@
<col width='25'>
<col>
<tr>
<td><span class='fas fa-user-tag'></span></td>
<td>{% trans "Customer" %}</td>
<td>{% include 'yesnolabel.html' with value=company.is_customer %}</td>
<td><span class='fas fa-industry'></span></td>
<td>{% trans "Manufacturer" %}</td>
<td>{% include "yesnolabel.html" with value=company.is_manufacturer %}</td>
</tr>
<tr>
<td><span class='fas fa-industry'></span></td>
<td><span class='fas fa-building'></span></td>
<td>{% trans "Supplier" %}</td>
<td>{% include 'yesnolabel.html' with value=company.is_supplier %}</td>
</tr>
<tr>
<td><span class='fas fa-user-tie'></span></td>
<td>{% trans "Customer" %}</td>
<td>{% include 'yesnolabel.html' with value=company.is_customer %}</td>
</tr>
</table>
{% endblock %}

View File

@ -47,73 +47,18 @@
});
});
$("#part-table").inventreeTable({
formatNoMatches: function() { return "No supplier parts found for {{ company.name }}"; },
queryParams: function(p) {
return {
supplier: {{ company.id }},
loadSupplierPartTable(
"#part-table",
"{% url 'api-supplier-part-list' %}",
{
params: {
part_detail: true,
}
},
columns: [
{
checkbox: true,
supplier_detail: true,
manufacturer_detail: true,
company: {{ company.id }},
},
{
sortable: true,
field: 'part_detail.full_name',
title: '{% trans "Part" %}',
formatter: function(value, row, index, field) {
var html = imageHoverIcon(row.part_detail.thumbnail) + renderLink(value, '/part/' + row.part + '/suppliers/');
if (row.part_detail.is_template) {
html += `<span class='fas fa-clone label-right' title='Template part'></span>`;
}
if (row.part_detail.assembly) {
html += `<span class='fas fa-tools label-right' title='Assembled part'></span>`;
}
if (!row.part_detail.active) {
html += `<span class='label label-warning label-right'>INACTIVE</span>`;
}
return html;
}
},
{
sortable: true,
field: 'SKU',
title: '{% trans "SKU" %}',
formatter: function(value, row, index, field) {
return renderLink(value, row.url);
}
},
{
sortable: true,
field: 'manufacturer',
title: '{% trans "Manufacturer" %}',
},
{
sortable: true,
field: 'MPN',
title: 'MPN',
},
{
field: 'link',
title: '{% trans "Link" %}',
formatter: function(value, row, index, field) {
if (value) {
return renderLink(value, value);
} else {
return '';
}
}
},
],
url: "{% url 'api-part-supplier-list' %}"
});
}
);
$("#multi-part-delete").click(function() {
var selections = $("#part-table").bootstrapTable("getSelections");

View File

@ -25,7 +25,7 @@
{% block js_ready %}
{{ block.super }}
loadPurchaseOrderTable($("#purchase-order-table"), {
loadPurchaseOrderTable("#purchase-order-table", {
url: "{% url 'api-po-list' %}?supplier={{ company.id }}",
});
@ -48,7 +48,4 @@
newOrder();
});
$(".po-table").inventreeTable({
});
{% endblock %}

View File

@ -19,8 +19,9 @@
loadStockTable($('#stock-table'), {
url: "{% url 'api-stock-list' %}",
params: {
supplier: {{ company.id }},
company: {{ company.id }},
part_detail: true,
supplier_detail: true,
location_detail: true,
},
buttons: [

View File

@ -9,12 +9,12 @@ InvenTree | {% trans "Supplier List" %}
{% block content %}
<h3>{% trans "Supplier List" %}</h3>
<h3>{{ title }}</h3>
<hr>
<div id='button-toolbar'>
<div class='btn-group'>
<button type='button' class="btn btn-success" id='new-company' title='Add new supplier'>{% trans "New Supplier" %}</button>
<button type='button' class="btn btn-success" id='new-company'>{{ button_text }}</button>
</div>
</div>
@ -26,54 +26,17 @@ InvenTree | {% trans "Supplier List" %}
{% block js_ready %}
{{ block.super }}
$('#new-company').click(function () {
launchModalForm(
"{% url 'company-create' %}",
{
follow: true
});
launchModalForm("{{ create_url }}", {
follow: true
});
});
$("#company-table").inventreeTable({
formatNoMatches: function() { return "No company information found"; },
columns: [
{
field: 'pk',
title: '{% trans "ID" %}',
visible: false,
},
{
field: 'name',
title: '{% trans "Supplier" %}',
sortable: true,
formatter: function(value, row, index, field) {
return imageHoverIcon(row.image) + renderLink(value, row.url);
}
},
{
field: 'description',
title: '{% trans "Description" %}',
sortable: true,
},
{
field: 'website',
title: '{% trans "Website" %}',
formatter: function(value, row, index, field) {
if (value) {
return renderLink(value, value);
}
return '';
}
},
{
field: 'part_count',
title: '{% trans "Parts" %}',
sortable: true,
formatter: function(value, row, index, field) {
return renderLink(value, row.url + 'parts/');
}
},
],
url: "{% url 'api-company-list' %}"
});
loadCompanyTable("#company-table", "{% url 'api-company-list' %}",
{
params: {
{% for key,value in filters.items %}{{ key }}: "{{ value }}",{% endfor %}
}
}
);
{% endblock %}

View File

@ -33,7 +33,9 @@ InvenTree | {% trans "Supplier Part" %}
<div class='col-sm-6'>
<h4>{% trans "Supplier Part Details" %}</h4>
<table class="table table-striped table-condensed">
<col width='25'>
<tr>
<td><span class='fas fa-shapes'></span></td>
<td>{% trans "Internal Part" %}</td>
<td>
{% if part.part %}
@ -41,21 +43,46 @@ InvenTree | {% trans "Supplier Part" %}
{% endif %}
</td>
</tr>
<tr><td>{% trans "Supplier" %}</td><td><a href="{% url 'company-detail-parts' part.supplier.id %}">{{ part.supplier.name }}</a></td></tr>
<tr><td>{% trans "SKU" %}</td><td>{{ part.SKU }}</tr></tr>
{% if part.link %}
<tr><td>{% trans "External Link" %}</td><td><a href="{{ part.link }}">{{ part.link }}</a></td></tr>
{% endif %}
{% if part.description %}
<tr><td>{% trans "Description" %}</td><td>{{ part.description }}</td></tr>
{% endif %}
{% if part.manufacturer %}
<tr><td>{% trans "Manufacturer" %}</td><td>{{ part.manufacturer }}</td></tr>
<tr><td>{% trans "MPN" %}</td><td>{{ part.MPN }}</td></tr>
{% endif %}
{% if part.note %}
<tr><td>{% trans "Note" %}</td><td>{{ part.note }}</td></tr>
{% endif %}
{% if part.description %}
<tr>
<td>{% trans "Description" %}</td>
<td>{{ part.description }}</td>
</tr>
{% endif %}
{% if part.link %}
<tr>
<td><span class='fas fa-link'></span></td>
<td>{% trans "External Link" %}</td>
<td><a href="{{ part.link }}">{{ part.link }}</a></td>
</tr>
{% endif %}
<tr>
<td><span class='fas fa-building'></span></td>
<td>{% trans "Supplier" %}</td>
<td><a href="{% url 'company-detail-parts' part.supplier.id %}">{{ part.supplier.name }}</a></td></tr>
<tr>
<td></td>
<td>{% trans "SKU" %}</td>
<td>{{ part.SKU }}</tr>
</tr>
{% if part.manufacturer %}
<tr>
<td><span class='fas fa-industry'></span></td>
<td>{% trans "Manufacturer" %}</td>
<td><a href="{% url 'company-detail-parts' part.manufacturer.id %}">{{ part.manufacturer.name }}</a></td></tr>
<tr>
<td></td>
<td>{% trans "MPN" %}</td>
<td>{{ part.MPN }}</td>
</tr>
{% endif %}
{% if part.note %}
<tr>
<td></td>
<td>{% trans "Note" %}</td>
<td>{{ part.note }}</td>
</tr>
{% endif %}
</table>
</div>
</div>

View File

@ -4,13 +4,15 @@
<li{% if tab == 'details' %} class='active'{% endif %}>
<a href="{% url 'company-detail' company.id %}">{% trans "Details" %}</a>
</li>
{% if company.is_supplier %}
{% if company.is_supplier or company.is_manufacturer %}
<li{% if tab == 'parts' %} class='active'{% endif %}>
<a href="{% url 'company-detail-parts' company.id %}">{% trans "Supplier Parts" %} <span class='badge'>{{ company.part_count }}</span></a>
<a href="{% url 'company-detail-parts' company.id %}">{% trans "Parts" %} <span class='badge'>{{ company.part_count }}</span></a>
</li>
<li{% if tab == 'stock' %} class='active'{% endif %}>
<a href="{% url 'company-detail-stock' company.id %}">{% trans "Stock" %} <span class='badge'>{{ company.stock_count }}</a>
</li>
{% endif %}
{% if company.is_supplier %}
<li{% if tab == 'po' %} class='active'{% endif %}>
<a href="{% url 'company-detail-purchase-orders' company.id %}">{% trans "Purchase Orders" %} <span class='badge'>{{ company.purchase_orders.count }}</span></a>
</li>

View File

@ -56,13 +56,13 @@ class CompanySimpleTest(TestCase):
zerg = Company.objects.get(pk=3)
self.assertTrue(acme.has_parts)
self.assertEqual(acme.part_count, 4)
self.assertEqual(acme.supplied_part_count, 4)
self.assertTrue(appel.has_parts)
self.assertEqual(appel.part_count, 2)
self.assertEqual(appel.supplied_part_count, 2)
self.assertTrue(zerg.has_parts)
self.assertEqual(zerg.part_count, 1)
self.assertEqual(zerg.supplied_part_count, 1)
def test_price_breaks(self):

View File

@ -2,9 +2,7 @@
URL lookup for Company app
"""
from django.conf.urls import url, include
from django.views.generic.base import RedirectView
from . import views
@ -15,7 +13,7 @@ company_detail_urls = [
# url(r'orders/?', views.CompanyDetail.as_view(template_name='company/orders.html'), name='company-detail-orders'),
url(r'parts/?', views.CompanyDetail.as_view(template_name='company/detail_part.html'), name='company-detail-parts'),
url(r'parts/', views.CompanyDetail.as_view(template_name='company/detail_part.html'), name='company-detail-parts'),
url(r'stock/?', views.CompanyDetail.as_view(template_name='company/detail_stock.html'), name='company-detail-stock'),
url(r'purchase-orders/?', views.CompanyDetail.as_view(template_name='company/detail_purchase_orders.html'), name='company-detail-purchase-orders'),
url(r'notes/?', views.CompanyNotes.as_view(), name='company-notes'),
@ -29,14 +27,19 @@ company_detail_urls = [
company_urls = [
url(r'new/supplier/', views.CompanyCreate.as_view(), name='supplier-create'),
url(r'new/manufacturer/', views.CompanyCreate.as_view(), name='manufacturer-create'),
url(r'new/customer/', views.CompanyCreate.as_view(), name='customer-create'),
url(r'new/?', views.CompanyCreate.as_view(), name='company-create'),
url(r'^(?P<pk>\d+)/', include(company_detail_urls)),
url(r'', views.CompanyIndex.as_view(), name='company-index'),
url(r'suppliers/', views.CompanyIndex.as_view(), name='supplier-index'),
url(r'manufacturers/', views.CompanyIndex.as_view(), name='manufacturer-index'),
url(r'customers/', views.CompanyIndex.as_view(), name='customer-index'),
# Redirect any other patterns
url(r'^.*$', RedirectView.as_view(url='', permanent=False), name='company-index'),
# Redirect any other patterns to the 'company' index which displays all companies
url(r'^.*$', views.CompanyIndex.as_view(), name='company-index'),
]
price_break_urls = [

View File

@ -39,6 +39,56 @@ class CompanyIndex(ListView):
context_object_name = 'companies'
paginate_by = 50
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
# Provide custom context data to the template,
# based on the URL we use to access this page
lookup = {
reverse('supplier-index'): {
'title': _('Suppliers'),
'button_text': _('New Supplier'),
'filters': {'is_supplier': 'true'},
'create_url': reverse('supplier-create'),
},
reverse('manufacturer-index'): {
'title': _('Manufacturers'),
'button_text': _('New Manufacturer'),
'filters': {'is_manufacturer': 'true'},
'create_url': reverse('manufacturer-create'),
},
reverse('customer-index'): {
'title': _('Customers'),
'button_text': _('New Customer'),
'filters': {'is_customer': 'true'},
'create_url': reverse('customer-create'),
}
}
default = {
'title': _('Companies'),
'button_text': _('New Company'),
'filters': {},
'create_url': reverse('company-create'),
}
context = None
for item in lookup:
if self.request.path == item:
context = lookup[item]
break
if context is None:
context = default
for key, value in context.items():
ctx[key] = value
return ctx
def get_queryset(self):
""" Retrieve the Company queryset based on HTTP request parameters.
@ -125,7 +175,44 @@ class CompanyCreate(AjaxCreateView):
context_object_name = 'company'
form_class = EditCompanyForm
ajax_template_name = 'modal_form.html'
ajax_form_title = _("Create new Company")
def get_form_title(self):
url = self.request.path
if url == reverse('supplier-create'):
return _("Create new Supplier")
if url == reverse('manufacturer-create'):
return _('Create new Manufacturer')
if url == reverse('customer-create'):
return _('Create new Customer')
return _('Create new Company')
def get_initial(self):
""" Initial values for the form data """
initials = super().get_initial().copy()
url = self.request.path
if url == reverse('supplier-create'):
initials['is_supplier'] = True
initials['is_customer'] = False
initials['is_manufacturer'] = False
elif url == reverse('manufacturer-create'):
initials['is_manufacturer'] = True
initials['is_supplier'] = True
initials['is_customer'] = False
elif url == reverse('customer-create'):
initials['is_customer'] = True
initials['is_manufacturer'] = False
initials['is_supplier'] = False
return initials
def get_data(self):
return {

View File

@ -37,10 +37,7 @@ $("#po-create").click(function() {
);
});
$("#po-table").inventreeTable({
});
loadPurchaseOrderTable($("#purchase-order-table"), {
loadPurchaseOrderTable("#purchase-order-table", {
url: "{% url 'api-po-list' %}",
});

View File

@ -9,8 +9,11 @@
<hr>
<div id='button-bar'>
<div class='btn-group'>
<div class='button-toolbar container-fluid' style='float: right;'>
<button class='btn btn-primary' type='button' id='part-order2' title='Order part'>Order Part</button>
<div class='filter-list' id='filter-list-order'>
<!-- An empty div in which the filter list will be constructed -->
</div>
</div>
</div>

View File

@ -66,58 +66,18 @@
});
});
$("#supplier-table").inventreeTable({
formatNoMatches: function() { return "No supplier parts available for {{ part.full_name }}"; },
queryParams: function(p) {
return {
part: {{ part.id }}
}
},
columns: [
{
checkbox: true,
loadSupplierPartTable(
"#supplier-table",
"{% url 'api-supplier-part-list' %}",
{
params: {
part: {{ part.id }},
part_detail: true,
supplier_detail: true,
manufacturer_detail: true,
},
{
sortable: true,
field: 'supplier_name',
title: 'Supplier',
formatter: function(value, row, index, field) {
return imageHoverIcon(row.supplier_logo) + renderLink(value, '/company/' + row.supplier + '/');
}
},
{
sortable: true,
field: 'SKU',
title: 'SKU',
formatter: function(value, row, index, field) {
return renderLink(value, row.url);
}
},
{
sortable: true,
field: 'manufacturer',
title: 'Manufacturer',
},
{
sortable: true,
field: 'MPN',
title: 'MPN',
},
{
sortable: true,
field: 'pricing',
title: 'Price',
formatter: function(value, row, index, field) {
if (value) {
return value;
} else {
return "<span class='warning-msg'><i>No pricing available</i></span>";
}
},
}
],
url: "{% url 'api-part-supplier-list' %}"
});
}
);
linkButtonsToSelection($("#supplier-table"), ['#supplier-part-options'])

View File

@ -1268,8 +1268,6 @@ class PartExport(AjaxView):
# Filter by part category
cat_id = request.GET.get('category', None)
print('cat_id:', cat_id)
part_list = None
if cat_id is not None:

View File

@ -8,6 +8,7 @@ from django_filters import NumberFilter
from django.conf import settings
from django.conf.urls import url, include
from django.urls import reverse
from django.db.models import Q
from .models import StockLocation, StockItem
from .models import StockItemTracking
@ -494,11 +495,23 @@ class StockList(generics.ListCreateAPIView):
if supplier_part_id:
stock_list = stock_list.filter(supplier_part=supplier_part_id)
# Filter by supplier ID
supplier_id = self.request.query_params.get('supplier', None)
# Filter by company (either manufacturer or supplier)
company = self.request.query_params.get('company', None)
if supplier_id:
stock_list = stock_list.filter(supplier_part__supplier=supplier_id)
if company is not None:
stock_list = stock_list.filter(Q(supplier_part__supplier=company) | Q(supplier_part__manufacturer=company))
# Filter by supplier
supplier = self.request.query_params.get('supplier', None)
if supplier is not None:
stock_list = stock_list.filter(supplier_part__supplier=supplier)
# Filter by manufacturer
manufacturer = self.request.query_params.get('manufacturer', None)
if manufacturer is not None:
stock_list = stock_list.filter(supplier_part__manufacturer=manufacturer)
# Also ensure that we pre-fecth all the related items
stock_list = stock_list.prefetch_related(

View File

@ -135,55 +135,23 @@ InvenTree | {% trans "Search Results" %}
}
);
$("#company-results-table").inventreeTable({
url: "{% url 'api-company-list' %}",
queryParams: {
search: "{{ query }}",
},
columns: [
{
field: 'name',
title: 'Name',
formatter: function(value, row, index, field) {
return imageHoverIcon(row.image) + renderLink(value, row.url);
},
},
{
field: 'description',
title: 'Description',
},
]
loadCompanyTable('#company-results-table', "{% url 'api-company-list' %}", {
params: {
serach: "{{ query }}",
}
});
$("#supplier-part-results-table").inventreeTable({
url: "{% url 'api-part-supplier-list' %}",
queryParams: {
search: "{{ query }}",
},
columns: [
{
field: 'supplier_name',
title: 'Supplier',
formatter: function(value, row, index, field) {
return imageHoverIcon(row.supplier_logo) + renderLink(value, '/company/' + row.supplier + '/');
}
loadSupplierPartTable(
"#supplier-part-results-table",
"{% url 'api-supplier-part-list' %}",
{
params: {
search: "{{ query }}",
part_detail: true,
supplier_detail: true,
manufacturer_detail: true
},
{
field: 'SKU',
title: 'SKU',
formatter: function(value, row, index, field) {
return renderLink(value, row.url);
}
},
{
field: 'manufacturer',
title: 'Manufacturer',
},
{
field: 'MPN',
title: 'MPN',
}
]
});
}
);
{% endblock %}

View File

@ -108,6 +108,7 @@ InvenTree
<script type='text/javascript' src="{% static 'script/inventree/build.js' %}"></script>
<script type='text/javascript' src="{% static 'script/inventree/modals.js' %}"></script>
<script type='text/javascript' src="{% static 'script/inventree/order.js' %}"></script>
<script type='text/javascript' src="{% static 'script/inventree/company.js' %}"></script>
<script type='text/javascript' src="{% static 'script/inventree/notification.js' %}"></script>
<script type='text/javascript' src="{% static 'script/inventree/sidenav.js' %}"></script>

View File

@ -10,8 +10,20 @@
<li><a href="{% url 'part-index' %}"><span class='fas fa-shapes icon-header'></span> {% trans "Parts" %}</a></li>
<li><a href="{% url 'stock-index' %}"><span class='fas fa-boxes icon-header'></span>{% trans "Stock" %}</a></li>
<li><a href="{% url 'build-index' %}"><span class='fas fa-tools icon-header'></span>{% trans "Build" %}</a></li>
<li><a href="{% url 'company-index' %}"><span class='fas fa-industry icon-header'></span>{% trans "Suppliers" %}</a></li>
<li><a href="{% url 'po-index' %}"><span class='fas fa-shopping-cart icon-header'></span>{% trans "Orders" %}</a></li>
<li class='nav navbar-nav'>
<a class='dropdown-toggle' data-toggle='dropdown' href='#'><span class='fas fa-shopping-cart icon-header'></span>{% trans "Buy" %}</a>
<ul class='dropdown-menu'>
<li><a href="{% url 'supplier-index' %}"><span class='fas fa-building icon-header'></span>{% trans "Suppliers" %}</a></li>
<li><a href="{% url 'manufacturer-index' %}"><span class='fas fa-industry icon-header'></span>{% trans "Manufacturers" %}</a></li>
<li><a href="{% url 'po-index' %}"><span class='fas fa-list icon-header'></span>{% trans "Purchase Orders" %}</a></li>
</ul>
</li>
<li class='nav navbar-nav'>
<a class='dropdown-toggle' data-toggle='dropdown' href='#'><span class='fas fa-truck icon-header'></span>{% trans "Sell" %}</a>
<ul class='dropdown-menu'>
<li><a href="{% url 'customer-index' %}"><span class='fas fa-user-tie icon-header'></span>{% trans "Customers" %}</a>
</ul>
</li>
</ul>
<ul class="nav navbar-nav navbar-right">
{% include "search_form.html" %}