Merge remote-tracking branch 'inventree/master'

This commit is contained in:
Oliver Walters 2019-05-08 22:09:40 +10:00
commit 0deb6e8bea
32 changed files with 535 additions and 253 deletions

View File

@ -0,0 +1,19 @@
# Generated by Django 2.2 on 2019-05-07 21:48
import django.core.validators
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('build', '0010_auto_20190505_2233'),
]
operations = [
migrations.AlterField(
model_name='build',
name='status',
field=models.PositiveIntegerField(choices=[(10, 'Pending'), (20, 'Allocated'), (30, 'Cancelled'), (40, 'Complete')], default=10, validators=[django.core.validators.MinValueValidator(0)]),
),
]

View File

@ -13,9 +13,11 @@ from django.core.exceptions import ValidationError
from django.urls import reverse from django.urls import reverse
from django.db import models, transaction from django.db import models, transaction
from django.db.models import Sum
from django.core.validators import MinValueValidator from django.core.validators import MinValueValidator
from stock.models import StockItem from stock.models import StockItem
from part.models import BomItem
class Build(models.Model): class Build(models.Model):
@ -33,49 +35,6 @@ class Build(models.Model):
notes: Text notes notes: Text notes
""" """
def save(self, *args, **kwargs):
""" Called when the Build model is saved to the database.
If this is a new Build, try to allocate StockItem objects automatically.
- If there is only one StockItem for a Part, use that one.
- If there are multiple StockItem objects, leave blank and let the user decide
"""
allocate_parts = False
# If there is no PK yet, then this is the first time the Build has been saved
if not self.pk:
allocate_parts = True
# Save this Build first
super(Build, self).save(*args, **kwargs)
if allocate_parts:
for item in self.part.bom_items.all():
part = item.sub_part
# Number of parts required for this build
q_req = item.quantity * self.quantity
stock = StockItem.objects.filter(part=part)
if len(stock) == 1:
stock_item = stock[0]
# Are there any parts available?
if stock_item.quantity > 0:
# If there are not enough parts, reduce the amount we will take
if stock_item.quantity < q_req:
q_req = stock_item.quantity
# Allocate parts to this build
build_item = BuildItem(
build=self,
stock_item=stock_item,
quantity=q_req)
build_item.save()
def __str__(self): def __str__(self):
return "Build {q} x {part}".format(q=self.quantity, part=str(self.part)) return "Build {q} x {part}".format(q=self.quantity, part=str(self.part))
@ -103,11 +62,13 @@ class Build(models.Model):
# Build status codes # Build status codes
PENDING = 10 # Build is pending / active PENDING = 10 # Build is pending / active
ALLOCATED = 20 # Parts have been removed from stock
CANCELLED = 30 # Build was cancelled CANCELLED = 30 # Build was cancelled
COMPLETE = 40 # Build is complete COMPLETE = 40 # Build is complete
#: Build status codes #: Build status codes
BUILD_STATUS_CODES = {PENDING: _("Pending"), BUILD_STATUS_CODES = {PENDING: _("Pending"),
ALLOCATED: _("Allocated"),
CANCELLED: _("Cancelled"), CANCELLED: _("Cancelled"),
COMPLETE: _("Complete"), COMPLETE: _("Complete"),
} }
@ -153,6 +114,68 @@ class Build(models.Model):
self.status = self.CANCELLED self.status = self.CANCELLED
self.save() self.save()
def getAutoAllocations(self):
""" Return a list of parts which will be allocated
using the 'AutoAllocate' function.
For each item in the BOM for the attached Part:
- If there is a single StockItem, use that StockItem
- Take as many parts as available (up to the quantity required for the BOM)
- If there are multiple StockItems available, ignore (leave up to the user)
Returns:
A dict object containing the StockItem objects to be allocated (and the quantities)
"""
allocations = {}
for item in self.part.bom_items.all():
# How many parts required for this build?
q_required = item.quantity * self.quantity
stock = StockItem.objects.filter(part=item.sub_part)
# Only one StockItem to choose from? Default to that one!
if len(stock) == 1:
stock_item = stock[0]
# Check that we have not already allocated this stock-item against this build
build_items = BuildItem.objects.filter(build=self, stock_item=stock_item)
if len(build_items) > 0:
continue
# Are there any parts available?
if stock_item.quantity > 0:
# Only take as many as are available
if stock_item.quantity < q_required:
q_required = stock_item.quantity
# Add the item to the allocations list
allocations[stock_item] = q_required
return allocations
@transaction.atomic
def autoAllocate(self):
""" Run auto-allocation routine to allocate StockItems to this Build.
See: getAutoAllocations()
"""
allocations = self.getAutoAllocations()
for item in allocations:
# Create a new allocation
build_item = BuildItem(
build=self,
stock_item=item,
quantity=allocations[item])
build_item.save()
@transaction.atomic @transaction.atomic
def completeBuild(self, location, user): def completeBuild(self, location, user):
""" Mark the Build as COMPLETE """ Mark the Build as COMPLETE
@ -200,6 +223,41 @@ class Build(models.Model):
self.status = self.COMPLETE self.status = self.COMPLETE
self.save() self.save()
def getRequiredQuantity(self, part):
""" Calculate the quantity of <part> required to make this build.
"""
try:
item = BomItem.objects.get(part=self.part.id, sub_part=part.id)
return item.quantity * self.quantity
except BomItem.DoesNotExist:
return 0
def getAllocatedQuantity(self, part):
""" Calculate the total number of <part> currently allocated to this build
"""
allocated = BuildItem.objects.filter(build=self.id, stock_item__part=part.id).aggregate(Sum('quantity'))
q = allocated['quantity__sum']
if q:
return int(q)
else:
return 0
def getUnallocatedQuantity(self, part):
""" Calculate the quantity of <part> which still needs to be allocated to this build.
Args:
Part - the part to be tested
Returns:
The remaining allocated quantity
"""
return max(self.getRequiredQuantity(part) - self.getAllocatedQuantity(part), 0)
@property @property
def required_parts(self): def required_parts(self):
""" Returns a dict of parts required to build this part (BOM) """ """ Returns a dict of parts required to build this part (BOM) """
@ -208,7 +266,8 @@ class Build(models.Model):
for item in self.part.bom_items.all(): for item in self.part.bom_items.all():
part = {'part': item.sub_part, part = {'part': item.sub_part,
'per_build': item.quantity, 'per_build': item.quantity,
'quantity': item.quantity * self.quantity 'quantity': item.quantity * self.quantity,
'allocated': self.getAllocatedQuantity(item.sub_part)
} }
parts.append(part) parts.append(part)

