Merge remote-tracking branch 'inventree/master' into currency-support

# Conflicts:
#	InvenTree/InvenTree/settings.py
#	InvenTree/InvenTree/urls.py
#	InvenTree/templates/InvenTree/settings/tabs.html
#	InvenTree/users/models.py
#	requirements.txt

IMPORTANT: Had to merge some migration files due to different migrations applied on the part model tables
This commit is contained in:
Oliver Walters 2020-11-12 17:24:48 +11:00
commit cb3c86f87a
31 changed files with 878 additions and 149 deletions

View File

@ -30,11 +30,24 @@ before_install:
script:
- cd InvenTree && python3 manage.py makemigrations && cd ..
- python3 ci/check_migration_files.py
# Run unit testing / code coverage tests
- invoke coverage
# Run unit test for SQL database backend
- cd InvenTree && python3 manage.py test --settings=InvenTree.ci_mysql && cd ..
# Run unit test for PostgreSQL database backend
- cd InvenTree && python3 manage.py test --settings=InvenTree.ci_postgresql && cd ..
- invoke translate
- invoke style
# Create an empty database and fill it with test data
- rm inventree_default_db.sqlite3
- invoke migrate
- invoke import-fixtures
# Export database records
- invoke export-records -f data.json
# Create a new empty database and import the saved data
- rm inventree_default_db.sqlite3
- invoke migrate
- invoke import-records -f data.json
after_success:
- coveralls

View File

@ -12,6 +12,7 @@ from crispy_forms.layout import Layout, Field
from crispy_forms.bootstrap import PrependedText, AppendedText, PrependedAppendedText, StrictButton, Div
from django.contrib.auth.models import User
from common.models import ColorTheme
from part.models import PartCategory
class HelperForm(forms.ModelForm):
@ -200,3 +201,33 @@ class ColorThemeSelectForm(forms.ModelForm):
css_class='row',
),
)
class SettingCategorySelectForm(forms.ModelForm):
""" Form for setting category settings """
category = forms.ModelChoiceField(queryset=PartCategory.objects.all())
class Meta:
model = PartCategory
fields = [
'category'
]
def __init__(self, *args, **kwargs):
super(SettingCategorySelectForm, self).__init__(*args, **kwargs)
self.helper = FormHelper()
# Form rendering
self.helper.form_show_labels = False
self.helper.layout = Layout(
Div(
Div(Field('category'),
css_class='col-sm-6',
style='width: 70%;'),
Div(StrictButton(_('Select Category'), css_class='btn btn-primary', type='submit'),
css_class='col-sm-6',
style='width: 30%; padding-left: 0;'),
css_class='row',
),
)

View File

