Merge remote-tracking branch 'inventree/master'

This commit is contained in:
Oliver Walters 2020-04-16 21:55:59 +10:00
commit 762d735618
47 changed files with 920 additions and 198 deletions

142
InvenTree/InvenTree/api.py Normal file
View File

@ -0,0 +1,142 @@
"""
Main JSON interface views
"""
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.utils.translation import ugettext as _
from django.http import JsonResponse
from rest_framework import permissions
from rest_framework.response import Response
from rest_framework.views import APIView
from .views import AjaxView
from .version import inventreeVersion, inventreeInstanceName
from plugins import plugins as inventree_plugins
# Load barcode plugins
print("INFO: Loading plugins")
barcode_plugins = inventree_plugins.load_barcode_plugins()
action_plugins = inventree_plugins.load_action_plugins()
class InfoView(AjaxView):
""" Simple JSON endpoint for InvenTree information.
Use to confirm that the server is running, etc.
"""
def get(self, request, *args, **kwargs):
data = {
'server': 'InvenTree',
'version': inventreeVersion(),
'instance': inventreeInstanceName(),
}
return JsonResponse(data)
class ActionPluginView(APIView):
"""
Endpoint for running custom action plugins.
"""
permission_classes = [
permissions.IsAuthenticated,
]
def post(self, request, *args, **kwargs):
action = request.data.get('action', None)
data = request.data.get('data', None)
if action is None:
return Response({
'error': _("No action specified")
})
for plugin_class in action_plugins:
if plugin_class.action_name() == action:
plugin = plugin_class(request.user, data=data)
plugin.perform_action()
return Response(plugin.get_response())
# If we got to here, no matching action was found
return Response({
'error': _("No matching action found"),
"action": action,
})
class BarcodePluginView(APIView):
"""
Endpoint for handling barcode scan requests.
Barcode data are decoded by the client application,
and sent to this endpoint (as a JSON object) for validation.
A barcode could follow the internal InvenTree barcode format,
or it could match to a third-party barcode format (e.g. Digikey).
"""
permission_classes = [
permissions.IsAuthenticated,
]
def post(self, request, *args, **kwargs):
response = {}
barcode_data = request.data.get('barcode', None)
print("Barcode data:")
print(barcode_data)
if barcode_data is None:
response['error'] = _('No barcode data provided')
else:
# Look for a barcode plugin that knows how to handle the data
for plugin_class in barcode_plugins:
# Instantiate the plugin with the provided plugin data
plugin = plugin_class(barcode_data)
if plugin.validate():
# Plugin should return a dict response
response = plugin.decode()
if type(response) is dict:
if 'success' not in response.keys() and 'error' not in response.keys():
response['success'] = _('Barcode successfully decoded')
else:
response = {
'error': _('Barcode plugin returned incorrect response')
}
response['plugin'] = plugin.plugin_name()
response['hash'] = plugin.hash()
break
if 'error' not in response and 'success' not in response:
response = {
'error': _('Unknown barcode format'),
}
# Include the original barcode data
response['barcode_data'] = barcode_data
print("Response:")
print(response)
return Response(response)

View File

@ -172,7 +172,7 @@ def WrapWithQuotes(text, quote='"'):
return text return text
def MakeBarcode(object_type, object_id, object_url, data={}): def MakeBarcode(object_name, object_data):
""" Generate a string for a barcode. Adds some global InvenTree parameters. """ Generate a string for a barcode. Adds some global InvenTree parameters.
Args: Args:
@ -185,13 +185,12 @@ def MakeBarcode(object_type, object_id, object_url, data={}):
json string of the supplied data plus some other data json string of the supplied data plus some other data
""" """
# Add in some generic InvenTree data data = {
data['type'] = object_type 'tool': 'InvenTree',
data['id'] = object_id 'version': inventreeVersion(),
data['url'] = object_url 'instance': inventreeInstanceName(),
data['tool'] = 'InvenTree' object_name: object_data
data['instance'] = inventreeInstanceName() }
data['version'] = inventreeVersion()
return json.dumps(data, sort_keys=True) return json.dumps(data, sort_keys=True)

View File

