mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +00:00
Merge remote-tracking branch 'inventree/master'
This commit is contained in:
commit
28d9e320fd
@ -6,7 +6,7 @@ No pushing to master! New featues must be submitted in a separate branch (one br
|
||||
|
||||
## Include Migration Files
|
||||
|
||||
Any required migration files **must** be included in the commit, or the pull-request will be rejected. If you change the underlying database schema, make sure you run `make migrate` and commit the migration files before submitting the PR.
|
||||
Any required migration files **must** be included in the commit, or the pull-request will be rejected. If you change the underlying database schema, make sure you run `invoke migrate` and commit the migration files before submitting the PR.
|
||||
|
||||
## Update Translation Files
|
||||
|
||||
@ -14,7 +14,7 @@ Any PRs which update translatable strings (i.e. text strings that will appear in
|
||||
|
||||
*This does not mean that all translations must be provided, but that the translation files must include locations for the translated strings to be written.*
|
||||
|
||||
To perform this step, simply run `make_translate` from the top level directory before submitting the PR.
|
||||
To perform this step, simply run `invoke translate` from the top level directory before submitting the PR.
|
||||
|
||||
## Testing
|
||||
|
||||
@ -22,9 +22,8 @@ Any new code should be covered by unit tests - a submitted PR may not be accepte
|
||||
|
||||
## Documentation
|
||||
|
||||
New features or updates to existing features should be accompanied by user documentation.
|
||||
A PR with associated documentation should link to the matching PR at https://github.com/inventree/InvenTree.github.io
|
||||
New features or updates to existing features should be accompanied by user documentation. A PR with associated documentation should link to the matching PR at https://github.com/inventree/inventree-docs/
|
||||
|
||||
## Code Style
|
||||
|
||||
Sumbitted Python code is automatically checked against PEP style guidelines. Locally you can run `make style` to ensure the style checks will pass, before submitting the PR.
|
||||
Sumbitted Python code is automatically checked against PEP style guidelines. Locally you can run `invoke style` to ensure the style checks will pass, before submitting the PR.
|
||||
|
@ -157,6 +157,11 @@ $.fn.inventreeTable = function(options) {
|
||||
console.log('Could not get list of visible columns!');
|
||||
}
|
||||
}
|
||||
|
||||
// Optionally, link buttons to the table selection
|
||||
if (options.buttons) {
|
||||
linkButtonsToSelection(table, options.buttons);
|
||||
}
|
||||
}
|
||||
|
||||
function customGroupSorter(sortName, sortOrder, sortData) {
|
||||
|
@ -19,7 +19,7 @@
|
||||
loadStockTable($("#stock-table"), {
|
||||
params: {
|
||||
location_detail: true,
|
||||
part_details: true,
|
||||
part_detail: true,
|
||||
build: {{ build.id }},
|
||||
},
|
||||
groupByField: 'location',
|
||||
|
Binary file not shown.
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
@ -1273,7 +1273,7 @@ class POLineItemEdit(AjaxUpdateView):
|
||||
form = super().get_form()
|
||||
|
||||
# Prevent user from editing order once line item is assigned
|
||||
form.fields.pop('order')
|
||||
form.fields['order'].widget = HiddenInput()
|
||||
|
||||
return form
|
||||
|
||||
|
@ -7,7 +7,7 @@ from __future__ import unicode_literals
|
||||
|
||||
from django_filters.rest_framework import DjangoFilterBackend
|
||||
from django.http import JsonResponse
|
||||
from django.db.models import Q, F, Count
|
||||
from django.db.models import Q, F, Count, Prefetch, Sum
|
||||
|
||||
from rest_framework import status
|
||||
from rest_framework.response import Response
|
||||
@ -22,11 +22,14 @@ from .models import PartParameter, PartParameterTemplate
|
||||
from .models import PartAttachment, PartTestTemplate
|
||||
from .models import PartSellPriceBreak
|
||||
|
||||
from build.models import Build
|
||||
|
||||
from . import serializers as part_serializers
|
||||
|
||||
from InvenTree.views import TreeSerializer
|
||||
from InvenTree.helpers import str2bool, isNull
|
||||
from InvenTree.api import AttachmentMixin
|
||||
from InvenTree.status_codes import BuildStatus
|
||||
|
||||
|
||||
class PartCategoryTree(TreeSerializer):
|
||||
@ -531,12 +534,24 @@ class PartList(generics.ListCreateAPIView):
|
||||
if stock_to_build is not None:
|
||||
# Filter only active parts
|
||||
queryset = queryset.filter(active=True)
|
||||
# Prefetch current active builds
|
||||
build_active_queryset = Build.objects.filter(status__in=BuildStatus.ACTIVE_CODES)
|
||||
build_active_prefetch = Prefetch('builds',
|
||||
queryset=build_active_queryset,
|
||||
to_attr='current_builds')
|
||||
parts = queryset.prefetch_related(build_active_prefetch)
|
||||
|
||||
# Store parts with builds needing stock
|
||||
parts_need_stock = []
|
||||
|
||||
# Find parts with active builds
|
||||
# where any subpart's stock is lower than quantity being built
|
||||
for part in queryset:
|
||||
if part.active_builds and part.can_build < part.quantity_being_built:
|
||||
for part in parts:
|
||||
if part.current_builds:
|
||||
builds_ids = [build.id for build in part.current_builds]
|
||||
total_build_quantity = build_active_queryset.filter(pk__in=builds_ids).aggregate(quantity=Sum('quantity'))['quantity']
|
||||
|
||||
if part.can_build < total_build_quantity:
|
||||
parts_need_stock.append(part.pk)
|
||||
|
||||
queryset = queryset.filter(pk__in=parts_need_stock)
|
||||
|
@ -83,7 +83,7 @@ def inventree_github_url(*args, **kwargs):
|
||||
@register.simple_tag()
|
||||
def inventree_docs_url(*args, **kwargs):
|
||||
""" Return URL for InvenTree documenation site """
|
||||
return "https://inventree.github.io"
|
||||
return "https://inventree.readthedocs.io/"
|
||||
|
||||
|
||||
@register.simple_tag()
|
||||
|
@ -35,7 +35,7 @@ class TemplateTagTest(TestCase):
|
||||
self.assertIn('github.com', inventree_extras.inventree_github_url())
|
||||
|
||||
def test_docs(self):
|
||||
self.assertIn('inventree.github.io', inventree_extras.inventree_docs_url())
|
||||
self.assertIn('inventree.readthedocs.io', inventree_extras.inventree_docs_url())
|
||||
|
||||
|
||||
class PartTest(TestCase):
|
||||
|
@ -455,6 +455,12 @@ class StockList(generics.ListCreateAPIView):
|
||||
if belongs_to:
|
||||
queryset = queryset.filter(belongs_to=belongs_to)
|
||||
|
||||
# Filter by batch code
|
||||
batch = params.get('batch', None)
|
||||
|
||||
if batch is not None:
|
||||
queryset = queryset.filter(batch=batch)
|
||||
|
||||
build = params.get('build', None)
|
||||
|
||||
if build:
|
||||
@ -470,6 +476,26 @@ class StockList(generics.ListCreateAPIView):
|
||||
if sales_order:
|
||||
queryset = queryset.filter(sales_order=sales_order)
|
||||
|
||||
# Filter stock items which are installed in another (specific) stock item
|
||||
installed_in = params.get('installed_in', None)
|
||||
|
||||
if installed_in:
|
||||
# Note: The "installed_in" field is called "belongs_to"
|
||||
queryset = queryset.filter(belongs_to=installed_in)
|
||||
|
||||
# Filter stock items which are installed in another stock item
|
||||
installed = params.get('installed', None)
|
||||
|
||||
if installed is not None:
|
||||
installed = str2bool(installed)
|
||||
|
||||
if installed:
|
||||
# Exclude items which are *not* installed in another item
|
||||
queryset = queryset.exclude(belongs_to=None)
|
||||
else:
|
||||
# Exclude items which are instaled in another item
|
||||
queryset = queryset.filter(belongs_to=None)
|
||||
|
||||
# Filter by customer
|
||||
customer = params.get('customer', None)
|
||||
|
||||
|
@ -62,7 +62,7 @@
|
||||
pk: 8
|
||||
fields:
|
||||
stock_item: 522
|
||||
test: 'Check that chair is GreEn '
|
||||
test: 'Check that chair is GreEn'
|
||||
result: True
|
||||
date: 2020-05-17
|
||||
|
||||
|
@ -271,6 +271,28 @@ class ExportOptionsForm(HelperForm):
|
||||
self.fields['file_format'].choices = self.get_format_choices()
|
||||
|
||||
|
||||
class UninstallStockForm(forms.ModelForm):
|
||||
"""
|
||||
Form for uninstalling a stock item which is installed in another item.
|
||||
"""
|
||||
|
||||
location = TreeNodeChoiceField(queryset=StockLocation.objects.all(), label=_('Location'), help_text=_('Destination location for uninstalled items'))
|
||||
|
||||
note = forms.CharField(label=_('Notes'), required=False, help_text=_('Add transaction note (optional)'))
|
||||
|
||||
confirm = forms.BooleanField(required=False, initial=False, label=_('Confirm uninstall'), help_text=_('Confirm removal of installed stock items'))
|
||||
|
||||
class Meta:
|
||||
|
||||
model = StockItem
|
||||
|
||||
fields = [
|
||||
'location',
|
||||
'note',
|
||||
'confirm',
|
||||
]
|
||||
|
||||
|
||||
class AdjustStockForm(forms.ModelForm):
|
||||
""" Form for performing simple stock adjustments.
|
||||
|
||||
@ -282,15 +304,15 @@ class AdjustStockForm(forms.ModelForm):
|
||||
This form is used for managing stock adjuments for single or multiple stock items.
|
||||
"""
|
||||
|
||||
destination = TreeNodeChoiceField(queryset=StockLocation.objects.all(), label='Destination', required=True, help_text=_('Destination stock location'))
|
||||
destination = TreeNodeChoiceField(queryset=StockLocation.objects.all(), label=_('Destination'), required=True, help_text=_('Destination stock location'))
|
||||
|
||||
note = forms.CharField(label='Notes', required=True, help_text='Add note (required)')
|
||||
note = forms.CharField(label=_('Notes'), required=True, help_text=_('Add note (required)'))
|
||||
|
||||
# transaction = forms.BooleanField(required=False, initial=False, label='Create Transaction', help_text='Create a stock transaction for these parts')
|
||||
|
||||
confirm = forms.BooleanField(required=False, initial=False, label='Confirm stock adjustment', help_text=_('Confirm movement of stock items'))
|
||||
confirm = forms.BooleanField(required=False, initial=False, label=_('Confirm stock adjustment'), help_text=_('Confirm movement of stock items'))
|
||||
|
||||
set_loc = forms.BooleanField(required=False, initial=False, label='Set Default Location', help_text=_('Set the destination as the default location for selected parts'))
|
||||
set_loc = forms.BooleanField(required=False, initial=False, label=_('Set Default Location'), help_text=_('Set the destination as the default location for selected parts'))
|
||||
|
||||
class Meta:
|
||||
model = StockItem
|
||||
|
19
InvenTree/stock/migrations/0051_auto_20200928_0928.py
Normal file
19
InvenTree/stock/migrations/0051_auto_20200928_0928.py
Normal file
@ -0,0 +1,19 @@
|
||||
# Generated by Django 3.0.7 on 2020-09-28 09:28
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('stock', '0050_auto_20200821_1403'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='stockitem',
|
||||
name='belongs_to',
|
||||
field=models.ForeignKey(blank=True, help_text='Is this item installed in another item?', null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='installed_parts', to='stock.StockItem', verbose_name='Installed In'),
|
||||
),
|
||||
]
|
@ -341,7 +341,7 @@ class StockItem(MPTTModel):
|
||||
'self',
|
||||
verbose_name=_('Installed In'),
|
||||
on_delete=models.DO_NOTHING,
|
||||
related_name='owned_parts', blank=True, null=True,
|
||||
related_name='installed_parts', blank=True, null=True,
|
||||
help_text=_('Is this item installed in another item?')
|
||||
)
|
||||
|
||||
@ -585,6 +585,78 @@ class StockItem(MPTTModel):
|
||||
|
||||
return True
|
||||
|
||||
def installedItemCount(self):
|
||||
"""
|
||||
Return the number of stock items installed inside this one.
|
||||
"""
|
||||
|
||||
return self.installed_parts.count()
|
||||
|
||||
def hasInstalledItems(self):
|
||||
"""
|
||||
Returns true if this stock item has other stock items installed in it.
|
||||
"""
|
||||
|
||||
return self.installedItemCount() > 0
|
||||
|
||||
@transaction.atomic
|
||||
def installIntoStockItem(self, otherItem, user, notes):
|
||||
"""
|
||||
Install this stock item into another stock item.
|
||||
|
||||
Args
|
||||
otherItem: The stock item to install this item into
|
||||
user: The user performing the operation
|
||||
notes: Any notes associated with the operation
|
||||
"""
|
||||
|
||||
# Cannot be already installed in another stock item!
|
||||
if self.belongs_to is not None:
|
||||
return False
|
||||
|
||||
# TODO - Are there any other checks that need to be performed at this stage?
|
||||
|
||||
# Mark this stock item as belonging to the other one
|
||||
self.belongs_to = otherItem
|
||||
|
||||
self.save()
|
||||
|
||||
# Add a transaction note!
|
||||
self.addTransactionNote(
|
||||
_('Installed in stock item') + ' ' + str(otherItem.pk),
|
||||
user,
|
||||
notes=notes
|
||||
)
|
||||
|
||||
@transaction.atomic
|
||||
def uninstallIntoLocation(self, location, user, notes):
|
||||
"""
|
||||
Uninstall this stock item from another item, into a location.
|
||||
|
||||
Args:
|
||||
location: The stock location where the item will be moved
|
||||
user: The user performing the operation
|
||||
notes: Any notes associated with the operation
|
||||
"""
|
||||
|
||||
# If the stock item is not installed in anything, ignore
|
||||
if self.belongs_to is None:
|
||||
return False
|
||||
|
||||
# TODO - Are there any other checks that need to be performed at this stage?
|
||||
|
||||
self.belongs_to = None
|
||||
self.location = location
|
||||
|
||||
self.save()
|
||||
|
||||
# Add a transaction note!
|
||||
self.addTransactionNote(
|
||||
_('Uninstalled into location') + ' ' + str(location),
|
||||
user,
|
||||
notes=notes
|
||||
)
|
||||
|
||||
@property
|
||||
def children(self):
|
||||
""" Return a list of the child items which have been split from this stock item """
|
||||
@ -1042,7 +1114,9 @@ class StockItem(MPTTModel):
|
||||
as all named tests are accessible.
|
||||
"""
|
||||
|
||||
results = self.getTestResults(**kwargs).order_by('-date')
|
||||
# Filter results by "date", so that newer results
|
||||
# will override older ones.
|
||||
results = self.getTestResults(**kwargs).order_by('date')
|
||||
|
||||
result_map = {}
|
||||
|
||||
|
@ -31,6 +31,7 @@ loadStockTable($("#stock-table"), {
|
||||
part_details: true,
|
||||
ancestor: {{ item.id }},
|
||||
},
|
||||
name: 'item-childs',
|
||||
groupByField: 'location',
|
||||
buttons: [
|
||||
'#stock-options',
|
||||
|
184
InvenTree/stock/templates/stock/item_installed.html
Normal file
184
InvenTree/stock/templates/stock/item_installed.html
Normal file
@ -0,0 +1,184 @@
|
||||
{% extends "stock/item_base.html" %}
|
||||
|
||||
{% load static %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block details %}
|
||||
|
||||
{% include "stock/tabs.html" with tab='installed' %}
|
||||
|
||||
<h4>{% trans "Installed Stock Items" %}</h4>
|
||||
<hr>
|
||||
|
||||
<div id='button-toolbar'>
|
||||
<div class='button-toolbar container-fluid' style='float: right;'>
|
||||
<div class="btn-group">
|
||||
<button id='stock-options' class="btn btn-primary dropdown-toggle" type="button" data-toggle="dropdown">{% trans "Options" %}<span class="caret"></span></button>
|
||||
<ul class="dropdown-menu">
|
||||
<li><a href="#" id='multi-item-uninstall' title='{% trans "Uninstall selected stock items" %}'>{% trans "Uninstall" %}</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<table class='table table-striped table-condensed' id='installed-table' data-toolbar='#button-toolbar'>
|
||||
</table>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block js_ready %}
|
||||
|
||||
{{ block.super }}
|
||||
|
||||
$('#installed-table').inventreeTable({
|
||||
formatNoMatches: function() {
|
||||
return '{% trans "No stock items installed" %}';
|
||||
},
|
||||
url: "{% url 'api-stock-list' %}",
|
||||
queryParams: {
|
||||
installed_in: {{ item.id }},
|
||||
part_detail: true,
|
||||
},
|
||||
name: 'stock-item-installed',
|
||||
url: "{% url 'api-stock-list' %}",
|
||||
showColumns: true,
|
||||
columns: [
|
||||
{
|
||||
checkbox: true,
|
||||
title: '{% trans 'Select' %}',
|
||||
searchable: false,
|
||||
switchable: false,
|
||||
},
|
||||
{
|
||||
field: 'pk',
|
||||
title: 'ID',
|
||||
visible: false,
|
||||
switchable: false,
|
||||
},
|
||||
{
|
||||
field: 'part_name',
|
||||
title: '{% trans "Part" %}',
|
||||
sortable: true,
|
||||
formatter: function(value, row, index, field) {
|
||||
|
||||
var url = `/stock/item/${row.pk}/`;
|
||||
var thumb = row.part_detail.thumbnail;
|
||||
var name = row.part_detail.full_name;
|
||||
|
||||
html = imageHoverIcon(thumb) + renderLink(name, url);
|
||||
|
||||
return html;
|
||||
}
|
||||
},
|
||||
{
|
||||
field: 'IPN',
|
||||
title: 'IPN',
|
||||
sortable: true,
|
||||
formatter: function(value, row, index, field) {
|
||||
return row.part_detail.IPN;
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'part_description',
|
||||
title: '{% trans "Description" %}',
|
||||
sortable: true,
|
||||
formatter: function(value, row, index, field) {
|
||||
return row.part_detail.description;
|
||||
}
|
||||
},
|
||||
{
|
||||
field: 'quantity',
|
||||
title: '{% trans "Stock" %}',
|
||||
sortable: true,
|
||||
formatter: function(value, row, index, field) {
|
||||
|
||||
var val = parseFloat(value);
|
||||
|
||||
// If there is a single unit with a serial number, use the serial number
|
||||
if (row.serial && row.quantity == 1) {
|
||||
val = '# ' + row.serial;
|
||||
} else {
|
||||
val = +val.toFixed(5);
|
||||
}
|
||||
|
||||
var html = renderLink(val, `/stock/item/${row.pk}/`);
|
||||
|
||||
return html;
|
||||
}
|
||||
},
|
||||
{
|
||||
field: 'status',
|
||||
title: '{% trans "Status" %}',
|
||||
sortable: 'true',
|
||||
formatter: function(value, row, index, field) {
|
||||
return stockStatusDisplay(value);
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'batch',
|
||||
title: '{% trans "Batch" %}',
|
||||
sortable: true,
|
||||
},
|
||||
{
|
||||
field: 'actions',
|
||||
switchable: false,
|
||||
title: '',
|
||||
formatter: function(value, row) {
|
||||
var pk = row.pk;
|
||||
|
||||
var html = `<div class='btn-group float-right' role='group'>`;
|
||||
|
||||
html += makeIconButton('fa-trash-alt icon-red', 'button-uninstall', pk, '{% trans "Uninstall item" %}');
|
||||
|
||||
html += `</div>`;
|
||||
|
||||
return html;
|
||||
}
|
||||
}
|
||||
],
|
||||
onLoadSuccess: function() {
|
||||
|
||||
var table = $('#installed-table');
|
||||
|
||||
// Find buttons and associate actions
|
||||
table.find('.button-uninstall').click(function() {
|
||||
var pk = $(this).attr('pk');
|
||||
|
||||
launchModalForm(
|
||||
"{% url 'stock-item-uninstall' %}",
|
||||
{
|
||||
data: {
|
||||
'items[]': [pk],
|
||||
},
|
||||
reload: true,
|
||||
}
|
||||
);
|
||||
});
|
||||
},
|
||||
buttons: [
|
||||
'#stock-options',
|
||||
]
|
||||
});
|
||||
|
||||
$('#multi-item-uninstall').click(function() {
|
||||
|
||||
var selections = $('#installed-table').bootstrapTable('getSelections');
|
||||
|
||||
var items = [];
|
||||
|
||||
selections.forEach(function(item) {
|
||||
items.push(item.pk);
|
||||
});
|
||||
|
||||
launchModalForm(
|
||||
"{% url 'stock-item-uninstall' %}",
|
||||
{
|
||||
data: {
|
||||
'items[]': items,
|
||||
},
|
||||
reload: true,
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
{% endblock %}
|
28
InvenTree/stock/templates/stock/stock_uninstall.html
Normal file
28
InvenTree/stock/templates/stock/stock_uninstall.html
Normal file
@ -0,0 +1,28 @@
|
||||
{% extends "modal_form.html" %}
|
||||
{% load i18n %}
|
||||
{% load inventree_extras %}
|
||||
|
||||
{% block pre_form_content %}
|
||||
|
||||
<div class='alert alert-block alert-success'>
|
||||
{% trans "The following stock items will be uninstalled" %}
|
||||
</div>
|
||||
|
||||
<ul class='list-group'>
|
||||
{% for item in stock_items %}
|
||||
<li class='list-group-item'>
|
||||
{% include "hover_image.html" with image=item.part.image hover=False %}
|
||||
{{ item }}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block form_data %}
|
||||
|
||||
{% for item in stock_items %}
|
||||
<input type='hidden' name='stock-item-{{ item.pk }}' value='{{ item.pk }}'/>
|
||||
{% endfor %}
|
||||
|
||||
{% endblock %}
|
@ -38,4 +38,12 @@
|
||||
<a href="{% url 'stock-item-children' item.id %}">{% trans "Children" %}{% if item.child_count > 0 %}<span class='badge'>{{ item.child_count }}</span>{% endif %}</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if item.part.assembly or item.installedItemCount > 0 %}
|
||||
<li {% if tab == 'installed' %} class='active'{% endif %}>
|
||||
<a href="{% url 'stock-item-installed' item.id %}">
|
||||
{% trans "Installed Items" %}
|
||||
{% if item.installedItemCount > 0 %}<span class='badge'>{{ item.installedItemCount }}</span>{% endif %}
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
@ -3,6 +3,8 @@ from django.db.models import Sum
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
import datetime
|
||||
|
||||
from .models import StockLocation, StockItem, StockItemTracking
|
||||
from .models import StockItemTestResult
|
||||
from part.models import Part
|
||||
@ -439,13 +441,14 @@ class TestResultTest(StockTest):
|
||||
self.assertIn(test, result_map.keys())
|
||||
|
||||
def test_test_results(self):
|
||||
|
||||
item = StockItem.objects.get(pk=522)
|
||||
|
||||
status = item.requiredTestStatus()
|
||||
|
||||
self.assertEqual(status['total'], 5)
|
||||
self.assertEqual(status['passed'], 3)
|
||||
self.assertEqual(status['failed'], 1)
|
||||
self.assertEqual(status['passed'], 2)
|
||||
self.assertEqual(status['failed'], 2)
|
||||
|
||||
self.assertFalse(item.passedAllRequiredTests())
|
||||
|
||||
@ -460,6 +463,18 @@ class TestResultTest(StockTest):
|
||||
result=True
|
||||
)
|
||||
|
||||
# Still should be failing at this point,
|
||||
# as the most recent "apply paint" test was False
|
||||
self.assertFalse(item.passedAllRequiredTests())
|
||||
|
||||
# Add a new test result against this required test
|
||||
StockItemTestResult.objects.create(
|
||||
stock_item=item,
|
||||
test='apply paint',
|
||||
date=datetime.datetime(2022, 12, 12),
|
||||
result=True
|
||||
)
|
||||
|
||||
self.assertTrue(item.passedAllRequiredTests())
|
||||
|
||||
def test_duplicate_item_tests(self):
|
||||
|
@ -34,6 +34,7 @@ stock_item_detail_urls = [
|
||||
url(r'^test/', views.StockItemDetail.as_view(template_name='stock/item_tests.html'), name='stock-item-test-results'),
|
||||
url(r'^children/', views.StockItemDetail.as_view(template_name='stock/item_childs.html'), name='stock-item-children'),
|
||||
url(r'^attachments/', views.StockItemDetail.as_view(template_name='stock/item_attachments.html'), name='stock-item-attachments'),
|
||||
url(r'^installed/', views.StockItemDetail.as_view(template_name='stock/item_installed.html'), name='stock-item-installed'),
|
||||
url(r'^notes/', views.StockItemNotes.as_view(), name='stock-item-notes'),
|
||||
|
||||
url('^.*$', views.StockItemDetail.as_view(), name='stock-item-detail'),
|
||||
@ -59,6 +60,8 @@ stock_urls = [
|
||||
|
||||
url(r'^item/new/?', views.StockItemCreate.as_view(), name='stock-item-create'),
|
||||
|
||||
url(r'^item/uninstall/', views.StockItemUninstall.as_view(), name='stock-item-uninstall'),
|
||||
|
||||
url(r'^item/test-report-download/', views.StockItemTestReportDownload.as_view(), name='stock-item-test-report-download'),
|
||||
url(r'^item/print-stock-labels/', views.StockItemPrintLabels.as_view(), name='stock-item-print-labels'),
|
||||
|
||||
|
@ -683,6 +683,139 @@ class StockItemQRCode(QRCodeView):
|
||||
return None
|
||||
|
||||
|
||||
class StockItemUninstall(AjaxView, FormMixin):
|
||||
"""
|
||||
View for uninstalling one or more StockItems,
|
||||
which are installed in another stock item.
|
||||
|
||||
Stock items are uninstalled into a location,
|
||||
defaulting to the location that they were "in" before they were installed.
|
||||
|
||||
If multiple default locations are detected,
|
||||
leave the final location up to the user.
|
||||
"""
|
||||
|
||||
ajax_template_name = 'stock/stock_uninstall.html'
|
||||
ajax_form_title = _('Uninstall Stock Items')
|
||||
form_class = StockForms.UninstallStockForm
|
||||
|
||||
# List of stock items to uninstall (initially empty)
|
||||
stock_items = []
|
||||
|
||||
def get_stock_items(self):
|
||||
|
||||
return self.stock_items
|
||||
|
||||
def get_initial(self):
|
||||
|
||||
initials = super().get_initial().copy()
|
||||
|
||||
# Keep track of the current locations of stock items
|
||||
current_locations = set()
|
||||
|
||||
# Keep track of the default locations for stock items
|
||||
default_locations = set()
|
||||
|
||||
for item in self.stock_items:
|
||||
|
||||
if item.location:
|
||||
current_locations.add(item.location)
|
||||
|
||||
if item.part.default_location:
|
||||
default_locations.add(item.part.default_location)
|
||||
|
||||
if len(current_locations) == 1:
|
||||
# If the selected stock items are currently in a single location,
|
||||
# select that location as the destination.
|
||||
initials['location'] = next(iter(current_locations))
|
||||
elif len(current_locations) == 0:
|
||||
# There are no current locations set
|
||||
if len(default_locations) == 1:
|
||||
# Select the single default location
|
||||
initials['location'] = next(iter(default_locations))
|
||||
|
||||
return initials
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
|
||||
""" Extract list of stock items, which are supplied as a list,
|
||||
e.g. items[]=1,2,3
|
||||
"""
|
||||
|
||||
if 'items[]' in request.GET:
|
||||
self.stock_items = StockItem.objects.filter(id__in=request.GET.getlist('items[]'))
|
||||
else:
|
||||
self.stock_items = []
|
||||
|
||||
return self.renderJsonResponse(request, self.get_form())
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
|
||||
"""
|
||||
Extract a list of stock items which are included as hidden inputs in the form data.
|
||||
"""
|
||||
|
||||
items = []
|
||||
|
||||
for item in self.request.POST:
|
||||
if item.startswith('stock-item-'):
|
||||
pk = item.replace('stock-item-', '')
|
||||
|
||||
try:
|
||||
stock_item = StockItem.objects.get(pk=pk)
|
||||
items.append(stock_item)
|
||||
except (ValueError, StockItem.DoesNotExist):
|
||||
pass
|
||||
|
||||
self.stock_items = items
|
||||
|
||||
# Assume the form is valid, until it isn't!
|
||||
valid = True
|
||||
|
||||
confirmed = str2bool(request.POST.get('confirm'))
|
||||
|
||||
note = request.POST.get('note', '')
|
||||
|
||||
location = request.POST.get('location', None)
|
||||
|
||||
if location:
|
||||
try:
|
||||
location = StockLocation.objects.get(pk=location)
|
||||
except (ValueError, StockLocation.DoesNotExist):
|
||||
location = None
|
||||
|
||||
if not location:
|
||||
# Location is required!
|
||||
valid = False
|
||||
|
||||
form = self.get_form()
|
||||
|
||||
if not confirmed:
|
||||
valid = False
|
||||
form.errors['confirm'] = [_('Confirm stock adjustment')]
|
||||
|
||||
data = {
|
||||
'form_valid': valid,
|
||||
}
|
||||
|
||||
if valid:
|
||||
# Ok, now let's actually uninstall the stock items
|
||||
for item in self.stock_items:
|
||||
item.uninstallIntoLocation(location, request.user, note)
|
||||
|
||||
data['success'] = _('Uninstalled stock items')
|
||||
|
||||
return self.renderJsonResponse(request, form=form, data=data)
|
||||
|
||||
def get_context_data(self):
|
||||
|
||||
context = super().get_context_data()
|
||||
|
||||
context['stock_items'] = self.get_stock_items()
|
||||
|
||||
return context
|
||||
|
||||
|
||||
class StockAdjust(AjaxView, FormMixin):
|
||||
""" View for enacting simple stock adjustments:
|
||||
|
||||
@ -1037,8 +1170,9 @@ class StockItemEdit(AjaxUpdateView):
|
||||
query = query.filter(part=item.part.id)
|
||||
form.fields['supplier_part'].queryset = query
|
||||
|
||||
if not item.part.trackable or not item.serialized:
|
||||
form.fields.pop('serial')
|
||||
# Hide the serial number field if it is not required
|
||||
if not item.part.trackable and not item.serialized:
|
||||
form.fields['serial'].widget = HiddenInput()
|
||||
|
||||
return form
|
||||
|
||||
|
@ -238,7 +238,7 @@ function loadStockTable(table, options) {
|
||||
|
||||
var filters = {};
|
||||
|
||||
var filterKey = options.filterKey || "stock";
|
||||
var filterKey = options.filterKey || options.name || "stock";
|
||||
|
||||
if (!options.disableFilters) {
|
||||
filters = loadTableFilters(filterKey);
|
||||
@ -416,14 +416,6 @@ function loadStockTable(table, options) {
|
||||
visible: false,
|
||||
switchable: false,
|
||||
},
|
||||
{
|
||||
field: 'IPN',
|
||||
title: 'IPN',
|
||||
sortable: true,
|
||||
formatter: function(value, row, index, field) {
|
||||
return row.part_detail.IPN;
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'part_name',
|
||||
title: '{% trans "Part" %}',
|
||||
@ -439,6 +431,14 @@ function loadStockTable(table, options) {
|
||||
return html;
|
||||
}
|
||||
},
|
||||
{
|
||||
field: 'IPN',
|
||||
title: 'IPN',
|
||||
sortable: true,
|
||||
formatter: function(value, row, index, field) {
|
||||
return row.part_detail.IPN;
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'part_description',
|
||||
title: '{% trans "Description" %}',
|
||||
@ -512,18 +512,22 @@ function loadStockTable(table, options) {
|
||||
title: '{% trans "Location" %}',
|
||||
sortable: true,
|
||||
formatter: function(value, row, index, field) {
|
||||
if (value) {
|
||||
if (row.belongs_to) {
|
||||
var text = "{% trans 'Installed in Stock Item ' %}" + row.belongs_to;
|
||||
var url = `/stock/item/${row.belongs_to}/installed/`;
|
||||
|
||||
return renderLink(text, url);
|
||||
} else if (row.customer) {
|
||||
var text = "{% trans "Shipped to customer" %}";
|
||||
return renderLink(text, `/company/${row.customer}/assigned-stock/`);
|
||||
}
|
||||
else if (value) {
|
||||
return renderLink(value, `/stock/location/${row.location}/`);
|
||||
}
|
||||
else {
|
||||
if (row.customer) {
|
||||
var text = "{% trans "Shipped to customer" %}";
|
||||
return renderLink(text, `/company/${row.customer}/assigned-stock/`);
|
||||
} else {
|
||||
return '<i>{% trans "No stock location set" %}</i>';
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
field: 'notes',
|
||||
|
@ -30,6 +30,10 @@ function getAvailableTableFilters(tableKey) {
|
||||
title: "{% trans "Serial number" %}",
|
||||
description: "{% trans "Serial number" %}"
|
||||
},
|
||||
batch: {
|
||||
title: '{% trans "Batch" %}',
|
||||
description: '{% trans "Batch code" %}',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@ -61,6 +65,11 @@ function getAvailableTableFilters(tableKey) {
|
||||
title: '{% trans "In Stock" %}',
|
||||
description: '{% trans "Show items which are in stock" %}',
|
||||
},
|
||||
installed: {
|
||||
type: 'bool',
|
||||
title: '{% trans "Installed" %}',
|
||||
description: '{% trans "Show stock items which are installed in another item" %}',
|
||||
},
|
||||
sent_to_customer: {
|
||||
type: 'bool',
|
||||
title: '{% trans "Sent to customer" %}',
|
||||
@ -87,6 +96,10 @@ function getAvailableTableFilters(tableKey) {
|
||||
title: '{% trans "Stock status" %}',
|
||||
description: '{% trans "Stock status" %}',
|
||||
},
|
||||
batch: {
|
||||
title: '{% trans "Batch" %}',
|
||||
description: '{% trans "Batch code" %}',
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
|
28
README.md
28
README.md
@ -9,32 +9,28 @@ InvenTree is designed to be lightweight and easy to use for SME or hobbyist appl
|
||||
|
||||
However, powerful business logic works in the background to ensure that stock tracking history is maintained, and users have ready access to stock level information.
|
||||
|
||||
# Documentation
|
||||
|
||||
For InvenTree documentation, refer to the [InvenTree documentation website](https://inventree.readthedocs.io/en/latest/).
|
||||
|
||||
## Getting Started
|
||||
|
||||
Refer to the [getting started guide](https://inventree.github.io/docs/start/install) for installation and setup instructions.
|
||||
|
||||
## Documentation
|
||||
|
||||
For InvenTree documentation, refer to the [InvenTree documentation website](https://inventree.github.io).
|
||||
Refer to the [getting started guide](https://inventree.readthedocs.io/en/latest/start/install/) for installation and setup instructions.
|
||||
|
||||
## Integration
|
||||
|
||||
InvenTree is designed to be extensible, and provides multiple options for integration with external applications or addition of custom plugins:
|
||||
|
||||
* [InvenTree API](https://inventree.github.io/docs/extend/api)
|
||||
* [Python module](https://inventree.github.io/docs/extend/python)
|
||||
* [Plugin interface](https://inventree.github.io/docs/extend/plugins)
|
||||
* [Third party](https://inventree.github.io/docs/extend/integrate)
|
||||
* [InvenTree API](https://inventree.readthedocs.io/en/latest/extend/api/)
|
||||
* [Python module](https://inventree.readthedocs.io/en/latest/extend/python)
|
||||
* [Plugin interface](https://inventree.readthedocs.io/en/latest/extend/plugins)
|
||||
* [Third party](https://inventree.readthedocs.io/en/latest/extend/integrate)
|
||||
|
||||
## Developer Documentation
|
||||
# Contributing
|
||||
|
||||
For code documentation, refer to the [developer documentation](http://inventree.readthedocs.io/en/latest/).
|
||||
Contributions are welcomed and encouraged. Please help to make this project even better! Refer to the [contribution page](https://inventree.readthedocs.io/en/latest/contribute/).
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions are welcomed and encouraged. Please help to make this project even better! Refer to the [contribution page](https://inventree.github.io/pages/contribute).
|
||||
|
||||
## Donate
|
||||
# Donate
|
||||
|
||||
If you use InvenTree and find it to be useful, please consider making a donation toward its continued development.
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user