Merge pull request #1000 from SchrodingersGat/installed-in

Installed in
This commit is contained in:
Oliver 2020-09-28 22:16:07 +10:00 committed by GitHub
commit 41d6ad2db9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 2169 additions and 1160 deletions

View File

@ -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) {

View File

@ -19,7 +19,7 @@
loadStockTable($("#stock-table"), {
params: {
location_detail: true,
part_details: true,
part_detail: true,
build: {{ build.id }},
},
groupByField: 'location',

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

View File

@ -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

View File

@ -476,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)

View File

@ -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

View 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'),
),
]

View File

@ -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 """

View File

@ -31,6 +31,7 @@ loadStockTable($("#stock-table"), {
part_details: true,
ancestor: {{ item.id }},
},
name: 'item-childs',
groupByField: 'location',
buttons: [
'#stock-options',

View 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 %}

View 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 %}

View File

@ -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>

View File

@ -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'),

View File

@ -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

View File

@ -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',

View File

@ -65,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" %}',

View File

@ -256,4 +256,4 @@ def server(c, address="127.0.0.1:8000"):
Note: This is *not* sufficient for a production installation.
"""
manage(c, "runserver {address}".format(address=address))
manage(c, "runserver {address}".format(address=address), pty=True)