View File

@ -6,17 +6,24 @@
<h3>Allocate Parts for Build</h3> <h3>Allocate Parts for Build</h3>
<h4><a href="{% url 'build-detail' build.id %}">{{ build.title }}</a></h4> <div class='row'>
{{ build.quantity }} x {{ build.part.name }} <div class='col-sm-6'>
<h4><a href="{% url 'build-detail' build.id %}">{{ build.title }}</a></h4>
{{ build.quantity }} x {{ build.part.name }}
</div>
<div class='col-sm-6'>
<div class='btn-group' style='float: right;'>
<button class='btn btn-warning' type='button' id='complete-build'>Complete Build</button>
</div>
</div>
</div>
<hr> <hr>
{% for bom_item in bom_items.all %} {% for bom_item in bom_items.all %}
{% include "build/allocation_item.html" with item=bom_item build=build collapse_id=bom_item.id %} {% include "build/allocation_item.html" with item=bom_item build=build collapse_id=bom_item.id %}
{% endfor %} {% endfor %}
<div>
<button class='btn btn-warning' type='button' id='complete-build'>Complete Build</button>
</div>
{% endblock %} {% endblock %}

View File

@ -1,9 +1,23 @@
{% extends "collapse.html" %} {% extends "collapse.html" %}
{% load static %}
{% load inventree_extras %} {% load inventree_extras %}
{% block collapse_title %} {% block collapse_title %}
{{ item.sub_part.name }} <div>
<div class='media-left'>
<img class='part-thumb' style='height: auto; width: 48px;'
{% if item.sub_part.image %}
src="{{ item.sub_part.image.url }}" alt='{{ item.sub_part.name }}'>
{% else %}
src="{% static 'img/blank_image.png' %}" alt='No image'>
{% endif %}
</div>
<div class='media-body'>
{{ item.sub_part.name }}<br>
<small><i>{{ item.sub_part.description }}</i></small>
</div>
</div>
{% endblock %} {% endblock %}
{% block collapse_heading %} {% block collapse_heading %}

View File

@ -12,6 +12,11 @@
<th>Part</th> <th>Part</th>
<th>Quantity</th> <th>Quantity</th>
<th>Status</th> <th>Status</th>
{% if completed %}
<th>Completed</th>
{% else %}
<th>Created</th>
{% endif %}
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@ -21,6 +26,11 @@
<td><a href="{% url 'part-build' build.part.id %}">{{ build.part.name }}</a></td> <td><a href="{% url 'part-build' build.part.id %}">{{ build.part.name }}</a></td>
<td>{{ build.quantity }}</td> <td>{{ build.quantity }}</td>
<td>{% include "build_status.html" with build=build %} <td>{% include "build_status.html" with build=build %}
{% if completed %}
<td>{{ build.completion_date }}<span class='badge'>{{ build.completed_by.username }}</span></td>
{% else %}
<td>{{ build.creation_date }}</td>
{% endif %}
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>

