mirror of
https://github.com/inventree/InvenTree.git
synced 2025-12-18 04:45:12 -06:00
Compare commits
33 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
13c7e2af49 | ||
|
|
85e20041c7 | ||
|
|
ff3cc96e0e | ||
|
|
746e9ab983 | ||
|
|
99fcbcc646 | ||
|
|
fee6246a8f | ||
|
|
4ec5e9a907 | ||
|
|
ef66a3b8f3 | ||
|
|
654f5d348e | ||
|
|
f5c86bc457 | ||
|
|
57fa69f6e6 | ||
|
|
c72fce0cc5 | ||
|
|
b2c40c91b7 | ||
|
|
0334035e77 | ||
|
|
4b1b9df193 | ||
|
|
6a89e0089d | ||
|
|
5233281a24 | ||
|
|
468eba1759 | ||
|
|
ff91c4ec53 | ||
|
|
3a64d0bc8f | ||
|
|
092215918c | ||
|
|
2621c51a7e | ||
|
|
69b8eed028 | ||
|
|
85d1c585c0 | ||
|
|
9cb1af9587 | ||
|
|
b580df0d30 | ||
|
|
d953f1a31e | ||
|
|
a28b7df9d4 | ||
|
|
880655c141 | ||
|
|
4f3f78f55a | ||
|
|
6e3f603413 | ||
|
|
2b70b947ee | ||
|
|
37fcb810e4 |
@@ -104,21 +104,23 @@ function removePurchaseOrderLineItem(e) {
|
||||
function loadPurchaseOrderTable(table, options) {
|
||||
/* Create a purchase-order table */
|
||||
|
||||
var params = options.params || {};
|
||||
options.params = options.params || {};
|
||||
|
||||
options.params['supplier_detail'] = true;
|
||||
|
||||
var filters = loadTableFilters("order");
|
||||
|
||||
for (var key in params) {
|
||||
filters[key] = params[key];
|
||||
for (var key in options.params) {
|
||||
filters[key] = options.params[key];
|
||||
}
|
||||
|
||||
setupFilterList("order", table);
|
||||
setupFilterList("order", $(table));
|
||||
|
||||
$(table).inventreeTable({
|
||||
url: options.url,
|
||||
queryParams: filters,
|
||||
groupBy: false,
|
||||
original: params,
|
||||
original: options.params,
|
||||
formatNoMatches: function() { return "No purchase orders found"; },
|
||||
columns: [
|
||||
{
|
||||
@@ -131,15 +133,15 @@ function loadPurchaseOrderTable(table, options) {
|
||||
field: 'reference',
|
||||
title: 'Purchase Order',
|
||||
formatter: function(value, row, index, field) {
|
||||
return renderLink(value, "/order/purchase-order/" + row.pk + "/");
|
||||
return renderLink(value, `/order/purchase-order/${row.pk}/`);
|
||||
}
|
||||
},
|
||||
{
|
||||
sortable: true,
|
||||
field: 'supplier',
|
||||
field: 'supplier_detail',
|
||||
title: 'Supplier',
|
||||
formatter: function(value, row, index, field) {
|
||||
return imageHoverIcon(row.supplier__image) + renderLink(row.supplier__name, '/company/' + value + '/purchase-orders/');
|
||||
return imageHoverIcon(row.supplier_detail.image) + renderLink(row.supplier_detail.name, `/company/${row.supplier}/purchase-orders/`);
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -162,7 +164,7 @@ function loadPurchaseOrderTable(table, options) {
|
||||
},
|
||||
{
|
||||
sortable: true,
|
||||
field: 'lines',
|
||||
field: 'line_items',
|
||||
title: 'Items'
|
||||
},
|
||||
],
|
||||
|
||||
@@ -87,6 +87,9 @@ function loadPartTable(table, url, options={}) {
|
||||
* disableFilters: If true, disable custom filters
|
||||
*/
|
||||
|
||||
// Ensure category detail is included
|
||||
options.params['category_detail'] = true;
|
||||
|
||||
var params = options.params || {};
|
||||
|
||||
var filters = {};
|
||||
@@ -184,11 +187,11 @@ function loadPartTable(table, url, options={}) {
|
||||
|
||||
columns.push({
|
||||
sortable: true,
|
||||
field: 'category__name',
|
||||
field: 'category_detail',
|
||||
title: 'Category',
|
||||
formatter: function(value, row, index, field) {
|
||||
if (row.category) {
|
||||
return renderLink(row.category__name, "/part/category/" + row.category + "/");
|
||||
return renderLink(value.pathstring, "/part/category/" + row.category + "/");
|
||||
}
|
||||
else {
|
||||
return 'No category';
|
||||
|
||||
@@ -43,8 +43,12 @@ function loadStockTable(table, options) {
|
||||
* filterList - <ul> element where filters are displayed
|
||||
* disableFilters: If true, disable custom filters
|
||||
*/
|
||||
|
||||
|
||||
// List of user-params which override the default filters
|
||||
|
||||
options.params['part_detail'] = true;
|
||||
options.params['location_detail'] = true;
|
||||
|
||||
var params = options.params || {};
|
||||
|
||||
var filterListElement = options.filterList || "#filter-list-stock";
|
||||
@@ -83,27 +87,21 @@ function loadStockTable(table, options) {
|
||||
|
||||
var row = data[0];
|
||||
|
||||
if (field == 'part__name') {
|
||||
if (field == 'part_name') {
|
||||
|
||||
var name = row.part__IPN;
|
||||
var name = row.part_detail.full_name;
|
||||
|
||||
if (name) {
|
||||
name += ' | ';
|
||||
}
|
||||
|
||||
name += row.part__name;
|
||||
|
||||
return imageHoverIcon(row.part__thumbnail) + name + ' <i>(' + data.length + ' items)</i>';
|
||||
return imageHoverIcon(row.part_detail.thumbnail) + name + ' <i>(' + data.length + ' items)</i>';
|
||||
}
|
||||
else if (field == 'part__description') {
|
||||
return row.part__description;
|
||||
else if (field == 'part_description') {
|
||||
return row.part_detail.description;
|
||||
}
|
||||
else if (field == 'quantity') {
|
||||
var stock = 0;
|
||||
var items = 0;
|
||||
|
||||
data.forEach(function(item) {
|
||||
stock += item.quantity;
|
||||
stock += parseFloat(item.quantity);
|
||||
items += 1;
|
||||
});
|
||||
|
||||
@@ -216,25 +214,14 @@ function loadStockTable(table, options) {
|
||||
visible: false,
|
||||
},
|
||||
{
|
||||
field: 'part__name',
|
||||
field: 'part_name',
|
||||
title: 'Part',
|
||||
sortable: true,
|
||||
formatter: function(value, row, index, field) {
|
||||
|
||||
var name = row.part__IPN;
|
||||
|
||||
if (name) {
|
||||
name += ' | ';
|
||||
}
|
||||
|
||||
name += row.part__name;
|
||||
|
||||
if (row.part__revision) {
|
||||
name += " | ";
|
||||
name += row.part__revision;
|
||||
}
|
||||
|
||||
var url = '';
|
||||
var thumb = row.part_detail.thumbnail;
|
||||
var name = row.part_detail.full_name;
|
||||
|
||||
if (row.supplier_part) {
|
||||
url = `/supplier-part/${row.supplier_part}/`;
|
||||
@@ -242,13 +229,16 @@ function loadStockTable(table, options) {
|
||||
url = `/part/${row.part}/`;
|
||||
}
|
||||
|
||||
return imageHoverIcon(row.part__thumbnail) + renderLink(name, url);
|
||||
return imageHoverIcon(thumb) + renderLink(name, url);
|
||||
}
|
||||
},
|
||||
{
|
||||
field: 'part__description',
|
||||
field: 'part_description',
|
||||
title: 'Description',
|
||||
sortable: true,
|
||||
formatter: function(value, row, index, field) {
|
||||
return row.part_detail.description;
|
||||
}
|
||||
},
|
||||
{
|
||||
field: 'quantity',
|
||||
@@ -256,11 +246,13 @@ function loadStockTable(table, options) {
|
||||
sortable: true,
|
||||
formatter: function(value, row, index, field) {
|
||||
|
||||
var val = value;
|
||||
var val = parseFloat(value);
|
||||
|
||||
// If there is a single unit with a serial number, use the serial number
|
||||
if (row.serial && row.quantity == 1) {
|
||||
val = '# ' + row.serial;
|
||||
} else {
|
||||
val = +val.toFixed(5);
|
||||
}
|
||||
|
||||
var text = renderLink(val, '/stock/item/' + row.pk + '/');
|
||||
@@ -282,7 +274,7 @@ function loadStockTable(table, options) {
|
||||
sortable: true,
|
||||
},
|
||||
{
|
||||
field: 'location__path',
|
||||
field: 'location_detail.pathstring',
|
||||
title: 'Location',
|
||||
sortable: true,
|
||||
formatter: function(value, row, index, field) {
|
||||
|
||||
@@ -4,9 +4,10 @@ from rest_framework.test import APITestCase
|
||||
from rest_framework import status
|
||||
|
||||
from django.urls import reverse
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
|
||||
from base64 import b64encode
|
||||
|
||||
|
||||
class APITests(APITestCase):
|
||||
""" Tests for the InvenTree API """
|
||||
@@ -21,24 +22,48 @@ class APITests(APITestCase):
|
||||
username = 'test_user'
|
||||
password = 'test_pass'
|
||||
|
||||
token = None
|
||||
|
||||
def setUp(self):
|
||||
|
||||
# Create a user (but do not log in!)
|
||||
User = get_user_model()
|
||||
User.objects.create_user(self.username, 'user@email.com', self.password)
|
||||
|
||||
def get_token(self):
|
||||
def basicAuth(self):
|
||||
# Use basic authentication
|
||||
|
||||
authstring = bytes("{u}:{p}".format(u=self.username, p=self.password), "ascii")
|
||||
|
||||
# Use "basic" auth by default
|
||||
auth = b64encode(authstring).decode("ascii")
|
||||
self.client.credentials(HTTP_AUTHORIZATION="Basic {auth}".format(auth=auth))
|
||||
|
||||
def tokenAuth(self):
|
||||
|
||||
self.basicAuth()
|
||||
token_url = reverse('api-token')
|
||||
response = self.client.get(token_url, format='json', data={})
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertIn('token', response.data)
|
||||
|
||||
# POST to retreive a token
|
||||
response = self.client.post(token_url, format='json', data={'username': self.username, 'password': self.password})
|
||||
|
||||
token = response.data['token']
|
||||
|
||||
self.client.credentials(HTTP_AUTHORIZATION='Token ' + token)
|
||||
|
||||
self.token = token
|
||||
|
||||
def token_failure(self):
|
||||
# Test token endpoint without basic auth
|
||||
url = reverse('api-token')
|
||||
response = self.client.get(url, format='json')
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||
self.assertIsNone(self.token)
|
||||
|
||||
def token_success(self):
|
||||
|
||||
self.tokenAuth()
|
||||
self.assertIsNotNone(self.token)
|
||||
|
||||
def test_info_view(self):
|
||||
"""
|
||||
Test that we can read the 'info-view' endpoint.
|
||||
@@ -55,51 +80,18 @@ class APITests(APITestCase):
|
||||
|
||||
self.assertEquals('InvenTree', data['server'])
|
||||
|
||||
def test_get_token_fail(self):
|
||||
""" Ensure that an invalid user cannot get a token """
|
||||
|
||||
token_url = reverse('api-token')
|
||||
|
||||
response = self.client.post(token_url, format='json', data={'username': 'bad', 'password': 'also_bad'})
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
self.assertFalse('token' in response.data)
|
||||
|
||||
def test_get_token_pass(self):
|
||||
""" Ensure that a valid user can request an API token """
|
||||
|
||||
token_url = reverse('api-token')
|
||||
|
||||
# POST to retreive a token
|
||||
response = self.client.post(token_url, format='json', data={'username': self.username, 'password': self.password})
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertTrue('token' in response.data)
|
||||
self.assertTrue('pk' in response.data)
|
||||
self.assertTrue(len(response.data['token']) > 0)
|
||||
|
||||
# Now, use the token to access other data
|
||||
token = response.data['token']
|
||||
|
||||
part_url = reverse('api-part-list')
|
||||
|
||||
# Try to access without a token
|
||||
response = self.client.get(part_url, format='json')
|
||||
def test_barcode_fail(self):
|
||||
# Test barcode endpoint without auth
|
||||
response = self.client.post(reverse('api-barcode-plugin'), format='json')
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||
|
||||
# Now, with the token
|
||||
self.client.credentials(HTTP_AUTHORIZATION='Token ' + token)
|
||||
response = self.client.get(part_url, format='json')
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
|
||||
def test_barcode(self):
|
||||
""" Test the barcode endpoint """
|
||||
|
||||
url = reverse('api-barcode-plugin')
|
||||
self.tokenAuth()
|
||||
|
||||
self.get_token()
|
||||
url = reverse('api-barcode-plugin')
|
||||
|
||||
data = {
|
||||
'barcode': {
|
||||
|
||||
@@ -4,8 +4,9 @@ Provides information on the current InvenTree version
|
||||
|
||||
import subprocess
|
||||
from common.models import InvenTreeSetting
|
||||
import django
|
||||
|
||||
INVENTREE_SW_VERSION = "0.0.11_pre"
|
||||
INVENTREE_SW_VERSION = "0.0.12"
|
||||
|
||||
|
||||
def inventreeInstanceName():
|
||||
@@ -18,6 +19,11 @@ def inventreeVersion():
|
||||
return INVENTREE_SW_VERSION
|
||||
|
||||
|
||||
def inventreeDjangoVersion():
|
||||
""" Return the version of Django library """
|
||||
return django.get_version()
|
||||
|
||||
|
||||
def inventreeCommitHash():
|
||||
""" Returns the git commit hash for the running codebase """
|
||||
|
||||
|
||||
@@ -31,6 +31,11 @@ class CompanyConfig(AppConfig):
|
||||
|
||||
if not os.path.exists(loc):
|
||||
print("InvenTree: Generating thumbnail for Company '{c}'".format(c=company.name))
|
||||
company.image.render_variations(replace=False)
|
||||
try:
|
||||
company.image.render_variations(replace=False)
|
||||
except FileNotFoundError:
|
||||
print("Image file missing")
|
||||
company.image = None
|
||||
company.save()
|
||||
except (OperationalError, ProgrammingError):
|
||||
print("Could not generate Company thumbnails")
|
||||
|
||||
@@ -42,12 +42,12 @@ cors:
|
||||
# - https://sub.example.com
|
||||
|
||||
# MEDIA_ROOT is the local filesystem location for storing uploaded files
|
||||
# By default, it is stored in a directory named 'media' local to the InvenTree directory
|
||||
# By default, it is stored in a directory named 'inventree_media' local to the InvenTree directory
|
||||
# This should be changed for a production installation
|
||||
media_root: '../inventree_media'
|
||||
|
||||
# STATIC_ROOT is the local filesystem location for storing static files
|
||||
# By default it is stored in a directory named 'static' local to the InvenTree directory
|
||||
# By default it is stored in a directory named 'inventree_static' local to the InvenTree directory
|
||||
static_root: '../inventree_static'
|
||||
|
||||
# Optional URL schemes to allow in URL fields
|
||||
|
||||
@@ -8,14 +8,10 @@ from __future__ import unicode_literals
|
||||
from django_filters.rest_framework import DjangoFilterBackend
|
||||
from rest_framework import generics, permissions
|
||||
from rest_framework import filters
|
||||
from rest_framework.response import Response
|
||||
|
||||
from django.conf import settings
|
||||
from django.conf.urls import url
|
||||
|
||||
from InvenTree.status_codes import OrderStatus
|
||||
|
||||
import os
|
||||
from InvenTree.helpers import str2bool
|
||||
|
||||
from part.models import Part
|
||||
from company.models import SupplierPart
|
||||
@@ -34,66 +30,66 @@ class POList(generics.ListCreateAPIView):
|
||||
queryset = PurchaseOrder.objects.all()
|
||||
serializer_class = POSerializer
|
||||
|
||||
def list(self, request, *args, **kwargs):
|
||||
def get_serializer(self, *args, **kwargs):
|
||||
|
||||
queryset = self.get_queryset().prefetch_related('supplier', 'lines')
|
||||
try:
|
||||
kwargs['supplier_detail'] = str2bool(self.request.query_params.get('supplier_detail', False))
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
queryset = self.filter_queryset(queryset)
|
||||
# Ensure the request context is passed through
|
||||
kwargs['context'] = self.get_serializer_context()
|
||||
|
||||
return self.serializer_class(*args, **kwargs)
|
||||
|
||||
def get_queryset(self, *args, **kwargs):
|
||||
|
||||
queryset = super().get_queryset(*args, **kwargs)
|
||||
|
||||
queryset = queryset.prefetch_related(
|
||||
'supplier',
|
||||
'lines',
|
||||
)
|
||||
|
||||
queryset = POSerializer.annotate_queryset(queryset)
|
||||
|
||||
return queryset
|
||||
|
||||
def filter_queryset(self, queryset):
|
||||
|
||||
# Perform basic filtering
|
||||
queryset = super().filter_queryset(queryset)
|
||||
|
||||
params = self.request.query_params
|
||||
|
||||
# Special filtering for 'status' field
|
||||
if 'status' in request.GET:
|
||||
status = request.GET['status']
|
||||
status = params.get('status', None)
|
||||
|
||||
if status is not None:
|
||||
# First attempt to filter by integer value
|
||||
try:
|
||||
status = int(status)
|
||||
queryset = queryset.filter(status=status)
|
||||
except ValueError:
|
||||
try:
|
||||
value = OrderStatus.value(status)
|
||||
queryset = queryset.filter(status=value)
|
||||
except ValueError:
|
||||
pass
|
||||
queryset = queryset.filter(status=status)
|
||||
|
||||
# Attempt to filter by part
|
||||
if 'part' in request.GET:
|
||||
part = params.get('part', None)
|
||||
|
||||
if part is not None:
|
||||
try:
|
||||
part = Part.objects.get(pk=request.GET['part'])
|
||||
part = Part.objects.get(pk=part)
|
||||
queryset = queryset.filter(id__in=[p.id for p in part.purchase_orders()])
|
||||
except (Part.DoesNotExist, ValueError):
|
||||
pass
|
||||
|
||||
# Attempt to filter by supplier part
|
||||
if 'supplier_part' in request.GET:
|
||||
supplier_part = params.get('supplier_part', None)
|
||||
|
||||
if supplier_part is not None:
|
||||
try:
|
||||
supplier_part = SupplierPart.objects.get(pk=request.GET['supplier_part'])
|
||||
supplier_part = SupplierPart.objects.get(pk=supplier_part)
|
||||
queryset = queryset.filter(id__in=[p.id for p in supplier_part.purchase_orders()])
|
||||
except (ValueError, SupplierPart.DoesNotExist):
|
||||
pass
|
||||
|
||||
data = queryset.values(
|
||||
'pk',
|
||||
'supplier',
|
||||
'supplier_reference',
|
||||
'supplier__name',
|
||||
'supplier__image',
|
||||
'reference',
|
||||
'description',
|
||||
'link',
|
||||
'status',
|
||||
'notes',
|
||||
'creation_date',
|
||||
)
|
||||
|
||||
for item in data:
|
||||
|
||||
order = queryset.get(pk=item['pk'])
|
||||
|
||||
item['supplier__image'] = os.path.join(settings.MEDIA_URL, item['supplier__image'])
|
||||
item['status_text'] = OrderStatus.label(item['status'])
|
||||
item['lines'] = order.lines.count()
|
||||
|
||||
return Response(data)
|
||||
return queryset
|
||||
|
||||
permission_classes = [
|
||||
permissions.IsAuthenticated,
|
||||
@@ -123,6 +119,31 @@ class PODetail(generics.RetrieveUpdateAPIView):
|
||||
queryset = PurchaseOrder.objects.all()
|
||||
serializer_class = POSerializer
|
||||
|
||||
def get_serializer(self, *args, **kwargs):
|
||||
|
||||
try:
|
||||
kwargs['supplier_detail'] = str2bool(self.request.query_params.get('supplier_detail', False))
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
# Ensure the request context is passed through
|
||||
kwargs['context'] = self.get_serializer_context()
|
||||
|
||||
return self.serializer_class(*args, **kwargs)
|
||||
|
||||
def get_queryset(self, *args, **kwargs):
|
||||
|
||||
queryset = super().get_queryset(*args, **kwargs)
|
||||
|
||||
queryset = queryset.prefetch_related(
|
||||
'supplier',
|
||||
'lines',
|
||||
)
|
||||
|
||||
queryset = POSerializer.annotate_queryset(queryset)
|
||||
|
||||
return queryset
|
||||
|
||||
permission_classes = [
|
||||
permissions.IsAuthenticated
|
||||
]
|
||||
|
||||
@@ -5,7 +5,12 @@ JSON serializers for the Order API
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from rest_framework import serializers
|
||||
|
||||
from django.db.models import Count
|
||||
|
||||
from InvenTree.serializers import InvenTreeModelSerializer
|
||||
from company.serializers import CompanyBriefSerializer
|
||||
|
||||
from .models import PurchaseOrder, PurchaseOrderLineItem
|
||||
|
||||
@@ -13,17 +18,48 @@ from .models import PurchaseOrder, PurchaseOrderLineItem
|
||||
class POSerializer(InvenTreeModelSerializer):
|
||||
""" Serializes an Order object """
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
|
||||
supplier_detail = kwargs.pop('supplier_detail', False)
|
||||
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
if supplier_detail is not True:
|
||||
self.fields.pop('supplier_detail')
|
||||
|
||||
@staticmethod
|
||||
def annotate_queryset(queryset):
|
||||
"""
|
||||
Add extra information to the queryset
|
||||
"""
|
||||
|
||||
return queryset.annotate(
|
||||
line_items=Count('lines'),
|
||||
)
|
||||
|
||||
supplier_detail = CompanyBriefSerializer(source='supplier', many=False, read_only=True)
|
||||
|
||||
line_items = serializers.IntegerField(read_only=True)
|
||||
|
||||
status_text = serializers.CharField(source='get_status_display', read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = PurchaseOrder
|
||||
|
||||
fields = [
|
||||
'pk',
|
||||
'supplier',
|
||||
'supplier_reference',
|
||||
'reference',
|
||||
'issue_date',
|
||||
'complete_date',
|
||||
'creation_date',
|
||||
'description',
|
||||
'line_items',
|
||||
'link',
|
||||
'reference',
|
||||
'supplier',
|
||||
'supplier_detail',
|
||||
'supplier_reference',
|
||||
'status',
|
||||
'status_text',
|
||||
'notes',
|
||||
]
|
||||
|
||||
|
||||
@@ -6,10 +6,8 @@ Provides a JSON API for the Part app
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django_filters.rest_framework import DjangoFilterBackend
|
||||
from django.conf import settings
|
||||
|
||||
from django.db.models import Q, F, Sum, Count
|
||||
from django.db.models.functions import Coalesce
|
||||
from django.db.models import Q, F, Count
|
||||
|
||||
from rest_framework import status
|
||||
from rest_framework.response import Response
|
||||
@@ -19,15 +17,11 @@ from rest_framework import generics, permissions
|
||||
from django.conf.urls import url, include
|
||||
from django.urls import reverse
|
||||
|
||||
import os
|
||||
from decimal import Decimal
|
||||
|
||||
from .models import Part, PartCategory, BomItem, PartStar
|
||||
from .models import PartParameter, PartParameterTemplate
|
||||
|
||||
from . import serializers as part_serializers
|
||||
|
||||
from InvenTree.status_codes import OrderStatus, StockStatus, BuildStatus
|
||||
from InvenTree.views import TreeSerializer
|
||||
from InvenTree.helpers import str2bool, isNull
|
||||
|
||||
@@ -125,6 +119,8 @@ class PartThumbs(generics.ListAPIView):
|
||||
# Get all Parts which have an associated image
|
||||
queryset = Part.objects.all().exclude(image='')
|
||||
|
||||
# TODO - We should return the thumbnails here, not the full image!
|
||||
|
||||
# Return the most popular parts first
|
||||
data = queryset.values(
|
||||
'image',
|
||||
@@ -138,11 +134,41 @@ class PartDetail(generics.RetrieveUpdateAPIView):
|
||||
|
||||
queryset = Part.objects.all()
|
||||
serializer_class = part_serializers.PartSerializer
|
||||
|
||||
starred_parts = None
|
||||
|
||||
def get_queryset(self, *args, **kwargs):
|
||||
queryset = super().get_queryset(*args, **kwargs)
|
||||
|
||||
queryset = part_serializers.PartSerializer.prefetch_queryset(queryset)
|
||||
queryset = part_serializers.PartSerializer.annotate_queryset(queryset)
|
||||
return queryset
|
||||
|
||||
permission_classes = [
|
||||
permissions.IsAuthenticated,
|
||||
]
|
||||
|
||||
def get_serializer(self, *args, **kwargs):
|
||||
|
||||
try:
|
||||
cat_detail = str2bool(self.request.query_params.get('category_detail', False))
|
||||
except AttributeError:
|
||||
cat_detail = None
|
||||
|
||||
# Ensure the request context is passed through
|
||||
kwargs['context'] = self.get_serializer_context()
|
||||
|
||||
kwargs['category_detail'] = cat_detail
|
||||
|
||||
# Pass a list of "starred" parts fo the current user to the serializer
|
||||
# We do this to reduce the number of database queries required!
|
||||
if self.starred_parts is None and self.request is not None:
|
||||
self.starred_parts = [star.part for star in self.request.user.starred_parts.all()]
|
||||
|
||||
kwargs['starred_parts'] = self.starred_parts
|
||||
|
||||
return self.serializer_class(*args, **kwargs)
|
||||
|
||||
|
||||
class PartList(generics.ListCreateAPIView):
|
||||
""" API endpoint for accessing a list of Part objects
|
||||
@@ -166,6 +192,31 @@ class PartList(generics.ListCreateAPIView):
|
||||
|
||||
serializer_class = part_serializers.PartSerializer
|
||||
|
||||
queryset = Part.objects.all()
|
||||
|
||||
starred_parts = None
|
||||
|
||||
def get_serializer(self, *args, **kwargs):
|
||||
|
||||
try:
|
||||
cat_detail = str2bool(self.request.query_params.get('category_detail', False))
|
||||
except AttributeError:
|
||||
cat_detail = None
|
||||
|
||||
# Ensure the request context is passed through
|
||||
kwargs['context'] = self.get_serializer_context()
|
||||
|
||||
kwargs['category_detail'] = cat_detail
|
||||
|
||||
# Pass a list of "starred" parts fo the current user to the serializer
|
||||
# We do this to reduce the number of database queries required!
|
||||
if self.starred_parts is None and self.request is not None:
|
||||
self.starred_parts = [star.part for star in self.request.user.starred_parts.all()]
|
||||
|
||||
kwargs['starred_parts'] = self.starred_parts
|
||||
|
||||
return self.serializer_class(*args, **kwargs)
|
||||
|
||||
def create(self, request, *args, **kwargs):
|
||||
""" Override the default 'create' behaviour:
|
||||
We wish to save the user who created this part!
|
||||
@@ -184,129 +235,20 @@ class PartList(generics.ListCreateAPIView):
|
||||
headers = self.get_success_headers(serializer.data)
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
|
||||
|
||||
def list(self, request, *args, **kwargs):
|
||||
def get_queryset(self, *args, **kwargs):
|
||||
|
||||
queryset = super().get_queryset(*args, **kwargs)
|
||||
queryset = part_serializers.PartSerializer.prefetch_queryset(queryset)
|
||||
|
||||
return queryset
|
||||
|
||||
def filter_queryset(self, queryset):
|
||||
"""
|
||||
Instead of using the DRF serialiser to LIST,
|
||||
we serialize the objects manually.
|
||||
This turns out to be significantly faster.
|
||||
Perform custom filtering of the queryset
|
||||
"""
|
||||
|
||||
queryset = self.filter_queryset(self.get_queryset())
|
||||
|
||||
# Filters for annotations
|
||||
|
||||
# "in_stock" count should only sum stock items which are "in stock"
|
||||
stock_filter = Q(stock_items__status__in=StockStatus.AVAILABLE_CODES)
|
||||
|
||||
# "on_order" items should only sum orders which are currently outstanding
|
||||
order_filter = Q(supplier_parts__purchase_order_line_items__order__status__in=OrderStatus.OPEN)
|
||||
|
||||
# "building" should only reference builds which are active
|
||||
build_filter = Q(builds__status__in=BuildStatus.ACTIVE_CODES)
|
||||
|
||||
# Set of fields we wish to serialize
|
||||
data = queryset.values(
|
||||
'pk',
|
||||
'category',
|
||||
'image',
|
||||
'name',
|
||||
'IPN',
|
||||
'revision',
|
||||
'description',
|
||||
'keywords',
|
||||
'is_template',
|
||||
'link',
|
||||
'units',
|
||||
'minimum_stock',
|
||||
'trackable',
|
||||
'assembly',
|
||||
'component',
|
||||
'salable',
|
||||
'active',
|
||||
).annotate(
|
||||
# Quantity of items which are "in stock"
|
||||
in_stock=Coalesce(Sum('stock_items__quantity', filter=stock_filter), Decimal(0)),
|
||||
on_order=Coalesce(Sum('supplier_parts__purchase_order_line_items__quantity', filter=order_filter), Decimal(0)),
|
||||
building=Coalesce(Sum('builds__quantity', filter=build_filter), Decimal(0)),
|
||||
)
|
||||
|
||||
# If we are filtering by 'has_stock' status
|
||||
has_stock = self.request.query_params.get('has_stock', None)
|
||||
|
||||
if has_stock is not None:
|
||||
has_stock = str2bool(has_stock)
|
||||
|
||||
if has_stock:
|
||||
# Filter items which have a non-null 'in_stock' quantity above zero
|
||||
data = data.filter(in_stock__gt=0)
|
||||
else:
|
||||
# Filter items which a null or zero 'in_stock' quantity
|
||||
data = data.filter(Q(in_stock__lte=0))
|
||||
|
||||
# If we are filtering by 'low_stock' status
|
||||
low_stock = self.request.query_params.get('low_stock', None)
|
||||
|
||||
if low_stock is not None:
|
||||
low_stock = str2bool(low_stock)
|
||||
|
||||
if low_stock:
|
||||
# Ignore any parts which do not have a specified 'minimum_stock' level
|
||||
data = data.exclude(minimum_stock=0)
|
||||
# Filter items which have an 'in_stock' level lower than 'minimum_stock'
|
||||
data = data.filter(Q(in_stock__lt=F('minimum_stock')))
|
||||
else:
|
||||
# Filter items which have an 'in_stock' level higher than '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
|
||||
categories = {}
|
||||
|
||||
for item in data:
|
||||
|
||||
if item['image']:
|
||||
# Is this part 'starred' for the current user?
|
||||
item['starred'] = item['pk'] in starred_parts
|
||||
|
||||
img = item['image']
|
||||
|
||||
# Use the 'thumbnail' image here instead of the full-size image
|
||||
# Note: The full-size image is used when requesting the /api/part/<x>/ endpoint
|
||||
|
||||
if img:
|
||||
fn, ext = os.path.splitext(img)
|
||||
|
||||
thumb = "{fn}.thumbnail{ext}".format(fn=fn, ext=ext)
|
||||
|
||||
thumb = os.path.join(settings.MEDIA_URL, thumb)
|
||||
else:
|
||||
thumb = ''
|
||||
|
||||
item['thumbnail'] = thumb
|
||||
|
||||
del item['image']
|
||||
|
||||
cat_id = item['category']
|
||||
|
||||
if cat_id:
|
||||
if cat_id not in categories:
|
||||
categories[cat_id] = PartCategory.objects.get(pk=cat_id).pathstring
|
||||
|
||||
item['category__name'] = categories[cat_id]
|
||||
else:
|
||||
item['category__name'] = None
|
||||
|
||||
return Response(data)
|
||||
|
||||
def get_queryset(self):
|
||||
"""
|
||||
Implement custom filtering for the Part list API
|
||||
"""
|
||||
|
||||
# Start with all objects
|
||||
parts_list = Part.objects.all()
|
||||
# Perform basic filtering
|
||||
queryset = super().filter_queryset(queryset)
|
||||
|
||||
# Filter by 'starred' parts?
|
||||
starred = str2bool(self.request.query_params.get('starred', None))
|
||||
@@ -315,12 +257,13 @@ class PartList(generics.ListCreateAPIView):
|
||||
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)
|
||||
queryset = queryset.filter(pk__in=starred_parts)
|
||||
else:
|
||||
parts_list = parts_list.exclude(pk__in=starred_parts)
|
||||
queryset = queryset.exclude(pk__in=starred_parts)
|
||||
|
||||
# Cascade?
|
||||
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)
|
||||
|
||||
@@ -334,7 +277,7 @@ class PartList(generics.ListCreateAPIView):
|
||||
# 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)
|
||||
queryset = queryset.filter(category=None)
|
||||
|
||||
else:
|
||||
try:
|
||||
@@ -342,17 +285,43 @@ class PartList(generics.ListCreateAPIView):
|
||||
|
||||
# If '?cascade=true' then include parts which exist in sub-categories
|
||||
if cascade:
|
||||
parts_list = parts_list.filter(category__in=category.getUniqueChildren())
|
||||
queryset = queryset.filter(category__in=category.getUniqueChildren())
|
||||
# Just return parts directly in the requested category
|
||||
else:
|
||||
parts_list = parts_list.filter(category=cat_id)
|
||||
queryset = queryset.filter(category=cat_id)
|
||||
except (ValueError, PartCategory.DoesNotExist):
|
||||
pass
|
||||
|
||||
# Ensure that related models are pre-loaded to reduce DB trips
|
||||
parts_list = self.get_serializer_class().setup_eager_loading(parts_list)
|
||||
# Annotate calculated data to the queryset
|
||||
# (This will be used for further filtering)
|
||||
queryset = part_serializers.PartSerializer.annotate_queryset(queryset)
|
||||
|
||||
return parts_list
|
||||
# Filter by whether the part has stock
|
||||
has_stock = self.request.query_params.get("has_stock", None)
|
||||
if has_stock is not None:
|
||||
has_stock = str2bool(has_stock)
|
||||
|
||||
if has_stock:
|
||||
queryset = queryset.filter(Q(in_stock__gt=0))
|
||||
else:
|
||||
queryset = queryset.filter(Q(in_stock__lte=0))
|
||||
|
||||
# If we are filtering by 'low_stock' status
|
||||
low_stock = self.request.query_params.get('low_stock', None)
|
||||
|
||||
if low_stock is not None:
|
||||
low_stock = str2bool(low_stock)
|
||||
|
||||
if low_stock:
|
||||
# Ignore any parts which do not have a specified 'minimum_stock' level
|
||||
queryset = queryset.exclude(minimum_stock=0)
|
||||
# Filter items which have an 'in_stock' level lower than 'minimum_stock'
|
||||
queryset = queryset.filter(Q(in_stock__lt=F('minimum_stock')))
|
||||
else:
|
||||
# Filter items which have an 'in_stock' level higher than 'minimum_stock'
|
||||
queryset = queryset.filter(Q(in_stock__gte=F('minimum_stock')))
|
||||
|
||||
return queryset
|
||||
|
||||
permission_classes = [
|
||||
permissions.IsAuthenticated,
|
||||
@@ -379,6 +348,7 @@ class PartList(generics.ListCreateAPIView):
|
||||
'name',
|
||||
]
|
||||
|
||||
# Default ordering
|
||||
ordering = 'name'
|
||||
|
||||
search_fields = [
|
||||
@@ -507,7 +477,9 @@ class BomList(generics.ListCreateAPIView):
|
||||
kwargs['part_detail'] = part_detail
|
||||
kwargs['sub_part_detail'] = sub_part_detail
|
||||
|
||||
# Ensure the request context is passed through!
|
||||
kwargs['context'] = self.get_serializer_context()
|
||||
|
||||
return self.serializer_class(*args, **kwargs)
|
||||
|
||||
def get_queryset(self):
|
||||
|
||||
@@ -30,6 +30,11 @@ class PartConfig(AppConfig):
|
||||
|
||||
if not os.path.exists(loc):
|
||||
print("InvenTree: Generating thumbnail for Part '{p}'".format(p=part.name))
|
||||
part.image.render_variations(replace=False)
|
||||
try:
|
||||
part.image.render_variations(replace=False)
|
||||
except FileNotFoundError:
|
||||
print("Image file missing")
|
||||
part.image = None
|
||||
part.save()
|
||||
except (OperationalError, ProgrammingError):
|
||||
print("Could not generate Part thumbnails")
|
||||
|
||||
@@ -16,7 +16,14 @@ def create_thumbnails(apps, schema_editor):
|
||||
for part in Part.objects.all():
|
||||
# Render thumbnail for each existing Part
|
||||
if part.image:
|
||||
part.image.render_variations()
|
||||
try:
|
||||
part.image.render_variations()
|
||||
except FileNotFoundError:
|
||||
print("Missing image:", part.image())
|
||||
# The image is missing, so clear the field
|
||||
part.image = None
|
||||
part.save()
|
||||
|
||||
except (OperationalError, ProgrammingError):
|
||||
# Migrations have not yet been applied - table does not exist
|
||||
print("Could not generate Part thumbnails")
|
||||
|
||||
@@ -10,6 +10,12 @@ from .models import PartCategory
|
||||
from .models import BomItem
|
||||
from .models import PartParameter, PartParameterTemplate
|
||||
|
||||
from decimal import Decimal
|
||||
|
||||
from django.db.models import Q, Sum
|
||||
from django.db.models.functions import Coalesce
|
||||
|
||||
from InvenTree.status_codes import StockStatus, OrderStatus, BuildStatus
|
||||
from InvenTree.serializers import InvenTreeModelSerializer
|
||||
|
||||
|
||||
@@ -48,14 +54,6 @@ class PartBriefSerializer(InvenTreeModelSerializer):
|
||||
|
||||
url = serializers.CharField(source='get_absolute_url', read_only=True)
|
||||
thumbnail = serializers.CharField(source='get_thumbnail_url', read_only=True)
|
||||
|
||||
@staticmethod
|
||||
def setup_eager_loading(queryset):
|
||||
queryset = queryset.prefetch_related('category')
|
||||
queryset = queryset.prefetch_related('stock_items')
|
||||
queryset = queryset.prefetch_related('bom_items')
|
||||
queryset = queryset.prefetch_related('builds')
|
||||
return queryset
|
||||
|
||||
class Meta:
|
||||
model = Part
|
||||
@@ -64,8 +62,6 @@ class PartBriefSerializer(InvenTreeModelSerializer):
|
||||
'url',
|
||||
'full_name',
|
||||
'description',
|
||||
'total_stock',
|
||||
'available_stock',
|
||||
'thumbnail',
|
||||
'active',
|
||||
'assembly',
|
||||
@@ -78,57 +74,140 @@ class PartSerializer(InvenTreeModelSerializer):
|
||||
Used when displaying all details of a single component.
|
||||
"""
|
||||
|
||||
allocated_stock = serializers.FloatField(source='allocation_count', read_only=True)
|
||||
bom_items = serializers.IntegerField(source='bom_count', read_only=True)
|
||||
building = serializers.FloatField(source='quantity_being_built', read_only=False)
|
||||
category_name = serializers.CharField(source='category_path', read_only=True)
|
||||
image = serializers.CharField(source='get_image_url', read_only=True)
|
||||
on_order = serializers.FloatField(read_only=True)
|
||||
thumbnail = serializers.CharField(source='get_thumbnail_url', read_only=True)
|
||||
url = serializers.CharField(source='get_absolute_url', read_only=True)
|
||||
used_in = serializers.IntegerField(source='used_in_count', read_only=True)
|
||||
def __init__(self, *args, **kwargs):
|
||||
"""
|
||||
Custom initialization method for PartSerializer,
|
||||
so that we can optionally pass extra fields based on the query.
|
||||
"""
|
||||
|
||||
self.starred_parts = kwargs.pop('starred_parts', [])
|
||||
|
||||
category_detail = kwargs.pop('category_detail', False)
|
||||
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
if category_detail is not True:
|
||||
self.fields.pop('category_detail')
|
||||
|
||||
@staticmethod
|
||||
def setup_eager_loading(queryset):
|
||||
queryset = queryset.prefetch_related('category')
|
||||
queryset = queryset.prefetch_related('stock_items')
|
||||
queryset = queryset.prefetch_related('bom_items')
|
||||
queryset = queryset.prefetch_related('builds')
|
||||
def prefetch_queryset(queryset):
|
||||
"""
|
||||
Prefetch related database tables,
|
||||
to reduce database hits.
|
||||
"""
|
||||
|
||||
return queryset.prefetch_related(
|
||||
'category',
|
||||
'stock_items',
|
||||
'bom_items',
|
||||
'builds',
|
||||
'supplier_parts',
|
||||
'supplier_parts__purchase_order_line_items',
|
||||
'supplier_parts__purchase_order_line_items__order',
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def annotate_queryset(queryset):
|
||||
"""
|
||||
Add some extra annotations to the queryset,
|
||||
performing database queries as efficiently as possible,
|
||||
to reduce database trips.
|
||||
"""
|
||||
|
||||
# Filter to limit stock items to "available"
|
||||
stock_filter = Q(stock_items__status__in=StockStatus.AVAILABLE_CODES)
|
||||
|
||||
# Filter to limit orders to "open"
|
||||
order_filter = Q(supplier_parts__purchase_order_line_items__order__status__in=OrderStatus.OPEN)
|
||||
|
||||
# Filter to limit builds to "active"
|
||||
build_filter = Q(builds__status__in=BuildStatus.ACTIVE_CODES)
|
||||
|
||||
# Annotate the number total stock count
|
||||
queryset = queryset.annotate(
|
||||
in_stock=Coalesce(Sum('stock_items__quantity', filter=stock_filter, distinct=True), Decimal(0))
|
||||
)
|
||||
|
||||
# Annotate the number of parts "on order"
|
||||
# Total "on order" parts = "Quantity" - "Received" for each active purchase order
|
||||
queryset = queryset.annotate(
|
||||
ordering=Coalesce(Sum(
|
||||
'supplier_parts__purchase_order_line_items__quantity',
|
||||
filter=order_filter,
|
||||
distinct=True
|
||||
), Decimal(0)) - Coalesce(Sum(
|
||||
'supplier_parts__purchase_order_line_items__received',
|
||||
filter=order_filter,
|
||||
distinct=True
|
||||
), Decimal(0))
|
||||
)
|
||||
|
||||
# Annotate number of parts being build
|
||||
queryset = queryset.annotate(
|
||||
building=Coalesce(
|
||||
Sum('builds__quantity', filter=build_filter, distinct=True), Decimal(0)
|
||||
)
|
||||
)
|
||||
|
||||
return queryset
|
||||
|
||||
# TODO - Include a 'category_detail' field which serializers the category object
|
||||
def get_starred(self, part):
|
||||
"""
|
||||
Return "true" if the part is starred by the current user.
|
||||
"""
|
||||
|
||||
return part in self.starred_parts
|
||||
|
||||
# Extra detail for the category
|
||||
category_detail = CategorySerializer(source='category', many=False, read_only=True)
|
||||
|
||||
# Calculated fields
|
||||
in_stock = serializers.FloatField(read_only=True)
|
||||
ordering = serializers.FloatField(read_only=True)
|
||||
building = serializers.FloatField(read_only=True)
|
||||
|
||||
image = serializers.CharField(source='get_image_url', read_only=True)
|
||||
thumbnail = serializers.CharField(source='get_thumbnail_url', read_only=True)
|
||||
starred = serializers.SerializerMethodField()
|
||||
|
||||
# TODO - Include annotation for the following fields:
|
||||
# allocated_stock = serializers.FloatField(source='allocation_count', read_only=True)
|
||||
# bom_items = serializers.IntegerField(source='bom_count', read_only=True)
|
||||
# used_in = serializers.IntegerField(source='used_in_count', read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Part
|
||||
partial = True
|
||||
fields = [
|
||||
'active',
|
||||
'allocated_stock',
|
||||
# 'allocated_stock',
|
||||
'assembly',
|
||||
'bom_items',
|
||||
'building',
|
||||
# 'bom_items',
|
||||
'category',
|
||||
'category_name',
|
||||
'category_detail',
|
||||
'component',
|
||||
'description',
|
||||
'full_name',
|
||||
'image',
|
||||
'in_stock',
|
||||
'ordering',
|
||||
'building',
|
||||
'IPN',
|
||||
'is_template',
|
||||
'keywords',
|
||||
'link',
|
||||
'minimum_stock',
|
||||
'name',
|
||||
'notes',
|
||||
'on_order',
|
||||
'pk',
|
||||
'purchaseable',
|
||||
'revision',
|
||||
'salable',
|
||||
'starred',
|
||||
'thumbnail',
|
||||
'trackable',
|
||||
'total_stock',
|
||||
'units',
|
||||
'used_in',
|
||||
'url', # Link to the part detail page
|
||||
# 'used_in',
|
||||
'variant_of',
|
||||
'virtual',
|
||||
]
|
||||
|
||||
@@ -55,6 +55,12 @@ def inventree_version(*args, **kwargs):
|
||||
return version.inventreeVersion()
|
||||
|
||||
|
||||
@register.simple_tag()
|
||||
def django_version(*args, **kwargs):
|
||||
""" Return Django version string """
|
||||
return version.inventreeDjangoVersion()
|
||||
|
||||
|
||||
@register.simple_tag()
|
||||
def inventree_commit_hash(*args, **kwargs):
|
||||
""" Return InvenTree git commit hash string """
|
||||
|
||||
@@ -5,7 +5,6 @@ JSON API for the Stock app
|
||||
from django_filters.rest_framework import FilterSet, DjangoFilterBackend
|
||||
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
|
||||
@@ -21,9 +20,7 @@ from .serializers import StockTrackingSerializer
|
||||
|
||||
from InvenTree.views import TreeSerializer
|
||||
from InvenTree.helpers import str2bool, isNull
|
||||
from InvenTree.status_codes import StockStatus
|
||||
|
||||
import os
|
||||
from decimal import Decimal, InvalidOperation
|
||||
|
||||
from rest_framework.serializers import ValidationError
|
||||
@@ -61,20 +58,28 @@ class StockDetail(generics.RetrieveUpdateDestroyAPIView):
|
||||
serializer_class = StockItemSerializer
|
||||
permission_classes = (permissions.IsAuthenticated,)
|
||||
|
||||
def get_queryset(self, *args, **kwargs):
|
||||
|
||||
queryset = super().get_queryset(*args, **kwargs)
|
||||
queryset = StockItemSerializer.prefetch_queryset(queryset)
|
||||
queryset = StockItemSerializer.annotate_queryset(queryset)
|
||||
|
||||
return queryset
|
||||
|
||||
def get_serializer(self, *args, **kwargs):
|
||||
|
||||
try:
|
||||
kwargs['part_detail'] = str2bool(self.request.GET.get('part_detail', False))
|
||||
kwargs['part_detail'] = str2bool(self.request.query_params.get('part_detail', False))
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
try:
|
||||
kwargs['location_detail'] = str2bool(self.request.GET.get('location_detail', False))
|
||||
kwargs['location_detail'] = str2bool(self.request.query_params.get('location_detail', False))
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
try:
|
||||
kwargs['supplier_detail'] = str2bool(self.request.GET.get('supplier_detail', False))
|
||||
kwargs['supplier_part_detail'] = str2bool(self.request.query_params.get('supplier_detail', False))
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
@@ -317,100 +322,46 @@ class StockList(generics.ListCreateAPIView):
|
||||
- status: Filter by the StockItem status
|
||||
"""
|
||||
|
||||
serializer_class = StockItemSerializer
|
||||
|
||||
queryset = StockItem.objects.all()
|
||||
|
||||
def get_serializer(self, *args, **kwargs):
|
||||
|
||||
try:
|
||||
part_detail = str2bool(self.request.GET.get('part_detail', None))
|
||||
location_detail = str2bool(self.request.GET.get('location_detail', None))
|
||||
kwargs['part_detail'] = str2bool(self.request.query_params.get('part_detail', None))
|
||||
except AttributeError:
|
||||
part_detail = None
|
||||
location_detail = None
|
||||
pass
|
||||
|
||||
kwargs['part_detail'] = part_detail
|
||||
kwargs['location_detail'] = location_detail
|
||||
try:
|
||||
kwargs['location_detail'] = str2bool(self.request.query_params.get('location_detail', None))
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
try:
|
||||
kwargs['supplier_part_detail'] = str2bool(self.request.query_params.get('supplier_part_detail', None))
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
# Ensure the request context is passed through
|
||||
kwargs['context'] = self.get_serializer_context()
|
||||
|
||||
return self.serializer_class(*args, **kwargs)
|
||||
|
||||
def list(self, request, *args, **kwargs):
|
||||
# TODO - Override the 'create' method for this view,
|
||||
# to allow the user to be recorded when a new StockItem object is created
|
||||
|
||||
queryset = self.filter_queryset(self.get_queryset())
|
||||
def get_queryset(self, *args, **kwargs):
|
||||
|
||||
# Instead of using the DRF serializer to LIST,
|
||||
# we will serialize the objects manually.
|
||||
# This is significantly faster
|
||||
queryset = super().get_queryset(*args, **kwargs)
|
||||
queryset = StockItemSerializer.prefetch_queryset(queryset)
|
||||
|
||||
data = queryset.values(
|
||||
'pk',
|
||||
'uid',
|
||||
'parent',
|
||||
'quantity',
|
||||
'serial',
|
||||
'batch',
|
||||
'status',
|
||||
'notes',
|
||||
'link',
|
||||
'location',
|
||||
'location__name',
|
||||
'location__description',
|
||||
'part',
|
||||
'part__IPN',
|
||||
'part__name',
|
||||
'part__revision',
|
||||
'part__description',
|
||||
'part__image',
|
||||
'part__category',
|
||||
'part__category__name',
|
||||
'part__category__description',
|
||||
'supplier_part',
|
||||
)
|
||||
return queryset
|
||||
|
||||
# Reduce the number of lookups we need to do for categories
|
||||
# Cache location lookups for this query
|
||||
locations = {}
|
||||
|
||||
for item in data:
|
||||
|
||||
img = item['part__image']
|
||||
|
||||
if img:
|
||||
# Use the thumbnail image instead
|
||||
fn, ext = os.path.splitext(img)
|
||||
|
||||
thumb = "{fn}.thumbnail{ext}".format(fn=fn, ext=ext)
|
||||
|
||||
thumb = os.path.join(settings.MEDIA_URL, thumb)
|
||||
else:
|
||||
thumb = ''
|
||||
|
||||
item['part__thumbnail'] = thumb
|
||||
|
||||
del item['part__image']
|
||||
|
||||
loc_id = item['location']
|
||||
|
||||
if loc_id:
|
||||
if loc_id not in locations:
|
||||
locations[loc_id] = StockLocation.objects.get(pk=loc_id).pathstring
|
||||
|
||||
item['location__path'] = locations[loc_id]
|
||||
else:
|
||||
item['location__path'] = None
|
||||
|
||||
item['status_text'] = StockStatus.label(item['status'])
|
||||
|
||||
return Response(data)
|
||||
|
||||
def get_queryset(self):
|
||||
"""
|
||||
If the query includes a particular location,
|
||||
we may wish to also request stock items from all child locations.
|
||||
"""
|
||||
def filter_queryset(self, queryset):
|
||||
|
||||
# Start with all objects
|
||||
stock_list = super(StockList, self).get_queryset()
|
||||
stock_list = super().filter_queryset(queryset)
|
||||
|
||||
# Filter out parts which are not actually "in stock"
|
||||
stock_list = stock_list.filter(customer=None, belongs_to=None)
|
||||
|
||||
@@ -7,8 +7,8 @@ from rest_framework import serializers
|
||||
from .models import StockItem, StockLocation
|
||||
from .models import StockItemTracking
|
||||
|
||||
from part.serializers import PartBriefSerializer
|
||||
from company.serializers import SupplierPartSerializer
|
||||
from part.serializers import PartBriefSerializer
|
||||
from InvenTree.serializers import UserSerializerBrief, InvenTreeModelSerializer
|
||||
|
||||
|
||||
@@ -56,24 +56,45 @@ class StockItemSerializer(InvenTreeModelSerializer):
|
||||
- Includes serialization for the item location
|
||||
"""
|
||||
|
||||
url = serializers.CharField(source='get_absolute_url', read_only=True)
|
||||
@staticmethod
|
||||
def prefetch_queryset(queryset):
|
||||
"""
|
||||
Prefetch related database tables,
|
||||
to reduce database hits.
|
||||
"""
|
||||
|
||||
return queryset.prefetch_related(
|
||||
'supplier_part',
|
||||
'supplier_part__supplier',
|
||||
'supplier_part__manufacturer',
|
||||
'location',
|
||||
'part',
|
||||
'tracking_info',
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def annotate_queryset(queryset):
|
||||
"""
|
||||
Add some extra annotations to the queryset,
|
||||
performing database queries as efficiently as possible.
|
||||
"""
|
||||
|
||||
# TODO - Add custom annotated fields
|
||||
return queryset
|
||||
|
||||
status_text = serializers.CharField(source='get_status_display', read_only=True)
|
||||
|
||||
part_name = serializers.CharField(source='get_part_name', read_only=True)
|
||||
|
||||
part_image = serializers.CharField(source='part__image', read_only=True)
|
||||
|
||||
tracking_items = serializers.IntegerField(source='tracking_info_count', read_only=True)
|
||||
|
||||
part_detail = PartBriefSerializer(source='part', many=False, read_only=True)
|
||||
location_detail = LocationBriefSerializer(source='location', many=False, read_only=True)
|
||||
supplier_detail = SupplierPartSerializer(source='supplier_part', many=False, read_only=True)
|
||||
supplier_part_detail = SupplierPartSerializer(source='supplier_part', many=False, read_only=True)
|
||||
|
||||
tracking_items = serializers.IntegerField(source='tracking_info_count', read_only=True)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
|
||||
part_detail = kwargs.pop('part_detail', False)
|
||||
location_detail = kwargs.pop('location_detail', False)
|
||||
supplier_detail = kwargs.pop('supplier_detail', False)
|
||||
supplier_part_detail = kwargs.pop('supplier_part_detail', False)
|
||||
|
||||
super(StockItemSerializer, self).__init__(*args, **kwargs)
|
||||
|
||||
@@ -83,8 +104,8 @@ class StockItemSerializer(InvenTreeModelSerializer):
|
||||
if location_detail is not True:
|
||||
self.fields.pop('location_detail')
|
||||
|
||||
if supplier_detail is not True:
|
||||
self.fields.pop('supplier_detail')
|
||||
if supplier_part_detail is not True:
|
||||
self.fields.pop('supplier_part_detail')
|
||||
|
||||
class Meta:
|
||||
model = StockItem
|
||||
@@ -97,18 +118,15 @@ class StockItemSerializer(InvenTreeModelSerializer):
|
||||
'notes',
|
||||
'part',
|
||||
'part_detail',
|
||||
'part_name',
|
||||
'part_image',
|
||||
'pk',
|
||||
'quantity',
|
||||
'serial',
|
||||
'supplier_part',
|
||||
'supplier_detail',
|
||||
'supplier_part_detail',
|
||||
'status',
|
||||
'status_text',
|
||||
'tracking_items',
|
||||
'uid',
|
||||
'url',
|
||||
]
|
||||
|
||||
""" These fields are read-only in this context.
|
||||
|
||||
@@ -25,6 +25,10 @@
|
||||
<td><span class='fas fa-hashtag'></span></td>
|
||||
<td>{% trans "InvenTree Version" %}</td><td><a href="https://github.com/inventree/InvenTree/releases">{% inventree_version %}</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><span class='fas fa-hashtag'></span></td>
|
||||
<td>{% trans "Django Version" %}</td><td><a href="https://www.djangoproject.com/">{% django_version %}</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><span class='fas fa-code-branch'></span></td>
|
||||
<td>{% trans "Commit Hash" %}</td><td><a href="https://github.com/inventree/InvenTree/commit/{% inventree_commit_hash %}">{% inventree_commit_hash %}</a></td>
|
||||
@@ -45,7 +49,7 @@
|
||||
<tr>
|
||||
<td><span class='fas fa-exclamation-circle'></span></td>
|
||||
<td>{% trans "Submit Bug Report" %}</td>
|
||||
<td><a href='{% inventree_github_url %}/issues'>{% inventree_github_url %}/issues</a></td>
|
||||
<td><a href='{% inventree_github_url %}/issues'>{% inventree_github_url %}issues</a></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ from django.contrib.auth.models import User
|
||||
from django.core.exceptions import ObjectDoesNotExist
|
||||
from .serializers import UserSerializer
|
||||
|
||||
from rest_framework.authtoken.views import ObtainAuthToken
|
||||
from rest_framework.views import APIView
|
||||
from rest_framework.authtoken.models import Token
|
||||
from rest_framework.response import Response
|
||||
from rest_framework import status
|
||||
@@ -25,28 +25,32 @@ class UserList(generics.ListAPIView):
|
||||
permission_classes = (permissions.IsAuthenticated,)
|
||||
|
||||
|
||||
class GetAuthToken(ObtainAuthToken):
|
||||
class GetAuthToken(APIView):
|
||||
""" Return authentication token for an authenticated user. """
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
permission_classes = [
|
||||
permissions.IsAuthenticated,
|
||||
]
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
return self.login(request)
|
||||
|
||||
def delete(self, request):
|
||||
return self.logout(request)
|
||||
|
||||
def login(self, request):
|
||||
serializer = self.serializer_class(data=request.data,
|
||||
context={'request': request})
|
||||
serializer.is_valid(raise_exception=True)
|
||||
user = serializer.validated_data['user']
|
||||
token, created = Token.objects.get_or_create(user=user)
|
||||
|
||||
return Response({
|
||||
'token': token.key,
|
||||
'pk': user.pk,
|
||||
'username': user.username,
|
||||
'email': user.email
|
||||
})
|
||||
if request.user.is_authenticated:
|
||||
# Get the user token (or create one if it does not exist)
|
||||
token, created = Token.objects.get_or_create(user=request.user)
|
||||
return Response({
|
||||
'token': token.key,
|
||||
})
|
||||
|
||||
else:
|
||||
return Response({
|
||||
'error': 'User not authenticated',
|
||||
})
|
||||
|
||||
def logout(self, request):
|
||||
try:
|
||||
|
||||
18
README.md
18
README.md
@@ -9,18 +9,18 @@ InvenTree is designed to be lightweight and easy to use for SME or hobbyist appl
|
||||
|
||||
However, powerful business logic works in the background to ensure that stock tracking history is maintained, and users have ready access to stock level information.
|
||||
|
||||
## User Documentation
|
||||
## Getting Started
|
||||
|
||||
For InvenTree documentation, refer to the [user documentation](https://inventree.github.io).
|
||||
Refer to the [getting started guide](https://inventree.github.io/docs/start/install) for installation and setup instructions.
|
||||
|
||||
## Documentation
|
||||
|
||||
For InvenTree documentation, refer to the [InvenTre documentation website](https://inventree.github.io).
|
||||
|
||||
## Developer Documentation
|
||||
|
||||
For site administrator and project code documentation, refer to the [developer documentation](http://inventree.readthedocs.io/en/latest/). This includes auto-generated documentation of the InvenTree python codebase.
|
||||
For code documentation, refer to the [developer documentation](http://inventree.readthedocs.io/en/latest/).
|
||||
|
||||
## Getting Started
|
||||
## Contributing
|
||||
|
||||
Refer to the [getting started guide](https://inventree.readthedocs.io/en/latest/start.html) for installation and setup instructions.
|
||||
|
||||
## Third Party Extensions
|
||||
|
||||
[InvenTree Docker](https://github.com/Zeigren/inventree-docker) - A docker build for InvenTree by [Zeigren](https://github.com/Zeigren)
|
||||
Contributions are welcomed and encouraged. Please help to make this project even better! Refer to the [contribution page](https://inventree.github.io/pages/contribute).
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
Backup and Restore
|
||||
==================
|
||||
|
||||
.. toctree::
|
||||
:titlesonly:
|
||||
:maxdepth: 2
|
||||
:caption: Backup
|
||||
:hidden:
|
||||
|
||||
InvenTree provides database backup and restore functionality through the `django-dbbackup <https://github.com/django-dbbackup/django-dbbackup>`_ extension.
|
||||
|
||||
This extension allows database models and uploaded media files to be backed up (and restored) via the command line.
|
||||
|
||||
In the root InvenTre directory, run ``make backup`` to generate backup files for the database models and media files.
|
||||
@@ -1,79 +0,0 @@
|
||||
InvenTree Configuration
|
||||
=======================
|
||||
|
||||
.. toctree::
|
||||
:titlesonly:
|
||||
:maxdepth: 2
|
||||
:caption: Configuration
|
||||
:hidden:
|
||||
|
||||
Admin users will need to adjust the InvenTree installation to meet the particular needs of their setup. For example, pointing to the correct database backend, or specifying a list of allowed hosts.
|
||||
|
||||
The Django configuration parameters are found in the normal place (*settings.py*). However the settings presented in this file should not be adjusted as they will alter the core behaviour of the InvenTree application.
|
||||
|
||||
To support install specific settings, a simple configuration file ``config.yaml`` is provided. This configuration file is loaded by **settings.py** at runtime. Settings specific to a given install should be adjusted in ``config.yaml``.
|
||||
|
||||
The default configuration file launches a *DEBUG* configuration with a simple SQLITE database backend. This default configuration file is shown below:
|
||||
|
||||
.. literalinclude :: ../InvenTree/config_template.yaml
|
||||
:language: yaml
|
||||
:linenos:
|
||||
|
||||
Database Options
|
||||
----------------
|
||||
|
||||
InvenTree provides support for multiple database backends - any backend supported natively by Django can be used.
|
||||
|
||||
Database options are specified under the *database* heading in the configuration file. Any option available in the Django documentation can be used here - it is passed through transparently to the management scripts.
|
||||
|
||||
**SQLite:**
|
||||
By default, InvenTree uses an sqlite database file : ``inventree_db.sqlite3``. This provides a simple, portable database file that is easy to use for debug and testing purposes.
|
||||
|
||||
|
||||
**MySQL:** MySQL database backend is supported with the native Django implemetation. To run InvenTree with the MySQL backend, a number of extra packages need to be installed:
|
||||
|
||||
* mysql-server - *MySQL backend server*
|
||||
* libmysqlclient-dev - *Required for connecting to the MySQL database in Python*
|
||||
* (pip) mysqlclient - *Python package for communication with MySQL database*
|
||||
|
||||
These requirements can be installed from the base directory with the command ``make mysql``.
|
||||
|
||||
It is then up to the database adminstrator to create a new MySQL database to store inventree data, in addition to a username/password to access the data.
|
||||
|
||||
.. important:: MySQL Collation:
|
||||
When creating the MySQL database, the adminstrator must ensure that the collation option is set to *utf8_unicode_520_ci* to ensure that InvenTree features function correctly.
|
||||
|
||||
The database options (in the ``config.yaml`` file) then need to be adjusted to communicate the MySQL backend. Refer to the `Django docs <https://docs.djangoproject.com/en/dev/ref/databases/>`_ for further information.
|
||||
|
||||
**PostgreSQL:** PostgreSQL database backend is supported with the native Django implementation. Note that to use this backend, the following system packages must be installed:
|
||||
|
||||
* postgresql
|
||||
* postgresql-contrib
|
||||
* libpq-dev
|
||||
* (pip3) psycopg2
|
||||
|
||||
These requirements can be installed from the base directory with the command ``make postgresql``.
|
||||
|
||||
It is then up to the database adminstrator to create a new PostgreSQL database to store inventree data, in addition to a username/password to access the data.
|
||||
|
||||
The database options (in the ``config.yaml`` file) then need to be adjusted to communicate the PostgreSQL backend. Refer to the `Django docs <https://docs.djangoproject.com/en/dev/ref/databases/>`_ for further information.
|
||||
|
||||
Allowed Hosts / CORS
|
||||
--------------------
|
||||
|
||||
By default, all hosts are allowed, and CORS requests are enabled from any origin. **This is not secure and should be adjusted for your installation**. These options can be changed in the configuration file.
|
||||
|
||||
For further information, refer to the following documentation:
|
||||
|
||||
* `Django ALLOWED_HOSTS <https://docs.djangoproject.com/en/2.2/ref/settings/#allowed-hosts>`_
|
||||
* `Django CORS headers <https://github.com/OttoYiu/django-cors-headers>`_
|
||||
|
||||
Uploaded File Storage
|
||||
---------------------
|
||||
|
||||
By default, uploaded files are stored in the local direction ``./media``. This directory should be changed based on the particular installation requirements.
|
||||
|
||||
Backup Location
|
||||
---------------
|
||||
|
||||
The default behaviour of the database backup is to generate backup files for database tables and media files to the user's temporary directory. The target directory can be overridden by setting the *backup_dir* parameter in the config file.
|
||||
@@ -1,54 +0,0 @@
|
||||
Deploying InvenTree
|
||||
===================
|
||||
|
||||
.. toctree::
|
||||
:titlesonly:
|
||||
:maxdepth: 2
|
||||
:caption: Deployment
|
||||
:hidden:
|
||||
|
||||
The development server provided by the Django ecosystem may be fine for a testing environment or small contained setups. However special consideration must be given when deploying InvenTree in a real-world environment.
|
||||
|
||||
Django apps provide multiple deployment methods - see the `Django documentation <https://docs.djangoproject.com/en/2.2/howto/deployment/>`_.
|
||||
|
||||
There are also numerous online tutorials describing how to deploy a Django application either locally or on an online platform.
|
||||
|
||||
Following is a simple tutorial on serving InvenTree using `Gunicorn <https://gunicorn.org/>`_. Gunicorn is a Python WSGI server which provides a multi-worker server which is much better suited to handling multiple simultaneous requests.
|
||||
|
||||
Install Gunicorn
|
||||
----------------
|
||||
|
||||
Gunicorn can be installed using PIP:
|
||||
|
||||
``pip3 install gunicorn``
|
||||
|
||||
|
||||
Configure Static Directories
|
||||
----------------------------
|
||||
|
||||
Directories for storing *media* files and *static* files should be specified in the ``config.yaml`` configuration file. These directories are the ``MEDIA_ROOT`` and ``STATIC_ROOT`` paths required by the Django app.
|
||||
|
||||
Collect Static Files
|
||||
--------------------
|
||||
|
||||
The required static files must be collected into the specified ``STATIC_ROOT`` directory:
|
||||
|
||||
``python3 InvenTree/manage.py collectstatic``
|
||||
|
||||
Configure Gunicorn
|
||||
------------------
|
||||
|
||||
The Gunicorn server can be configured with a simple configuration file (e.g. python script). An example configuration file is provided in ``InvenTree/gunicorn.conf.py``
|
||||
|
||||
.. literalinclude :: ../InvenTree/gunicorn.conf.py
|
||||
:language: python
|
||||
:linenos:
|
||||
|
||||
This file can be used to configure the Gunicorn server to match particular requirements.
|
||||
|
||||
Run Gunicorn
|
||||
------------
|
||||
|
||||
From the directory where ``manage.py`` is located:
|
||||
|
||||
Run ``gunicorn -c gunicorn.conf.py InvenTree.wsgi``
|
||||
@@ -6,14 +6,8 @@ InvenTree Source Documentation
|
||||
:maxdepth: 2
|
||||
:caption: Index
|
||||
:hidden:
|
||||
|
||||
Getting Started<start>
|
||||
Configuration<config>
|
||||
Deployment<deploy>
|
||||
Migrate Data<migrate>
|
||||
Update InvenTree<update>
|
||||
|
||||
Translations<translate>
|
||||
Backup and Restore<backup>
|
||||
Modal Forms<forms>
|
||||
Tables<tables>
|
||||
REST API<rest>
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
Migrating Data
|
||||
==============
|
||||
|
||||
.. toctree::
|
||||
:titlesonly:
|
||||
:maxdepth: 2
|
||||
:caption: Migrating Data
|
||||
:hidden:
|
||||
|
||||
In the case that data needs to be migrated from one database installation to another, the following procedure can be used to export data, initialize the new database, and re-import the data.
|
||||
|
||||
Export Data
|
||||
-----------
|
||||
|
||||
``python3 InvenTree/manage.py dumpdata --exclude contenttypes --exclude auth.permission --indent 2 > data.json``
|
||||
|
||||
This will export all data (including user information) to a json data file.
|
||||
|
||||
Initialize Database
|
||||
-------------------
|
||||
|
||||
Configure the new database using the normal processes (see `Getting Started <start.html>`_):
|
||||
|
||||
``python3 InvenTree/manage.py makemigrations``
|
||||
|
||||
``python3 InvenTree/manage.py migrate --run-syncdb``
|
||||
|
||||
Import Data
|
||||
-----------
|
||||
|
||||
The new database should now be correctly initialized with the correct table structures requried to import the data.
|
||||
|
||||
``python3 InvenTree/manage.py loaddata data.json``
|
||||
|
||||
.. important::
|
||||
If the character encoding of the data file does not exactly match the target database, the import operation may not succeed. In this case, some manual editing of the data file may be required.
|
||||
124
docs/start.rst
124
docs/start.rst
@@ -1,124 +0,0 @@
|
||||
Getting Started Guide
|
||||
=====================
|
||||
|
||||
.. toctree::
|
||||
:titlesonly:
|
||||
:maxdepth: 2
|
||||
:caption: Getting Started
|
||||
:hidden:
|
||||
|
||||
To install a complete *development* environment for InvenTree, follow the steps presented below. A production environment will require further work as per the particular application requirements.
|
||||
|
||||
A makefile in the root directory provides shortcuts for the installation process, and can also be very useful during development.
|
||||
|
||||
Requirements
|
||||
------------
|
||||
|
||||
To install InvenTree you will need the following system components installed:
|
||||
|
||||
* python3
|
||||
* python3-dev
|
||||
* python3-pip (pip3)
|
||||
* g++
|
||||
* make
|
||||
|
||||
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
|
||||
-------------------
|
||||
|
||||
Installing the required Python packages inside a virtual environment allows a local install separate to the system-wide Python installation. While not strictly necessary, using a virtual environment is highly recommended as it prevents conflicts between the different Python installations.
|
||||
|
||||
To configure Inventree inside a virtual environment, ``cd`` into the inventree base directory and run the following commands:
|
||||
|
||||
``apt-get install python3-venv``
|
||||
|
||||
``python3 -m venv inventree-env``
|
||||
|
||||
``source inventree-env/bin/activate``
|
||||
|
||||
This will place the current shell session inside a virtual environment - the terminal should display the ``(inventree-env)`` prefix.
|
||||
|
||||
.. note::
|
||||
Remember to run ``source inventree-env/bin/activate`` when starting each shell session, before running Inventree commands. This will ensure that the correct environment is being used.
|
||||
|
||||
|
||||
Installation
|
||||
------------
|
||||
|
||||
First, download the latest InvenTree source code:
|
||||
|
||||
``git clone https://github.com/inventree/inventree/``
|
||||
|
||||
InvenTree is a Python/Django application and relies on the pip package manager. All packages required to develop and test InvenTree can be installed via pip. Package requirements can be found in ``requirements.txt``.
|
||||
|
||||
To setup the InvenTree environment, *cd into the inventree directory* and run the command:
|
||||
|
||||
``make install``
|
||||
|
||||
which installs all required Python packages using pip package manager. It also creates a (default) database configuration file which needs to be edited to meet user needs before proceeding (see next step below).
|
||||
|
||||
Additionally, this step creates a *SECRET_KEY* file which is used for the django authentication framework.
|
||||
|
||||
.. important::
|
||||
The *SECRET_KEY* file should never be shared or made public.
|
||||
|
||||
Database Configuration
|
||||
-----------------------
|
||||
|
||||
Once the required packages are installed, the database configuration must be adjusted to suit your particular needs. InvenTree provides a simple default setup which should work *out of the box* for testing and debug purposes.
|
||||
|
||||
As part of the previous *install* step, a configuration file (``config.yaml``) is created. The configuration file provides administrators control over various setup options without digging into the Django *settings.py* script. The default setup uses a local sqlite database with *DEBUG* mode enabled.
|
||||
|
||||
For further information on installation configuration, refer to the `Configuration <config.html>`_ section.
|
||||
|
||||
Initialize Database
|
||||
-------------------
|
||||
|
||||
Once install settings are correctly configured (in *config.yaml*) run the initial setup script:
|
||||
|
||||
``make migrate``
|
||||
|
||||
This performs the initial database migrations, creating the required tables, etc.
|
||||
|
||||
The database should now be installed!
|
||||
|
||||
Create Admin Account
|
||||
--------------------
|
||||
|
||||
Create an initial superuser (administrator) account for the InvenTree instance:
|
||||
|
||||
``make superuser``
|
||||
|
||||
Run Development Server
|
||||
----------------------
|
||||
|
||||
Run ``cd InvenTree && python3 manage.py runserver 127.0.0.1:8000`` to launch a development server. This will launch the InvenTree web interface at ``127.0.0.1:8000``. For other options refer to the `django docs <https://docs.djangoproject.com/en/2.2/ref/django-admin/>`_.
|
||||
|
||||
Database Migrations
|
||||
-------------------
|
||||
|
||||
Whenever a change is made to the underlying database schema, database migrations must be performed. Call ``make migrate`` to run any outstanding database migrations.
|
||||
|
||||
Development and Testing
|
||||
-----------------------
|
||||
|
||||
Other shorthand functions are provided for the development and testing process:
|
||||
|
||||
* ``make install`` - Install all required underlying packages using PIP
|
||||
* ``make update`` - Update InvenTree installation (after database configuration)
|
||||
* ``make superuser`` - Create a superuser account
|
||||
* ``make migrate`` - Perform database migrations
|
||||
* ``make mysql`` - Install packages required for MySQL database backend
|
||||
* ``make postgresql`` - Install packages required for PostgreSQL database backend
|
||||
* ``make translate`` - Compile language translation files (requires gettext system package)
|
||||
* ``make backup`` - Backup database tables and media files
|
||||
* ``make test`` - Run all unit tests
|
||||
* ``make coverage`` - Run all unit tests and generate code coverage report
|
||||
* ``make style`` - Check Python codebase against PEP coding standards (using Flake)
|
||||
* ``make docreqs`` - Install the packages required to generate documentation
|
||||
* ``make docs`` - Generate this documentation
|
||||
@@ -1,42 +0,0 @@
|
||||
Update InvenTree
|
||||
================
|
||||
|
||||
.. toctree::
|
||||
:titlesonly:
|
||||
:maxdepth: 2
|
||||
:caption: Update
|
||||
:hidden:
|
||||
|
||||
Adminitrators wishing to update InvenTree to the latest version should follow the instructions below. The commands listed below should be run from the InvenTree root directory.
|
||||
|
||||
.. important::
|
||||
It is advisable to backup the InvenTree database before performing these steps. The particular backup procedure may depend on your installation details. To perform a simple database dump, run the command ``make backup``.
|
||||
|
||||
Stop Server
|
||||
-----------
|
||||
|
||||
Stop the InvenTree server (e.g. gunicorn)
|
||||
|
||||
Update Source
|
||||
-------------
|
||||
|
||||
Update the InvenTree source code to the latest version.
|
||||
|
||||
``git pull origin master``
|
||||
|
||||
Perform Migrations
|
||||
------------------
|
||||
|
||||
Updating the database is as simple as calling the makefile target:
|
||||
|
||||
``make update``
|
||||
|
||||
This command performs the following steps:
|
||||
|
||||
* Perform required database schema changes
|
||||
* Collect required static files
|
||||
|
||||
Restart Server
|
||||
--------------
|
||||
|
||||
Restart the InvenTree server
|
||||
@@ -1,11 +1,11 @@
|
||||
wheel>=0.34.2 # Wheel
|
||||
Django==2.2.10 # Django package
|
||||
Django==3.0.5 # Django package
|
||||
pillow==6.2.0 # Image manipulation
|
||||
djangorestframework==3.10.3 # DRF framework
|
||||
django-dbbackup==3.3.0 # Database backup / restore functionality
|
||||
django-cors-headers==3.2.0 # CORS headers extension for DRF
|
||||
django_filter==2.2.0 # Extended filtering options
|
||||
django-mptt==0.10.0 # Modified Preorder Tree Traversal
|
||||
django-dbbackup==3.2.0 # Database backup / restore functionality
|
||||
django-mptt==0.11.0 # Modified Preorder Tree Traversal
|
||||
django-markdownx==3.0.1 # Markdown form fields
|
||||
django-markdownify==0.8.0 # Markdown rendering
|
||||
coreapi==2.3.0 # API documentation
|
||||
@@ -14,9 +14,12 @@ tablib==0.13.0 # Import / export data files
|
||||
django-crispy-forms==1.8.1 # Form helpers
|
||||
django-import-export==2.0.0 # Data import / export for admin interface
|
||||
django-cleanup==4.0.0 # Manage deletion of old / unused uploaded files
|
||||
django-qr-code==1.1.0 # Generate QR codes
|
||||
# TODO: Once the official django-qr-code package has been updated with Django3.x support,
|
||||
# the following line should be removed.
|
||||
git+git://github.com/chrissam/django-qr-code
|
||||
# django-qr-code==1.1.0 # Generate QR codes
|
||||
flake8==3.3.0 # PEP checking
|
||||
coverage==4.0.3 # Unit test coverage
|
||||
python-coveralls==2.9.1 # Coveralls linking (for Travis)
|
||||
rapidfuzz==0.2.1 # Fuzzy string matching
|
||||
django-stdimage==5.0.3 # Advanced ImageField management
|
||||
rapidfuzz==0.7.6 # Fuzzy string matching
|
||||
django-stdimage==5.1.1 # Advanced ImageField management
|
||||
Reference in New Issue
Block a user