2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-06-21 06:16:29 +00:00

Merge branch 'master' into categories_parameters

This commit is contained in:
Francois
2020-11-03 07:01:56 -05:00
committed by GitHub
84 changed files with 7218 additions and 3669 deletions

View File

@ -9,7 +9,7 @@ from import_export.fields import Field
import import_export.widgets as widgets
from .models import PartCategory, Part
from .models import PartAttachment, PartStar
from .models import PartAttachment, PartStar, PartRelated
from .models import BomItem
from .models import PartParameterTemplate, PartParameter
from .models import PartCategoryParameterTemplate
@ -122,6 +122,11 @@ class PartCategoryAdmin(ImportExportModelAdmin):
search_fields = ('name', 'description')
class PartRelatedAdmin(admin.ModelAdmin):
''' Class to manage PartRelated objects '''
pass
class PartAttachmentAdmin(admin.ModelAdmin):
list_display = ('part', 'attachment', 'comment')
@ -292,6 +297,7 @@ class PartSellPriceBreakAdmin(admin.ModelAdmin):
admin.site.register(Part, PartAdmin)
admin.site.register(PartCategory, PartCategoryAdmin)
admin.site.register(PartRelated, PartRelatedAdmin)
admin.site.register(PartAttachment, PartAttachmentAdmin)
admin.site.register(PartStar, PartStarAdmin)
admin.site.register(BomItem, BomItemAdmin)

View File