View File

@ -85,7 +85,8 @@
<tr> <tr>
<th>Part</th> <th>Part</th>
<th>Required</th> <th>Required</th>
<th>Stock</th> <th>Available</th>
<th>Allocated</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@ -94,6 +95,7 @@
<td><a href="{% url 'part-detail' item.part.id %}">{{ item.part.name }}</a></td> <td><a href="{% url 'part-detail' item.part.id %}">{{ item.part.name }}</a></td>
<td>{{ item.quantity }}</td> <td>{{ item.quantity }}</td>
<td>{{ item.part.total_stock }}</td> <td>{{ item.part.total_stock }}</td>
<td>{{ item.allocated }}</td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>

View File

@ -3,39 +3,26 @@
{% block content %} {% block content %}
<h4>Active Builds</h4> <div class='row'>
<div class='col-sm-6'>
<div id='active-build-toolbar'> <h3>Part Builds</h3>
<div class='btn-group'> </div>
<button class="btn btn-success" id='new-build'>Start New Build</button> <div class='col-sm-6'>
<div class='container' id='active-build-toolbar' style='float: right;'>
<div class='btn-group' style='float: right;'>
<button type='button' class="btn btn-success" id='new-build'>Start New Build</button>
</div>
</div>
</div> </div>
</div> </div>
<table class='table table-striped table-condensed build-table' id='build-table-{{collapse_id}}' data-toolbar='#active-build-toolbar'> <hr>
<thead>
<tr>
<th>Build</th>
<th>Part</th>
<th>Quantity</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{% for build in active %}
<tr>
<td><a href="{% url 'build-detail' build.id %}">{{ build.title }}</a></td>
<td><a href="{% url 'part-build' build.part.id %}">{{ build.part.name }}</a></td>
<td>{{ build.quantity }}</td>
<td>{% include "build_status.html" with build=build %}
</tr>
{% endfor %}
</tbody>
</table>
<p></p> {% include "build/build_list.html" with builds=active title="Active Builds" completed=False collapse_id='active' %}
{% include "build/build_list.html" with builds=completed title="Completed Builds" collapse_id="complete" %}
<p></p> {% include "build/build_list.html" with builds=completed completed=True title="Completed Builds" collapse_id="complete" %}
{% include "build/build_list.html" with builds=cancelled title="Cancelled Builds" collapse_id="cancelled" %}
{% include "build/build_list.html" with builds=cancelled title="Cancelled Builds" completed=False collapse_id="cancelled" %}
{% include 'modals.html' %} {% include 'modals.html' %}
@ -43,6 +30,9 @@
{% block js_ready %} {% block js_ready %}
{{ block.super }} {{ block.super }}
$("#collapse-item-active").collapse().show();
$("#new-build").click(function() { $("#new-build").click(function() {
launchModalForm( launchModalForm(
"{% url 'build-create' %}", "{% url 'build-create' %}",
@ -74,7 +64,10 @@
{ {
title: 'Status', title: 'Status',
sortable: true, sortable: true,
} },
{
sortable: true,
},
] ]
}); });

View File

@ -289,6 +289,16 @@ class BuildItemCreate(AjaxCreateView):
query = query.exclude(id__in=[item.stock_item.id for item in BuildItem.objects.filter(build=build_id, stock_item__part=part_id)]) query = query.exclude(id__in=[item.stock_item.id for item in BuildItem.objects.filter(build=build_id, stock_item__part=part_id)])
form.fields['stock_item'].queryset = query form.fields['stock_item'].queryset = query
stocks = query.all()
# If there is only one item selected, select it
if len(stocks) == 1:
form.fields['stock_item'].initial = stocks[0].id
# There is no stock available
elif len(stocks) == 0:
# TODO - Add a message to the form describing the problem
pass
except Part.DoesNotExist: except Part.DoesNotExist:
pass pass
@ -303,10 +313,24 @@ class BuildItemCreate(AjaxCreateView):
initials = super(AjaxCreateView, self).get_initial().copy() initials = super(AjaxCreateView, self).get_initial().copy()
build_id = self.get_param('build') build_id = self.get_param('build')
part_id = self.get_param('part')
if part_id:
try:
part = Part.objects.get(pk=part_id)
except Part.DoesNotExist:
part = None
if build_id: if build_id:
try: try:
initials['build'] = Build.objects.get(pk=build_id) build = Build.objects.get(pk=build_id)
initials['build'] = build
# Try to work out how many parts to allocate
if part:
unallocated = build.getUnallocatedQuantity(part)
initials['quantity'] = unallocated
except Build.DoesNotExist: except Build.DoesNotExist:
pass pass

