Merge pull request #124 from SchrodingersGat/bom-download

Bom download
This commit is contained in:
Oliver 2019-04-16 22:42:21 +10:00 committed by GitHub
commit 9b0fefb0b4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
31 changed files with 4864 additions and 249 deletions

View File

@ -0,0 +1,32 @@
import io
from wsgiref.util import FileWrapper
from django.http import StreamingHttpResponse
def WrapWithQuotes(text):
# TODO - Make this better
if not text.startswith('"'):
text = '"' + text
if not text.endswith('"'):
text = text + '"'
return text
def DownloadFile(data, filename, content_type='application/text'):
"""
Create a dynamic file for the user to download.
@param data is the raw file data
"""
filename = WrapWithQuotes(filename)
wrapper = FileWrapper(io.StringIO(data))
response = StreamingHttpResponse(wrapper, content_type=content_type)
response['Content-Length'] = len(data)
response['Content-Disposition'] = 'attachment; filename={f}'.format(f=filename)
return response

View File

@ -14,6 +14,7 @@ from build.urls import build_urls
from part.api import part_api_urls
from company.api import company_api_urls
from stock.api import stock_api_urls
from build.api import build_api_urls
from django.conf import settings
from django.conf.urls.static import static
@ -31,6 +32,7 @@ apipatterns = [
url(r'^part/', include(part_api_urls)),
url(r'^company/', include(company_api_urls)),
url(r'^stock/', include(stock_api_urls)),
url(r'^build/', include(build_api_urls)),
# User URLs
url(r'^user/', include(user_urls)),

View File

@ -65,9 +65,7 @@ class AjaxMixin(object):
else:
return self.template_name
def renderJsonResponse(self, request, form, data={}):
context = {}
def renderJsonResponse(self, request, form=None, data={}, context={}):
if form:
context['form'] = form
@ -92,22 +90,46 @@ class AjaxMixin(object):
class AjaxView(AjaxMixin, View):
""" Bare-bones AjaxView """
# By default, point to the modal_form template
# (this can be overridden by a child class)
ajax_template_name = 'modal_form.html'
def post(self, request, *args, **kwargs):
return JsonResponse('', safe=False)
def get(self, request, *args, **kwargs):
return self.renderJsonResponse(request, None)
return self.renderJsonResponse(request)
class AjaxCreateView(AjaxMixin, CreateView):
""" An 'AJAXified' CreateView for creating a new object in the db
- Returns a form in JSON format (for delivery to a modal window)
- Handles form validation via AJAX POST requests
"""
def get(self, request, *args, **kwargs):
response = super(CreateView, self).get(request, *args, **kwargs)
if request.is_ajax():
# Initialize a a new form
form = self.form_class(initial=self.get_initial())
return self.renderJsonResponse(request, form)
else:
return response
def post(self, request, *args, **kwargs):
form = self.form_class(data=request.POST, files=request.FILES)
if request.is_ajax():
data = {'form_valid': form.is_valid()}
data = {
'form_valid': form.is_valid(),
}
if form.is_valid():
obj = form.save()
@ -122,20 +144,25 @@ class AjaxCreateView(AjaxMixin, CreateView):
else:
return super(CreateView, self).post(request, *args, **kwargs)
class AjaxUpdateView(AjaxMixin, UpdateView):
""" An 'AJAXified' UpdateView for updating an object in the db
- Returns form in JSON format (for delivery to a modal window)
- Handles repeated form validation (via AJAX) until the form is valid
"""
def get(self, request, *args, **kwargs):
response = super(CreateView, self).get(request, *args, **kwargs)
html_response = super(UpdateView, self).get(request, *args, **kwargs)
if request.is_ajax():
form = self.form_class(initial=self.get_initial())
form = self.form_class(instance=self.get_object())
return self.renderJsonResponse(request, form)
else:
return response
class AjaxUpdateView(AjaxMixin, UpdateView):
return html_response
def post(self, request, *args, **kwargs):
@ -154,45 +181,26 @@ class AjaxUpdateView(AjaxMixin, UpdateView):
response = self.renderJsonResponse(request, form, data)
return response
else:
return response
def get(self, request, *args, **kwargs):
if request.is_ajax():
form = self.form_class(instance=self.get_object())
return self.renderJsonResponse(request, form)
else:
return super(UpdateView, self).post(request, *args, **kwargs)
class AjaxDeleteView(AjaxMixin, DeleteView):
def post(self, request, *args, **kwargs):
if request.is_ajax():
obj = self.get_object()
pk = obj.id
obj.delete()
data = {'id': pk,
'delete': True}
return self.renderJsonResponse(request, None, data)
else:
return super(DeleteView, self).post(request, *args, **kwargs)
""" An 'AJAXified DeleteView for removing an object from the DB
- Returns a HTML object (not a form!) in JSON format (for delivery to a modal window)
- Handles deletion
"""
def get(self, request, *args, **kwargs):
response = super(DeleteView, self).get(request, *args, **kwargs)
html_response = super(DeleteView, self).get(request, *args, **kwargs)
if request.is_ajax():
data = {'id': self.get_object().id,
'title': self.ajax_form_title,
'delete': False,
'title': self.ajax_form_title,
'html_data': render_to_string(self.getAjaxTemplate(),
self.get_context_data(),
request=request)
@ -201,7 +209,23 @@ class AjaxDeleteView(AjaxMixin, DeleteView):
return JsonResponse(data)
else:
return response
return html_response
def post(self, request, *args, **kwargs):
if request.is_ajax():
obj = self.get_object()
pk = obj.id
obj.delete()
data = {'id': pk,
'delete': True}
return self.renderJsonResponse(request, data=data)
else:
return super(DeleteView, self).post(request, *args, **kwargs)
class IndexView(TemplateView):

36
InvenTree/build/api.py Normal file
View File

@ -0,0 +1,36 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django_filters.rest_framework import DjangoFilterBackend
from rest_framework import filters
from rest_framework import generics, permissions
from django.conf.urls import url
from .models import Build
from .serializers import BuildSerializer
class BuildList(generics.ListAPIView):
queryset = Build.objects.all()
serializer_class = BuildSerializer
permission_classes = [
permissions.IsAuthenticatedOrReadOnly,
]
filter_backends = [
DjangoFilterBackend,
filters.SearchFilter,
filters.OrderingFilter,
]
filter_fields = [
'part',
]
build_api_urls = [
url(r'^.*$', BuildList.as_view(), name='api-build-list')
]

View File

@ -0,0 +1,23 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from rest_framework import serializers
from .models import Build
class BuildSerializer(serializers.ModelSerializer):
url = serializers.CharField(source='get_absolute_url', read_only=True)
class Meta:
model = Build
fields = [
'pk',
'url',
'title',
'creation_date',
'completion_date',
'part',
'quantity',
'notes']

View File

@ -4,7 +4,11 @@
<h3>Part Builds</h3>
<table class='table table-striped table-condensed' id='build-table'>
<div id='button-toolbar'>
<button class="btn btn-success" id='new-build'>Start New Build</button>
</div>
<table class='table table-striped table-condensed' id='build-table' data-toolbar='#button-toolbar'>
<thead>
<tr>
<th>Build</th>
@ -20,9 +24,6 @@
</tbody>
</table>
<div class='container-fluid'>
<button class="btn btn-success" id='new-build'>Start New Build</button>
</div>
{% include 'modals.html' %}

View File

@ -4,26 +4,21 @@
{% include 'company/tabs.html' with tab='parts' %}
<div class='row'>
<div class='col-sm-6'>
<h3>Supplier Parts</h3>
</div>
<div class='col-sm-6'>
<h3 class='float-right'>
<button class="btn btn-success" id='part-create'>New Supplier Part</button>
<div class="dropdown" style="float: right;">
<button class="btn btn-primary dropdown-toggle" type="button" data-toggle="dropdown">Options
<span class="caret"></span></button>
<ul class="dropdown-menu">
</ul>
</div>
</h3>
<h3>Supplier Parts</h3>
<div id='button-toolbar'>
<button class="btn btn-success" id='part-create'>New Supplier Part</button>
<div class="dropdown" style="float: right;">
<button class="btn btn-primary dropdown-toggle" type="button" data-toggle="dropdown">Options
<span class="caret"></span></button>
<ul class="dropdown-menu">
</ul>
</div>
</div>
<hr>
<table clas='table table-striped table-condensed' id='part-table'>
<table clas='table table-striped table-condensed' id='part-table' data-toolbar='#button-toolbar'>
</table>
{% endblock %}

View File

@ -4,12 +4,12 @@
{% block content %}
<div class='container-fluid'>
<h3><button style='float: right;' class="btn btn-success" id='new-company'>New Company</button></h3>
<h3>Companies</h3>
<div id='button-toolbar'>
<h3><button style='float: right;' class="btn btn-success" id='new-company'>New Company</button></h3>
</div>
<table class='table table-striped' id='company-table'>
<table class='table table-striped' id='company-table' data-toolbar='#button-toolbar'>
</table>

View File

@ -70,13 +70,14 @@ class PartList(generics.ListCreateAPIView):
serializer_class = PartSerializer
def get_queryset(self):
print("Get queryset")
# Does the user wish to filter by category?
cat_id = self.request.query_params.get('category', None)
# Start with all objects
parts_list = Part.objects.all()
if cat_id:
print("Getting category:", cat_id)
category = get_object_or_404(PartCategory, pk=cat_id)
# Filter by the supplied category
@ -90,10 +91,10 @@ class PartList(generics.ListCreateAPIView):
continue
flt |= Q(category=child)
return Part.objects.filter(flt)
parts_list = parts_list.filter(flt)
# Default - return all parts
return Part.objects.all()
return parts_list
permission_classes = [
permissions.IsAuthenticatedOrReadOnly,
@ -106,6 +107,11 @@ class PartList(generics.ListCreateAPIView):
]
filter_fields = [
'buildable',
'consumable',
'trackable',
'purchaseable',
'salable',
]
ordering_fields = [

View File

@ -3,6 +3,8 @@ from __future__ import unicode_literals
from InvenTree.forms import HelperForm
from django import forms
from .models import Part, PartCategory, BomItem
from .models import SupplierPart
@ -16,6 +18,27 @@ class PartImageForm(HelperForm):
]
class BomExportForm(HelperForm):
# TODO - Define these choices somewhere else, and import them here
format_choices = (
('csv', 'CSV'),
('pdf', 'PDF'),
('xml', 'XML'),
('xlsx', 'XLSX'),
('html', 'HTML')
)
# Select export type
format = forms.CharField(label='Format', widget=forms.Select(choices=format_choices), required='true', help_text='Select export format')
class Meta:
model = Part
fields = [
'format',
]
class EditPartForm(HelperForm):
class Meta:
@ -28,8 +51,10 @@ class EditPartForm(HelperForm):
'URL',
'default_location',
'default_supplier',
'units',
'minimum_stock',
'buildable',
'consumable',
'trackable',
'purchaseable',
'salable',
@ -56,8 +81,10 @@ class EditBomItemForm(HelperForm):
fields = [
'part',
'sub_part',
'quantity'
'quantity',
'note'
]
widgets = {'part': forms.HiddenInput()}
class EditSupplierPartForm(HelperForm):

View File

@ -0,0 +1,18 @@
# Generated by Django 2.2 on 2019-04-14 08:25
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('part', '0003_auto_20190412_2030'),
]
operations = [
migrations.AddField(
model_name='bomitem',
name='note',
field=models.CharField(blank=True, help_text='Item notes', max_length=100),
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 2.2 on 2019-04-15 13:48
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('part', '0004_bomitem_note'),
]
operations = [
migrations.AddField(
model_name='part',
name='consumable',
field=models.BooleanField(default=False, help_text='Can this part be used to build other parts?'),
),
]

View File

@ -126,9 +126,12 @@ class Part(models.Model):
# Units of quantity for this part. Default is "pcs"
units = models.CharField(max_length=20, default="pcs", blank=True)
# Can this part be built?
# Can this part be built from other parts?
buildable = models.BooleanField(default=False, help_text='Can this part be built from other parts?')
# Can this part be used to make other parts?
consumable = models.BooleanField(default=True, help_text='Can this part be used to build other parts?')
# Is this part "trackable"?
# Trackable parts can have unique instances
# which are assigned serial numbers (or batch numbers)
@ -278,6 +281,73 @@ class Part(models.Model):
# Return the number of supplier parts available for this part
return self.supplier_parts.count()
def export_bom(self, **kwargs):
# Construct the export data
header = []
header.append('Part')
header.append('Description')
header.append('Quantity')
header.append('Note')
rows = []
for it in self.bom_items.all():
line = []
line.append(it.sub_part.name)
line.append(it.sub_part.description)
line.append(it.quantity)
line.append(it.note)
rows.append([str(x) for x in line])
file_format = kwargs.get('format', 'csv').lower()
kwargs['header'] = header
kwargs['rows'] = rows
if file_format == 'csv':
return self.export_bom_csv(**kwargs)
elif file_format in ['xls', 'xlsx']:
return self.export_bom_xls(**kwargs)
elif file_format == 'xml':
return self.export_bom_xml(**kwargs)
elif file_format in ['htm', 'html']:
return self.export_bom_htm(**kwargs)
elif file_format == 'pdf':
return self.export_bom_pdf(**kwargs)
else:
return None
def export_bom_csv(self, **kwargs):
# Construct header line
header = kwargs.get('header')
rows = kwargs.get('rows')
# TODO - Choice of formatters goes here?
out = ','.join(header)
for row in rows:
out += '\n'
out += ','.join(row)
return out
def export_bom_xls(self, **kwargs):
return ''
def export_bom_xml(self, **kwargs):
return ''
def export_bom_htm(self, **kwargs):
return ''
def export_bom_pdf(self, **kwargs):
return ''
"""
@property
def projects(self):
@ -338,11 +408,15 @@ class BomItem(models.Model):
# A link to the child item (sub-part)
# Each part will get a reverse lookup field 'used_in'
sub_part = models.ForeignKey(Part, on_delete=models.CASCADE, related_name='used_in')
sub_part = models.ForeignKey(Part, on_delete=models.CASCADE, related_name='used_in',
limit_choices_to={'consumable': True})
# Quantity required
quantity = models.PositiveIntegerField(default=1, validators=[MinValueValidator(0)])
# Note attached to this BOM line item
note = models.CharField(max_length=100, blank=True, help_text='Item notes')
def clean(self):
# A part cannot refer to itself in its BOM

View File

@ -43,7 +43,7 @@ class PartSerializer(serializers.ModelSerializer):
"""
url = serializers.CharField(source='get_absolute_url', read_only=True)
category = CategorySerializer(many=False, read_only=True)
category_name = serializers.CharField(source='category_path', read_only=True)
class Meta:
model = Part
@ -55,11 +55,13 @@ class PartSerializer(serializers.ModelSerializer):
'URL', # Link to an external URL (optional)
'description',
'category',
'category_name',
'total_stock',
'available_stock',
'units',
'trackable',
'buildable',
'consumable',
'trackable',
'salable',
]
@ -79,7 +81,8 @@ class BomItemSerializer(serializers.ModelSerializer):
'url',
'part',
'sub_part',
'quantity'
'quantity',
'note',
]

