Merge remote-tracking branch 'inventree/master'

# Conflicts:
#	InvenTree/static/script/inventree/stock.js
#	InvenTree/stock/forms.py
#	InvenTree/stock/urls.py
#	InvenTree/stock/views.py
This commit is contained in:
Oliver Walters 2019-06-02 12:51:56 +10:00
commit 9577e4e505
16 changed files with 393 additions and 569 deletions

View File

@ -6,10 +6,7 @@
{% block collapse_panel_setup %}class='panel part-allocation' id='allocation-panel-{{ item.sub_part.id }}'{% endblock %} {% block collapse_panel_setup %}class='panel part-allocation' id='allocation-panel-{{ item.sub_part.id }}'{% endblock %}
{% block collapse_title %} {% block collapse_title %}
<div class='hover-icon media-left' style='float: left;'> {% include "hover_image.html" with image=item.sub_part.image %}
<img class='hover-img-thumb' src="{% if item.sub_part.image %}{{ item.sub_part.image.url }}{% endif %}">
<img class='hover-img-large' src="{% if item.sub_part.image %}{{ item.sub_part.image.url }}{% endif %}">
</div>
<div> <div>
{{ item.sub_part.full_name }} {{ item.sub_part.full_name }}
<small><i>{{ item.sub_part.description }}</i></small> <small><i>{{ item.sub_part.description }}</i></small>

View File

@ -21,10 +21,7 @@ Automatically allocate stock to this build?
{% for item in allocations %} {% for item in allocations %}
<tr> <tr>
<td> <td>
<a class='hover-icon'> {% include "hover_image.html" with image=item.stock_item.part.image %}
<img class='hover-img-thumb' src='{% if item.stock_item.part.image %}{{ item.stock_item.part.image.url }}{% endif %}'>
<img class='hover-img-large' src='{% if item.stock_item.part.image %}{{ item.stock_item.part.image.url }}{% endif %}'>
</a>
</td> </td>
<td> <td>
{{ item.stock_item.part.full_name }}<br> {{ item.stock_item.part.full_name }}<br>

View File

@ -30,7 +30,7 @@ InvenTree | Build - {{ build }}
</tr> </tr>
<tr> <tr>
<td>Part</td> <td>Part</td>
<td>{{ build.part.full_name }}</td> <td><a href="{% url 'part-detail' build.part.id %}">{{ build.part.full_name }}</a></td>
</tr> </tr>
<tr> <tr>
<td>Quantity</td> <td>Quantity</td>

View File

@ -18,10 +18,7 @@ The following items will be removed from stock:
{% for item in taking %} {% for item in taking %}
<tr> <tr>
<td> <td>
<a class='hover-icon'> {% include "hover_image.html" with image=item.stock_item.part.image %}
<img class='hover-img-thumb' src='{{ item.stock_item.part.image.url }}'>
<img class='hover-img-large' src='{{ item.stock_item.part.image.url }}'>
</a>
</td> </td>
<td> <td>
{{ item.stock_item.part.full_name }}<br> {{ item.stock_item.part.full_name }}<br>
@ -38,10 +35,7 @@ No parts have been allocated to this build.
<hr> <hr>
The following items will be created: The following items will be created:
<div class='panel panel-default'> <div class='panel panel-default'>
<a class='hover-icon'> {% include "hover_image.html" with image=build.part.image %}
<img class='hover-img-thumb' src='{{ build.part.image.url }}'>
<img class='hover-img-large' src='{{ build.part.image.url }}'>
</a>
{{ build.quantity }} x {{ build.part.full_name }} {{ build.quantity }} x {{ build.part.full_name }}
</div> </div>

View File

@ -19,10 +19,7 @@
{% for item in build.required_parts %} {% for item in build.required_parts %}
<tr> <tr>
<td> <td>
<a class='hover-icon'> {% include "hover_image.html" with image=item.part.image %}
<img class='hover-img-thumb' src='{% if item.part.image %}{{ item.part.image.url }}{% endif %}'>
<img class='hover-img-large' src='{% if item.part.image %}{{ item.part.image.url }}{% endif %}'>
</a>
<a class='hover-icon'a href="{% url 'part-detail' item.part.id %}">{{ item.part.full_name }}</a> <a class='hover-icon'a href="{% url 'part-detail' item.part.id %}">{{ item.part.full_name }}</a>
</td> </td>
<td>{{ item.part.total_stock }}</td> <td>{{ item.part.total_stock }}</td>

