mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +00:00
Merge branch 'master' into categories_parameters
This commit is contained in:
commit
a7444a9926
@ -29,6 +29,11 @@ class HelperForm(forms.ModelForm):
|
|||||||
|
|
||||||
self.helper.form_tag = False
|
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.
|
Create a default 'layout' for this form.
|
||||||
Ref: https://django-crispy-forms.readthedocs.io/en/latest/layouts.html
|
Ref: https://django-crispy-forms.readthedocs.io/en/latest/layouts.html
|
||||||
|
@ -155,6 +155,8 @@ INSTALLED_APPS = [
|
|||||||
'markdownify', # Markdown template rendering
|
'markdownify', # Markdown template rendering
|
||||||
'django_tex', # LaTeX output
|
'django_tex', # LaTeX output
|
||||||
'django_admin_shell', # Python shell for the admin interface
|
'django_admin_shell', # Python shell for the admin interface
|
||||||
|
'error_report', # Error reporting in the admin interface
|
||||||
|
|
||||||
]
|
]
|
||||||
|
|
||||||
LOGGING = {
|
LOGGING = {
|
||||||
@ -181,6 +183,9 @@ MIDDLEWARE = CONFIG.get('middleware', [
|
|||||||
'InvenTree.middleware.AuthRequiredMiddleware'
|
'InvenTree.middleware.AuthRequiredMiddleware'
|
||||||
])
|
])
|
||||||
|
|
||||||
|
# Error reporting middleware
|
||||||
|
MIDDLEWARE.append('error_report.middleware.ExceptionProcessor')
|
||||||
|
|
||||||
AUTHENTICATION_BACKENDS = CONFIG.get('authentication_backends', [
|
AUTHENTICATION_BACKENDS = CONFIG.get('authentication_backends', [
|
||||||
'django.contrib.auth.backends.ModelBackend'
|
'django.contrib.auth.backends.ModelBackend'
|
||||||
])
|
])
|
||||||
|
@ -128,6 +128,7 @@ urlpatterns = [
|
|||||||
url(r'^edit-user/', EditUserView.as_view(), name='edit-user'),
|
url(r'^edit-user/', EditUserView.as_view(), name='edit-user'),
|
||||||
url(r'^set-password/', SetPasswordView.as_view(), name='set-password'),
|
url(r'^set-password/', SetPasswordView.as_view(), name='set-password'),
|
||||||
|
|
||||||
|
url(r'^admin/error_log/', include('error_report.urls')),
|
||||||
url(r'^admin/shell/', include('django_admin_shell.urls')),
|
url(r'^admin/shell/', include('django_admin_shell.urls')),
|
||||||
url(r'^admin/', admin.site.urls, name='inventree-admin'),
|
url(r'^admin/', admin.site.urls, name='inventree-admin'),
|
||||||
|
|
||||||
|
@ -66,7 +66,7 @@ class BuildOutputCreateForm(HelperForm):
|
|||||||
'serial_numbers': 'fa-hashtag',
|
'serial_numbers': 'fa-hashtag',
|
||||||
}
|
}
|
||||||
|
|
||||||
quantity = forms.IntegerField(
|
output_quantity = forms.IntegerField(
|
||||||
label=_('Quantity'),
|
label=_('Quantity'),
|
||||||
help_text=_('Enter quantity for build output'),
|
help_text=_('Enter quantity for build output'),
|
||||||
)
|
)
|
||||||
@ -86,7 +86,7 @@ class BuildOutputCreateForm(HelperForm):
|
|||||||
class Meta:
|
class Meta:
|
||||||
model = Build
|
model = Build
|
||||||
fields = [
|
fields = [
|
||||||
'quantity',
|
'output_quantity',
|
||||||
'batch',
|
'batch',
|
||||||
'serial_numbers',
|
'serial_numbers',
|
||||||
'confirm',
|
'confirm',
|
||||||
|
@ -182,6 +182,14 @@ class Build(MPTTModel):
|
|||||||
blank=True, help_text=_('Extra build notes')
|
blank=True, help_text=_('Extra build notes')
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def active(self):
|
||||||
|
"""
|
||||||
|
Return True if this build is active
|
||||||
|
"""
|
||||||
|
|
||||||
|
return self.status in BuildStatus.ACTIVE_CODES
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def bom_items(self):
|
def bom_items(self):
|
||||||
"""
|
"""
|
||||||
@ -594,6 +602,9 @@ class Build(MPTTModel):
|
|||||||
- Mark the output as complete
|
- Mark the output as complete
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
# Select the location for the build output
|
||||||
|
location = kwargs.get('location', self.destination)
|
||||||
|
|
||||||
# List the allocated BuildItem objects for the given output
|
# List the allocated BuildItem objects for the given output
|
||||||
allocated_items = output.items_to_install.all()
|
allocated_items = output.items_to_install.all()
|
||||||
|
|
||||||
@ -613,6 +624,7 @@ class Build(MPTTModel):
|
|||||||
# Ensure that the output is updated correctly
|
# Ensure that the output is updated correctly
|
||||||
output.build = self
|
output.build = self
|
||||||
output.is_building = False
|
output.is_building = False
|
||||||
|
output.location = location
|
||||||
|
|
||||||
output.save()
|
output.save()
|
||||||
|
|
||||||
|
@ -21,6 +21,7 @@ InvenTree | Allocate Parts
|
|||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class='btn-group' role='group'>
|
<div class='btn-group' role='group'>
|
||||||
|
{% if build.active %}
|
||||||
<button class='btn btn-primary' type='button' id='btn-create-output' title='{% trans "Create new build output" %}'>
|
<button class='btn btn-primary' type='button' id='btn-create-output' title='{% trans "Create new build output" %}'>
|
||||||
<span class='fas fa-plus-circle'></span> {% trans "Create New Output" %}
|
<span class='fas fa-plus-circle'></span> {% trans "Create New Output" %}
|
||||||
</button>
|
</button>
|
||||||
@ -32,6 +33,7 @@ InvenTree | Allocate Parts
|
|||||||
<button class='btn btn-danger' type='button' id='btn-unallocate' title='{% trans "Unallocate stock" %}'>
|
<button class='btn btn-danger' type='button' id='btn-unallocate' title='{% trans "Unallocate stock" %}'>
|
||||||
<span class='fas fa-minus-circle'></span> {% trans "Unallocate Stock" %}
|
<span class='fas fa-minus-circle'></span> {% trans "Unallocate Stock" %}
|
||||||
</button>
|
</button>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<hr>
|
<hr>
|
||||||
@ -74,7 +76,7 @@ InvenTree | Allocate Parts
|
|||||||
);
|
);
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|
||||||
{% if build.status == BuildStatus.PENDING %}
|
{% if build.active %}
|
||||||
$("#btn-allocate").on('click', function() {
|
$("#btn-allocate").on('click', function() {
|
||||||
launchModalForm(
|
launchModalForm(
|
||||||
"{% url 'build-auto-allocate' build.id %}",
|
"{% url 'build-auto-allocate' build.id %}",
|
||||||
|
@ -4,16 +4,29 @@
|
|||||||
<li{% if tab == 'details' %} class='active'{% endif %}>
|
<li{% if tab == 'details' %} class='active'{% endif %}>
|
||||||
<a href="{% url 'build-detail' build.id %}">{% trans "Details" %}</a>
|
<a href="{% url 'build-detail' build.id %}">{% trans "Details" %}</a>
|
||||||
</li>
|
</li>
|
||||||
|
{% if build.active %}
|
||||||
<li{% if tab == 'allocate' %} class='active'{% endif %}>
|
<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>
|
</li>
|
||||||
|
{% endif %}
|
||||||
<li{% if tab == 'output' %} class='active'{% 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>
|
||||||
<li{% if tab == 'notes' %} class='active'{% endif %}>
|
<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>
|
||||||
<li {% if tab == 'attachments' %} class='active'{% endif %}>
|
<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>
|
</li>
|
||||||
</ul>
|
</ul>
|
@ -188,7 +188,7 @@ class BuildOutputCreate(AjaxUpdateView):
|
|||||||
Validation for the form:
|
Validation for the form:
|
||||||
"""
|
"""
|
||||||
|
|
||||||
quantity = form.cleaned_data.get('quantity', None)
|
quantity = form.cleaned_data.get('output_quantity', None)
|
||||||
serials = form.cleaned_data.get('serial_numbers', None)
|
serials = form.cleaned_data.get('serial_numbers', None)
|
||||||
|
|
||||||
# Check that the serial numbers are valid
|
# Check that the serial numbers are valid
|
||||||
@ -222,7 +222,7 @@ class BuildOutputCreate(AjaxUpdateView):
|
|||||||
|
|
||||||
data = form.cleaned_data
|
data = form.cleaned_data
|
||||||
|
|
||||||
quantity = data.get('quantity', None)
|
quantity = data.get('output_quantity', None)
|
||||||
batch = data.get('batch', None)
|
batch = data.get('batch', None)
|
||||||
|
|
||||||
serials = data.get('serial_numbers', None)
|
serials = data.get('serial_numbers', None)
|
||||||
|
@ -64,6 +64,13 @@ class InvenTreeSetting(models.Model):
|
|||||||
'description': _('Regular expression pattern for matching Part IPN')
|
'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': {
|
'PART_COPY_BOM': {
|
||||||
'name': _('Copy Part BOM Data'),
|
'name': _('Copy Part BOM Data'),
|
||||||
'description': _('Copy BOM data by default when duplicating a part'),
|
'description': _('Copy BOM data by default when duplicating a part'),
|
||||||
@ -92,6 +99,41 @@ class InvenTreeSetting(models.Model):
|
|||||||
'validator': bool
|
'validator': bool
|
||||||
},
|
},
|
||||||
|
|
||||||
|
'PART_CATEGORY_PARAMETERS': {
|
||||||
|
'name': _('Copy Category Parameter Templates'),
|
||||||
|
'description': _('Copy category parameter templates when creating a part'),
|
||||||
|
'default': True,
|
||||||
|
'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': {
|
'BUILDORDER_REFERENCE_PREFIX': {
|
||||||
'name': _('Build Order Reference Prefix'),
|
'name': _('Build Order Reference Prefix'),
|
||||||
'description': _('Prefix value for build order reference'),
|
'description': _('Prefix value for build order reference'),
|
||||||
@ -249,9 +291,16 @@ class InvenTreeSetting(models.Model):
|
|||||||
setting = InvenTreeSetting.get_setting_object(key)
|
setting = InvenTreeSetting.get_setting_object(key)
|
||||||
|
|
||||||
if setting:
|
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:
|
else:
|
||||||
return backup_value
|
value = backup_value
|
||||||
|
|
||||||
|
return value
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def set_setting(cls, key, value, user, create=True):
|
def set_setting(cls, key, value, user, create=True):
|
||||||
@ -278,6 +327,10 @@ class InvenTreeSetting(models.Model):
|
|||||||
else:
|
else:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# Enforce standard boolean representation
|
||||||
|
if setting.is_bool():
|
||||||
|
value = InvenTree.helpers.str2bool(value)
|
||||||
|
|
||||||
setting.value = str(value)
|
setting.value = str(value)
|
||||||
setting.save()
|
setting.save()
|
||||||
|
|
||||||
@ -289,6 +342,10 @@ class InvenTreeSetting(models.Model):
|
|||||||
def name(self):
|
def name(self):
|
||||||
return InvenTreeSetting.get_setting_name(self.key)
|
return InvenTreeSetting.get_setting_name(self.key)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def default_value(self):
|
||||||
|
return InvenTreeSetting.get_default_value(self.key)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def description(self):
|
def description(self):
|
||||||
return InvenTreeSetting.get_setting_description(self.key)
|
return InvenTreeSetting.get_setting_description(self.key)
|
||||||
|
@ -69,4 +69,14 @@ class SettingsTest(TestCase):
|
|||||||
|
|
||||||
InvenTreeSetting.set_setting(key, value, self.user)
|
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
@ -335,7 +335,8 @@ class PurchaseOrderCreate(AjaxCreateView):
|
|||||||
|
|
||||||
order = form.save(commit=False)
|
order = form.save(commit=False)
|
||||||
order.created_by = self.request.user
|
order.created_by = self.request.user
|
||||||
order.save()
|
|
||||||
|
return super().save(form)
|
||||||
|
|
||||||
|
|
||||||
class SalesOrderCreate(AjaxCreateView):
|
class SalesOrderCreate(AjaxCreateView):
|
||||||
@ -370,7 +371,8 @@ class SalesOrderCreate(AjaxCreateView):
|
|||||||
|
|
||||||
order = form.save(commit=False)
|
order = form.save(commit=False)
|
||||||
order.created_by = self.request.user
|
order.created_by = self.request.user
|
||||||
order.save()
|
|
||||||
|
return super().save(form)
|
||||||
|
|
||||||
|
|
||||||
class PurchaseOrderEdit(AjaxUpdateView):
|
class PurchaseOrderEdit(AjaxUpdateView):
|
||||||
@ -428,7 +430,7 @@ class PurchaseOrderCancel(AjaxUpdateView):
|
|||||||
form.add_error('confirm', _('Confirm order cancellation'))
|
form.add_error('confirm', _('Confirm order cancellation'))
|
||||||
|
|
||||||
if not order.can_cancel():
|
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):
|
def save(self, order, form, **kwargs):
|
||||||
"""
|
"""
|
||||||
|
@ -155,11 +155,6 @@ class CreatePartRelatedForm(HelperForm):
|
|||||||
'part_2': _('Related Part'),
|
'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 """
|
||||||
@ -180,7 +175,9 @@ class SetPartCategoryForm(forms.Form):
|
|||||||
|
|
||||||
|
|
||||||
class EditPartForm(HelperForm):
|
class EditPartForm(HelperForm):
|
||||||
""" Form for editing a Part object """
|
"""
|
||||||
|
Form for editing a Part object.
|
||||||
|
"""
|
||||||
|
|
||||||
field_prefix = {
|
field_prefix = {
|
||||||
'keywords': 'fa-key',
|
'keywords': 'fa-key',
|
||||||
@ -218,9 +215,6 @@ class EditPartForm(HelperForm):
|
|||||||
class Meta:
|
class Meta:
|
||||||
model = Part
|
model = Part
|
||||||
fields = [
|
fields = [
|
||||||
'bom_copy',
|
|
||||||
'parameters_copy',
|
|
||||||
'confirm_creation',
|
|
||||||
'category',
|
'category',
|
||||||
'selected_category_templates',
|
'selected_category_templates',
|
||||||
'parent_category_templates',
|
'parent_category_templates',
|
||||||
@ -228,6 +222,9 @@ class EditPartForm(HelperForm):
|
|||||||
'IPN',
|
'IPN',
|
||||||
'description',
|
'description',
|
||||||
'revision',
|
'revision',
|
||||||
|
'bom_copy',
|
||||||
|
'parameters_copy',
|
||||||
|
'confirm_creation',
|
||||||
'keywords',
|
'keywords',
|
||||||
'variant_of',
|
'variant_of',
|
||||||
'link',
|
'link',
|
||||||
@ -235,6 +232,9 @@ class EditPartForm(HelperForm):
|
|||||||
'default_supplier',
|
'default_supplier',
|
||||||
'units',
|
'units',
|
||||||
'minimum_stock',
|
'minimum_stock',
|
||||||
|
'trackable',
|
||||||
|
'purchaseable',
|
||||||
|
'salable',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
44
InvenTree/part/migrations/0054_auto_20201109_1246.py
Normal file
44
InvenTree/part/migrations/0054_auto_20201109_1246.py
Normal 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'),
|
||||||
|
),
|
||||||
|
]
|
@ -48,6 +48,7 @@ from company.models import SupplierPart
|
|||||||
from stock import models as StockModels
|
from stock import models as StockModels
|
||||||
|
|
||||||
import common.models
|
import common.models
|
||||||
|
import part.settings as part_settings
|
||||||
|
|
||||||
|
|
||||||
class PartCategory(InvenTreeTree):
|
class PartCategory(InvenTreeTree):
|
||||||
@ -590,6 +591,18 @@ class Part(MPTTModel):
|
|||||||
"""
|
"""
|
||||||
super().validate_unique(exclude)
|
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
|
# Part name uniqueness should be case insensitive
|
||||||
try:
|
try:
|
||||||
parts = Part.objects.exclude(id=self.id).filter(
|
parts = Part.objects.exclude(id=self.id).filter(
|
||||||
@ -620,7 +633,8 @@ class Part(MPTTModel):
|
|||||||
super().clean()
|
super().clean()
|
||||||
|
|
||||||
if self.trackable:
|
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:
|
if not parent_part.trackable:
|
||||||
parent_part.trackable = True
|
parent_part.trackable = True
|
||||||
parent_part.clean()
|
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'))
|
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'))
|
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
|
- Exclude parts which this part is in the BOM for
|
||||||
"""
|
"""
|
||||||
|
|
||||||
parts = Part.objects.filter(component=True).exclude(id=self.id)
|
# Start with a list of all parts designated as 'sub components'
|
||||||
parts = parts.exclude(id__in=[part.id for part in self.used_in.all()])
|
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
|
return parts
|
||||||
|
|
||||||
@ -2013,24 +2058,3 @@ class PartRelated(models.Model):
|
|||||||
'and that the relationship is unique')
|
'and that the relationship is unique')
|
||||||
|
|
||||||
raise ValidationError(error_message)
|
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
|
|
||||||
|
40
InvenTree/part/settings.py
Normal file
40
InvenTree/part/settings.py
Normal 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')
|
@ -203,6 +203,7 @@
|
|||||||
<td><i>{% trans "Part cannot be sold to customers" %}</i></td>
|
<td><i>{% trans "Part cannot be sold to customers" %}</i></td>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</tr>
|
</tr>
|
||||||
|
<tr><td colspan='4'></td></tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td>
|
||||||
{% if part.active %}
|
{% if part.active %}
|
||||||
|
@ -4,6 +4,8 @@
|
|||||||
|
|
||||||
from __future__ import unicode_literals
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
from django.core.exceptions import ValidationError
|
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 .models import rename_part_image, match_part_names
|
||||||
from .templatetags import inventree_extras
|
from .templatetags import inventree_extras
|
||||||
|
|
||||||
|
import part.settings
|
||||||
|
|
||||||
|
from common.models import InvenTreeSetting
|
||||||
|
|
||||||
|
|
||||||
class TemplateTagTest(TestCase):
|
class TemplateTagTest(TestCase):
|
||||||
""" Tests for the custom template tag code """
|
""" Tests for the custom template tag code """
|
||||||
@ -164,3 +170,107 @@ class TestTemplateTest(TestCase):
|
|||||||
PartTestTemplate.objects.create(part=variant, test_name='A Sample Test')
|
PartTestTemplate.objects.create(part=variant, test_name='A Sample Test')
|
||||||
|
|
||||||
self.assertEqual(variant.getTestTemplates().count(), n + 1)
|
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')
|
||||||
|
@ -5,7 +5,7 @@ from django.urls import reverse
|
|||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
from django.contrib.auth.models import Group
|
from django.contrib.auth.models import Group
|
||||||
|
|
||||||
from .models import Part
|
from .models import Part, PartRelated
|
||||||
|
|
||||||
|
|
||||||
class PartViewTestCase(TestCase):
|
class PartViewTestCase(TestCase):
|
||||||
@ -204,24 +204,31 @@ class PartTests(PartViewTestCase):
|
|||||||
class PartRelatedTests(PartViewTestCase):
|
class PartRelatedTests(PartViewTestCase):
|
||||||
|
|
||||||
def test_valid_create(self):
|
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)
|
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):
|
# Try to create the same relationship with part_1 and part_2 pks reversed
|
||||||
""" test creation of an attachment for an invalid part """
|
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
|
# Try to create part related to itself
|
||||||
pass
|
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)
|
||||||
|
|
||||||
def test_edit(self):
|
# Check final count
|
||||||
""" test editing an attachment """
|
n = PartRelated.objects.all().count()
|
||||||
|
self.assertEqual(n, 1)
|
||||||
# TODO
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class PartAttachmentTests(PartViewTestCase):
|
class PartAttachmentTests(PartViewTestCase):
|
||||||
|
@ -130,17 +130,6 @@ class PartRelatedCreate(AjaxCreateView):
|
|||||||
|
|
||||||
return form
|
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):
|
class PartRelatedDelete(AjaxDeleteView):
|
||||||
""" View for deleting a PartRelated object """
|
""" View for deleting a PartRelated object """
|
||||||
|
@ -1247,12 +1247,21 @@ class StockItem(MPTTModel):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def required_test_count(self):
|
def required_test_count(self):
|
||||||
|
"""
|
||||||
|
Return the number of 'required tests' for this StockItem
|
||||||
|
"""
|
||||||
return self.part.getRequiredTests().count()
|
return self.part.getRequiredTests().count()
|
||||||
|
|
||||||
def hasRequiredTests(self):
|
def hasRequiredTests(self):
|
||||||
|
"""
|
||||||
|
Return True if there are any 'required tests' associated with this StockItem
|
||||||
|
"""
|
||||||
return self.part.getRequiredTests().count() > 0
|
return self.part.getRequiredTests().count() > 0
|
||||||
|
|
||||||
def passedAllRequiredTests(self):
|
def passedAllRequiredTests(self):
|
||||||
|
"""
|
||||||
|
Returns True if this StockItem has passed all required tests
|
||||||
|
"""
|
||||||
|
|
||||||
status = self.requiredTestStatus()
|
status = self.requiredTestStatus()
|
||||||
|
|
||||||
|
@ -11,10 +11,19 @@
|
|||||||
|
|
||||||
{% block settings %}
|
{% block settings %}
|
||||||
|
|
||||||
|
<h4>{% trans "Part Options" %}</h4>
|
||||||
|
|
||||||
<table class='table table-striped table-condensed'>
|
<table class='table table-striped table-condensed'>
|
||||||
<thead></thead>
|
<thead></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% include "InvenTree/settings/setting.html" with key="PART_IPN_REGEX" %}
|
{% 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_BOM" %}
|
||||||
{% include "InvenTree/settings/setting.html" with key="PART_COPY_PARAMETERS" %}
|
{% include "InvenTree/settings/setting.html" with key="PART_COPY_PARAMETERS" %}
|
||||||
{% include "InvenTree/settings/setting.html" with key="PART_COPY_TESTS" %}
|
{% include "InvenTree/settings/setting.html" with key="PART_COPY_TESTS" %}
|
||||||
|
@ -110,6 +110,9 @@ class RuleSet(models.Model):
|
|||||||
'report_reportasset',
|
'report_reportasset',
|
||||||
'report_testreport',
|
'report_testreport',
|
||||||
'part_partstar',
|
'part_partstar',
|
||||||
|
|
||||||
|
# Third-party tables
|
||||||
|
'error_report_error',
|
||||||
]
|
]
|
||||||
|
|
||||||
RULE_OPTIONS = [
|
RULE_OPTIONS = [
|
||||||
@ -317,7 +320,8 @@ def update_group_roles(group, debug=False):
|
|||||||
|
|
||||||
permission = get_permission_object(perm)
|
permission = get_permission_object(perm)
|
||||||
|
|
||||||
group.permissions.add(permission)
|
if permission:
|
||||||
|
group.permissions.add(permission)
|
||||||
|
|
||||||
if debug:
|
if debug:
|
||||||
print(f"Adding permission {perm} to group {group.name}")
|
print(f"Adding permission {perm} to group {group.name}")
|
||||||
@ -331,7 +335,8 @@ def update_group_roles(group, debug=False):
|
|||||||
|
|
||||||
permission = get_permission_object(perm)
|
permission = get_permission_object(perm)
|
||||||
|
|
||||||
group.permissions.remove(permission)
|
if permission:
|
||||||
|
group.permissions.remove(permission)
|
||||||
|
|
||||||
if debug:
|
if debug:
|
||||||
print(f"Removing permission {perm} from group {group.name}")
|
print(f"Removing permission {perm} from group {group.name}")
|
||||||
|
@ -26,5 +26,6 @@ django-tex==1.1.7 # LaTeX PDF export
|
|||||||
django-weasyprint==1.0.1 # HTML PDF export
|
django-weasyprint==1.0.1 # HTML PDF export
|
||||||
django-debug-toolbar==2.2 # Debug / profiling toolbar
|
django-debug-toolbar==2.2 # Debug / profiling toolbar
|
||||||
django-admin-shell==0.1.2 # Python shell for the admin interface
|
django-admin-shell==0.1.2 # Python shell for the admin interface
|
||||||
|
django-error-report==0.2.0 # Error report viewer for the admin interface
|
||||||
|
|
||||||
inventree # Install the latest version of the InvenTree API python library
|
inventree # Install the latest version of the InvenTree API python library
|
Loading…
Reference in New Issue
Block a user