2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-04-30 04:26:44 +00:00

Merge pull request #370 from SchrodingersGat/variants

Part templates / variants
This commit is contained in:
Oliver 2019-05-26 00:48:14 +10:00 committed by GitHub
commit 2bd2ffed62
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
30 changed files with 433 additions and 34 deletions

View File

@ -0,0 +1,19 @@
# Generated by Django 2.2 on 2019-05-25 13:55
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('build', '0002_auto_20190520_2204'),
]
operations = [
migrations.AlterField(
model_name='build',
name='part',
field=models.ForeignKey(help_text='Select part to build', limit_choices_to={'active': True, 'buildable': True, 'has_variants': False}, on_delete=django.db.models.deletion.CASCADE, related_name='builds', to='part.Part'),
),
]

View File

@ -0,0 +1,19 @@
# Generated by Django 2.2 on 2019-05-25 13:56
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('build', '0003_auto_20190525_2355'),
]
operations = [
migrations.AlterField(
model_name='build',
name='part',
field=models.ForeignKey(help_text='Select part to build', limit_choices_to={'active': True, 'buildable': True, 'is_template': False}, on_delete=django.db.models.deletion.CASCADE, related_name='builds', to='part.Part'),
),
]

View File

@ -50,6 +50,7 @@ class Build(models.Model):
part = models.ForeignKey('part.Part', on_delete=models.CASCADE, part = models.ForeignKey('part.Part', on_delete=models.CASCADE,
related_name='builds', related_name='builds',
limit_choices_to={ limit_choices_to={
'is_template': False,
'buildable': True, 'buildable': True,
'active': True 'active': True
}, },

View File

@ -0,0 +1,19 @@
# Generated by Django 2.2 on 2019-05-25 13:54
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('company', '0003_remove_supplierpart_minimum'),
]
operations = [
migrations.AlterField(
model_name='supplierpart',
name='part',
field=models.ForeignKey(help_text='Select part', limit_choices_to={'has_variants': False, 'purchaseable': True}, on_delete=django.db.models.deletion.CASCADE, related_name='supplier_parts', to='part.Part'),
),
]

View File

@ -0,0 +1,19 @@
# Generated by Django 2.2 on 2019-05-25 13:56
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('company', '0004_auto_20190525_2354'),
]
operations = [
migrations.AlterField(
model_name='supplierpart',
name='part',
field=models.ForeignKey(help_text='Select part', limit_choices_to={'is_template': False, 'purchaseable': True}, on_delete=django.db.models.deletion.CASCADE, related_name='supplier_parts', to='part.Part'),
),
]

View File

@ -188,7 +188,10 @@ class SupplierPart(models.Model):
part = models.ForeignKey('part.Part', on_delete=models.CASCADE, part = models.ForeignKey('part.Part', on_delete=models.CASCADE,
related_name='supplier_parts', related_name='supplier_parts',
limit_choices_to={'purchaseable': True}, limit_choices_to={
'purchaseable': True,
'is_template': False,
},
help_text='Select part', help_text='Select part',
) )

View File

@ -84,7 +84,7 @@ InvenTree | {{ company.name }} - Parts
<td>{{ pb.quantity }}</td> <td>{{ pb.quantity }}</td>
<td>{{ pb.cost }} <td>{{ pb.cost }}
<div class='btn-group' style='float: right;'> <div class='btn-group' style='float: right;'>
<button title='Edit Price Break' class='btn btn-primary pb-edit-button btn-sm' type='button' url="{% url 'price-break-edit' pb.id %}"><span class='glyphicon glyphicon-small glyphicon-pencil'></span></button> <button title='Edit Price Break' class='btn btn-primary pb-edit-button btn-sm' type='button' url="{% url 'price-break-edit' pb.id %}"><span class='glyphicon glyphicon-small glyphicon-edit'></span></button>
<button title='Delete Price Break' class='btn btn-danger pb-delete-button btn-sm' type='button' url="{% url 'price-break-delete' pb.id %}"><span class='glyphicon glyphicon-small glyphicon-trash'></span></button> <button title='Delete Price Break' class='btn btn-danger pb-delete-button btn-sm' type='button' url="{% url 'price-break-delete' pb.id %}"><span class='glyphicon glyphicon-small glyphicon-trash'></span></button>
</div> </div>
</td> </td>

