mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +00:00
Merge branch 'inventree:master' into webhooks
This commit is contained in:
commit
b6b6e10f4d
6
.github/workflows/docker_test.yaml
vendored
6
.github/workflows/docker_test.yaml
vendored
@ -26,9 +26,9 @@ jobs:
|
||||
- name: Build Docker Image
|
||||
run: |
|
||||
cd docker
|
||||
docker-compose -f docker-compose.dev.yml build
|
||||
docker-compose -f docker-compose.dev.yml run inventree-dev-server invoke update
|
||||
docker-compose -f docker-compose.dev.yml up -d
|
||||
docker-compose -f docker-compose.sqlite.yml build
|
||||
docker-compose -f docker-compose.sqlite.yml run inventree-dev-server invoke update
|
||||
docker-compose -f docker-compose.sqlite.yml up -d
|
||||
- name: Sleepy Time
|
||||
run: sleep 60
|
||||
- name: Test API
|
||||
|
@ -14,6 +14,10 @@
|
||||
--bs-body-color: #68686a;
|
||||
}
|
||||
|
||||
main {
|
||||
overflow-x: clip;
|
||||
}
|
||||
|
||||
.login-screen {
|
||||
background: url(/static/img/paper_splash.jpg) no-repeat center fixed;
|
||||
background-size: cover;
|
||||
|
@ -252,6 +252,9 @@ class StockHistoryCode(StatusCode):
|
||||
SPLIT_FROM_PARENT = 40
|
||||
SPLIT_CHILD_ITEM = 42
|
||||
|
||||
# Stock merging operations
|
||||
MERGED_STOCK_ITEMS = 45
|
||||
|
||||
# Build order codes
|
||||
BUILD_OUTPUT_CREATED = 50
|
||||
BUILD_OUTPUT_COMPLETED = 55
|
||||
@ -288,6 +291,8 @@ class StockHistoryCode(StatusCode):
|
||||
SPLIT_FROM_PARENT: _('Split from parent item'),
|
||||
SPLIT_CHILD_ITEM: _('Split child item'),
|
||||
|
||||
MERGED_STOCK_ITEMS: _('Merged stock items'),
|
||||
|
||||
SENT_TO_CUSTOMER: _('Sent to customer'),
|
||||
RETURNED_FROM_CUSTOMER: _('Returned from customer'),
|
||||
|
||||
|
@ -12,10 +12,14 @@ import common.models
|
||||
INVENTREE_SW_VERSION = "0.6.0 dev"
|
||||
|
||||
# InvenTree API version
|
||||
INVENTREE_API_VERSION = 21
|
||||
INVENTREE_API_VERSION = 22
|
||||
|
||||
"""
|
||||
Increment this API version number whenever there is a significant change to the API that any clients need to know about
|
||||
|
||||
v22 -> 2021-12-20
|
||||
- Adds API endpoint to "merge" multiple stock items
|
||||
|
||||
v21 -> 2021-12-04
|
||||
- Adds support for multiple "Shipments" against a SalesOrder
|
||||
- Refactors process for stock allocation against a SalesOrder
|
||||
|
@ -45,10 +45,10 @@
|
||||
{% endif %}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
{% include "filter_list.html" with id="supplier-part" %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<table class='table table-striped table-condensed' id='supplier-part-table' data-toolbar='#supplier-part-button-toolbar'>
|
||||
@ -92,8 +92,8 @@
|
||||
{% endif %}
|
||||
</ul>
|
||||
</div>
|
||||
{% include "filter_list.html" with id="manufacturer-part" %}
|
||||
</div>
|
||||
{% include "filter_list.html" with id="supplier-part" %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
@ -169,10 +169,10 @@
|
||||
</div>
|
||||
<div class='panel-content'>
|
||||
<div id='assigned-stock-button-toolbar'>
|
||||
{% include "filter_list.html" with id="stock" %}
|
||||
{% include "filter_list.html" with id="customerstock" %}
|
||||
</div>
|
||||
|
||||
<table class='table table-striped table-condensed' id='assigned-stock-table' data-toolbar='#bassigned-stock-utton-toolbar'></table>
|
||||
<table class='table table-striped table-condensed' id='assigned-stock-table' data-toolbar='#assigned-stock-button-toolbar'></table>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
@ -225,6 +225,7 @@
|
||||
},
|
||||
url: "{% url 'api-stock-list' %}",
|
||||
filterKey: "customerstock",
|
||||
filterTarget: '#filter-list-customerstock',
|
||||
});
|
||||
|
||||
{% if company.is_customer %}
|
||||
|
@ -25,6 +25,7 @@
|
||||
<div class='panel-content'>
|
||||
|
||||
<div id='button-toolbar'>
|
||||
{% include "filter_list.html" with id='company' %}
|
||||
</div>
|
||||
|
||||
<table class='table table-striped table-condensed' id='company-table' data-toolbar='#button-toolbar'>
|
||||
|
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -628,29 +628,31 @@ class SalesOrder(Order):
|
||||
Throws a ValidationError if cannot be completed.
|
||||
"""
|
||||
|
||||
try:
|
||||
|
||||
# Order without line items cannot be completed
|
||||
if self.lines.count() == 0:
|
||||
if raise_error:
|
||||
raise ValidationError(_('Order cannot be completed as no parts have been assigned'))
|
||||
|
||||
# Only a PENDING order can be marked as SHIPPED
|
||||
elif self.status != SalesOrderStatus.PENDING:
|
||||
if raise_error:
|
||||
raise ValidationError(_('Only a pending order can be marked as complete'))
|
||||
|
||||
elif self.pending_shipment_count > 0:
|
||||
if raise_error:
|
||||
raise ValidationError(_("Order cannot be completed as there are incomplete shipments"))
|
||||
|
||||
elif self.pending_line_count > 0:
|
||||
if raise_error:
|
||||
raise ValidationError(_("Order cannot be completed as there are incomplete line items"))
|
||||
|
||||
else:
|
||||
return True
|
||||
except ValidationError as e:
|
||||
|
||||
if raise_error:
|
||||
raise e
|
||||
else:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def complete_order(self, user):
|
||||
"""
|
||||
Mark this order as "complete"
|
||||
|
@ -454,6 +454,76 @@ class PartSerialNumberDetail(generics.RetrieveAPIView):
|
||||
return Response(data)
|
||||
|
||||
|
||||
class PartCopyBOM(generics.CreateAPIView):
|
||||
"""
|
||||
API endpoint for duplicating a BOM
|
||||
"""
|
||||
|
||||
queryset = Part.objects.all()
|
||||
serializer_class = part_serializers.PartCopyBOMSerializer
|
||||
|
||||
def get_serializer_context(self):
|
||||
|
||||
ctx = super().get_serializer_context()
|
||||
|
||||
try:
|
||||
ctx['part'] = Part.objects.get(pk=self.kwargs.get('pk', None))
|
||||
except:
|
||||
pass
|
||||
|
||||
return ctx
|
||||
|
||||
|
||||
class PartValidateBOM(generics.RetrieveUpdateAPIView):
|
||||
"""
|
||||
API endpoint for 'validating' the BOM for a given Part
|
||||
"""
|
||||
|
||||
class BOMValidateSerializer(serializers.ModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = Part
|
||||
fields = [
|
||||
'checksum',
|
||||
'valid',
|
||||
]
|
||||
|
||||
checksum = serializers.CharField(
|
||||
read_only=True,
|
||||
source='bom_checksum',
|
||||
)
|
||||
|
||||
valid = serializers.BooleanField(
|
||||
write_only=True,
|
||||
default=False,
|
||||
label=_('Valid'),
|
||||
help_text=_('Validate entire Bill of Materials'),
|
||||
)
|
||||
|
||||
def validate_valid(self, valid):
|
||||
if not valid:
|
||||
raise ValidationError(_('This option must be selected'))
|
||||
|
||||
queryset = Part.objects.all()
|
||||
|
||||
serializer_class = BOMValidateSerializer
|
||||
|
||||
def update(self, request, *args, **kwargs):
|
||||
|
||||
part = self.get_object()
|
||||
|
||||
partial = kwargs.pop('partial', False)
|
||||
|
||||
serializer = self.get_serializer(part, data=request.data, partial=partial)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
part.validate_bom(request.user)
|
||||
|
||||
return Response({
|
||||
'checksum': part.bom_checksum,
|
||||
})
|
||||
|
||||
|
||||
class PartDetail(generics.RetrieveUpdateDestroyAPIView):
|
||||
""" API endpoint for detail view of a single Part object """
|
||||
|
||||
@ -1585,6 +1655,12 @@ part_api_urls = [
|
||||
# Endpoint for extra serial number information
|
||||
url(r'^serial-numbers/', PartSerialNumberDetail.as_view(), name='api-part-serial-number-detail'),
|
||||
|
||||
# Endpoint for duplicating a BOM for the specific Part
|
||||
url(r'^bom-copy/', PartCopyBOM.as_view(), name='api-part-bom-copy'),
|
||||
|
||||
# Endpoint for validating a BOM for the specific Part
|
||||
url(r'^bom-validate/', PartValidateBOM.as_view(), name='api-part-bom-validate'),
|
||||
|
||||
# Part detail endpoint
|
||||
url(r'^.*$', PartDetail.as_view(), name='api-part-detail'),
|
||||
])),
|
||||
|
@ -55,54 +55,6 @@ class PartImageDownloadForm(HelperForm):
|
||||
]
|
||||
|
||||
|
||||
class BomDuplicateForm(HelperForm):
|
||||
"""
|
||||
Simple confirmation form for BOM duplication.
|
||||
|
||||
Select which parent to select from.
|
||||
"""
|
||||
|
||||
parent = PartModelChoiceField(
|
||||
label=_('Parent Part'),
|
||||
help_text=_('Select parent part to copy BOM from'),
|
||||
queryset=Part.objects.filter(is_template=True),
|
||||
)
|
||||
|
||||
clear = forms.BooleanField(
|
||||
required=False, initial=True,
|
||||
help_text=_('Clear existing BOM items')
|
||||
)
|
||||
|
||||
confirm = forms.BooleanField(
|
||||
required=False, initial=False,
|
||||
label=_('Confirm'),
|
||||
help_text=_('Confirm BOM duplication')
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Part
|
||||
fields = [
|
||||
'parent',
|
||||
'clear',
|
||||
'confirm',
|
||||
]
|
||||
|
||||
|
||||
class BomValidateForm(HelperForm):
|
||||
""" Simple confirmation form for BOM validation.
|
||||
User is presented with a single checkbox input,
|
||||
to confirm that the BOM for this part is valid
|
||||
"""
|
||||
|
||||
validate = forms.BooleanField(required=False, initial=False, label=_('validate'), help_text=_('Confirm that the BOM is correct'))
|
||||
|
||||
class Meta:
|
||||
model = Part
|
||||
fields = [
|
||||
'validate'
|
||||
]
|
||||
|
||||
|
||||
class BomMatchItemForm(MatchItemForm):
|
||||
""" Override MatchItemForm fields """
|
||||
|
||||
|
@ -481,7 +481,7 @@ class Part(MPTTModel):
|
||||
def __str__(self):
|
||||
return f"{self.full_name} - {self.description}"
|
||||
|
||||
def checkAddToBOM(self, parent):
|
||||
def check_add_to_bom(self, parent, raise_error=False, recursive=True):
|
||||
"""
|
||||
Check if this Part can be added to the BOM of another part.
|
||||
|
||||
@ -491,13 +491,11 @@ class Part(MPTTModel):
|
||||
b) The parent part is used in the BOM for *this* part
|
||||
c) The parent part is used in the BOM for any child parts under this one
|
||||
|
||||
Failing this check raises a ValidationError!
|
||||
|
||||
"""
|
||||
|
||||
if parent is None:
|
||||
return
|
||||
result = True
|
||||
|
||||
try:
|
||||
if self.pk == parent.pk:
|
||||
raise ValidationError({'sub_part': _("Part '{p1}' is used in BOM for '{p2}' (recursive)").format(
|
||||
p1=str(self),
|
||||
@ -517,7 +515,20 @@ class Part(MPTTModel):
|
||||
)})
|
||||
|
||||
# And recursively check too
|
||||
item.sub_part.checkAddToBOM(parent)
|
||||
if recursive:
|
||||
result = result and item.sub_part.check_add_to_bom(
|
||||
parent,
|
||||
recursive=True,
|
||||
raise_error=raise_error
|
||||
)
|
||||
|
||||
except ValidationError as e:
|
||||
if raise_error:
|
||||
raise e
|
||||
else:
|
||||
return False
|
||||
|
||||
return result
|
||||
|
||||
def checkIfSerialNumberExists(self, sn, exclude_self=False):
|
||||
"""
|
||||
@ -1816,23 +1827,45 @@ class Part(MPTTModel):
|
||||
clear - Remove existing BOM items first (default=True)
|
||||
"""
|
||||
|
||||
# Ignore if the other part is actually this part?
|
||||
if other == self:
|
||||
return
|
||||
|
||||
if clear:
|
||||
# Remove existing BOM items
|
||||
# Note: Inherited BOM items are *not* deleted!
|
||||
self.bom_items.all().delete()
|
||||
|
||||
# List of "ancestor" parts above this one
|
||||
my_ancestors = self.get_ancestors(include_self=False)
|
||||
|
||||
raise_error = not kwargs.get('skip_invalid', True)
|
||||
|
||||
include_inherited = kwargs.get('include_inherited', False)
|
||||
|
||||
# Copy existing BOM items from another part
|
||||
# Note: Inherited BOM Items will *not* be duplicated!!
|
||||
for bom_item in other.get_bom_items(include_inherited=False).all():
|
||||
for bom_item in other.get_bom_items(include_inherited=include_inherited).all():
|
||||
# If this part already has a BomItem pointing to the same sub-part,
|
||||
# delete that BomItem from this part first!
|
||||
|
||||
try:
|
||||
existing = BomItem.objects.get(part=self, sub_part=bom_item.sub_part)
|
||||
existing.delete()
|
||||
except (BomItem.DoesNotExist):
|
||||
pass
|
||||
# Ignore invalid BomItem objects
|
||||
if not bom_item.part or not bom_item.sub_part:
|
||||
continue
|
||||
|
||||
# Ignore ancestor parts which are inherited
|
||||
if bom_item.part in my_ancestors and bom_item.inherited:
|
||||
continue
|
||||
|
||||
# Skip if already exists
|
||||
if BomItem.objects.filter(part=self, sub_part=bom_item.sub_part).exists():
|
||||
continue
|
||||
|
||||
# Skip (or throw error) if BomItem is not valid
|
||||
if not bom_item.sub_part.check_add_to_bom(self, raise_error=raise_error):
|
||||
continue
|
||||
|
||||
# Construct a new BOM item
|
||||
bom_item.part = self
|
||||
bom_item.pk = None
|
||||
|
||||
@ -2677,7 +2710,7 @@ class BomItem(models.Model):
|
||||
try:
|
||||
# Check for circular BOM references
|
||||
if self.sub_part:
|
||||
self.sub_part.checkAddToBOM(self.part)
|
||||
self.sub_part.check_add_to_bom(self.part, raise_error=True)
|
||||
|
||||
# If the sub_part is 'trackable' then the 'quantity' field must be an integer
|
||||
if self.sub_part.trackable:
|
||||
|
@ -9,6 +9,7 @@ from django.urls import reverse_lazy
|
||||
from django.db import models
|
||||
from django.db.models import Q
|
||||
from django.db.models.functions import Coalesce
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
|
||||
from rest_framework import serializers
|
||||
from sql_util.utils import SubqueryCount, SubquerySum
|
||||
@ -636,3 +637,65 @@ class CategoryParameterTemplateSerializer(InvenTreeModelSerializer):
|
||||
'parameter_template',
|
||||
'default_value',
|
||||
]
|
||||
|
||||
|
||||
class PartCopyBOMSerializer(serializers.Serializer):
|
||||
"""
|
||||
Serializer for copying a BOM from another part
|
||||
"""
|
||||
|
||||
class Meta:
|
||||
fields = [
|
||||
'part',
|
||||
'remove_existing',
|
||||
]
|
||||
|
||||
part = serializers.PrimaryKeyRelatedField(
|
||||
queryset=Part.objects.all(),
|
||||
many=False,
|
||||
required=True,
|
||||
allow_null=False,
|
||||
label=_('Part'),
|
||||
help_text=_('Select part to copy BOM from'),
|
||||
)
|
||||
|
||||
def validate_part(self, part):
|
||||
"""
|
||||
Check that a 'valid' part was selected
|
||||
"""
|
||||
|
||||
return part
|
||||
|
||||
remove_existing = serializers.BooleanField(
|
||||
label=_('Remove Existing Data'),
|
||||
help_text=_('Remove existing BOM items before copying'),
|
||||
default=True,
|
||||
)
|
||||
|
||||
include_inherited = serializers.BooleanField(
|
||||
label=_('Include Inherited'),
|
||||
help_text=_('Include BOM items which are inherited from templated parts'),
|
||||
default=False,
|
||||
)
|
||||
|
||||
skip_invalid = serializers.BooleanField(
|
||||
label=_('Skip Invalid Rows'),
|
||||
help_text=_('Enable this option to skip invalid rows'),
|
||||
default=False,
|
||||
)
|
||||
|
||||
def save(self):
|
||||
"""
|
||||
Actually duplicate the BOM
|
||||
"""
|
||||
|
||||
base_part = self.context['part']
|
||||
|
||||
data = self.validated_data
|
||||
|
||||
base_part.copy_bom_from(
|
||||
data['part'],
|
||||
clear=data.get('remove_existing', True),
|
||||
skip_invalid=data.get('skip_invalid', False),
|
||||
include_inherited=data.get('include_inherited', False),
|
||||
)
|
||||
|
@ -1,17 +0,0 @@
|
||||
{% extends "modal_form.html" %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block pre_form_content %}
|
||||
|
||||
<p>
|
||||
{% trans "Select parent part to copy BOM from" %}
|
||||
</p>
|
||||
|
||||
{% if part.has_bom %}
|
||||
<div class='alert alert-block alert-danger'>
|
||||
<strong>{% trans "Warning" %}</strong><br>
|
||||
{% trans "This part already has a Bill of Materials" %}<br>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% endblock %}
|
@ -1,12 +0,0 @@
|
||||
{% extends "modal_form.html" %}
|
||||
|
||||
{% load i18n %}
|
||||
|
||||
{% block pre_form_content %}
|
||||
{% blocktrans with part.full_name as part %}Confirm that the Bill of Materials (BOM) is valid for:<br><em>{{ part }}</em>{% endblocktrans %}
|
||||
|
||||
<div class='alert alert-warning alert-block'>
|
||||
{% trans 'This will validate each line in the BOM.' %}
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
@ -181,6 +181,9 @@
|
||||
<div class='panel-content'>
|
||||
<div id='param-button-toolbar'>
|
||||
<div class='button-toolbar container-fluid' style='float: right;'>
|
||||
<div class='btn-group' role='group'>
|
||||
{% include "filter_list.html" with id="parameters" %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<table id='parameter-table' class='table table-condensed table-striped' data-toolbar="#param-button-toolbar"></table>
|
||||
@ -217,7 +220,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class='panel-content'>
|
||||
<div id='related-button-bar'>
|
||||
<div id='related-button-toolbar'>
|
||||
<div class='btn-group' role='group'>
|
||||
{% include "filter_list.html" with id="related" %}
|
||||
</div>
|
||||
@ -344,6 +347,7 @@
|
||||
<li><a class='dropdown-item' href='#' id='supplier-part-delete' title='{% trans "Delete supplier parts" %}'>{% trans "Delete" %}</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
{% include "filter_list.html" with id="supplier-part" %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -371,6 +375,7 @@
|
||||
<ul class="dropdown-menu">
|
||||
<li><a class='dropdown-item' href='#' id='manufacturer-part-delete' title='{% trans "Delete manufacturer parts" %}'><span class='fas fa-trash-alt icon-red'></span> {% trans "Delete" %}</a></li>
|
||||
</ul>
|
||||
{% include "filter_list.html" with id="manufacturer-part" %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -580,14 +585,12 @@
|
||||
});
|
||||
|
||||
$('#bom-duplicate').click(function() {
|
||||
launchModalForm(
|
||||
"{% url 'duplicate-bom' part.id %}",
|
||||
{
|
||||
success: function() {
|
||||
|
||||
duplicateBom({{ part.pk }}, {
|
||||
success: function(response) {
|
||||
$('#bom-table').bootstrapTable('refresh');
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
$("#bom-item-new").click(function () {
|
||||
@ -611,12 +614,10 @@
|
||||
});
|
||||
|
||||
$("#validate-bom").click(function() {
|
||||
launchModalForm(
|
||||
"{% url 'bom-validate' part.id %}",
|
||||
{
|
||||
reload: true,
|
||||
}
|
||||
);
|
||||
|
||||
validateBom({{ part.id }}, {
|
||||
reload: true
|
||||
});
|
||||
});
|
||||
|
||||
$("#download-bom").click(function () {
|
||||
|
@ -35,12 +35,10 @@ part_detail_urls = [
|
||||
url(r'^delete/?', views.PartDelete.as_view(), name='part-delete'),
|
||||
url(r'^bom-export/?', views.BomExport.as_view(), name='bom-export'),
|
||||
url(r'^bom-download/?', views.BomDownload.as_view(), name='bom-download'),
|
||||
url(r'^validate-bom/', views.BomValidate.as_view(), name='bom-validate'),
|
||||
|
||||
url(r'^pricing/', views.PartPricing.as_view(), name='part-pricing'),
|
||||
|
||||
url(r'^bom-upload/?', views.BomUpload.as_view(), name='upload-bom'),
|
||||
url(r'^bom-duplicate/?', views.BomDuplicate.as_view(), name='duplicate-bom'),
|
||||
|
||||
url(r'^qr_code/?', views.PartQRCode.as_view(), name='part-qr'),
|
||||
|
||||
|
@ -694,100 +694,6 @@ class PartImageSelect(AjaxUpdateView):
|
||||
return self.renderJsonResponse(request, form, data)
|
||||
|
||||
|
||||
class BomDuplicate(AjaxUpdateView):
|
||||
"""
|
||||
View for duplicating BOM from a parent item.
|
||||
"""
|
||||
|
||||
model = Part
|
||||
context_object_name = 'part'
|
||||
ajax_form_title = _('Duplicate BOM')
|
||||
ajax_template_name = 'part/bom_duplicate.html'
|
||||
form_class = part_forms.BomDuplicateForm
|
||||
|
||||
def get_form(self):
|
||||
|
||||
form = super().get_form()
|
||||
|
||||
# Limit choices to parents of the current part
|
||||
parents = self.get_object().get_ancestors()
|
||||
|
||||
form.fields['parent'].queryset = parents
|
||||
|
||||
return form
|
||||
|
||||
def get_initial(self):
|
||||
initials = super().get_initial()
|
||||
|
||||
parents = self.get_object().get_ancestors()
|
||||
|
||||
if parents.count() == 1:
|
||||
initials['parent'] = parents[0]
|
||||
|
||||
return initials
|
||||
|
||||
def validate(self, part, form):
|
||||
|
||||
confirm = str2bool(form.cleaned_data.get('confirm', False))
|
||||
|
||||
if not confirm:
|
||||
form.add_error('confirm', _('Confirm duplication of BOM from parent'))
|
||||
|
||||
def save(self, part, form):
|
||||
"""
|
||||
Duplicate BOM from the specified parent
|
||||
"""
|
||||
|
||||
parent = form.cleaned_data.get('parent', None)
|
||||
|
||||
clear = str2bool(form.cleaned_data.get('clear', True))
|
||||
|
||||
if parent:
|
||||
part.copy_bom_from(parent, clear=clear)
|
||||
|
||||
|
||||
class BomValidate(AjaxUpdateView):
|
||||
"""
|
||||
Modal form view for validating a part BOM
|
||||
"""
|
||||
|
||||
model = Part
|
||||
ajax_form_title = _("Validate BOM")
|
||||
ajax_template_name = 'part/bom_validate.html'
|
||||
context_object_name = 'part'
|
||||
form_class = part_forms.BomValidateForm
|
||||
|
||||
def get_context(self):
|
||||
return {
|
||||
'part': self.get_object(),
|
||||
}
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
|
||||
form = self.get_form()
|
||||
|
||||
return self.renderJsonResponse(request, form, context=self.get_context())
|
||||
|
||||
def validate(self, part, form, **kwargs):
|
||||
|
||||
confirm = str2bool(form.cleaned_data.get('validate', False))
|
||||
|
||||
if not confirm:
|
||||
form.add_error('validate', _('Confirm that the BOM is valid'))
|
||||
|
||||
def save(self, part, form, **kwargs):
|
||||
"""
|
||||
Mark the BOM as validated
|
||||
"""
|
||||
|
||||
part.validate_bom(self.request.user)
|
||||
|
||||
def get_data(self):
|
||||
return {
|
||||
'success': _('Validated Bill of Materials')
|
||||
}
|
||||
|
||||
|
||||
class BomUpload(InvenTreeRoleMixin, FileManagementFormView):
|
||||
""" View for uploading a BOM file, and handling BOM data importing.
|
||||
|
||||
|
@ -4,6 +4,7 @@ This script calculates translation coverage for various languages
|
||||
|
||||
import os
|
||||
import json
|
||||
import sys
|
||||
|
||||
|
||||
def calculate_coverage(filename):
|
||||
@ -42,7 +43,7 @@ if __name__ == '__main__':
|
||||
locales = {}
|
||||
locales_perc = {}
|
||||
|
||||
print("InvenTree translation coverage:")
|
||||
verbose = '-v' in sys.argv
|
||||
|
||||
for locale in os.listdir(LC_DIR):
|
||||
path = os.path.join(LC_DIR, locale)
|
||||
@ -53,8 +54,11 @@ if __name__ == '__main__':
|
||||
if os.path.exists(locale_file) and os.path.isfile(locale_file):
|
||||
locales[locale] = locale_file
|
||||
|
||||
if verbose:
|
||||
print("-" * 16)
|
||||
|
||||
percentages = []
|
||||
|
||||
for locale in locales.keys():
|
||||
locale_file = locales[locale]
|
||||
stats = calculate_coverage(locale_file)
|
||||
@ -66,11 +70,23 @@ if __name__ == '__main__':
|
||||
else:
|
||||
percentage = 0
|
||||
|
||||
if verbose:
|
||||
print(f"| {locale.ljust(4, ' ')} : {str(percentage).rjust(4, ' ')}% |")
|
||||
|
||||
locales_perc[locale] = percentage
|
||||
|
||||
percentages.append(percentage)
|
||||
|
||||
if verbose:
|
||||
print("-" * 16)
|
||||
|
||||
# write locale stats
|
||||
with open(STAT_FILE, 'w') as target:
|
||||
json.dump(locales_perc, target)
|
||||
|
||||
if len(percentages) > 0:
|
||||
avg = int(sum(percentages) / len(percentages))
|
||||
else:
|
||||
avg = 0
|
||||
|
||||
print(f"InvenTree translation coverage: {avg}%")
|
||||
|
@ -180,6 +180,20 @@ class StockAssign(generics.CreateAPIView):
|
||||
return ctx
|
||||
|
||||
|
||||
class StockMerge(generics.CreateAPIView):
|
||||
"""
|
||||
API endpoint for merging multiple stock items
|
||||
"""
|
||||
|
||||
queryset = StockItem.objects.none()
|
||||
serializer_class = StockSerializers.StockMergeSerializer
|
||||
|
||||
def get_serializer_context(self):
|
||||
ctx = super().get_serializer_context()
|
||||
ctx['request'] = self.request
|
||||
return ctx
|
||||
|
||||
|
||||
class StockLocationList(generics.ListCreateAPIView):
|
||||
"""
|
||||
API endpoint for list view of StockLocation objects:
|
||||
@ -1213,6 +1227,7 @@ stock_api_urls = [
|
||||
url(r'^remove/', StockRemove.as_view(), name='api-stock-remove'),
|
||||
url(r'^transfer/', StockTransfer.as_view(), name='api-stock-transfer'),
|
||||
url(r'^assign/', StockAssign.as_view(), name='api-stock-assign'),
|
||||
url(r'^merge/', StockMerge.as_view(), name='api-stock-merge'),
|
||||
|
||||
# StockItemAttachment API endpoints
|
||||
url(r'^attachment/', include([
|
||||
|
@ -114,19 +114,6 @@
|
||||
lft: 0
|
||||
rght: 0
|
||||
|
||||
- model: stock.stockitem
|
||||
pk: 501
|
||||
fields:
|
||||
part: 10001
|
||||
location: 7
|
||||
batch: "AAA"
|
||||
quantity: 1
|
||||
serial: 1
|
||||
level: 0
|
||||
tree_id: 0
|
||||
lft: 0
|
||||
rght: 0
|
||||
|
||||
- model: stock.stockitem
|
||||
pk: 501
|
||||
fields:
|
||||
|
@ -0,0 +1,19 @@
|
||||
# Generated by Django 3.2.10 on 2021-12-20 21:49
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('stock', '0072_remove_stockitem_scheduled_for_deletion'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='stockitem',
|
||||
name='belongs_to',
|
||||
field=models.ForeignKey(blank=True, help_text='Is this item installed in another item?', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='installed_parts', to='stock.stockitem', verbose_name='Installed In'),
|
||||
),
|
||||
]
|
@ -455,6 +455,7 @@ class StockItem(MPTTModel):
|
||||
|
||||
uid = models.CharField(blank=True, max_length=128, help_text=("Unique identifier field"))
|
||||
|
||||
# Note: When a StockItem is deleted, a pre_delete signal handles the parent/child relationship
|
||||
parent = TreeForeignKey(
|
||||
'self',
|
||||
verbose_name=_('Parent Stock Item'),
|
||||
@ -477,6 +478,7 @@ class StockItem(MPTTModel):
|
||||
help_text=_('Select a matching supplier part for this stock item')
|
||||
)
|
||||
|
||||
# Note: When a StockLocation is deleted, stock items are updated via a signal
|
||||
location = TreeForeignKey(
|
||||
StockLocation, on_delete=models.DO_NOTHING,
|
||||
verbose_name=_('Stock Location'),
|
||||
@ -492,10 +494,11 @@ class StockItem(MPTTModel):
|
||||
help_text=_('Packaging this stock item is stored in')
|
||||
)
|
||||
|
||||
# When deleting a stock item with installed items, those installed items are also installed
|
||||
belongs_to = models.ForeignKey(
|
||||
'self',
|
||||
verbose_name=_('Installed In'),
|
||||
on_delete=models.DO_NOTHING,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='installed_parts', blank=True, null=True,
|
||||
help_text=_('Is this item installed in another item?')
|
||||
)
|
||||
@ -800,14 +803,14 @@ class StockItem(MPTTModel):
|
||||
def can_delete(self):
|
||||
""" Can this stock item be deleted? It can NOT be deleted under the following circumstances:
|
||||
|
||||
- Has child StockItems
|
||||
- Has installed stock items
|
||||
- Has a serial number and is tracked
|
||||
- Is installed inside another StockItem
|
||||
- It has been assigned to a SalesOrder
|
||||
- It has been assigned to a BuildOrder
|
||||
"""
|
||||
|
||||
if self.child_count > 0:
|
||||
if self.installed_item_count() > 0:
|
||||
return False
|
||||
|
||||
if self.part.trackable and self.serial is not None:
|
||||
@ -853,20 +856,13 @@ class StockItem(MPTTModel):
|
||||
|
||||
return installed
|
||||
|
||||
def installedItemCount(self):
|
||||
def installed_item_count(self):
|
||||
"""
|
||||
Return the number of stock items installed inside this one.
|
||||
"""
|
||||
|
||||
return self.installed_parts.count()
|
||||
|
||||
def hasInstalledItems(self):
|
||||
"""
|
||||
Returns true if this stock item has other stock items installed in it.
|
||||
"""
|
||||
|
||||
return self.installedItemCount() > 0
|
||||
|
||||
@transaction.atomic
|
||||
def installStockItem(self, other_item, quantity, user, notes):
|
||||
"""
|
||||
@ -1153,6 +1149,124 @@ class StockItem(MPTTModel):
|
||||
result.stock_item = self
|
||||
result.save()
|
||||
|
||||
def can_merge(self, other=None, raise_error=False, **kwargs):
|
||||
"""
|
||||
Check if this stock item can be merged into another stock item
|
||||
"""
|
||||
|
||||
allow_mismatched_suppliers = kwargs.get('allow_mismatched_suppliers', False)
|
||||
|
||||
allow_mismatched_status = kwargs.get('allow_mismatched_status', False)
|
||||
|
||||
try:
|
||||
# Generic checks (do not rely on the 'other' part)
|
||||
if self.sales_order:
|
||||
raise ValidationError(_('Stock item has been assigned to a sales order'))
|
||||
|
||||
if self.belongs_to:
|
||||
raise ValidationError(_('Stock item is installed in another item'))
|
||||
|
||||
if self.installed_item_count() > 0:
|
||||
raise ValidationError(_('Stock item contains other items'))
|
||||
|
||||
if self.customer:
|
||||
raise ValidationError(_('Stock item has been assigned to a customer'))
|
||||
|
||||
if self.is_building:
|
||||
raise ValidationError(_('Stock item is currently in production'))
|
||||
|
||||
if self.serialized:
|
||||
raise ValidationError(_("Serialized stock cannot be merged"))
|
||||
|
||||
if other:
|
||||
# Specific checks (rely on the 'other' part)
|
||||
|
||||
# Prevent stock item being merged with itself
|
||||
if self == other:
|
||||
raise ValidationError(_('Duplicate stock items'))
|
||||
|
||||
# Base part must match
|
||||
if self.part != other.part:
|
||||
raise ValidationError(_("Stock items must refer to the same part"))
|
||||
|
||||
# Check if supplier part references match
|
||||
if self.supplier_part != other.supplier_part and not allow_mismatched_suppliers:
|
||||
raise ValidationError(_("Stock items must refer to the same supplier part"))
|
||||
|
||||
# Check if stock status codes match
|
||||
if self.status != other.status and not allow_mismatched_status:
|
||||
raise ValidationError(_("Stock status codes must match"))
|
||||
|
||||
except ValidationError as e:
|
||||
if raise_error:
|
||||
raise e
|
||||
else:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
@transaction.atomic
|
||||
def merge_stock_items(self, other_items, raise_error=False, **kwargs):
|
||||
"""
|
||||
Merge another stock item into this one; the two become one!
|
||||
|
||||
*This* stock item subsumes the other, which is essentially deleted:
|
||||
|
||||
- The quantity of this StockItem is increased
|
||||
- Tracking history for the *other* item is deleted
|
||||
- Any allocations (build order, sales order) are moved to this StockItem
|
||||
"""
|
||||
|
||||
if len(other_items) == 0:
|
||||
return
|
||||
|
||||
user = kwargs.get('user', None)
|
||||
location = kwargs.get('location', None)
|
||||
notes = kwargs.get('notes', None)
|
||||
|
||||
parent_id = self.parent.pk if self.parent else None
|
||||
|
||||
for other in other_items:
|
||||
# If the stock item cannot be merged, return
|
||||
if not self.can_merge(other, raise_error=raise_error, **kwargs):
|
||||
return
|
||||
|
||||
for other in other_items:
|
||||
|
||||
self.quantity += other.quantity
|
||||
|
||||
# Any "build order allocations" for the other item must be assigned to this one
|
||||
for allocation in other.allocations.all():
|
||||
|
||||
allocation.stock_item = self
|
||||
allocation.save()
|
||||
|
||||
# Any "sales order allocations" for the other item must be assigned to this one
|
||||
for allocation in other.sales_order_allocations.all():
|
||||
|
||||
allocation.stock_item = self()
|
||||
allocation.save()
|
||||
|
||||
# Prevent atomicity issues when we are merging our own "parent" part in
|
||||
if parent_id and parent_id == other.pk:
|
||||
self.parent = None
|
||||
self.save()
|
||||
|
||||
other.delete()
|
||||
|
||||
self.add_tracking_entry(
|
||||
StockHistoryCode.MERGED_STOCK_ITEMS,
|
||||
user,
|
||||
quantity=self.quantity,
|
||||
notes=notes,
|
||||
deltas={
|
||||
'location': location.pk,
|
||||
}
|
||||
)
|
||||
|
||||
self.location = location
|
||||
self.save()
|
||||
|
||||
@transaction.atomic
|
||||
def splitStock(self, quantity, location, user, **kwargs):
|
||||
""" Split this stock item into two items, in the same location.
|
||||
@ -1648,7 +1762,8 @@ class StockItem(MPTTModel):
|
||||
|
||||
@receiver(pre_delete, sender=StockItem, dispatch_uid='stock_item_pre_delete_log')
|
||||
def before_delete_stock_item(sender, instance, using, **kwargs):
|
||||
""" Receives pre_delete signal from StockItem object.
|
||||
"""
|
||||
Receives pre_delete signal from StockItem object.
|
||||
|
||||
Before a StockItem is deleted, ensure that each child object is updated,
|
||||
to point to the new parent item.
|
||||
|
@ -674,6 +674,149 @@ class StockAssignmentSerializer(serializers.Serializer):
|
||||
)
|
||||
|
||||
|
||||
class StockMergeItemSerializer(serializers.Serializer):
|
||||
"""
|
||||
Serializer for a single StockItem within the StockMergeSerializer class.
|
||||
|
||||
Here, the individual StockItem is being checked for merge compatibility.
|
||||
"""
|
||||
|
||||
class Meta:
|
||||
fields = [
|
||||
'item',
|
||||
]
|
||||
|
||||
item = serializers.PrimaryKeyRelatedField(
|
||||
queryset=StockItem.objects.all(),
|
||||
many=False,
|
||||
allow_null=False,
|
||||
required=True,
|
||||
label=_('Stock Item'),
|
||||
)
|
||||
|
||||
def validate_item(self, item):
|
||||
|
||||
# Check that the stock item is able to be merged
|
||||
item.can_merge(raise_error=True)
|
||||
|
||||
return item
|
||||
|
||||
|
||||
class StockMergeSerializer(serializers.Serializer):
|
||||
"""
|
||||
Serializer for merging two (or more) stock items together
|
||||
"""
|
||||
|
||||
class Meta:
|
||||
fields = [
|
||||
'items',
|
||||
'location',
|
||||
'notes',
|
||||
'allow_mismatched_suppliers',
|
||||
'allow_mismatched_status',
|
||||
]
|
||||
|
||||
items = StockMergeItemSerializer(
|
||||
many=True,
|
||||
required=True,
|
||||
)
|
||||
|
||||
location = serializers.PrimaryKeyRelatedField(
|
||||
queryset=StockLocation.objects.all(),
|
||||
many=False,
|
||||
required=True,
|
||||
allow_null=False,
|
||||
label=_('Location'),
|
||||
help_text=_('Destination stock location'),
|
||||
)
|
||||
|
||||
notes = serializers.CharField(
|
||||
required=False,
|
||||
allow_blank=True,
|
||||
label=_('Notes'),
|
||||
help_text=_('Stock merging notes'),
|
||||
)
|
||||
|
||||
allow_mismatched_suppliers = serializers.BooleanField(
|
||||
required=False,
|
||||
label=_('Allow mismatched suppliers'),
|
||||
help_text=_('Allow stock items with different supplier parts to be merged'),
|
||||
)
|
||||
|
||||
allow_mismatched_status = serializers.BooleanField(
|
||||
required=False,
|
||||
label=_('Allow mismatched status'),
|
||||
help_text=_('Allow stock items with different status codes to be merged'),
|
||||
)
|
||||
|
||||
def validate(self, data):
|
||||
|
||||
data = super().validate(data)
|
||||
|
||||
items = data['items']
|
||||
|
||||
if len(items) < 2:
|
||||
raise ValidationError(_('At least two stock items must be provided'))
|
||||
|
||||
unique_items = set()
|
||||
|
||||
# The "base item" is the first item
|
||||
base_item = items[0]['item']
|
||||
|
||||
data['base_item'] = base_item
|
||||
|
||||
# Ensure stock items are unique!
|
||||
for element in items:
|
||||
item = element['item']
|
||||
|
||||
if item.pk in unique_items:
|
||||
raise ValidationError(_('Duplicate stock items'))
|
||||
|
||||
unique_items.add(item.pk)
|
||||
|
||||
# Checks from here refer to the "base_item"
|
||||
if item == base_item:
|
||||
continue
|
||||
|
||||
# Check that this item can be merged with the base_item
|
||||
item.can_merge(
|
||||
raise_error=True,
|
||||
other=base_item,
|
||||
allow_mismatched_suppliers=data.get('allow_mismatched_suppliers', False),
|
||||
allow_mismatched_status=data.get('allow_mismatched_status', False),
|
||||
)
|
||||
|
||||
return data
|
||||
|
||||
def save(self):
|
||||
"""
|
||||
Actually perform the stock merging action.
|
||||
At this point we are confident that the merge can take place
|
||||
"""
|
||||
|
||||
data = self.validated_data
|
||||
|
||||
base_item = data['base_item']
|
||||
items = data['items'][1:]
|
||||
|
||||
request = self.context['request']
|
||||
user = getattr(request, 'user', None)
|
||||
|
||||
items = []
|
||||
|
||||
for item in data['items'][1:]:
|
||||
items.append(item['item'])
|
||||
|
||||
base_item.merge_stock_items(
|
||||
items,
|
||||
allow_mismatched_suppliers=data.get('allow_mismatched_suppliers', False),
|
||||
allow_mismatched_status=data.get('allow_mismatched_status', False),
|
||||
user=user,
|
||||
location=data['location'],
|
||||
notes=data.get('notes', None)
|
||||
)
|
||||
|
||||
|
||||
class StockAdjustmentItemSerializer(serializers.Serializer):
|
||||
"""
|
||||
Serializer for a single StockItem within a stock adjument request.
|
||||
@ -837,7 +980,7 @@ class StockTransferSerializer(StockAdjustmentSerializer):
|
||||
|
||||
def validate(self, data):
|
||||
|
||||
super().validate(data)
|
||||
data = super().validate(data)
|
||||
|
||||
# TODO: Any specific validation of location field?
|
||||
|
||||
|
@ -274,14 +274,6 @@
|
||||
<div class='alert alert-block alert-warning'>
|
||||
{% trans "This stock item is serialized - it has a unique serial number and the quantity cannot be adjusted." %}
|
||||
</div>
|
||||
{% elif item.child_count > 0 %}
|
||||
<div class='alert alert-block alert-warning'>
|
||||
{% trans "This stock item cannot be deleted as it has child items" %}
|
||||
</div>
|
||||
{% elif item.delete_on_deplete and item.can_delete %}
|
||||
<div class='alert alert-block alert-warning'>
|
||||
{% trans "This stock item will be automatically deleted when all stock is depleted." %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
|
@ -842,3 +842,189 @@ class StockAssignTest(StockAPITestCase):
|
||||
|
||||
# 5 stock items should now have been assigned to this customer
|
||||
self.assertEqual(customer.assigned_stock.count(), 5)
|
||||
|
||||
|
||||
class StockMergeTest(StockAPITestCase):
|
||||
"""
|
||||
Unit tests for merging stock items via the API
|
||||
"""
|
||||
|
||||
URL = reverse('api-stock-merge')
|
||||
|
||||
def setUp(self):
|
||||
|
||||
super().setUp()
|
||||
|
||||
self.part = part.models.Part.objects.get(pk=25)
|
||||
self.loc = StockLocation.objects.get(pk=1)
|
||||
self.sp_1 = company.models.SupplierPart.objects.get(pk=100)
|
||||
self.sp_2 = company.models.SupplierPart.objects.get(pk=101)
|
||||
|
||||
self.item_1 = StockItem.objects.create(
|
||||
part=self.part,
|
||||
supplier_part=self.sp_1,
|
||||
quantity=100,
|
||||
)
|
||||
|
||||
self.item_2 = StockItem.objects.create(
|
||||
part=self.part,
|
||||
supplier_part=self.sp_2,
|
||||
quantity=100,
|
||||
)
|
||||
|
||||
self.item_3 = StockItem.objects.create(
|
||||
part=self.part,
|
||||
supplier_part=self.sp_2,
|
||||
quantity=50,
|
||||
)
|
||||
|
||||
def test_missing_data(self):
|
||||
"""
|
||||
Test responses which are missing required data
|
||||
"""
|
||||
|
||||
# Post completely empty
|
||||
|
||||
data = self.post(
|
||||
self.URL,
|
||||
{},
|
||||
expected_code=400
|
||||
).data
|
||||
|
||||
self.assertIn('This field is required', str(data['items']))
|
||||
self.assertIn('This field is required', str(data['location']))
|
||||
|
||||
# Post with a location and empty items list
|
||||
data = self.post(
|
||||
self.URL,
|
||||
{
|
||||
'items': [],
|
||||
'location': 1,
|
||||
},
|
||||
expected_code=400
|
||||
).data
|
||||
|
||||
self.assertIn('At least two stock items', str(data))
|
||||
|
||||
def test_invalid_data(self):
|
||||
"""
|
||||
Test responses which have invalid data
|
||||
"""
|
||||
|
||||
# Serialized stock items should be rejected
|
||||
data = self.post(
|
||||
self.URL,
|
||||
{
|
||||
'items': [
|
||||
{
|
||||
'item': 501,
|
||||
},
|
||||
{
|
||||
'item': 502,
|
||||
}
|
||||
],
|
||||
'location': 1,
|
||||
},
|
||||
expected_code=400,
|
||||
).data
|
||||
|
||||
self.assertIn('Serialized stock cannot be merged', str(data))
|
||||
|
||||
# Prevent item duplication
|
||||
|
||||
data = self.post(
|
||||
self.URL,
|
||||
{
|
||||
'items': [
|
||||
{
|
||||
'item': 11,
|
||||
},
|
||||
{
|
||||
'item': 11,
|
||||
}
|
||||
],
|
||||
'location': 1,
|
||||
},
|
||||
expected_code=400,
|
||||
).data
|
||||
|
||||
self.assertIn('Duplicate stock items', str(data))
|
||||
|
||||
# Check for mismatching stock items
|
||||
data = self.post(
|
||||
self.URL,
|
||||
{
|
||||
'items': [
|
||||
{
|
||||
'item': 1234,
|
||||
},
|
||||
{
|
||||
'item': 11,
|
||||
}
|
||||
],
|
||||
'location': 1,
|
||||
},
|
||||
expected_code=400,
|
||||
).data
|
||||
|
||||
self.assertIn('Stock items must refer to the same part', str(data))
|
||||
|
||||
# Check for mismatching supplier parts
|
||||
payload = {
|
||||
'items': [
|
||||
{
|
||||
'item': self.item_1.pk,
|
||||
},
|
||||
{
|
||||
'item': self.item_2.pk,
|
||||
},
|
||||
],
|
||||
'location': 1,
|
||||
}
|
||||
|
||||
data = self.post(
|
||||
self.URL,
|
||||
payload,
|
||||
expected_code=400,
|
||||
).data
|
||||
|
||||
self.assertIn('Stock items must refer to the same supplier part', str(data))
|
||||
|
||||
def test_valid_merge(self):
|
||||
"""
|
||||
Test valid merging of stock items
|
||||
"""
|
||||
|
||||
# Check initial conditions
|
||||
n = StockItem.objects.filter(part=self.part).count()
|
||||
self.assertEqual(self.item_1.quantity, 100)
|
||||
|
||||
payload = {
|
||||
'items': [
|
||||
{
|
||||
'item': self.item_1.pk,
|
||||
},
|
||||
{
|
||||
'item': self.item_2.pk,
|
||||
},
|
||||
{
|
||||
'item': self.item_3.pk,
|
||||
},
|
||||
],
|
||||
'location': 1,
|
||||
'allow_mismatched_suppliers': True,
|
||||
}
|
||||
|
||||
self.post(
|
||||
self.URL,
|
||||
payload,
|
||||
expected_code=201,
|
||||
)
|
||||
|
||||
self.item_1.refresh_from_db()
|
||||
|
||||
# Stock quantity should have been increased!
|
||||
self.assertEqual(self.item_1.quantity, 250)
|
||||
|
||||
# Total number of stock items has been reduced!
|
||||
self.assertEqual(StockItem.objects.filter(part=self.part).count(), n - 2)
|
||||
|
@ -16,12 +16,6 @@
|
||||
<div class='panel panel-inventree'>
|
||||
<div class='panel-content'>
|
||||
{% include "search_form.html" with query_text=query %}
|
||||
{% if query %}
|
||||
{% else %}
|
||||
<div id='empty-search-query'>
|
||||
<h4><em>{% trans "Enter a search query" %}</em></h4>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
@ -2,7 +2,7 @@
|
||||
|
||||
<div id='attachment-buttons'>
|
||||
<div class='btn-group' role='group'>
|
||||
{% include "filter_list.html" with id="related" %}
|
||||
{% include "filter_list.html" with id="attachments" %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
@ -207,6 +207,11 @@ function showApiError(xhr, url) {
|
||||
title = '{% trans "Error 404: Resource Not Found" %}';
|
||||
message = '{% trans "The requested resource could not be located on the server" %}';
|
||||
break;
|
||||
// Method not allowed
|
||||
case 405:
|
||||
title = '{% trans "Error 405: Method Not Allowed" %}';
|
||||
message = '{% trans "HTTP method not allowed at URL" %}';
|
||||
break;
|
||||
// Timeout
|
||||
case 408:
|
||||
title = '{% trans "Error 408: Timeout" %}';
|
||||
|
@ -67,6 +67,8 @@ function loadAttachmentTable(url, options) {
|
||||
|
||||
var table = options.table || '#attachment-table';
|
||||
|
||||
setupFilterList('attachments', $(table), '#filter-list-attachments');
|
||||
|
||||
addAttachmentButtonCallbacks(url, options.fields || {});
|
||||
|
||||
$(table).inventreeTable({
|
||||
|
@ -661,7 +661,7 @@ function loadBomTable(table, options={}) {
|
||||
if (!row.inherited) {
|
||||
return yesNoLabel(false);
|
||||
} else if (row.part == options.parent_id) {
|
||||
return '{% trans "Inherited" %}';
|
||||
return yesNoLabel(true);
|
||||
} else {
|
||||
// If this BOM item is inherited from a parent part
|
||||
return renderLink(
|
||||
|
@ -380,6 +380,7 @@ function loadCompanyTable(table, url, options={}) {
|
||||
url: url,
|
||||
method: 'get',
|
||||
queryParams: filters,
|
||||
original: params,
|
||||
groupBy: false,
|
||||
sidePagination: 'server',
|
||||
formatNoMatches: function() {
|
||||
@ -463,7 +464,9 @@ function loadManufacturerPartTable(table, url, options) {
|
||||
filters[key] = params[key];
|
||||
}
|
||||
|
||||
setupFilterList('manufacturer-part', $(table));
|
||||
var filterTarget = options.filterTarget || '#filter-list-manufacturer-part';
|
||||
|
||||
setupFilterList('manufacturer-part', $(table), filterTarget);
|
||||
|
||||
$(table).inventreeTable({
|
||||
url: url,
|
||||
|
@ -21,6 +21,7 @@
|
||||
*/
|
||||
|
||||
/* exported
|
||||
duplicateBom,
|
||||
duplicatePart,
|
||||
editCategory,
|
||||
editPart,
|
||||
@ -39,6 +40,7 @@
|
||||
loadStockPricingChart,
|
||||
partStockLabel,
|
||||
toggleStar,
|
||||
validateBom,
|
||||
*/
|
||||
|
||||
/* Part API functions
|
||||
@ -428,6 +430,59 @@ function toggleStar(options) {
|
||||
}
|
||||
|
||||
|
||||
/* Validate a BOM */
|
||||
function validateBom(part_id, options={}) {
|
||||
|
||||
var html = `
|
||||
<div class='alert alert-block alert-success'>
|
||||
{% trans "Validating the BOM will mark each line item as valid" %}
|
||||
</div>
|
||||
`;
|
||||
|
||||
constructForm(`/api/part/${part_id}/bom-validate/`, {
|
||||
method: 'PUT',
|
||||
fields: {
|
||||
valid: {},
|
||||
},
|
||||
preFormContent: html,
|
||||
title: '{% trans "Validate Bill of Materials" %}',
|
||||
reload: options.reload,
|
||||
onSuccess: function(response) {
|
||||
showMessage('{% trans "Validated Bill of Materials" %}');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
/* Duplicate a BOM */
|
||||
function duplicateBom(part_id, options={}) {
|
||||
|
||||
constructForm(`/api/part/${part_id}/bom-copy/`, {
|
||||
method: 'POST',
|
||||
fields: {
|
||||
part: {
|
||||
icon: 'fa-shapes',
|
||||
filters: {
|
||||
assembly: true,
|
||||
exclude_tree: part_id,
|
||||
}
|
||||
},
|
||||
include_inherited: {},
|
||||
remove_existing: {},
|
||||
skip_invalid: {},
|
||||
},
|
||||
confirm: true,
|
||||
title: '{% trans "Copy Bill of Materials" %}',
|
||||
onSuccess: function(response) {
|
||||
if (options.success) {
|
||||
options.success(response);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
|
||||
function partStockLabel(part, options={}) {
|
||||
|
||||
if (part.in_stock) {
|
||||
@ -621,7 +676,9 @@ function loadPartParameterTable(table, url, options) {
|
||||
filters[key] = params[key];
|
||||
}
|
||||
|
||||
// setupFilterList("#part-parameters", $(table));
|
||||
var filterTarget = options.filterTarget || '#filter-list-parameters';
|
||||
|
||||
setupFilterList('part-parameters', $(table), filterTarget);
|
||||
|
||||
$(table).inventreeTable({
|
||||
url: url,
|
||||
@ -727,7 +784,7 @@ function loadPartPurchaseOrderTable(table, part_id, options={}) {
|
||||
options.params.part_detail = true;
|
||||
options.params.order_detail = true;
|
||||
|
||||
var filters = loadTableFilters('partpurchaseorders');
|
||||
var filters = loadTableFilters('purchaseorderlineitem');
|
||||
|
||||
for (var key in options.params) {
|
||||
filters[key] = options.params[key];
|
||||
@ -871,7 +928,7 @@ function loadPartPurchaseOrderTable(table, part_id, options={}) {
|
||||
if (row.received >= row.quantity) {
|
||||
// Already recevied
|
||||
return `<span class='badge bg-success rounded-pill'>{% trans "Received" %}</span>`;
|
||||
} else {
|
||||
} else if (row.order_detail && row.order_detail.status == {{ PurchaseOrderStatus.PLACED }}) {
|
||||
var html = `<div class='btn-group' role='group'>`;
|
||||
var pk = row.pk;
|
||||
|
||||
@ -879,6 +936,8 @@ function loadPartPurchaseOrderTable(table, part_id, options={}) {
|
||||
|
||||
html += `</div>`;
|
||||
return html;
|
||||
} else {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -52,6 +52,7 @@
|
||||
loadStockTestResultsTable,
|
||||
loadStockTrackingTable,
|
||||
loadTableFilters,
|
||||
mergeStockItems,
|
||||
removeStockRow,
|
||||
serializeStockItem,
|
||||
stockItemFields,
|
||||
@ -595,7 +596,7 @@ function assignStockToCustomer(items, options={}) {
|
||||
buttons += '</div>';
|
||||
|
||||
html += `
|
||||
<tr id='stock_item_${pk}' class='stock-item'row'>
|
||||
<tr id='stock_item_${pk}' class='stock-item-row'>
|
||||
<td id='part_${pk}'>${thumbnail} ${part.full_name}</td>
|
||||
<td id='stock_${pk}'>
|
||||
<div id='div_id_items_item_${pk}'>
|
||||
@ -615,13 +616,13 @@ function assignStockToCustomer(items, options={}) {
|
||||
method: 'POST',
|
||||
preFormContent: html,
|
||||
fields: {
|
||||
'customer': {
|
||||
customer: {
|
||||
value: options.customer,
|
||||
filters: {
|
||||
is_customer: true,
|
||||
},
|
||||
},
|
||||
'notes': {},
|
||||
notes: {},
|
||||
},
|
||||
confirm: true,
|
||||
confirmMessage: '{% trans "Confirm stock assignment" %}',
|
||||
@ -694,6 +695,184 @@ function assignStockToCustomer(items, options={}) {
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Merge multiple stock items together
|
||||
*/
|
||||
function mergeStockItems(items, options={}) {
|
||||
|
||||
// Generate HTML content for the form
|
||||
var html = `
|
||||
<div class='alert alert-block alert-danger'>
|
||||
<h5>{% trans "Warning: Merge operation cannot be reversed" %}</h5>
|
||||
<strong>{% trans "Some information will be lost when merging stock items" %}:</strong>
|
||||
<ul>
|
||||
<li>{% trans "Stock transaction history will be deleted for merged items" %}</li>
|
||||
<li>{% trans "Supplier part information will be deleted for merged items" %}</li>
|
||||
</ul>
|
||||
</div>
|
||||
`;
|
||||
|
||||
html += `
|
||||
<table class='table table-striped table-condensed' id='stock-merge-table'>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{% trans "Part" %}</th>
|
||||
<th>{% trans "Stock Item" %}</th>
|
||||
<th>{% trans "Location" %}</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
`;
|
||||
|
||||
// Keep track of how many "locations" there are
|
||||
var locations = [];
|
||||
|
||||
for (var idx = 0; idx < items.length; idx++) {
|
||||
var item = items[idx];
|
||||
|
||||
var pk = item.pk;
|
||||
|
||||
if (item.location && !locations.includes(item.location)) {
|
||||
locations.push(item.location);
|
||||
}
|
||||
|
||||
var part = item.part_detail;
|
||||
var location = locationDetail(item, false);
|
||||
|
||||
var thumbnail = thumbnailImage(part.thumbnail || part.image);
|
||||
|
||||
var quantity = '';
|
||||
|
||||
if (item.serial && item.quantity == 1) {
|
||||
quantity = `{% trans "Serial" %}: ${item.serial}`;
|
||||
} else {
|
||||
quantity = `{% trans "Quantity" %}: ${item.quantity}`;
|
||||
}
|
||||
|
||||
quantity += stockStatusDisplay(item.status, {classes: 'float-right'});
|
||||
|
||||
var buttons = `<div class='btn-group' role='group'>`;
|
||||
|
||||
buttons += makeIconButton(
|
||||
'fa-times icon-red',
|
||||
'button-stock-item-remove',
|
||||
pk,
|
||||
'{% trans "Remove row" %}',
|
||||
);
|
||||
|
||||
html += `
|
||||
<tr id='stock_item_${pk}' class='stock-item-row'>
|
||||
<td id='part_${pk}'>${thumbnail} ${part.full_name}</td>
|
||||
<td id='stock_${pk}'>
|
||||
<div id='div_id_items_item_${pk}'>
|
||||
${quantity}
|
||||
<div id='errors-items_item_${pk}'></div>
|
||||
</div>
|
||||
</td>
|
||||
<td id='location_${pk}'>${location}</td>
|
||||
<td id='buttons_${pk}'>${buttons}</td>
|
||||
</tr>
|
||||
`;
|
||||
}
|
||||
|
||||
html += '</tbody></table>';
|
||||
|
||||
var location = locations.length == 1 ? locations[0] : null;
|
||||
|
||||
constructForm('{% url "api-stock-merge" %}', {
|
||||
method: 'POST',
|
||||
preFormContent: html,
|
||||
fields: {
|
||||
location: {
|
||||
value: location,
|
||||
icon: 'fa-sitemap',
|
||||
},
|
||||
notes: {},
|
||||
allow_mismatched_suppliers: {},
|
||||
allow_mismatched_status: {},
|
||||
},
|
||||
confirm: true,
|
||||
confirmMessage: '{% trans "Confirm stock item merge" %}',
|
||||
title: '{% trans "Merge Stock Items" %}',
|
||||
afterRender: function(fields, opts) {
|
||||
// Add button callbacks to remove rows
|
||||
$(opts.modal).find('.button-stock-item-remove').click(function() {
|
||||
var pk = $(this).attr('pk');
|
||||
|
||||
$(opts.modal).find(`#stock_item_${pk}`).remove();
|
||||
});
|
||||
},
|
||||
onSubmit: function(fields, opts) {
|
||||
|
||||
// Extract data elements from the form
|
||||
var data = {
|
||||
items: [],
|
||||
};
|
||||
|
||||
var item_pk_values = [];
|
||||
|
||||
items.forEach(function(item) {
|
||||
var pk = item.pk;
|
||||
|
||||
// Does the row still exist in the form?
|
||||
var row = $(opts.modal).find(`#stock_item_${pk}`);
|
||||
|
||||
if (row.exists()) {
|
||||
item_pk_values.push(pk);
|
||||
|
||||
data.items.push({
|
||||
item: pk,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
var extra_fields = [
|
||||
'location',
|
||||
'notes',
|
||||
'allow_mismatched_suppliers',
|
||||
'allow_mismatched_status',
|
||||
];
|
||||
|
||||
extra_fields.forEach(function(field) {
|
||||
data[field] = getFormFieldValue(field, fields[field], opts);
|
||||
});
|
||||
|
||||
opts.nested = {
|
||||
'items': item_pk_values
|
||||
};
|
||||
|
||||
// Submit the form data
|
||||
inventreePut(
|
||||
'{% url "api-stock-merge" %}',
|
||||
data,
|
||||
{
|
||||
method: 'POST',
|
||||
success: function(response) {
|
||||
$(opts.modal).modal('hide');
|
||||
|
||||
if (options.success) {
|
||||
options.success(response);
|
||||
}
|
||||
},
|
||||
error: function(xhr) {
|
||||
switch (xhr.status) {
|
||||
case 400:
|
||||
handleFormErrors(xhr.responseJSON, fields, opts);
|
||||
break;
|
||||
default:
|
||||
$(opts.modal).modal('hide');
|
||||
showApiError(xhr, opts.url);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Perform stock adjustments
|
||||
*/
|
||||
@ -1289,7 +1468,7 @@ function loadStockTable(table, options) {
|
||||
|
||||
var params = options.params || {};
|
||||
|
||||
var filterListElement = options.filterList || '#filter-list-stock';
|
||||
var filterTarget = options.filterTarget || '#filter-list-stock';
|
||||
|
||||
var filters = {};
|
||||
|
||||
@ -1305,7 +1484,7 @@ function loadStockTable(table, options) {
|
||||
original[k] = params[k];
|
||||
}
|
||||
|
||||
setupFilterList(filterKey, table, filterListElement);
|
||||
setupFilterList(filterKey, table, filterTarget);
|
||||
|
||||
// Override the default values, or add new ones
|
||||
for (var key in params) {
|
||||
@ -1458,7 +1637,7 @@ function loadStockTable(table, options) {
|
||||
}
|
||||
|
||||
if (row.quantity <= 0) {
|
||||
html += `<span class='badge rounded-pill bg-danger'>{% trans "Depleted" %}</span>`;
|
||||
html += `<span class='badge badge-right rounded-pill bg-danger'>{% trans "Depleted" %}</span>`;
|
||||
}
|
||||
|
||||
return html;
|
||||
@ -1875,6 +2054,20 @@ function loadStockTable(table, options) {
|
||||
stockAdjustment('move');
|
||||
});
|
||||
|
||||
$('#multi-item-merge').click(function() {
|
||||
var items = $(table).bootstrapTable('getSelections');
|
||||
|
||||
mergeStockItems(items, {
|
||||
success: function(response) {
|
||||
$(table).bootstrapTable('refresh');
|
||||
|
||||
showMessage('{% trans "Merged stock items" %}', {
|
||||
style: 'success',
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
$('#multi-item-assign').click(function() {
|
||||
|
||||
var items = $(table).bootstrapTable('getSelections');
|
||||
|
@ -381,6 +381,24 @@ function getAvailableTableFilters(tableKey) {
|
||||
};
|
||||
}
|
||||
|
||||
// Filters for "company" table
|
||||
if (tableKey == 'company') {
|
||||
return {
|
||||
is_manufacturer: {
|
||||
type: 'bool',
|
||||
title: '{% trans "Manufacturer" %}',
|
||||
},
|
||||
is_supplier: {
|
||||
type: 'bool',
|
||||
title: '{% trans "Supplier" %}',
|
||||
},
|
||||
is_customer: {
|
||||
type: 'bool',
|
||||
title: '{% trans "Customer" %}',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Filters for the "Parts" table
|
||||
if (tableKey == 'parts') {
|
||||
return {
|
||||
|
@ -49,6 +49,7 @@
|
||||
<li><a class='dropdown-item' href="#" id='multi-item-remove' title='{% trans "Remove from selected stock items" %}'><span class='fas fa-minus-circle'></span> {% trans "Remove stock" %}</a></li>
|
||||
<li><a class='dropdown-item' href="#" id='multi-item-stocktake' title='{% trans "Stocktake selected stock items" %}'><span class='fas fa-check-circle'></span> {% trans "Count stock" %}</a></li>
|
||||
<li><a class='dropdown-item' href='#' id='multi-item-move' title='{% trans "Move selected stock items" %}'><span class='fas fa-exchange-alt'></span> {% trans "Move stock" %}</a></li>
|
||||
<li><a class='dropdown-item' href='#' id='multi-item-merge' title='{% trans "Merge selected stock items" %}'><span class='fas fa-object-group'></span> {% trans "Merge stock" %}</a></li>
|
||||
<li><a class='dropdown-item' href='#' id='multi-item-order' title='{% trans "Order selected items" %}'><span class='fas fa-shopping-cart'></span> {% trans "Order stock" %}</a></li>
|
||||
<li><a class='dropdown-item' href='#' id='multi-item-assign' title='{% trans "Assign to customer" %}'><span class='fas fa-user-tie'></span> {% trans "Assign to customer" %}</a></li>
|
||||
<li><a class='dropdown-item' href='#' id='multi-item-set-status' title='{% trans "Change status" %}'><span class='fas fa-exclamation-circle'></span> {% trans "Change stock status" %}</a></li>
|
||||
|
@ -12,7 +12,7 @@ InvenTree is an open-source Inventory Management System which provides powerful
|
||||
|
||||
InvenTree is designed to be lightweight and easy to use for SME or hobbyist applications, where many existing stock management solutions are bloated and cumbersome to use. Updating stock is a single-action process and does not require a complex system of work orders or stock transactions.
|
||||
|
||||
However, powerful business logic works in the background to ensure that stock tracking history is maintained, and users have ready access to stock level information.
|
||||
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
|
||||
|
||||
|
@ -101,7 +101,7 @@ RUN chown -R inventree:inventreegroup ${INVENTREE_HOME}/*
|
||||
USER inventree
|
||||
|
||||
# Install InvenTree packages
|
||||
RUN pip3 install --no-cache-dir --disable-pip-version-check -r ${INVENTREE_HOME}/requirements.txt
|
||||
RUN pip3 install --user --no-cache-dir --disable-pip-version-check -r ${INVENTREE_HOME}/requirements.txt
|
||||
|
||||
# Need to be running from within this directory
|
||||
WORKDIR ${INVENTREE_MNG_DIR}
|
||||
|
@ -2,14 +2,13 @@
|
||||
|
||||
# Set DEBUG to False for a production environment!
|
||||
INVENTREE_DEBUG=True
|
||||
|
||||
# Change verbosity level for debug output
|
||||
INVENTREE_DEBUG_LEVEL=INFO
|
||||
|
||||
# Database linking options
|
||||
INVENTREE_DB_ENGINE=sqlite3
|
||||
INVENTREE_DB_NAME=/home/inventree/dev/inventree_db.sqlite3
|
||||
# INVENTREE_DB_HOST=hostaddress
|
||||
# INVENTREE_DB_PORT=5432
|
||||
# INVENTREE_DB_USERNAME=dbuser
|
||||
# INVENTREE_DB_PASSWEORD=dbpassword
|
||||
# Database configuration options
|
||||
# Note: The example setup is for a PostgreSQL database (change as required)
|
||||
INVENTREE_DB_ENGINE=postgresql
|
||||
INVENTREE_DB_NAME=inventree
|
||||
INVENTREE_DB_HOST=inventree-dev-db
|
||||
INVENTREE_DB_PORT=5432
|
||||
INVENTREE_DB_USER=pguser
|
||||
INVENTREE_DB_PASSWORD=pgpassword
|
||||
|
@ -1,8 +1,10 @@
|
||||
version: "3.8"
|
||||
|
||||
# Docker compose recipe for InvenTree development server
|
||||
# - Runs sqlite3 as the database backend
|
||||
# - Runs PostgreSQL as the database backend
|
||||
# - Uses built-in django webserver
|
||||
# - Runs the InvenTree background worker process
|
||||
# - Serves media and static content directly from Django webserver
|
||||
|
||||
# IMPORANT NOTE:
|
||||
# The InvenTree docker image does not clone source code from git.
|
||||
@ -11,10 +13,32 @@ version: "3.8"
|
||||
# The django server will auto-detect any code changes and reload the server.
|
||||
|
||||
services:
|
||||
|
||||
# Database service
|
||||
# Use PostgreSQL as the database backend
|
||||
# Note: This can be changed to a different backend if required
|
||||
inventree-dev-db:
|
||||
container_name: inventree-dev-db
|
||||
image: postgres:13
|
||||
ports:
|
||||
- 5432/tcp
|
||||
environment:
|
||||
- PGDATA=/var/lib/postgresql/data/dev/pgdb
|
||||
# The pguser and pgpassword values must be the same in the other containers
|
||||
# Ensure that these are correctly configured in your dev-config.env file
|
||||
- POSTGRES_USER=pguser
|
||||
- POSTGRES_PASSWORD=pgpassword
|
||||
volumes:
|
||||
# Map 'data' volume such that postgres database is stored externally
|
||||
- src:/var/lib/postgresql/data
|
||||
restart: unless-stopped
|
||||
|
||||
# InvenTree web server services
|
||||
# Uses gunicorn as the web server
|
||||
inventree-dev-server:
|
||||
container_name: inventree-dev-server
|
||||
depends_on:
|
||||
- inventree-dev-db
|
||||
build:
|
||||
context: .
|
||||
target: dev
|
||||
|
62
docker/docker-compose.sqlite.yml
Normal file
62
docker/docker-compose.sqlite.yml
Normal file
@ -0,0 +1,62 @@
|
||||
version: "3.8"
|
||||
|
||||
# Docker compose recipe for InvenTree development server
|
||||
# - Runs sqlite database
|
||||
# - Uses built-in django webserver
|
||||
# - Runs the InvenTree background worker process
|
||||
# - Serves media and static content directly from Django webserver
|
||||
|
||||
# IMPORANT NOTE:
|
||||
# The InvenTree docker image does not clone source code from git.
|
||||
# Instead, you must specify *where* the source code is located,
|
||||
# (on your local machine).
|
||||
# The django server will auto-detect any code changes and reload the server.
|
||||
|
||||
services:
|
||||
|
||||
# InvenTree web server services
|
||||
# Uses gunicorn as the web server
|
||||
inventree-dev-server:
|
||||
container_name: inventree-dev-server
|
||||
build:
|
||||
context: .
|
||||
target: dev
|
||||
ports:
|
||||
# Expose web server on port 8000
|
||||
- 8000:8000
|
||||
volumes:
|
||||
# Ensure you specify the location of the 'src' directory at the end of this file
|
||||
- src:/home/inventree
|
||||
env_file:
|
||||
# Environment variables required for the dev server are configured in dev-config.env
|
||||
- sqlite-config.env
|
||||
restart: unless-stopped
|
||||
|
||||
# Background worker process handles long-running or periodic tasks
|
||||
inventree-dev-worker:
|
||||
container_name: inventree-dev-worker
|
||||
build:
|
||||
context: .
|
||||
target: dev
|
||||
command: invoke worker
|
||||
depends_on:
|
||||
- inventree-dev-server
|
||||
volumes:
|
||||
# Ensure you specify the location of the 'src' directory at the end of this file
|
||||
- src:/home/inventree
|
||||
env_file:
|
||||
# Environment variables required for the dev server are configured in dev-config.env
|
||||
- sqlite-config.env
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
# NOTE: Change "../" to a directory on your local machine, where the InvenTree source code is located
|
||||
# Persistent data, stored external to the container(s)
|
||||
src:
|
||||
driver: local
|
||||
driver_opts:
|
||||
type: none
|
||||
o: bind
|
||||
# This directory specified where InvenTree source code is stored "outside" the docker containers
|
||||
# By default, this directory is one level above the "docker" directory
|
||||
device: ../
|
@ -27,7 +27,7 @@ fi
|
||||
if [[ -n "$INVENTREE_PY_ENV" ]]; then
|
||||
echo "Using Python virtual environment: ${INVENTREE_PY_ENV}"
|
||||
# Setup a virtual environment (within the "dev" directory)
|
||||
python3 -m venv ${INVENTREE_PY_ENV}
|
||||
python3 -m venv ${INVENTREE_PY_ENV} --system-site-packages
|
||||
|
||||
# Activate the virtual environment
|
||||
source ${INVENTREE_PY_ENV}/bin/activate
|
||||
|
@ -6,7 +6,7 @@
|
||||
INVENTREE_DEBUG=False
|
||||
INVENTREE_LOG_LEVEL=WARNING
|
||||
|
||||
# Database configuration
|
||||
# Database configuration options
|
||||
# Note: The example setup is for a PostgreSQL database (change as required)
|
||||
INVENTREE_DB_ENGINE=postgresql
|
||||
INVENTREE_DB_NAME=inventree
|
||||
|
@ -4,7 +4,6 @@
|
||||
setuptools>=57.4.0
|
||||
wheel>=0.37.0
|
||||
invoke>=1.4.0 # Invoke build tool
|
||||
gunicorn>=20.1.0 # Gunicorn web server
|
||||
|
||||
# Database links
|
||||
psycopg2>=2.9.1
|
||||
@ -12,5 +11,5 @@ mysqlclient>=2.0.3
|
||||
pgcli>=3.1.0
|
||||
mariadb>=1.0.7
|
||||
|
||||
# Cache
|
||||
django-redis>=5.0.0
|
||||
# gunicorn web server
|
||||
gunicorn>=20.1.0
|
||||
|
10
docker/sqlite-config.env
Normal file
10
docker/sqlite-config.env
Normal file
@ -0,0 +1,10 @@
|
||||
# InvenTree environment variables for a development setup
|
||||
|
||||
# Set DEBUG to False for a production environment!
|
||||
INVENTREE_DEBUG=True
|
||||
INVENTREE_DEBUG_LEVEL=INFO
|
||||
|
||||
# Database configuration options
|
||||
# Note: The example setup is for a PostgreSQL database (change as required)
|
||||
INVENTREE_DB_ENGINE=sqlite
|
||||
INVENTREE_DB_NAME=/home/inventree/dev/inventree_db.sqlite3
|
@ -21,6 +21,7 @@ django-markdownify==0.8.0 # Markdown rendering
|
||||
django-markdownx==3.0.1 # Markdown form fields
|
||||
django-money==1.1 # Django app for currency management
|
||||
django-mptt==0.11.0 # Modified Preorder Tree Traversal
|
||||
django-redis>=5.0.0
|
||||
django-q==1.3.4 # Background task scheduling
|
||||
django-sql-utils==0.5.0 # Advanced query annotation / aggregation
|
||||
django-stdimage==5.1.1 # Advanced ImageField management
|
||||
|
Loading…
Reference in New Issue
Block a user