@ -81,15 +81,19 @@ function loadPartTable(table, url, options={}) {
* - table: HTML reference to the table * - table: HTML reference to the table
* - url: Base URL for API query * - url: Base URL for API query
* - options: object containing following (optional) fields * - options: object containing following (optional) fields
* allowInactive: If true, allow display of inactive parts
* checkbox: Show the checkbox column * checkbox: Show the checkbox column
* query: extra query params for API request * query: extra query params for API request
* buttons: If provided, link buttons to selection status of this table * buttons: If provided, link buttons to selection status of this table
* disableFilters: If true, disable custom filters
*/ */
var params = options.parms || {}; var params = options.params || {};
var filters = loadTableFilters("parts"); var filters = {};
if (!options.disableFilters) {
filters = loadTableFilters("parts");
}
for (var key in params) { for (var key in params) {
filters[key] = params[key]; filters[key] = params[key];
@ -147,6 +151,10 @@ function loadPartTable(table, url, options={}) {
display += `<span class='fas fa-tools label-right' title='Assembled part'></span>`; display += `<span class='fas fa-tools label-right' title='Assembled part'></span>`;
} }
if (row.starred) {
display += `<span class='fas fa-star label-right' title='Starred part'></span>`;
}
/* /*
if (row.component) { if (row.component) {
display = display + `<span class='fas fa-cogs label-right' title='Component part'></span>`; display = display + `<span class='fas fa-cogs label-right' title='Component part'></span>`;

View File

@ -41,6 +41,7 @@ function loadStockTable(table, options) {
* groupByField - Column for grouping stock items * groupByField - Column for grouping stock items
* buttons - Which buttons to link to stock selection callbacks * buttons - Which buttons to link to stock selection callbacks
* filterList - <ul> element where filters are displayed * filterList - <ul> element where filters are displayed
* disableFilters: If true, disable custom filters
*/ */
// List of user-params which override the default filters // List of user-params which override the default filters
@ -48,7 +49,11 @@ function loadStockTable(table, options) {
var filterListElement = options.filterList || "#filter-list-stock"; var filterListElement = options.filterList || "#filter-list-stock";
var filters = loadTableFilters("stock"); var filters = {};
if (!options.disableFilters) {
filters = loadTableFilters("stock");
}
var original = {}; var original = {};

View File

@ -27,6 +27,22 @@ class APITests(APITestCase):
User = get_user_model() User = get_user_model()
User.objects.create_user(self.username, 'user@email.com', self.password) User.objects.create_user(self.username, 'user@email.com', self.password)
def test_info_view(self):
"""
Test that we can read the 'info-view' endpoint.
"""
url = reverse('api-inventree-info')
response = self.client.get(url, format='json')
data = response.json()
self.assertIn('server', data)
self.assertIn('version', data)
self.assertIn('instance', data)
self.assertEquals('InvenTree', data['server'])
def test_get_token_fail(self): def test_get_token_fail(self):
""" Ensure that an invalid user cannot get a token """ """ Ensure that an invalid user cannot get a token """
@ -65,3 +81,7 @@ class APITests(APITestCase):
response = self.client.get(part_url, format='json') response = self.client.get(part_url, format='json')
self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.status_code, status.HTTP_200_OK)
def test_barcode(self):
# TODO - Complete this
pass

View File

@ -113,15 +113,15 @@ class TestMakeBarcode(TestCase):
def test_barcode(self): def test_barcode(self):
data = { bc = helpers.MakeBarcode(
'animal': 'cat', "part",
'legs': 3, {
'noise': 'purr' "id": 3,
} "url": "www.google.com",
}
)
bc = helpers.MakeBarcode("part", 3, "www.google.com", data) self.assertIn('part', bc)
self.assertIn('animal', bc)
self.assertIn('tool', bc) self.assertIn('tool', bc)
self.assertIn('"tool": "InvenTree"', bc) self.assertIn('"tool": "InvenTree"', bc)

View File

@ -35,7 +35,8 @@ from rest_framework.documentation import include_docs_urls
from .views import IndexView, SearchView, DatabaseStatsView from .views import IndexView, SearchView, DatabaseStatsView
from .views import SettingsView, EditUserView, SetPasswordView from .views import SettingsView, EditUserView, SetPasswordView
from .views import InfoView
from .api import InfoView, BarcodePluginView, ActionPluginView
from users.urls import user_urls from users.urls import user_urls
@ -53,8 +54,12 @@ apipatterns = [
# User URLs # User URLs
url(r'^user/', include(user_urls)), url(r'^user/', include(user_urls)),
# Plugin endpoints
url(r'^barcode/', BarcodePluginView.as_view(), name='api-barcode-plugin'),
url(r'^action/', ActionPluginView.as_view(), name='api-action-plugin'),
# InvenTree information endpoint # InvenTree information endpoint
url(r'^$', InfoView.as_view(), name='inventree-info'), url(r'^$', InfoView.as_view(), name='api-inventree-info'),
] ]
settings_urls = [ settings_urls = [

View File

@ -22,7 +22,6 @@ from common.models import InvenTreeSetting
from .forms import DeleteForm, EditUserForm, SetPasswordForm from .forms import DeleteForm, EditUserForm, SetPasswordForm
from .helpers import str2bool from .helpers import str2bool
from .version import inventreeVersion, inventreeInstanceName
from rest_framework import views from rest_framework import views
@ -416,22 +415,6 @@ class AjaxDeleteView(AjaxMixin, UpdateView):
return self.renderJsonResponse(request, form, data=data, context=context) return self.renderJsonResponse(request, form, data=data, context=context)
class InfoView(AjaxView):
""" Simple JSON endpoint for InvenTree information.
Use to confirm that the server is running, etc.
"""
def get(self, request, *args, **kwargs):
data = {
'server': 'InvenTree',
'version': inventreeVersion(),
'instance': inventreeInstanceName(),
}
return JsonResponse(data)
class EditUserView(AjaxUpdateView): class EditUserView(AjaxUpdateView):
""" View for editing user information """ """ View for editing user information """
@ -494,15 +477,16 @@ class IndexView(TemplateView):
context = super(TemplateView, self).get_context_data(**kwargs) context = super(TemplateView, self).get_context_data(**kwargs)
context['starred'] = [star.part for star in self.request.user.starred_parts.all()] # TODO - Re-implement this when a less expensive method is worked out
# context['starred'] = [star.part for star in self.request.user.starred_parts.all()]
# Generate a list of orderable parts which have stock below their minimum values # Generate a list of orderable parts which have stock below their minimum values
# TODO - Is there a less expensive way to get these from the database # TODO - Is there a less expensive way to get these from the database
context['to_order'] = [part for part in Part.objects.filter(purchaseable=True) if part.need_to_restock()] # context['to_order'] = [part for part in Part.objects.filter(purchaseable=True) if part.need_to_restock()]
# Generate a list of assembly parts which have stock below their minimum values # Generate a list of assembly parts which have stock below their minimum values
# TODO - Is there a less expensive way to get these from the database # TODO - Is there a less expensive way to get these from the database
context['to_build'] = [part for part in Part.objects.filter(assembly=True) if part.need_to_restock()] # context['to_build'] = [part for part in Part.objects.filter(assembly=True) if part.need_to_restock()]
return context return context

View File

@ -3,7 +3,7 @@
import os import os
from rapidfuzz import fuzz from rapidfuzz import fuzz
from django.db import migrations from django.db import migrations, connection
from company.models import Company, SupplierPart from company.models import Company, SupplierPart
from django.db.utils import OperationalError, ProgrammingError from django.db.utils import OperationalError, ProgrammingError
@ -29,17 +29,41 @@ def reverse_association(apps, schema_editor):
print("Reversing migration for manufacturer association") print("Reversing migration for manufacturer association")
try: for part in SupplierPart.objects.all():
for part in SupplierPart.objects.all():
if part.manufacturer is not None:
part.manufacturer_name = part.manufacturer.name
part.save() print("Checking part [{pk}]:".format(pk=part.pk))
except (OperationalError, ProgrammingError): cursor = connection.cursor()
# An exception might be called if the database is empty
pass
# Grab the manufacturer ID from the part
response = cursor.execute('SELECT manufacturer_id FROM part_supplierpart WHERE id={ID};'.format(ID=part.id))
manufacturer_id = None
row = response.fetchone()
if len(row) > 0:
try:
manufacturer_id = int(row[0])
except (TypeError, ValueError):
pass
if manufacturer_id is None:
print(" - Manufacturer ID not set: Skipping")
continue
print(" - Manufacturer ID: [{id}]".format(id=manufacturer_id))
# Now extract the "name" for the manufacturer
response = cursor.execute('SELECT name from company_company where id={ID};'.format(ID=manufacturer_id))
row = response.fetchone()
name = row[0]
print(" - Manufacturer name: '{name}'".format(name=name))
response = cursor.execute("UPDATE part_supplierpart SET manufacturer_name='{name}' WHERE id={ID};".format(name=name, ID=part.id))
def associate_manufacturers(apps, schema_editor): def associate_manufacturers(apps, schema_editor):
""" """
@ -55,6 +79,29 @@ def associate_manufacturers(apps, schema_editor):
It uses fuzzy pattern matching to help the user out as much as possible. It uses fuzzy pattern matching to help the user out as much as possible.
""" """
def get_manufacturer_name(part_id):
"""
THIS IS CRITICAL!
Once the pythonic representation of the model has removed the 'manufacturer_name' field,
it is NOT ACCESSIBLE by calling SupplierPart.manufacturer_name.
However, as long as the migrations are applied in order, then the table DOES have a field called 'manufacturer_name'.
So, we just need to request it using dirty SQL.
"""
query = "SELECT manufacturer_name from part_supplierpart where id={ID};".format(ID=part_id)
cursor = connection.cursor()
response = cursor.execute(query)
row = response.fetchone()
if len(row) > 0:
return row[0]
return ''
# Exit if there are no SupplierPart objects # Exit if there are no SupplierPart objects
# This crucial otherwise the unit test suite fails! # This crucial otherwise the unit test suite fails!
if SupplierPart.objects.count() == 0: if SupplierPart.objects.count() == 0:
@ -70,23 +117,19 @@ def associate_manufacturers(apps, schema_editor):
for company in Company.objects.all(): for company in Company.objects.all():
companies[company.name] = company companies[company.name] = company
# List of parts which will need saving
parts = []
def link_part(part, name): def link_part(part, name):
""" Attempt to link Part to an existing Company """ """ Attempt to link Part to an existing Company """
# Matches a company name directly # Matches a company name directly
if name in companies.keys(): if name in companies.keys():
print(" -> '{n}' maps to existing manufacturer".format(n=name)) print(" - Part[{pk}]: '{n}' maps to existing manufacturer".format(pk=part.pk, n=name))
part.manufacturer = companies[name] part.manufacturer = companies[name]
part.save() part.save()
return True return True
# Have we already mapped this # Have we already mapped this
if name in links.keys(): if name in links.keys():
print(" -> Mapped '{n}' -> '{c}'".format(n=name, c=links[name].name)) print(" - Part[{pk}]: Mapped '{n}' - '{c}'".format(pk=part.pk, n=name, c=links[name].name))
part.manufacturer = links[name] part.manufacturer = links[name]
part.save() part.save()
return True return True
@ -101,22 +144,21 @@ def associate_manufacturers(apps, schema_editor):
company.is_manufacturer = True company.is_manufacturer = True
# Save the company BEFORE we associate the part, otherwise the PK does not exist
company.save()
# Map both names to the same company # Map both names to the same company
links[input_name] = company links[input_name] = company
links[company_name] = company links[company_name] = company
companies[company_name] = company companies[company_name] = company
# Save the company BEFORE we associate the part, otherwise the PK does not exist print(" - Part[{pk}]: Created new manufacturer: '{name}'".format(pk=part.pk, name=company_name))
company.save()
# Save the manufacturer reference link # Save the manufacturer reference link
part.manufacturer = company part.manufacturer = company
part.save() part.save()
print(" -> Created new manufacturer: '{name}'".format(name=company_name))
def find_matches(text, threshold=65): def find_matches(text, threshold=65):
""" """
Attempt to match a 'name' to an existing Company. Attempt to match a 'name' to an existing Company.
@ -140,10 +182,11 @@ def associate_manufacturers(apps, schema_editor):
def map_part_to_manufacturer(part, idx, total): def map_part_to_manufacturer(part, idx, total):
name = str(part.manufacturer_name) name = get_manufacturer_name(part.id)
# Skip empty names # Skip empty names
if not name or len(name) == 0: if not name or len(name) == 0:
print(" - Part[{pk}]: No manufacturer_name provided, skipping".format(pk=part.pk))
return return
# Can be linked to an existing manufacturer # Can be linked to an existing manufacturer
@ -157,7 +200,7 @@ def associate_manufacturers(apps, schema_editor):
# Present a list of options # Present a list of options
print("----------------------------------") print("----------------------------------")
print("Checking part {idx} of {total}".format(idx=idx+1, total=total)) print("Checking part [{pk}] ({idx} of {total})".format(pk=part.pk, idx=idx+1, total=total))
print("Manufacturer name: '{n}'".format(n=name)) print("Manufacturer name: '{n}'".format(n=name))
print("----------------------------------") print("----------------------------------")
print("Select an option from the list below:") print("Select an option from the list below:")
@ -171,8 +214,7 @@ def associate_manufacturers(apps, schema_editor):
print("") print("")
print("OR - Type a new custom manufacturer name") print("OR - Type a new custom manufacturer name")
while True:
while (1):
response = str(input("> ")).strip() response = str(input("> ")).strip()
# Attempt to parse user response as an integer # Attempt to parse user response as an integer
@ -185,7 +227,7 @@ def associate_manufacturers(apps, schema_editor):
create_manufacturer(part, name, name) create_manufacturer(part, name, name)
return return
# Options 1) -> n) select an existing manufacturer # Options 1) - n) select an existing manufacturer
else: else:
n = n - 1 n = n - 1
@ -206,9 +248,11 @@ def associate_manufacturers(apps, schema_editor):
links[name] = company links[name] = company
links[company_name] = company links[company_name] = company
print(" -> Linked '{n}' to manufacturer '{m}'".format(n=name, m=company_name)) print(" - Part[{pk}]: Linked '{n}' to manufacturer '{m}'".format(pk=part.pk, n=name, m=company_name))
return return
else:
print("Please select a valid option")
except ValueError: except ValueError:
# User has typed in a custom name! # User has typed in a custom name!
@ -256,11 +300,10 @@ def associate_manufacturers(apps, schema_editor):
for idx, part in enumerate(SupplierPart.objects.all()): for idx, part in enumerate(SupplierPart.objects.all()):
if part.manufacturer is not None: if part.manufacturer is not None:
print(" -> Part '{p}' already has a manufacturer associated (skipping)".format(p=part)) print(" - Part '{p}' already has a manufacturer associated (skipping)".format(p=part))
continue continue
map_part_to_manufacturer(part, idx, part_count) map_part_to_manufacturer(part, idx, part_count)
parts.append(part)
print("Done!") print("Done!")

View File

@ -33,16 +33,29 @@
"{% url 'supplier-part-create' %}", "{% url 'supplier-part-create' %}",
{ {
data: { data: {
supplier: {{ company.id }} {% if company.is_supplier %}supplier: {{ company.id }},{% endif %}
{% if company.is_manufacturer %}manufacturer: {{ company.id }},{% endif %}
}, },
reload: true, reload: true,
secondary: [ secondary: [
{ {
field: 'part', field: 'part',
label: 'New Part', label: '{% trans "New Part" %}',
title: 'Create New Part', title: '{% trans "Create new Part" %}',
url: "{% url 'part-create' %}" url: "{% url 'part-create' %}"
}, },
{
field: 'supplier',
label: "{% trans 'New Supplier' %}",
title: "{% trans 'Create new Supplier' %}",
url: "{% url 'supplier-create' %}",
},
{
field: 'manufacturer',
label: '{% trans "New Manufacturer" %}',
title: '{% trans "Create new Manufacturer" %}',
url: "{% url 'manufacturer-create' %}",
},
] ]
}); });
}); });