View File

@ -11,109 +11,72 @@
<h3>Bill of Materials</h3>
<table class='table table-striped table-condensed' id='bom-table'>
</table>
<div class='container-fluid'>
<button type='button' class='btn btn-success' id='new-bom-item'>Add BOM Item</button>
<div id='button-toolbar'>
{% if editing_enabled %}
<div class='btn-group' style='float: right;'>
<button class='btn btn-info' type='button' id='bom-item-new'>New BOM Item</button>
<button class='btn btn-success' type='button' id='editing-finished'>Finish Editing</button>
</div>
{% else %}
<div class='dropdown' style="float: right;">
<button class='btn btn-primary dropdown-toggle' type='button' data-toggle='dropdown'>
Options
<span class='caret'></span>
</button>
<ul class='dropdown-menu'>
<li><a href='#' id='edit-bom' title='Edit BOM'>Edit BOM</a></li>
<li><a href='#' id='export-bom' title='Export BOM'>Export BOM</a></li>
</ul>
</div>
{% endif %}
</div>
<table class='table table-striped table-condensed' data-toolbar="#button-toolbar" id='bom-table'>
</table>
{% endblock %}
{% block js_load %}
{{ block.super }}
<script type='text/javascript' src="{% static 'script/inventree/api.js' %}"></script>
<script type='text/javascript' src="{% static 'script/inventree/part.js' %}"></script>
<script type='text/javascript' src="{% static 'script/inventree/bom.js' %}"></script>
{% endblock %}
{% block js_ready %}
{{ block.super }}
function reloadBom() {
$("#bom-table").bootstrapTable('refresh');
}
$('#bom-table').on('click', '.delete-button', function () {
var button = $(this);
launchDeleteForm(
button.attr('url'),
{
success: reloadBom
});
// Load the BOM table data
loadBomTable($("#bom-table"), {
editable: {{ editing_enabled }},
bom_url: "{% url 'api-bom-list' %}",
part_url: "{% url 'api-part-list' %}",
parent_id: {{ part.id }}
});
$("#bom-table").on('click', '.edit-button', function () {
var button = $(this);
{% if editing_enabled %}
$("#editing-finished").click(function() {
location.href = "{% url 'part-bom' part.id %}";
});
launchModalForm(
button.attr('url'),
{
success: reloadBom
});
$("#bom-item-new").click(function () {
launchModalForm("{% url 'bom-item-create' %}?parent={{ part.id }}", {});
});
{% else %}
$("#edit-bom").click(function () {
location.href = "{% url 'part-bom' part.id %}?edit=True";
});
$("#export-bom").click(function () {
downloadBom({
modal: '#modal-form',
url: "{% url 'bom-export' part.id %}"
});
});
$("#new-bom-item").click(function () {
launchModalForm(
"{% url 'bom-item-create' %}",
{
reload: true,
data: {
parent: {{ part.id }}
}
});
});
{% endif %}
$("#bom-table").bootstrapTable({
sortable: true,
search: true,
queryParams: function(p) {
return {
part: {{ part.id }}
}
},
columns: [
{
field: 'pk',
title: 'ID',
visible: false,
},
{
field: 'sub_part',
title: 'Part',
sortable: true,
formatter: function(value, row, index, field) {
return renderLink(value.name, value.url);
}
},
{
field: 'sub_part.description',
title: 'Description',
},
{
field: 'quantity',
title: 'Required',
searchable: false,
sortable: true
},
{
field: 'sub_part.available_stock',
title: 'Available',
searchable: false,
sortable: true,
formatter: function(value, row, index, field) {
var text = "";
if (row.quantity < row.sub_part.available_stock)
{
text = "<span class='label label-success'>" + value + "</span>";
}
else
{
text = "<span class='label label-warning'>" + value + "</span>";
}
return renderLink(text, row.sub_part.url + "stock/");
}
},
{
formatter: function(value, row, index, field) {
return editButton(row.url + 'edit') + ' ' + deleteButton(row.url + 'delete');
}
}
],
url: "{% url 'api-bom-list' %}"
});
{% endblock %}

View File

@ -6,34 +6,13 @@
<h3>Part Builds</h3>
<table class='table table-striped'>
<tr>
<th>Title</th>
<th>Quantity</th>
<th>Status</th>
<th>Completion Date</th>
</tr>
{% if part.active_builds|length > 0 %}
<tr>
<td colspan="4"><b>Active Builds</b></td>
</tr>
{% include "part/build_list.html" with builds=part.active_builds %}
{% endif %}
{% if part.inactive_builds|length > 0 %}
<tr><td colspan="4"></td></tr>
<tr>
<td colspan="4"><b>Inactive Builds</b></td>
</tr>
{% include "part/build_list.html" with builds=part.inactive_builds %}
{% endif %}
<div id='button-toolbar'>
<button class="btn btn-success" id='start-build'>Start New Build</button>
</div>
<table class='table table-striped table-condensed' data-toolbar='#button-toolbar' id='build-table'>
</table>
<div class='container-fluid'>
<button class="btn btn-success" id='start-build'>Start New Build</button>
</div>
{% endblock %}
@ -49,4 +28,43 @@
}
});
});
$("#build-table").bootstrapTable({
sortable: true,
search: true,
pagination: true,
queryParams: function(p) {
return {
part: {{ part.id }},
}
},
columns: [
{
field: 'pk',
title: 'ID',
visible: false,
},
{
field: 'title',
title: 'Title',
formatter: function(value, row, index, field) {
return renderLink(value, row.url);
}
},
{
field: 'quantity',
title: 'Quantity',
},
{
field: 'status',
title: 'Status',
},
{
field: 'completion_date',
title: 'Completed'
}
],
url: "{% url 'api-build-list' %}",
});
{% endblock %}

