Merge branch 'master' of https://github.com/inventree/InvenTree into matmair/issue2788

This commit is contained in:
Matthias 2022-05-04 12:33:21 +02:00
commit 1a3d482e42
No known key found for this signature in database
GPG Key ID: AB6D0E6C4CB65093
45 changed files with 985 additions and 787 deletions

View File

@ -17,7 +17,7 @@ The HEAD of the "main" or "master" branch of InvenTree represents the current "l
**No pushing to master:** New featues must be submitted as a pull request from a separate branch (one branch per feature).
#### Feature Branches
### Feature Branches
Feature branches should be branched *from* the *master* branch.
@ -45,7 +45,7 @@ The HEAD of the "stable" branch represents the latest stable release code.
- The bugfix *must* also be cherry picked into the *master* branch.
## Environment
#### Target version
### Target version
We are currently targeting:
| Name | Minimum version |
|---|---|
@ -65,7 +65,7 @@ pyupgrade `find . -name "*.py"`
django-upgrade --target-version 3.2 `find . -name "*.py"`
```
### Credits
## Credits
If you add any new dependencies / libraries, they need to be added to [the docs](https://github.com/inventree/inventree-docs/blob/master/docs/credits.md). Please try to do that as timely as possible.
@ -124,4 +124,41 @@ HTML and javascript files are passed through the django templating engine. Trans
{% load i18n %}
<span>{% trans "This string will be translated" %} - this string will not!</span>
```
```
## Github use
### Tags
The tags describe issues and PRs in multiple areas:
| Area | Name | Description |
|---|---|---|
| Type Labels | | |
| | bug | Identifies a bug which needs to be addressed |
| | dependency | Relates to a project dependency |
| | duplicate | Duplicate of another issue or PR |
| | enhancement | This is an suggested enhancement or new feature |
| | help wanted | Assistance required |
| | invalid | This issue or PR is considered invalid |
| | inactive | Indicates lack of activity |
| | question | This is a question |
| | roadmap | This is a roadmap feature with no immediate plans for implementation |
| | security | Relates to a security issue |
| | starter | Good issue for a developer new to the project |
| | wontfix | No work will be done against this issue or PR |
| Feature Labels | | |
| | API | Relates to the API |
| | barcode | Barcode scanning and integration |
| | build | Build orders |
| | importer | Data importing and processing |
| | order | Purchase order and sales orders |
| | part | Parts |
| | plugin | Plugin ecosystem |
| | pricing | Pricing functionality |
| | report | Report generation |
| | stock | Stock item management |
| | user interface | User interface |
| Ecosystem Labels | | |
| | demo | Relates to the InvenTree demo server or dataset |
| | docker | Docker / docker-compose |
| | CI | CI / unit testing ecosystem |
| | setup | Relates to the InvenTree setup / installation process |

View File

@ -537,7 +537,7 @@ def extract_serial_numbers(serials, expected_quantity, next_number: int):
# The number of extracted serial numbers must match the expected quantity
if not expected_quantity == len(numbers):
raise ValidationError([_("Number of unique serial number ({s}) must match quantity ({q})").format(s=len(numbers), q=expected_quantity)])
raise ValidationError([_("Number of unique serial numbers ({s}) must match quantity ({q})").format(s=len(numbers), q=expected_quantity)])
return numbers

View File

@ -1177,7 +1177,7 @@ class BuildItem(models.Model):
a = normalize(self.stock_item.quantity)
raise ValidationError({
'quantity': _(f'Allocated quantity ({q}) must not execed available stock quantity ({a})')
'quantity': _(f'Allocated quantity ({q}) must not exceed available stock quantity ({a})')
})
# Allocated quantity cannot cause the stock item to be over-allocated

View File

@ -387,7 +387,7 @@ class BuildOutputCompleteSerializer(serializers.Serializer):
default=False,
required=False,
label=_('Accept Incomplete Allocation'),
help_text=_('Complete ouputs if stock has not been fully allocated'),
help_text=_('Complete outputs if stock has not been fully allocated'),
)
notes = serializers.CharField(

View File

@ -546,14 +546,6 @@ $('#allocate-selected-items').click(function() {
);
});
$("#btn-order-parts").click(function() {
launchModalForm("/order/purchase-order/order-parts/", {
data: {
build: {{ build.id }},
},
});
});
{% endif %}
enableSidebar('buildorder');

View File

@ -312,7 +312,7 @@ class SupplierPartList(generics.ListCreateAPIView):
try:
params = self.request.query_params
kwargs['part_detail'] = str2bool(params.get('part_detail', None))
kwargs['supplier_detail'] = str2bool(params.get('supplier_detail', None))
kwargs['supplier_detail'] = str2bool(params.get('supplier_detail', True))
kwargs['manufacturer_detail'] = str2bool(params.get('manufacturer_detail', None))
kwargs['pretty'] = str2bool(params.get('pretty', None))
except AttributeError:

View File

@ -325,14 +325,14 @@
var parts = [];
selections.forEach(function(item) {
parts.push(item.part);
var part = item.part_detail;
part.manufacturer_part = item.pk;
parts.push(part);
});
launchModalForm("/order/purchase-order/order-parts/", {
data: {
parts: parts,
},
});
orderParts(
parts,
);
});
{% endif %}
@ -396,14 +396,16 @@
var parts = [];
selections.forEach(function(item) {
parts.push(item.part);
var part = item.part_detail;
parts.push(part);
});
launchModalForm("/order/purchase-order/order-parts/", {
data: {
parts: parts,
},
});
orderParts(
parts,
{
supplier: {{ company.pk }},
}
);
});
{% endif %}

View File

@ -31,13 +31,11 @@
{% include "admin_button.html" with url=url %}
{% endif %}
{% if roles.purchase_order.change %}
{% comment "for later" %}
{% if roles.purchase_order.add %}
{% if roles.purchase_order.add and part.part.purchaseable %}
<button type='button' class='btn btn-outline-secondary' id='order-part' title='{% trans "Order part" %}'>
<span class='fas fa-shopping-cart'></span>
</button>
{% endif %}
{% endcomment %}
<button type='button' class='btn btn-outline-secondary' id='edit-part' title='{% trans "Edit manufacturer part" %}'>
<span class='fas fa-edit icon-green'/>
</button>
@ -134,6 +132,7 @@ src="{% static 'img/blank_image.png' %}"
<li><a class='dropdown-item' href='#' id='supplier-part-delete' title='{% trans "Delete supplier parts" %}'><span class='fas fa-trash-alt icon-red'></span> {% trans "Delete" %}</a></li>
</ul>
</div>
{% include "filter_list.html" with id='supplier-part' %}
</div>
</div>
@ -304,14 +303,20 @@ linkButtonsToSelection($("#supplier-table"), ['#supplier-part-options']);
linkButtonsToSelection($("#parameter-table"), ['#parameter-options']);
$('#order-part, #order-part2').click(function() {
launchModalForm(
"{% url 'order-parts' %}",
inventreeGet(
'{% url "api-part-detail" part.part.pk %}', {},
{
data: {
part: {{ part.part.id }},
},
reload: true,
},
success: function(response) {
orderParts([response], {
manufacturer_part: {{ part.pk }},
{% if part.manufacturer %}
manufacturer: {{ part.manufacturer.pk }},
{% endif %}
});
}
}
);
});

View File

@ -169,7 +169,8 @@ src="{% static 'img/blank_image.png' %}"
</div>
<div class='panel-content'>
<div id='button-bar'>
<div class='btn-group'>
<div class='btn-group' role='group'>
{% include "filter_list.html" with id='purchaseorder' %}
</div>
</div>
<table class='table table-striped table-condensed po-table' id='purchase-order-table' data-toolbar='#button-bar'>
@ -330,14 +331,19 @@ $("#item-create").click(function() {
});
$('#order-part, #order-part2').click(function() {
launchModalForm(
"{% url 'order-parts' %}",
inventreeGet(
'{% url "api-part-detail" part.part.pk %}', {},
{
data: {
part: {{ part.part.id }},
},
reload: true,
},
success: function(response) {
orderParts([response], {
supplier_part: {{ part.pk }},
{% if part.supplier %}
supplier: {{ part.supplier.pk }},
{% endif %}
});
}
}
);
});

View File

@ -35,7 +35,7 @@ def _convert_model(apps, line_item_ref, extra_line_ref, price_ref):
print(f'Done converting line items - now at {OrderExtraLine.objects.all().count()} {extra_line_ref} / {OrderLineItem.objects.all().count()} {line_item_ref} instance(s)')
def _reconvert_model(apps, line_item_ref, extra_line_ref):
def _reconvert_model(apps, line_item_ref, extra_line_ref): # pragma: no cover
"""Convert ExtraLine instances back to OrderLineItem instances"""
OrderLineItem = apps.get_model('order', line_item_ref)
OrderExtraLine = apps.get_model('order', extra_line_ref)

View File

@ -223,14 +223,30 @@ class PurchaseOrderLineItemSerializer(InvenTreeModelSerializer):
if order_detail is not True:
self.fields.pop('order_detail')
quantity = serializers.FloatField(default=1)
received = serializers.FloatField(default=0)
quantity = serializers.FloatField(min_value=0, required=True)
def validate_quantity(self, quantity):
if quantity <= 0:
raise ValidationError(_("Quantity must be greater than zero"))
return quantity
def validate_purchase_order(self, purchase_order):
if purchase_order.status not in PurchaseOrderStatus.OPEN:
raise ValidationError(_('Order is not open'))
return purchase_order
received = serializers.FloatField(default=0, read_only=True)
overdue = serializers.BooleanField(required=False, read_only=True)
total_price = serializers.FloatField(read_only=True)
part_detail = PartBriefSerializer(source='get_base_part', many=False, read_only=True)
supplier_part_detail = SupplierPartSerializer(source='part', many=False, read_only=True)
purchase_price = InvenTreeMoneySerializer(
@ -248,6 +264,32 @@ class PurchaseOrderLineItemSerializer(InvenTreeModelSerializer):
order_detail = PurchaseOrderSerializer(source='order', read_only=True, many=False)
def validate(self, data):
data = super().validate(data)
supplier_part = data.get('part', None)
purchase_order = data.get('order', None)
if not supplier_part:
raise ValidationError({
'part': _('Supplier part must be specified'),
})
if not purchase_order:
raise ValidationError({
'order': _('Purchase order must be specified'),
})
# Check that the supplier part and purchase order match
if supplier_part is not None and supplier_part.supplier != purchase_order.supplier:
raise ValidationError({
'part': _('Supplier must match purchase order'),
'order': _('Purchase order must match supplier'),
})
return data
class Meta:
model = order.models.PurchaseOrderLineItem

View File

@ -1,85 +0,0 @@
{% extends "modal_form.html" %}
{% load inventree_extras %}
{% load i18n %}
{% block form %}
{% default_currency as currency %}
{% settings_value 'PART_SHOW_PRICE_IN_FORMS' as show_price %}
<h4>
{% trans "Step 1 of 2 - Select Part Suppliers" %}
</h4>
{% if parts|length > 0 %}
<div class='alert alert-info alert-block' role='alert'>
{% trans "Select suppliers" %}
</div>
{% else %}
<div class='alert alert-warning alert-block' role='alert'>
{% trans "No purchaseable parts selected" %}
</div>
{% endif %}
<form method="post" action='' class='js-modal-form' enctype="multipart/form-data">
{% csrf_token %}
{% load crispy_forms_tags %}
<input type='hidden' name='form_step' value='select_parts'/>
<table class='table table-condensed table-striped' id='order-wizard-part-table'>
<tr>
<th>{% trans "Part" %}</th>
<th colspan='2'>{% trans "Select Supplier" %}</th>
<th>{% trans "Quantity" %}</th>
<th></th>
</tr>
{% for part in parts %}
<tr id='part_row_{{ part.id }}'>
<td>
{% include "hover_image.html" with image=part.image hover=False %}
{{ part.full_name }} <small><em>{{ part.description }}</em></small>
</td>
<td>
<button class='btn btn-outline-secondary btn-create' onClick='newSupplierPartFromOrderWizard()' id='new_supplier_part_{{ part.id }}' part='{{ part.pk }}' title='{% trans "Create new supplier part" %}' type='button'>
<span part='{{ part.pk }}' class='fas fa-plus-circle'></span>
</button>
</td>
<td>
<div class='control-group'>
<div class='controls'>
<select class='select' id='id_supplier_part_{{ part.id }}' name="part-supplier-{{ part.id }}">
<option value=''>---------</option>
{% for supplier in part.supplier_parts.all %}
<option value="{{ supplier.id }}"{% if part.order_supplier == supplier.id %} selected="selected"{% endif %}>
{% if show_price %}
{% call_method supplier 'get_price' part.order_quantity as price %}
{% if price != None %}{% include "price.html" with price=price %}{% else %}{% trans 'No price' %}{% endif %} -
{% endif %}
{{ supplier }}
</option>
{% endfor %}
</select>
</div>
{% if not part.order_supplier %}
<span class='help-inline'>{% blocktrans with name=part.name %}Select a supplier for <em>{{name}}</em>{% endblocktrans %}</span>
{% endif %}
</div>
</td>
<td>
<div class='control-group'>
<div class='controls'>
<input class='numberinput' type='number' min='0' value='{% decimal part.order_quantity %}' name='part-quantity-{{ part.id }}'/>
</div>
</div>
</td>
<td>
<button class='btn btn-outline-secondary btn-remove' onclick='removeOrderRowFromOrderWizard()' id='del_item_{{ part.id }}' title='{% trans "Remove part" %}' type='button'>
<span row='part_row_{{ part.id }}' class='fas fa-trash-alt icon-red'></span>
</button>
</td>
</tr>
{% endfor %}
</table>
</form>
{% endblock %}

View File

@ -1,79 +0,0 @@
{% extends "modal_form.html" %}
{% load i18n %}
{% block form %}
<h4>
{% trans "Step 2 of 2 - Select Purchase Orders" %}
</h4>
<div class='alert alert-info alert-block' role='alert'>
{% trans "Select existing purchase orders, or create new orders." %}
</div>
<form method='post' action='' class='js-modal-form' enctype='multipart/form-data'>
{% csrf_token %}
{% load crispy_forms_tags %}
<input type='hidden' name='form_step' value='select_purchase_orders'/>
{% for supplier in suppliers %}
{% for item in supplier.order_items %}
<input type='hidden' name='part-supplier-{{ item.id }}' value='{{ item.order_supplier }}'/>
<input type='hidden' name='part-quantity-{{ item.id }}' value='{{ item.order_quantity }}'/>
{% endfor %}
{% endfor %}
<table class='table table-condensed table-striped' id='order-wizard-po-table'>
<tr>
<th>{% trans "Supplier" %}</th>
<th>{% trans "Items" %}</th>
<th colspan='2'>{% trans "Select Purchase Order" %}</th>
</tr>
{% for supplier in suppliers %}
{% if supplier %}
<tr id='suppier_row_{{ supplier.id }}'>
<td>
{% include 'hover_image.html' with image=supplier.image hover=False %}
{{ supplier.name }}
</td>
<td>{{ supplier.order_items|length }}</td>
<td>
<button
class='btn btn-outline-secondary btn-create'
id='new_po_{{ supplier.id }}'
title='{% blocktrans with name=supplier.name %}Create new purchase order for {{name}}{% endblocktrans %}'
type='button'
supplierid='{{ supplier.id }}'
onclick='newPurchaseOrderFromOrderWizard()'>
<span supplierid='{{ supplier.id }}' class='fas fa-plus-circle'></span>
</button>
</td>
<td>
<div class='control-group'>
<div class='controls'>
<select
class='select'
id='id-purchase-order-{{ supplier.id }}'
name='purchase-order-{{ supplier.id }}'>
<option value=''>---------</option>
{% for order in supplier.pending_purchase_orders %}
<option value='{{ order.id }}'{% if supplier.selected_purchase_order == order.id %} selected='selected'{% endif %}>
{{ order }}
</option>
{% endfor %}
</select>
</div>
{% if not supplier.selected_purchase_order %}
<span class='help-inline'>{% blocktrans with name=supplier.name %}Select a purchase order for {{name}}{% endblocktrans %}</span>
{% endif %}
</div>
</td>
</tr>
{% endif %}
{% endfor %}
</table>
</form>
{% endblock %}

View File

@ -23,7 +23,6 @@ purchase_order_detail_urls = [
purchase_order_urls = [
re_path(r'^order-parts/', views.OrderParts.as_view(), name='order-parts'),
re_path(r'^pricing/', views.LineItemPricing.as_view(), name='line-pricing'),
# Display detail view for a single purchase order

View File

@ -5,7 +5,6 @@ Django views for interacting with Order app
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import transaction
from django.db.utils import IntegrityError
from django.http.response import JsonResponse
from django.shortcuts import get_object_or_404
@ -21,9 +20,7 @@ from decimal import Decimal, InvalidOperation
from .models import PurchaseOrder, PurchaseOrderLineItem
from .models import SalesOrder, SalesOrderLineItem
from .admin import PurchaseOrderLineItemResource, SalesOrderLineItemResource
from build.models import Build
from company.models import Company, SupplierPart # ManufacturerPart
from stock.models import StockItem
from company.models import SupplierPart # ManufacturerPart
from part.models import Part
from common.forms import UploadFileForm, MatchFieldForm
@ -37,8 +34,6 @@ from InvenTree.views import AjaxView, AjaxUpdateView
from InvenTree.helpers import DownloadFile, str2bool
from InvenTree.views import InvenTreeRoleMixin
from InvenTree.status_codes import PurchaseOrderStatus
logger = logging.getLogger("inventree")
@ -448,346 +443,6 @@ class PurchaseOrderExport(AjaxView):
return DownloadFile(filedata, filename)
class OrderParts(AjaxView):
""" View for adding various SupplierPart items to a Purchase Order.
SupplierParts can be selected from a variety of 'sources':
- ?supplier_parts[]= -> Direct list of SupplierPart objects
- ?parts[]= -> List of base Part objects (user must then select supplier parts)
- ?stock[]= -> List of StockItem objects (user must select supplier parts)
- ?build= -> A Build object (user must select parts, then supplier parts)
"""
ajax_form_title = _("Order Parts")
ajax_template_name = 'order/order_wizard/select_parts.html'
role_required = [
'part.view',
'purchase_order.change',
]
# List of Parts we wish to order
parts = []
suppliers = []
def get_context_data(self):
ctx = {}
ctx['parts'] = sorted(self.parts, key=lambda part: int(part.order_quantity), reverse=True)
ctx['suppliers'] = self.suppliers
return ctx
def get_data(self):
""" enrich respone json data """
data = super().get_data()
# if in selection-phase, add a button to update the prices
if getattr(self, 'form_step', 'select_parts') == 'select_parts':
data['buttons'] = [{'name': 'update_price', 'title': _('Update prices')}] # set buttons
data['hideErrorMessage'] = '1' # hide the error message
return data
def get_suppliers(self):
""" Calculates a list of suppliers which the user will need to create PurchaseOrders for.
This is calculated AFTER the user finishes selecting the parts to order.
Crucially, get_parts() must be called before get_suppliers()
"""
suppliers = {}
for supplier in self.suppliers:
supplier.order_items = []
suppliers[supplier.name] = supplier
for part in self.parts:
supplier_part_id = part.order_supplier
try:
supplier = SupplierPart.objects.get(pk=supplier_part_id).supplier
except SupplierPart.DoesNotExist:
continue
if supplier.name not in suppliers:
supplier.order_items = []
# Attempt to auto-select a purchase order
orders = PurchaseOrder.objects.filter(supplier=supplier, status__in=PurchaseOrderStatus.OPEN)
if orders.count() == 1:
supplier.selected_purchase_order = orders.first().id
else:
supplier.selected_purchase_order = None
suppliers[supplier.name] = supplier
suppliers[supplier.name].order_items.append(part)
self.suppliers = [suppliers[key] for key in suppliers.keys()]
def get_parts(self):
""" Determine which parts the user wishes to order.
This is performed on the initial GET request.
"""
self.parts = []
part_ids = set()
# User has passed a list of stock items
if 'stock[]' in self.request.GET:
stock_id_list = self.request.GET.getlist('stock[]')
""" Get a list of all the parts associated with the stock items.
- Base part must be purchaseable.
- Return a set of corresponding Part IDs
"""
stock_items = StockItem.objects.filter(
part__purchaseable=True,
id__in=stock_id_list)
for item in stock_items:
part_ids.add(item.part.id)
# User has passed a single Part ID
elif 'part' in self.request.GET:
try:
part_id = self.request.GET.get('part')
part = Part.objects.get(id=part_id)
part_ids.add(part.id)
except Part.DoesNotExist:
pass
# User has passed a list of part ID values
elif 'parts[]' in self.request.GET:
part_id_list = self.request.GET.getlist('parts[]')
parts = Part.objects.filter(
purchaseable=True,
id__in=part_id_list)
for part in parts:
part_ids.add(part.id)
# User has provided a Build ID
elif 'build' in self.request.GET:
build_id = self.request.GET.get('build')
try:
build = Build.objects.get(id=build_id)
parts = build.required_parts
for part in parts:
# If ordering from a Build page, ignore parts that we have enough of
if part.quantity_to_order <= 0:
continue
part_ids.add(part.id)
except Build.DoesNotExist:
pass
# Create the list of parts
for id in part_ids:
try:
part = Part.objects.get(id=id)
# Pre-fill the 'order quantity' value
part.order_quantity = part.quantity_to_order
default_supplier = part.get_default_supplier()
if default_supplier:
part.order_supplier = default_supplier.id
else:
part.order_supplier = None
except Part.DoesNotExist:
continue
self.parts.append(part)
def get(self, request, *args, **kwargs):
self.request = request
self.get_parts()
return self.renderJsonResponse(request)
def post(self, request, *args, **kwargs):
""" Handle the POST action for part selection.
- Validates each part / quantity / supplier / etc
Part selection form contains the following fields for each part:
- supplier-<pk> : The ID of the selected supplier
- quantity-<pk> : The quantity to add to the order
"""
self.request = request
self.parts = []
self.suppliers = []
# Any errors for the part selection form?
part_errors = False
supplier_errors = False
# Extract part information from the form
for item in self.request.POST:
if item.startswith('part-supplier-'):
pk = item.replace('part-supplier-', '')
# Check that the part actually exists
try:
part = Part.objects.get(id=pk)
except (Part.DoesNotExist, ValueError):
continue
supplier_part_id = self.request.POST[item]
quantity = self.request.POST.get('part-quantity-' + str(pk), 0)
# Ensure a valid supplier has been passed
try:
supplier_part = SupplierPart.objects.get(id=supplier_part_id)
except (SupplierPart.DoesNotExist, ValueError):
supplier_part = None
# Ensure a valid quantity is passed
try:
quantity = int(quantity)
# Eliminate lines where the quantity is zero
if quantity == 0:
continue
except ValueError:
quantity = part.quantity_to_order
part.order_supplier = supplier_part.id if supplier_part else None
part.order_quantity = quantity
# set supplier-price
if supplier_part:
supplier_price = supplier_part.get_price(quantity)
if supplier_price:
part.purchase_price = supplier_price / quantity
if not hasattr(part, 'purchase_price'):
part.purchase_price = None
self.parts.append(part)
if supplier_part is None:
part_errors = True
elif quantity < 0:
part_errors = True
elif item.startswith('purchase-order-'):
# Which purchase order is selected for a given supplier?
pk = item.replace('purchase-order-', '')
# Check that the Supplier actually exists
try:
supplier = Company.objects.get(id=pk)
except Company.DoesNotExist:
# Skip this item
continue
purchase_order_id = self.request.POST[item]
# Ensure that a valid purchase order has been passed
try:
purchase_order = PurchaseOrder.objects.get(pk=purchase_order_id)
except (PurchaseOrder.DoesNotExist, ValueError):
purchase_order = None
supplier.selected_purchase_order = purchase_order.id if purchase_order else None
self.suppliers.append(supplier)
if supplier.selected_purchase_order is None:
supplier_errors = True
form_step = request.POST.get('form_step')
# Map parts to suppliers
self.get_suppliers()
valid = False
if form_step == 'select_parts':
# No errors? and the price-update button was not used to submit? Proceed to PO selection form
if part_errors is False and 'act-btn_update_price' not in request.POST:
self.ajax_template_name = 'order/order_wizard/select_pos.html'
self.form_step = 'select_purchase_orders' # set step (important for get_data)
else:
self.ajax_template_name = 'order/order_wizard/select_parts.html'
elif form_step == 'select_purchase_orders':
self.ajax_template_name = 'order/order_wizard/select_pos.html'
valid = part_errors is False and supplier_errors is False
# Form wizard is complete! Add items to purchase orders
if valid:
self.order_items()
data = {
'form_valid': valid,
'success': _('Ordered {n} parts').format(n=len(self.parts))
}
return self.renderJsonResponse(self.request, data=data)
@transaction.atomic
def order_items(self):
""" Add the selected items to the purchase orders. """
for supplier in self.suppliers:
# Check that the purchase order does actually exist
try:
order = PurchaseOrder.objects.get(pk=supplier.selected_purchase_order)
except PurchaseOrder.DoesNotExist:
logger.critical('Could not add items to purchase order {po} - Order does not exist'.format(po=supplier.selected_purchase_order))
continue
for item in supplier.order_items:
# Ensure that the quantity is valid
try:
quantity = int(item.order_quantity)
if quantity <= 0:
continue
except ValueError:
logger.warning("Did not add part to purchase order - incorrect quantity")
continue
# Check that the supplier part does actually exist
try:
supplier_part = SupplierPart.objects.get(pk=item.order_supplier)
except SupplierPart.DoesNotExist:
logger.critical("Could not add part '{part}' to purchase order - selected supplier part '{sp}' does not exist.".format(
part=item,
sp=item.order_supplier))
continue
# get purchase price
purchase_price = item.purchase_price
order.add_line_item(supplier_part, quantity, purchase_price=purchase_price)
class LineItemPricing(PartPricing):
""" View for inspecting part pricing information """

View File

@ -169,13 +169,18 @@
</button>
<ul class='dropdown-menu'>
{% if roles.part.change %}
<li><a class='dropdown-item' href='#' id='multi-part-category' title='{% trans "Set category" %}'>{% trans "Set Category" %}</a></li>
<li><a class='dropdown-item' href='#' id='multi-part-category' title='{% trans "Set category" %}'>
<span class='fas fa-sitemap'></span> {% trans "Set Category" %}
</a></li>
{% endif %}
<li><a class='dropdown-item' href='#' id='multi-part-order' title='{% trans "Order parts" %}'>{% trans "Order Parts" %}</a></li>
<li><a class='dropdown-item' href='#' id='multi-part-order' title='{% trans "Order parts" %}'>
<span class='fas fa-shopping-cart'></span> {% trans "Order Parts" %}
</a></li>
{% if report_enabled %}
<li><a class='dropdown-item' href='#' id='multi-part-print-label' title='{% trans "Print Labels" %}'>{% trans "Print Labels" %}</a></li>
<li><a class='dropdown-item' href='#' id='multi-part-print-label' title='{% trans "Print Labels" %}'>
<span class='fas fa-tag'></span> {% trans "Print Labels" %}
</a></li>
{% endif %}
<li><a class='dropdown-item' href='#' id='multi-part-export' title='{% trans "Export" %}'>{% trans "Export Data" %}</a></li>
</ul>
</div>
{% include "filter_list.html" with id="parts" %}

View File

@ -754,12 +754,18 @@
$("#part-order2").click(function() {
launchModalForm("{% url 'order-parts' %}", {
data: {
part: {{ part.id }},
},
reload: true,
});
inventreeGet(
'{% url "api-part-detail" part.pk %}',
{},
{
success: function(part) {
orderParts(
[part],
{}
);
}
}
);
});
onPanelLoad("test-templates", function() {

View File

@ -536,12 +536,19 @@
{% endif %}
$("#part-order").click(function() {
launchModalForm("{% url 'order-parts' %}", {
data: {
part: {{ part.id }},
},
reload: true,
});
inventreeGet(
'{% url "api-part-detail" part.pk %}',
{},
{
success: function(part) {
orderParts(
[part],
{}
);
}
}
);
});
{% if roles.part.add %}

View File

@ -35,7 +35,7 @@ class PluginAppConfig(AppConfig):
if InvenTreeSetting.get_setting('PLUGIN_ON_STARTUP', create=False):
# make sure all plugins are installed
registry.install_plugin_file()
except:
except: # pragma: no cover
pass
# get plugins and init them

View File

@ -69,7 +69,7 @@ class BarcodeMixin:
Default implementation returns None
"""
return None
return None # pragma: no cover
def getStockItemByHash(self):
"""
@ -97,7 +97,7 @@ class BarcodeMixin:
Default implementation returns None
"""
return None
return None # pragma: no cover
def renderStockLocation(self, loc):
"""
@ -113,7 +113,7 @@ class BarcodeMixin:
Default implementation returns None
"""
return None
return None # pragma: no cover
def renderPart(self, part):
"""
@ -143,4 +143,4 @@ class BarcodeMixin:
"""
Default implementation returns False
"""
return False
return False # pragma: no cover

View File

@ -56,7 +56,7 @@ class SettingsMixin:
if not plugin:
# Cannot find associated plugin model, return
return
return # pragma: no cover
PluginSetting.set_setting(key, value, user, plugin=plugin)
@ -171,7 +171,7 @@ class ScheduleMixin:
if Schedule.objects.filter(name=task_name).exists():
# Scheduled task already exists - continue!
continue
continue # pragma: no cover
logger.info(f"Adding scheduled task '{task_name}'")
@ -209,7 +209,7 @@ class ScheduleMixin:
repeats=task.get('repeats', -1),
)
except (ProgrammingError, OperationalError):
except (ProgrammingError, OperationalError): # pragma: no cover
# Database might not yet be ready
logger.warning("register_tasks failed, database not ready")
@ -230,7 +230,7 @@ class ScheduleMixin:
scheduled_task.delete()
except Schedule.DoesNotExist:
pass
except (ProgrammingError, OperationalError):
except (ProgrammingError, OperationalError): # pragma: no cover
# Database might not yet be ready
logger.warning("unregister_tasks failed, database not ready")
@ -408,7 +408,7 @@ class LabelPrintingMixin:
"""
MIXIN_NAME = 'Label printing'
def __init__(self):
def __init__(self): # pragma: no cover
super().__init__()
self.add_mixin('labels', True, __class__)
@ -426,7 +426,7 @@ class LabelPrintingMixin:
"""
# Unimplemented (to be implemented by the particular plugin class)
...
... # pragma: no cover
class APICallMixin:

View File

@ -163,17 +163,15 @@ def after_save(sender, instance, created, **kwargs):
if created:
trigger_event(
'instance.created',
f'{table}.created',
id=instance.id,
model=sender.__name__,
table=table,
)
else:
trigger_event(
'instance.saved',
f'{table}.saved',
id=instance.id,
model=sender.__name__,
table=table,
)
@ -189,9 +187,8 @@ def after_delete(sender, instance, **kwargs):
return
trigger_event(
'instance.deleted',
f'{table}.deleted',
model=sender.__name__,
table=table,
)

View File

@ -75,7 +75,7 @@ def handle_error(error, do_raise: bool = True, do_log: bool = True, log_name: st
path_parts.remove('plugin')
path_parts.pop(0)
else:
path_parts.remove('plugins')
path_parts.remove('plugins') # pragma: no cover
package_name = '.'.join(path_parts)
@ -88,7 +88,7 @@ def handle_error(error, do_raise: bool = True, do_log: bool = True, log_name: st
if do_raise:
# do a straight raise if we are playing with enviroment variables at execution time, ignore the broken sample
if settings.TESTING_ENV and package_name != 'integration.broken_sample' and isinstance(error, IntegrityError):
raise error
raise error # pragma: no cover
raise IntegrationPluginError(package_name, str(error))
# endregion
@ -135,7 +135,7 @@ def check_git_version():
except ValueError: # pragma: no cover
pass
return False
return False # pragma: no cover
class GitStatus:

View File

@ -191,7 +191,7 @@ class IntegrationPluginBase(MixinBase, plugin_base.InvenTreePluginBase):
Path to the plugin
"""
if self._is_package:
return self.__module__
return self.__module__ # pragma: no cover
return pathlib.Path(self.def_path).relative_to(settings.BASE_DIR)
@property

View File

@ -283,7 +283,7 @@ class PluginsRegistry:
if not settings.PLUGIN_TESTING:
raise error # pragma: no cover
plugin_db_setting = None
except (IntegrityError) as error:
except (IntegrityError) as error: # pragma: no cover
logger.error(f"Error initializing plugin: {error}")
# Always activate if testing
@ -322,7 +322,7 @@ class PluginsRegistry:
self.plugins[plugin.slug] = plugin
else:
# save for later reference
self.plugins_inactive[plug_key] = plugin_db_setting
self.plugins_inactive[plug_key] = plugin_db_setting # pragma: no cover
def _activate_plugins(self, force_reload=False):
"""
@ -411,7 +411,7 @@ class PluginsRegistry:
deleted_count += 1
if deleted_count > 0:
logger.info(f"Removed {deleted_count} old scheduled tasks")
logger.info(f"Removed {deleted_count} old scheduled tasks") # pragma: no cover
except (ProgrammingError, OperationalError):
# Database might not yet be ready
logger.warning("activate_integration_schedule failed, database not ready")

View File

@ -8,11 +8,11 @@ from plugin.mixins import ScheduleMixin, SettingsMixin
# Define some simple tasks to perform
def print_hello():
print("Hello")
print("Hello") # pragma: no cover
def print_world():
print("World")
print("World") # pragma: no cover
class ScheduledTaskPlugin(ScheduleMixin, SettingsMixin, IntegrationPluginBase):
@ -36,7 +36,7 @@ class ScheduledTaskPlugin(ScheduleMixin, SettingsMixin, IntegrationPluginBase):
'minutes': 45,
},
'world': {
'func': 'plugin.samples.integration.scheduled_task.print_hello',
'func': 'plugin.samples.integration.scheduled_task.print_world',
'schedule': 'H',
},
}
@ -58,3 +58,4 @@ class ScheduledTaskPlugin(ScheduleMixin, SettingsMixin, IntegrationPluginBase):
t_or_f = self.get_setting('T_OR_F')
print(f"Called member_func - value is {t_or_f}")
return t_or_f

View File

@ -0,0 +1,153 @@
""" Unit tests for scheduled tasks"""
from django.test import TestCase
from plugin import registry, IntegrationPluginBase
from plugin.helpers import MixinImplementationError
from plugin.registry import call_function
from plugin.mixins import ScheduleMixin
class ExampleScheduledTaskPluginTests(TestCase):
""" Tests for provided ScheduledTaskPlugin """
def test_function(self):
"""check if the scheduling works"""
# The plugin should be defined
self.assertIn('schedule', registry.plugins)
plg = registry.plugins['schedule']
self.assertTrue(plg)
# check that the built-in function is running
self.assertEqual(plg.member_func(), False)
# check that the tasks are defined
self.assertEqual(plg.get_task_names(), ['plugin.schedule.member', 'plugin.schedule.hello', 'plugin.schedule.world'])
# register
plg.register_tasks()
# check that schedule was registers
from django_q.models import Schedule
scheduled_plugin_tasks = Schedule.objects.filter(name__istartswith="plugin.")
self.assertEqual(len(scheduled_plugin_tasks), 3)
# delete middle task
# this is to check the system also deals with disappearing tasks
scheduled_plugin_tasks[1].delete()
# there should be one less now
scheduled_plugin_tasks = Schedule.objects.filter(name__istartswith="plugin.")
self.assertEqual(len(scheduled_plugin_tasks), 2)
# test unregistering
plg.unregister_tasks()
scheduled_plugin_tasks = Schedule.objects.filter(name__istartswith="plugin.")
self.assertEqual(len(scheduled_plugin_tasks), 0)
def test_calling(self):
"""check if a function can be called without errors"""
self.assertEqual(call_function('schedule', 'member_func'), False)
class ScheduledTaskPluginTests(TestCase):
""" Tests for ScheduledTaskPluginTests mixin base """
def test_init(self):
"""Check that all MixinImplementationErrors raise"""
class Base(ScheduleMixin, IntegrationPluginBase):
PLUGIN_NAME = 'APlugin'
class NoSchedules(Base):
"""Plugin without schedules"""
pass
with self.assertRaises(MixinImplementationError):
NoSchedules()
class WrongFuncSchedules(Base):
"""
Plugin with broken functions
This plugin is missing a func
"""
SCHEDULED_TASKS = {
'test': {
'schedule': 'I',
'minutes': 30,
},
}
def test(self):
pass # pragma: no cover
with self.assertRaises(MixinImplementationError):
WrongFuncSchedules()
class WrongFuncSchedules1(WrongFuncSchedules):
"""
Plugin with broken functions
This plugin is missing a schedule
"""
SCHEDULED_TASKS = {
'test': {
'func': 'test',
'minutes': 30,
},
}
with self.assertRaises(MixinImplementationError):
WrongFuncSchedules1()
class WrongFuncSchedules2(WrongFuncSchedules):
"""
Plugin with broken functions
This plugin is missing a schedule
"""
SCHEDULED_TASKS = {
'test': {
'func': 'test',
'minutes': 30,
},
}
with self.assertRaises(MixinImplementationError):
WrongFuncSchedules2()
class WrongFuncSchedules3(WrongFuncSchedules):
"""
Plugin with broken functions
This plugin has a broken schedule
"""
SCHEDULED_TASKS = {
'test': {
'func': 'test',
'schedule': 'XX',
'minutes': 30,
},
}
with self.assertRaises(MixinImplementationError):
WrongFuncSchedules3()
class WrongFuncSchedules4(WrongFuncSchedules):
"""
Plugin with broken functions
This plugin is missing a minute marker for its schedule
"""
SCHEDULED_TASKS = {
'test': {
'func': 'test',
'schedule': 'I',
},
}
with self.assertRaises(MixinImplementationError):
WrongFuncSchedules4()

View File

@ -11,6 +11,8 @@ from plugin import IntegrationPluginBase
from plugin.mixins import AppMixin, SettingsMixin, UrlsMixin, NavigationMixin, APICallMixin
from plugin.urls import PLUGIN_BASE
from plugin.samples.integration.sample import SampleIntegrationPlugin
class BaseMixinDefinition:
def test_mixin_name(self):
@ -238,6 +240,7 @@ class IntegrationPluginBaseTests(TestCase):
LICENSE = 'MIT'
self.plugin_name = NameIntegrationPluginBase()
self.plugin_sample = SampleIntegrationPlugin()
def test_action_name(self):
"""check the name definition possibilities"""
@ -246,6 +249,10 @@ class IntegrationPluginBaseTests(TestCase):
self.assertEqual(self.plugin_simple.plugin_name(), 'SimplePlugin')
self.assertEqual(self.plugin_name.plugin_name(), 'Aplugin')
# is_sampe
self.assertEqual(self.plugin.is_sample, False)
self.assertEqual(self.plugin_sample.is_sample, True)
# slug
self.assertEqual(self.plugin.slug, '')
self.assertEqual(self.plugin_simple.slug, 'simpleplugin')

View File

@ -31,6 +31,10 @@ class InvenTreePluginTests(TestCase):
self.assertEqual(self.named_plugin.PLUGIN_NAME, 'abc123')
self.assertEqual(self.named_plugin.plugin_name(), 'abc123')
def test_basic_is_active(self):
"""check if a basic plugin is active"""
self.assertEqual(self.plugin.is_active(), False)
class PluginTagTests(TestCase):
""" Tests for the plugin extras """

View File

@ -105,7 +105,7 @@ function inventreeFormDataUpload(url, data, options={}) {
}
},
error: function(xhr, status, error) {
console.log('Form data upload failure: ' + status);
console.error('Form data upload failure: ' + status);
if (options.error) {
options.error(xhr, status, error);

View File

@ -86,7 +86,6 @@ function onCameraAvailable(hasCamera, options) {
function onBarcodeScanCompleted(result, options) {
if (result.data == '') return;
console.log('decoded qr code:', result.data);
stopQrScanner();
postBarcodeData(result.data, options);
}

View File

@ -129,7 +129,7 @@ function constructBomUploadTable(data, options={}) {
var modal = createNewModal({
title: '{% trans "Row Data" %}',
cancelText: '{% trans "Close" %}',
closeText: '{% trans "Close" %}',
hideSubmitButton: true
});
@ -617,7 +617,7 @@ function bomSubstitutesDialog(bom_item_id, substitutes, options={}) {
},
},
preFormContent: html,
cancelText: '{% trans "Close" %}',
closeText: '{% trans "Close" %}',
submitText: '{% trans "Add Substitute" %}',
title: '{% trans "Edit BOM Item Substitutes" %}',
afterRender: function(fields, opts) {
@ -1061,7 +1061,7 @@ function loadBomTable(table, options={}) {
table.bootstrapTable('append', response);
},
error: function(xhr) {
console.log('Error requesting BOM for part=' + part_pk);
console.error('Error requesting BOM for part=' + part_pk);
showApiError(xhr);
}
}

View File

@ -1532,13 +1532,18 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
var pk = $(this).attr('pk');
launchModalForm('{% url "order-parts" %}', {
data: {
parts: [
pk,
]
inventreeGet(
`/api/part/${pk}/`,
{},
{
success: function(part) {
orderParts(
[part],
{}
);
}
}
});
);
});
// Callback for 'build' button

View File

@ -115,10 +115,6 @@ function supplierPartFields() {
return {
part: {},
supplier: {},
SKU: {
icon: 'fa-hashtag',
},
manufacturer_part: {
filters: {
part_detail: true,
@ -126,6 +122,10 @@ function supplierPartFields() {
},
auto_fill: true,
},
supplier: {},
SKU: {
icon: 'fa-hashtag',
},
description: {},
link: {
icon: 'fa-link',

View File

@ -62,7 +62,7 @@ function loadTableFilters(tableKey) {
if (f.length == 2) {
filters[f[0]] = f[1];
} else {
console.log(`Improperly formatted filter: ${item}`);
console.warn(`Improperly formatted filter: ${item}`);
}
}
});
@ -274,7 +274,7 @@ function setupFilterList(tableKey, table, target, options={}) {
var element = $(target);
if (!element || !element.exists()) {
console.log(`WARNING: setupFilterList could not find target '${target}'`);
console.warn(`setupFilterList could not find target '${target}'`);
return;
}

View File

@ -135,7 +135,7 @@ function getApiEndpointOptions(url, callback) {
success: callback,
error: function(xhr) {
// TODO: Handle error
console.log(`ERROR in getApiEndpointOptions at '${url}'`);
console.error(`Error in getApiEndpointOptions at '${url}'`);
showApiError(xhr, url);
}
});
@ -227,7 +227,7 @@ function constructChangeForm(fields, options) {
},
error: function(xhr) {
// TODO: Handle error here
console.log(`ERROR in constructChangeForm at '${options.url}'`);
console.error(`Error in constructChangeForm at '${options.url}'`);
showApiError(xhr, options.url);
}
@ -268,7 +268,7 @@ function constructDeleteForm(fields, options) {
},
error: function(xhr) {
// TODO: Handle error here
console.log(`ERROR in constructDeleteForm at '${options.url}`);
console.error(`Error in constructDeleteForm at '${options.url}`);
showApiError(xhr, options.url);
}
@ -354,7 +354,7 @@ function constructForm(url, options) {
icon: 'fas fa-user-times',
});
console.log(`'POST action unavailable at ${url}`);
console.warn(`'POST action unavailable at ${url}`);
}
break;
case 'PUT':
@ -369,7 +369,7 @@ function constructForm(url, options) {
icon: 'fas fa-user-times',
});
console.log(`${options.method} action unavailable at ${url}`);
console.warn(`${options.method} action unavailable at ${url}`);
}
break;
case 'DELETE':
@ -383,7 +383,7 @@ function constructForm(url, options) {
icon: 'fas fa-user-times',
});
console.log(`DELETE action unavailable at ${url}`);
console.warn(`DELETE action unavailable at ${url}`);
}
break;
case 'GET':
@ -397,11 +397,11 @@ function constructForm(url, options) {
icon: 'fas fa-user-times',
});
console.log(`GET action unavailable at ${url}`);
console.warn(`GET action unavailable at ${url}`);
}
break;
default:
console.log(`constructForm() called with invalid method '${options.method}'`);
console.warn(`constructForm() called with invalid method '${options.method}'`);
break;
}
});
@ -731,7 +731,7 @@ function submitFormData(fields, options) {
data[name] = value;
}
} else {
console.log(`WARNING: Could not find field matching '${name}'`);
console.warn(`Could not find field matching '${name}'`);
}
}
@ -776,7 +776,7 @@ function submitFormData(fields, options) {
default:
$(options.modal).modal('hide');
console.log(`upload error at ${options.url}`);
console.error(`Upload error at ${options.url}`);
showApiError(xhr, options.url);
break;
}
@ -827,7 +827,7 @@ function updateFieldValue(name, value, field, options) {
var el = getFormFieldElement(name, options);
if (!el) {
console.log(`WARNING: updateFieldValue could not find field '${name}'`);
console.warn(`updateFieldValue could not find field '${name}'`);
return;
}
@ -870,7 +870,7 @@ function getFormFieldElement(name, options) {
}
if (!el.exists) {
console.log(`ERROR: Could not find form element for field '${name}'`);
console.error(`Could not find form element for field '${name}'`);
}
return el;
@ -918,7 +918,7 @@ function getFormFieldValue(name, field={}, options={}) {
var el = getFormFieldElement(name, options);
if (!el.exists()) {
console.log(`ERROR: getFormFieldValue could not locate field '${name}'`);
console.error(`getFormFieldValue could not locate field '${name}'`);
return null;
}
@ -1104,7 +1104,7 @@ function handleNestedErrors(errors, field_name, options={}) {
// Nest list must be provided!
if (!nest_list) {
console.log(`WARNING: handleNestedErrors missing nesting options for field '${fieldName}'`);
console.warn(`handleNestedErrors missing nesting options for field '${fieldName}'`);
return;
}
@ -1113,7 +1113,7 @@ function handleNestedErrors(errors, field_name, options={}) {
var error_item = error_list[idx];
if (idx >= nest_list.length) {
console.log(`WARNING: handleNestedErrors returned greater number of errors (${error_list.length}) than could be handled (${nest_list.length})`);
console.warn(`handleNestedErrors returned greater number of errors (${error_list.length}) than could be handled (${nest_list.length})`);
break;
}
@ -1218,29 +1218,26 @@ function handleFormErrors(errors, fields={}, options={}) {
for (var field_name in errors) {
if (field_name in fields) {
var field = fields[field_name] || {};
var field = fields[field_name];
if ((field.type == 'field') && ('child' in field)) {
// This is a "nested" field
handleNestedErrors(errors, field_name, options);
} else {
// This is a "simple" field
if ((field.type == 'field') && ('child' in field)) {
// This is a "nested" field
handleNestedErrors(errors, field_name, options);
} else {
// This is a "simple" field
var field_errors = errors[field_name];
var field_errors = errors[field_name];
if (field_errors && !first_error_field && isFieldVisible(field_name, options)) {
first_error_field = field_name;
}
if (field_errors && !first_error_field && isFieldVisible(field_name, options)) {
first_error_field = field_name;
}
// Add an entry for each returned error message
for (var ii = field_errors.length-1; ii >= 0; ii--) {
// Add an entry for each returned error message
for (var ii = field_errors.length-1; ii >= 0; ii--) {
var error_text = field_errors[ii];
var error_text = field_errors[ii];
addFieldErrorMessage(field_name, error_text, ii, options);
}
addFieldErrorMessage(field_name, error_text, ii, options);
}
}
}
@ -1285,7 +1282,7 @@ function addFieldErrorMessage(name, error_text, error_idx=0, options={}) {
field_dom.append(error_html);
} else {
console.log(`WARNING: addFieldErrorMessage could not locate field '${field_name}'`);
console.warn(`addFieldErrorMessage could not locate field '${field_name}'`);
}
}
@ -1358,7 +1355,7 @@ function addClearCallback(name, field, options={}) {
}
if (!el) {
console.log(`WARNING: addClearCallback could not find field '${name}'`);
console.warn(`addClearCallback could not find field '${name}'`);
return;
}
@ -1582,7 +1579,7 @@ function initializeRelatedField(field, fields, options={}) {
var name = field.name;
if (!field.api_url) {
console.log(`WARNING: Related field '${name}' missing 'api_url' parameter.`);
console.warn(`Related field '${name}' missing 'api_url' parameter.`);
return;
}
@ -1712,7 +1709,7 @@ function initializeRelatedField(field, fields, options={}) {
return $(html);
} else {
// Return a simple renderering
console.log(`WARNING: templateResult() missing 'field.model' for '${name}'`);
console.warn(`templateResult() missing 'field.model' for '${name}'`);
return `${name} - ${item.id}`;
}
},
@ -1742,7 +1739,7 @@ function initializeRelatedField(field, fields, options={}) {
return $(html);
} else {
// Return a simple renderering
console.log(`WARNING: templateSelection() missing 'field.model' for '${name}'`);
console.warn(`templateSelection() missing 'field.model' for '${name}'`);
return `${name} - ${item.id}`;
}
}
@ -1780,6 +1777,11 @@ function initializeRelatedField(field, fields, options={}) {
// Only a single result is available, given the provided filters
if (data.count == 1) {
setRelatedFieldData(name, data.results[0], options);
// Run "callback" function (if supplied)
if (field.onEdit) {
field.onEdit(data.results[0], name, field, options);
}
}
}
});
@ -1911,7 +1913,7 @@ function renderModelData(name, model, data, parameters, options) {
if (html != null) {
return html;
} else {
console.log(`ERROR: Rendering not implemented for model '${model}'`);
console.error(`Rendering not implemented for model '${model}'`);
// Simple text rendering
return `${model} - ID ${data.id}`;
}
@ -1924,6 +1926,10 @@ function renderModelData(name, model, data, parameters, options) {
function getFieldName(name, options={}) {
var field_name = name;
if (options.field_suffix) {
field_name += options.field_suffix;
}
if (options && options.depth) {
field_name += `_${options.depth}`;
}
@ -2196,7 +2202,7 @@ function constructInput(name, parameters, options={}) {
if (func != null) {
html = func(name, parameters, options);
} else {
console.log(`WARNING: Unhandled form field type: '${parameters.type}'`);
console.warn(`Unhandled form field type: '${parameters.type}'`);
}
return html;
@ -2499,12 +2505,12 @@ function constructHelpText(name, parameters) {
function selectImportFields(url, data={}, options={}) {
if (!data.model_fields) {
console.log(`WARNING: selectImportFields is missing 'model_fields'`);
console.warn(`selectImportFields is missing 'model_fields'`);
return;
}
if (!data.file_fields) {
console.log(`WARNING: selectImportFields is missing 'file_fields'`);
console.warn(`selectImportFields is missing 'file_fields'`);
return;
}
@ -2595,7 +2601,7 @@ function selectImportFields(url, data={}, options={}) {
default:
$(opts.modal).modal('hide');
console.log(`upload error at ${opts.url}`);
console.error(`upload error at ${opts.url}`);
showApiError(xhr, opts.url);
break;
}

View File

@ -85,12 +85,25 @@ function createNewModal(options={}) {
var modal_name = `#modal-form-${id}`;
// Callback *after* the modal has been rendered
$(modal_name).on('shown.bs.modal', function() {
$(modal_name + ' .modal-form-content').scrollTop(0);
if (options.focus) {
getFieldByName(modal_name, options.focus).focus();
}
// Steal keyboard focus
$(modal_name).focus();
if (options.hideCloseButton) {
$(modal_name).find('#modal-form-cancel').hide();
}
if (options.preventSubmit || options.hideSubmitButton) {
$(modal_name).find('#modal-form-submit').hide();
}
});
// Automatically remove the modal when it is deleted!
@ -102,8 +115,11 @@ function createNewModal(options={}) {
$(modal_name).on('keydown', 'input', function(event) {
if (event.keyCode == 13) {
event.preventDefault();
// Simulate a click on the 'Submit' button
$(modal_name).find('#modal-form-submit').click();
if (!options.preventSubmit) {
// Simulate a click on the 'Submit' button
$(modal_name).find('#modal-form-submit').click();
}
return false;
}
@ -117,18 +133,7 @@ function createNewModal(options={}) {
// Set labels based on supplied options
modalSetTitle(modal_name, options.title || '{% trans "Form Title" %}');
modalSetSubmitText(modal_name, options.submitText || '{% trans "Submit" %}');
modalSetCloseText(modal_name, options.cancelText || '{% trans "Cancel" %}');
if (options.hideSubmitButton) {
$(modal_name).find('#modal-form-submit').hide();
}
if (options.hideCloseButton) {
$(modal_name).find('#modal-form-cancel').hide();
}
// Steal keyboard focus
$(modal_name).focus();
modalSetCloseText(modal_name, options.closeText || '{% trans "Cancel" %}');
// Return the "name" of the modal
return modal_name;
@ -274,7 +279,7 @@ function reloadFieldOptions(fieldName, options) {
setFieldOptions(fieldName, opts);
},
error: function() {
console.log('Error GETting field options');
console.error('Error GETting field options');
}
});
}
@ -581,7 +586,7 @@ function showAlertDialog(title, content, options={}) {
var modal = createNewModal({
title: title,
cancelText: '{% trans "Close" %}',
closeText: '{% trans "Close" %}',
hideSubmitButton: true,
});
@ -607,7 +612,7 @@ function showQuestionDialog(title, content, options={}) {
var modal = createNewModal({
title: title,
submitText: options.accept_text || '{% trans "Accept" %}',
cancelText: options.cancel_text || '{% trans "Cancel" %}',
closeText: options.cancel_text || '{% trans "Cancel" %}',
});
modalSetContent(modal, content);
@ -842,7 +847,7 @@ function attachFieldCallback(modal, callback) {
// Run the callback function with the new value of the field!
callback.action(field.val(), field);
} else {
console.log(`Value changed for field ${callback.field} - ${field.val()}`);
console.info(`Value changed for field ${callback.field} - ${field.val()} (no callback attached)`);
}
});
}
@ -1085,8 +1090,8 @@ function launchModalForm(url, options = {}) {
showAlertDialog('{% trans "Error requesting form data" %}', renderErrorMessage(xhr));
}
console.log('Modal form error: ' + xhr.status);
console.log('Message: ' + xhr.responseText);
console.error('Modal form error: ' + xhr.status);
console.info('Message: ' + xhr.responseText);
}
};

View File

@ -34,7 +34,7 @@
// Should the ID be rendered for this string
function renderId(title, pk, parameters={}) {
// Default = do not display
// Default = do not render
var render = false;
if ('render_pk' in parameters) {
@ -297,7 +297,12 @@ function renderSalesOrderShipment(name, data, parameters={}, options={}) {
var so_prefix = global_settings.SALESORDER_REFERENCE_PREFIX;
var html = `<span>${so_prefix}${data.order_detail.reference} - {% trans "Shipment" %} ${data.reference}</span>`;
var html = `
<span>${so_prefix}${data.order_detail.reference} - {% trans "Shipment" %} ${data.reference}</span>
<span class='float-right'>
<small>{% trans "Shipment ID" %}: ${data.pk}</small>
</span>
`;
html += renderId('{% trans "Shipment ID" %}', data.pk, parameters);
@ -384,10 +389,18 @@ function renderSupplierPart(name, data, parameters={}, options={}) {
var html = '';
html += select2Thumbnail(supplier_image);
html += select2Thumbnail(part_image);
html += ` <span><b>${data.supplier_detail.name}</b> - ${data.SKU}</span>`;
html += ` - <i>${data.part_detail.full_name}</i>`;
if (data.part_detail) {
html += select2Thumbnail(part_image);
}
if (data.supplier_detail) {
html += ` <span><b>${data.supplier_detail.name}</b> - ${data.SKU}</span>`;
}
if (data.part_detail) {
html += ` - <i>${data.part_detail.full_name}</i>`;
}
html += renderId('{% trans "Supplier Part ID" %}', data.pk, parameters);

View File

@ -35,6 +35,7 @@
loadSalesOrderTable,
newPurchaseOrderFromOrderWizard,
newSupplierPartFromOrderWizard,
orderParts,
removeOrderRowFromOrderWizard,
removePurchaseOrderLineItem,
loadOrderTotal,
@ -259,8 +260,8 @@ function createPurchaseOrder(options={}) {
}
}
},
supplier_reference: {},
description: {},
supplier_reference: {},
target_date: {
icon: 'fa-calendar-alt',
},
@ -476,6 +477,328 @@ function exportOrder(redirect_url, options={}) {
});
}
/*
* Create a new form to order parts based on the list of provided parts.
*/
function orderParts(parts_list, options={}) {
var parts = [];
var parts_seen = {};
parts_list.forEach(function(part) {
if (part.purchaseable) {
// Prevent duplicates
if (!(part.pk in parts_seen)) {
parts_seen[part.pk] = true;
parts.push(part);
}
}
});
if (parts.length == 0) {
showAlertDialog(
'{% trans "Select Parts" %}',
'{% trans "At least one purchaseable part must be selected" %}',
);
return;
}
// Render a single part within the dialog
function renderPart(part, opts={}) {
var pk = part.pk;
var thumb = thumbnailImage(part.thumbnail || part.image);
// The "quantity" field should have been provided for each part
var quantity = part.quantity || 1;
if (quantity < 0) {
quantity = 0;
}
var quantity_input = constructField(
`quantity_${pk}`,
{
type: 'decimal',
min_value: 0,
value: quantity,
title: '{% trans "Quantity to order" %}',
required: true,
},
{
hideLabels: true,
}
);
var supplier_part_prefix = `
<button type='button' class='input-group-text button-row-new-sp' pk='${pk}' title='{% trans "New supplier part" %}'>
<span class='fas fa-plus-circle icon-green'></span>
</button>
`;
var supplier_part_input = constructField(
`part_${pk}`,
{
type: 'related field',
required: true,
prefixRaw: supplier_part_prefix,
},
{
hideLabels: true,
}
);
var purchase_order_prefix = `
<button type='button' class='input-group-text button-row-new-po' pk='${pk}' title='{% trans "New purchase order" %}'>
<span class='fas fa-plus-circle icon-green'></span>
</button>
`;
var purchase_order_input = constructField(
`order_${pk}`,
{
type: 'related field',
required: true,
prefixRaw: purchase_order_prefix,
},
{
hideLabels: 'true',
}
);
var buttons = `<div class='btn-group float-right' role='group'>`;
if (parts.length > 1) {
buttons += makeIconButton(
'fa-times icon-red',
'button-row-remove',
pk,
'{% trans "Remove row" %}',
);
}
// Button to add row to purchase order
buttons += makeIconButton(
'fa-shopping-cart icon-blue',
'button-row-add',
pk,
'{% trans "Add to purchase order" %}',
);
buttons += `</div>`;
var html = `
<tr id='order_row_${pk}' class='part-order-row'>
<td id='td_part_${pk}'>${thumb} ${part.full_name}</td>
<td id='td_supplier_part_${pk}'>${supplier_part_input}</td>
<td id='td_order_${pk}'>${purchase_order_input}</td>
<td id='td_quantity_${pk}'>${quantity_input}</td>
<td id='td_actions_${pk}'>${buttons}</td>
</tr>`;
return html;
}
// Remove a single row form this dialog
function removeRow(pk, opts) {
// Remove the row
$(opts.modal).find(`#order_row_${pk}`).remove();
// If the modal is now "empty", dismiss it
if (!($(opts.modal).find('.part-order-row').exists())) {
closeModal(opts.modal);
}
}
var table_entries = '';
parts.forEach(function(part) {
table_entries += renderPart(part);
});
var html = '';
// Add table
html += `
<table class='table table-striped table-condensed' id='order-parts-table'>
<thead>
<tr>
<th>{% trans "Part" %}</th>
<th style='min-width: 300px;'>{% trans "Supplier Part" %}</th>
<th style='min-width: 300px;'>{% trans "Purchase Order" %}</th>
<th style='min-width: 50px;'>{% trans "Quantity" %}</th>
<th><!-- Actions --></th>
</tr>
</thead>
<tbody>
${table_entries}
</tbody>
</table>
`;
// Construct API filters for the SupplierPart field
var supplier_part_filters = {
supplier_detail: true,
part_detail: true,
};
if (options.supplier) {
supplier_part_filters.supplier = options.supplier;
}
if (options.manufacturer) {
supplier_part_filters.manufacturer = options.manufacturer;
}
if (options.manufacturer_part) {
supplier_part_filters.manufacturer_part = options.manufacturer_part;
}
// Construct API filtres for the PurchaseOrder field
var order_filters = {
status: {{ PurchaseOrderStatus.PENDING }},
supplier_detail: true,
};
if (options.supplier) {
order_filters.supplier = options.supplier;
}
constructFormBody({}, {
preFormContent: html,
title: '{% trans "Order Parts" %}',
preventSubmit: true,
closeText: '{% trans "Close" %}',
afterRender: function(fields, opts) {
parts.forEach(function(part) {
// Filter by base part
supplier_part_filters.part = part.pk;
if (part.manufacturer_part) {
// Filter by manufacturer part
supplier_part_filters.manufacturer_part = part.manufacturer_part;
}
// Configure the "supplier part" field
initializeRelatedField({
name: `part_${part.pk}`,
model: 'supplierpart',
api_url: '{% url "api-supplier-part-list" %}',
required: true,
type: 'related field',
auto_fill: true,
value: options.supplier_part,
filters: supplier_part_filters,
noResults: function(query) {
return '{% trans "No matching supplier parts" %}';
}
}, null, opts);
// Configure the "purchase order" field
initializeRelatedField({
name: `order_${part.pk}`,
model: 'purchaseorder',
api_url: '{% url "api-po-list" %}',
required: true,
type: 'related field',
auto_fill: false,
value: options.order,
filters: order_filters,
noResults: function(query) {
return '{% trans "No matching purchase orders" %}';
}
}, null, opts);
});
// Add callback for "add to purchase order" button
$(opts.modal).find('.button-row-add').click(function() {
var pk = $(this).attr('pk');
opts.field_suffix = null;
// Extract information from the row
var data = {
quantity: getFormFieldValue(`quantity_${pk}`, {type: 'decimal'}, opts),
part: getFormFieldValue(`part_${pk}`, {}, opts),
order: getFormFieldValue(`order_${pk}`, {}, opts),
};
// Duplicate the form options, to prevent 'field_suffix' override
var row_opts = Object.assign(opts);
row_opts.field_suffix = `_${pk}`;
inventreePut(
'{% url "api-po-line-list" %}',
data,
{
method: 'POST',
success: function(response) {
removeRow(pk, opts);
},
error: function(xhr) {
switch (xhr.status) {
case 400:
handleFormErrors(xhr.responseJSON, fields, row_opts);
break;
default:
console.error(`Error adding line to purchase order`);
showApiError(xhr, options.url);
break;
}
}
}
);
});
// Add callback for "remove row" button
$(opts.modal).find('.button-row-remove').click(function() {
var pk = $(this).attr('pk');
removeRow(pk, opts);
});
// Add callback for "new supplier part" button
$(opts.modal).find('.button-row-new-sp').click(function() {
var pk = $(this).attr('pk');
// Launch dialog to create new supplier part
createSupplierPart({
part: pk,
onSuccess: function(response) {
setRelatedFieldData(
`part_${pk}`,
response,
opts
);
}
});
});
// Add callback for "new purchase order" button
$(opts.modal).find('.button-row-new-po').click(function() {
var pk = $(this).attr('pk');
// Launch dialog to create new purchase order
createPurchaseOrder({
onSuccess: function(response) {
setRelatedFieldData(
`order_${pk}`,
response,
opts
);
}
});
});
}
});
}
function newPurchaseOrderFromOrderWizard(e) {
/* Create a new purchase order directly from an order form.
* Launches a secondary modal and (if successful),
@ -681,12 +1004,14 @@ function receivePurchaseOrderItems(order_id, line_items, options={}) {
);
}
buttons += makeIconButton(
'fa-times icon-red',
'button-row-remove',
pk,
'{% trans "Remove row" %}',
);
if (line_items.length > 1) {
buttons += makeIconButton(
'fa-times icon-red',
'button-row-remove',
pk,
'{% trans "Remove row" %}',
);
}
buttons += '</div>';
@ -1155,7 +1480,7 @@ function loadPurchaseOrderLineItemTable(table, options={}) {
var line_item = $(table).bootstrapTable('getRowByUniqueId', pk);
if (!line_item) {
console.log('WARNING: getRowByUniqueId returned null');
console.warn('getRowByUniqueId returned null');
return;
}
@ -1414,12 +1739,12 @@ function loadPurchaseOrderExtraLineTable(table, options={}) {
options.params = options.params || {};
if (!options.order) {
console.log('ERROR: function called without order ID');
console.error('function called without order ID');
return;
}
if (!options.status) {
console.log('ERROR: function called without order status');
console.error('function called without order status');
return;
}
@ -2541,12 +2866,12 @@ function loadSalesOrderLineItemTable(table, options={}) {
options.params = options.params || {};
if (!options.order) {
console.log('ERROR: function called without order ID');
console.error('function called without order ID');
return;
}
if (!options.status) {
console.log('ERROR: function called without order status');
console.error('function called without order status');
return;
}
@ -2965,13 +3290,18 @@ function loadSalesOrderLineItemTable(table, options={}) {
$(table).find('.button-buy').click(function() {
var pk = $(this).attr('pk');
launchModalForm('{% url "order-parts" %}', {
data: {
parts: [
pk
],
},
});
inventreeGet(
`/api/part/${pk}/`,
{},
{
success: function(part) {
orderParts(
[part],
{}
);
}
}
);
});
// Callback for displaying price
@ -3049,12 +3379,12 @@ function loadSalesOrderExtraLineTable(table, options={}) {
options.params = options.params || {};
if (!options.order) {
console.log('ERROR: function called without order ID');
console.error('function called without order ID');
return;
}
if (!options.status) {
console.log('ERROR: function called without order status');
console.error('function called without order status');
return;
}

View File

@ -876,7 +876,7 @@ function loadPartPurchaseOrderTable(table, part_id, options={}) {
var line_item = $(table).bootstrapTable('getRowByUniqueId', pk);
if (!line_item) {
console.log('WARNING: getRowByUniqueId returned null');
console.warn('getRowByUniqueId returned null');
return;
}
@ -1564,15 +1564,16 @@ function loadPartTable(table, url, options={}) {
var parts = [];
selections.forEach(function(item) {
parts.push(item.pk);
selections.forEach(function(part) {
parts.push(part);
});
launchModalForm('/order/purchase-order/order-parts/', {
data: {
parts: parts,
},
});
orderParts(
parts,
{
}
);
});
$('#multi-part-category').click(function() {
@ -1603,19 +1604,6 @@ function loadPartTable(table, url, options={}) {
printPartLabels(items);
});
$('#multi-part-export').click(function() {
var selections = $(table).bootstrapTable('getSelections');
var parts = '';
selections.forEach(function(item) {
parts += item.pk;
parts += ',';
});
location.href = '/part/export/?parts=' + parts;
});
}

View File

@ -2043,17 +2043,17 @@ function loadStockTable(table, options) {
$('#multi-item-order').click(function() {
var selections = $(table).bootstrapTable('getSelections');
var stock = [];
var parts = [];
selections.forEach(function(item) {
stock.push(item.pk);
var part = item.part_detail;
if (part) {
parts.push(part);
}
});
launchModalForm('/order/purchase-order/order-parts/', {
data: {
stock: stock,
},
});
orderParts(parts, {});
});
$('#multi-item-set-status').click(function() {

View File

@ -39,7 +39,7 @@ function downloadTableData(table, opts={}) {
var url = table_options.url;
if (!url) {
console.log('Error: downloadTableData could not find "url" parameter.');
console.error('downloadTableData could not find "url" parameter.');
}
var query_params = table_options.query_params || {};
@ -343,7 +343,7 @@ $.fn.inventreeTable = function(options) {
}
});
} else {
console.log(`Could not get list of visible columns for table '${tableName}'`);
console.error(`Could not get list of visible columns for table '${tableName}'`);
}
}

View File

@ -564,14 +564,14 @@ class Owner(models.Model):
try:
owners.append(cls.objects.get(owner_id=user.pk, owner_type=user_type))
except:
except: # pragma: no cover
pass
for group in user.groups.all():
try:
owner = cls.objects.get(owner_id=group.pk, owner_type=group_type)
owners.append(owner)
except:
except: # pragma: no cover
pass
return owners

View File

@ -197,6 +197,10 @@ class OwnerModelTest(TestCase):
self.assertTrue(user_as_owner in related_owners)
self.assertTrue(group_as_owner in related_owners)
# Check owner matching
owners = Owner.get_owners_matching_user(self.user)
self.assertEqual(owners, [user_as_owner, group_as_owner])
# Delete user and verify owner was deleted too
self.user.delete()
user_as_owner = Owner.get_owner(self.user)

188
README.md
View File

@ -1,16 +1,38 @@
<div align="center">
<img src="images/logo/inventree.png" alt="InvenTree logo" width="200" height="auto" />
<h1>InvenTree</h1>
<p>Open Source Inventory Management System </p>
<img src="images/logo/inventree.png" alt="InvenTree" width="128"/>
# InvenTree
<p><a href="https://twitter.com/intent/follow?screen_name=inventreedb">
<img src="https://img.shields.io/twitter/follow/inventreedb?style=social&logo=twitter"
alt="follow on Twitter"></a></p>
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Coverage Status](https://coveralls.io/repos/github/inventree/InvenTree/badge.svg)](https://coveralls.io/github/inventree/InvenTree)
[![Crowdin](https://badges.crowdin.net/inventree/localized.svg)](https://crowdin.com/project/inventree)
<!-- Badges -->
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)![GitHub tag (latest SemVer)](https://img.shields.io/github/v/tag/inventree/inventree)
![CI](https://github.com/inventree/inventree/actions/workflows/qc_checks.yaml/badge.svg)
![Docker Build](https://github.com/inventree/inventree/actions/workflows/docker_latest.yaml/badge.svg)
![Coveralls](https://img.shields.io/coveralls/github/inventree/InvenTree)
[![Crowdin](https://badges.crowdin.net/inventree/localized.svg)](https://crowdin.com/project/inventree)
![Lines of code](https://img.shields.io/tokei/lines/github/inventree/InvenTree)
![GitHub commit activity](https://img.shields.io/github/commit-activity/m/inventree/inventree)
![PyPI - Downloads](https://img.shields.io/pypi/dm/inventree)
[![Docker Pulls](https://img.shields.io/docker/pulls/inventree/inventree)](https://hub.docker.com/r/inventree/inventree)
![GitHub Org's stars](https://img.shields.io/github/stars/inventree?style=social)
![Twitter Follow](https://img.shields.io/twitter/follow/inventreedb?style=social)
![Subreddit subscribers](https://img.shields.io/reddit/subreddit-subscribers/inventree?style=social)
<h4>
<a href="https://demo.inventree.org/">View Demo</a>
<span> · </span>
<a href="https://inventree.readthedocs.io/en/latest/">Documentation</a>
<span> · </span>
<a href="https://github.com/inventree/InvenTree/issues/new?template=bug_report.md&title=[BUG]">Report Bug</a>
<span> · </span>
<a href="https://github.com/inventree/InvenTree/issues/new?template=feature_request.md&title=[FR]">Request Feature</a>
</h4>
</div>
<!-- About the Project -->
## :star2: About the Project
InvenTree is an open-source Inventory Management System which provides powerful low-level stock control and part tracking. The core of the InvenTree system is a Python/Django database backend which provides an admin interface (web-based) and a REST API for interaction with external interfaces and applications.
@ -18,61 +40,133 @@ InvenTree is designed to be lightweight and easy to use for SME or hobbyist appl
Powerful business logic works in the background to ensure that stock tracking history is maintained, and users have ready access to stock level information.
# Demo
<!-- Features -->
A demo instance of InvenTree is provided to allow users to explore the functionality of the software. [Read more here](https://inventree.readthedocs.io/en/latest/demo/)
### :dart: Features
# Docker
- Organize Parts and BOMs
- Manage Suppliers
- Instant Stock Knowledge
- Extend and Customize to fit your usage
[![Docker Pulls](https://img.shields.io/docker/pulls/inventree/inventree)](https://hub.docker.com/r/inventree/inventree)
![Docker Build](https://github.com/inventree/inventree/actions/workflows/docker_latest.yaml/badge.svg)
<!-- Roadmap -->
### :compass: Roadmap
InvenTree is [available via Docker](https://hub.docker.com/r/inventree/inventree). Read the [docker guide](https://inventree.readthedocs.io/en/latest/start/docker/) for full details.
* [x] Plugins
* [ ] Improved Importers
* [ ] Custom Workflow Processes
# Mobile App
<!-- Integration -->
### :hammer_and_wrench: Integration
InvenTree is supported by a [companion mobile app](https://inventree.readthedocs.io/en/latest/app/app/) which allows users access to stock control information and functionality.
- [**Download InvenTree from the Android Play Store**](https://play.google.com/store/apps/details?id=inventree.inventree_app)
- [**Download InvenTree from the Apple App Store**](https://apps.apple.com/au/app/inventree/id1581731101#?platform=iphone)
# Deploy to DigitalOcean
[![Deploy to DO](https://www.deploytodo.com/do-btn-blue-ghost.svg)](https://marketplace.digitalocean.com/apps/inventree?refcode=d6172576d014)
# 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.readthedocs.io/en/latest/start/install/) for installation and setup instructions.
# 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:
InvenTree is designed to be **extensible**, and provides multiple options for **integration** with external applications or addition of custom plugins:
* [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)
* [Third party tools](https://inventree.readthedocs.io/en/latest/extend/integrate)
# Contributing
<!-- TechStack -->
### :space_invader: Tech Stack
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/).
<details>
<summary>Server</summary>
<ul>
<li><a href="https://www.python.org/">Python</a></li>
<li><a href="https://www.djangoproject.com/">Django</a></li>
<li><a href="https://www.django-rest-framework.org/">DRF</a></li>
<li><a href="https://django-q.readthedocs.io/">Django Q</a></li>
<li><a href="https://django-allauth.readthedocs.io/">Django-Allauth</a></li>
</ul>
</details>
# Translation
<details>
<summary>Database</summary>
<ul>
<li><a href="https://www.postgresql.org/">PostgreSQL</a></li>
<li><a href="https://www.mysql.com/">MySQL</a></li>
<li><a href="https://www.sqlite.org/">SQLite</a></li>
<li><a href="https://redis.io/">Redis</a></li>
</ul>
</details>
<details>
<summary>Client</summary>
<ul>
<li><a href="https://getbootstrap.com/">Bootstrap</a></li>
<li><a href="https://jquery.com/">jQuery</a></li>
<li><a href="https://bootstrap-table.com/">Bootstrap-Table</a></li>
</ul>
</details>
<details>
<summary>DevOps</summary>
<ul>
<li><a href="https://www.docker.com/">Docker</a></li>
<li><a href="https://crowdin.com/">Crowdin</a></li>
<li><a href="https://coveralls.io/">Coveralls</a></li>
</ul>
</details>
<!-- Getting Started -->
## :toolbox: Getting Started
Refer to the [getting started guide](https://inventree.readthedocs.io/en/latest/start/install/) for installation and setup instructions.
<!-- Mobile App -->
## :iphone: Mobile App
InvenTree is supported by a [companion mobile app](https://inventree.readthedocs.io/en/latest/app/app/) which allows users access to stock control information and functionality.
<div align="center"><h4>
<a href="https://play.google.com/store/apps/details?id=inventree.inventree_app">Android Play Store</a>
<span> · </span>
<a href="https://apps.apple.com/au/app/inventree/id1581731101#?platform=iphone">Apple App Store</a>
</h4></div>
<!-- Deploy -->
## :train: Deploy
There are several options to deploy InvenTree.
<div align="center"><h4>
<a href="https://inventree.readthedocs.io/en/latest/start/docker/">Docker</a>
<span> · </span>
<a href="https://marketplace.digitalocean.com/apps/inventree?refcode=d6172576d014"><img src="https://www.deploytodo.com/do-btn-blue-ghost.svg" alt="Deploy to DO" width="auto" height="40" /></a>
<span> · </span>
<a href="https://inventree.readthedocs.io/en/latest/start/install/">Bare Metal</a>
</h4></div>
<!-- Contributing -->
## :wave: Contributing
Contributions are welcomed and encouraged. Please help to make this project even better!
Refer to the [contribution page in the docs ](https://inventree.readthedocs.io/en/latest/contribute/) and check out [contributing.md](CONTRIBUTING.md).
<!-- Translation -->
## :scroll: Translation
Native language translation of the InvenTree web application is [community contributed via crowdin](https://crowdin.com/project/inventree). **Contributions are welcomed and encouraged**.
To contribute to the translation effort, navigate to the [InvenTree crowdin project](https://crowdin.com/project/inventree), create a free account, and start making translations suggestions for your language of choice!
# Donate
<!-- Sponsor -->
## :money_with_wings: Sponsor
If you use InvenTree and find it to be useful, please consider making a donation toward its continued development.
[Donate via PayPal](https://paypal.me/inventree?locale.x=en_AU)
<!-- Acknowledgments -->
## :gem: Acknowledgements
We would like to acknowledge a few special projects:
- [PartKeepr](https://github.com/partkeepr/PartKeepr) as a valuable predecessor and inspiration
- [Readme Template](https://github.com/Louis3797/awesome-readme-template) for the template of this page
Find a full list of used third-party libraries in [our documentation](https://inventree.readthedocs.io/en/latest/credits/).
<!-- License -->
## :warning: License
Distributed under the [MIT](https://choosealicense.com/licenses/mit/) License. See LICENSE.txt for more information.