@ -157,6 +157,7 @@ INSTALLED_APPS = [
'django_admin_shell', # Python shell for the admin interface
'djmoney', # django-money integration
'djmoney.contrib.exchange', # django-money exchange rates
'error_report', # Error reporting in the admin interface
]
LOGGING = {
@ -183,6 +184,9 @@ MIDDLEWARE = CONFIG.get('middleware', [
'InvenTree.middleware.AuthRequiredMiddleware'
])
# Error reporting middleware
MIDDLEWARE.append('error_report.middleware.ExceptionProcessor')
AUTHENTICATION_BACKENDS = CONFIG.get('authentication_backends', [
'django.contrib.auth.backends.ModelBackend'
])

View File

@ -36,7 +36,8 @@ from django.views.generic.base import RedirectView
from rest_framework.documentation import include_docs_urls
from .views import IndexView, SearchView, DatabaseStatsView
from .views import SettingsView, EditUserView, SetPasswordView, ColorThemeSelectView
from .views import SettingsView, EditUserView, SetPasswordView
from .views import ColorThemeSelectView, SettingCategorySelectView
from .views import DynamicJsView
from common.views import SettingEdit
@ -74,6 +75,7 @@ settings_urls = [
url(r'^theme/?', ColorThemeSelectView.as_view(), name='settings-theme'),
url(r'^global/?', SettingsView.as_view(template_name='InvenTree/settings/global.html'), name='settings-global'),
url(r'^category/?', SettingCategorySelectView.as_view(), name='settings-category'),
url(r'^part/?', SettingsView.as_view(template_name='InvenTree/settings/part.html'), name='settings-part'),
url(r'^stock/?', SettingsView.as_view(template_name='InvenTree/settings/stock.html'), name='settings-stock'),
url(r'^build/?', SettingsView.as_view(template_name='InvenTree/settings/build.html'), name='settings-build'),
@ -125,6 +127,7 @@ urlpatterns = [
url(r'^edit-user/', EditUserView.as_view(), name='edit-user'),
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/', admin.site.urls, name='inventree-admin'),

View File

@ -24,7 +24,8 @@ from stock.models import StockLocation, StockItem
from common.models import InvenTreeSetting, ColorTheme
from users.models import check_user_role, RuleSet
from .forms import DeleteForm, EditUserForm, SetPasswordForm, ColorThemeSelectForm
from .forms import DeleteForm, EditUserForm, SetPasswordForm
from .forms import ColorThemeSelectForm, SettingCategorySelectForm
from .helpers import str2bool
from rest_framework import views
@ -750,6 +751,42 @@ class ColorThemeSelectView(FormView):
return self.form_invalid(form)
class SettingCategorySelectView(FormView):
""" View for selecting categories in settings """
form_class = SettingCategorySelectForm
success_url = reverse_lazy('settings-category')
template_name = "InvenTree/settings/category.html"
def get_initial(self):
""" Set category selection """
initial = super(SettingCategorySelectView, self).get_initial()
category = self.request.GET.get('category', None)
if category:
initial['category'] = category
return initial
def post(self, request, *args, **kwargs):
""" Handle POST request (which contains category selection).
Pass the selected category to the page template
"""
form = self.get_form()
if form.is_valid():
context = self.get_context_data()
context['category'] = form.cleaned_data['category']
return super(SettingCategorySelectView, self).render_to_response(context)
return self.form_invalid(form)
class DatabaseStatsView(AjaxView):
""" View for displaying database statistics """

View File

@ -1,92 +1,10 @@
# -*- coding: utf-8 -*-
from django.apps import AppConfig
from django.db.utils import OperationalError, ProgrammingError, IntegrityError
class CommonConfig(AppConfig):
name = 'common'
def ready(self):
""" Will be called when the Common app is first loaded """
self.add_instance_name()
self.add_default_settings()
def add_instance_name(self):
"""
Check if an InstanceName has been defined for this database.
If not, create a random one!
"""
# See note above
from .models import InvenTreeSetting
"""
Note: The "old" instance name was stored under the key 'InstanceName',
but has now been renamed to 'INVENTREE_INSTANCE'.
"""
try:
# Quick exit if a value already exists for 'inventree_instance'
if InvenTreeSetting.objects.filter(key='INVENTREE_INSTANCE').exists():
return
# Default instance name
instance_name = InvenTreeSetting.get_default_value('INVENTREE_INSTANCE')
# Use the old name if it exists
if InvenTreeSetting.objects.filter(key='InstanceName').exists():
instance = InvenTreeSetting.objects.get(key='InstanceName')
instance_name = instance.value
# Delete the legacy key
instance.delete()
# Create new value
InvenTreeSetting.objects.create(
key='INVENTREE_INSTANCE',
value=instance_name
)
except (OperationalError, ProgrammingError, IntegrityError):
# Migrations have not yet been applied - table does not exist
pass
def add_default_settings(self):
"""
Create all required settings, if they do not exist.
"""
from .models import InvenTreeSetting
for key in InvenTreeSetting.GLOBAL_SETTINGS.keys():
try:
settings = InvenTreeSetting.objects.filter(key__iexact=key)
if settings.count() == 0:
value = InvenTreeSetting.get_default_value(key)
print(f"Creating default setting for {key} -> '{value}'")
InvenTreeSetting.objects.create(
key=key,
value=value
)
return
elif settings.count() > 1:
# Prevent multiple shadow copies of the same setting!
for setting in settings[1:]:
setting.delete()
# Ensure that the key has the correct case
setting = settings[0]
if not setting.key == key:
setting.key = key
setting.save()
except (OperationalError, ProgrammingError, IntegrityError):
# Table might not yet exist
pass
pass

View File

@ -1,16 +0,0 @@
# Test fixtures for Currency objects
- model: common.currency
fields:
symbol: '$'
suffix: 'AUD'
description: 'Australian Dollars'
base: True
- model: common.currency
fields:
symbol: '$'
suffix: 'USD'
description: 'US Dollars'
base: False
value: 1.4

View File

@ -0,0 +1,13 @@
# Sample settings objects
- model: common.InvenTreeSetting
pk: 1
fields:
key: INVENTREE_INSTANCE
value: "My very first InvenTree Instance"
- model: common.InvenTreeSetting
pk: 2
fields:
key: INVENTREE_COMPANY_NAME
value: "ACME Pty Ltd"

View File

@ -103,6 +103,13 @@ class InvenTreeSetting(models.Model):
'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'),

View File

@ -12,6 +12,10 @@ class SettingsTest(TestCase):
Tests for the 'settings' model
"""
fixtures = [
'settings',
]
def setUp(self):
User = get_user_model()
@ -22,6 +26,20 @@ class SettingsTest(TestCase):
self.client.login(username='username', password='password')
def test_settings_objects(self):
# There should be two settings objects in the database
settings = InvenTreeSetting.objects.all()
self.assertEqual(settings.count(), 2)
instance_name = InvenTreeSetting.objects.get(pk=1)
self.assertEqual(instance_name.key, 'INVENTREE_INSTANCE')
self.assertEqual(instance_name.value, 'My very first InvenTree Instance')
# Check object lookup (case insensitive)
self.assertEqual(InvenTreeSetting.get_setting_object('iNvEnTrEE_inSTanCE').pk, 1)
def test_required_values(self):
"""
- Ensure that every global setting has a name.

View File

@ -12,6 +12,7 @@ from .models import PartCategory, Part
from .models import PartAttachment, PartStar, PartRelated
from .models import BomItem
from .models import PartParameterTemplate, PartParameter
from .models import PartCategoryParameterTemplate
from .models import PartTestTemplate
from .models import PartSellPriceBreak
@ -274,6 +275,11 @@ class ParameterAdmin(ImportExportModelAdmin):
list_display = ('part', 'template', 'data')
class PartCategoryParameterAdmin(admin.ModelAdmin):
pass
class PartSellPriceBreakAdmin(admin.ModelAdmin):
class Meta:
@ -290,5 +296,6 @@ admin.site.register(PartStar, PartStarAdmin)
admin.site.register(BomItem, BomItemAdmin)
admin.site.register(PartParameterTemplate, ParameterTemplateAdmin)
admin.site.register(PartParameter, ParameterAdmin)
admin.site.register(PartCategoryParameterTemplate, PartCategoryParameterAdmin)
admin.site.register(PartTestTemplate, PartTestTemplateAdmin)
admin.site.register(PartSellPriceBreak, PartSellPriceBreakAdmin)

View File

@ -21,6 +21,7 @@ from .models import Part, PartCategory, BomItem, PartStar
from .models import PartParameter, PartParameterTemplate
from .models import PartAttachment, PartTestTemplate
from .models import PartSellPriceBreak
from .models import PartCategoryParameterTemplate
from build.models import Build
@ -111,6 +112,36 @@ class CategoryDetail(generics.RetrieveUpdateDestroyAPIView):
queryset = PartCategory.objects.all()
class CategoryParameters(generics.ListAPIView):
""" API endpoint for accessing a list of PartCategory objects.
- GET: Return a list of PartCategory objects
"""
queryset = PartCategoryParameterTemplate.objects.all()
serializer_class = part_serializers.CategoryParameterTemplateSerializer
def get_queryset(self):
"""
Custom filtering:
- Allow filtering by "null" parent to retrieve top-level part categories
"""
cat_id = self.kwargs.get('pk', None)
queryset = super().get_queryset()
if cat_id is not None:
try:
cat_id = int(cat_id)
queryset = queryset.filter(category=cat_id)
except ValueError:
pass
return queryset
class PartSalePriceList(generics.ListCreateAPIView):
"""
API endpoint for list view of PartSalePriceBreak model
@ -864,6 +895,7 @@ part_api_urls = [
# Base URL for PartCategory API endpoints
url(r'^category/', include([
url(r'^(?P<pk>\d+)/parameters/?', CategoryParameters.as_view(), name='api-part-category-parameters'),
url(r'^(?P<pk>\d+)/?', CategoryDetail.as_view(), name='api-part-category-detail'),
url(r'^$', CategoryList.as_view(), name='api-part-category-list'),
])),

View File

@ -18,7 +18,7 @@
name: Thickness
units: mm
# And some parameters (requires part.yaml)
# Add some parameters to parts (requires part.yaml)
- model: part.PartParameter
pk: 1
fields:
@ -31,4 +31,19 @@
fields:
part: 2
template: 1
data: 12
data: 12
# Add some template parameters to categories (requires category.yaml)
- model: part.PartCategoryParameterTemplate
pk: 1
fields:
category: 7
parameter_template: 1
default_value: '2.8'
- model: part.PartCategoryParameterTemplate
pk: 2
fields:
category: 7
parameter_template: 3
default_value: '0.5'

View File

@ -16,6 +16,7 @@ from django.utils.translation import ugettext as _
from .models import Part, PartCategory, PartAttachment, PartRelated
from .models import BomItem
from .models import PartParameterTemplate, PartParameter
from .models import PartCategoryParameterTemplate
from .models import PartTestTemplate
from .models import PartSellPriceBreak
@ -199,10 +200,22 @@ class EditPartForm(HelperForm):
help_text=_('Confirm part creation'),
widget=forms.HiddenInput())
selected_category_templates = forms.BooleanField(required=False,
initial=False,
label=_('Include category parameter templates'),
widget=forms.HiddenInput())
parent_category_templates = forms.BooleanField(required=False,
initial=False,
label=_('Include parent categories parameter templates'),
widget=forms.HiddenInput())
class Meta:
model = Part
fields = [
'category',
'selected_category_templates',
'parent_category_templates',
'name',
'IPN',
'description',
@ -264,6 +277,28 @@ class EditCategoryForm(HelperForm):
]
class EditCategoryParameterTemplateForm(HelperForm):
""" Form for editing a PartCategoryParameterTemplate object """
add_to_same_level_categories = forms.BooleanField(required=False,
initial=False,
help_text=_('Add parameter template to same level categories'))
add_to_all_categories = forms.BooleanField(required=False,
initial=False,
help_text=_('Add parameter template to all categories'))
class Meta:
model = PartCategoryParameterTemplate
fields = [
'category',
'parameter_template',
'default_value',
'add_to_same_level_categories',
'add_to_all_categories',
]
class EditBomItemForm(HelperForm):
""" Form for editing a BomItem object """

View File

@ -1,19 +0,0 @@
# Generated by Django 3.0.7 on 2020-10-27 04:57
import InvenTree.fields
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('part', '0051_bomitem_optional'),
]
operations = [
migrations.AlterField(
model_name='part',
name='link',
field=InvenTree.fields.InvenTreeURLField(blank=True, help_text='Link to external URL', null=True),
),
]

View File

@ -2,7 +2,7 @@
from django.db import migrations, models
import django.db.models.deletion
import InvenTree.fields
class Migration(migrations.Migration):
@ -19,4 +19,9 @@ class Migration(migrations.Migration):
('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')),
],
),
migrations.AlterField(
model_name='part',
name='link',
field=InvenTree.fields.InvenTreeURLField(blank=True, help_text='Link to external URL', null=True),
),
]

View File

@ -1,14 +0,0 @@
# Generated by Django 3.0.7 on 2020-11-03 10:28
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('part', '0052_auto_20201027_1557'),
('part', '0052_partrelated'),
]
operations = [
]

View File

@ -0,0 +1,27 @@
# Generated by Django 3.0.7 on 2020-10-30 18:04
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('part', '0052_partrelated'),
]
operations = [
migrations.CreateModel(
name='PartCategoryParameterTemplate',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('default_value', models.CharField(blank=True, help_text='Default Parameter Value', max_length=500)),
('category', models.ForeignKey(help_text='Part Category', on_delete=django.db.models.deletion.CASCADE, related_name='parameter_templates', to='part.PartCategory')),
('parameter_template', models.ForeignKey(help_text='Parameter Template', on_delete=django.db.models.deletion.CASCADE, related_name='part_categories', to='part.PartParameterTemplate')),
],
),
migrations.AddConstraint(
model_name='partcategoryparametertemplate',
constraint=models.UniqueConstraint(fields=('category', 'parameter_template'), name='unique_category_parameter_template_pair'),
),
]

View File

@ -7,7 +7,7 @@ import part.settings
class Migration(migrations.Migration):
dependencies = [
('part', '0053_merge_20201103_1028'),
('part', '0052_partrelated'),
]
operations = [

View File

@ -0,0 +1,14 @@
# Generated by Django 3.0.7 on 2020-11-12 06:22
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('part', '0053_partcategoryparametertemplate'),
('part', '0059_auto_20201112_1112'),
]
operations = [
]

View File

@ -12,7 +12,8 @@ from django.core.exceptions import ValidationError
from django.urls import reverse
from django.db import models, transaction
from django.db.models import Sum
from django.db.utils import IntegrityError
from django.db.models import Sum, UniqueConstraint
from django.db.models.functions import Coalesce
from django.core.validators import MinValueValidator
@ -164,6 +165,26 @@ class PartCategory(InvenTreeTree):
return category_parameters
@classmethod
def get_parent_categories(cls):
""" Return tuple list of parent (root) categories """
# Get root nodes
root_categories = cls.objects.filter(level=0)
parent_categories = []
for category in root_categories:
parent_categories.append((category.id, category.name))
return parent_categories
def get_parameter_templates(self):
""" Return parameter templates associated to category """
prefetch = PartCategoryParameterTemplate.objects.prefetch_related('category', 'parameter_template')
return prefetch.filter(category=self.id)
@receiver(pre_delete, sender=PartCategory, dispatch_uid='partcategory_delete_log')
def before_delete_part_category(sender, instance, using, **kwargs):
@ -307,6 +328,9 @@ class Part(MPTTModel):
If not, it is considered "orphaned" and will be deleted.
"""
# Get category templates settings
add_category_templates = kwargs.pop('add_category_templates', None)
if self.pk:
previous = Part.objects.get(pk=self.pk)
@ -322,6 +346,44 @@ class Part(MPTTModel):
super().save(*args, **kwargs)
if add_category_templates:
# Get part category
category = self.category
if add_category_templates:
# Store templates added to part
template_list = []
# Create part parameters for selected category
category_templates = add_category_templates['main']
if category_templates:
for template in category.get_parameter_templates():
parameter = PartParameter.create(part=self,
template=template.parameter_template,
data=template.default_value,
save=True)
if parameter:
template_list.append(template.parameter_template)
# Create part parameters for parent category
category_templates = add_category_templates['parent']
if category_templates:
# Get parent categories
parent_categories = category.get_ancestors()
for category in parent_categories:
for template in category.get_parameter_templates():
# Check that template wasn't already added
if template.parameter_template not in template_list:
try:
PartParameter.create(part=self,
template=template.parameter_template,
data=template.default_value,
save=True)
except IntegrityError:
# PartParameter already exists
pass
def __str__(self):
return f"{self.full_name} - {self.description}"
@ -571,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()
@ -1041,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
@ -1655,6 +1726,49 @@ class PartParameter(models.Model):
return part_parameter
class PartCategoryParameterTemplate(models.Model):
"""
A PartCategoryParameterTemplate creates a unique relationship between a PartCategory
and a PartParameterTemplate.
Multiple PartParameterTemplate instances can be associated to a PartCategory to drive
a default list of parameter templates attached to a Part instance upon creation.
Attributes:
category: Reference to a single PartCategory object
parameter_template: Reference to a single PartParameterTemplate object
default_value: The default value for the parameter in the context of the selected
category
"""
class Meta:
constraints = [
UniqueConstraint(fields=['category', 'parameter_template'],
name='unique_category_parameter_template_pair')
]
def __str__(self):
""" String representation of a PartCategoryParameterTemplate (admin interface) """
if self.default_value:
return f'{self.category.name} | {self.parameter_template.name} | {self.default_value}'
else:
return f'{self.category.name} | {self.parameter_template.name}'
category = models.ForeignKey(PartCategory,
on_delete=models.CASCADE,
related_name='parameter_templates',
help_text=_('Part Category'))
parameter_template = models.ForeignKey(PartParameterTemplate,
on_delete=models.CASCADE,
related_name='part_categories',
help_text=_('Parameter Template'))
default_value = models.CharField(max_length=500,
blank=True,
help_text=_('Default Parameter Value'))
class BomItem(models.Model):
""" A BomItem links a part to its component items.
A part can have a BOM (bill of materials) which defines

View File

@ -15,7 +15,7 @@ from stock.models import StockItem
from .models import (BomItem, Part, PartAttachment, PartCategory,
PartParameter, PartParameterTemplate, PartSellPriceBreak,
PartStar, PartTestTemplate)
PartStar, PartTestTemplate, PartCategoryParameterTemplate)
class CategorySerializer(InvenTreeModelSerializer):
@ -418,3 +418,21 @@ class PartParameterTemplateSerializer(InvenTreeModelSerializer):
'name',
'units',
]
class CategoryParameterTemplateSerializer(InvenTreeModelSerializer):
""" Serializer for PartCategoryParameterTemplate """
parameter_template_detail = PartParameterTemplateSerializer(source='parameter_template',
many=False,
read_only=True)
class Meta:
model = PartCategoryParameterTemplate
fields = [
'pk',
'category',
'parameter_template',
'parameter_template_detail',
'default_value',
]

View File

@ -3,10 +3,12 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.test import TestCase
from django.test import TestCase, TransactionTestCase
import django.core.exceptions as django_exceptions
from .models import Part, PartCategory
from .models import PartParameter, PartParameterTemplate
from .models import PartCategoryParameterTemplate
class TestParams(TestCase):
@ -24,7 +26,10 @@ class TestParams(TestCase):
self.assertEquals(str(t1), 'Length (mm)')
p1 = PartParameter.objects.get(pk=1)
self.assertEqual(str(p1), "M2x4 LPHS : Length = 4mm")
self.assertEqual(str(p1), 'M2x4 LPHS : Length = 4mm')
c1 = PartCategoryParameterTemplate.objects.get(pk=1)
self.assertEqual(str(c1), 'Mechanical | Length | 2.8')
def test_validate(self):
@ -40,3 +45,47 @@ class TestParams(TestCase):
t3 = PartParameterTemplate(name='aBcde', units='dd')
t3.full_clean()
t3.save()
class TestCategoryTemplates(TransactionTestCase):
fixtures = [
'location',
'category',
'part',
'params'
]
def test_validate(self):
# Category templates
n = PartCategoryParameterTemplate.objects.all().count()
self.assertEqual(n, 2)
category = PartCategory.objects.get(pk=8)
t1 = PartParameterTemplate.objects.get(pk=2)
c1 = PartCategoryParameterTemplate(category=category,
parameter_template=t1,
default_value='xyz')
c1.save()
n = PartCategoryParameterTemplate.objects.all().count()
self.assertEqual(n, 3)
# Get test part
part = Part.objects.get(pk=1)
# Get part parameters count
n_param = part.get_parameters().count()
add_category_templates = {
'main': True,
'parent': True,
}
# Save it with category parameters
part.save(**{'add_category_templates': add_category_templates})
# Check new part parameters count
# Only 2 parameters should be added as one already existed with same template
self.assertEqual(n_param + 2, part.get_parameters().count())

View File

@ -80,10 +80,18 @@ part_detail_urls = [
url(r'^.*$', views.PartDetail.as_view(), name='part-detail'),
]
category_parameter_urls = [
url(r'^new/', views.CategoryParameterTemplateCreate.as_view(), name='category-param-template-create'),
url(r'^(?P<pid>\d+)/edit/', views.CategoryParameterTemplateEdit.as_view(), name='category-param-template-edit'),
url(r'^(?P<pid>\d+)/delete/', views.CategoryParameterTemplateDelete.as_view(), name='category-param-template-delete'),
]
part_category_urls = [
url(r'^edit/?', views.CategoryEdit.as_view(), name='category-edit'),
url(r'^delete/?', views.CategoryDelete.as_view(), name='category-delete'),
url(r'^parameters/', include(category_parameter_urls)),
url(r'^parametric/?', views.CategoryParametric.as_view(), name='category-parametric'),
url(r'^.*$', views.CategoryDetail.as_view(), name='category-detail'),
]

View File

@ -7,6 +7,7 @@ from __future__ import unicode_literals
from django.core.exceptions import ValidationError
from django.db import transaction
from django.db.utils import IntegrityError
from django.shortcuts import get_object_or_404
from django.shortcuts import HttpResponseRedirect
from django.utils.translation import gettext_lazy as _
@ -25,6 +26,7 @@ from decimal import Decimal, InvalidOperation
from .models import PartCategory, Part, PartAttachment, PartRelated
from .models import PartParameterTemplate, PartParameter
from .models import PartCategoryParameterTemplate
from .models import BomItem
from .models import match_part_names
from .models import PartTestTemplate
@ -627,6 +629,10 @@ class PartCreate(AjaxCreateView):
# Hide the default_supplier field (there are no matching supplier parts yet!)
form.fields['default_supplier'].widget = HiddenInput()
# Display category templates widgets
form.fields['selected_category_templates'].widget = CheckboxInput()
form.fields['parent_category_templates'].widget = CheckboxInput()
return form
def post(self, request, *args, **kwargs):
@ -669,7 +675,14 @@ class PartCreate(AjaxCreateView):
# Record the user who created this part
part.creation_user = request.user
part.save()
# Store category templates settings
add_category_templates = {
'main': form.cleaned_data['selected_category_templates'],
'parent': form.cleaned_data['parent_category_templates'],
}
# Save part and pass category template settings
part.save(**{'add_category_templates': add_category_templates})
data['pk'] = part.pk
data['text'] = str(part)
@ -702,6 +715,10 @@ class PartCreate(AjaxCreateView):
if label in self.request.GET:
initials[label] = self.request.GET.get(label)
# Automatically create part parameters from category templates
initials['selected_category_templates'] = str2bool(InvenTreeSetting.get_setting('PART_CATEGORY_PARAMETERS', False))
initials['parent_category_templates'] = initials['selected_category_templates']
return initials
@ -2205,6 +2222,185 @@ class CategoryCreate(AjaxCreateView):
return initials
class CategoryParameterTemplateCreate(AjaxCreateView):
""" View for creating a new PartCategoryParameterTemplate """
role_required = 'part.add'
model = PartCategoryParameterTemplate
form_class = part_forms.EditCategoryParameterTemplateForm
ajax_form_title = _('Create Category Parameter Template')
def get_initial(self):
""" Get initial data for Category """
initials = super().get_initial()
category_id = self.kwargs.get('pk', None)
if category_id:
try:
initials['category'] = PartCategory.objects.get(pk=category_id)
except (PartCategory.DoesNotExist, ValueError):
pass
return initials
def get_form(self):
""" Create a form to upload a new CategoryParameterTemplate
- Hide the 'category' field (parent part)
- Display parameter templates which are not yet related
"""
form = super(AjaxCreateView, self).get_form()
form.fields['category'].widget = HiddenInput()
if form.is_valid():
form.cleaned_data['category'] = self.kwargs.get('pk', None)
try:
# Get selected category
category = self.get_initial()['category']
# Get existing parameter templates
parameters = [template.parameter_template.pk
for template in category.get_parameter_templates()]
# Exclude templates already linked to category
updated_choices = []
for choice in form.fields["parameter_template"].choices:
if (choice[0] not in parameters):
updated_choices.append(choice)
# Update choices for parameter templates
form.fields['parameter_template'].choices = updated_choices
except KeyError:
pass
return form
def post(self, request, *args, **kwargs):
""" Capture the POST request
- If the add_to_all_categories object is set, link parameter template to
all categories
- If the add_to_same_level_categories object is set, link parameter template to
same level categories
"""
form = self.get_form()
valid = form.is_valid()
if valid:
add_to_same_level_categories = form.cleaned_data['add_to_same_level_categories']
add_to_all_categories = form.cleaned_data['add_to_all_categories']
selected_category = PartCategory.objects.get(pk=int(self.kwargs['pk']))
parameter_template = form.cleaned_data['parameter_template']
default_value = form.cleaned_data['default_value']
categories = PartCategory.objects.all()
if add_to_same_level_categories and not add_to_all_categories:
# Get level
level = selected_category.level
# Filter same level categories
categories = categories.filter(level=level)
if add_to_same_level_categories or add_to_all_categories:
# Add parameter template and default value to categories
for category in categories:
# Skip selected category (will be processed in the post call)
if category.pk != selected_category.pk:
try:
cat_template = PartCategoryParameterTemplate.objects.create(category=category,
parameter_template=parameter_template,
default_value=default_value)
cat_template.save()
except IntegrityError:
# Parameter template is already linked to category
pass
return super().post(request, *args, **kwargs)
class CategoryParameterTemplateEdit(AjaxUpdateView):
""" View for editing a PartCategoryParameterTemplate """
role_required = 'part.change'
model = PartCategoryParameterTemplate
form_class = part_forms.EditCategoryParameterTemplateForm
ajax_form_title = _('Edit Category Parameter Template')
def get_object(self):
try:
self.object = self.model.objects.get(pk=self.kwargs['pid'])
except:
return None
return self.object
def get_form(self):
""" Create a form to upload a new CategoryParameterTemplate
- Hide the 'category' field (parent part)
- Display parameter templates which are not yet related
"""
form = super(AjaxUpdateView, self).get_form()
form.fields['category'].widget = HiddenInput()
form.fields['add_to_all_categories'].widget = HiddenInput()
form.fields['add_to_same_level_categories'].widget = HiddenInput()
if form.is_valid():
form.cleaned_data['category'] = self.kwargs.get('pk', None)
try:
# Get selected category
category = PartCategory.objects.get(pk=self.kwargs.get('pk', None))
# Get selected template
selected_template = self.get_object().parameter_template
# Get existing parameter templates
parameters = [template.parameter_template.pk
for template in category.get_parameter_templates()
if template.parameter_template.pk != selected_template.pk]
# Exclude templates already linked to category
updated_choices = []
for choice in form.fields["parameter_template"].choices:
if (choice[0] not in parameters):
updated_choices.append(choice)
# Update choices for parameter templates
form.fields['parameter_template'].choices = updated_choices
# Set initial choice to current template
form.fields['parameter_template'].initial = selected_template
except KeyError:
pass
return form
class CategoryParameterTemplateDelete(AjaxDeleteView):
""" View for deleting an existing PartCategoryParameterTemplate """
role_required = 'part.delete'
model = PartCategoryParameterTemplate
ajax_form_title = _("Delete Category Parameter Template")
def get_object(self):
try:
self.object = self.model.objects.get(pk=self.kwargs['pid'])
except:
return None
return self.object
class BomItemDetail(InvenTreeRoleMixin, DetailView):
""" Detail view for BomItem """
context_object_name = 'item'

View File

@ -0,0 +1,114 @@
{% extends "InvenTree/settings/settings.html" %}
{% load i18n %}
{% block tabs %}
{% include "InvenTree/settings/tabs.html" with tab='category' %}
{% endblock %}
{% block subtitle %}
{% trans "Category Settings" %}
{% endblock %}
{% block settings %}
<form action="{% url 'settings-category' %}" method="post">
{% csrf_token %}
{% load crispy_forms_tags %}
<div id="category-select">
{% crispy form %}
</div>
</form>
{% if category %}
<hr>
<h4>{% trans "Category Parameter Templates" %}</h4>
<div id='param-buttons'>
<button class='btn btn-success' id='new-param'>
<span class='fas fa-plus-circle'></span> {% trans "New Parameter" %}
</button>
</div>
<table class='table table-striped table-condensed' id='param-table' data-toolbar='#param-buttons'>
</table>
{% endif %}
{% endblock %}
{% block js_ready %}
{{ block.super }}
{# Convert dropdown to select2 format #}
$(document).ready(function() {
attachSelect('#category-select');
});
{% if category %}
$("#param-table").inventreeTable({
url: "{% url 'api-part-category-parameters' category.pk %}",
queryParams: {
ordering: 'name',
},
formatNoMatches: function() { return '{% trans "No category parameter templates found" %}'; },
columns: [
{
field: 'pk',
title: 'ID',
visible: false,
switchable: false,
},
{
field: 'parameter_template_detail.name',
title: '{% trans "Parameter Template" %}',
sortable: 'true',
},
{
field: 'default_value',
title: '{% trans "Default Value" %}',
sortable: 'true',
formatter: function(value, row, index, field) {
var bEdit = "<button title='{% trans "Edit Template" %}' class='template-edit btn btn-default btn-glyph' type='button' pk='" + row.pk + "'><span class='fas fa-edit'></span></button>";
var bDel = "<button title='{% trans "Delete Template" %}' class='template-delete btn btn-default btn-glyph' type='button' pk='" + row.pk + "'><span class='fas fa-trash-alt icon-red'></span></button>";
var html = value
html += "<div class='btn-group float-right' role='group'>" + bEdit + bDel + "</div>";
return html;
}
}
]
});
$("#new-param").click(function() {
launchModalForm("{% url 'category-param-template-create' category.pk %}", {
success: function() {
$("#param-table").bootstrapTable('refresh');
},
});
});
$("#param-table").on('click', '.template-edit', function() {
var button = $(this);
var url = "/part/category/{{ category.pk }}/parameters/" + button.attr('pk') + "/edit/";
launchModalForm(url, {
success: function() {
$("#param-table").bootstrapTable('refresh');
}
});
});
$("#param-table").on('click', '.template-delete', function() {
var button = $(this);
var url = "/part/category/{{ category.pk }}/parameters/" + button.attr('pk') + "/delete/";
launchModalForm(url, {
success: function() {
$("#param-table").bootstrapTable('refresh');
}
});
});
{% endif %}
{% endblock %}

View File

@ -27,6 +27,7 @@
{% 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" %}
{% include "InvenTree/settings/setting.html" with key="PART_CATEGORY_PARAMETERS" %}
</tbody>
</table>

View File

@ -15,6 +15,9 @@
<li {% if tab == 'global' %} class='active' {% endif %}>
<a href='{% url "settings-global" %}'><span class='fas fa-globe'></span> {% trans "Global" %}</a>
</li>
<li{% ifequal tab 'category' %} class='active'{% endifequal %}>
<a href="{% url 'settings-category' %}"><span class='fa fa-sitemap'></span> {% trans "Categories" %}</a>
</li>
<li{% ifequal tab 'part' %} class='active'{% endifequal %}>
<a href="{% url 'settings-part' %}"><span class='fas fa-shapes'></span> {% trans "Parts" %}</a>
</li>
@ -31,4 +34,4 @@
<a href="{% url 'settings-so' %}"><span class='fas fa-truck'></span> {% trans "Sales Orders" %}</a>
</li>
</ul>
{% endif %}
{% endif %}

View File

@ -58,6 +58,7 @@ class RuleSet(models.Model):
'part_partparametertemplate',
'part_partparameter',
'part_partrelated',
'part_partcategoryparametertemplate',
],
'stock': [
'stock_stockitem',
@ -108,6 +109,9 @@ class RuleSet(models.Model):
'report_reportasset',
'report_testreport',
'part_partstar',
# Third-party tables
'error_report_error',
'exchange_rate',
'exchange_exchangebackend',
]

View File

@ -28,5 +28,6 @@ django-debug-toolbar==2.2 # Debug / profiling toolbar
django-admin-shell==0.1.2 # Python shell for the admin interface
django-money==1.1 # Django app for currency management
certifi # Certifi is (most likely) installed through one of the requirements above
django-error-report==0.2.0 # Error report viewer for the admin interface
inventree # Install the latest version of the InvenTree API python library

View File

@ -6,6 +6,7 @@ from shutil import copyfile
import random
import string
import os
import sys
def apps():
"""
@ -238,6 +239,96 @@ def postgresql(c):
c.run('sudo apt-get install postgresql postgresql-contrib libpq-dev')
c.run('pip3 install psycopg2')
@task(help={'filename': "Output filename (default = 'data.json')"})
def export_records(c, filename='data.json'):
"""
Export all database records to a file
"""
# Get an absolute path to the file
if not os.path.isabs(filename):
filename = os.path.join(localDir(), filename)
filename = os.path.abspath(filename)
print(f"Exporting database records to file '{filename}'")
if os.path.exists(filename):
response = input("Warning: file already exists. Do you want to overwrite? [y/N]: ")
response = str(response).strip().lower()
if response not in ['y', 'yes']:
print("Cancelled export operation")
sys.exit(1)
cmd = f'dumpdata --exclude contenttypes --exclude auth.permission --indent 2 --output {filename}'
manage(c, cmd, pty=True)
@task(help={'filename': 'Input filename'})
def import_records(c, filename='data.json'):
"""
Import database records from a file
"""
# Get an absolute path to the supplied filename
if not os.path.isabs(filename):
filename = os.path.join(localDir(), filename)
if not os.path.exists(filename):
print(f"Error: File '{filename}' does not exist")
sys.exit(1)
print(f"Importing database records from '{filename}'")
cmd = f'loaddata {filename}'
manage(c, cmd, pty=True)
@task
def import_fixtures(c):
"""
Import fixture data into the database.
This command imports all existing test fixture data into the database.
Warning:
- Intended for testing / development only!
- Running this command may overwrite existing database data!!
- Don't say you were not warned...
"""
fixtures = [
# Build model
'build',
# Common models
'settings',
# Company model
'company',
'price_breaks',
'supplier_part',
# Order model
'order',
# Part model
'bom',
'category',
'params',
'part',
'test_templates',
# Stock model
'location',
'stock_tests',
'stock',
]
command = 'loaddata ' + ' '.join(fixtures)
manage(c, command, pty=True)
@task
def backup(c):
"""