View File

@ -33,12 +33,7 @@
{% for variant in part.variants.all %} {% for variant in part.variants.all %}
<tr> <tr>
<td> <td>
<div class='hover-icon media-left' style='float: left;'> {% include "hover_image.html" with image=variant.image %}
<img class='hover-img-thumb' src="{% if variant.image %}{{ variant.image.url }}{% else %}{% static 'img/blank_image.png' %}{% endif %}">
{% if variant.image %}
<img class='hover-img-large' src="{{ variant.image.url }}">
{% endif %}
</div>
<a href="{% url 'part-detail' variant.id %}">{{ variant.full_name }}</a> <a href="{% url 'part-detail' variant.id %}">{{ variant.full_name }}</a>
</td> </td>
<td>{{ variant.description }}</td> <td>{{ variant.description }}</td>

View File

@ -192,7 +192,7 @@
} }
.modal-dialog { .modal-dialog {
width: 45%; width: 60%;
} }
.modal-secondary .modal-dialog { .modal-secondary .modal-dialog {
@ -225,6 +225,7 @@
/* Force a control-label div to be 100% width */ /* Force a control-label div to be 100% width */
.modal .control-label { .modal .control-label {
width: 100%; width: 100%;
margin-top: 5px;
} }
.modal .control-label .btn { .modal .control-label .btn {
@ -281,6 +282,13 @@
margin-right: 2px; margin-right: 2px;
} }
.btn-remove {
padding: 3px;
padding-left: 5px;
padding-right: 5px;
color: #A11;
}
.button-toolbar { .button-toolbar {
padding-left: 0px; padding-left: 0px;
} }

View File

@ -14,377 +14,34 @@ function getStockLocations(filters={}, options={}) {
return inventreeGet('/api/stock/location/', filters, options) return inventreeGet('/api/stock/location/', filters, options)
} }
/* Functions for interacting with stock management forms
/* Present user with a dialog to update multiple stock items
* Possible actions:
* - Stocktake
* - Take stock
* - Add stock
*/ */
function updateStock(items, options={}) {
if (!options.action) { function removeStockRow(e) {
alert('No action supplied to stock update'); // Remove a selected row from a stock modal form
return false;
}
var modal = options.modal || '#modal-form'; e = e || window.event;
var src = e.target || e.srcElement;
if (items.length == 0) { var row = $(src).attr('row');
alert('No items selected');
return;
}
var html = ''; $('#' + row).remove();
html += "<table class='table table-striped table-condensed' id='stocktake-table'>\n";
html += '<thead><tr>';
html += '<th>Item</th>';
html += '<th>Location</th>';
html += '<th>Quantity</th>';
html += '<th>' + options.action + '</th>';
html += '</thead><tbody>';
for (idx=0; idx<items.length; idx++) {
var item = items[idx];
var vMin = 0;
var vMax = 0;
var vCur = item.quantity;
if (options.action == 'remove') {
vCur = 0;
vMax = item.quantity;
}
else if (options.action == 'add') {
vCur = 0;
vMax = 0;
}
html += '<tr>';
html += '<td>' + item.part.full_name + '</td>';
if (item.location) {
html += '<td>' + item.location.name + '</td>';
} else {
html += '<td><i>No location set</i></td>';
}
html += '<td>' + item.quantity + '</td>';
html += "<td><input class='form-control' ";
html += "value='" + vCur + "' ";
html += "min='" + vMin + "' ";
if (vMax > 0) {
html += "max='" + vMax + "' ";
}
html += "type='number' id='q-update-" + item.pk + "'/></td>";
html += '</tr>';
}
html += '</tbody></table>';
html += "<hr><input type='text' id='stocktake-notes' placeholder='Notes'/>";
html += "<p class='help-inline' id='note-warning'><strong>Note field must be filled</strong></p>";
html += `
<hr>
<div class='control-group'>
<label class='checkbox'>
<input type='checkbox' id='stocktake-confirm' placeholder='Confirm'/>
Confirm Stocktake
</label>
<p class='help-inline' id='confirm-warning'><strong>Confirm stock count</strong></p>
</div>`;
var title = '';
if (options.action == 'stocktake') {
title = 'Stocktake';
}
else if (options.action == 'remove') {
title = 'Remove stock items';
}
else if (options.action == 'add') {
title = 'Add stock items';
}
openModal({
modal: modal,
title: title,
content: html
});
$(modal).find('#note-warning').hide();
$(modal).find('#confirm-warning').hide();
modalEnable(modal, true);
modalSubmit(modal, function() {
var stocktake = [];
var notes = $(modal).find('#stocktake-notes').val();
var confirm = $(modal).find('#stocktake-confirm').is(':checked');
var valid = true;
if (!notes) {
$(modal).find('#note-warning').show();
valid = false;
}
if (!confirm) {
$(modal).find('#confirm-warning').show();
valid = false;
}
if (!valid) {
return false;
}
// Form stocktake data
for (idx = 0; idx < items.length; idx++) {
var item = items[idx];
var q = $(modal).find("#q-update-" + item.pk).val();
stocktake.push({
pk: item.pk,
quantity: q
});
};
if (!valid) {
alert('Invalid data');
return false;
}
inventreePut("/api/stock/stocktake/",
{
'action': options.action,
'items[]': stocktake,
'notes': $(modal).find('#stocktake-notes').val()
},
{
method: 'post',
}).then(function(response) {
closeModal(modal);
afterForm(response, options);
}).fail(function(xhr, status, error) {
alert(error);
});
});
}
function selectStockItems(options) {
/* Return list of selections from stock table
* If options.table not provided, assumed to be '#stock-table'
*/
var table_name = options.table || '#stock-table';
// Return list of selected items from the bootstrap table
return $(table_name).bootstrapTable('getSelections');
}
function adjustStock(options) {
if (options.items) {
updateStock(options.items, options);
}
else {
// Lookup of individual item
if (options.query.pk) {
getStockDetail(options.query.pk).then(function(response) {
updateStock([response], options);
});
}
else {
getStockList(options.query).then(function(response) {
updateStock(response, options);
});
}
}
}
function updateStockItems(options) {
/* Update one or more stock items selected from a stock-table
* Options available:
* 'action' - Action to perform - 'add' / 'remove' / 'stocktake'
* 'table' - ID of the stock table (default = '#stock-table'
*/
var table = options.table || '#stock-table';
var items = selectStockItems({
table: table,
});
// Pass items through
options.items = items;
options.table = table;
// On success, reload the table
options.success = function() {
$(table).bootstrapTable('refresh');
};
adjustStock(options);
}
function moveStockItems(items, options) {
var modal = options.modal || '#modal-form';
if (items.length == 0) {
alert('No stock items selected');
return;
}
function doMove(location, parts, notes) {
inventreePut("/api/stock/move/",
{
location: location,
'stock': parts,
'notes': notes,
},
{
method: 'post',
}).then(function(response) {
closeModal(modal);
afterForm(response, options);
}).fail(function(xhr, status, error) {
alert(error);
});
}
getStockLocations({},
{
success: function(response) {
// Extact part row info
var parts = [];
var html = "Select new location:<br>\n";
html += "<select class='select' id='stock-location'>";
for (i = 0; i < response.length; i++) {
var loc = response[i];
html += makeOption(loc.pk, loc.pathstring + ' - <i>' + loc.description + '</i>');
}
html += "</select><br>";
html += "<hr><input type='text' id='notes' placeholder='Notes'/>";
html += "<p class='warning-msg' id='note-warning'><i>Note field must be filled</i></p>";
html += "<hr>The following stock items will be moved:<hr>";
html += `
<table class='table table-striped table-condensed'>
<tr>
<th>Part</th>
<th>Location</th>
<th>Available</th>
<th>Moving</th>
</tr>
`;
for (i = 0; i < items.length; i++) {
parts.push({
pk: items[i].pk,
quantity: items[i].quantity,
});
var item = items[i];
var name = item.part__IPN;
if (name) {
name += ' | ';
}
name += item.part__name;
html += "<tr>";
html += "<td>" + name + "</td>";
html += "<td>" + item.location__path + "</td>";
html += "<td>" + item.quantity + "</td>";
html += "<td>";
html += "<input class='form-control' min='0' max='" + item.quantity + "'";
html += " value='" + item.quantity + "'";
html += "type='number' id='q-move-" + item.pk + "'/></td>";
html += "</tr>";
}
html += "</table>";
openModal({
modal: modal,
title: "Move " + items.length + " stock items",
submit_text: "Move",
content: html
});
//modalSetContent(modal, html);
attachSelect(modal);
modalEnable(modal, true);
$(modal).find('#note-warning').hide();
modalSubmit(modal, function() {
var locId = $(modal).find("#stock-location").val();
var notes = $(modal).find('#notes').val();
if (!notes) {
$(modal).find('#note-warning').show();
return false;
}
// Update the quantity for each item
for (var ii = 0; ii < parts.length; ii++) {
var pk = parts[ii].pk;
var q = $(modal).find('#q-move-' + pk).val();
parts[ii].quantity = q;
}
doMove(locId, parts, notes);
});
},
error: function(error) {
alert('Error getting stock locations:\n' + error.error);
}
});
} }
function loadStockTable(table, options) { function loadStockTable(table, options) {
/* Load data into a stock table with adjustable options.
* Fetches data (via AJAX) and loads into a bootstrap table.
* Also links in default button callbacks.
*
* Options:
* url - URL for the stock query
* params - query params for augmenting stock data request
* groupByField - Column for grouping stock items
* buttons - Which buttons to link to stock selection callbacks
*/
var params = options.params || {}; var params = options.params || {};
// Aggregate stock items
//params.aggregate = true;
table.bootstrapTable({ table.bootstrapTable({
sortable: true, sortable: true,
search: true, search: true,
@ -526,31 +183,8 @@ function loadStockTable(table, options) {
linkButtonsToSelection(table, options.buttons); linkButtonsToSelection(table, options.buttons);
} }
// Automatically link button callbacks function stockAdjustment(action) {
$('#multi-item-stocktake').click(function() { var items = $("#stock-table").bootstrapTable("getSelections");
updateStockItems({
action: 'stocktake',
});
return false;
});
$('#multi-item-remove').click(function() {
updateStockItems({
action: 'remove',
});
return false;
});
$('#multi-item-add').click(function() {
updateStockItems({
action: 'add',
});
return false;
});
$("#multi-item-move").click(function() {
var items = $('#stock-table').bootstrapTable('getSelections');
var stock = []; var stock = [];
@ -558,27 +192,34 @@ function loadStockTable(table, options) {
stock.push(item.pk); stock.push(item.pk);
}); });
launchModalForm("/stock/move/", launchModalForm("/stock/adjust/",
{ {
data: { data: {
action: action,
stock: stock, stock: stock,
}, },
success: function() {
$("#stock-table").bootstrapTable('refresh');
},
} }
); );
}
/* // Automatically link button callbacks
$('#multi-item-stocktake').click(function() {
stockAdjustment('count');
});
var items = $("#stock-table").bootstrapTable('getSelections'); $('#multi-item-remove').click(function() {
stockAdjustment('take');
});
moveStockItems(items, $('#multi-item-add').click(function() {
{ stockAdjustment('add');
success: function() { });
$("#stock-table").bootstrapTable('refresh');
}
});
return false; $("#multi-item-move").click(function() {
*/ stockAdjustment('move');
}); });
} }