@ -16,8 +16,15 @@ class PartConfig(AppConfig):
"""
self.generate_part_thumbnails()
self.update_trackable_status()
def generate_part_thumbnails(self):
"""
Generate thumbnail images for any Part that does not have one.
This function exists mainly for legacy support,
as any *new* image uploaded will have a thumbnail generated automatically.
"""
from .models import Part
print("InvenTree: Checking Part image thumbnails")
@ -37,4 +44,27 @@ class PartConfig(AppConfig):
part.image = None
part.save()
except (OperationalError, ProgrammingError):
# Exception if the database has not been migrated yet
pass
def update_trackable_status(self):
"""
Check for any instances where a trackable part is used in the BOM
for a non-trackable part.
In such a case, force the top-level part to be trackable too.
"""
from .models import BomItem
try:
items = BomItem.objects.filter(part__trackable=False, sub_part__trackable=True)
for item in items:
print(f"Marking part '{item.part.name}' as trackable")
item.part.trackable = True
item.part.clean()
item.part.save()
except (OperationalError, ProgrammingError):
# Exception if the database has not been migrated yet
pass

View File

@ -67,6 +67,7 @@
name: 'Widget'
description: 'A watchamacallit'
category: 7
assembly: true
trackable: true
tree_id: 0
level: 0

View File

@ -13,7 +13,7 @@ from mptt.fields import TreeNodeChoiceField
from django import forms
from django.utils.translation import ugettext as _
from .models import Part, PartCategory, PartAttachment
from .models import Part, PartCategory, PartAttachment, PartRelated
from .models import BomItem
from .models import PartParameterTemplate, PartParameter
from .models import PartCategoryParameterTemplate
@ -142,6 +142,25 @@ class BomUploadSelectFile(HelperForm):
]
class CreatePartRelatedForm(HelperForm):
""" Form for creating a PartRelated object """
class Meta:
model = PartRelated
fields = [
'part_1',
'part_2',
]
labels = {
'part_2': _('Related Part'),
}
def save(self):
""" Disable model saving """
return super(CreatePartRelatedForm, self).save(commit=False)
class EditPartAttachmentForm(HelperForm):
""" Form for editing a PartAttachment object """

View File

@ -0,0 +1,22 @@
# Generated by Django 3.0.7 on 2020-10-16 20:42
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('part', '0051_bomitem_optional'),
]
operations = [
migrations.CreateModel(
name='PartRelated',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('part_1', models.ForeignKey(on_delete=django.db.models.deletion.DO_NOTHING, related_name='related_parts_1', to='part.Part')),
('part_2', models.ForeignKey(help_text='Select Related Part', on_delete=django.db.models.deletion.DO_NOTHING, related_name='related_parts_2', to='part.Part')),
],
),
]

View File

@ -0,0 +1,14 @@
# Generated by Django 3.0.7 on 2020-11-03 10:28
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('part', '0052_auto_20201027_1557'),
('part', '0052_partrelated'),
]
operations = [
]

View File

@ -564,10 +564,24 @@ class Part(MPTTModel):
pass
def clean(self):
""" Perform cleaning operations for the Part model """
"""
Perform cleaning operations for the Part model
Update trackable status:
If this part is trackable, and it is used in the BOM
for a parent part which is *not* trackable,
then we will force the parent part to be trackable.
"""
super().clean()
if self.trackable:
for parent_part in self.used_in.all():
if not parent_part.trackable:
parent_part.trackable = True
parent_part.clean()
parent_part.save()
name = models.CharField(max_length=100, blank=False,
help_text=_('Part name'),
validators=[validators.validate_part_name]
@ -909,6 +923,19 @@ class Part(MPTTModel):
def has_bom(self):
return self.bom_count > 0
@property
def has_trackable_parts(self):
"""
Return True if any parts linked in the Bill of Materials are trackable.
This is important when building the part.
"""
for bom_item in self.bom_items.all():
if bom_item.sub_part.trackable:
return True
return False
@property
def bom_count(self):
""" Return the number of items contained in the BOM for this part """
@ -966,15 +993,31 @@ class Part(MPTTModel):
self.bom_items.all().delete()
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().select_related('sub_part'):
parts.append(bom.sub_part)
def getRequiredParts(self, recursive=False, parts=set()):
"""
Return a list of parts required to make this part (i.e. BOM items).
Args:
recursive: If True iterate down through sub-assemblies
parts: Set of parts already found (to prevent recursion issues)
"""
for bom_item in self.bom_items.all().select_related('sub_part'):
sub_part = bom_item.sub_part
if sub_part not in parts:
parts.add(sub_part)
if recursive:
sub_part.getRequiredParts(recursive=True, parts=parts)
return parts
def get_allowed_bom_items(self):
""" Return a list of parts which can be added to a BOM for this part.
"""
Return a list of parts which can be added to a BOM for this part.
- Exclude parts which are not 'component' parts
- Exclude parts which this part is in the BOM for
@ -1176,7 +1219,7 @@ class Part(MPTTModel):
parameter.save()
@transaction.atomic
def deepCopy(self, other, **kwargs):
def deep_copy(self, other, **kwargs):
""" Duplicates non-field data from another part.
Does not alter the normal fields of this part,
but can be used to copy other data linked by ForeignKey refernce.
@ -1331,6 +1374,32 @@ class Part(MPTTModel):
return self.get_descendants(include_self=False)
def get_related_parts(self):
""" Return list of tuples for all related parts:
- first value is PartRelated object
- second value is matching Part object
"""
related_parts = []
related_parts_1 = self.related_parts_1.filter(part_1__id=self.pk)
related_parts_2 = self.related_parts_2.filter(part_2__id=self.pk)
for related_part in related_parts_1:
# Add to related parts list
related_parts.append((related_part, related_part.part_2))
for related_part in related_parts_2:
# Add to related parts list
related_parts.append((related_part, related_part.part_1))
return related_parts
@property
def related_count(self):
return len(self.get_related_parts())
def attach_file(instance, filename):
""" Function for storing a file for a PartAttachment
@ -1715,12 +1784,15 @@ class BomItem(models.Model):
return self.get_item_hash() == self.checksum
def clean(self):
""" Check validity of the BomItem model.
"""
Check validity of the BomItem model.
Performs model checks beyond simple field validation.
- A part cannot refer to itself in its BOM
- A part cannot refer to a part which refers to it
- If the "sub_part" is trackable, then the "part" must be trackable too!
"""
# If the sub_part is 'trackable' then the 'quantity' field must be an integer
@ -1730,6 +1802,13 @@ class BomItem(models.Model):
raise ValidationError({
"quantity": _("Quantity must be integer value for trackable parts")
})
# Force the upstream part to be trackable if the sub_part is trackable
if not self.part.trackable:
self.part.trackable = True
self.part.clean()
self.part.save()
except Part.DoesNotExist:
pass
@ -1842,3 +1921,71 @@ class BomItem(models.Model):
pmax = decimal2string(pmax)
return "{pmin} to {pmax}".format(pmin=pmin, pmax=pmax)
class PartRelated(models.Model):
""" Store and handle related parts (eg. mating connector, crimps, etc.) """
part_1 = models.ForeignKey(Part, related_name='related_parts_1',
on_delete=models.DO_NOTHING)
part_2 = models.ForeignKey(Part, related_name='related_parts_2',
on_delete=models.DO_NOTHING,
help_text=_('Select Related Part'))
def __str__(self):
return f'{self.part_1} <--> {self.part_2}'
def validate(self, part_1, part_2):
''' Validate that the two parts relationship is unique '''
validate = True
parts = Part.objects.all()
related_parts = PartRelated.objects.all()
# Check if part exist and there are not the same part
if (part_1 in parts and part_2 in parts) and (part_1.pk != part_2.pk):
# Check if relation exists already
for relation in related_parts:
if (part_1 == relation.part_1 and part_2 == relation.part_2) \
or (part_1 == relation.part_2 and part_2 == relation.part_1):
validate = False
break
else:
validate = False
return validate
def clean(self):
''' Overwrite clean method to check that relation is unique '''
validate = self.validate(self.part_1, self.part_2)
if not validate:
error_message = _('Error creating relationship: check that '
'the part is not related to itself '
'and that the relationship is unique')
raise ValidationError(error_message)
def create_relationship(self, part_1, part_2):
''' Create relationship between two parts '''
validate = self.validate(part_1, part_2)
if validate:
# Add relationship
self.part_1 = part_1
self.part_2 = part_2
self.save()
return validate
@classmethod
def create(cls, part_1, part_2):
''' Create PartRelated object and relationship between two parts '''
related_part = cls()
related_part.create_relationship(part_1, part_2)
return related_part

