diff --git a/InvenTree/InvenTree/forms.py b/InvenTree/InvenTree/forms.py index f9f8378526..6cc4a9ed5f 100644 --- a/InvenTree/InvenTree/forms.py +++ b/InvenTree/InvenTree/forms.py @@ -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', + ), + ) diff --git a/InvenTree/InvenTree/urls.py b/InvenTree/InvenTree/urls.py index 70fb8c87f8..ca2112d031 100644 --- a/InvenTree/InvenTree/urls.py +++ b/InvenTree/InvenTree/urls.py @@ -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 @@ -75,6 +76,7 @@ settings_urls = [ url(r'^global/?', SettingsView.as_view(template_name='InvenTree/settings/global.html'), name='settings-global'), url(r'^currency/?', SettingsView.as_view(template_name='InvenTree/settings/currency.html'), name='settings-currency'), + 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'), diff --git a/InvenTree/InvenTree/views.py b/InvenTree/InvenTree/views.py index dd7a782485..e61e2d497c 100644 --- a/InvenTree/InvenTree/views.py +++ b/InvenTree/InvenTree/views.py @@ -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 """ diff --git a/InvenTree/common/models.py b/InvenTree/common/models.py index 8254d161d6..2173df0ae7 100644 --- a/InvenTree/common/models.py +++ b/InvenTree/common/models.py @@ -92,6 +92,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'), diff --git a/InvenTree/part/admin.py b/InvenTree/part/admin.py index 7476197547..1d1c981b74 100644 --- a/InvenTree/part/admin.py +++ b/InvenTree/part/admin.py @@ -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) diff --git a/InvenTree/part/api.py b/InvenTree/part/api.py index c96cff6da5..a258d58962 100644 --- a/InvenTree/part/api.py +++ b/InvenTree/part/api.py @@ -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\d+)/parameters/?', CategoryParameters.as_view(), name='api-part-category-parameters'), url(r'^(?P\d+)/?', CategoryDetail.as_view(), name='api-part-category-detail'), url(r'^$', CategoryList.as_view(), name='api-part-category-list'), ])), diff --git a/InvenTree/part/fixtures/params.yaml b/InvenTree/part/fixtures/params.yaml index 121bb79074..e65c7335cc 100644 --- a/InvenTree/part/fixtures/params.yaml +++ b/InvenTree/part/fixtures/params.yaml @@ -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 \ No newline at end of file + 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' diff --git a/InvenTree/part/forms.py b/InvenTree/part/forms.py index d72df4ed9f..5f6a34ce51 100644 --- a/InvenTree/part/forms.py +++ b/InvenTree/part/forms.py @@ -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 @@ -201,10 +202,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', @@ -266,6 +279,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 """ diff --git a/InvenTree/part/migrations/0052_auto_20201027_1557.py b/InvenTree/part/migrations/0052_auto_20201027_1557.py deleted file mode 100644 index 94dbcac06e..0000000000 --- a/InvenTree/part/migrations/0052_auto_20201027_1557.py +++ /dev/null @@ -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), - ), - ] diff --git a/InvenTree/part/migrations/0052_partrelated.py b/InvenTree/part/migrations/0052_partrelated.py index a8672ba7dc..7f9dc896a5 100644 --- a/InvenTree/part/migrations/0052_partrelated.py +++ b/InvenTree/part/migrations/0052_partrelated.py @@ -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), + ), ] diff --git a/InvenTree/part/migrations/0053_merge_20201103_1028.py b/InvenTree/part/migrations/0053_merge_20201103_1028.py deleted file mode 100644 index d42595675a..0000000000 --- a/InvenTree/part/migrations/0053_merge_20201103_1028.py +++ /dev/null @@ -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 = [ - ] diff --git a/InvenTree/part/migrations/0053_partcategoryparametertemplate.py b/InvenTree/part/migrations/0053_partcategoryparametertemplate.py new file mode 100644 index 0000000000..6f2809af12 --- /dev/null +++ b/InvenTree/part/migrations/0053_partcategoryparametertemplate.py @@ -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'), + ), + ] diff --git a/InvenTree/part/migrations/0054_auto_20201109_1246.py b/InvenTree/part/migrations/0054_auto_20201109_1246.py index 705ef51466..830525763e 100644 --- a/InvenTree/part/migrations/0054_auto_20201109_1246.py +++ b/InvenTree/part/migrations/0054_auto_20201109_1246.py @@ -7,7 +7,7 @@ import part.settings class Migration(migrations.Migration): dependencies = [ - ('part', '0053_merge_20201103_1028'), + ('part', '0053_partcategoryparametertemplate'), ] operations = [ diff --git a/InvenTree/part/models.py b/InvenTree/part/models.py index 1f1c06e81e..8501fc82b3 100644 --- a/InvenTree/part/models.py +++ b/InvenTree/part/models.py @@ -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}" @@ -1664,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 diff --git a/InvenTree/part/serializers.py b/InvenTree/part/serializers.py index f6eb8dc95b..faa6c9973a 100644 --- a/InvenTree/part/serializers.py +++ b/InvenTree/part/serializers.py @@ -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): @@ -425,3 +425,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', + ] diff --git a/InvenTree/part/test_param.py b/InvenTree/part/test_param.py index 6876a2b5df..24eee44d89 100644 --- a/InvenTree/part/test_param.py +++ b/InvenTree/part/test_param.py @@ -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()) diff --git a/InvenTree/part/urls.py b/InvenTree/part/urls.py index 624a94b0b6..4373de2385 100644 --- a/InvenTree/part/urls.py +++ b/InvenTree/part/urls.py @@ -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\d+)/edit/', views.CategoryParameterTemplateEdit.as_view(), name='category-param-template-edit'), + url(r'^(?P\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'), ] diff --git a/InvenTree/part/views.py b/InvenTree/part/views.py index 2504b056c5..ff412bb50c 100644 --- a/InvenTree/part/views.py +++ b/InvenTree/part/views.py @@ -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 _ @@ -23,6 +24,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 @@ -625,6 +627,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): @@ -667,7 +673,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) @@ -700,6 +713,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 @@ -2215,6 +2232,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' diff --git a/InvenTree/templates/InvenTree/settings/category.html b/InvenTree/templates/InvenTree/settings/category.html new file mode 100644 index 0000000000..c84573e8b4 --- /dev/null +++ b/InvenTree/templates/InvenTree/settings/category.html @@ -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 %} + +
+ {% csrf_token %} + {% load crispy_forms_tags %} +
+ {% crispy form %} +
+
+ +{% if category %} +
+ +