View File

@ -93,6 +93,8 @@ class EditPartForm(HelperForm):
'name', 'name',
'IPN', 'IPN',
'variant', 'variant',
'is_template',
'variant_of',
'description', 'description',
'keywords', 'keywords',
'URL', 'URL',

View File

@ -0,0 +1,24 @@
# Generated by Django 2.2 on 2019-05-25 12:26
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('part', '0002_auto_20190520_2204'),
]
operations = [
migrations.AddField(
model_name='part',
name='has_variants',
field=models.BooleanField(default=False, help_text='Is this part a template part?'),
),
migrations.AddField(
model_name='part',
name='variant_of',
field=models.ForeignKey(blank=True, help_text='Is this part a variant of another part?', limit_choices_to={'active': True, 'has_variants': True}, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='variants', to='part.Part'),
),
]

View File

@ -0,0 +1,24 @@
# Generated by Django 2.2 on 2019-05-25 13:56
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('part', '0003_auto_20190525_2226'),
]
operations = [
migrations.RenameField(
model_name='part',
old_name='has_variants',
new_name='is_template',
),
migrations.AlterField(
model_name='part',
name='variant_of',
field=models.ForeignKey(blank=True, help_text='Is this part a variant of another part?', limit_choices_to={'active': True, 'is_template': True}, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='variants', to='part.Part'),
),
]

View File

@ -192,6 +192,7 @@ class Part(models.Model):
description: Longer form description of the part description: Longer form description of the part
keywords: Optional keywords for improving part search results keywords: Optional keywords for improving part search results
IPN: Internal part number (optional) IPN: Internal part number (optional)
is_template: If True, this part is a 'template' part and cannot be instantiated as a StockItem
URL: Link to an external page with more information about this part (e.g. internal Wiki) URL: Link to an external page with more information about this part (e.g. internal Wiki)
image: Image of this part image: Image of this part
default_location: Where the item is normally stored (may be null) default_location: Where the item is normally stored (may be null)
@ -252,12 +253,32 @@ class Part(models.Model):
else: else:
return static('/img/blank_image.png') return static('/img/blank_image.png')
def clean(self):
""" Perform cleaning operations for the Part model """
if self.is_template and self.variant_of is not None:
raise ValidationError({
'is_template': _("Part cannot be a template part if it is a variant of another part"),
'variant_of': _("Part cannot be a variant of another part if it is already a template"),
})
name = models.CharField(max_length=100, blank=False, help_text='Part name', name = models.CharField(max_length=100, blank=False, help_text='Part name',
validators=[validators.validate_part_name] validators=[validators.validate_part_name]
) )
variant = models.CharField(max_length=32, blank=True, help_text='Part variant or revision code') variant = models.CharField(max_length=32, blank=True, help_text='Part variant or revision code')
is_template = models.BooleanField(default=False, help_text='Is this part a template part?')
variant_of = models.ForeignKey('part.Part', related_name='variants',
null=True, blank=True,
limit_choices_to={
'is_template': True,
'active': True,
},
on_delete=models.SET_NULL,
help_text='Is this part a variant of another part?')
description = models.CharField(max_length=250, blank=False, help_text='Part description') description = models.CharField(max_length=250, blank=False, help_text='Part description')
keywords = models.CharField(max_length=250, blank=True, help_text='Part keywords to improve visibility in search results') keywords = models.CharField(max_length=250, blank=True, help_text='Part keywords to improve visibility in search results')
@ -501,6 +522,9 @@ class Part(models.Model):
Part may be stored in multiple locations Part may be stored in multiple locations
""" """
if self.is_template:
total = sum([variant.total_stock for variant in self.variants.all()])
else:
total = self.stock_entries.aggregate(total=Sum('quantity'))['total'] total = self.stock_entries.aggregate(total=Sum('quantity'))['total']
if total: if total:
@ -747,6 +771,21 @@ class Part(models.Model):
return data.export(file_format) return data.export(file_format)
@property
def attachment_count(self):
""" Count the number of attachments for this part.
If the part is a variant of a template part,
include the number of attachments for the template part.
"""
n = self.attachments.count()
if self.variant_of:
n += self.variant_of.attachments.count()
return n
def attach_file(instance, filename): def attach_file(instance, filename):
""" Function for storing a file for a PartAttachment """ Function for storing a file for a PartAttachment

