mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +00:00
commit
50f2229685
@ -24,6 +24,21 @@ class PartImageForm(HelperForm):
|
||||
]
|
||||
|
||||
|
||||
class BomValidateForm(HelperForm):
|
||||
""" Simple confirmation form for BOM validation.
|
||||
User is presented with a single checkbox input,
|
||||
to confirm that the BOM for this part is valid
|
||||
"""
|
||||
|
||||
validate = forms.BooleanField(required=False, initial=False, help_text='Confirm that the BOM is correct')
|
||||
|
||||
class Meta:
|
||||
model = Part
|
||||
fields = [
|
||||
'validate'
|
||||
]
|
||||
|
||||
|
||||
class BomExportForm(HelperForm):
|
||||
|
||||
# TODO - Define these choices somewhere else, and import them here
|
||||
|
31
InvenTree/part/migrations/0022_auto_20190512_1246.py
Normal file
31
InvenTree/part/migrations/0022_auto_20190512_1246.py
Normal file
@ -0,0 +1,31 @@
|
||||
# Generated by Django 2.2 on 2019-05-12 02:46
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
('part', '0021_auto_20190510_2220'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='part',
|
||||
name='bom_checked_by',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='boms_checked', to=settings.AUTH_USER_MODEL),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='part',
|
||||
name='bom_checked_date',
|
||||
field=models.DateField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='part',
|
||||
name='bom_checksum',
|
||||
field=models.CharField(blank=True, help_text='Stored BOM checksum', max_length=128),
|
||||
),
|
||||
]
|
@ -16,7 +16,7 @@ from django.core.exceptions import ValidationError
|
||||
from django.urls import reverse
|
||||
from django.conf import settings
|
||||
|
||||
from django.db import models
|
||||
from django.db import models, transaction
|
||||
from django.core.validators import MinValueValidator
|
||||
|
||||
from django.contrib.staticfiles.templatetags.staticfiles import static
|
||||
@ -24,7 +24,9 @@ from django.contrib.auth.models import User
|
||||
from django.db.models.signals import pre_delete
|
||||
from django.dispatch import receiver
|
||||
|
||||
from datetime import datetime
|
||||
from fuzzywuzzy import fuzz
|
||||
import hashlib
|
||||
|
||||
from InvenTree import helpers
|
||||
from InvenTree import validators
|
||||
@ -300,23 +302,23 @@ class Part(models.Model):
|
||||
|
||||
consumable = models.BooleanField(default=True, help_text='Can this part be used to build other parts?')
|
||||
|
||||
# Is this part "trackable"?
|
||||
# Trackable parts can have unique instances
|
||||
# which are assigned serial numbers (or batch numbers)
|
||||
# and can have their movements tracked
|
||||
trackable = models.BooleanField(default=False, help_text='Does this part have tracking for unique items?')
|
||||
|
||||
# Is this part "purchaseable"?
|
||||
purchaseable = models.BooleanField(default=True, help_text='Can this part be purchased from external suppliers?')
|
||||
|
||||
# Can this part be sold to customers?
|
||||
salable = models.BooleanField(default=False, help_text="Can this part be sold to customers?")
|
||||
|
||||
# Is this part active?
|
||||
active = models.BooleanField(default=True, help_text='Is this part active?')
|
||||
|
||||
notes = models.TextField(blank=True)
|
||||
|
||||
bom_checksum = models.CharField(max_length=128, blank=True, help_text='Stored BOM checksum')
|
||||
|
||||
bom_checked_by = models.ForeignKey(User, on_delete=models.SET_NULL, blank=True, null=True,
|
||||
related_name='boms_checked')
|
||||
|
||||
bom_checked_date = models.DateField(blank=True, null=True)
|
||||
|
||||
def format_barcode(self):
|
||||
""" Return a JSON string for formatting a barcode for this Part object """
|
||||
|
||||
@ -467,13 +469,59 @@ class Part(models.Model):
|
||||
|
||||
@property
|
||||
def bom_count(self):
|
||||
""" Return the number of items contained in the BOM for this part """
|
||||
return self.bom_items.count()
|
||||
|
||||
@property
|
||||
def used_in_count(self):
|
||||
""" Return the number of part BOMs that this part appears in """
|
||||
return self.used_in.count()
|
||||
|
||||
def get_bom_hash(self):
|
||||
""" Return a checksum hash for the BOM for this part.
|
||||
Used to determine if the BOM has changed (and needs to be signed off!)
|
||||
|
||||
For hash is calculated from the following fields of each BOM item:
|
||||
|
||||
- Part.full_name (if the part name changes, the BOM checksum is invalidated)
|
||||
- quantity
|
||||
- Note field
|
||||
|
||||
returns a string representation of a hash object which can be compared with a stored value
|
||||
"""
|
||||
|
||||
hash = hashlib.md5('bom seed'.encode())
|
||||
|
||||
for item in self.bom_items.all():
|
||||
hash.update(str(item.sub_part.full_name).encode())
|
||||
hash.update(str(item.quantity).encode())
|
||||
hash.update(str(item.note).encode())
|
||||
|
||||
return str(hash.digest())
|
||||
|
||||
@property
|
||||
def is_bom_valid(self):
|
||||
""" Check if the BOM is 'valid' - if the calculated checksum matches the stored value
|
||||
"""
|
||||
|
||||
return self.get_bom_hash() == self.bom_checksum
|
||||
|
||||
@transaction.atomic
|
||||
def validate_bom(self, user):
|
||||
""" Validate the BOM (mark the BOM as validated by the given User.
|
||||
|
||||
- Calculates and stores the hash for the BOM
|
||||
- Saves the current date and the checking user
|
||||
"""
|
||||
|
||||
self.bom_checksum = self.get_bom_hash()
|
||||
self.bom_checked_by = user
|
||||
self.bom_checked_date = datetime.now().date()
|
||||
|
||||
self.save()
|
||||
|
||||
def required_parts(self):
|
||||
""" Return a list of parts required to make this part (list of BOM items) """
|
||||
parts = []
|
||||
for bom in self.bom_items.all():
|
||||
parts.append(bom.sub_part)
|
||||
@ -481,7 +529,7 @@ class Part(models.Model):
|
||||
|
||||
@property
|
||||
def supplier_count(self):
|
||||
# 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()
|
||||
|
||||
def export_bom(self, **kwargs):
|
||||
|
@ -11,6 +11,21 @@
|
||||
|
||||
<h3>Bill of Materials</h3>
|
||||
|
||||
{% if part.bom_checked_date %}
|
||||
{% if part.is_bom_valid %}
|
||||
<div class='alert alert-block alert-info'>
|
||||
{% else %}
|
||||
<div class='alert alert-block alert-danger'>
|
||||
The BOM for <i>{{ part.full_name }}</i> has changed, and must be validated.<br>
|
||||
{% endif %}
|
||||
The BOM for <i>{{ part.full_name }}</i> was last checked by {{ part.bom_checked_by }} on {{ part.bom_checked_date }}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class='alert alert-danger alert-block'>
|
||||
<b>The BOM for <i>{{ part.full_name }}</i> has not been validated.</b>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div id='button-toolbar'>
|
||||
{% if editing_enabled %}
|
||||
<div class='btn-group' style='float: right;'>
|
||||
@ -24,6 +39,9 @@
|
||||
<span class='caret'></span>
|
||||
</button>
|
||||
<ul class='dropdown-menu'>
|
||||
{% if part.is_bom_valid == False %}
|
||||
<li><a href='#' id='validate-bom' title='Validate BOM'>Validate BOM</a></li>
|
||||
{% endif %}
|
||||
<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>
|
||||
@ -72,6 +90,15 @@
|
||||
|
||||
{% else %}
|
||||
|
||||
$("#validate-bom").click(function() {
|
||||
launchModalForm(
|
||||
"{% url 'bom-validate' part.id %}",
|
||||
{
|
||||
reload: true,
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
$("#edit-bom").click(function () {
|
||||
location.href = "{% url 'part-bom' part.id %}?edit=True";
|
||||
});
|
||||
|
5
InvenTree/part/templates/part/bom_validate.html
Normal file
5
InvenTree/part/templates/part/bom_validate.html
Normal file
@ -0,0 +1,5 @@
|
||||
{% extends "modal_form.html" %}
|
||||
|
||||
{% block pre_form_content %}
|
||||
Confirm that the Bill of Materials (BOM) is valid for:<br><i>{{ part.full_name }}</i>
|
||||
{% endblock %}
|
@ -12,7 +12,7 @@
|
||||
{% endif %}
|
||||
{% if part.buildable %}
|
||||
<li{% ifequal tab 'bom' %} class="active"{% endifequal %}>
|
||||
<a href="{% url 'part-bom' part.id %}">BOM<span class="badge">{{ part.bom_count }}</span></a></li>
|
||||
<a href="{% url 'part-bom' part.id %}">BOM<span class="badge{% if part.is_bom_valid == False %} badge-alert{% endif %}">{{ part.bom_count }}</span></a></li>
|
||||
<li{% ifequal tab 'build' %} class="active"{% endifequal %}>
|
||||
<a href="{% url 'part-build' part.id %}">Build<span class='badge'>{{ part.active_builds|length }}</span></a></li>
|
||||
{% endif %}
|
||||
|
@ -35,6 +35,7 @@ part_detail_urls = [
|
||||
url(r'^edit/?', views.PartEdit.as_view(), name='part-edit'),
|
||||
url(r'^delete/?', views.PartDelete.as_view(), name='part-delete'),
|
||||
url(r'^bom-export/?', views.BomDownload.as_view(), name='bom-export'),
|
||||
url(r'^validate-bom/', views.BomValidate.as_view(), name='bom-validate'),
|
||||
|
||||
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'),
|
||||
|
@ -331,12 +331,50 @@ class PartEdit(AjaxUpdateView):
|
||||
return form
|
||||
|
||||
|
||||
class BomValidate(AjaxUpdateView):
|
||||
""" Modal form view for validating a part BOM """
|
||||
|
||||
model = Part
|
||||
ajax_form_title = "Validate BOM"
|
||||
ajax_template_name = 'part/bom_validate.html'
|
||||
context_object_name = 'part'
|
||||
form_class = part_forms.BomValidateForm
|
||||
|
||||
def get_context(self):
|
||||
return {
|
||||
'part': self.get_object(),
|
||||
}
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
|
||||
form = self.get_form()
|
||||
|
||||
return self.renderJsonResponse(request, form, context=self.get_context())
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
|
||||
form = self.get_form()
|
||||
part = self.get_object()
|
||||
|
||||
confirmed = str2bool(request.POST.get('validate', False))
|
||||
|
||||
if confirmed:
|
||||
part.validate_bom(request.user)
|
||||
else:
|
||||
form.errors['validate'] = ['Confirm that the BOM is valid']
|
||||
|
||||
data = {
|
||||
'form_valid': confirmed
|
||||
}
|
||||
|
||||
return self.renderJsonResponse(request, form, data, context=self.get_context())
|
||||
|
||||
|
||||
class BomExport(AjaxView):
|
||||
|
||||
model = Part
|
||||
ajax_form_title = 'Export BOM'
|
||||
ajax_template_name = 'part/bom_export.html'
|
||||
context_object_name = 'part'
|
||||
form_class = part_forms.BomExportForm
|
||||
|
||||
def get_object(self):
|
||||
@ -345,17 +383,6 @@ class BomExport(AjaxView):
|
||||
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):
|
||||
|
@ -14,6 +14,7 @@
|
||||
color: #ffcc00;
|
||||
}
|
||||
|
||||
|
||||
/* CSS overrides for treeview */
|
||||
.expand-icon {
|
||||
font-size: 11px;
|
||||
@ -97,6 +98,10 @@
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
.badge-alert {
|
||||
background-color: #f33;
|
||||
}
|
||||
|
||||
.part-thumb {
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
@ -219,6 +224,10 @@
|
||||
pointer-events: all;
|
||||
}
|
||||
|
||||
.alert-block {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.btn {
|
||||
margin-left: 2px;
|
||||
margin-right: 2px;
|
||||
|
Loading…
Reference in New Issue
Block a user