Merge branch 'master' of https://github.com/inventree/InvenTree into price-history

This commit is contained in:
Matthias 2021-05-04 00:02:15 +02:00
commit 75f487aee9
29 changed files with 279 additions and 139 deletions

View File

@ -357,6 +357,8 @@ def extract_serial_numbers(serials, expected_quantity):
- Serial numbers must be positive
- Serial numbers can be split by whitespace / newline / commma chars
- Serial numbers can be supplied as an inclusive range using hyphen char e.g. 10-20
- Serial numbers can be supplied as <start>+ for getting all expecteded numbers starting from <start>
- Serial numbers can be supplied as <start>+<length> for getting <length> numbers starting from <start>
Args:
expected_quantity: The number of (unique) serial numbers we expect
@ -369,6 +371,13 @@ def extract_serial_numbers(serials, expected_quantity):
numbers = []
errors = []
# helpers
def number_add(n):
if n in numbers:
errors.append(_('Duplicate serial: {n}').format(n=n))
else:
numbers.append(n)
try:
expected_quantity = int(expected_quantity)
except ValueError:
@ -395,10 +404,7 @@ def extract_serial_numbers(serials, expected_quantity):
if a < b:
for n in range(a, b + 1):
if n in numbers:
errors.append(_('Duplicate serial: {n}').format(n=n))
else:
numbers.append(n)
number_add(n)
else:
errors.append(_("Invalid group: {g}").format(g=group))
@ -409,6 +415,31 @@ def extract_serial_numbers(serials, expected_quantity):
errors.append(_("Invalid group: {g}").format(g=group))
continue
# plus signals either
# 1: 'start+': expected number of serials, starting at start
# 2: 'start+number': number of serials, starting at start
elif '+' in group:
items = group.split('+')
# case 1, 2
if len(items) == 2:
start = int(items[0])
# case 2
if bool(items[1]):
end = start + int(items[1]) + 1
# case 1
else:
end = start + expected_quantity
for n in range(start, end):
number_add(n)
# no case
else:
errors.append(_("Invalid group: {g}").format(g=group))
continue
else:
if group in numbers:
errors.append(_("Duplicate serial: {g}".format(g=group)))

View File

@ -491,7 +491,7 @@ LANGUAGES = [
('en', _('English')),
('fr', _('French')),
('de', _('German')),
('pk', _('Polish')),
('pl', _('Polish')),
('tr', _('Turkish')),
]

View File

@ -244,6 +244,14 @@ class TestSerialNumberExtraction(TestCase):
self.assertIn(3, sn)
self.assertIn(13, sn)
sn = e("1+", 10)
self.assertEqual(len(sn), 10)
self.assertEqual(sn, [_ for _ in range(1, 11)])
sn = e("4, 1+2", 4)
self.assertEqual(len(sn), 4)
self.assertEqual(sn, ["4", 1, 2, 3])
def test_failures(self):
e = helpers.extract_serial_numbers

View File