View File

@ -10,13 +10,9 @@
{% include 'part/tabs.html' with tab='bom' %}
<h3>{% trans "Bill of Materials" %}</h3>
<h4>{% trans "Bill of Materials" %}</h4>
<hr>
{% if part.has_complete_bom_pricing == False %}
<div class='alert alert-block alert-warning'>
The BOM for <i>{{ part.full_name }}</i> does not have complete pricing information
</div>
{% endif %}
{% if part.bom_checked_date %}
{% if part.is_bom_valid %}
<div class='alert alert-block alert-info'>

View File

@ -5,13 +5,14 @@
{% include 'part/tabs.html' with tab='build' %}
<h3>{% trans "Part Builds" %}</h3>
<h4>{% trans "Part Builds" %}</h4>
<hr>
<div id='button-toolbar'>
<div class='button-toolbar container-flui' style='float: right';>
{% if part.active %}
{% if roles.build.add %}
<button class="btn btn-success" id='start-build'>{% trans "Start New Build" %}</button>
<button class="btn btn-success" id='start-build'><span class='fas fa-tools'></span> {% trans "Start New Build" %}</button>
{% endif %}
{% endif %}
<div class='filter-list' id='filter-list-build'>
@ -29,14 +30,11 @@
{% block js_ready %}
{{ block.super }}
$("#start-build").click(function() {
launchModalForm(
"{% url 'build-create' %}",
{
follow: true,
data: {
part: {{ part.id }}
}
});
newBuildOrder({
data: {
part: {{ part.id }},
}
});
});
loadBuildTable($("#build-table"), {

View File

@ -41,7 +41,7 @@
{% if part.getLatestSerialNumber %}
{{ part.getLatestSerialNumber }}
{% else %}
{% trans "No serial numbers recorded" %}
<i>{% trans "No serial numbers recorded" %}</i>
{% endif %}
</td>
</tr>

View File

@ -0,0 +1,77 @@
{% extends "part/part_base.html" %}
{% load static %}
{% load i18n %}
{% block details %}
{% include 'part/tabs.html' with tab='related-parts' %}
<h4>{% trans "Related Parts" %}</h4>
<hr>
<div id='button-bar'>
<div class='button-toolbar container-fluid' style='float: left;'>
{% if roles.part.change %}
<button class='btn btn-primary' type='button' id='add-related-part' title='{% trans "Add Related" %}'>{% trans "Add Related" %}</button>
<div class='filter-list' id='filter-list-related'>
<!-- An empty div in which the filter list will be constructed -->
</div>
{% endif %}
</div>
</div>
<table id='table-related-part' class='table table-condensed table-striped' data-toolbar='#button-toolbar'>
<thead>
<tr>
<th data-field='part' data-serachable='true'>{% trans "Part" %}</th>
</tr>
</thead>
<tbody>
{% for item in part.get_related_parts %}
{% with part_related=item.0 part=item.1 %}
<tr>
<td>
<a class='hover-icon'>
<img class='hover-img-thumb' src='{{ part.get_thumbnail_url }}'>
<img class='hover-img-large' src='{{ part.get_thumbnail_url }}'>
</a>
<a href='/part/{{ part.id }}/'>{{ part }}</a>
<div class='btn-group' style='float: right;'>
{% if roles.part.change %}
<button title='{% trans "Delete" %}' class='btn btn-default btn-glyph delete-related-part' url="{% url 'part-related-delete' part_related.id %}" type='button'><span class='fas fa-trash-alt icon-red'/></button>
{% endif %}
</div>
</td>
</tr>
{% endwith %}
{% endfor %}
</tbody>
</table>
{% endblock %}
{% block js_ready %}
{{ block.super }}
$('#table-related-part').inventreeTable({
});
$("#add-related-part").click(function() {
launchModalForm("{% url 'part-related-create' %}", {
data: {
part: {{ part.id }},
},
reload: true,
});
});
$('.delete-related-part').click(function() {
var button = $(this);
launchModalForm(button.attr('url'), {
reload: true,
});
});
{% endblock %}

View File

@ -63,6 +63,9 @@
</a>
</li>
{% endif %}
<li{% ifequal tab 'related-parts' %} class="active"{% endifequal %}>
<a href="{% url 'part-related' part.id %}">{% trans "Related" %} {% if part.related_count > 0 %}<span class="badge">{{ part.related_count }}</span>{% endif %}</a>
</li>
<li{% ifequal tab 'attachments' %} class="active"{% endifequal %}>
<a href="{% url 'part-attachments' part.id %}">{% trans "Attachments" %} {% if part.attachment_count > 0 %}<span class="badge">{{ part.attachment_count }}</span>{% endif %}</a>
</li>

View File

@ -13,6 +13,19 @@ from common.models import InvenTreeSetting, ColorTheme
register = template.Library()
@register.simple_tag()
def define(value, *args, **kwargs):
"""
Shortcut function to overcome the shortcomings of the django templating language
Use as follows: {% define "hello_world" as hello %}
Ref: https://stackoverflow.com/questions/1070398/how-to-set-a-value-of-a-variable-inside-a-template-code
"""
return value
@register.simple_tag()
def decimal(x, *args, **kwargs):
""" Simplified rendering of a decimal number """

View File

@ -28,10 +28,12 @@ class BomItemTest(TestCase):
self.assertEqual(self.bob.bom_count, 4)
def test_in_bom(self):
parts = self.bob.required_parts()
parts = self.bob.getRequiredParts()
self.assertIn(self.orphan, parts)
# TODO: Tests for multi-level BOMs
def test_used_in(self):
self.assertEqual(self.bob.used_in_count, 0)
self.assertEqual(self.orphan.used_in_count, 1)

View File

@ -99,7 +99,7 @@ class PartTest(TestCase):
self.assertIn(self.R1.name, barcode)
def test_copy(self):
self.R2.deepCopy(self.R1, image=True, bom=True)
self.R2.deep_copy(self.R1, image=True, bom=True)
def test_match_names(self):

View File

@ -201,6 +201,29 @@ class PartTests(PartViewTestCase):
self.assertEqual(response.status_code, 200)
class PartRelatedTests(PartViewTestCase):
def test_valid_create(self):
""" test creation of an attachment for a valid part """
response = self.client.get(reverse('part-related-create'), {'part': 1}, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
self.assertEqual(response.status_code, 200)
# TODO - Create a new attachment using this view
def test_invalid_create(self):
""" test creation of an attachment for an invalid part """
# TODO
pass
def test_edit(self):
""" test editing an attachment """
# TODO
pass
class PartAttachmentTests(PartViewTestCase):
def test_valid_create(self):

View File

@ -12,6 +12,11 @@ from django.conf.urls import url, include
from . import views
part_related_urls = [
url(r'^new/?', views.PartRelatedCreate.as_view(), name='part-related-create'),
url(r'^(?P<pk>\d+)/delete/?', views.PartRelatedDelete.as_view(), name='part-related-delete'),
]
part_attachment_urls = [
url(r'^new/?', views.PartAttachmentCreate.as_view(), name='part-attachment-create'),
url(r'^(?P<pk>\d+)/edit/?', views.PartAttachmentEdit.as_view(), name='part-attachment-edit'),
@ -61,6 +66,7 @@ part_detail_urls = [
url(r'^sale-prices/', views.PartDetail.as_view(template_name='part/sale_prices.html'), name='part-sale-prices'),
url(r'^tests/', views.PartDetail.as_view(template_name='part/part_tests.html'), name='part-test-templates'),
url(r'^track/?', views.PartDetail.as_view(template_name='part/track.html'), name='part-track'),
url(r'^related-parts/?', views.PartDetail.as_view(template_name='part/related.html'), name='part-related'),
url(r'^attachments/?', views.PartDetail.as_view(template_name='part/attachments.html'), name='part-attachments'),
url(r'^notes/?', views.PartNotes.as_view(), name='part-notes'),
@ -121,6 +127,9 @@ part_urls = [
# Part category
url(r'^category/(?P<pk>\d+)/', include(part_category_urls)),
# Part related
url(r'^related-parts/', include(part_related_urls)),
# Part attachments
url(r'^attachment/', include(part_attachment_urls)),

View File

@ -22,7 +22,7 @@ import os
from rapidfuzz import fuzz
from decimal import Decimal, InvalidOperation
from .models import PartCategory, Part, PartAttachment
from .models import PartCategory, Part, PartAttachment, PartRelated
from .models import PartParameterTemplate, PartParameter
from .models import PartCategoryParameterTemplate
from .models import BomItem
@ -72,6 +72,85 @@ class PartIndex(InvenTreeRoleMixin, ListView):
return context
class PartRelatedCreate(AjaxCreateView):
""" View for creating a new PartRelated object
- The view only makes sense if a Part object is passed to it
"""
model = PartRelated
form_class = part_forms.CreatePartRelatedForm
ajax_form_title = _("Add Related Part")
ajax_template_name = "modal_form.html"
role_required = 'part.change'
def get_initial(self):
""" Set parent part as part_1 field """
initials = {}
part_id = self.request.GET.get('part', None)
if part_id:
try:
initials['part_1'] = Part.objects.get(pk=part_id)
except (Part.DoesNotExist, ValueError):
pass
return initials
def get_form(self):
""" Create a form to upload a new PartRelated
- Hide the 'part_1' field (parent part)
- Display parts which are not yet related
"""
form = super(AjaxCreateView, self).get_form()
form.fields['part_1'].widget = HiddenInput()
try:
# Get parent part
parent_part = self.get_initial()['part_1']
# Get existing related parts
related_parts = [related_part[1].pk for related_part in parent_part.get_related_parts()]
# Build updated choice list excluding
# - parts already related to parent part
# - the parent part itself
updated_choices = []
for choice in form.fields["part_2"].choices:
if (choice[0] not in related_parts) and (choice[0] != parent_part.pk):
updated_choices.append(choice)
# Update choices for related part
form.fields['part_2'].choices = updated_choices
except KeyError:
pass
return form
def post_save(self):
""" Save PartRelated model (POST method does not) """
form = self.get_form()
if form.is_valid():
part_1 = form.cleaned_data['part_1']
part_2 = form.cleaned_data['part_2']
PartRelated.create(part_1, part_2)
class PartRelatedDelete(AjaxDeleteView):
""" View for deleting a PartRelated object """
model = PartRelated
ajax_form_title = _("Delete Related Part")
context_object_name = "related"
role_required = 'part.change'
class PartAttachmentCreate(AjaxCreateView):
""" View for creating a new PartAttachment object
@ -84,10 +163,14 @@ class PartAttachmentCreate(AjaxCreateView):
role_required = 'part.add'
def post_save(self):
""" Record the user that uploaded the attachment """
self.object.user = self.request.user
self.object.save()
def save(self, form, **kwargs):
"""
Record the user that uploaded this attachment
"""
attachment = form.save(commit=False)
attachment.user = self.request.user
attachment.save()
def get_data(self):
return {
@ -362,7 +445,7 @@ class MakePartVariant(AjaxCreateView):
parameters_copy = str2bool(request.POST.get('parameters_copy', False))
# Copy relevent information from the template part
part.deepCopy(part_template, bom=bom_copy, parameters=parameters_copy)
part.deep_copy(part_template, bom=bom_copy, parameters=parameters_copy)
return self.renderJsonResponse(request, form, data, context=context)
@ -475,7 +558,7 @@ class PartDuplicate(AjaxCreateView):
original = self.get_part_to_copy()
if original:
part.deepCopy(original, bom=bom_copy, parameters=parameters_copy)
part.deep_copy(original, bom=bom_copy, parameters=parameters_copy)
try:
data['url'] = part.get_absolute_url()
@ -894,7 +977,10 @@ class BomDuplicate(AjaxUpdateView):
if not confirm:
form.add_error('confirm', _('Confirm duplication of BOM from parent'))
def post_save(self, part, form):
def save(self, part, form):
"""
Duplicate BOM from the specified parent
"""
parent = form.cleaned_data.get('parent', None)
@ -935,7 +1021,10 @@ class BomValidate(AjaxUpdateView):
if not confirm:
form.add_error('validate', _('Confirm that the BOM is valid'))
def post_save(self, part, form, **kwargs):
def save(self, part, form, **kwargs):
"""
Mark the BOM as validated
"""
part.validate_bom(self.request.user)
@ -2371,7 +2460,7 @@ class BomItemCreate(AjaxCreateView):
query = query.filter(active=True)
# Eliminate any options that are already in the BOM!
query = query.exclude(id__in=[item.id for item in part.required_parts()])
query = query.exclude(id__in=[item.id for item in part.getRequiredParts()])
form.fields['sub_part'].queryset = query
@ -2439,7 +2528,7 @@ class BomItemEdit(AjaxUpdateView):
except ValueError:
sub_part_id = -1
existing = [item.pk for item in part.required_parts()]
existing = [item.pk for item in part.getRequiredParts()]
if sub_part_id in existing:
existing.remove(sub_part_id)