View File

@ -470,9 +470,9 @@ stock_api_urls = [
url(r'location/(?P<pk>\d+)/', include(location_endpoints)), url(r'location/(?P<pk>\d+)/', include(location_endpoints)),
url(r'stocktake/?', StockStocktake.as_view(), name='api-stock-stocktake'), # These JSON endpoints have been replaced (for now) with server-side form rendering - 02/06/2019
# url(r'stocktake/?', StockStocktake.as_view(), name='api-stock-stocktake'),
url(r'move/?', StockMove.as_view(), name='api-stock-move'), # url(r'move/?', StockMove.as_view(), name='api-stock-move'),
url(r'track/?', StockTrackingList.as_view(), name='api-stock-track'), url(r'track/?', StockTrackingList.as_view(), name='api-stock-track'),

View File

@ -42,8 +42,16 @@ class CreateStockItemForm(HelperForm):
] ]
class MoveStockItemForm(forms.ModelForm): class AdjustStockForm(forms.ModelForm):
""" Form for moving a StockItem to a new location """ """ Form for performing simple stock adjustments.
- Add stock
- Remove stock
- Count stock
- Move stock
This form is used for managing stock adjuments for single or multiple stock items.
"""
def get_location_choices(self): def get_location_choices(self):
locs = StockLocation.objects.all() locs = StockLocation.objects.all()
@ -55,37 +63,27 @@ class MoveStockItemForm(forms.ModelForm):
return choices return choices
location = forms.ChoiceField(label='Destination', required=True, help_text='Destination stock location') destination = forms.ChoiceField(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') # 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 Movement', help_text='Confirm movement of stock items') confirm = forms.BooleanField(required=False, initial=False, label='Confirm Stock Movement', help_text='Confirm movement of stock items')
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super(MoveStockItemForm, self).__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.fields['location'].choices = self.get_location_choices() self.fields['destination'].choices = self.get_location_choices()
class Meta: class Meta:
model = StockItem model = StockItem
fields = [ fields = [
'location', 'destination',
'note', 'note',
'transaction', # 'transaction',
'confirm', 'confirm',
] ]
class StocktakeForm(forms.ModelForm):
class Meta:
model = StockItem
fields = [
'quantity',
]
class EditStockItemForm(HelperForm): class EditStockItemForm(HelperForm):
""" Form for editing a StockItem object. """ Form for editing a StockItem object.
Note that not all fields can be edited here (even if they can be specified during creation. Note that not all fields can be edited here (even if they can be specified during creation.

View File

@ -22,11 +22,13 @@
<ul class="dropdown-menu"> <ul class="dropdown-menu">
{% if item.in_stock %} {% if item.in_stock %}
<li><a href="#" id='stock-edit' title='Edit stock item'>Edit stock item</a></li> <li><a href="#" id='stock-edit' title='Edit stock item'>Edit stock item</a></li>
<li><a href="#" id='stock-move' title='Move stock item'>Move stock item</a></li> <hr>
<li><a href='#' id='stock-add' title='Add stock'>Add to stock</a></li> <li><a href='#' id='stock-add' title='Add stock'>Add to stock</a></li>
<li><a href='#' id='stock-remove' title='Remove stock'>Take from stock</a></li> <li><a href='#' id='stock-remove' title='Take stock'>Take from stock</a></li>
<li><a href='#' id='stock-stocktake' title='Count stock'>Stocktake</a></li> <li><a href='#' id='stock-stocktake' title='Count stock'>Stocktake</a></li>
<li><a href="#" id='stock-move' title='Move stock'>Move stock item</a></li>
{% endif %} {% endif %}
<hr>
<li><a href="#" id='stock-delete' title='Delete stock item'>Delete stock item</a></li> <li><a href="#" id='stock-delete' title='Delete stock item'>Delete stock item</a></li>
</div> </div>
</div> </div>
@ -155,40 +157,33 @@
}); });
{% if item.in_stock %} {% if item.in_stock %}
$("#stock-move").click(function() {
launchModalForm(
"{% url 'stock-item-move' item.id %}",
{
reload: true,
submit_text: "Move"
});
});
function itemAdjust(action) { function itemAdjust(action) {
adjustStock({ launchModalForm("/stock/adjust/",
query: { {
pk: {{ item.id }}, data: {
}, action: action,
action: action, item: {{ item.id }},
success: function() { },
location.reload(); reload: true,
} }
}); );
} }
$("#stock-move").click(function() {
itemAdjust("move");
});
$("#stock-stocktake").click(function() { $("#stock-stocktake").click(function() {
itemAdjust('stocktake'); itemAdjust('count');
return false;
}); });
$('#stock-remove').click(function() { $('#stock-remove').click(function() {
itemAdjust('remove'); itemAdjust('take');
return false;
}); });
$('#stock-add').click(function() { $('#stock-add').click(function() {
itemAdjust('add'); itemAdjust('add');
return false;
}); });
{% endif %} {% endif %}