View File

@ -61,7 +61,7 @@ InvenTree | {% trans "Supplier Part" %}
<td>{% trans "Supplier" %}</td> <td>{% trans "Supplier" %}</td>
<td><a href="{% url 'company-detail-parts' part.supplier.id %}">{{ part.supplier.name }}</a></td></tr> <td><a href="{% url 'company-detail-parts' part.supplier.id %}">{{ part.supplier.name }}</a></td></tr>
<tr> <tr>
<td></td> <td><span class='fas fa-hashtag'></span></td>
<td>{% trans "SKU" %}</td> <td>{% trans "SKU" %}</td>
<td>{{ part.SKU }}</tr> <td>{{ part.SKU }}</tr>
</tr> </tr>
@ -71,14 +71,14 @@ InvenTree | {% trans "Supplier Part" %}
<td>{% trans "Manufacturer" %}</td> <td>{% trans "Manufacturer" %}</td>
<td><a href="{% url 'company-detail-parts' part.manufacturer.id %}">{{ part.manufacturer.name }}</a></td></tr> <td><a href="{% url 'company-detail-parts' part.manufacturer.id %}">{{ part.manufacturer.name }}</a></td></tr>
<tr> <tr>
<td></td> <td><span class='fas fa-hashtag'></span></td>
<td>{% trans "MPN" %}</td> <td>{% trans "MPN" %}</td>
<td>{{ part.MPN }}</td> <td>{{ part.MPN }}</td>
</tr> </tr>
{% endif %} {% endif %}
{% if part.note %} {% if part.note %}
<tr> <tr>
<td></td> <td><span class='fas fa-sticky-note'></span></td>
<td>{% trans "Note" %}</td> <td>{% trans "Note" %}</td>
<td>{{ part.note }}</td> <td>{{ part.note }}</td>
</tr> </tr>