View File

@ -85,6 +85,8 @@ class PartSerializer(serializers.ModelSerializer):
'full_name', 'full_name',
'name', 'name',
'IPN', 'IPN',
'is_template',
'variant_of',
'variant', 'variant',
'description', 'description',
'keywords', 'keywords',

View File

@ -30,12 +30,26 @@
<td>{{ attachment.comment }}</td> <td>{{ attachment.comment }}</td>
<td> <td>
<div class='btn-group' style='float: right;'> <div class='btn-group' style='float: right;'>
<button type='button' class='btn btn-primary attachment-edit-button' url="{% url 'part-attachment-edit' attachment.id %}" data-toggle='tooltip' title='Edit attachment ({{ attachment.basename }})'><span class='glyphicon glyphicon-small glyphicon-pencil'></span></button> <button type='button' class='btn btn-primary attachment-edit-button' url="{% url 'part-attachment-edit' attachment.id %}" data-toggle='tooltip' title='Edit attachment ({{ attachment.basename }})'><span class='glyphicon glyphicon-small glyphicon-edit'></span></button>
<button type='button' class='btn btn-danger attachment-delete-button' url="{% url 'part-attachment-delete' attachment.id %}" data-toggle='tooltip' title='Delete attachment ({{ attachment.basename }})'><span class='glyphicon glyphicon-small glyphicon-trash'></span></button> <button type='button' class='btn btn-danger attachment-delete-button' url="{% url 'part-attachment-delete' attachment.id %}" data-toggle='tooltip' title='Delete attachment ({{ attachment.basename }})'><span class='glyphicon glyphicon-small glyphicon-trash'></span></button>
</div> </div>
</td> </td>
</tr> </tr>
{% endfor %} {% endfor %}
{% if part.variant_of and part.variant_of.attachments.count > 0 %}
<tr>
<td colspan='3'>
Attachments for template part <b><i>{{ part.variant_of.full_name }}</i></b>
</td>
</tr>
{% for attachment in part.variant_of.attachments.all %}
<tr>
<td><a href='/media/{{ attachment.attachment }}'>{{ attachment.basename }}</a></td>
<td>{{ attachment.comment }}</td>
<td></td>
</tr>
{% endfor %}
{% endif %}
</table> </table>
{% endblock %} {% endblock %}

View File

@ -52,6 +52,12 @@
<td><b>Description</b></td> <td><b>Description</b></td>
<td>{{ part.description }}</td> <td>{{ part.description }}</td>
</tr> </tr>
{% if part.variant_of %}
<tr>
<td><b>Variant Of</b></td>
<td><a href="{% url 'part-detail' part.variant_of.id %}">{{ part.variant_of.full_name }}</a></td>
</tr>
{% endif %}
{% if part.keywords %} {% if part.keywords %}
<tr> <tr>
<td><b>Keywords</b></td> <td><b>Keywords</b></td>

View File

