Merge remote-tracking branch 'inventree/master'

This commit is contained in:
Oliver Walters 2020-11-10 12:28:05 +11:00
commit 0f80c2699e
17 changed files with 1447 additions and 895 deletions

View File

@ -28,6 +28,11 @@ class HelperForm(forms.ModelForm):
self.helper.form_tag = False
# Check for errors from model validation
# If none, disable crispy form errors
if not self.errors:
self.helper.form_show_errors = False
"""
Create a default 'layout' for this form.
Ref: https://django-crispy-forms.readthedocs.io/en/latest/layouts.html

View File

@ -4,16 +4,29 @@
<li{% if tab == 'details' %} class='active'{% endif %}>
<a href="{% url 'build-detail' build.id %}">{% trans "Details" %}</a>
</li>
{% if build.active %}
<li{% if tab == 'allocate' %} class='active'{% endif %}>
<a href="{% url 'build-allocate' build.id %}">{% trans "Allocate Parts" %}</a>
<a href="{% url 'build-allocate' build.id %}">
{% trans "Incomplete" %}
<span class='badge'>{{ build.incomplete_outputs.count }}</span>
</a>
</li>
{% endif %}
<li{% if tab == 'output' %} class='active'{% endif %}>
<a href="{% url 'build-output' build.id %}">{% trans "Build Outputs" %}{% if build.output_count > 0%}<span class='badge'>{{ build.output_count }}</span>{% endif %}</a>
<a href="{% url 'build-output' build.id %}">
{% trans "Build Outputs" %}
<span class='badge'>{{ build.output_count }}</span>
</a>
</li>
<li{% if tab == 'notes' %} class='active'{% endif %}>
<a href="{% url 'build-notes' build.id %}">{% trans "Notes" %}{% if build.notes %} <span class='fas fa-info-circle'></span>{% endif %}</a>
<a href="{% url 'build-notes' build.id %}">
{% trans "Notes" %}
{% if build.notes %} <span class='fas fa-info-circle'></span>{% endif %}
</a>
</li>
<li {% if tab == 'attachments' %} class='active'{% endif %}>
<a href='{% url "build-attachments" build.id %}'>{% trans "Attachments" %}</a>
<a href='{% url "build-attachments" build.id %}'>
{% trans "Attachments" %}
</a>
</li>
</ul>

View File

@ -64,6 +64,13 @@ class InvenTreeSetting(models.Model):
'description': _('Regular expression pattern for matching Part IPN')
},
'PART_ALLOW_DUPLICATE_IPN': {
'name': _('Allow Duplicate IPN'),
'description': _('Allow multiple parts to share the same IPN'),
'default': True,
'validator': bool,
},
'PART_COPY_BOM': {
'name': _('Copy Part BOM Data'),
'description': _('Copy BOM data by default when duplicating a part'),
@ -85,6 +92,34 @@ class InvenTreeSetting(models.Model):
'validator': bool
},
'PART_COMPONENT': {
'name': _('Component'),
'description': _('Parts can be used as sub-components by default'),
'default': True,
'validator': bool,
},
'PART_PURCHASEABLE': {
'name': _('Purchaseable'),
'description': _('Parts are purchaseable by default'),
'default': False,
'validator': bool,
},
'PART_SALABLE': {
'name': _('Salable'),
'description': _('Parts are salable by default'),
'default': False,
'validator': bool,
},
'PART_TRACKABLE': {
'name': _('Trackable'),
'description': _('Parts are trackable by default'),
'default': False,
'validator': bool,
},
'BUILDORDER_REFERENCE_PREFIX': {
'name': _('Build Order Reference Prefix'),
'description': _('Prefix value for build order reference'),
@ -242,9 +277,16 @@ class InvenTreeSetting(models.Model):
setting = InvenTreeSetting.get_setting_object(key)
if setting:
return setting.value
value = setting.value
# If the particular setting is defined as a boolean, cast the value to a boolean
if setting.is_bool():
value = InvenTree.helpers.str2bool(value)
else:
return backup_value
value = backup_value
return value
@classmethod
def set_setting(cls, key, value, user, create=True):
@ -270,6 +312,10 @@ class InvenTreeSetting(models.Model):
setting = InvenTreeSetting(key=key)
else:
return
# Enforce standard boolean representation
if setting.is_bool():
value = InvenTree.helpers.str2bool(value)
setting.value = str(value)
setting.save()
@ -282,6 +328,10 @@ class InvenTreeSetting(models.Model):
def name(self):
return InvenTreeSetting.get_setting_name(self.key)
@property
def default_value(self):
return InvenTreeSetting.get_default_value(self.key)
@property
def description(self):
return InvenTreeSetting.get_setting_description(self.key)

View File

@ -69,4 +69,14 @@ class SettingsTest(TestCase):
InvenTreeSetting.set_setting(key, value, self.user)
self.assertEqual(str(value), InvenTreeSetting.get_setting(key))
self.assertEqual(value, InvenTreeSetting.get_setting(key))
# Any fields marked as 'boolean' must have a default value specified
setting = InvenTreeSetting.get_setting_object(key)
if setting.is_bool():
if setting.default_value in ['', None]:
raise ValueError(f'Default value for boolean setting {key} not provided')
if setting.default_value not in [True, False]:
raise ValueError(f'Non-boolean default value specified for {key}')

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -335,7 +335,8 @@ class PurchaseOrderCreate(AjaxCreateView):
order = form.save(commit=False)
order.created_by = self.request.user
order.save()
return super().save(form)
class SalesOrderCreate(AjaxCreateView):
@ -370,7 +371,8 @@ class SalesOrderCreate(AjaxCreateView):
order = form.save(commit=False)
order.created_by = self.request.user
order.save()
return super().save(form)
class PurchaseOrderEdit(AjaxUpdateView):
@ -428,7 +430,7 @@ class PurchaseOrderCancel(AjaxUpdateView):
form.add_error('confirm', _('Confirm order cancellation'))
if not order.can_cancel():
form.add_error(None, _('Order cannot be cancelled'))
form.add_error(None, _('Order cannot be cancelled as either pending or placed'))
def save(self, order, form, **kwargs):
"""