View File

@ -274,10 +274,6 @@ class SupplierPartCreate(AjaxCreateView):
""" """
form = super(AjaxCreateView, self).get_form() form = super(AjaxCreateView, self).get_form()
if form.initial.get('supplier', None):
# Hide the supplier field
form.fields['supplier'].widget = HiddenInput()
if form.initial.get('part', None): if form.initial.get('part', None):
# Hide the part field # Hide the part field
form.fields['part'].widget = HiddenInput() form.fields['part'].widget = HiddenInput()
@ -292,20 +288,27 @@ class SupplierPartCreate(AjaxCreateView):
""" """
initials = super(SupplierPartCreate, self).get_initial().copy() initials = super(SupplierPartCreate, self).get_initial().copy()
manufacturer_id = self.get_param('manufacturer')
supplier_id = self.get_param('supplier') supplier_id = self.get_param('supplier')
part_id = self.get_param('part') part_id = self.get_param('part')
if supplier_id: if supplier_id:
try: try:
initials['supplier'] = Company.objects.get(pk=supplier_id) initials['supplier'] = Company.objects.get(pk=supplier_id)
except Company.DoesNotExist: except (ValueError, Company.DoesNotExist):
initials['supplier'] = None pass
if manufacturer_id:
try:
initials['manufacturer'] = Company.objects.get(pk=manufacturer_id)
except (ValueError, Company.DoesNotExist):
pass
if part_id: if part_id:
try: try:
initials['part'] = Part.objects.get(pk=part_id) initials['part'] = Part.objects.get(pk=part_id)
except Part.DoesNotExist: except (ValueError, Part.DoesNotExist):
initials['part'] = None pass
return initials return initials

View File

