diff --git a/InvenTree/part/admin.py b/InvenTree/part/admin.py
index f0c9e3f233..7476197547 100644
--- a/InvenTree/part/admin.py
+++ b/InvenTree/part/admin.py
@@ -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)
diff --git a/InvenTree/part/forms.py b/InvenTree/part/forms.py
index 52c39bf3ba..c4523113e5 100644
--- a/InvenTree/part/forms.py
+++ b/InvenTree/part/forms.py
@@ -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 """
diff --git a/InvenTree/part/migrations/0052_partrelated.py b/InvenTree/part/migrations/0052_partrelated.py
new file mode 100644
index 0000000000..a8672ba7dc
--- /dev/null
+++ b/InvenTree/part/migrations/0052_partrelated.py
@@ -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')),
+ ],
+ ),
+ ]
diff --git a/InvenTree/part/models.py b/InvenTree/part/models.py
index 6a64f595b5..60e100b563 100644
--- a/InvenTree/part/models.py
+++ b/InvenTree/part/models.py
@@ -1356,6 +1356,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
@@ -1835,3 +1861,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
diff --git a/InvenTree/part/templates/part/related.html b/InvenTree/part/templates/part/related.html
new file mode 100644
index 0000000000..8c5cc074c8
--- /dev/null
+++ b/InvenTree/part/templates/part/related.html
@@ -0,0 +1,77 @@
+{% extends "part/part_base.html" %}
+{% load static %}
+{% load i18n %}
+
+{% block details %}
+
+{% include 'part/tabs.html' with tab='related-parts' %}
+
+
{% trans "Related Parts" %}
+
+
+
+
+
+
+
+{% 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 %}
\ No newline at end of file
diff --git a/InvenTree/part/templates/part/tabs.html b/InvenTree/part/templates/part/tabs.html
index 8322a225bc..8bfaba4d89 100644
--- a/InvenTree/part/templates/part/tabs.html
+++ b/InvenTree/part/templates/part/tabs.html
@@ -63,6 +63,9 @@
{% endif %}
+
+ {% trans "Related" %} {% if part.related_count > 0 %}{{ part.related_count }}{% endif %}
+
{% trans "Attachments" %} {% if part.attachment_count > 0 %}{{ part.attachment_count }}{% endif %}
diff --git a/InvenTree/part/test_views.py b/InvenTree/part/test_views.py
index d8c345d243..1ad5c33e45 100644
--- a/InvenTree/part/test_views.py
+++ b/InvenTree/part/test_views.py
@@ -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):
diff --git a/InvenTree/part/urls.py b/InvenTree/part/urls.py
index 4d81faa2d6..624a94b0b6 100644
--- a/InvenTree/part/urls.py
+++ b/InvenTree/part/urls.py
@@ -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\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\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\d+)/', include(part_category_urls)),
+ # Part related
+ url(r'^related-parts/', include(part_related_urls)),
+
# Part attachments
url(r'^attachment/', include(part_attachment_urls)),
diff --git a/InvenTree/part/views.py b/InvenTree/part/views.py
index f88f7f415a..bd602448db 100644
--- a/InvenTree/part/views.py
+++ b/InvenTree/part/views.py
@@ -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
diff --git a/InvenTree/users/admin.py b/InvenTree/users/admin.py
index e4974a3f7a..4614e5bc4c 100644
--- a/InvenTree/users/admin.py
+++ b/InvenTree/users/admin.py
@@ -3,12 +3,13 @@ from __future__ import unicode_literals
from django.utils.translation import ugettext_lazy as _
-from django.contrib import admin
+from django.contrib import admin, messages
from django import forms
from django.contrib.auth import get_user_model
from django.contrib.admin.widgets import FilteredSelectMultiple
from django.contrib.auth.models import Group
from django.contrib.auth.admin import UserAdmin
+from django.utils.safestring import mark_safe
from users.models import RuleSet
@@ -94,15 +95,37 @@ class RoleGroupAdmin(admin.ModelAdmin):
filter_horizontal = ['permissions']
- # Save inlines before model
- # https://stackoverflow.com/a/14860703/12794913
def save_model(self, request, obj, form, change):
- pass # don't actually save the parent instance
+ """
+ This method serves two purposes:
+ - show warning message whenever the group users belong to multiple groups
+ - skip saving of the group instance model as inlines needs to be saved before.
+ """
+
+ # Get form cleaned data
+ users = form.cleaned_data['users']
+
+ # Check for users who are members of multiple groups
+ warning_message = ''
+ for user in users:
+ if user.groups.all().count() > 1:
+ warning_message += f'
- {user.username} is member of: '
+ for idx, group in enumerate(user.groups.all()):
+ warning_message += f'{group.name}'
+ if idx < len(user.groups.all()) - 1:
+ warning_message += ', '
+
+ # If any, display warning message when group is saved
+ if warning_message:
+ warning_message = mark_safe(_(f'The following users are members of multiple groups:'
+ f'{warning_message}'))
+ messages.add_message(request, messages.WARNING, warning_message)
def save_formset(self, request, form, formset, change):
- formset.save() # this will save the children
- # update_fields is required to trigger permissions update
- form.instance.save(update_fields=['name']) # form.instance is the parent
+ # Save inline Rulesets
+ formset.save()
+ # Save Group instance and update permissions
+ form.instance.save(update_fields=['name'])
class InvenTreeUserAdmin(UserAdmin):
diff --git a/InvenTree/users/models.py b/InvenTree/users/models.py
index 7c0768428b..9dd117f1bd 100644
--- a/InvenTree/users/models.py
+++ b/InvenTree/users/models.py
@@ -57,6 +57,7 @@ class RuleSet(models.Model):
'part_parttesttemplate',
'part_partparametertemplate',
'part_partparameter',
+ 'part_partrelated',
],
'stock': [
'stock_stockitem',