mirror of
				https://github.com/inventree/InvenTree.git
				synced 2025-10-31 13:15:43 +00:00 
			
		
		
		
	| @@ -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 PartTestTemplate | ||||
| @@ -121,6 +121,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') | ||||
| @@ -279,6 +284,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) | ||||
|   | ||||
| @@ -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 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): | ||||
|     """ 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) | ||||
|  | ||||
|     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 | ||||
| @@ -1782,3 +1808,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 | ||||
|   | ||||
							
								
								
									
										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> | ||||
|     </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> | ||||
|   | ||||
| @@ -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): | ||||
|   | ||||
| @@ -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'), | ||||
|      | ||||
| @@ -113,6 +119,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)), | ||||
|  | ||||
|   | ||||
| @@ -21,7 +21,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 BomItem | ||||
| from .models import match_part_names | ||||
| @@ -70,6 +70,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 | ||||
|  | ||||
|   | ||||
| @@ -57,6 +57,7 @@ class RuleSet(models.Model): | ||||
|             'part_parttesttemplate', | ||||
|             'part_partparametertemplate', | ||||
|             'part_partparameter', | ||||
|             'part_partrelated', | ||||
|         ], | ||||
|         'stock': [ | ||||
|             'stock_stockitem', | ||||
|   | ||||
		Reference in New Issue
	
	Block a user