mirror of
https://github.com/inventree/InvenTree.git
synced 2025-04-30 04:26:44 +00:00
commit
ef2c04baa8
@ -9,7 +9,7 @@ from import_export.fields import Field
|
|||||||
import import_export.widgets as widgets
|
import import_export.widgets as widgets
|
||||||
|
|
||||||
from .models import PartCategory, Part
|
from .models import PartCategory, Part
|
||||||
from .models import PartAttachment, PartStar
|
from .models import PartAttachment, PartStar, PartRelated
|
||||||
from .models import BomItem
|
from .models import BomItem
|
||||||
from .models import PartParameterTemplate, PartParameter
|
from .models import PartParameterTemplate, PartParameter
|
||||||
from .models import PartTestTemplate
|
from .models import PartTestTemplate
|
||||||
@ -121,6 +121,11 @@ class PartCategoryAdmin(ImportExportModelAdmin):
|
|||||||
search_fields = ('name', 'description')
|
search_fields = ('name', 'description')
|
||||||
|
|
||||||
|
|
||||||
|
class PartRelatedAdmin(admin.ModelAdmin):
|
||||||
|
''' Class to manage PartRelated objects '''
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
class PartAttachmentAdmin(admin.ModelAdmin):
|
class PartAttachmentAdmin(admin.ModelAdmin):
|
||||||
|
|
||||||
list_display = ('part', 'attachment', 'comment')
|
list_display = ('part', 'attachment', 'comment')
|
||||||
@ -279,6 +284,7 @@ class PartSellPriceBreakAdmin(admin.ModelAdmin):
|
|||||||
|
|
||||||
admin.site.register(Part, PartAdmin)
|
admin.site.register(Part, PartAdmin)
|
||||||
admin.site.register(PartCategory, PartCategoryAdmin)
|
admin.site.register(PartCategory, PartCategoryAdmin)
|
||||||
|
admin.site.register(PartRelated, PartRelatedAdmin)
|
||||||
admin.site.register(PartAttachment, PartAttachmentAdmin)
|
admin.site.register(PartAttachment, PartAttachmentAdmin)
|
||||||
admin.site.register(PartStar, PartStarAdmin)
|
admin.site.register(PartStar, PartStarAdmin)
|
||||||
admin.site.register(BomItem, BomItemAdmin)
|
admin.site.register(BomItem, BomItemAdmin)
|
||||||
|
@ -13,7 +13,7 @@ from mptt.fields import TreeNodeChoiceField
|
|||||||
from django import forms
|
from django import forms
|
||||||
from django.utils.translation import ugettext as _
|
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 BomItem
|
||||||
from .models import PartParameterTemplate, PartParameter
|
from .models import PartParameterTemplate, PartParameter
|
||||||
from .models import PartTestTemplate
|
from .models import PartTestTemplate
|
||||||
@ -141,6 +141,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):
|
class EditPartAttachmentForm(HelperForm):
|
||||||
""" Form for editing a PartAttachment object """
|
""" Form for editing a PartAttachment object """
|
||||||
|
|
||||||
|
22
InvenTree/part/migrations/0052_partrelated.py
Normal file
22
InvenTree/part/migrations/0052_partrelated.py
Normal 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')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
@ -1313,6 +1313,32 @@ class Part(MPTTModel):
|
|||||||
|
|
||||||
return self.get_descendants(include_self=False)
|
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):
|
def attach_file(instance, filename):
|
||||||
""" Function for storing a file for a PartAttachment
|
""" Function for storing a file for a PartAttachment
|
||||||
@ -1782,3 +1808,71 @@ class BomItem(models.Model):
|
|||||||
pmax = decimal2string(pmax)
|
pmax = decimal2string(pmax)
|
||||||
|
|
||||||
return "{pmin} to {pmax}".format(pmin=pmin, pmax=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
|
||||||
|
77
InvenTree/part/templates/part/related.html
Normal file
77
InvenTree/part/templates/part/related.html
Normal 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 %}
|
@ -63,6 +63,9 @@
|
|||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
{% endif %}
|
{% 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 %}>
|
<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>
|
<a href="{% url 'part-attachments' part.id %}">{% trans "Attachments" %} {% if part.attachment_count > 0 %}<span class="badge">{{ part.attachment_count }}</span>{% endif %}</a>
|
||||||
</li>
|
</li>
|
||||||
|
@ -201,6 +201,29 @@ class PartTests(PartViewTestCase):
|
|||||||
self.assertEqual(response.status_code, 200)
|
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):
|
class PartAttachmentTests(PartViewTestCase):
|
||||||
|
|
||||||
def test_valid_create(self):
|
def test_valid_create(self):
|
||||||
|
@ -12,6 +12,11 @@ from django.conf.urls import url, include
|
|||||||
|
|
||||||
from . import views
|
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 = [
|
part_attachment_urls = [
|
||||||
url(r'^new/?', views.PartAttachmentCreate.as_view(), name='part-attachment-create'),
|
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'),
|
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'^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'^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'^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'^attachments/?', views.PartDetail.as_view(template_name='part/attachments.html'), name='part-attachments'),
|
||||||
url(r'^notes/?', views.PartNotes.as_view(), name='part-notes'),
|
url(r'^notes/?', views.PartNotes.as_view(), name='part-notes'),
|
||||||
|
|
||||||
@ -113,6 +119,9 @@ part_urls = [
|
|||||||
# Part category
|
# Part category
|
||||||
url(r'^category/(?P<pk>\d+)/', include(part_category_urls)),
|
url(r'^category/(?P<pk>\d+)/', include(part_category_urls)),
|
||||||
|
|
||||||
|
# Part related
|
||||||
|
url(r'^related-parts/', include(part_related_urls)),
|
||||||
|
|
||||||
# Part attachments
|
# Part attachments
|
||||||
url(r'^attachment/', include(part_attachment_urls)),
|
url(r'^attachment/', include(part_attachment_urls)),
|
||||||
|
|
||||||
|
@ -21,7 +21,7 @@ import os
|
|||||||
from rapidfuzz import fuzz
|
from rapidfuzz import fuzz
|
||||||
from decimal import Decimal, InvalidOperation
|
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 PartParameterTemplate, PartParameter
|
||||||
from .models import BomItem
|
from .models import BomItem
|
||||||
from .models import match_part_names
|
from .models import match_part_names
|
||||||
@ -70,6 +70,85 @@ class PartIndex(InvenTreeRoleMixin, ListView):
|
|||||||
return context
|
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):
|
class PartAttachmentCreate(AjaxCreateView):
|
||||||
""" View for creating a new PartAttachment object
|
""" View for creating a new PartAttachment object
|
||||||
|
|
||||||
|
@ -57,6 +57,7 @@ class RuleSet(models.Model):
|
|||||||
'part_parttesttemplate',
|
'part_parttesttemplate',
|
||||||
'part_partparametertemplate',
|
'part_partparametertemplate',
|
||||||
'part_partparameter',
|
'part_partparameter',
|
||||||
|
'part_partrelated',
|
||||||
],
|
],
|
||||||
'stock': [
|
'stock': [
|
||||||
'stock_stockitem',
|
'stock_stockitem',
|
||||||
|
Loading…
x
Reference in New Issue
Block a user