@ -74,6 +74,7 @@ class POList(generics.ListCreateAPIView):
data = queryset.values( data = queryset.values(
'pk', 'pk',
'supplier', 'supplier',
'supplier_reference',
'supplier__name', 'supplier__name',
'supplier__image', 'supplier__image',
'reference', 'reference',

View File

@ -69,6 +69,7 @@ class EditPurchaseOrderForm(HelperForm):
fields = [ fields = [
'reference', 'reference',
'supplier', 'supplier',
'supplier_reference',
'description', 'description',
'link', 'link',
] ]

View File

@ -0,0 +1,18 @@
# Generated by Django 2.2.10 on 2020-04-15 03:31
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('order', '0018_auto_20200406_0151'),
]
operations = [
migrations.AddField(
model_name='purchaseorder',
name='supplier_reference',
field=models.CharField(blank=True, help_text='Supplier order reference', max_length=64),
),
]

View File

@ -131,6 +131,8 @@ class PurchaseOrder(Order):
help_text=_('Company') help_text=_('Company')
) )
supplier_reference = models.CharField(max_length=64, blank=True, help_text=_("Supplier order reference"))
received_by = models.ForeignKey( received_by = models.ForeignKey(
User, User,
on_delete=models.SET_NULL, on_delete=models.SET_NULL,

View File

@ -19,6 +19,7 @@ class POSerializer(InvenTreeModelSerializer):
fields = [ fields = [
'pk', 'pk',
'supplier', 'supplier',
'supplier_reference',
'reference', 'reference',
'description', 'description',
'link', 'link',

View File

@ -63,15 +63,27 @@ InvenTree | {{ order }}
<table class='table'> <table class='table'>
<col width='25'> <col width='25'>
<tr> <tr>
<td><span class='fas fa-industry'></span></td> <td><span class='fas fa-hashtag'></span></td>
<td>{% trans "Supplier" %}</td> <td>{% trans "Order Reference" %}</td>
<td><a href="{% url 'company-detail' order.supplier.id %}">{{ order.supplier }}</a></td> <td>{{ order.reference }}</td>
</tr> </tr>
<tr> <tr>
<td><span class='fas fa-info'></span></td> <td><span class='fas fa-info'></span></td>
<td>{% trans "Status" %}</td> <td>{% trans "Order Status" %}</td>
<td>{% order_status order.status %}</td> <td>{% order_status order.status %}</td>
</tr> </tr>
<tr>
<td><span class='fas fa-building'></span></td>
<td>{% trans "Supplier" %}</td>
<td><a href="{% url 'company-detail' order.supplier.id %}">{{ order.supplier.name }}</a></td>
</tr>
{% if order.supplier_reference %}
<tr>
<td><span class='fas fa-hashtag'></span></td>
<td>{% trans "Supplier Reference" %}</td>
<td>{{ order.supplier_reference }}</td>
</tr>
{% endif %}
{% if order.link %} {% if order.link %}
<tr> <tr>
<td><span class='fas fa-link'></span></td> <td><span class='fas fa-link'></span></td>

View File

@ -153,6 +153,7 @@ class PartList(generics.ListCreateAPIView):
The Part object list can be filtered by: The Part object list can be filtered by:
- category: Filter by PartCategory reference - category: Filter by PartCategory reference
- cascade: If true, include parts from sub-categories - cascade: If true, include parts from sub-categories
- starred: Is the part "starred" by the current user?
- is_template: Is the part a template part? - is_template: Is the part a template part?
- variant_of: Filter by variant_of Part reference - variant_of: Filter by variant_of Part reference
- assembly: Filter by assembly field - assembly: Filter by assembly field
@ -257,12 +258,18 @@ class PartList(generics.ListCreateAPIView):
# Filter items which have an 'in_stock' level higher than 'minimum_stock' # Filter items which have an 'in_stock' level higher than 'minimum_stock'
data = data.filter(Q(in_stock__gte=F('minimum_stock'))) data = data.filter(Q(in_stock__gte=F('minimum_stock')))
# Get a list of the parts that this user has starred
starred_parts = [star.part.pk for star in self.request.user.starred_parts.all()]
# Reduce the number of lookups we need to do for the part categories # Reduce the number of lookups we need to do for the part categories
categories = {} categories = {}
for item in data: for item in data:
if item['image']: if item['image']:
# Is this part 'starred' for the current user?
item['starred'] = item['pk'] in starred_parts
img = item['image'] img = item['image']
# Use the 'thumbnail' image here instead of the full-size image # Use the 'thumbnail' image here instead of the full-size image
@ -294,32 +301,53 @@ class PartList(generics.ListCreateAPIView):
return Response(data) return Response(data)
def get_queryset(self): def get_queryset(self):
"""
# Does the user wish to filter by category? Implement custom filtering for the Part list API
cat_id = self.request.query_params.get('category', None) """
# Start with all objects # Start with all objects
parts_list = Part.objects.all() parts_list = Part.objects.all()
cascade = str2bool(self.request.query_params.get('cascade', False)) # Filter by 'starred' parts?
starred = str2bool(self.request.query_params.get('starred', None))
if starred is not None:
starred_parts = [star.part.pk for star in self.request.user.starred_parts.all()]
if starred:
parts_list = parts_list.filter(pk__in=starred_parts)
else:
parts_list = parts_list.exclude(pk__in=starred_parts)
cascade = str2bool(self.request.query_params.get('cascade', None))
# Does the user wish to filter by category?
cat_id = self.request.query_params.get('category', None)
if cat_id is None: if cat_id is None:
# Top-level parts # No category filtering if category is not specified
if not cascade: pass
parts_list = parts_list.filter(category=None)
else: else:
try: # Category has been specified!
category = PartCategory.objects.get(pk=cat_id) if isNull(cat_id):
# A 'null' category is the top-level category
if cascade is False:
# Do not cascade, only list parts in the top-level category
parts_list = parts_list.filter(category=None)
# If '?cascade=true' then include parts which exist in sub-categories else:
if cascade: try:
parts_list = parts_list.filter(category__in=category.getUniqueChildren()) category = PartCategory.objects.get(pk=cat_id)
# Just return parts directly in the requested category
else: # If '?cascade=true' then include parts which exist in sub-categories
parts_list = parts_list.filter(category=cat_id) if cascade:
except (ValueError, PartCategory.DoesNotExist): parts_list = parts_list.filter(category__in=category.getUniqueChildren())
pass # Just return parts directly in the requested category
else:
parts_list = parts_list.filter(category=cat_id)
except (ValueError, PartCategory.DoesNotExist):
pass
# Ensure that related models are pre-loaded to reduce DB trips # Ensure that related models are pre-loaded to reduce DB trips
parts_list = self.get_serializer_class().setup_eager_loading(parts_list) parts_list = self.get_serializer_class().setup_eager_loading(parts_list)

View File

@ -478,11 +478,11 @@ class Part(models.Model):
""" Return a JSON string for formatting a barcode for this Part object """ """ Return a JSON string for formatting a barcode for this Part object """
return helpers.MakeBarcode( return helpers.MakeBarcode(
"Part", "part",
self.id,
reverse('api-part-detail', kwargs={'pk': self.id}),
{ {
'name': self.name, "id": self.id,
"name": self.full_name,
"url": reverse('api-part-detail', kwargs={'pk': self.id}),
} }
) )

View File

@ -96,6 +96,8 @@ class PartSerializer(InvenTreeModelSerializer):
queryset = queryset.prefetch_related('builds') queryset = queryset.prefetch_related('builds')
return queryset return queryset
# TODO - Include a 'category_detail' field which serializers the category object
class Meta: class Meta:
model = Part model = Part
partial = True partial = True

View File

@ -137,29 +137,30 @@
$("#cat-create").click(function() { $("#cat-create").click(function() {
launchModalForm( launchModalForm(
"{% url 'category-create' %}", "{% url 'category-create' %}",
{ {
follow: true, follow: true,
{% if category %} {% if category %}
data: { data: {
category: {{ category.id }} category: {{ category.id }}
}, },
{% endif %} {% endif %}
secondary: [ secondary: [
{ {
field: 'default_location', field: 'default_location',
label: 'New Location', label: 'New Location',
title: 'Create new location', title: 'Create new location',
url: "{% url 'stock-location-create' %}", url: "{% url 'stock-location-create' %}",
}, },
{ {
field: 'parent', field: 'parent',
label: 'New Category', label: 'New Category',
title: 'Create new category', title: 'Create new category',
url: "{% url 'category-create' %}", url: "{% url 'category-create' %}",
}, },
] ]
}); }
);
}) })
$("#part-export").click(function() { $("#part-export").click(function() {
@ -200,11 +201,11 @@
{% if category %} {% if category %}
$("#cat-edit").click(function () { $("#cat-edit").click(function () {
launchModalForm( launchModalForm(
"{% url 'category-edit' category.id %}", "{% url 'category-edit' category.id %}",
{ {
reload: true reload: true
}, },
); );
return false; return false;
}); });
@ -227,9 +228,9 @@
"#part-table", "#part-table",
"{% url 'api-part-list' %}", "{% url 'api-part-list' %}",
{ {
query: { params: {
{% if category %} {% if category %}category: {{ category.id }},
category: {{ category.id }}, {% else %}category: "null",
{% endif %} {% endif %}
}, },
buttons: ['#part-options'], buttons: ['#part-options'],

View File

@ -70,7 +70,7 @@ def inventree_commit_date(*args, **kwargs):
@register.simple_tag() @register.simple_tag()
def inventree_github_url(*args, **kwargs): def inventree_github_url(*args, **kwargs):
""" Return URL for InvenTree github site """ """ Return URL for InvenTree github site """
return "https://github.com/InvenTree" return "https://github.com/InvenTree/InvenTree/"
@register.simple_tag() @register.simple_tag()

View File

View File

View File

@ -0,0 +1,87 @@
# -*- coding: utf-8 -*-
import plugins.plugin as plugin
class ActionPlugin(plugin.InvenTreePlugin):
"""
The ActionPlugin class is used to perform custom actions
"""
ACTION_NAME = ""
@classmethod
def action_name(cls):
"""
Return the action name for this plugin.
If the ACTION_NAME parameter is empty,
look at the PLUGIN_NAME instead.
"""
action = cls.ACTION_NAME
if not action:
action = cls.PLUGIN_NAME
return action
def __init__(self, user, data=None):
"""
An action plugin takes a user reference, and an optional dataset (dict)
"""
plugin.InvenTreePlugin.__init__(self)
self.user = user
self.data = data
def perform_action(self):
"""
Override this method to perform the action!
"""
pass
def get_result(self):
"""
Result of the action?
"""
# Re-implement this for cutsom actions
return False
def get_info(self):
"""
Extra info? Can be a string / dict / etc
"""
return None
def get_response(self):
"""
Return a response. Default implementation is a simple response
which can be overridden.
"""
return {
"action": self.action_name(),
"result": self.get_result(),
"info": self.get_info(),
}
class SimpleActionPlugin(ActionPlugin):
"""
An EXTREMELY simple action plugin which demonstrates
the capability of the ActionPlugin class
"""
PLUGIN_NAME = "SimpleActionPlugin"
ACTION_NAME = "simple"
def perform_action(self):
print("Action plugin in action!")
def get_info(self):
return {
"user": self.user.username,
"hello": "world",
}
def get_result(self):
return True

View File

View File

@ -0,0 +1,81 @@
# -*- coding: utf-8 -*-
import hashlib
from rest_framework.renderers import JSONRenderer
from stock.serializers import StockItemSerializer, LocationSerializer
from part.serializers import PartSerializer
import plugins.plugin as plugin
class BarcodePlugin(plugin.InvenTreePlugin):
"""
The BarcodePlugin class is the base class for any barcode plugin.
"""
def __init__(self, barcode_data):
plugin.InvenTreePlugin.__init__(self)
self.data = barcode_data
def hash(self):
"""
Calculate a hash for the barcode data.
This is supposed to uniquely identify the barcode contents,
at least within the bardcode sub-type.
The default implementation simply returns an MD5 hash of the barcode data,
encoded to a string.
This may be sufficient for most applications, but can obviously be overridden
by a subclass.
"""
hash = hashlib.md5(str(self.data).encode())
return str(hash.hexdigest())
def validate(self):
"""
Default implementation returns False
"""
return False
def decode(self):
"""
Decode the barcode, and craft a response
"""
return None
def render_part(self, part):
"""
Render a Part object to JSON
Use the existing serializer to do this.
"""
serializer = PartSerializer(part)
return serializer.data
def render_stock_location(self, loc):
"""
Render a StockLocation object to JSON
Use the existing serializer to do this.
"""
serializer = LocationSerializer(loc)
return serializer.data
def render_stock_item(self, item):
"""
Render a StockItem object to JSON.
Use the existing serializer to do this
"""
serializer = StockItemSerializer(item, part_detail=True, location_detail=True, supplier_detail=True)
return serializer.data

View File

@ -0,0 +1,8 @@
# -*- coding: utf-8 -*-
from . import barcode
class DigikeyBarcodePlugin(barcode.BarcodePlugin):
PLUGIN_NAME = "DigikeyBarcodePlugin"

View File

@ -0,0 +1,94 @@
"""
The InvenTreeBarcodePlugin validates barcodes generated by InvenTree itself.
It can be used as a template for developing third-party barcode plugins.
The data format is very simple, and maps directly to database objects,
via the "id" parameter.
Parsing an InvenTree barcode simply involves validating that the
references model objects actually exist in the database.
"""
# -*- coding: utf-8 -*-
import json
from . import barcode
from stock.models import StockItem, StockLocation
from part.models import Part
from django.utils.translation import ugettext as _
class InvenTreeBarcodePlugin(barcode.BarcodePlugin):
PLUGIN_NAME = "InvenTreeBarcodePlugin"
def validate(self):
"""
An "InvenTree" barcode must include the following tags:
{
'tool': 'InvenTree',
'version': <anything>
}
"""
# The data must either be dict or be able to dictified
if type(self.data) is dict:
pass
elif type(self.data) is str:
try:
self.data = json.loads(self.data)
except json.JSONDecodeError:
return False
else:
return False
for key in ['tool', 'version']:
if key not in self.data.keys():
return False
if not self.data['tool'] == 'InvenTree':
return False
return True
def decode(self):
response = {}
if 'part' in self.data.keys():
id = self.data['part'].get('id', None)
try:
part = Part.objects.get(id=id)
response['part'] = self.render_part(part)
except (ValueError, Part.DoesNotExist):
response['error'] = _('Part does not exist')
elif 'stocklocation' in self.data.keys():
id = self.data['stocklocation'].get('id', None)
try:
loc = StockLocation.objects.get(id=id)
response['stocklocation'] = self.render_stock_location(loc)
except (ValueError, StockLocation.DoesNotExist):
response['error'] = _('StockLocation does not exist')
elif 'stockitem' in self.data.keys():
id = self.data['stockitem'].get('id', None)
try:
item = StockItem.objects.get(id=id)
response['stockitem'] = self.render_stock_item(item)
except (ValueError, StockItem.DoesNotExist):
response['error'] = _('StockItem does not exist')
else:
response['error'] = _('No matching data')
return response

View File

@ -0,0 +1,16 @@
# -*- coding: utf-8 -*-
class InvenTreePlugin():
"""
Base class for a Barcode plugin
"""
# Override the plugin name for each concrete plugin instance
PLUGIN_NAME = ''
def plugin_name(self):
return self.PLUGIN_NAME
def __init__(self):
pass

View File

@ -0,0 +1,87 @@
# -*- coding: utf-8 -*-
import inspect
import importlib
import pkgutil
# Barcode plugins
import plugins.barcode as barcode
from plugins.barcode.barcode import BarcodePlugin
# Action plugins
import plugins.action as action
from plugins.action.action import ActionPlugin
def iter_namespace(pkg):
return pkgutil.iter_modules(pkg.__path__, pkg.__name__ + ".")
def get_modules(pkg):
# Return all modules in a given package
return [importlib.import_module(name) for finder, name, ispkg in iter_namespace(pkg)]
def get_classes(module):
# Return all classes in a given module
return inspect.getmembers(module, inspect.isclass)
def get_plugins(pkg, baseclass):
"""
Return a list of all modules under a given package.
- Modules must be a subclass of the provided 'baseclass'
- Modules must have a non-empty PLUGIN_NAME parameter
"""
plugins = []
modules = get_modules(pkg)
# Iterate through each module in the package
for mod in modules:
# Iterate through each class in the module
for item in get_classes(mod):
plugin = item[1]
if issubclass(plugin, baseclass) and plugin.PLUGIN_NAME:
plugins.append(plugin)
return plugins
def load_barcode_plugins():
"""
Return a list of all registered barcode plugins
"""
print("Loading barcode plugins")
plugins = get_plugins(barcode, BarcodePlugin)
if len(plugins) > 0:
print("Discovered {n} barcode plugins:".format(n=len(plugins)))
for bp in plugins:
print(" - {bp}".format(bp=bp.PLUGIN_NAME))
return plugins
def load_action_plugins():
"""
Return a list of all registered action plugins
"""
print("Loading action plugins")
plugins = get_plugins(action, ActionPlugin)
if len(plugins) > 0:
print("Discovered {n} action plugins:".format(n=len(plugins)))
for ap in plugins:
print(" - {ap}".format(ap=ap.PLUGIN_NAME))
return plugins

View File

@ -344,6 +344,7 @@ class StockList(generics.ListCreateAPIView):
data = queryset.values( data = queryset.values(
'pk', 'pk',
'uid',
'parent', 'parent',
'quantity', 'quantity',
'serial', 'serial',
@ -540,7 +541,7 @@ class StockList(generics.ListCreateAPIView):
'supplier_part', 'supplier_part',
'customer', 'customer',
'belongs_to', 'belongs_to',
'build' 'build',
] ]

View File

@ -0,0 +1,18 @@
# Generated by Django 2.2.10 on 2020-04-14 12:53
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('stock', '0025_auto_20200405_2243'),
]
operations = [
migrations.AddField(
model_name='stockitem',
name='uid',
field=models.CharField(blank=True, help_text='Unique identifier field', max_length=128),
),
]

View File

@ -44,11 +44,11 @@ class StockLocation(InvenTreeTree):
""" Return a JSON string for formatting a barcode for this StockLocation object """ """ Return a JSON string for formatting a barcode for this StockLocation object """
return helpers.MakeBarcode( return helpers.MakeBarcode(
'StockLocation', 'stocklocation',
self.id,
reverse('api-location-detail', kwargs={'pk': self.id}),
{ {
'name': self.name, "id": self.id,
"name": self.name,
"url": reverse('api-location-detail', kwargs={'pk': self.id}),
} }
) )
@ -108,6 +108,7 @@ class StockItem(MPTTModel):
Attributes: Attributes:
parent: Link to another StockItem from which this StockItem was created parent: Link to another StockItem from which this StockItem was created
uid: Field containing a unique-id which is mapped to a third-party identifier (e.g. a barcode)
part: Link to the master abstract part that this StockItem is an instance of part: Link to the master abstract part that this StockItem is an instance of
supplier_part: Link to a specific SupplierPart (optional) supplier_part: Link to a specific SupplierPart (optional)
location: Where this StockItem is located location: Where this StockItem is located
@ -288,15 +289,15 @@ class StockItem(MPTTModel):
""" """
return helpers.MakeBarcode( return helpers.MakeBarcode(
'StockItem', "stockitem",
self.id,
reverse('api-stock-detail', kwargs={'pk': self.id}),
{ {
'part_id': self.part.id, "id": self.id,
'part_name': self.part.full_name "url": reverse('api-stock-detail', kwargs={'pk': self.id}),
} }
) )
uid = models.CharField(blank=True, max_length=128, help_text=("Unique identifier field"))
parent = TreeForeignKey('self', parent = TreeForeignKey('self',
on_delete=models.DO_NOTHING, on_delete=models.DO_NOTHING,
blank=True, null=True, blank=True, null=True,

View File

@ -39,6 +39,7 @@ class StockItemSerializerBrief(InvenTreeModelSerializer):
model = StockItem model = StockItem
fields = [ fields = [
'pk', 'pk',
'uid',
'part', 'part',
'part_name', 'part_name',
'supplier_part', 'supplier_part',
@ -106,6 +107,7 @@ class StockItemSerializer(InvenTreeModelSerializer):
'status', 'status',
'status_text', 'status_text',
'tracking_items', 'tracking_items',
'uid',
'url', 'url',
] ]

View File

@ -85,7 +85,7 @@
</tr> </tr>
{% if item.belongs_to %} {% if item.belongs_to %}
<tr> <tr>
<td></td> <td><span class='fas fa-box'></span></td>
<td>{% trans "Belongs To" %}</td> <td>{% trans "Belongs To" %}</td>
<td><a href="{% url 'stock-item-detail' item.belongs_to.id %}">{{ item.belongs_to }}</a></td> <td><a href="{% url 'stock-item-detail' item.belongs_to.id %}">{{ item.belongs_to }}</a></td>
</tr> </tr>
@ -96,6 +96,13 @@
<td><a href="{% url 'stock-location-detail' item.location.id %}">{{ item.location.name }}</a></td> <td><a href="{% url 'stock-location-detail' item.location.id %}">{{ item.location.name }}</a></td>
</tr> </tr>
{% endif %} {% endif %}
{% if item.uid %}
<tr>
<td><span class='fas fa-barcode'></span></td>
<td>{% trans "Unique Identifier" %}</td>
<td>{{ item.uid }}</td>
</tr>
{% endif %}
{% if item.serialized %} {% if item.serialized %}
<tr> <tr>
<td></td> <td></td>

View File

@ -9,13 +9,7 @@ InvenTree | Index
<hr> <hr>
{% include "InvenTree/starred_parts.html" with collapse_id="starred" %} {% include "InvenTree/starred_parts.html" with collapse_id="starred" %}
{% if to_order %} {% include "InvenTree/low_stock.html" with collapse_id="order" %}
{% include "InvenTree/parts_to_order.html" with collapse_id="order" %}
{% endif %}
{% if to_build %}
{% include "InvenTree/parts_to_build.html" with collapse_id="build" %}
{% endif %}
{% endblock %} {% endblock %}
@ -25,15 +19,31 @@ InvenTree | Index
{% block js_ready %} {% block js_ready %}
console.log("abcde?");
{{ block.super }} {{ block.super }}
//TODO: These calls to bootstrapTable() are failing, for some reason? loadPartTable("#starred-parts-table", "{% url 'api-part-list' %}", {
//$("#to-build-table").bootstrapTable(); params: {
//$("#to-order-table").bootstrapTable(); "starred": true,
//$("#starred-parts-table").bootstrapTable(); }
});
loadPartTable("#low-stock-table", "{% url 'api-part-list' %}", {
params: {
"low_stock": true,
}
});
$("#starred-parts-table").on('load-success.bs.table', function() {
var count = $("#starred-parts-table").bootstrapTable('getData').length;
$("#starred-parts-count").html(count);
});
$("#low-stock-table").on('load-success.bs.table', function() {
var count = $("#low-stock-table").bootstrapTable('getData').length;
$("#low-stock-count").html(count);
});
console.log("Got to here...");
{% endblock %} {% endblock %}

View File

@ -0,0 +1,15 @@
{% extends "collapse.html" %}
{% load i18n %}
{% block collapse_title %}
<span class='fas fa-shopping-cart icon-header'></span>
{% trans "Low Stock" %}<span class='badge' id='low-stock-count'>0</span>
{% endblock %}
{% block collapse_content %}
<table class='table table-striped table-condensed' id='low-stock-table'>
</table>
{% endblock %}

View File

@ -1,15 +0,0 @@
{% extends "collapse.html" %}
{% block collapse_title %}
<span class='fas fa-shopping-cart icon-header'></span>
Parts to Order<span class='badge'>{{ to_order | length }}</span>
{% endblock %}
{% block collapse_heading %}
There are {{ to_order | length }} parts which need to be ordered.
{% endblock %}
{% block collapse_content %}
{% include "required_part_table.html" with parts=to_order table_id="to-order-table" %}
{% endblock %}

View File

@ -104,6 +104,7 @@ InvenTree | {% trans "Search Results" %}
], ],
}); });
$("#location-results-table").inventreeTable({ $("#location-results-table").inventreeTable({
url: "{% url 'api-location-list' %}", url: "{% url 'api-location-list' %}",
queryParams: { queryParams: {
@ -124,20 +125,22 @@ InvenTree | {% trans "Search Results" %}
], ],
}); });
loadPartTable("#part-results-table", loadPartTable("#part-results-table",
"{% url 'api-part-list' %}", "{% url 'api-part-list' %}",
{ {
query: { params: {
search: "{{ query }}", search: "{{ query }}",
}, },
allowInactive: true,
checkbox: false, checkbox: false,
disableFilters: true,
} }
); );
loadCompanyTable('#company-results-table', "{% url 'api-company-list' %}", { loadCompanyTable('#company-results-table', "{% url 'api-company-list' %}", {
params: { params: {
serach: "{{ query }}", search: "{{ query }}",
} }
}); });

View File

@ -9,6 +9,13 @@
{% endblock %} {% endblock %}
{% block collapse_content %} {% block collapse_content %}
<div id='button-toolbar'>
<div class='button-toolbar container-fluid' style='float: right;'>
<button class='btn btn-default'>Hello</button>
<div class='filter-list' id='filter-list-parts'></div>
</div>
</div>
<table class='table table-striped table-condensed' data-toolbar="#button-toolbar" id='part-results-table'> <table class='table table-striped table-condensed' data-toolbar="#button-toolbar" id='part-results-table'>
</table> </table>
{% endblock %} {% endblock %}

View File

@ -1,15 +1,15 @@
{% extends "collapse.html" %} {% extends "collapse.html" %}
{% load i18n %}
{% block collapse_title %} {% block collapse_title %}
<span class='fas fa-star icon-header'></span> <span class='fas fa-star icon-header'></span>
Starred Parts<span class='badge'>{{ starred | length }}</span> {% trans "Starred Parts" %}<span class='badge' id='starred-parts-count'>0</span>
{% endblock %}
{% block collapse_heading %}
You have {{ starred | length }} favourite parts
{% endblock %} {% endblock %}
{% block collapse_content %} {% block collapse_content %}
{% include "required_part_table.html" with parts=starred table_id="starred-parts-table" %} <table class='table tabe-striped table-condensed' id='starred-parts-table'>
</table>
{% endblock %} {% endblock %}

View File

@ -102,6 +102,7 @@ InvenTree
<script type='text/javascript' src="{% static 'script/inventree/inventree.js' %}"></script> <script type='text/javascript' src="{% static 'script/inventree/inventree.js' %}"></script>
<script type='text/javascript' src="{% static 'script/inventree/api.js' %}"></script> <script type='text/javascript' src="{% static 'script/inventree/api.js' %}"></script>
<script type='text/javascript' src="{% static 'script/inventree/part.js' %}"></script>
<script type='text/javascript' src="{% static 'script/inventree/bom.js' %}"></script> <script type='text/javascript' src="{% static 'script/inventree/bom.js' %}"></script>
<script type='text/javascript' src="{% static 'script/inventree/filters.js' %}"></script> <script type='text/javascript' src="{% static 'script/inventree/filters.js' %}"></script>
<script type='text/javascript' src="{% static 'script/inventree/tables.js' %}"></script> <script type='text/javascript' src="{% static 'script/inventree/tables.js' %}"></script>

View File

@ -89,6 +89,10 @@ function getAvailableTableFilters(tableKey) {
type: 'bool', type: 'bool',
title: '{% trans "Component" %}', title: '{% trans "Component" %}',
}, },
starred: {
type: 'bool',
title: '{% trans "Starred" %}',
},
}; };
} }

View File

@ -17,10 +17,16 @@ Requirements
To install InvenTree you will need the following system components installed: To install InvenTree you will need the following system components installed:
* python3 * python3
* python3-dev
* python3-pip (pip3) * python3-pip (pip3)
* g++
* make * make
Each of these programs need to be installed (e.g. using apt or similar) before running the ``make install`` script. Each of these programs need to be installed (e.g. using apt or similar) before running the ``make install`` script:
```
sudo apt-get install python3 python3-dev python3-pip g++ make
```
Virtual Environment Virtual Environment
------------------- -------------------

View File

@ -1,4 +1,5 @@
Django==2.2.10 # Django package wheel>=0.34.2 # Wheel
Django==2.2.10 # Django package
pillow==6.2.0 # Image manipulation pillow==6.2.0 # Image manipulation
djangorestframework==3.10.3 # DRF framework djangorestframework==3.10.3 # DRF framework
django-cors-headers==3.2.0 # CORS headers extension for DRF django-cors-headers==3.2.0 # CORS headers extension for DRF