@ -39,7 +39,7 @@ from rest_framework.documentation import include_docs_urls
from .views import IndexView, SearchView, DatabaseStatsView
from .views import SettingsView, EditUserView, SetPasswordView
from .views import ColorThemeSelectView, SettingCategorySelectView
from .views import AppearanceSelectView, SettingCategorySelectView
from .views import DynamicJsView
from common.views import SettingEdit
@ -79,7 +79,8 @@ apipatterns = [
settings_urls = [
url(r'^user/?', SettingsView.as_view(template_name='InvenTree/settings/user.html'), name='settings-user'),
url(r'^theme/?', ColorThemeSelectView.as_view(), name='settings-theme'),
url(r'^appearance/?', AppearanceSelectView.as_view(), name='settings-appearance'),
url(r'^i18n/?', include('django.conf.urls.i18n')),
url(r'^global/?', SettingsView.as_view(template_name='InvenTree/settings/global.html'), name='settings-global'),
url(r'^report/?', SettingsView.as_view(template_name='InvenTree/settings/report.html'), name='settings-report'),

View File

@ -769,12 +769,12 @@ class SettingsView(TemplateView):
return ctx
class ColorThemeSelectView(FormView):
class AppearanceSelectView(FormView):
""" View for selecting a color theme """
form_class = ColorThemeSelectForm
success_url = reverse_lazy('settings-theme')
template_name = "InvenTree/settings/theme.html"
success_url = reverse_lazy('settings-appearance')
template_name = "InvenTree/settings/appearance.html"
def get_user_theme(self):
""" Get current user color theme """
@ -788,7 +788,7 @@ class ColorThemeSelectView(FormView):
def get_initial(self):
""" Select current user color theme as initial choice """
initial = super(ColorThemeSelectView, self).get_initial()
initial = super(AppearanceSelectView, self).get_initial()
user_theme = self.get_user_theme()
if user_theme:

View File

@ -996,14 +996,28 @@ class Build(MPTTModel):
@property
def required_parts(self):
""" Returns a dict of parts required to build this part (BOM) """
""" Returns a list of parts required to build this part (BOM) """
parts = []
for item in self.part.bom_items.all().prefetch_related('sub_part'):
for item in self.bom_items:
parts.append(item.sub_part)
return parts
@property
def required_parts_to_complete_build(self):
""" Returns a list of parts required to complete the full build """
parts = []
for bom_item in self.bom_items:
# Get remaining quantity needed
required_quantity_to_complete_build = self.remaining * bom_item.quantity
# Compare to net stock
if bom_item.sub_part.net_stock < required_quantity_to_complete_build:
parts.append(bom_item.sub_part)
return parts
def availableStockItems(self, part, output):
"""
Returns stock items which are available for allocation to this build.

View File

@ -40,8 +40,8 @@
<div class='panel-heading'>
{% trans "The following items will be created" %}
</div>
<div class='panel-content'>
{% include "hover_image.html" with image=build.part.image hover=True %}
<div class='panel-content' style='padding-bottom:16px'>
{% include "hover_image.html" with image=build.part.image %}
{% if output.serialized %}
{{ output.part.full_name }} - {% trans "Serial Number" %} {{ output.serial }}
{% else %}

View File

@ -157,6 +157,17 @@ class BuildOutputCreate(AjaxUpdateView):
quantity = form.cleaned_data.get('output_quantity', None)
serials = form.cleaned_data.get('serial_numbers', None)
if quantity:
build = self.get_object()
# Check that requested output don't exceed build remaining quantity
maximum_output = int(build.remaining - build.incomplete_count)
if quantity > maximum_output:
form.add_error(
'output_quantity',
_('Maximum output quantity is ') + str(maximum_output),
)
# Check that the serial numbers are valid
if serials:
try:
@ -212,7 +223,7 @@ class BuildOutputCreate(AjaxUpdateView):
# Calculate the required quantity
quantity = max(0, build.remaining - build.incomplete_count)
initials['output_quantity'] = quantity
initials['output_quantity'] = int(quantity)
return initials

View File

@ -131,7 +131,7 @@ class ManufacturerPartList(generics.ListCreateAPIView):
params = self.request.query_params
# Filter by manufacturer
manufacturer = params.get('company', None)
manufacturer = params.get('manufacturer', None)
if manufacturer is not None:
queryset = queryset.filter(manufacturer=manufacturer)

View File

@ -6,7 +6,7 @@ Company database model definitions
from __future__ import unicode_literals
import os
import decimal
import math
from django.utils.translation import ugettext_lazy as _
@ -566,12 +566,15 @@ class SupplierPart(models.Model):
- If order multiples are to be observed, then we need to calculate based on that, too
"""
price_breaks = self.price_breaks.filter(quantity__lte=quantity)
price_breaks = self.price_breaks.all()
# No price break information available?
if len(price_breaks) == 0:
return None
# Check if quantity is fraction and disable multiples
multiples = (quantity % 1 == 0)
# Order multiples
if multiples:
quantity = int(math.ceil(quantity / self.multiple) * self.multiple)
@ -584,7 +587,12 @@ class SupplierPart(models.Model):
# Default currency selection
currency = common.models.InvenTreeSetting.get_setting('INVENTREE_DEFAULT_CURRENCY')
pb_min = None
for pb in self.price_breaks.all():
# Store smallest price break
if not pb_min:
pb_min = pb
# Ignore this pricebreak (quantity is too high)
if pb.quantity > quantity:
continue
@ -598,6 +606,17 @@ class SupplierPart(models.Model):
# Convert everything to the selected currency
pb_cost = pb.convert_to(currency)
# Use smallest price break
if not pb_found and pb_min:
# Update price break information
pb_quantity = pb_min.quantity
pb_cost = pb_min.convert_to(currency)
# Trigger cost calculation using smallest price break
pb_found = True
# Convert quantity to decimal.Decimal format
quantity = decimal.Decimal(f'{quantity}')
if pb_found:
cost = pb_cost * quantity
return normalize(cost + self.base_cost)
@ -675,4 +694,4 @@ class SupplierPriceBreak(common.models.PriceBreak):
db_table = 'part_supplierpricebreak'
def __str__(self):
return f'{self.part.MPN} - {self.price} @ {self.quantity}'
return f'{self.part.SKU} - {self.price} @ {self.quantity}'

View File

@ -192,8 +192,9 @@ class SupplierPartSerializer(InvenTreeModelSerializer):
manufacturer_id = self.initial_data.get('manufacturer', None)
MPN = self.initial_data.get('MPN', None)
if manufacturer_id or MPN:
kwargs = {'manufacturer': manufacturer_id,
if manufacturer_id and MPN:
kwargs = {
'manufacturer': manufacturer_id,
'MPN': MPN,
}
supplier_part.save(**kwargs)

View File

@ -100,7 +100,7 @@ class ManufacturerTest(InvenTreeAPITestCase):
self.assertEqual(response.data['MPN'], 'MPN_TEST')
# Filter by manufacturer
data = {'company': 7}
data = {'manufacturer': 7}
response = self.get(url, data)
self.assertEqual(len(response.data), 3)

View File

@ -5,6 +5,7 @@ from django.test import TestCase
from django.core.exceptions import ValidationError
import os
from decimal import Decimal
from .models import Company, Contact, ManufacturerPart, SupplierPart
from .models import rename_company_image
@ -103,8 +104,9 @@ class CompanySimpleTest(TestCase):
self.assertEqual(p(100), 350)
p = self.acme0002.get_price
self.assertEqual(p(1), None)
self.assertEqual(p(2), None)
self.assertEqual(p(0.5), 3.5)
self.assertEqual(p(1), 7)
self.assertEqual(p(2), 14)
self.assertEqual(p(5), 35)
self.assertEqual(p(45), 315)
self.assertEqual(p(55), 68.75)
@ -112,6 +114,7 @@ class CompanySimpleTest(TestCase):
def test_part_pricing(self):
m2x4 = Part.objects.get(name='M2x4 LPHS')
self.assertEqual(m2x4.get_price_info(5.5), "38.5 - 41.25")
self.assertEqual(m2x4.get_price_info(10), "70 - 75")
self.assertEqual(m2x4.get_price_info(100), "125 - 350")
@ -121,7 +124,8 @@ class CompanySimpleTest(TestCase):
m3x12 = Part.objects.get(name='M3x12 SHCS')
self.assertIsNone(m3x12.get_price_info(3))
self.assertEqual(m3x12.get_price_info(0.3), Decimal('2.4'))
self.assertEqual(m3x12.get_price_info(3), Decimal('24'))
self.assertIsNotNone(m3x12.get_price_info(50))
def test_currency_validation(self):

View File

@ -344,14 +344,16 @@ class PurchaseOrder(Order):
raise ValidationError({"status": _("Lines can only be received against an order marked as 'Placed'")})
try:
if not (quantity % 1 == 0):
raise ValidationError({"quantity": _("Quantity must be an integer")})
if quantity < 0:
raise ValidationError({"quantity": _("Quantity must be a positive number")})
quantity = int(quantity)
if quantity <= 0:
raise ValidationError({"quantity": _("Quantity must be greater than zero")})
except ValueError:
except (ValueError, TypeError):
raise ValidationError({"quantity": _("Invalid quantity provided")})
# Create a new stock item
if line.part:
if line.part and quantity > 0:
stock = stock_models.StockItem(
part=line.part.part,
supplier_part=line.part,

View File

@ -171,11 +171,35 @@ $("#edit-order").click(function() {
);
});
$("#receive-order").click(function() {
launchModalForm("{% url 'po-receive' order.id %}", {
reload: true,
secondary: [
{
field: 'location',
label: '{% trans "New Location" %}',
title: '{% trans "Create new stock location" %}',
url: "{% url 'stock-location-create' %}",
},
]
});
});
$("#complete-order").click(function() {
launchModalForm("{% url 'po-complete' order.id %}", {
reload: true,
});
});
$("#cancel-order").click(function() {
launchModalForm("{% url 'po-cancel' order.id %}", {
reload: true,
});
});
$("#export-order").click(function() {
location.href = "{% url 'po-export' order.id %}";
});
{% endblock %}

View File

@ -4,6 +4,8 @@
{% block pre_form_content %}
{% trans "Cancelling this order means that the order will no longer be editable." %}
<div class='alert alert-danger alert-block'>
{% trans "Cancelling this order means that the order and line items will no longer be editable." %}
</div>
{% endblock %}

View File

@ -6,9 +6,9 @@
{% trans 'Mark this order as complete?' %}
{% if not order.is_complete %}
<div class='alert alert-warning alert-block'>
{% trans 'This order has line items which have not been marked as received.' %}
{% trans 'Marking this order as complete will remove these line items.' %}
<div class='alert alert-warning alert-block' style='margin-top:12px'>
{% trans 'This order has line items which have not been marked as received.' %}</br>
{% trans 'Completing this order means that the order and line items will no longer be editable.' %}
</div>
{% endif %}

View File

@ -4,6 +4,8 @@
{% block pre_form_content %}
{% trans 'After placing this purchase order, line items will no longer be editable.' %}
<div class='alert alert-warning alert-block'>
{% trans 'After placing this purchase order, line items will no longer be editable.' %}
</div>
{% endblock %}

View File

@ -35,31 +35,6 @@
{{ block.super }}
$("#receive-order").click(function() {
launchModalForm("{% url 'po-receive' order.id %}", {
reload: true,
secondary: [
{
field: 'location',
label: '{% trans "New Location" %}',
title: '{% trans "Create new stock location" %}',
url: "{% url 'stock-location-create' %}",
},
]
});
});
$("#complete-order").click(function() {
launchModalForm("{% url 'po-complete' order.id %}", {
reload: true,
});
});
$("#export-order").click(function() {
location.href = "{% url 'po-export' order.id %}";
});
{% if order.status == PurchaseOrderStatus.PENDING %}
$('#new-po-line').click(function() {
launchModalForm("{% url 'po-line-item-create' %}",
@ -261,5 +236,4 @@ $("#po-table").inventreeTable({
]
});
{% endblock %}

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, Prefetch, Sum
from django.db.models import Q, F, Count
from django.utils.translation import ugettext_lazy as _
from rest_framework import status
@ -635,29 +635,15 @@ class PartList(generics.ListCreateAPIView):
# TODO: Need to figure out a cheaper way of making this filter query
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)
# Get active builds
builds = Build.objects.filter(status__in=BuildStatus.ACTIVE_CODES)
# Store parts with builds needing stock
parts_need_stock = []
parts_needed_to_complete_builds = []
# Filter required parts
for build in builds:
parts_needed_to_complete_builds += [part.pk for part in build.required_parts_to_complete_build]
# Find parts with active builds
# where any subpart's stock is lower than 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)
queryset = queryset.filter(pk__in=parts_needed_to_complete_builds)
# Optionally limit the maximum number of returned results
# e.g. for displaying "recent part" list

View File

@ -116,6 +116,12 @@ def inventree_docs_url(*args, **kwargs):
return "https://inventree.readthedocs.io/"
@register.simple_tag()
def inventree_credits_url(*args, **kwargs):
""" Return URL for InvenTree credits site """
return "https://inventree.readthedocs.io/en/latest/credits/"
@register.simple_tag()
def setting_object(key, *args, **kwargs):
"""

View File

@ -1959,6 +1959,11 @@ class PartPricing(AjaxView):
role_required = ['sales_order.view', 'part.view']
def get_quantity(self):
""" Return set quantity in decimal format """
return Decimal(self.request.POST.get('quantity', 1))
def get_part(self):
try:
return Part.objects.get(id=self.kwargs['pk'])
@ -1967,12 +1972,12 @@ class PartPricing(AjaxView):
def get_pricing(self, quantity=1, currency=None):
try:
quantity = int(quantity)
except ValueError:
quantity = 1
# try:
# quantity = int(quantity)
# except ValueError:
# quantity = 1
if quantity < 1:
if quantity <= 0:
quantity = 1
# TODO - Capacity for price comparison in different currencies
@ -2002,16 +2007,19 @@ class PartPricing(AjaxView):
min_buy_price /= scaler
max_buy_price /= scaler
min_unit_buy_price = round(min_buy_price / quantity, 3)
max_unit_buy_price = round(max_buy_price / quantity, 3)
min_buy_price = round(min_buy_price, 3)
max_buy_price = round(max_buy_price, 3)
if min_buy_price:
ctx['min_total_buy_price'] = min_buy_price
ctx['min_unit_buy_price'] = min_buy_price / quantity
ctx['min_unit_buy_price'] = min_unit_buy_price
if max_buy_price:
ctx['max_total_buy_price'] = max_buy_price
ctx['max_unit_buy_price'] = max_buy_price / quantity
ctx['max_unit_buy_price'] = max_unit_buy_price
# BOM pricing information
if part.bom_count > 0:
@ -2024,16 +2032,19 @@ class PartPricing(AjaxView):
min_bom_price /= scaler
max_bom_price /= scaler
min_unit_bom_price = round(min_bom_price / quantity, 3)
max_unit_bom_price = round(max_bom_price / quantity, 3)
min_bom_price = round(min_bom_price, 3)
max_bom_price = round(max_bom_price, 3)
if min_bom_price:
ctx['min_total_bom_price'] = min_bom_price
ctx['min_unit_bom_price'] = min_bom_price / quantity
ctx['min_unit_bom_price'] = min_unit_bom_price
if max_bom_price:
ctx['max_total_bom_price'] = max_bom_price
ctx['max_unit_bom_price'] = max_bom_price / quantity
ctx['max_unit_bom_price'] = max_unit_bom_price
# Stock history
if part_settings.part_show_graph and part.total_stock > 1:
@ -2077,10 +2088,11 @@ class PartPricing(AjaxView):
currency = None
try:
quantity = int(self.request.POST.get('quantity', 1))
except ValueError:
quantity = 1
quantity = self.get_quantity()
# Retain quantity value set by user
form = self.form_class()
form.fields['quantity'].initial = quantity
# TODO - How to handle pricing in different currencies?
currency = None
@ -2090,7 +2102,7 @@ class PartPricing(AjaxView):
'form_valid': False,
}
return self.renderJsonResponse(request, self.form_class(), data=data, context=self.get_pricing(quantity, currency))
return self.renderJsonResponse(request, form, data=data, context=self.get_pricing(quantity, currency))
class PartParameterTemplateCreate(AjaxCreateView):

View File

@ -0,0 +1,67 @@
{% extends "InvenTree/settings/settings.html" %}
{% load i18n %}
{% load inventree_extras %}
{% block tabs %}
{% include "InvenTree/settings/tabs.html" with tab='theme' %}
{% endblock %}
{% block subtitle %}
{% trans "Theme Settings" %}
{% endblock %}
{% block settings %}
<div class='row'>
<div class='col-sm-6'>
<h4>{% trans "Color Themes" %}</h4>
</div>
</div>
<form action="{% url 'settings-appearance' %}" method="post">
{% csrf_token %}
{% load crispy_forms_tags %}
{% crispy form %}
</form>
{% if invalid_color_theme %}
<div class="alert alert-danger alert-block" role="alert" style="display: inline-block;">
{% blocktrans %}
The CSS sheet "{{invalid_color_theme}}.css" for the currently selected color theme was not found.<br>
Please select another color theme :)
{% endblocktrans %}
</div>
{% endif %}
<div class='row'>
<div class='col-sm-6'>
<h4>{% trans "Language" %}</h4>
</div>
</div>
<div class="row">
<form action="{% url 'set_language' %}" method="post">{% csrf_token %}
<input name="next" type="hidden" value="{% url 'settings-appearance' %}">
<div class="col-sm-6" style="width: 200px;"><div id="div_id_name" class="form-group"><div class="controls ">
<select name="language" class="select form-control">
{% get_current_language as LANGUAGE_CODE %}
{% get_available_languages as LANGUAGES %}
{% get_language_info_list for LANGUAGES as languages %}
{% for language in languages %}
<option value="{{ language.code }}"{% if language.code == LANGUAGE_CODE %} selected{% endif %}>
{{ language.name_local }} ({{ language.code }})
</option>
{% endfor %}
</select>
</div></div></div>
<div class="col-sm-6" style="width: auto;">
<input type="submit" value="{% trans 'Set Language' %}" class="btn btn btn-primary">
</div>
</form>
</div>
{% endblock %}

View File

@ -6,7 +6,7 @@
<a href="{% url 'settings-user' %}"><span class='fas fa-user'></span> {% trans "Account" %}</a>
</li>
<li{% ifequal tab 'theme' %} class='active'{% endifequal %}>
<a href="{% url 'settings-theme' %}"><span class='fas fa-fill'></span> {% trans "Theme" %}</a>
<a href="{% url 'settings-appearance' %}"><span class='fas fa-fill'></span> {% trans "Appearance" %}</a>
</li>
</ul>
{% if user.is_staff %}

View File

@ -1,36 +0,0 @@
{% extends "InvenTree/settings/settings.html" %}
{% load i18n %}
{% load inventree_extras %}
{% block tabs %}
{% include "InvenTree/settings/tabs.html" with tab='theme' %}
{% endblock %}
{% block subtitle %}
{% trans "Theme Settings" %}
{% endblock %}
{% block settings %}
<div class='row'>
<div class='col-sm-6'>
<h4>{% trans "Color Themes" %}</h4>
</div>
</div>
<form action="{% url 'settings-theme' %}" method="post">
{% csrf_token %}
{% load crispy_forms_tags %}
{% crispy form %}
</form>
{% if invalid_color_theme %}
<div class="alert alert-danger alert-block" role="alert" style="display: inline-block;">
{% blocktrans %}
The CSS sheet "{{invalid_color_theme}}.css" for the currently selected color theme was not found.<br>
Please select another color theme :)
{% endblocktrans %}
</div>
{% endif %}
{% endblock %}

View File

@ -58,6 +58,11 @@
<td>{% trans "View Code on GitHub" %}</td>
<td><a href="{% inventree_github_url %}">{% inventree_github_url %}</a></td>
</tr>
<tr>
<td><span class='fas fa-balance-scale'></span></td>
<td>{% trans "Credits" %}</td>
<td><a href="{% inventree_credits_url %}">{% inventree_credits_url %}</a></td>
</tr>
<tr>
<td><span class='fas fa-mobile-alt'></span></td>
<td>{% trans "Mobile App" %}</td>

View File

@ -16,7 +16,7 @@
</button>
{% if owner_control.value == "True" and user in owners or user.is_superuser or owner_control.value == "False" %}
{% if roles.stock.add %}
{% if not read_only and roles.stock.add %}
<button class="btn btn-success" id='item-create' title='{% trans "New Stock Item" %}'>
<span class='fas fa-plus-circle'></span>
</button>
@ -44,6 +44,7 @@
{% endif %}
</ul>
</div>
{% if not read_only %}
{% if roles.stock.change or roles.stock.delete %}
<div class="btn-group">
<button id='stock-options' class="btn btn-primary dropdown-toggle" type="button" data-toggle="dropdown" title='{% trans "Stock Options" %}'>
@ -65,6 +66,7 @@
</div>
{% endif %}
{% endif %}
{% endif %}
<div class='filter-list' id='filter-list-stock'>
<!-- An empty div in which the filter list will be constructed -->
</div>

View File

@ -51,11 +51,15 @@ To contribute to the translation effort, navigate to the [InvenTree crowdin proj
For InvenTree documentation, refer to the [InvenTree documentation website](https://inventree.readthedocs.io/en/latest/).
## Getting Started
# Getting Started
Refer to the [getting started guide](https://inventree.readthedocs.io/en/latest/start/install/) for installation and setup instructions.
## Integration
# Credits
The credits for all used packages are part of the [InvenTree documentation website](https://inventree.readthedocs.io/en/latest/credits/).
# Integration
InvenTree is designed to be extensible, and provides multiple options for integration with external applications or addition of custom plugins:

View File

@ -248,6 +248,7 @@ def content_excludes():
"django_q.schedule",
"django_q.task",
"django_q.ormq",
"users.owner",
]
output = ""