{% trans "Category Parameter Templates" %}

+ +
+ +
+ + +
+{% 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 = ""; + var bDel = ""; + + var html = value + html += "
" + bEdit + bDel + "
"; + + 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 %} diff --git a/InvenTree/templates/InvenTree/settings/part.html b/InvenTree/templates/InvenTree/settings/part.html index a19ce83922..d1ad6e98e9 100644 --- a/InvenTree/templates/InvenTree/settings/part.html +++ b/InvenTree/templates/InvenTree/settings/part.html @@ -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" %} diff --git a/InvenTree/templates/InvenTree/settings/tabs.html b/InvenTree/templates/InvenTree/settings/tabs.html index d104908c49..6991b54b95 100644 --- a/InvenTree/templates/InvenTree/settings/tabs.html +++ b/InvenTree/templates/InvenTree/settings/tabs.html @@ -18,6 +18,9 @@ {% trans "Currency" %} + + {% trans "Categories" %} + {% trans "Parts" %} diff --git a/InvenTree/users/models.py b/InvenTree/users/models.py index 6728f6244d..46a6d59c5b 100644 --- a/InvenTree/users/models.py +++ b/InvenTree/users/models.py @@ -58,6 +58,7 @@ class RuleSet(models.Model): 'part_partparametertemplate', 'part_partparameter', 'part_partrelated', + 'part_partcategoryparametertemplate', ], 'stock': [ 'stock_stockitem',