@ -4,12 +4,24 @@
{% block content %} {% block content %}
<div class="row">
{% if part.active == False %} {% if part.active == False %}
<div class='alert alert-danger' style='display: block;'> <div class='alert alert-danger alert-block'>
This part ({{ part.full_name }}) is not active: This part is not active:
</div> </div>
{% endif %} {% endif %}
{% if part.is_template %}
<div class='alert alert-info alert-block'>
This part is a <i>template part</i>.<br>
It is not a <i>real</i> part, but real parts can be based on this template.
</div>
{% endif %}
{% if part.variant_of %}
<div class='alert alert-info alert-block'>
This part is a variant of <b><a href="{% url 'part-detail' part.variant_of.id %}">{{ part.variant_of.full_name }}</a></b>
</div>
{% endif %}
<div class="row">
<div class="col-sm-6"> <div class="col-sm-6">
<div class="media"> <div class="media">
<div class="media-left"> <div class="media-left">
@ -35,10 +47,12 @@
<button type='button' class='btn btn-default btn-glyph' id='toggle-starred' title='Star this part'> <button type='button' class='btn btn-default btn-glyph' id='toggle-starred' title='Star this part'>
<span id='part-star-icon' class='starred-part glyphicon {% if starred %}glyphicon-star{% else %}glyphicon-star-empty{% endif %}'/> <span id='part-star-icon' class='starred-part glyphicon {% if starred %}glyphicon-star{% else %}glyphicon-star-empty{% endif %}'/>
</button> </button>
{% if part.is_template == False %}
{% include "qr_button.html" %} {% include "qr_button.html" %}
<button type='button' class='btn btn-default btn-glyph' id='price-button' title='Show pricing information'> <button type='button' class='btn btn-default btn-glyph' id='price-button' title='Show pricing information'>
<span id='part-price-icon' class='part-price glyphicon glyphicon-usd'/> <span id='part-price-icon' class='part-price glyphicon glyphicon-usd'/>
</button> </button>
{% endif %}
</div> </div>
</p> </p>
<table class='table table-condensed'> <table class='table table-condensed'>

View File

@ -13,6 +13,12 @@
</div> </div>
<hr> <hr>
{% if part.is_template %}
<div class='alert alert-info alert-block'>
Showing stock for all variants of <i>{{ part.full_name }}</i>
</div>
{% endif %}
{% include "stock_table.html" %} {% include "stock_table.html" %}
{% endblock %} {% endblock %}

View File

@ -2,6 +2,11 @@
<li{% ifequal tab 'detail' %} class="active"{% endifequal %}> <li{% ifequal tab 'detail' %} class="active"{% endifequal %}>
<a href="{% url 'part-detail' part.id %}">Details</a> <a href="{% url 'part-detail' part.id %}">Details</a>
</li> </li>
{% if part.is_template %}
<li{% ifequal tab 'variants' %} class='active'{% endifequal %}>
<a href="{% url 'part-variants' part.id %}">Variants <span class='badge'>{{ part.variants.count }}</span></span></a>
</li>
{% endif %}
<li{% ifequal tab 'stock' %} class="active"{% endifequal %}> <li{% ifequal tab 'stock' %} class="active"{% endifequal %}>
<a href="{% url 'part-stock' part.id %}">Stock <span class="badge">{{ part.total_stock }}</span></a> <a href="{% url 'part-stock' part.id %}">Stock <span class="badge">{{ part.total_stock }}</span></a>
</li> </li>
@ -20,7 +25,7 @@
<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 %}
{% if part.purchaseable %} {% if part.purchaseable and part.is_template == False %}
<li{% ifequal tab 'suppliers' %} class="active"{% endifequal %}> <li{% ifequal tab 'suppliers' %} class="active"{% endifequal %}>
<a href="{% url 'part-suppliers' part.id %}">Suppliers <a href="{% url 'part-suppliers' part.id %}">Suppliers
<span class="badge">{{ part.supplier_count }}</span> <span class="badge">{{ part.supplier_count }}</span>
@ -35,7 +40,7 @@
</a></li> </a></li>
{% endif %} {% endif %}
<li{% ifequal tab 'attachments' %} class="active"{% endifequal %}> <li{% ifequal tab 'attachments' %} class="active"{% endifequal %}>
<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.attachment_count > 0 %}<span class="badge">{{ part.attachment_count }}</span>{% endif %}</a>
</li> </li>
</ul> </ul>

