2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-05-01 13:06:45 +00:00

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

View File

@ -65,9 +65,7 @@ class AjaxMixin(object):
else: else:
return self.template_name return self.template_name
def renderJsonResponse(self, request, form, data={}): def renderJsonResponse(self, request, form=None, data={}, context={}):
context = {}
if form: if form:
context['form'] = form context['form'] = form
@ -92,22 +90,46 @@ class AjaxMixin(object):
class AjaxView(AjaxMixin, View): class AjaxView(AjaxMixin, View):
""" Bare-bones AjaxView """ """ 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): def post(self, request, *args, **kwargs):
return JsonResponse('', safe=False) return JsonResponse('', safe=False)
def get(self, request, *args, **kwargs): def get(self, request, *args, **kwargs):
return self.renderJsonResponse(request, None) return self.renderJsonResponse(request)
class AjaxCreateView(AjaxMixin, CreateView): 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): def post(self, request, *args, **kwargs):
form = self.form_class(data=request.POST, files=request.FILES) form = self.form_class(data=request.POST, files=request.FILES)
if request.is_ajax(): if request.is_ajax():
data = {'form_valid': form.is_valid()} data = {
'form_valid': form.is_valid(),
}
if form.is_valid(): if form.is_valid():
obj = form.save() obj = form.save()
@ -122,20 +144,25 @@ class AjaxCreateView(AjaxMixin, CreateView):
else: else:
return super(CreateView, self).post(request, *args, **kwargs) 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): 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(): 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) return self.renderJsonResponse(request, form)
else: else:
return response return html_response
class AjaxUpdateView(AjaxMixin, UpdateView):
def post(self, request, *args, **kwargs): def post(self, request, *args, **kwargs):
@ -154,45 +181,26 @@ class AjaxUpdateView(AjaxMixin, UpdateView):
response = self.renderJsonResponse(request, form, data) response = self.renderJsonResponse(request, form, data)
return response 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: else:
return super(UpdateView, self).post(request, *args, **kwargs) return super(UpdateView, self).post(request, *args, **kwargs)
class AjaxDeleteView(AjaxMixin, DeleteView): class AjaxDeleteView(AjaxMixin, DeleteView):
def post(self, 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)
if request.is_ajax(): - Handles deletion
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)
def get(self, request, *args, **kwargs): 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(): if request.is_ajax():
data = {'id': self.get_object().id, data = {'id': self.get_object().id,
'title': self.ajax_form_title,
'delete': False, 'delete': False,
'title': self.ajax_form_title,
'html_data': render_to_string(self.getAjaxTemplate(), 'html_data': render_to_string(self.getAjaxTemplate(),
self.get_context_data(), self.get_context_data(),
request=request) request=request)
@ -201,7 +209,23 @@ class AjaxDeleteView(AjaxMixin, DeleteView):
return JsonResponse(data) return JsonResponse(data)
else: 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): 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> <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> <thead>
<tr> <tr>
<th>Build</th> <th>Build</th>
@ -20,9 +24,6 @@
</tbody> </tbody>
</table> </table>
<div class='container-fluid'>
<button class="btn btn-success" id='new-build'>Start New Build</button>
</div>
{% include 'modals.html' %} {% include 'modals.html' %}

View File

@ -4,12 +4,9 @@
{% include 'company/tabs.html' with tab='parts' %} {% include 'company/tabs.html' with tab='parts' %}
<div class='row'>
<div class='col-sm-6'>
<h3>Supplier Parts</h3> <h3>Supplier Parts</h3>
</div>
<div class='col-sm-6'> <div id='button-toolbar'>
<h3 class='float-right'>
<button class="btn btn-success" id='part-create'>New Supplier Part</button> <button class="btn btn-success" id='part-create'>New Supplier Part</button>
<div class="dropdown" style="float: right;"> <div class="dropdown" style="float: right;">
<button class="btn btn-primary dropdown-toggle" type="button" data-toggle="dropdown">Options <button class="btn btn-primary dropdown-toggle" type="button" data-toggle="dropdown">Options
@ -17,13 +14,11 @@
<ul class="dropdown-menu"> <ul class="dropdown-menu">
</ul> </ul>
</div> </div>
</h3>
</div>
</div> </div>
<hr> <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> </table>
{% endblock %} {% endblock %}

View File

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

View File

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

View File

@ -3,6 +3,8 @@ from __future__ import unicode_literals
from InvenTree.forms import HelperForm from InvenTree.forms import HelperForm
from django import forms
from .models import Part, PartCategory, BomItem from .models import Part, PartCategory, BomItem
from .models import SupplierPart 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 EditPartForm(HelperForm):
class Meta: class Meta:
@ -28,8 +51,10 @@ class EditPartForm(HelperForm):
'URL', 'URL',
'default_location', 'default_location',
'default_supplier', 'default_supplier',
'units',
'minimum_stock', 'minimum_stock',
'buildable', 'buildable',
'consumable',
'trackable', 'trackable',
'purchaseable', 'purchaseable',
'salable', 'salable',
@ -56,8 +81,10 @@ class EditBomItemForm(HelperForm):
fields = [ fields = [
'part', 'part',
'sub_part', 'sub_part',
'quantity' 'quantity',
'note'
] ]
widgets = {'part': forms.HiddenInput()}
class EditSupplierPartForm(HelperForm): 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 of quantity for this part. Default is "pcs"
units = models.CharField(max_length=20, default="pcs", blank=True) 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?') 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"? # Is this part "trackable"?
# Trackable parts can have unique instances # Trackable parts can have unique instances
# which are assigned serial numbers (or batch numbers) # 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 the number of supplier parts available for this part
return self.supplier_parts.count() 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 @property
def projects(self): def projects(self):
@ -338,11 +408,15 @@ class BomItem(models.Model):
# A link to the child item (sub-part) # A link to the child item (sub-part)
# Each part will get a reverse lookup field 'used_in' # 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 required
quantity = models.PositiveIntegerField(default=1, validators=[MinValueValidator(0)]) 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): def clean(self):
# A part cannot refer to itself in its BOM # 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) 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: class Meta:
model = Part model = Part
@ -55,11 +55,13 @@ class PartSerializer(serializers.ModelSerializer):
'URL', # Link to an external URL (optional) 'URL', # Link to an external URL (optional)
'description', 'description',
'category', 'category',
'category_name',
'total_stock', 'total_stock',
'available_stock', 'available_stock',
'units', 'units',
'trackable', 'trackable',
'buildable', 'buildable',
'consumable',
'trackable', 'trackable',
'salable', 'salable',
] ]
@ -79,7 +81,8 @@ class BomItemSerializer(serializers.ModelSerializer):
'url', 'url',
'part', 'part',
'sub_part', 'sub_part',
'quantity' 'quantity',
'note',
] ]

View File

@ -11,109 +11,72 @@
<h3>Bill of Materials</h3> <h3>Bill of Materials</h3>
<table class='table table-striped table-condensed' id='bom-table'> <div id='button-toolbar'>
</table> {% if editing_enabled %}
<div class='btn-group' style='float: right;'>
<div class='container-fluid'> <button class='btn btn-info' type='button' id='bom-item-new'>New BOM Item</button>
<button type='button' class='btn btn-success' id='new-bom-item'>Add 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> </div>
<table class='table table-striped table-condensed' data-toolbar="#button-toolbar" id='bom-table'>
</table>
{% endblock %} {% 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 js_ready %}
{{ block.super }} {{ block.super }}
function reloadBom() {
$("#bom-table").bootstrapTable('refresh');
}
$('#bom-table').on('click', '.delete-button', function () { // Load the BOM table data
var button = $(this); loadBomTable($("#bom-table"), {
editable: {{ editing_enabled }},
launchDeleteForm( bom_url: "{% url 'api-bom-list' %}",
button.attr('url'), part_url: "{% url 'api-part-list' %}",
{ parent_id: {{ part.id }}
success: reloadBom
});
}); });
$("#bom-table").on('click', '.edit-button', function () { {% if editing_enabled %}
var button = $(this); $("#editing-finished").click(function() {
location.href = "{% url 'part-bom' part.id %}";
});
launchModalForm( $("#bom-item-new").click(function () {
button.attr('url'), launchModalForm("{% url 'bom-item-create' %}?parent={{ part.id }}", {});
{ });
success: reloadBom
{% 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 () { {% endif %}
launchModalForm(
"{% url 'bom-item-create' %}",
{
reload: true,
data: {
parent: {{ part.id }}
}
});
});
$("#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 %} {% endblock %}

View File

@ -6,35 +6,14 @@
<h3>Part Builds</h3> <h3>Part Builds</h3>
<table class='table table-striped'> <div id='button-toolbar'>
<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 %}
</table>
<div class='container-fluid'>
<button class="btn btn-success" id='start-build'>Start New Build</button> <button class="btn btn-success" id='start-build'>Start New Build</button>
</div> </div>
<table class='table table-striped table-condensed' data-toolbar='#button-toolbar' id='build-table'>
</table>
{% endblock %} {% endblock %}
{% block js_ready %} {% block js_ready %}
@ -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 %} {% endblock %}

View File

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

View File

@ -32,7 +32,7 @@
</tr> </tr>
<tr> <tr>
<td>Description</td> <td>Description</td>
<td>{{ part.decription }}</td> <td>{{ part.description }}</td>
</tr> </tr>
{% if part.IPN %} {% if part.IPN %}
<tr> <tr>
@ -44,7 +44,7 @@
<td>Category</td> <td>Category</td>
<td> <td>
{% if part.category %} {% 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 %} {% endif %}
</td> </td>
</tr> </tr>
@ -70,6 +70,10 @@
<td>Buildable</td> <td>Buildable</td>
<td>{% include "yesnolabel.html" with value=part.buildable %}</td> <td>{% include "yesnolabel.html" with value=part.buildable %}</td>
</tr> </tr>
<tr>
<td>Consumable</td>
<td>{% include "yesnolabel.html" with value=part.consumable %}</td>
</tr>
<tr> <tr>
<td>Trackable</td> <td>Trackable</td>
<td>{% include "yesnolabel.html" with value=part.trackable %}</td> <td>{% include "yesnolabel.html" with value=part.trackable %}</td>

View File

@ -37,7 +37,7 @@
</div> </div>
</div> </div>
<div class="col-sm-6"> <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"> <table class="table table-striped">
<tr> <tr>
<td>In Stock</td> <td>In Stock</td>

View File

@ -4,13 +4,9 @@
{% include 'part/tabs.html' with tab='stock' %} {% include 'part/tabs.html' with tab='stock' %}
<div class='row'>
<div class='col-sm-6'>
<h3>Part Stock</h3> <h3>Part Stock</h3>
</div>
<div class='col-sm-6 float-right'> <div id='button-toolbar'>
<h3>
<div class='float-right'>
<button class='btn btn-success' id='add-stock-item'>New Stock Item</button> <button class='btn btn-success' id='add-stock-item'>New Stock Item</button>
<div id='opt-dropdown' class="dropdown" style='float: right;'> <div id='opt-dropdown' class="dropdown" style='float: right;'>
<button class="btn btn-primary dropdown-toggle" type="button" data-toggle="dropdown">Options <button class="btn btn-primary dropdown-toggle" type="button" data-toggle="dropdown">Options
@ -23,13 +19,8 @@
</ul> </ul>
</div> </div>
</div> </div>
</h3>
</div>
</div>
<hr> <table class='table table-striped table-condensed' data-toolbar='#button-toolbar' id='stock-table'>
<table class='table table-striped table-condensed' id='stock-table'>
</table> </table>

View File

@ -4,7 +4,7 @@
<div class="panel panel-default"> <div class="panel panel-default">
<div class="panel-heading"> <div class="panel-heading">
<h4 class="panel-title"> <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> </h4>
</div> </div>
<div id="collapse1" class="panel-collapse collapse"> <div id="collapse1" class="panel-collapse collapse">

View File

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

View File

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

View File

@ -19,6 +19,7 @@ part_detail_urls = [
url(r'^edit/?', views.PartEdit.as_view(), name='part-edit'), url(r'^edit/?', views.PartEdit.as_view(), name='part-edit'),
url(r'^delete/?', views.PartDelete.as_view(), name='part-delete'), 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'^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'^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'^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'), 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.shortcuts import get_object_or_404
from django.urls import reverse_lazy from django.urls import reverse_lazy
from django.views.generic import DetailView, ListView from django.views.generic import DetailView, ListView
from company.models import Company from company.models import Company
@ -15,10 +14,13 @@ from .forms import PartImageForm
from .forms import EditPartForm from .forms import EditPartForm
from .forms import EditCategoryForm from .forms import EditCategoryForm
from .forms import EditBomItemForm from .forms import EditBomItemForm
from .forms import BomExportForm
from .forms import EditSupplierPartForm 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): class PartIndex(ListView):
@ -88,6 +90,17 @@ class PartDetail(DetailView):
queryset = Part.objects.all() queryset = Part.objects.all()
template_name = 'part/detail.html' 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): class PartImage(AjaxUpdateView):
@ -104,10 +117,88 @@ class PartImage(AjaxUpdateView):
class PartEdit(AjaxUpdateView): class PartEdit(AjaxUpdateView):
model = Part model = Part
form_class = EditPartForm
template_name = 'part/edit.html' template_name = 'part/edit.html'
form_class = EditPartForm
ajax_template_name = 'modal_form.html' ajax_template_name = 'modal_form.html'
ajax_form_title = 'Edit Part Properties' 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): class PartDelete(AjaxDeleteView):
@ -115,6 +206,7 @@ class PartDelete(AjaxDeleteView):
template_name = 'part/delete.html' template_name = 'part/delete.html'
ajax_template_name = 'part/partial_delete.html' ajax_template_name = 'part/partial_delete.html'
ajax_form_title = 'Confirm Part Deletion' ajax_form_title = 'Confirm Part Deletion'
context_object_name = 'part'
success_url = '/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>'; 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,9 +38,7 @@
<hr> <hr>
<table class='table table-striped table-condensed' id='stock-table'> <div id='button-toolbar'>
</table>
<div class='container-fluid' style='float: right;'> <div class='container-fluid' style='float: right;'>
<button class="btn btn-success" id='item-create'>New Stock Item</span></button> <button class="btn btn-success" id='item-create'>New Stock Item</span></button>
<div class="dropdown" style='float: right;'> <div class="dropdown" style='float: right;'>
@ -54,6 +52,11 @@
</ul> </ul>
</div> </div>
</div> </div>
</div>
<table class='table table-striped table-condensed' data-toolbar='#button-toolbar' id='stock-table'>
</table>
{% include 'modals.html' %} {% include 'modals.html' %}

View File

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

View File

@ -10,8 +10,9 @@
<!-- CSS --> <!-- CSS -->
<link rel="stylesheet" href="{% static 'css/bootstrap_3.3.7_css_bootstrap.min.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/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' %}"> <link rel="stylesheet" href="{% static 'css/inventree.css' %}">
{% block css %} {% block css %}