mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +00:00
Merge branch 'master' of https://github.com/inventree/InvenTree into matmair/issue2788
This commit is contained in:
commit
1a3d482e42
@ -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 |
|
||||
|
||||
|
@ -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
|
||||
|
||||
|
@ -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
|
||||
|
@ -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(
|
||||
|
@ -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');
|
||||
|
@ -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:
|
||||
|
@ -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 %}
|
||||
|
@ -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 %}
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
|
@ -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 %}
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
|
@ -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)
|
||||
|
@ -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
|
||||
|
||||
|
@ -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 %}
|
@ -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 %}
|
@ -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
|
||||
|
@ -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 """
|
||||
|
||||
|
@ -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" %}
|
||||
|
@ -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() {
|
||||
|
@ -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 %}
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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:
|
||||
|
@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
@ -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:
|
||||
|
@ -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
|
||||
|
@ -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")
|
||||
|
@ -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
|
||||
|
153
InvenTree/plugin/samples/integration/test_scheduled_task.py
Normal file
153
InvenTree/plugin/samples/integration/test_scheduled_task.py
Normal 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()
|
@ -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')
|
||||
|
@ -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 """
|
||||
|
@ -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);
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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',
|
||||
|
@ -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;
|
||||
}
|
||||
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -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);
|
||||
|
||||
|
@ -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;
|
||||
}
|
||||
|
||||
|
@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
@ -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() {
|
||||
|
@ -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}'`);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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
|
||||
|
@ -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
188
README.md
@ -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.
|
||||
|
Loading…
Reference in New Issue
Block a user