View File

@ -9,6 +9,7 @@ import os
from django.db import models from django.db import models
from django.urls import reverse from django.urls import reverse
from django.conf import settings
def rename_company_image(instance, filename): def rename_company_image(instance, filename):
@ -78,6 +79,14 @@ class Company(models.Model):
""" Get the web URL for the detail view for this Company """ """ Get the web URL for the detail view for this Company """
return reverse('company-detail', kwargs={'pk': self.id}) return reverse('company-detail', kwargs={'pk': self.id})
def get_image_url(self):
""" Return the URL of the image for this company """
if self.image:
return os.path.join(settings.MEDIA_URL, str(self.image.url))
else:
return ''
@property @property
def part_count(self): def part_count(self):
""" The number of parts supplied by this company """ """ The number of parts supplied by this company """

View File

@ -51,10 +51,10 @@
}, },
{ {
sortable: true, sortable: true,
field: 'part_name', field: 'part_detail.name',
title: 'Part', title: 'Part',
formatter: function(value, row, index, field) { formatter: function(value, row, index, field) {
return renderLink(value, '/part/' + row.part + '/suppliers/'); return imageHoverIcon(row.part_detail.image_url) + renderLink(value, '/part/' + row.part + '/suppliers/');
} }
}, },
{ {

View File

@ -4,11 +4,21 @@
{% block content %} {% block content %}
<h3>Companies</h3> <div class='row'>
<div id='button-toolbar'> <div class='col-sm-6'>
<button style='float: right;' class="btn btn-success" id='new-company'>New Company</button> <h3>Company List</h3>
</div>
<div class='col-sm-6'>
<div class='container' id='active-build-toolbar' style='float: right;'>
<div class='btn-group' style='float: right;'>
<button type='button' class="btn btn-success" id='new-company'>New Company</button>
</div>
</div>
</div>
</div> </div>
<hr>
<table class='table table-striped' id='company-table' data-toolbar='#button-toolbar'> <table class='table table-striped' id='company-table' data-toolbar='#button-toolbar'>
</table> </table>

View File

@ -14,6 +14,7 @@ import tablib
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.urls import reverse from django.urls import reverse
from django.conf import settings
from django.db import models from django.db import models
from django.core.validators import MinValueValidator from django.core.validators import MinValueValidator
@ -120,8 +121,17 @@ class Part(models.Model):
""" """
def get_absolute_url(self): def get_absolute_url(self):
""" Return the web URL for viewing this part """
return reverse('part-detail', kwargs={'pk': self.id}) return reverse('part-detail', kwargs={'pk': self.id})
def get_image_url(self):
""" Return the URL of the image for this part """
if self.image:
return os.path.join(settings.MEDIA_URL, str(self.image.url))
else:
return ''
# Short name of the part # Short name of the part
name = models.CharField(max_length=100, unique=True, blank=False, help_text='Part name (must be unique)') name = models.CharField(max_length=100, unique=True, blank=False, help_text='Part name (must be unique)')

View File

@ -33,6 +33,7 @@ class PartBriefSerializer(serializers.ModelSerializer):
""" Serializer for Part (brief detail) """ """ Serializer for Part (brief detail) """
url = serializers.CharField(source='get_absolute_url', read_only=True) url = serializers.CharField(source='get_absolute_url', read_only=True)
image_url = serializers.CharField(source='get_image_url', read_only=True)
class Meta: class Meta:
model = Part model = Part
@ -40,6 +41,7 @@ class PartBriefSerializer(serializers.ModelSerializer):
'pk', 'pk',
'url', 'url',
'name', 'name',
'image_url',
'description', 'description',
'available_stock', 'available_stock',
] ]
@ -51,6 +53,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)
image_url = serializers.CharField(source='get_image_url', read_only=True)
category_name = serializers.CharField(source='category_path', read_only=True) category_name = serializers.CharField(source='category_path', read_only=True)
class Meta: class Meta:
@ -60,6 +63,7 @@ class PartSerializer(serializers.ModelSerializer):
'pk', 'pk',
'url', # Link to the part detail page 'url', # Link to the part detail page
'name', 'name',
'image_url',
'IPN', 'IPN',
'URL', # Link to an external URL (optional) 'URL', # Link to an external URL (optional)
'description', 'description',
@ -121,9 +125,10 @@ class SupplierPartSerializer(serializers.ModelSerializer):
url = serializers.CharField(source='get_absolute_url', read_only=True) url = serializers.CharField(source='get_absolute_url', read_only=True)
part_name = serializers.CharField(source='part.name', read_only=True) part_detail = PartBriefSerializer(source='part', many=False, read_only=True)
supplier_name = serializers.CharField(source='supplier.name', read_only=True) supplier_name = serializers.CharField(source='supplier.name', read_only=True)
supplier_logo = serializers.CharField(source='supplier.get_image_url', read_only=True)
class Meta: class Meta:
model = SupplierPart model = SupplierPart
@ -131,9 +136,10 @@ class SupplierPartSerializer(serializers.ModelSerializer):
'pk', 'pk',
'url', 'url',
'part', 'part',
'part_name', 'part_detail',
'supplier', 'supplier',
'supplier_name', 'supplier_name',
'supplier_logo',
'SKU', 'SKU',
'manufacturer', 'manufacturer',
'MPN', 'MPN',