View File

@ -0,0 +1,63 @@
{% extends "part/part_base.html" %}
{% load static %}
{% block details %}
{% include "part/tabs.html" with tab='variants' %}
<div class='row'>
<div class='col-sm-6'>
<h4>Part Variants</h4>
</div>
<div class='col-sm-6'>
</div>
</div>
<hr>
<div id='button-toolbar'>
<div class='btn-group'>
{% if part.is_template and part.active %}
<button class='btn btn-success' id='new-variant' title='Create new variant'>New Variant</button>
{% endif %}
</div>
</div>
<table class='table table-striped table-condensed' id='variant-table' data-toolbar='#button-toolbar'>
<thead>
<tr>
<th>Variant</th>
<th>Description</th>
<th>Stock</th>
</tr>
</thead>
<tbody>
{% for variant in part.variants.all %}
<tr>
<td>
<div class='hover-icon media-left' style='float: left;'>
<img class='hover-img-thumb' src="{% if variant.image %}{{ variant.image.url }}{% else %}{% static 'img/blank_image.png' %}{% endif %}">
{% if variant.image %}
<img class='hover-img-large' src="{{ variant.image.url }}">
{% endif %}
</div>
<a href="{% url 'part-detail' variant.id %}">{{ variant.full_name }}</a>
</td>
<td>{{ variant.description }}</td>
<td>{{ variant.total_stock }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endblock %}
{% block js_ready %}
{{ block.super }}
$('#variant-table').bootstrapTable({
search: true,
sortable: true,
});
{% endblock %}

View File

@ -26,14 +26,15 @@ part_detail_urls = [
url(r'^duplicate/', views.PartDuplicate.as_view(), name='part-duplicate'), url(r'^duplicate/', views.PartDuplicate.as_view(), name='part-duplicate'),
url(r'^pricing/', views.PartPricing.as_view(), name='part-pricing'), url(r'^pricing/', views.PartPricing.as_view(), name='part-pricing'),
url(r'^track/?', views.PartDetail.as_view(template_name='part/track.html'), name='part-track'), url(r'^variants/?', views.PartDetail.as_view(template_name='part/variants.html'), name='part-variants'),
url(r'^attachments/?', views.PartDetail.as_view(template_name='part/attachments.html'), name='part-attachments'), url(r'^stock/?', views.PartDetail.as_view(template_name='part/stock.html'), name='part-stock'),
url(r'^allocation/?', views.PartDetail.as_view(template_name='part/allocation.html'), name='part-allocation'),
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'^used/?', views.PartDetail.as_view(template_name='part/used_in.html'), name='part-used-in'), url(r'^used/?', views.PartDetail.as_view(template_name='part/used_in.html'), name='part-used-in'),
url(r'^allocation/?', views.PartDetail.as_view(template_name='part/allocation.html'), name='part-allocation'),
url(r'^suppliers/?', views.PartDetail.as_view(template_name='part/supplier.html'), name='part-suppliers'), url(r'^suppliers/?', views.PartDetail.as_view(template_name='part/supplier.html'), name='part-suppliers'),
url(r'^track/?', views.PartDetail.as_view(template_name='part/track.html'), name='part-track'),
url(r'^attachments/?', views.PartDetail.as_view(template_name='part/attachments.html'), name='part-attachments'),
url(r'^qr_code/?', views.PartQRCode.as_view(), name='part-qr'), url(r'^qr_code/?', views.PartQRCode.as_view(), name='part-qr'),

View File