View File

@ -0,0 +1,39 @@
{% block pre_form_content %}
{% endblock %}
<form method="post" action='' class='js-modal-form' enctype="multipart/form-data">
{% csrf_token %}
{% load crispy_forms_tags %}
<input type='hidden' name='stock_action' value='{{ stock_action }}'/>
<table class='table table-condensed table-striped' id='stock-table'>
<tr>
<th>Stock Item</th>
<th>Location</th>
<th>{{ stock_action_title }}</th>
<th></th>
</tr>
{% for item in stock_items %}
<tr id='stock-row-{{ item.id }}' class='error'>
<td>{% include "hover_image.html" with image=item.part.image %}
{{ item.part.full_name }} <small><i>{{ item.part.description }}</i></small></td>
<td>{{ item.location.pathstring }}</td>
<td>
<input class='numberinput'
min='0'
{% if stock_action == 'take' or stock_action == 'move' %} max='{{ item.quantity }}' {% endif %}
value='{{ item.new_quantity }}' type='number' name='stock-id-{{ item.id }}' id='stock-id-{{ item.id }}'/>
{% if item.error %}
<br><span class='help-inline'>{{ item.error }}</span>
{% endif %}
</td>
<td><button class='btn btn-default btn-remove' id='del-{{ item.id }}' title='Remove item' type='button'><span row='stock-row-{{ item.id }}' onclick='removeStockRow()' class='glyphicon glyphicon-small glyphicon-remove'></span></button></td>
</tr>
{% endfor %}
</table>
{% crispy form %}
</form>