View File

@ -5,12 +5,19 @@
{% include 'part/tabs.html' with tab='attachments' %} {% include 'part/tabs.html' with tab='attachments' %}
<h4>Attachments</h4> <div class='row'>
<div class='col-sm-6'>
<div id='toolbar' class='btn-group'> <h4>Part Attachments</h4>
<button type='button' class='btn btn-success' id='new-attachment'>Add Attachment</button> </div>
<div class='col-sm-6'>
<div class="btn-group" style="float: right;">
<button type='button' class='btn btn-success' id='new-attachment'>Add Attachment</button>
</div>
</div>
</div> </div>
<hr>
<table class='table table-striped table-condensed' data-toolbar='#toolbar' id='attachment-table'> <table class='table table-striped table-condensed' data-toolbar='#toolbar' id='attachment-table'>
<tr> <tr>
<th>File</th> <th>File</th>

View File

@ -119,81 +119,18 @@
{% endif %} {% endif %}
$("#part-table").bootstrapTable({ loadPartTable(
sortable: true, "#part-table",
search: true, "{% url 'api-part-list' %}",
sortName: 'description', {
idField: 'pk', query: {
method: 'get',
pagination: true,
rememberOrder: true,
queryParams: function(p) {
return {
active: true,
{% if category %} {% if category %}
category: {{ category.id }}, category: {{ category.id }},
include_child_categories: true, include_child_categories: true,
{% endif %} {% endif %}
} },
buttons: ['#part-options'],
}, },
columns: [
{
checkbox: true,
title: 'Select',
searchable: false,
},
{
field: 'pk',
title: 'ID',
visible: false,
},
{
field: 'name',
title: 'Part',
sortable: true,
formatter: function(value, row, index, field) {
return renderLink(value, row.url);
}
},
{
sortable: true,
field: 'description',
title: 'Description',
},
{
sortable: true,
field: 'category_name',
title: 'Category',
formatter: function(value, row, index, field) {
if (row.category) {
return renderLink(row.category_name, "/part/category/" + row.category + "/");
}
else {
return '';
}
}
},
{
field: 'total_stock',
title: 'Stock',
searchable: false,
sortable: true,
formatter: function(value, row, index, field) {
if (value) {
return renderLink(value, row.url + 'stock/');
}
else {
return "<span class='label label-warning'>No stock</span>";
}
}
}
],
url: "{% url 'api-part-list' %}",
});
linkButtonsToSelection(
$("#part-table"),
['#part-options']
); );
{% endblock %} {% endblock %}

View File

@ -6,26 +6,24 @@
<div class='row'> <div class='row'>
<div class='col-sm-6'> <div class='col-sm-6'>
<h3>Part Details</h3> <h4>Part Details</h4>
</div> </div>
<div class='col-sm-6'> <div class='col-sm-6'>
<h3> <div class="btn-group" 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 <span class="caret"></span></button>
<span class="caret"></span></button> <ul class="dropdown-menu">
<ul class="dropdown-menu"> {% if part.active %}
{% if part.active %} <li><a href='#' id='duplicate-part' title='Duplicate Part'>Duplicate</a></li>
<li><a href='#' id='duplicate-part' title='Duplicate Part'>Duplicate</a></li> <li><a href="#" id='edit-part' title='Edit part'>Edit</a></li>
<li><a href="#" id='edit-part' title='Edit part'>Edit</a></li> <li><a href='#' id='stocktake-part' title='Stocktake'>Stocktake</a></li>
<li><a href='#' id='stocktake-part' title='Stocktake'>Stocktake</a></li> <hr>
<hr> <li><a href="#" id='deactivate-part' title='Deactivate part'>Deactivate</a></li>
<li><a href="#" id='deactivate-part' title='Deactivate part'>Deactivate</a></li> {% else %}
{% else %} <li><a href="#" id='activate-part' title='Activate part'>Activate</a></li>
<li><a href="#" id='activate-part' title='Activate part'>Activate</a></li> {% endif %}
{% endif %} </ul>
</ul> </div>
</div>
</h3>
</div> </div>
</div> </div>