@ -189,7 +189,7 @@ function loadBomTable(table, options) {
if (options.editable) { if (options.editable) {
cols.push({ cols.push({
formatter: function(value, row, index, field) { formatter: function(value, row, index, field) {
var bEdit = "<button title='Edit BOM Item' class='btn btn-primary bom-edit-button btn-sm' type='button' url='/part/bom/" + row.pk + "/edit'><span class='glyphicon glyphicon-small glyphicon-pencil'></span></button>"; var bEdit = "<button title='Edit BOM Item' class='btn btn-primary bom-edit-button btn-sm' type='button' url='/part/bom/" + row.pk + "/edit'><span class='glyphicon glyphicon-small glyphicon-edit'></span></button>";
var bDelt = "<button title='Delete BOM Item' class='btn btn-danger bom-delete-button btn-sm' type='button' url='/part/bom/" + row.pk + "/delete'><span class='glyphicon glyphicon-small glyphicon-trash'></span></button>"; var bDelt = "<button title='Delete BOM Item' class='btn btn-danger bom-delete-button btn-sm' type='button' url='/part/bom/" + row.pk + "/delete'><span class='glyphicon glyphicon-small glyphicon-trash'></span></button>";
return "<div class='btn-group'>" + bEdit + bDelt + "</div>"; return "<div class='btn-group'>" + bEdit + bDelt + "</div>";

View File

@ -40,7 +40,7 @@ function loadAllocationTable(table, part_id, part, url, required, button) {
formatter: function(value, row, index, field) { formatter: function(value, row, index, field) {
var html = value; var html = value;
var bEdit = "<button class='btn btn-primary item-edit-button btn-sm' type='button' title='Edit stock allocation' url='/build/item/" + row.pk + "/edit/'><span class='glyphicon glyphicon-small glyphicon-pencil'></span></button>"; var bEdit = "<button class='btn btn-primary item-edit-button btn-sm' type='button' title='Edit stock allocation' url='/build/item/" + row.pk + "/edit/'><span class='glyphicon glyphicon-small glyphicon-edit'></span></button>";
var bDel = "<button class='btn btn-danger item-del-button btn-sm' type='button' title='Delete stock allocation' url='/build/item/" + row.pk + "/delete/'><span class='glyphicon glyphicon-small glyphicon-trash'></span></button>"; var bDel = "<button class='btn btn-danger item-del-button btn-sm' type='button' title='Delete stock allocation' url='/build/item/" + row.pk + "/delete/'><span class='glyphicon glyphicon-small glyphicon-trash'></span></button>";
html += "<div class='btn-group' style='float: right;'>" + bEdit + bDel + "</div>"; html += "<div class='btn-group' style='float: right;'>" + bEdit + bDel + "</div>";

View File

@ -124,7 +124,12 @@ function loadPartTable(table, url, options={}) {
sortable: true, sortable: true,
formatter: function(value, row, index, field) { formatter: function(value, row, index, field) {
if (row.is_template) {
value = '<i>' + value + '</i>';
}
var display = imageHoverIcon(row.image_url) + renderLink(value, row.url); var display = imageHoverIcon(row.image_url) + renderLink(value, row.url);
if (!row.active) { if (!row.active) {
display = display + "<span class='label label-warning' style='float: right;'>INACTIVE</span>"; display = display + "<span class='label label-warning' style='float: right;'>INACTIVE</span>";
} }
@ -135,6 +140,14 @@ function loadPartTable(table, url, options={}) {
sortable: true, sortable: true,
field: 'description', field: 'description',
title: 'Description', title: 'Description',
formatter: function(value, row, index, field) {
if (row.is_template) {
value = '<i>' + value + '</i>';
}
return value;
}
}, },
{ {
sortable: true, sortable: true,

View File

@ -11,7 +11,7 @@ from django.urls import reverse
from .models import StockLocation, StockItem from .models import StockLocation, StockItem
from .models import StockItemTracking from .models import StockItemTracking
from part.models import PartCategory from part.models import Part, PartCategory
from .serializers import StockItemSerializer, StockQuantitySerializer from .serializers import StockItemSerializer, StockQuantitySerializer
from .serializers import LocationSerializer from .serializers import LocationSerializer
@ -263,12 +263,28 @@ class StockList(generics.ListCreateAPIView):
we may wish to also request stock items from all child locations. we may wish to also request stock items from all child locations.
""" """
# Does the client wish to filter by stock location?
loc_id = self.request.query_params.get('location', None)
# Start with all objects # Start with all objects
stock_list = StockItem.objects.all() stock_list = StockItem.objects.all()
# Does the client wish to filter by the Part ID?
part_id = self.request.query_params.get('part', None)
if part_id:
try:
part = Part.objects.get(pk=part_id)
# If the part is a Template part, select stock items for any "variant" parts under that template
if part.is_template:
stock_list = stock_list.filter(part__in=[part.id for part in Part.objects.filter(variant_of=part_id)])
else:
stock_list = stock_list.filter(part=part_id)
except Part.DoesNotExist:
pass
# Does the client wish to filter by stock location?
loc_id = self.request.query_params.get('location', None)
if loc_id: if loc_id:
try: try:
location = StockLocation.objects.get(pk=loc_id) location = StockLocation.objects.get(pk=loc_id)
@ -312,7 +328,6 @@ class StockList(generics.ListCreateAPIView):
] ]
filter_fields = [ filter_fields = [
'part',
'supplier_part', 'supplier_part',
'customer', 'customer',
'belongs_to', 'belongs_to',

View File

@ -34,6 +34,7 @@ class CreateStockItemForm(HelperForm):
'location', 'location',
'quantity', 'quantity',
'batch', 'batch',
'serial',
'delete_on_deplete', 'delete_on_deplete',
'status', 'status',
'notes', 'notes',

View File

@ -0,0 +1,19 @@
# Generated by Django 2.2 on 2019-05-25 12:26
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('stock', '0001_initial'),
]
operations = [
migrations.AlterField(
model_name='stockitem',
name='part',
field=models.ForeignKey(help_text='Base part', limit_choices_to={'active': True, 'has_variants': True}, on_delete=django.db.models.deletion.CASCADE, related_name='stock_items', to='part.Part'),
),
]

View File

@ -0,0 +1,19 @@
# Generated by Django 2.2 on 2019-05-25 13:03
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('stock', '0002_auto_20190525_2226'),
]
operations = [
migrations.AlterField(
model_name='stockitem',
name='part',
field=models.ForeignKey(help_text='Base part', limit_choices_to={'active': True, 'has_variants': False}, on_delete=django.db.models.deletion.CASCADE, related_name='stock_items', to='part.Part'),
),
]

View File

@ -0,0 +1,19 @@
# Generated by Django 2.2 on 2019-05-25 13:56
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('stock', '0003_auto_20190525_2303'),
]
operations = [
migrations.AlterField(
model_name='stockitem',
name='part',
field=models.ForeignKey(help_text='Base part', limit_choices_to={'active': True, 'is_template': False}, on_delete=django.db.models.deletion.CASCADE, related_name='stock_items', to='part.Part'),
),
]

View File

@ -115,6 +115,22 @@ class StockItem(models.Model):
system=True system=True
) )
def validate_unique(self, exclude=None):
super(StockItem, self).validate_unique(exclude)
# If the Part object is a variant (of a template part),
# ensure that the serial number is unique
# across all variants of the same template part
try:
if self.serial is not None and self.part.variant_of is not None:
if StockItem.objects.filter(part__variant_of=self.part.variant_of, serial=self.serial).exclude(id=self.id).exists():
raise ValidationError({
'serial': _('A part with this serial number already exists for template part {part}'.format(part=self.part.variant_of))
})
except Part.DoesNotExist:
pass
def clean(self): def clean(self):
""" Validate the StockItem object (separate to field validation) """ Validate the StockItem object (separate to field validation)
@ -135,11 +151,18 @@ class StockItem(models.Model):
}) })
if self.part is not None: if self.part is not None:
# A trackable part must have a serial number
if self.part.trackable and not self.serial: if self.part.trackable and not self.serial:
raise ValidationError({ raise ValidationError({
'serial': _('Serial number must be set for trackable items') 'serial': _('Serial number must be set for trackable items')
}) })
# A template part cannot be instantiated as a StockItem
if self.part.is_template:
raise ValidationError({
'part': _('Stock item cannot be created for a template Part')
})
except Part.DoesNotExist: except Part.DoesNotExist:
# This gets thrown if self.supplier_part is null # This gets thrown if self.supplier_part is null
# TODO - Find a test than can be perfomed... # TODO - Find a test than can be perfomed...
@ -186,7 +209,12 @@ class StockItem(models.Model):
} }
) )
part = models.ForeignKey('part.Part', on_delete=models.CASCADE, related_name='stock_items', help_text='Base part') part = models.ForeignKey('part.Part', on_delete=models.CASCADE,
related_name='stock_items', help_text='Base part',
limit_choices_to={
'is_template': False,
'active': True,
})
supplier_part = models.ForeignKey('company.SupplierPart', blank=True, null=True, on_delete=models.SET_NULL, supplier_part = models.ForeignKey('company.SupplierPart', blank=True, null=True, on_delete=models.SET_NULL,
help_text='Select a matching supplier part for this stock item') help_text='Select a matching supplier part for this stock item')

View File

@ -208,13 +208,19 @@ class StockItemCreate(AjaxCreateView):
try: try:
part = Part.objects.get(id=part_id) part = Part.objects.get(id=part_id)
parts = form.fields['supplier_part'].queryset
parts = parts.filter(part=part.id) # Hide the 'part' field (as a valid part is selected)
form.fields['part'].widget = HiddenInput()
# If the part is NOT purchaseable, hide the supplier_part field # If the part is NOT purchaseable, hide the supplier_part field
if not part.purchaseable: if not part.purchaseable:
form.fields['supplier_part'].widget = HiddenInput() form.fields['supplier_part'].widget = HiddenInput()
else:
# Pre-select the allowable SupplierPart options
parts = form.fields['supplier_part'].queryset
parts = parts.filter(part=part.id)
form.fields['supplier_part'].queryset = parts form.fields['supplier_part'].queryset = parts
# If there is one (and only one) supplier part available, pre-select it # If there is one (and only one) supplier part available, pre-select it
@ -227,9 +233,6 @@ class StockItemCreate(AjaxCreateView):
except Part.DoesNotExist: except Part.DoesNotExist:
pass pass
# Hide the 'part' field
form.fields['part'].widget = HiddenInput()
# Otherwise if the user has selected a SupplierPart, we know what Part they meant! # Otherwise if the user has selected a SupplierPart, we know what Part they meant!
elif form['supplier_part'].value() is not None: elif form['supplier_part'].value() is not None:
pass pass

View File

@ -1,6 +1,8 @@
<div id='button-toolbar'> <div id='button-toolbar'>
<div class='button-toolbar container-fluid' style='float: right;'> <div class='button-toolbar container-fluid' style='float: right;'>
{% if part.is_template == False %}
<button class="btn btn-success" id='item-create'>New Stock Item</button> <button class="btn btn-success" id='item-create'>New Stock Item</button>
{% endif %}
<div class="dropdown" style='float: right;'> <div class="dropdown" style='float: right;'>
<button id='stock-options' class="btn btn-primary dropdown-toggle" type="button" data-toggle="dropdown">Options<span class="caret"></span></button> <button id='stock-options' class="btn btn-primary dropdown-toggle" type="button" data-toggle="dropdown">Options<span class="caret"></span></button>
<ul class="dropdown-menu"> <ul class="dropdown-menu">