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-11 06:40:11 -05:00
committed by GitHub
26 changed files with 1516 additions and 954 deletions

View File

@ -155,11 +155,6 @@ class CreatePartRelatedForm(HelperForm):
'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 """
@ -180,7 +175,9 @@ class SetPartCategoryForm(forms.Form):
class EditPartForm(HelperForm):
""" Form for editing a Part object """
"""
Form for editing a Part object.
"""
field_prefix = {
'keywords': 'fa-key',
@ -218,9 +215,6 @@ class EditPartForm(HelperForm):
class Meta:
model = Part
fields = [
'bom_copy',
'parameters_copy',
'confirm_creation',
'category',
'selected_category_templates',
'parent_category_templates',
@ -228,6 +222,9 @@ class EditPartForm(HelperForm):
'IPN',
'description',
'revision',
'bom_copy',
'parameters_copy',
'confirm_creation',
'keywords',
'variant_of',
'link',
@ -235,6 +232,9 @@ class EditPartForm(HelperForm):
'default_supplier',
'units',
'minimum_stock',
'trackable',
'purchaseable',
'salable',
]

View File

@ -0,0 +1,44 @@
# Generated by Django 3.0.7 on 2020-11-09 12:46
from django.db import migrations, models
import part.settings
class Migration(migrations.Migration):
dependencies = [
('part', '0053_merge_20201103_1028'),
]
operations = [
migrations.AlterField(
model_name='part',
name='active',
field=models.BooleanField(default=True, help_text='Is this part active?', verbose_name='Active'),
),
migrations.AlterField(
model_name='part',
name='component',
field=models.BooleanField(default=part.settings.part_component_default, help_text='Can this part be used to build other parts?', verbose_name='Component'),
),
migrations.AlterField(
model_name='part',
name='purchaseable',
field=models.BooleanField(default=part.settings.part_purchaseable_default, help_text='Can this part be purchased from external suppliers?', verbose_name='Purchaseable'),
),
migrations.AlterField(
model_name='part',
name='salable',
field=models.BooleanField(default=part.settings.part_salable_default, help_text='Can this part be sold to customers?', verbose_name='Salable'),
),
migrations.AlterField(
model_name='part',
name='trackable',
field=models.BooleanField(default=part.settings.part_trackable_default, help_text='Does this part have tracking for unique items?', verbose_name='Trackable'),
),
migrations.AlterField(
model_name='part',
name='virtual',
field=models.BooleanField(default=False, help_text='Is this a virtual part, such as a software product or license?', verbose_name='Virtual'),
),
]

View File

@ -48,6 +48,7 @@ from company.models import SupplierPart
from stock import models as StockModels
import common.models
import part.settings as part_settings
class PartCategory(InvenTreeTree):
@ -590,6 +591,18 @@ class Part(MPTTModel):
"""
super().validate_unique(exclude)
# User can decide whether duplicate IPN (Internal Part Number) values are allowed
allow_duplicate_ipn = common.models.InvenTreeSetting.get_setting('PART_ALLOW_DUPLICATE_IPN')
if not allow_duplicate_ipn:
parts = Part.objects.filter(IPN__iexact=self.IPN)
parts = parts.exclude(pk=self.pk)
if parts.exists():
raise ValidationError({
'IPN': _('Duplicate IPN not allowed in part settings'),
})
# Part name uniqueness should be case insensitive
try:
parts = Part.objects.exclude(id=self.id).filter(
@ -620,7 +633,8 @@ class Part(MPTTModel):
super().clean()
if self.trackable:
for parent_part in self.used_in.all():
for item in self.used_in.all():
parent_part = item.part
if not parent_part.trackable:
parent_part.trackable = True
parent_part.clean()
@ -718,19 +732,42 @@ class Part(MPTTModel):
units = models.CharField(max_length=20, default="", blank=True, null=True, help_text=_('Stock keeping units for this part'))
assembly = models.BooleanField(default=False, verbose_name='Assembly', help_text=_('Can this part be built from other parts?'))
assembly = models.BooleanField(
default=False,
verbose_name=_('Assembly'),
help_text=_('Can this part be built from other parts?')
)
component = models.BooleanField(default=True, verbose_name='Component', help_text=_('Can this part be used to build other parts?'))
component = models.BooleanField(
default=part_settings.part_component_default,
verbose_name=_('Component'),
help_text=_('Can this part be used to build other parts?')
)
trackable = models.BooleanField(default=False, help_text=_('Does this part have tracking for unique items?'))
trackable = models.BooleanField(
default=part_settings.part_trackable_default,
verbose_name=_('Trackable'),
help_text=_('Does this part have tracking for unique items?'))
purchaseable = models.BooleanField(default=True, help_text=_('Can this part be purchased from external suppliers?'))
purchaseable = models.BooleanField(
default=part_settings.part_purchaseable_default,
verbose_name=_('Purchaseable'),
help_text=_('Can this part be purchased from external suppliers?'))
salable = models.BooleanField(default=False, help_text=_("Can this part be sold to customers?"))
salable = models.BooleanField(
default=part_settings.part_salable_default,
verbose_name=_('Salable'),
help_text=_("Can this part be sold to customers?"))
active = models.BooleanField(default=True, help_text=_('Is this part active?'))
active = models.BooleanField(
default=True,
verbose_name=_('Active'),
help_text=_('Is this part active?'))
virtual = models.BooleanField(default=False, help_text=_('Is this a virtual part, such as a software product or license?'))
virtual = models.BooleanField(
default=False,
verbose_name=_('Virtual'),
help_text=_('Is this a virtual part, such as a software product or license?'))
notes = MarkdownxField(blank=True, null=True, help_text=_('Part notes - supports Markdown formatting'))
@ -1067,8 +1104,16 @@ class Part(MPTTModel):
- Exclude parts which this part is in the BOM for
"""
parts = Part.objects.filter(component=True).exclude(id=self.id)
parts = parts.exclude(id__in=[part.id for part in self.used_in.all()])
# Start with a list of all parts designated as 'sub components'
parts = Part.objects.filter(component=True)
# Exclude this part
parts = parts.exclude(id=self.id)
# Exclude any parts that this part is used *in* (to prevent recursive BOMs)
used_in = self.used_in.all()
parts = parts.exclude(id__in=[item.part.id for item in used_in])
return parts
@ -2013,24 +2058,3 @@ class PartRelated(models.Model):
'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

@ -0,0 +1,40 @@
"""
User-configurable settings for the Part app
"""
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from common.models import InvenTreeSetting
def part_component_default():
"""
Returns the default value for the 'component' field of a Part object
"""
return InvenTreeSetting.get_setting('PART_COMPONENT')
def part_purchaseable_default():
"""
Returns the default value for the 'purchasable' field for a Part object
"""
return InvenTreeSetting.get_setting('PART_PURCHASEABLE')
def part_salable_default():
"""
Returns the default value for the 'salable' field for a Part object
"""
return InvenTreeSetting.get_setting('PART_SALABLE')
def part_trackable_default():
"""
Returns the defualt value fro the 'trackable' field for a Part object
"""
return InvenTreeSetting.get_setting('PART_TRACKABLE')

View File

@ -203,6 +203,7 @@
<td><i>{% trans "Part cannot be sold to customers" %}</i></td>
{% endif %}
</tr>
<tr><td colspan='4'></td></tr>
<tr>
<td>
{% if part.active %}

View File

@ -4,6 +4,8 @@
from __future__ import unicode_literals
from django.contrib.auth import get_user_model
from django.test import TestCase
from django.core.exceptions import ValidationError
@ -13,6 +15,10 @@ from .models import Part, PartTestTemplate
from .models import rename_part_image, match_part_names
from .templatetags import inventree_extras
import part.settings
from common.models import InvenTreeSetting
class TemplateTagTest(TestCase):
""" Tests for the custom template tag code """
@ -164,3 +170,107 @@ class TestTemplateTest(TestCase):
PartTestTemplate.objects.create(part=variant, test_name='A Sample Test')
self.assertEqual(variant.getTestTemplates().count(), n + 1)
class PartSettingsTest(TestCase):
"""
Tests to ensure that the user-configurable default values work as expected.
Some fields for the Part model can have default values specified by the user.
"""
def setUp(self):
# Create a user for auth
User = get_user_model()
self.user = User.objects.create_user(
username='testuser',
email='test@testing.com',
password='password',
is_staff=True
)
def make_part(self):
"""
Helper function to create a simple part
"""
part = Part.objects.create(
name='Test Part',
description='I am but a humble test part',
IPN='IPN-123',
)
return part
def test_defaults(self):
"""
Test that the default values for the part settings are correct
"""
self.assertTrue(part.settings.part_component_default())
self.assertFalse(part.settings.part_purchaseable_default())
self.assertFalse(part.settings.part_salable_default())
self.assertFalse(part.settings.part_trackable_default())
def test_initial(self):
"""
Test the 'initial' default values (no default values have been set)
"""
part = self.make_part()
self.assertTrue(part.component)
self.assertFalse(part.purchaseable)
self.assertFalse(part.salable)
self.assertFalse(part.trackable)
def test_custom(self):
"""
Update some of the part values and re-test
"""
for val in [True, False]:
InvenTreeSetting.set_setting('PART_COMPONENT', val, self.user)
InvenTreeSetting.set_setting('PART_PURCHASEABLE', val, self.user)
InvenTreeSetting.set_setting('PART_SALABLE', val, self.user)
InvenTreeSetting.set_setting('PART_TRACKABLE', val, self.user)
self.assertEqual(val, InvenTreeSetting.get_setting('PART_COMPONENT'))
self.assertEqual(val, InvenTreeSetting.get_setting('PART_PURCHASEABLE'))
self.assertEqual(val, InvenTreeSetting.get_setting('PART_SALABLE'))
self.assertEqual(val, InvenTreeSetting.get_setting('PART_TRACKABLE'))
part = self.make_part()
self.assertEqual(part.component, val)
self.assertEqual(part.purchaseable, val)
self.assertEqual(part.salable, val)
self.assertEqual(part.trackable, val)
Part.objects.filter(pk=part.pk).delete()
def test_duplicate_ipn(self):
"""
Test the setting which controls duplicate IPN values
"""
# Create a part
Part.objects.create(name='Hello', description='A thing', IPN='IPN123')
# Attempt to create a duplicate item (should fail)
with self.assertRaises(ValidationError):
Part.objects.create(name='Hello', description='A thing', IPN='IPN123')
# Attempt to create item with duplicate IPN (should be allowed by default)
Part.objects.create(name='Hello', description='A thing', IPN='IPN123', revision='B')
# And attempt again with the same values (should fail)
with self.assertRaises(ValidationError):
Part.objects.create(name='Hello', description='A thing', IPN='IPN123', revision='B')
# Now update the settings so duplicate IPN values are *not* allowed
InvenTreeSetting.set_setting('PART_ALLOW_DUPLICATE_IPN', False, self.user)
with self.assertRaises(ValidationError):
Part.objects.create(name='Hello', description='A thing', IPN='IPN123', revision='C')

View File

@ -5,7 +5,7 @@ from django.urls import reverse
from django.contrib.auth import get_user_model
from django.contrib.auth.models import Group
from .models import Part
from .models import Part, PartRelated
class PartViewTestCase(TestCase):
@ -204,24 +204,31 @@ class PartTests(PartViewTestCase):
class PartRelatedTests(PartViewTestCase):
def test_valid_create(self):
""" test creation of an attachment for a valid part """
""" test creation of a related part """
response = self.client.get(reverse('part-related-create'), {'part': 1}, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
# Test GET view
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
# Test POST view with valid form data
response = self.client.post(reverse('part-related-create'), {'part_1': 1, 'part_2': 2},
HTTP_X_REQUESTED_WITH='XMLHttpRequest')
self.assertContains(response, '"form_valid": true', status_code=200)
def test_invalid_create(self):
""" test creation of an attachment for an invalid part """
# Try to create the same relationship with part_1 and part_2 pks reversed
response = self.client.post(reverse('part-related-create'), {'part_1': 2, 'part_2': 1},
HTTP_X_REQUESTED_WITH='XMLHttpRequest')
self.assertContains(response, '"form_valid": false', status_code=200)
# TODO
pass
def test_edit(self):
""" test editing an attachment """
# TODO
pass
# Try to create part related to itself
response = self.client.post(reverse('part-related-create'), {'part_1': 1, 'part_2': 1},
HTTP_X_REQUESTED_WITH='XMLHttpRequest')
self.assertContains(response, '"form_valid": false', status_code=200)
# Check final count
n = PartRelated.objects.all().count()
self.assertEqual(n, 1)
class PartAttachmentTests(PartViewTestCase):

View File

@ -130,17 +130,6 @@ class PartRelatedCreate(AjaxCreateView):
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 """