View File

@ -4,7 +4,14 @@
{% include 'part/tabs.html' with tab='stock' %} {% include 'part/tabs.html' with tab='stock' %}
<h3>Part Stock</h3> <div class='row'>
<div class='col-sm-6'>
<h4>Part Stock</h4>
</div>
<div class='col-sm-6'>
</div>
</div>
<hr>
<div id='button-toolbar'> <div id='button-toolbar'>
{% if part.active %} {% if part.active %}

View File

@ -4,7 +4,15 @@
{% include 'part/tabs.html' with tab='suppliers' %} {% include 'part/tabs.html' with tab='suppliers' %}
<h3>Part Suppliers</h3> <div class='row'>
<div class='col-sm-6'>
<h4>Part Suppliers</h4>
</div>
<div class='col-sm-6'>
</div>
</div>
<hr>
<div id='button-toolbar'> <div id='button-toolbar'>
<button class="btn btn-success" id='supplier-create'>New Supplier Part</button> <button class="btn btn-success" id='supplier-create'>New Supplier Part</button>
@ -55,7 +63,7 @@
field: 'supplier_name', field: 'supplier_name',
title: 'Supplier', title: 'Supplier',
formatter: function(value, row, index, field) { formatter: function(value, row, index, field) {
return renderLink(value, '/company/' + row.supplier + '/'); return imageHoverIcon(row.supplier_logo) + renderLink(value, '/company/' + row.supplier + '/');
} }
}, },
{ {

View File

@ -38,3 +38,5 @@
<a href="{% url 'part-attachments' part.id %}">Attachments {% if part.attachments.all|length > 0 %}<span class="badge">{{ part.attachments.all|length }}</span>{% endif %}</a> <a href="{% url 'part-attachments' part.id %}">Attachments {% if part.attachments.all|length > 0 %}<span class="badge">{{ part.attachments.all|length }}</span>{% endif %}</a>
</li> </li>
</ul> </ul>
<br>

View File

@ -4,7 +4,17 @@
{% include 'part/tabs.html' with tab='used' %} {% include 'part/tabs.html' with tab='used' %}
<h3>Used In</h3> <div class='row'>
<div class='col-sm-6'>
<h4>Used to Build</h4>
</div>
<div class='col-sm-6'>
<div class="btn-group" style="float: right;">
</div>
</div>
</div>
<hr>
<table class="table table-striped table-condensed" id='used-table'> <table class="table table-striped table-condensed" id='used-table'>
</table> </table>
@ -33,7 +43,7 @@
field: 'part_detail', field: 'part_detail',
title: 'Part', title: 'Part',
formatter: function(value, row, index, field) { formatter: function(value, row, index, field) {
return renderLink(value.name, value.url + 'bom/'); return imageHoverIcon(row.part_detail.image_url) + renderLink(value.name, value.url + 'bom/');
} }
}, },
{ {

View File

@ -404,12 +404,30 @@ class CategoryEdit(AjaxUpdateView):
context = super(CategoryEdit, self).get_context_data(**kwargs).copy() context = super(CategoryEdit, self).get_context_data(**kwargs).copy()
try: try:
context['category'] = PartCategory.objects.get(pk=self.kwargs['pk']) context['category'] = self.get_object()
except: except:
pass pass
return context return context
def get_form(self):
""" Customize form data for PartCategory editing.
Limit the choices for 'parent' field to those which make sense
"""
form = super(AjaxUpdateView, self).get_form()
category = self.get_object()
# Remove any invalid choices for the parent category part
parent_choices = PartCategory.objects.all()
parent_choices = parent_choices.exclude(id__in=category.getUniqueChildren())
form.fields['parent'].queryset = parent_choices
return form
class CategoryDelete(AjaxDeleteView): class CategoryDelete(AjaxDeleteView):
""" Delete view to delete a PartCategory """ """ Delete view to delete a PartCategory """

View File

@ -10,6 +10,32 @@
color: #ffcc00; color: #ffcc00;
} }
/* Part image icons with full-display on mouse hover */
.hover-img-thumb {
background: #eee;
width: 28px;
height: 28px;
border: 1px solid #cce;
}
.hover-img-large {
background: #eee;
display: none;
position: absolute;
z-index: 999;
border: 1px solid #555;
max-width: 250px;
}
.hover-icon {
margin-right: 10px;
}
.hover-icon:hover > .hover-img-large {
display: block;
}
/* dropzone class - for Drag-n-Drop file uploads */ /* dropzone class - for Drag-n-Drop file uploads */
.dropzone { .dropzone {
border: 1px solid #555; border: 1px solid #555;
@ -159,6 +185,14 @@
margin-bottom: 5px; margin-bottom: 5px;
} }
.panel-group .panel {
border-radius: 8px;
}
.panel-heading {
padding: 5px 10px;
}
.float-right { .float-right {
float: right; float: right;
} }