View File

@ -40,13 +40,13 @@
{% endif %}
<hr>
<table class='table table-striped table-condensed' id='part-table'>
</table>
<div>
<button style='float: right;' class='btn btn-success' id='part-create'>New Part</button>
<div id='button-toolbar'>
<button style='float: right;' class='btn btn-success' id='part-create'>New Part</button>
</div>
<table class='table table-striped table-condensed' data-toolbar='#button-toolbar' id='part-table'>
</table>
{% endblock %}
{% block js_load %}
{{ block.super }}
@ -151,11 +151,11 @@
},
{
sortable: true,
field: 'category',
field: 'category_name',
title: 'Category',
formatter: function(value, row, index, field) {
if (row.category) {
return renderLink(row.category.pathstring, row.category.url);
return renderLink(row.category_name, "/part/category/" + row.category + "/");
}
else {
return '';

View File

@ -32,7 +32,7 @@
</tr>
<tr>
<td>Description</td>
<td>{{ part.decription }}</td>
<td>{{ part.description }}</td>
</tr>
{% if part.IPN %}
<tr>
@ -44,7 +44,7 @@
<td>Category</td>
<td>
{% if part.category %}
<a href="{% url 'category-detail' part.category.id %}">{{ part.category.name }}</a>
<a href="{% url 'category-detail' part.category.id %}">{{ part.category.pathstring }}</a>
{% endif %}
</td>
</tr>
@ -70,6 +70,10 @@
<td>Buildable</td>
<td>{% include "yesnolabel.html" with value=part.buildable %}</td>
</tr>
<tr>
<td>Consumable</td>
<td>{% include "yesnolabel.html" with value=part.consumable %}</td>
</tr>
<tr>
<td>Trackable</td>
<td>{% include "yesnolabel.html" with value=part.trackable %}</td>

View File

@ -37,7 +37,7 @@
</div>
</div>
<div class="col-sm-6">
<h4>Stock Status - {{ part.available_stock }} available</h4>
<h4>Stock Status - {{ part.available_stock }}{% if part.units %} {{ part.units }} {% endif%} available</h4>
<table class="table table-striped">
<tr>
<td>In Stock</td>

View File

@ -4,32 +4,23 @@
{% include 'part/tabs.html' with tab='stock' %}
<div class='row'>
<div class='col-sm-6'>
<h3>Part Stock</h3>
</div>
<div class='col-sm-6 float-right'>
<h3>
<div class='float-right'>
<button class='btn btn-success' id='add-stock-item'>New Stock Item</button>
<div id='opt-dropdown' class="dropdown" style='float: right;'>
<button class="btn btn-primary dropdown-toggle" type="button" data-toggle="dropdown">Options
<span class="caret"></span></button>
<ul class="dropdown-menu">
<li><a href='#' id='multi-item-take' title='Take items from stock'>Take items</a></li>
<li><a href='#' id='multi-item-give' title='Give items to stock'>Add items</a></li>
<li><a href="#" id='multi-item-stocktake' title='Stocktake selected stock items'>Stocktake</a></li>
<li><a href='#' id='multi-item-move' title='Move selected stock items'>Move items</a></li>
</ul>
</div>
</div>
</h3>
<h3>Part Stock</h3>
<div id='button-toolbar'>
<button class='btn btn-success' id='add-stock-item'>New Stock Item</button>
<div id='opt-dropdown' class="dropdown" style='float: right;'>
<button class="btn btn-primary dropdown-toggle" type="button" data-toggle="dropdown">Options
<span class="caret"></span></button>
<ul class="dropdown-menu">
<li><a href='#' id='multi-item-take' title='Take items from stock'>Take items</a></li>
<li><a href='#' id='multi-item-give' title='Give items to stock'>Add items</a></li>
<li><a href="#" id='multi-item-stocktake' title='Stocktake selected stock items'>Stocktake</a></li>
<li><a href='#' id='multi-item-move' title='Move selected stock items'>Move items</a></li>
</ul>
</div>
</div>
<hr>
<table class='table table-striped table-condensed' id='stock-table'>
<table class='table table-striped table-condensed' data-toolbar='#button-toolbar' id='stock-table'>
</table>

View File

@ -4,7 +4,7 @@
<div class="panel panel-default">
<div class="panel-heading">
<h4 class="panel-title">
<a data-toggle="collapse" href="#collapse1">Child Categories</a><span class='badge'>{{ children|length }}</span>
<a data-toggle="collapse" href="#collapse1">{{ children | length }} Child Categories</a>
</h4>
</div>
<div id="collapse1" class="panel-collapse collapse">

View File

@ -4,20 +4,15 @@
{% include 'part/tabs.html' with tab='suppliers' %}
<div class='row'>
<div class='col-sm-6'>
<h3>Part Suppliers</h3>
</div>
<div class='col-sm-6'>
<h3>
<button class="btn btn-success float-right" id='supplier-create'>New Supplier Part</button>
</h3>
</div>
<h3>Part Suppliers</h3>
<div id='button-toolbar'>
<button class="btn btn-success float-right" id='supplier-create'>New Supplier Part</button>
</div>
<hr>
<table class="table table-striped table-condensed" id='supplier-table'>
<table class="table table-striped table-condensed" id='supplier-table' data-toolbar='#button-toolbar'>
</table>
{% endblock %}

View File

@ -7,7 +7,7 @@
<li{% ifequal tab 'build' %} class="active"{% endifequal %}>
<a href="{% url 'part-build' part.id %}">Build<span class='badge'>{{ part.active_builds|length }}</span></a></li>
{% endif %}
{% if part.used_in_count > 0 %}
{% if part.consumable or part.used_in_count > 0 %}
<li{% ifequal tab 'used' %} class="active"{% endifequal %}>
<a href="{% url 'part-used-in' part.id %}">Used In{% if part.used_in_count > 0 %}<span class="badge">{{ part.used_in_count }}</span>{% endif %}</a></li>
{% endif %}

View File

@ -19,6 +19,7 @@ part_detail_urls = [
url(r'^edit/?', views.PartEdit.as_view(), name='part-edit'),
url(r'^delete/?', views.PartDelete.as_view(), name='part-delete'),
url(r'^track/?', views.PartDetail.as_view(template_name='part/track.html'), name='part-track'),
url(r'^bom-export/?', views.BomDownload.as_view(), name='bom-export'),
url(r'^bom/?', views.PartDetail.as_view(template_name='part/bom.html'), name='part-bom'),
url(r'^build/?', views.PartDetail.as_view(template_name='part/build.html'), name='part-build'),
url(r'^stock/?', views.PartDetail.as_view(template_name='part/stock.html'), name='part-stock'),

View File

@ -4,7 +4,6 @@ from __future__ import unicode_literals
from django.shortcuts import get_object_or_404
from django.urls import reverse_lazy
from django.views.generic import DetailView, ListView
from company.models import Company
@ -15,10 +14,13 @@ from .forms import PartImageForm
from .forms import EditPartForm
from .forms import EditCategoryForm
from .forms import EditBomItemForm
from .forms import BomExportForm
from .forms import EditSupplierPartForm
from InvenTree.views import AjaxCreateView, AjaxUpdateView, AjaxDeleteView
from InvenTree.views import AjaxView, AjaxCreateView, AjaxUpdateView, AjaxDeleteView
from InvenTree.helpers import DownloadFile
class PartIndex(ListView):
@ -88,6 +90,17 @@ class PartDetail(DetailView):
queryset = Part.objects.all()
template_name = 'part/detail.html'
# Add in some extra context information based on query params
def get_context_data(self, **kwargs):
context = super(PartDetail, self).get_context_data(**kwargs)
if self.request.GET.get('edit', '').lower() in ['true', 'yes', '1']:
context['editing_enabled'] = 1
else:
context['editing_enabled'] = 0
return context
class PartImage(AjaxUpdateView):
@ -104,10 +117,88 @@ class PartImage(AjaxUpdateView):
class PartEdit(AjaxUpdateView):
model = Part
form_class = EditPartForm
template_name = 'part/edit.html'
form_class = EditPartForm
ajax_template_name = 'modal_form.html'
ajax_form_title = 'Edit Part Properties'
context_object_name = 'part'
class BomExport(AjaxView):
model = Part
ajax_form_title = 'Export BOM'
ajax_template_name = 'part/bom_export.html'
context_object_name = 'part'
form_class = BomExportForm
def get_object(self):
return get_object_or_404(Part, pk=self.kwargs['pk'])
def get(self, request, *args, **kwargs):
form = self.form_class()
"""
part = self.get_object()
context = {
'part': part
}
if request.is_ajax():
passs
"""
return self.renderJsonResponse(request, form)
def post(self, request, *args, **kwargs):
"""
User has now submitted the BOM export data
"""
# part = self.get_object()
return super(AjaxView, self).post(request, *args, **kwargs)
def get_data(self):
return {
# 'form_valid': True,
# 'redirect': '/'
# 'redirect': reverse('bom-download', kwargs={'pk': self.request.GET.get('pk')})
}
class BomDownload(AjaxView):
"""
Provide raw download of a BOM file.
- File format should be passed as a query param e.g. ?format=csv
"""
# TODO - This should no longer extend an AjaxView!
model = Part
# form_class = BomExportForm
# template_name = 'part/bom_export.html'
# ajax_form_title = 'Export Bill of Materials'
# context_object_name = 'part'
def get(self, request, *args, **kwargs):
part = get_object_or_404(Part, pk=self.kwargs['pk'])
export_format = request.GET.get('format', 'csv')
# Placeholder to test file export
filename = '"' + part.name + '_BOM.' + export_format + '"'
filedata = part.export_bom(format=export_format)
return DownloadFile(filedata, filename)
def get_data(self):
return {
'info': 'Exported BOM'
}
class PartDelete(AjaxDeleteView):
@ -115,6 +206,7 @@ class PartDelete(AjaxDeleteView):
template_name = 'part/delete.html'
ajax_template_name = 'part/partial_delete.html'
ajax_form_title = 'Confirm Part Deletion'
context_object_name = 'part'
success_url = '/part/'

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,192 @@
/* BOM management functions.
* Requires follwing files to be loaded first:
* - api.js
* - part.js
* - modals.js
*/
function reloadBomTable(table, options) {
table.bootstrapTable('refresh');
}
function downloadBom(options = {}) {
var modal = options.modal || "#modal-form";
var content = `
<b>Select file format</b><br>
<div class='controls'>
<select id='bom-format' class='select'>
<option value='csv'>CSV</option>
<option value='xls'>XLSX</option>
<option value='pdf'>PDF</option>
<option value='xml'>XML</option>
<option value='htm'>HTML</option>
</select>
</div>
`;
openModal({
modal: modal,
title: "Export Bill of Materials",
submit_text: "Download",
close_text: "Cancel",
});
modalSetContent(modal, content);
$(modal).on('click', '#modal-form-submit', function() {
$(modal).modal('hide');
var format = $(modal).find('#bom-format :selected').val();
if (options.url) {
var url = options.url + "?format=" + format;
location.href = url;
}
});
}
function loadBomTable(table, options) {
/* Load a BOM table with some configurable options.
*
* Following options are available:
* editable - Should the BOM table be editable?
* bom_url - Address to request BOM data from
* part_url - Address to request Part data from
* parent_id - Parent ID of the owning part
*
* BOM data are retrieved from the server via AJAX query
*/
// Construct the table columns
var cols = [
{
field: 'pk',
title: 'ID',
visible: false,
}
];
if (options.editable) {
cols.push({
formatter: function(value, row, index, field) {
var bEdit = "<button class='btn btn-success bom-edit-button btn-sm' type='button' url='" + row.url + "edit'>Edit</button>";
var bDelt = "<button class='btn btn-danger bom-delete-button btn-sm' type='button' url='" + row.url + "delete'>Delete</button>";
return "<div class='btn-group'>" + bEdit + bDelt + "</div>";
}
});
}
// Part column
cols.push(
{
field: 'sub_part',
title: 'Part',
sortable: true,
formatter: function(value, row, index, field) {
return renderLink(value.name, value.url);
}
}
);
// Part description
cols.push(
{
field: 'sub_part.description',
title: 'Description',
}
);
// Part quantity
cols.push(
{
field: 'quantity',
title: 'Required',
searchable: false,
sortable: true,
}
);
// Part notes
cols.push(
{
field: 'note',
title: 'Notes',
searchable: true,
sortable: false,
}
);
// If we are NOT editing, display the available stock
if (!options.editable) {
cols.push(
{
field: 'sub_part.available_stock',
title: 'Available',
searchable: false,
sortable: true,
formatter: function(value, row, index, field) {
var text = "";
if (row.quantity < row.sub_part.available_stock)
{
text = "<span class='label label-success'>" + value + "</span>";
}
else
{
text = "<span class='label label-warning'>" + value + "</span>";
}
return renderLink(text, row.sub_part.url + "stock/");
}
}
);
}
// Configure the table (bootstrap-table)
table.bootstrapTable({
sortable: true,
search: true,
clickToSelect: true,
queryParams: function(p) {
return {
part: options.parent_id,
}
},
columns: cols,
url: options.bom_url
});
// In editing mode, attached editables to the appropriate table elements
if (options.editable) {
table.on('click', '.bom-delete-button', function() {
var button = $(this);
launchDeleteForm(button.attr('url'), {
success: function() {
reloadBomTable(table);
}
});
});
table.on('click', '.bom-edit-button', function() {
var button = $(this);
launchModalForm(button.attr('url'), {
success: function() {
reloadBomTable(table);
}
});
});
}
}

View File

@ -14,5 +14,49 @@ function renderLink(text, url) {
return '<a href="' + url + '">' + text + '</a>';
}
function renderEditable(text, options) {
/* Wrap the text in an 'editable' link
* (using bootstrap-editable library)
*
* Can pass the following parameters in 'options':
* _type - parameter for data-type (default = 'text')
* _pk - parameter for data-pk (required)
* _title - title to show when editing
* _empty - placeholder text to show when field is empty
* _class - html class (default = 'editable-item')
* _id - id
* _value - Initial value of the editable (default = blank)
*/
// Default values (if not supplied)
var _type = options._type || 'text';
var _class = options._class || 'editable-item';
var html = "<a href='#' class='" + _class + "'";
// Add id parameter if provided
if (options._id) {
html = html + " id='" + options._id + "'";
}
html = html + " data-type='" + _type + "'";
html = html + " data-pk='" + options._pk + "'";
if (options._title) {
html = html + " data-title='" + options._title + "'";
}
if (options._value) {
html = html + " data-value='" + options._value + "'";
}
if (options._empty) {
html = html + " data-placeholder='" + options._empty + "'";
html = html + " data-emptytext='" + options._empty + "'";
}
html = html + ">" + text + "</a>";
return html;
}

View File

@ -38,23 +38,26 @@
<hr>
<table class='table table-striped table-condensed' id='stock-table'>
</table>
<div class='container-fluid' style='float: right;'>
<button class="btn btn-success" id='item-create'>New Stock Item</span></button>
<div class="dropdown" style='float: right;'>
<button class="btn btn-primary dropdown-toggle" type="button" data-toggle="dropdown">Options
<span class="caret"></span></button>
<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-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-move' title='Move selected stock items'>Move</a></li>
</ul>
<div id='button-toolbar'>
<div class='container-fluid' style='float: right;'>
<button class="btn btn-success" id='item-create'>New Stock Item</span></button>
<div class="dropdown" style='float: right;'>
<button class="btn btn-primary dropdown-toggle" type="button" data-toggle="dropdown">Options
<span class="caret"></span></button>
<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-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-move' title='Move selected stock items'>Move</a></li>
</ul>
</div>
</div>
</div>
<table class='table table-striped table-condensed' data-toolbar='#button-toolbar' id='stock-table'>
</table>
{% include 'modals.html' %}
{% endblock %}

View File

@ -80,7 +80,7 @@ class StockItemEdit(AjaxUpdateView):
model = StockItem
form_class = EditStockItemForm
template_name = 'stock/item_edit.html'
# template_name = 'stock/item_edit.html'
context_object_name = 'item'
ajax_template_name = 'modal_form.html'
ajax_form_title = 'Edit Stock Item'

View File

@ -10,8 +10,9 @@
<!-- CSS -->
<link rel="stylesheet" href="{% static 'css/bootstrap_3.3.7_css_bootstrap.min.css' %}">
<link rel="stylesheet" href="{% static 'css/select2.css' %}">
<link rel="stylesheet" href="{% static 'css/bootstrap-table.css' %}">
<link rel="stylesheet" href="{% static 'css/select2.css' %}">
<link rel="stylesheet" href="{% static 'css/select2-bootstrap.css' %}">
<link rel="stylesheet" href="{% static 'css/inventree.css' %}">
{% block css %}