View File

@ -174,7 +174,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',
@ -202,14 +204,14 @@ class EditPartForm(HelperForm):
class Meta:
model = Part
fields = [
'bom_copy',
'parameters_copy',
'confirm_creation',
'category',
'name',
'IPN',
'description',
'revision',
'bom_copy',
'parameters_copy',
'confirm_creation',
'keywords',
'variant_of',
'link',
@ -217,6 +219,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

@ -47,6 +47,7 @@ from company.models import SupplierPart
from stock import models as StockModels
import common.models
import part.settings as part_settings
class PartCategory(InvenTreeTree):
@ -528,6 +529,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(
@ -656,19 +669,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'))

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

@ -1247,12 +1247,21 @@ class StockItem(MPTTModel):
@property
def required_test_count(self):
"""
Return the number of 'required tests' for this StockItem
"""
return self.part.getRequiredTests().count()
def hasRequiredTests(self):
"""
Return True if there are any 'required tests' associated with this StockItem
"""
return self.part.getRequiredTests().count() > 0
def passedAllRequiredTests(self):
"""
Returns True if this StockItem has passed all required tests
"""
status = self.requiredTestStatus()

View File

@ -11,10 +11,19 @@
{% block settings %}
<h4>{% trans "Part Options" %}</h4>
<table class='table table-striped table-condensed'>
<thead></thead>
<tbody>
{% include "InvenTree/settings/setting.html" with key="PART_IPN_REGEX" %}
{% include "InvenTree/settings/setting.html" with key="PART_ALLOW_DUPLICATE_IPN" %}
<tr><td colspan='4'></td></tr>
{% include "InvenTree/settings/setting.html" with key="PART_COMPONENT" %}
{% include "InvenTree/settings/setting.html" with key="PART_PURCHASEABLE" %}
{% include "InvenTree/settings/setting.html" with key="PART_SALABLE" %}
{% include "InvenTree/settings/setting.html" with key="PART_TRACKABLE" %}
<tr><td colspan='4'></td></tr>
{% include "InvenTree/settings/setting.html" with key="PART_COPY_BOM" %}
{% include "InvenTree/settings/setting.html" with key="PART_COPY_PARAMETERS" %}
{% include "InvenTree/settings/setting.html" with key="PART_COPY_TESTS" %}

View File

@ -316,7 +316,8 @@ def update_group_roles(group, debug=False):
permission = get_permission_object(perm)
group.permissions.add(permission)
if permission:
group.permissions.add(permission)
if debug:
print(f"Adding permission {perm} to group {group.name}")
@ -330,7 +331,8 @@ def update_group_roles(group, debug=False):
permission = get_permission_object(perm)
group.permissions.remove(permission)
if permission:
group.permissions.remove(permission)
if debug:
print(f"Removing permission {perm} from group {group.name}")