Merge remote-tracking branch 'inventree/master'

This commit is contained in:
Oliver Walters 2020-09-28 22:16:32 +10:00
commit 28d9e320fd
27 changed files with 2241 additions and 1191 deletions

View File

@ -6,7 +6,7 @@ No pushing to master! New featues must be submitted in a separate branch (one br
## Include Migration Files
Any required migration files **must** be included in the commit, or the pull-request will be rejected. If you change the underlying database schema, make sure you run `make migrate` and commit the migration files before submitting the PR.
Any required migration files **must** be included in the commit, or the pull-request will be rejected. If you change the underlying database schema, make sure you run `invoke migrate` and commit the migration files before submitting the PR.
## Update Translation Files
@ -14,7 +14,7 @@ Any PRs which update translatable strings (i.e. text strings that will appear in
*This does not mean that all translations must be provided, but that the translation files must include locations for the translated strings to be written.*
To perform this step, simply run `make_translate` from the top level directory before submitting the PR.
To perform this step, simply run `invoke translate` from the top level directory before submitting the PR.
## Testing
@ -22,9 +22,8 @@ Any new code should be covered by unit tests - a submitted PR may not be accepte
## Documentation
New features or updates to existing features should be accompanied by user documentation.
A PR with associated documentation should link to the matching PR at https://github.com/inventree/InvenTree.github.io
New features or updates to existing features should be accompanied by user documentation. A PR with associated documentation should link to the matching PR at https://github.com/inventree/inventree-docs/
## Code Style
Sumbitted Python code is automatically checked against PEP style guidelines. Locally you can run `make style` to ensure the style checks will pass, before submitting the PR.
Sumbitted Python code is automatically checked against PEP style guidelines. Locally you can run `invoke style` to ensure the style checks will pass, before submitting the PR.

View File

@ -157,6 +157,11 @@ $.fn.inventreeTable = function(options) {
console.log('Could not get list of visible columns!');
}
}
// Optionally, link buttons to the table selection
if (options.buttons) {
linkButtonsToSelection(table, options.buttons);
}
}
function customGroupSorter(sortName, sortOrder, sortData) {

View File

@ -19,7 +19,7 @@
loadStockTable($("#stock-table"), {
params: {
location_detail: true,
part_details: true,
part_detail: true,
build: {{ build.id }},
},
groupByField: 'location',

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -1273,7 +1273,7 @@ class POLineItemEdit(AjaxUpdateView):
form = super().get_form()
# Prevent user from editing order once line item is assigned
form.fields.pop('order')
form.fields['order'].widget = HiddenInput()
return form

View File

@ -7,7 +7,7 @@ from __future__ import unicode_literals
from django_filters.rest_framework import DjangoFilterBackend
from django.http import JsonResponse
from django.db.models import Q, F, Count
from django.db.models import Q, F, Count, Prefetch, Sum
from rest_framework import status
from rest_framework.response import Response
@ -22,11 +22,14 @@ from .models import PartParameter, PartParameterTemplate
from .models import PartAttachment, PartTestTemplate
from .models import PartSellPriceBreak
from build.models import Build
from . import serializers as part_serializers
from InvenTree.views import TreeSerializer
from InvenTree.helpers import str2bool, isNull
from InvenTree.api import AttachmentMixin
from InvenTree.status_codes import BuildStatus
class PartCategoryTree(TreeSerializer):
@ -531,12 +534,24 @@ class PartList(generics.ListCreateAPIView):
if stock_to_build is not None:
# Filter only active parts
queryset = queryset.filter(active=True)
# Prefetch current active builds
build_active_queryset = Build.objects.filter(status__in=BuildStatus.ACTIVE_CODES)
build_active_prefetch = Prefetch('builds',
queryset=build_active_queryset,
to_attr='current_builds')
parts = queryset.prefetch_related(build_active_prefetch)
# Store parts with builds needing stock
parts_need_stock = []
# Find parts with active builds
# where any subpart's stock is lower than quantity being built
for part in queryset:
if part.active_builds and part.can_build < part.quantity_being_built:
for part in parts:
if part.current_builds:
builds_ids = [build.id for build in part.current_builds]
total_build_quantity = build_active_queryset.filter(pk__in=builds_ids).aggregate(quantity=Sum('quantity'))['quantity']
if part.can_build < total_build_quantity:
parts_need_stock.append(part.pk)
queryset = queryset.filter(pk__in=parts_need_stock)

View File

@ -83,7 +83,7 @@ def inventree_github_url(*args, **kwargs):
@register.simple_tag()
def inventree_docs_url(*args, **kwargs):
""" Return URL for InvenTree documenation site """
return "https://inventree.github.io"
return "https://inventree.readthedocs.io/"
@register.simple_tag()

View File

@ -35,7 +35,7 @@ class TemplateTagTest(TestCase):
self.assertIn('github.com', inventree_extras.inventree_github_url())
def test_docs(self):
self.assertIn('inventree.github.io', inventree_extras.inventree_docs_url())
self.assertIn('inventree.readthedocs.io', inventree_extras.inventree_docs_url())
class PartTest(TestCase):

View File

@ -455,6 +455,12 @@ class StockList(generics.ListCreateAPIView):
if belongs_to:
queryset = queryset.filter(belongs_to=belongs_to)
# Filter by batch code
batch = params.get('batch', None)
if batch is not None:
queryset = queryset.filter(batch=batch)
build = params.get('build', None)
if build:
@ -470,6 +476,26 @@ class StockList(generics.ListCreateAPIView):
if sales_order:
queryset = queryset.filter(sales_order=sales_order)
# Filter stock items which are installed in another (specific) stock item
installed_in = params.get('installed_in', None)
if installed_in:
# Note: The "installed_in" field is called "belongs_to"
queryset = queryset.filter(belongs_to=installed_in)
# Filter stock items which are installed in another stock item
installed = params.get('installed', None)
if installed is not None:
installed = str2bool(installed)
if installed:
# Exclude items which are *not* installed in another item
queryset = queryset.exclude(belongs_to=None)
else:
# Exclude items which are instaled in another item
queryset = queryset.filter(belongs_to=None)
# Filter by customer
customer = params.get('customer', None)

View File

@ -62,7 +62,7 @@
pk: 8
fields:
stock_item: 522
test: 'Check that chair is GreEn '
test: 'Check that chair is GreEn'
result: True
date: 2020-05-17

View File

@ -271,6 +271,28 @@ class ExportOptionsForm(HelperForm):
self.fields['file_format'].choices = self.get_format_choices()
class UninstallStockForm(forms.ModelForm):
"""
Form for uninstalling a stock item which is installed in another item.
"""
location = TreeNodeChoiceField(queryset=StockLocation.objects.all(), label=_('Location'), help_text=_('Destination location for uninstalled items'))
note = forms.CharField(label=_('Notes'), required=False, help_text=_('Add transaction note (optional)'))
confirm = forms.BooleanField(required=False, initial=False, label=_('Confirm uninstall'), help_text=_('Confirm removal of installed stock items'))
class Meta:
model = StockItem
fields = [
'location',
'note',
'confirm',
]
class AdjustStockForm(forms.ModelForm):
""" Form for performing simple stock adjustments.
@ -282,15 +304,15 @@ class AdjustStockForm(forms.ModelForm):
This form is used for managing stock adjuments for single or multiple stock items.
"""
destination = TreeNodeChoiceField(queryset=StockLocation.objects.all(), label='Destination', required=True, help_text=_('Destination stock location'))
destination = TreeNodeChoiceField(queryset=StockLocation.objects.all(), label=_('Destination'), required=True, help_text=_('Destination stock location'))
note = forms.CharField(label='Notes', required=True, help_text='Add note (required)')
note = forms.CharField(label=_('Notes'), required=True, help_text=_('Add note (required)'))
# transaction = forms.BooleanField(required=False, initial=False, label='Create Transaction', help_text='Create a stock transaction for these parts')
confirm = forms.BooleanField(required=False, initial=False, label='Confirm stock adjustment', help_text=_('Confirm movement of stock items'))
confirm = forms.BooleanField(required=False, initial=False, label=_('Confirm stock adjustment'), help_text=_('Confirm movement of stock items'))
set_loc = forms.BooleanField(required=False, initial=False, label='Set Default Location', help_text=_('Set the destination as the default location for selected parts'))
set_loc = forms.BooleanField(required=False, initial=False, label=_('Set Default Location'), help_text=_('Set the destination as the default location for selected parts'))
class Meta:
model = StockItem

View File

@ -0,0 +1,19 @@
# Generated by Django 3.0.7 on 2020-09-28 09:28
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('stock', '0050_auto_20200821_1403'),
]
operations = [
migrations.AlterField(
model_name='stockitem',
name='belongs_to',
field=models.ForeignKey(blank=True, help_text='Is this item installed in another item?', null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='installed_parts', to='stock.StockItem', verbose_name='Installed In'),
),
]

View File

@ -341,7 +341,7 @@ class StockItem(MPTTModel):
'self',
verbose_name=_('Installed In'),
on_delete=models.DO_NOTHING,
related_name='owned_parts', blank=True, null=True,
related_name='installed_parts', blank=True, null=True,
help_text=_('Is this item installed in another item?')
)
@ -585,6 +585,78 @@ class StockItem(MPTTModel):
return True
def installedItemCount(self):
"""
Return the number of stock items installed inside this one.
"""
return self.installed_parts.count()
def hasInstalledItems(self):
"""
Returns true if this stock item has other stock items installed in it.
"""
return self.installedItemCount() > 0
@transaction.atomic
def installIntoStockItem(self, otherItem, user, notes):
"""
Install this stock item into another stock item.
Args
otherItem: The stock item to install this item into
user: The user performing the operation
notes: Any notes associated with the operation
"""
# Cannot be already installed in another stock item!
if self.belongs_to is not None:
return False
# TODO - Are there any other checks that need to be performed at this stage?
# Mark this stock item as belonging to the other one
self.belongs_to = otherItem
self.save()
# Add a transaction note!
self.addTransactionNote(
_('Installed in stock item') + ' ' + str(otherItem.pk),
user,
notes=notes
)
@transaction.atomic
def uninstallIntoLocation(self, location, user, notes):
"""
Uninstall this stock item from another item, into a location.
Args:
location: The stock location where the item will be moved
user: The user performing the operation
notes: Any notes associated with the operation
"""
# If the stock item is not installed in anything, ignore
if self.belongs_to is None:
return False
# TODO - Are there any other checks that need to be performed at this stage?
self.belongs_to = None
self.location = location
self.save()
# Add a transaction note!
self.addTransactionNote(
_('Uninstalled into location') + ' ' + str(location),
user,
notes=notes
)
@property
def children(self):
""" Return a list of the child items which have been split from this stock item """
@ -1042,7 +1114,9 @@ class StockItem(MPTTModel):
as all named tests are accessible.
"""
results = self.getTestResults(**kwargs).order_by('-date')
# Filter results by "date", so that newer results
# will override older ones.
results = self.getTestResults(**kwargs).order_by('date')
result_map = {}

View File

@ -31,6 +31,7 @@ loadStockTable($("#stock-table"), {
part_details: true,
ancestor: {{ item.id }},
},
name: 'item-childs',
groupByField: 'location',
buttons: [
'#stock-options',

View File

@ -0,0 +1,184 @@
{% extends "stock/item_base.html" %}
{% load static %}
{% load i18n %}
{% block details %}
{% include "stock/tabs.html" with tab='installed' %}
<h4>{% trans "Installed Stock Items" %}</h4>
<hr>
<div id='button-toolbar'>
<div class='button-toolbar container-fluid' style='float: right;'>
<div class="btn-group">
<button id='stock-options' class="btn btn-primary dropdown-toggle" type="button" data-toggle="dropdown">{% trans "Options" %}<span class="caret"></span></button>
<ul class="dropdown-menu">
<li><a href="#" id='multi-item-uninstall' title='{% trans "Uninstall selected stock items" %}'>{% trans "Uninstall" %}</a></li>
</ul>
</div>
</div>
</div>
<table class='table table-striped table-condensed' id='installed-table' data-toolbar='#button-toolbar'>
</table>
{% endblock %}
{% block js_ready %}
{{ block.super }}
$('#installed-table').inventreeTable({
formatNoMatches: function() {
return '{% trans "No stock items installed" %}';
},
url: "{% url 'api-stock-list' %}",
queryParams: {
installed_in: {{ item.id }},
part_detail: true,
},
name: 'stock-item-installed',
url: "{% url 'api-stock-list' %}",
showColumns: true,
columns: [
{
checkbox: true,
title: '{% trans 'Select' %}',
searchable: false,
switchable: false,
},
{
field: 'pk',
title: 'ID',
visible: false,
switchable: false,
},
{
field: 'part_name',
title: '{% trans "Part" %}',
sortable: true,
formatter: function(value, row, index, field) {
var url = `/stock/item/${row.pk}/`;
var thumb = row.part_detail.thumbnail;
var name = row.part_detail.full_name;
html = imageHoverIcon(thumb) + renderLink(name, url);
return html;
}
},
{
field: 'IPN',
title: 'IPN',
sortable: true,
formatter: function(value, row, index, field) {
return row.part_detail.IPN;
},
},
{
field: 'part_description',
title: '{% trans "Description" %}',
sortable: true,
formatter: function(value, row, index, field) {
return row.part_detail.description;
}
},
{
field: 'quantity',
title: '{% trans "Stock" %}',
sortable: true,
formatter: function(value, row, index, field) {
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 html = renderLink(val, `/stock/item/${row.pk}/`);
return html;
}
},
{
field: 'status',
title: '{% trans "Status" %}',
sortable: 'true',
formatter: function(value, row, index, field) {
return stockStatusDisplay(value);
},
},
{
field: 'batch',
title: '{% trans "Batch" %}',
sortable: true,
},
{
field: 'actions',
switchable: false,
title: '',
formatter: function(value, row) {
var pk = row.pk;
var html = `<div class='btn-group float-right' role='group'>`;
html += makeIconButton('fa-trash-alt icon-red', 'button-uninstall', pk, '{% trans "Uninstall item" %}');
html += `</div>`;
return html;
}
}
],
onLoadSuccess: function() {
var table = $('#installed-table');
// Find buttons and associate actions
table.find('.button-uninstall').click(function() {
var pk = $(this).attr('pk');
launchModalForm(
"{% url 'stock-item-uninstall' %}",
{
data: {
'items[]': [pk],
},
reload: true,
}
);
});
},
buttons: [
'#stock-options',
]
});
$('#multi-item-uninstall').click(function() {
var selections = $('#installed-table').bootstrapTable('getSelections');
var items = [];
selections.forEach(function(item) {
items.push(item.pk);
});
launchModalForm(
"{% url 'stock-item-uninstall' %}",
{
data: {
'items[]': items,
},
reload: true,
}
);
});
{% endblock %}

View File

@ -0,0 +1,28 @@
{% extends "modal_form.html" %}
{% load i18n %}
{% load inventree_extras %}
{% block pre_form_content %}
<div class='alert alert-block alert-success'>
{% trans "The following stock items will be uninstalled" %}
</div>
<ul class='list-group'>
{% for item in stock_items %}
<li class='list-group-item'>
{% include "hover_image.html" with image=item.part.image hover=False %}
{{ item }}
</li>
{% endfor %}
</ul>
{% endblock %}
{% block form_data %}
{% for item in stock_items %}
<input type='hidden' name='stock-item-{{ item.pk }}' value='{{ item.pk }}'/>
{% endfor %}
{% endblock %}

View File

@ -38,4 +38,12 @@
<a href="{% url 'stock-item-children' item.id %}">{% trans "Children" %}{% if item.child_count > 0 %}<span class='badge'>{{ item.child_count }}</span>{% endif %}</a>
</li>
{% endif %}
{% if item.part.assembly or item.installedItemCount > 0 %}
<li {% if tab == 'installed' %} class='active'{% endif %}>
<a href="{% url 'stock-item-installed' item.id %}">
{% trans "Installed Items" %}
{% if item.installedItemCount > 0 %}<span class='badge'>{{ item.installedItemCount }}</span>{% endif %}
</a>
</li>
{% endif %}
</ul>

View File

@ -3,6 +3,8 @@ from django.db.models import Sum
from django.contrib.auth import get_user_model
from django.core.exceptions import ValidationError
import datetime
from .models import StockLocation, StockItem, StockItemTracking
from .models import StockItemTestResult
from part.models import Part
@ -439,13 +441,14 @@ class TestResultTest(StockTest):
self.assertIn(test, result_map.keys())
def test_test_results(self):
item = StockItem.objects.get(pk=522)
status = item.requiredTestStatus()
self.assertEqual(status['total'], 5)
self.assertEqual(status['passed'], 3)
self.assertEqual(status['failed'], 1)
self.assertEqual(status['passed'], 2)
self.assertEqual(status['failed'], 2)
self.assertFalse(item.passedAllRequiredTests())
@ -460,6 +463,18 @@ class TestResultTest(StockTest):
result=True
)
# Still should be failing at this point,
# as the most recent "apply paint" test was False
self.assertFalse(item.passedAllRequiredTests())
# Add a new test result against this required test
StockItemTestResult.objects.create(
stock_item=item,
test='apply paint',
date=datetime.datetime(2022, 12, 12),
result=True
)
self.assertTrue(item.passedAllRequiredTests())
def test_duplicate_item_tests(self):

View File

@ -34,6 +34,7 @@ stock_item_detail_urls = [
url(r'^test/', views.StockItemDetail.as_view(template_name='stock/item_tests.html'), name='stock-item-test-results'),
url(r'^children/', views.StockItemDetail.as_view(template_name='stock/item_childs.html'), name='stock-item-children'),
url(r'^attachments/', views.StockItemDetail.as_view(template_name='stock/item_attachments.html'), name='stock-item-attachments'),
url(r'^installed/', views.StockItemDetail.as_view(template_name='stock/item_installed.html'), name='stock-item-installed'),
url(r'^notes/', views.StockItemNotes.as_view(), name='stock-item-notes'),
url('^.*$', views.StockItemDetail.as_view(), name='stock-item-detail'),
@ -59,6 +60,8 @@ stock_urls = [
url(r'^item/new/?', views.StockItemCreate.as_view(), name='stock-item-create'),
url(r'^item/uninstall/', views.StockItemUninstall.as_view(), name='stock-item-uninstall'),
url(r'^item/test-report-download/', views.StockItemTestReportDownload.as_view(), name='stock-item-test-report-download'),
url(r'^item/print-stock-labels/', views.StockItemPrintLabels.as_view(), name='stock-item-print-labels'),

View File

@ -683,6 +683,139 @@ class StockItemQRCode(QRCodeView):
return None
class StockItemUninstall(AjaxView, FormMixin):
"""
View for uninstalling one or more StockItems,
which are installed in another stock item.
Stock items are uninstalled into a location,
defaulting to the location that they were "in" before they were installed.
If multiple default locations are detected,
leave the final location up to the user.
"""
ajax_template_name = 'stock/stock_uninstall.html'
ajax_form_title = _('Uninstall Stock Items')
form_class = StockForms.UninstallStockForm
# List of stock items to uninstall (initially empty)
stock_items = []
def get_stock_items(self):
return self.stock_items
def get_initial(self):
initials = super().get_initial().copy()
# Keep track of the current locations of stock items
current_locations = set()
# Keep track of the default locations for stock items
default_locations = set()
for item in self.stock_items:
if item.location:
current_locations.add(item.location)
if item.part.default_location:
default_locations.add(item.part.default_location)
if len(current_locations) == 1:
# If the selected stock items are currently in a single location,
# select that location as the destination.
initials['location'] = next(iter(current_locations))
elif len(current_locations) == 0:
# There are no current locations set
if len(default_locations) == 1:
# Select the single default location
initials['location'] = next(iter(default_locations))
return initials
def get(self, request, *args, **kwargs):
""" Extract list of stock items, which are supplied as a list,
e.g. items[]=1,2,3
"""
if 'items[]' in request.GET:
self.stock_items = StockItem.objects.filter(id__in=request.GET.getlist('items[]'))
else:
self.stock_items = []
return self.renderJsonResponse(request, self.get_form())
def post(self, request, *args, **kwargs):
"""
Extract a list of stock items which are included as hidden inputs in the form data.
"""
items = []
for item in self.request.POST:
if item.startswith('stock-item-'):
pk = item.replace('stock-item-', '')
try:
stock_item = StockItem.objects.get(pk=pk)
items.append(stock_item)
except (ValueError, StockItem.DoesNotExist):
pass
self.stock_items = items
# Assume the form is valid, until it isn't!
valid = True
confirmed = str2bool(request.POST.get('confirm'))
note = request.POST.get('note', '')
location = request.POST.get('location', None)
if location:
try:
location = StockLocation.objects.get(pk=location)
except (ValueError, StockLocation.DoesNotExist):
location = None
if not location:
# Location is required!
valid = False
form = self.get_form()
if not confirmed:
valid = False
form.errors['confirm'] = [_('Confirm stock adjustment')]
data = {
'form_valid': valid,
}
if valid:
# Ok, now let's actually uninstall the stock items
for item in self.stock_items:
item.uninstallIntoLocation(location, request.user, note)
data['success'] = _('Uninstalled stock items')
return self.renderJsonResponse(request, form=form, data=data)
def get_context_data(self):
context = super().get_context_data()
context['stock_items'] = self.get_stock_items()
return context
class StockAdjust(AjaxView, FormMixin):
""" View for enacting simple stock adjustments:
@ -1037,8 +1170,9 @@ class StockItemEdit(AjaxUpdateView):
query = query.filter(part=item.part.id)
form.fields['supplier_part'].queryset = query
if not item.part.trackable or not item.serialized:
form.fields.pop('serial')
# Hide the serial number field if it is not required
if not item.part.trackable and not item.serialized:
form.fields['serial'].widget = HiddenInput()
return form

View File

@ -238,7 +238,7 @@ function loadStockTable(table, options) {
var filters = {};
var filterKey = options.filterKey || "stock";
var filterKey = options.filterKey || options.name || "stock";
if (!options.disableFilters) {
filters = loadTableFilters(filterKey);
@ -416,14 +416,6 @@ function loadStockTable(table, options) {
visible: false,
switchable: false,
},
{
field: 'IPN',
title: 'IPN',
sortable: true,
formatter: function(value, row, index, field) {
return row.part_detail.IPN;
},
},
{
field: 'part_name',
title: '{% trans "Part" %}',
@ -439,6 +431,14 @@ function loadStockTable(table, options) {
return html;
}
},
{
field: 'IPN',
title: 'IPN',
sortable: true,
formatter: function(value, row, index, field) {
return row.part_detail.IPN;
},
},
{
field: 'part_description',
title: '{% trans "Description" %}',
@ -512,18 +512,22 @@ function loadStockTable(table, options) {
title: '{% trans "Location" %}',
sortable: true,
formatter: function(value, row, index, field) {
if (value) {
if (row.belongs_to) {
var text = "{% trans 'Installed in Stock Item ' %}" + row.belongs_to;
var url = `/stock/item/${row.belongs_to}/installed/`;
return renderLink(text, url);
} else if (row.customer) {
var text = "{% trans "Shipped to customer" %}";
return renderLink(text, `/company/${row.customer}/assigned-stock/`);
}
else if (value) {
return renderLink(value, `/stock/location/${row.location}/`);
}
else {
if (row.customer) {
var text = "{% trans "Shipped to customer" %}";
return renderLink(text, `/company/${row.customer}/assigned-stock/`);
} else {
return '<i>{% trans "No stock location set" %}</i>';
}
}
}
},
{
field: 'notes',

View File

@ -30,6 +30,10 @@ function getAvailableTableFilters(tableKey) {
title: "{% trans "Serial number" %}",
description: "{% trans "Serial number" %}"
},
batch: {
title: '{% trans "Batch" %}',
description: '{% trans "Batch code" %}',
},
};
}
@ -61,6 +65,11 @@ function getAvailableTableFilters(tableKey) {
title: '{% trans "In Stock" %}',
description: '{% trans "Show items which are in stock" %}',
},
installed: {
type: 'bool',
title: '{% trans "Installed" %}',
description: '{% trans "Show stock items which are installed in another item" %}',
},
sent_to_customer: {
type: 'bool',
title: '{% trans "Sent to customer" %}',
@ -87,6 +96,10 @@ function getAvailableTableFilters(tableKey) {
title: '{% trans "Stock status" %}',
description: '{% trans "Stock status" %}',
},
batch: {
title: '{% trans "Batch" %}',
description: '{% trans "Batch code" %}',
}
};
}

View File

@ -9,32 +9,28 @@ 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.
# Documentation
For InvenTree documentation, refer to the [InvenTree documentation website](https://inventree.readthedocs.io/en/latest/).
## Getting Started
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 [InvenTree documentation website](https://inventree.github.io).
Refer to the [getting started guide](https://inventree.readthedocs.io/en/latest/start/install/) for installation and setup instructions.
## Integration
InvenTree is designed to be extensible, and provides multiple options for integration with external applications or addition of custom plugins:
* [InvenTree API](https://inventree.github.io/docs/extend/api)
* [Python module](https://inventree.github.io/docs/extend/python)
* [Plugin interface](https://inventree.github.io/docs/extend/plugins)
* [Third party](https://inventree.github.io/docs/extend/integrate)
* [InvenTree API](https://inventree.readthedocs.io/en/latest/extend/api/)
* [Python module](https://inventree.readthedocs.io/en/latest/extend/python)
* [Plugin interface](https://inventree.readthedocs.io/en/latest/extend/plugins)
* [Third party](https://inventree.readthedocs.io/en/latest/extend/integrate)
## Developer Documentation
# Contributing
For code documentation, refer to the [developer documentation](http://inventree.readthedocs.io/en/latest/).
Contributions are welcomed and encouraged. Please help to make this project even better! Refer to the [contribution page](https://inventree.readthedocs.io/en/latest/contribute/).
## Contributing
Contributions are welcomed and encouraged. Please help to make this project even better! Refer to the [contribution page](https://inventree.github.io/pages/contribute).
## Donate
# Donate
If you use InvenTree and find it to be useful, please consider making a donation toward its continued development.

View File

@ -256,4 +256,4 @@ def server(c, address="127.0.0.1:8000"):
Note: This is *not* sufficient for a production installation.
"""
manage(c, "runserver {address}".format(address=address))
manage(c, "runserver {address}".format(address=address), pty=True)