View File

@ -19,8 +19,6 @@ stock_location_detail_urls = [
stock_item_detail_urls = [ stock_item_detail_urls = [
url(r'^edit/?', views.StockItemEdit.as_view(), name='stock-item-edit'), url(r'^edit/?', views.StockItemEdit.as_view(), name='stock-item-edit'),
url(r'^delete/?', views.StockItemDelete.as_view(), name='stock-item-delete'), url(r'^delete/?', views.StockItemDelete.as_view(), name='stock-item-delete'),
url(r'^move/?', views.StockItemMove.as_view(), name='stock-item-move'),
url(r'^stocktake/?', views.StockItemStocktake.as_view(), name='stock-item-stocktake'),
url(r'^qr_code/?', views.StockItemQRCode.as_view(), name='stock-item-qr'), url(r'^qr_code/?', views.StockItemQRCode.as_view(), name='stock-item-qr'),
url('^.*$', views.StockItemDetail.as_view(), name='stock-item-detail'), url('^.*$', views.StockItemDetail.as_view(), name='stock-item-detail'),
@ -36,7 +34,7 @@ stock_urls = [
url(r'^track/?', views.StockTrackingIndex.as_view(), name='stock-tracking-list'), url(r'^track/?', views.StockTrackingIndex.as_view(), name='stock-tracking-list'),
url(r'^move/?', views.StockItemMoveMultiple.as_view(), name='stock-item-move-multiple'), url(r'^adjust/?', views.StockAdjust.as_view(), name='stock-adjust'),
# Individual stock items # Individual stock items
url(r'^item/(?P<pk>\d+)/', include(stock_item_detail_urls)), url(r'^item/(?P<pk>\d+)/', include(stock_item_detail_urls)),

View File

@ -10,19 +10,21 @@ from django.views.generic import DetailView, ListView
from django.forms.models import model_to_dict from django.forms.models import model_to_dict
from django.forms import HiddenInput from django.forms import HiddenInput
from django.utils.translation import ugettext as _
from InvenTree.views import AjaxView from InvenTree.views import AjaxView
from InvenTree.views import AjaxUpdateView, AjaxDeleteView, AjaxCreateView from InvenTree.views import AjaxUpdateView, AjaxDeleteView, AjaxCreateView
from InvenTree.views import QRCodeView from InvenTree.views import QRCodeView
from InvenTree.helpers import str2bool
from part.models import Part from part.models import Part
from .models import StockItem, StockLocation, StockItemTracking from .models import StockItem, StockLocation, StockItemTracking
from .forms import EditStockLocationForm from .forms import EditStockLocationForm
from .forms import CreateStockItemForm from .forms import CreateStockItemForm
from .forms import EditStockItemForm from .forms import EditStockItemForm
from .forms import MoveStockItemForm from .forms import AdjustStockForm
from .forms import StocktakeForm
from .forms import MoveStockItemForm
class StockIndex(ListView): class StockIndex(ListView):
@ -125,52 +127,273 @@ class StockItemQRCode(QRCodeView):
return None return None
class StockItemMoveMultiple(AjaxView, FormMixin): class StockAdjust(AjaxView, FormMixin):
""" Move multiple stock items """ """ View for enacting simple stock adjustments:
ajax_template_name = 'stock/stock_move.html' - Take items from stock
ajax_form_title = 'Move Stock' - Add items to stock
form_class = MoveStockItemForm - Count items
- Move stock
def get_items(self, item_list): """
""" Return list of stock items. """
items = [] ajax_template_name = 'stock/stock_adjust.html'
ajax_form_title = 'Adjust Stock'
form_class = AdjustStockForm
stock_items = []
for pk in item_list: def get_GET_items(self):
try: """ Return list of stock items initally requested using GET """
items.append(StockItem.objects.get(pk=pk))
except StockItem.DoesNotExist: # Start with all 'in stock' items
pass items = StockItem.objects.filter(customer=None, belongs_to=None)
# Client provides a list of individual stock items
if 'stock[]' in self.request.GET:
items = items.filter(id__in=self.request.GET.getlist('stock[]'))
# Client provides a PART reference
elif 'part' in self.request.GET:
items = items.filter(part=self.request.GET.get('part'))
# Client provides a LOCATION reference
elif 'location' in self.request.GET:
items = items.filter(location=self.request.GET.get('location'))
# Client provides a single StockItem lookup
elif 'item' in self.request.GET:
items = [StockItem.objects.get(id=self.request.GET.get('item'))]
# Unsupported query
else:
items = None
for item in items:
# Initialize quantity to zero for addition/removal
if self.stock_action in ['take', 'add']:
item.new_quantity = 0
# Initialize quantity at full amount for counting or moving
else:
item.new_quantity = item.quantity
return items return items
def get_POST_items(self):
""" Return list of stock items sent back by client on a POST request """
items = []
for item in self.request.POST:
if item.startswith('stock-id-'):
pk = item.replace('stock-id-', '')
q = self.request.POST[item]
try:
stock_item = StockItem.objects.get(pk=pk)
except StockItem.DoesNotExist:
continue
stock_item.new_quantity = q
items.append(stock_item)
return items
def get_context_data(self):
context = super().get_context_data()
context['stock_items'] = self.stock_items
context['stock_action'] = self.stock_action
context['stock_action_title'] = self.stock_action.capitalize()
return context
def get_form(self):
form = super().get_form()
if not self.stock_action == 'move':
form.fields.pop('destination')
return form
def get(self, request, *args, **kwargs): def get(self, request, *args, **kwargs):
item_list = request.GET.getlist('stock[]') self.request = request
items = self.get_items(item_list) # Action
self.stock_action = request.GET.get('action', '').lower()
print(items) # Pick a default action...
if self.stock_action not in ['move', 'count', 'take', 'add']:
self.stock_action = 'count'
return self.renderJsonResponse(request, self.form_class()) # Choose the form title based on the action
titles = {
'move': 'Move Stock',
'count': 'Count Stock',
'take': 'Remove Stock',
'add': 'Add Stock'
}
self.ajax_form_title = titles[self.stock_action]
# Save list of items!
self.stock_items = self.get_GET_items()
return self.renderJsonResponse(request, self.get_form())
def post(self, request, *args, **kwargs): def post(self, request, *args, **kwargs):
self.request = request
self.stock_action = request.POST.get('stock_action').lower()
# Update list of stock items
self.stock_items = self.get_POST_items()
form = self.get_form() form = self.get_form()
valid = form.is_valid() valid = form.is_valid()
print("Valid:", valid) for item in self.stock_items:
try:
item.new_quantity = int(item.new_quantity)
except ValueError:
item.error = _('Must enter integer value')
valid = False
continue
if item.new_quantity < 0:
item.error = _('Quantity must be positive')
valid = False
continue
if self.stock_action in ['move', 'take']:
if item.new_quantity > item.quantity:
item.error = _('Quantity must not exceed {x}'.format(x=item.quantity))
valid = False
continue
confirmed = str2bool(request.POST.get('confirm'))
if not confirmed:
valid = False
form.errors['confirm'] = [_('Confirm stock adjustment')]
data = { data = {
'form_valid': False, 'form_valid': valid,
} }
#form.errors['note'] = ['hello world'] if valid:
data['success'] = self.do_action()
return self.renderJsonResponse(request, form, data=data) return self.renderJsonResponse(request, form, data=data)
def do_action(self):
""" Perform stock adjustment action """
if self.stock_action == 'move':
destination = None
try:
destination = StockLocation.objects.get(id=self.request.POST.get('destination'))
except StockLocation.DoesNotExist:
pass
except ValueError:
pass
return self.do_move(destination)
elif self.stock_action == 'add':
return self.do_add()
elif self.stock_action == 'take':
return self.do_take()
elif self.stock_action == 'count':
return self.do_count()
else:
return 'No action performed'
def do_add(self):
count = 0
note = self.request.POST['note']
for item in self.stock_items:
if item.new_quantity <= 0:
continue
item.add_stock(item.new_quantity, self.request.user, notes=note)
count += 1
return _("Added stock to {n} items".format(n=count))
def do_take(self):
count = 0
note = self.request.POST['note']
for item in self.stock_items:
if item.new_quantity <= 0:
continue
item.take_stock(item.new_quantity, self.request.user, notes=note)
count += 1
return _("Removed stock from {n} items".format(n=count))
def do_count(self):
count = 0
note = self.request.POST['note']
for item in self.stock_items:
item.stocktake(item.new_quantity, self.request.user, notes=note)
count += 1
return _("Counted stock for {n} items".format(n=count))
def do_move(self, destination):
""" Perform actual stock movement """
count = 0
note = self.request.POST['note']
for item in self.stock_items:
# Avoid moving zero quantity
if item.new_quantity <= 0:
continue
# Do not move to the same location
if destination == item.location:
continue
item.move(destination, note, self.request.user, quantity=int(item.new_quantity))
count += 1
if count == 0:
return _('No items were moved')
else:
return _('Moved {n} items to {dest}'.format(
n=count,
dest=destination.pathstring))
class StockItemEdit(AjaxUpdateView): class StockItemEdit(AjaxUpdateView):
@ -357,76 +580,6 @@ class StockItemDelete(AjaxDeleteView):
ajax_form_title = 'Delete Stock Item' ajax_form_title = 'Delete Stock Item'
class StockItemMove(AjaxUpdateView):
"""
View to move a StockItem from one location to another
Performs some data validation to prevent illogical stock moves
"""
model = StockItem
ajax_template_name = 'modal_form.html'
context_object_name = 'item'
ajax_form_title = 'Move Stock Item'
form_class = MoveStockItemForm
def post(self, request, *args, **kwargs):
form = self.form_class(request.POST, instance=self.get_object())
if form.is_valid():
obj = self.get_object()
try:
loc_id = form['location'].value()
if loc_id:
loc = StockLocation.objects.get(pk=form['location'].value())
if str(loc.pk) == str(obj.pk):
form.errors['location'] = ['Item is already in this location']
else:
obj.move(loc, form['note'].value(), request.user)
else:
form.errors['location'] = ['Cannot move to an empty location']
except StockLocation.DoesNotExist:
form.errors['location'] = ['Location does not exist']
data = {
'form_valid': form.is_valid() and len(form.errors) == 0,
}
return self.renderJsonResponse(request, form, data)
class StockItemStocktake(AjaxUpdateView):
"""
View to perform stocktake on a single StockItem
Updates the quantity, which will also create a new StockItemTracking item
"""
model = StockItem
template_name = 'modal_form.html'
context_object_name = 'item'
ajax_form_title = 'Item stocktake'
form_class = StocktakeForm
def post(self, request, *args, **kwargs):
form = self.form_class(request.POST, instance=self.get_object())
if form.is_valid():
obj = self.get_object()
obj.stocktake(form.data['quantity'], request.user)
data = {
'form_valid': form.is_valid()
}
return self.renderJsonResponse(request, form, data)
class StockTrackingIndex(ListView): class StockTrackingIndex(ListView):
""" """
StockTrackingIndex provides a page to display StockItemTracking objects StockTrackingIndex provides a page to display StockItemTracking objects

View File

@ -0,0 +1,12 @@
{% load static %}
<div class='hover-icon media-left' style='float: left;'>
{% if image %}
<a class='hover-icon'>
{% endif %}
<img class='hover-img-thumb' {% if image %}src='{{ image.url }}'{% else %}src='{% static "img/blank_image.png" %}'{% endif %}>
{% if image %}
<img class='hover-img-large' src='{{ image.url }}'>
</a>
{% endif %}
</div>

View File

@ -8,8 +8,8 @@
<ul class="dropdown-menu"> <ul class="dropdown-menu">
<li><a href="#" id='multi-item-add' title='Add to selected stock items'>Add stock</a></li> <li><a href="#" id='multi-item-add' title='Add to selected stock items'>Add stock</a></li>
<li><a href="#" id='multi-item-remove' title='Remove from selected stock items'>Remove stock</a></li> <li><a href="#" id='multi-item-remove' title='Remove from selected stock items'>Remove stock</a></li>
<li><a href="#" id='multi-item-stocktake' title='Stocktake selected stock items'>Stocktake</a></li> <li><a href="#" id='multi-item-stocktake' title='Stocktake selected stock items'>Count stock</a></li>
<li><a href='#' id='multi-item-move' title='Move selected stock items'>Move</a></li> <li><a href='#' id='multi-item-move' title='Move selected stock items'>Move stock</a></li>
</ul> </ul>
</div> </div>
</div> </div>