View File

@ -114,6 +114,16 @@ function loadBomTable(table, options) {
} }
); );
// Part notes
cols.push(
{
field: 'note',
title: 'Notes',
searchable: true,
sortable: true,
}
);
if (options.editable) { if (options.editable) {
cols.push({ cols.push({
formatter: function(value, row, index, field) { formatter: function(value, row, index, field) {
@ -124,6 +134,7 @@ function loadBomTable(table, options) {
} }
}); });
} }
else { else {
cols.push( cols.push(
{ {
@ -149,16 +160,6 @@ function loadBomTable(table, options) {
); );
} }
// Part notes
cols.push(
{
field: 'note',
title: 'Notes',
searchable: true,
sortable: false,
}
);
// Configure the table (bootstrap-table) // Configure the table (bootstrap-table)
table.bootstrapTable({ table.bootstrapTable({

View File

@ -118,3 +118,18 @@ function enableDragAndDrop(element, url, options) {
} }
}); });
} }
function imageHoverIcon(url) {
/* Render a small thumbnail icon for an image.
* On mouseover, display a full-size version of the image
*/
var html = `
<a class='hover-icon'>
<img class='hover-img-thumb' src='` + url + `'>
<img class='hover-img-large' src='` + url + `'>
</a>
`;
return html;
}

View File

@ -73,3 +73,99 @@ function toggleStar(options) {
} }
); );
} }
function loadPartTable(table, url, options={}) {
/* Load part listing data into specified table.
*
* Args:
* - table: HTML reference to the table
* - url: Base URL for API query
* - options: object containing following (optional) fields
* allowInactive: If true, allow display of inactive parts
* query: extra query params for API request
* buttons: If provided, link buttons to selection status of this table
*/
// Default query params
query = options.query;
if (!options.allowInactive) {
// Only display active parts
query.active = true;
}
$(table).bootstrapTable({
url: url,
sortable: true,
search: true,
sortName: 'name',
method: 'get',
pagination: true,
pageSize: 25,
rememberOrder: true,
formatNoMatches: function() { return "No parts found"; },
queryParams: function(p) {
return query;
},
columns: [
{
checkbox: true,
title: 'Select',
searchable: false,
},
{
field: 'pk',
title: 'ID',
visible: false,
},
{
field: 'name',
title: 'Part',
sortable: true,
formatter: function(value, row, index, field) {
var display = imageHoverIcon(row.image_url) + renderLink(value, row.url);
if (!row.active) {
display = display + "<span class='label label-warning' style='float: right;'>INACTIVE</span>";
}
return display;
}
},
{
sortable: true,
field: 'description',
title: 'Description',
},
{
sortable: true,
field: 'category_name',
title: 'Category',
formatter: function(value, row, index, field) {
if (row.category) {
return renderLink(row.category_name, "/part/category/" + row.category + "/");
}
else {
return '';
}
}
},
{
field: 'total_stock',
title: 'Stock',
searchable: false,
sortable: true,
formatter: function(value, row, index, field) {
if (value) {
return renderLink(value, row.url + 'stock/');
}
else {
return "<span class='label label-warning'>No stock</span>";
}
}
}
],
});
if (options.buttons) {
linkButtonsToSelection($(table), options.buttons);
}
}

View File

@ -347,6 +347,7 @@ function loadStockTable(table, options) {
search: true, search: true,
method: 'get', method: 'get',
pagination: true, pagination: true,
pageSize: 25,
rememberOrder: true, rememberOrder: true,
queryParams: options.params, queryParams: options.params,
columns: [ columns: [
@ -365,9 +366,14 @@ function loadStockTable(table, options) {
title: 'Part', title: 'Part',
sortable: true, sortable: true,
formatter: function(value, row, index, field) { formatter: function(value, row, index, field) {
return renderLink(value, row.part.url); return imageHoverIcon(row.part.image_url) + renderLink(value, row.part.url);
} }
}, },
{
field: 'part.description',
title: 'Description',
sortable: true,
},
{ {
field: 'location', field: 'location',
title: 'Location', title: 'Location',

View File

@ -75,6 +75,24 @@ class StockLocationEdit(AjaxUpdateView):
ajax_template_name = 'modal_form.html' ajax_template_name = 'modal_form.html'
ajax_form_title = 'Edit Stock Location' ajax_form_title = 'Edit Stock Location'
def get_form(self):
""" Customize form data for StockLocation editing.
Limit the choices for 'parent' field to those which make sense.
"""
form = super(AjaxUpdateView, self).get_form()
location = self.get_object()
# Remove any invalid choices for the 'parent' field
parent_choices = StockLocation.objects.all()
parent_choices = parent_choices.exclude(id__in=location.getUniqueChildren())
form.fields['parent'].queryset = parent_choices
return form
class StockLocationQRCode(QRCodeView): class StockLocationQRCode(QRCodeView):
""" View for displaying a QR code for a StockLocation object """ """ View for displaying a QR code for a StockLocation object """

View File

@ -2,7 +2,7 @@
{% block content %} {% block content %}
<h3>InvenTree</h3> <h3>InvenTree</h3>
<hr>
{% include "InvenTree/starred_parts.html" with collapse_id="starred" %} {% include "InvenTree/starred_parts.html" with collapse_id="starred" %}
{% if to_order %} {% if to_order %}

View File

@ -23,56 +23,14 @@
$("#part-result-count").html("(found " + n + " results)"); $("#part-result-count").html("(found " + n + " results)");
}); });
$("#part-results-table").bootstrapTable({ loadPartTable("#part-results-table",
sortable: true, "{% url 'api-part-list' %}",
search: true, {
pagination: true, query: {
formatNoMatches: function() { return "No parts found matching search query"; },
queryParams: function(p) {
return {
search: "{{ query }}", search: "{{ query }}",
}
},
columns: [
{
field: 'pk',
title: 'ID',
visible: false,
}, },
{ allowInactive: true,
field: 'name', }
title: 'Name', );
sortable: true,
searchable: true,
formatter: function(value, row, index, field) {
return renderLink(value, row.url);
}
},
{
field: 'IPN',
title: 'Internal Part Number',
searchable: true,
},
{
field: 'description',
title: 'Description',
searchable: true,
},
{
field: 'available_stock',
title: 'Stock',
formatter: function(value, row, index, field) {
if (value) {
return renderLink(value, row.url + 'stock/');
} else {
return renderLink('No stock', row.url + 'stock/');
}
}
},
],
url: "{% url 'api-part-list' %}"
});
{% endblock %} {% endblock %}

View File

@ -86,6 +86,7 @@ InvenTree
<script type='text/javascript' src="{% static 'script/inventree/inventree.js' %}"></script> <script type='text/javascript' src="{% static 'script/inventree/inventree.js' %}"></script>
<script type='text/javascript' src="{% static 'script/inventree/api.js' %}"></script> <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/tables.js' %}"></script> <script type='text/javascript' src="{% static 'script/inventree/tables.js' %}"></script>
<script type='text/javascript' src="{% static 'script/inventree/notification.js' %}"></script> <script type='text/javascript' src="{% static 'script/inventree/notification.js' %}"></script>
<script type='text/javascript' src="{% static 'script/inventree/modals.js' %}"></script> <script type='text/javascript' src="{% static 'script/inventree/modals.js' %}"></script>

View File

@ -1,7 +1,7 @@
{% if build.status == build.PENDING %} {% if build.status == build.PENDING %}
<span class='label label-info'> <span class='label label-info'>
{% elif build.status == build.HOLDING %} {% elif build.status == build.ALLOCATED %}
<span class='label label-warning'> <span class='label label-primary'>
{% elif build.status == build.CANCELLED %} {% elif build.status == build.CANCELLED %}
<span class='label label-danger'> <span class='label label-danger'>
{% elif build.status == build.COMPLETE %} {% elif build.status == build.COMPLETE %}

View File

@ -14,6 +14,9 @@
{% crispy form %} {% crispy form %}
{% block form_data %}
{% endblock %}
</form> </form>
{% block post_form_content %} {% block post_form_content %}