diff --git a/InvenTree/InvenTree/version.py b/InvenTree/InvenTree/version.py index a736bfe6a1..7b8546b1c2 100644 --- a/InvenTree/InvenTree/version.py +++ b/InvenTree/InvenTree/version.py @@ -16,9 +16,13 @@ Increment thi API version number whenever there is a significant change to the A v3 -> 2021-05-22: - The updated StockItem "history tracking" now uses a different interface +v4 -> 2021-06-01 + - BOM items can now accept "variant stock" to be assigned against them + - Many slight API tweaks were needed to get this to work properly! + """ -INVENTREE_API_VERSION = 3 +INVENTREE_API_VERSION = 4 def inventreeInstanceName(): diff --git a/InvenTree/build/migrations/0028_builditem_bom_item.py b/InvenTree/build/migrations/0028_builditem_bom_item.py new file mode 100644 index 0000000000..f93c63dc4c --- /dev/null +++ b/InvenTree/build/migrations/0028_builditem_bom_item.py @@ -0,0 +1,20 @@ +# Generated by Django 3.2 on 2021-06-01 05:23 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('part', '0066_bomitem_allow_variants'), + ('build', '0027_auto_20210404_2016'), + ] + + operations = [ + migrations.AddField( + model_name='builditem', + name='bom_item', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='allocate_build_items', to='part.bomitem'), + ), + ] diff --git a/InvenTree/build/migrations/0029_auto_20210601_1525.py b/InvenTree/build/migrations/0029_auto_20210601_1525.py new file mode 100644 index 0000000000..c5ea04b5c9 --- /dev/null +++ b/InvenTree/build/migrations/0029_auto_20210601_1525.py @@ -0,0 +1,62 @@ +# Generated by Django 3.2 on 2021-06-01 05:25 + +from django.db import migrations + + +def assign_bom_items(apps, schema_editor): + """ + Run through existing BuildItem objects, + and assign a matching BomItem + """ + + BuildItem = apps.get_model('build', 'builditem') + BomItem = apps.get_model('part', 'bomitem') + Part = apps.get_model('part', 'part') + + print("Assigning BomItems to existing BuildItem objects") + + count_valid = 0 + count_total = 0 + + for build_item in BuildItem.objects.all(): + + # Try to find a BomItem which matches the BuildItem + # Note: Before this migration, variant stock assignment was not allowed, + # so BomItem lookup should be pretty easy + + count_total += 1 + + try: + bom_item = BomItem.objects.get( + part__id=build_item.build.part.pk, + sub_part__id=build_item.stock_item.part.pk, + ) + + build_item.bom_item = bom_item + build_item.save() + + count_valid += 1 + + except BomItem.DoesNotExist: + pass + + print(f"Assigned BomItem for {count_valid}/{count_total} entries") + + +def unassign_bom_items(apps, schema_editor): + """ + Reverse migration does not do anything. + Function here to preserve ability to reverse migration + """ + pass + + +class Migration(migrations.Migration): + + dependencies = [ + ('build', '0028_builditem_bom_item'), + ] + + operations = [ + migrations.RunPython(assign_bom_items, reverse_code=unassign_bom_items), + ] diff --git a/InvenTree/build/models.py b/InvenTree/build/models.py index c80c0e8523..03a49b627f 100644 --- a/InvenTree/build/models.py +++ b/InvenTree/build/models.py @@ -30,6 +30,7 @@ from InvenTree.models import InvenTreeAttachment import common.models import InvenTree.fields +import InvenTree.helpers from stock import models as StockModels from part import models as PartModels @@ -880,9 +881,12 @@ class Build(MPTTModel): output - Build output (StockItem). """ + # Remember, if 'variant' stock is allowed to be allocated, it becomes more complicated! + variants = part.get_descendants(include_self=True) + allocations = BuildItem.objects.filter( build=self, - stock_item__part=part, + stock_item__part__pk__in=[p.pk for p in variants], install_into=output, ) @@ -1036,7 +1040,19 @@ class Build(MPTTModel): StockModels.StockItem.IN_STOCK_FILTER ) - items = items.filter(part=part) + # Check if variants are allowed for this part + try: + bom_item = PartModels.BomItem.objects.get(part=self.part, sub_part=part) + allow_part_variants = bom_item.allow_variants + except PartModels.BomItem.DoesNotExist: + allow_part_variants = False + + if allow_part_variants: + parts = part.get_descendants(include_self=True) + items = items.filter(part__pk__in=[p.pk for p in parts]) + + else: + items = items.filter(part=part) # Exclude any items which have already been allocated allocated = BuildItem.objects.filter( @@ -1160,10 +1176,6 @@ class BuildItem(models.Model): if self.stock_item.part and self.stock_item.part.trackable and not self.install_into: raise ValidationError(_('Build item must specify a build output, as master part is marked as trackable')) - # Allocated part must be in the BOM for the master part - if self.stock_item.part not in self.build.part.getRequiredParts(recursive=False): - errors['stock_item'] = [_("Selected stock item not found in BOM for part '{p}'").format(p=self.build.part.full_name)] - # Allocated quantity cannot exceed available stock quantity if self.quantity > self.stock_item.quantity: errors['quantity'] = [_("Allocated quantity ({n}) must not exceed available quantity ({q})").format( @@ -1189,6 +1201,61 @@ class BuildItem(models.Model): if len(errors) > 0: raise ValidationError(errors) + """ + Attempt to find the "BomItem" which links this BuildItem to the build. + + - If a BomItem is already set, and it is valid, then we are ok! + """ + + bom_item_valid = False + + if self.bom_item: + """ + A BomItem object has already been assigned. This is valid if: + + a) It points to the same "part" as the referened build + b) Either: + i) The sub_part points to the same part as the referenced StockItem + ii) The BomItem allows variants and the part referenced by the StockItem + is a variant of the sub_part referenced by the BomItem + """ + + if self.build and self.build.part == self.bom_item.part: + + # Check that the sub_part points to the stock_item (either directly or via a variant) + if self.bom_item.sub_part == self.stock_item.part: + bom_item_valid = True + + elif self.bom_item.allow_variants and self.stock_item.part in self.bom_item.sub_part.get_descendants(include_self=False): + bom_item_valid = True + + # If the existing BomItem is *not* valid, try to find a match + if not bom_item_valid: + + if self.build and self.stock_item: + ancestors = self.stock_item.part.get_ancestors(include_self=True, ascending=True) + + for idx, ancestor in enumerate(ancestors): + + try: + bom_item = PartModels.BomItem.objects.get(part=self.build.part, sub_part=ancestor) + except PartModels.BomItem.DoesNotExist: + continue + + # A matching BOM item has been found! + if idx == 0 or bom_item.allow_variants: + bom_item_valid = True + self.bom_item = bom_item + break + + # BomItem did not exist or could not be validated. + # Search for a new one + if not bom_item_valid: + + raise ValidationError({ + 'stock_item': _("Selected stock item not found in BOM for part '{p}'").format(p=self.build.part.full_name) + }) + @transaction.atomic def complete_allocation(self, user): """ @@ -1217,6 +1284,18 @@ class BuildItem(models.Model): # Simply remove the items from stock item.take_stock(self.quantity, user) + def getStockItemThumbnail(self): + """ + Return qualified URL for part thumbnail image + """ + + if self.stock_item and self.stock_item.part: + return InvenTree.helpers.getMediaUrl(self.stock_item.part.image.thumbnail.url) + elif self.bom_item and self.stock_item.sub_part: + return InvenTree.helpers.getMediaUrl(self.bom_item.sub_part.image.thumbnail.url) + else: + return InvenTree.helpers.getBlankThumbnail() + build = models.ForeignKey( Build, on_delete=models.CASCADE, @@ -1225,6 +1304,15 @@ class BuildItem(models.Model): help_text=_('Build to allocate parts') ) + # Internal model which links part <-> sub_part + # We need to track this separately, to allow for "variant' stock + bom_item = models.ForeignKey( + PartModels.BomItem, + on_delete=models.CASCADE, + related_name='allocate_build_items', + blank=True, null=True, + ) + stock_item = models.ForeignKey( 'stock.StockItem', on_delete=models.CASCADE, diff --git a/InvenTree/build/serializers.py b/InvenTree/build/serializers.py index 550a3c3a85..629422f6e5 100644 --- a/InvenTree/build/serializers.py +++ b/InvenTree/build/serializers.py @@ -97,9 +97,10 @@ class BuildSerializer(InvenTreeModelSerializer): class BuildItemSerializer(InvenTreeModelSerializer): """ Serializes a BuildItem object """ + bom_part = serializers.IntegerField(source='bom_item.sub_part.pk', read_only=True) part = serializers.IntegerField(source='stock_item.part.pk', read_only=True) part_name = serializers.CharField(source='stock_item.part.full_name', read_only=True) - part_image = serializers.CharField(source='stock_item.part.image', read_only=True) + part_thumb = serializers.CharField(source='getStockItemThumbnail', read_only=True) stock_item_detail = StockItemSerializerBrief(source='stock_item', read_only=True) quantity = serializers.FloatField() @@ -108,11 +109,12 @@ class BuildItemSerializer(InvenTreeModelSerializer): model = BuildItem fields = [ 'pk', + 'bom_part', 'build', 'install_into', 'part', 'part_name', - 'part_image', + 'part_thumb', 'stock_item', 'stock_item_detail', 'quantity' diff --git a/InvenTree/part/api.py b/InvenTree/part/api.py index 5bdd572145..537b0f9e40 100644 --- a/InvenTree/part/api.py +++ b/InvenTree/part/api.py @@ -821,6 +821,14 @@ class BomList(generics.ListCreateAPIView): queryset = queryset.filter(inherited=inherited) + # Filter by "allow_variants" + variants = params.get("allow_variants", None) + + if variants is not None: + variants = str2bool(variants) + + queryset = queryset.filter(allow_variants=variants) + # Filter by part? part = params.get('part', None) diff --git a/InvenTree/part/forms.py b/InvenTree/part/forms.py index 8f6e3d8898..95de4961f9 100644 --- a/InvenTree/part/forms.py +++ b/InvenTree/part/forms.py @@ -352,6 +352,7 @@ class EditBomItemForm(HelperForm): 'reference', 'overage', 'note', + 'allow_variants', 'inherited', 'optional', ] diff --git a/InvenTree/part/migrations/0066_bomitem_allow_variants.py b/InvenTree/part/migrations/0066_bomitem_allow_variants.py new file mode 100644 index 0000000000..e545c8e3cb --- /dev/null +++ b/InvenTree/part/migrations/0066_bomitem_allow_variants.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2 on 2021-06-01 03:58 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('part', '0065_auto_20210505_2144'), + ] + + operations = [ + migrations.AddField( + model_name='bomitem', + name='allow_variants', + field=models.BooleanField(default=False, help_text='Stock items for variant parts can be used for this BOM item', verbose_name='Allow Variants'), + ), + ] diff --git a/InvenTree/part/models.py b/InvenTree/part/models.py index 7db998ab3d..7b9038fecb 100644 --- a/InvenTree/part/models.py +++ b/InvenTree/part/models.py @@ -2240,6 +2240,7 @@ class BomItem(models.Model): note: Note field for this BOM item checksum: Validation checksum for the particular BOM line item inherited: This BomItem can be inherited by the BOMs of variant parts + allow_variants: Stock for part variants can be substituted for this BomItem """ def save(self, *args, **kwargs): @@ -2288,6 +2289,12 @@ class BomItem(models.Model): help_text=_('This BOM item is inherited by BOMs for variant parts'), ) + allow_variants = models.BooleanField( + default=False, + verbose_name=_('Allow Variants'), + help_text=_('Stock items for variant parts can be used for this BOM item') + ) + def get_item_hash(self): """ Calculate the checksum hash of this BOM line item: diff --git a/InvenTree/part/serializers.py b/InvenTree/part/serializers.py index 04e0b7a119..d03a37c6dc 100644 --- a/InvenTree/part/serializers.py +++ b/InvenTree/part/serializers.py @@ -453,6 +453,7 @@ class BomItemSerializer(InvenTreeModelSerializer): class Meta: model = BomItem fields = [ + 'allow_variants', 'inherited', 'note', 'optional', @@ -460,16 +461,16 @@ class BomItemSerializer(InvenTreeModelSerializer): 'pk', 'part', 'part_detail', + 'purchase_price_avg', + 'purchase_price_max', + 'purchase_price_min', + 'purchase_price_range', 'quantity', 'reference', 'sub_part', 'sub_part_detail', # 'price_range', 'validated', - 'purchase_price_min', - 'purchase_price_max', - 'purchase_price_avg', - 'purchase_price_range', ] diff --git a/InvenTree/templates/js/bom.js b/InvenTree/templates/js/bom.js index e35a51d8bd..7328bcb331 100644 --- a/InvenTree/templates/js/bom.js +++ b/InvenTree/templates/js/bom.js @@ -285,11 +285,18 @@ function loadBomTable(table, options) { title: '{% trans "Optional" %}', searchable: false, formatter: function(value) { - if (value == '1') return '{% trans "true" %}'; - if (value == '0') return '{% trans "false" %}'; + return yesNoLabel(value); } }); + cols.push({ + field: 'allow_variants', + title: '{% trans "Allow Variants" %}', + formatter: function(value) { + return yesNoLabel(value); + } + }) + cols.push({ field: 'inherited', title: '{% trans "Inherited" %}', @@ -297,7 +304,7 @@ function loadBomTable(table, options) { formatter: function(value, row, index, field) { // This BOM item *is* inheritable, but is defined for this BOM if (!row.inherited) { - return "-"; + return yesNoLabel(false); } else if (row.part == options.parent_id) { return '{% trans "Inherited" %}'; } else { diff --git a/InvenTree/templates/js/build.js b/InvenTree/templates/js/build.js index 0233974741..9523d24d39 100644 --- a/InvenTree/templates/js/build.js +++ b/InvenTree/templates/js/build.js @@ -372,7 +372,7 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) { data.forEach(function(item) { // Group BuildItem objects by part - var part = item.part; + var part = item.bom_part || item.part; var key = parseInt(part); if (!(key in allocations)) { @@ -461,6 +461,16 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) { data: row.allocations, showHeader: true, columns: [ + { + field: 'part', + title: '{% trans "Part" %}', + formatter: function(value, row) { + + var html = imageHoverIcon(row.part_thumb); + html += renderLink(row.part_name, `/part/${value}/`); + return html; + } + }, { width: '50%', field: 'quantity', diff --git a/InvenTree/templates/js/part.js b/InvenTree/templates/js/part.js index 81cc4e0630..7331ec25e0 100644 --- a/InvenTree/templates/js/part.js +++ b/InvenTree/templates/js/part.js @@ -5,6 +5,14 @@ * Requires api.js to be loaded first */ +function yesNoLabel(value) { + if (value) { + return `{% trans "YES" %}`; + } else { + return `{% trans "NO" %}`; + } +} + function toggleStar(options) { /* Toggle the 'starred' status of a part. * Performs AJAX queries and updates the display on the button. @@ -662,16 +670,6 @@ function loadPartCategoryTable(table, options) { }); } - -function yesNoLabel(value) { - if (value) { - return `{% trans "YES" %}`; - } else { - return `{% trans "NO" %}`; - } -} - - function loadPartTestTemplateTable(table, options) { /* * Load PartTestTemplate table. diff --git a/InvenTree/templates/js/stock.js b/InvenTree/templates/js/stock.js index da653402af..151265a3ae 100644 --- a/InvenTree/templates/js/stock.js +++ b/InvenTree/templates/js/stock.js @@ -81,15 +81,17 @@ function loadStockTestResultsTable(table, options) { return html; } + var parent_node = "parent node"; + table.inventreeTable({ url: "{% url 'api-part-test-template-list' %}", method: 'get', name: 'testresult', treeEnable: true, - rootParentId: options.stock_item, + rootParentId: parent_node, parentIdField: 'parent', idField: 'pk', - uniqueId: 'pk', + uniqueId: 'key', treeShowField: 'test_name', formatNoMatches: function() { return '{% trans "No test results found" %}'; @@ -190,7 +192,7 @@ function loadStockTestResultsTable(table, options) { match = true; if (row.result == null) { - item.parent = options.stock_item; + item.parent = parent_node; tableData[index] = item; override = true; } else { @@ -202,7 +204,7 @@ function loadStockTestResultsTable(table, options) { // No match could be found if (!match) { item.test_name = item.test; - item.parent = options.stock_item; + item.parent = parent_node; } if (!override) { @@ -1302,33 +1304,6 @@ function createNewStockItem(options) { function loadInstalledInTable(table, options) { /* * Display a table showing the stock items which are installed in this stock item. - * This is a multi-level tree table, where the "top level" items are Part objects, - * and the children of each top-level item are the associated installed stock items. - * - * The process for retrieving data and displaying the table is as follows: - * - * A) Get BOM data for the stock item - * - It is assumed that the stock item will be for an assembly - * (otherwise why are we installing stuff anyway?) - * - Request BOM items for stock_item.part (and only for trackable sub items) - * - * B) Add parts to table - * - Create rows for each trackable sub-part in the table - * - * C) Gather installed stock item data - * - Get the list of installed stock items via the API - * - If the Part reference is already in the table, add the sub-item as a child - * - If this is a stock item for a *new* part, request that part from the API, - * and add that part as a new row, then add the stock item as a child of that part - * - * D) Enjoy! - * - * - * And the options object contains the following things: - * - * - stock_item: The PK of the master stock_item object - * - part: The PK of the Part reference of the stock_item object - * - quantity: The quantity of the stock item */ function updateCallbacks() { @@ -1351,246 +1326,88 @@ function loadInstalledInTable(table, options) { }); } - table.inventreeTable( - { - url: "{% url 'api-bom-list' %}", - queryParams: { - part: options.part, - sub_part_trackable: true, - sub_part_detail: true, - }, - showColumns: false, - name: 'installed-in', - detailView: true, - detailViewByClick: true, - detailFilter: function(index, row) { - return row.installed_count && row.installed_count > 0; - }, - detailFormatter: function(index, row, element) { - var subTableId = `installed-table-${row.sub_part}`; + table.inventreeTable({ + url: "{% url 'api-stock-list' %}", + queryParams: { + installed_in: options.stock_item, + part_detail: true, + }, + formatNoMatches: function() { + return '{% trans "No installed items" %}'; + }, + columns: [ + { + field: 'part', + title: '{% trans "Part" %}', + formatter: function(value, row) { + var html = ''; - var html = `
`; + html += imageHoverIcon(row.part_detail.thumbnail); + html += renderLink(row.part_detail.full_name, `/stock/item/${row.pk}/`); - element.html(html); - - var subTable = $(`#${subTableId}`); - - // Display a "sub table" showing all the linked stock items - subTable.bootstrapTable({ - data: row.installed_items, - showHeader: true, - columns: [ - { - field: 'item', - title: '{% trans "Stock Item" %}', - formatter: function(value, subrow, index, field) { - - var pk = subrow.pk; - var html = ''; - - if (subrow.serial && subrow.quantity == 1) { - html += `{% trans "Serial" %}: ${subrow.serial}`; - } else { - html += `{% trans "Quantity" %}: ${subrow.quantity}`; - } - - return renderLink(html, `/stock/item/${subrow.pk}/`); - }, - }, - { - field: 'status', - title: '{% trans "Status" %}', - formatter: function(value, subrow, index, field) { - return stockStatusDisplay(value); - } - }, - { - field: 'batch', - title: '{% trans "Batch" %}', - }, - { - field: 'actions', - title: '', - formatter: function(value, subrow, index) { - - var pk = subrow.pk; - var html = ''; - - // Add some buttons yo! - html += `
`; - - html += makeIconButton('fa-unlink', 'button-uninstall', pk, "{% trans 'Uninstall stock item' %}"); - - html += `
`; - - return html; - } - } - ], - onPostBody: function() { - // Setup button callbacks - subTable.find('.button-uninstall').click(function() { - var pk = $(this).attr('pk'); - - launchModalForm( - "{% url 'stock-item-uninstall' %}", - { - data: { - 'items[]': [pk], - }, - success: function() { - // Refresh entire table! - table.bootstrapTable('refresh'); - } - } - ); - }); - } - }); - }, - columns: [ - { - checkbox: true, - title: '{% trans "Select" %}', - searchable: false, - switchable: false, - }, - { - field: 'pk', - title: 'ID', - visible: false, - switchable: false, - }, - { - field: 'part', - title: '{% trans "Part" %}', - sortable: true, - formatter: function(value, row, index, field) { - - var url = `/part/${row.sub_part}/`; - var thumb = row.sub_part_detail.thumbnail; - var name = row.sub_part_detail.full_name; - - html = imageHoverIcon(thumb) + renderLink(name, url); - - if (row.not_in_bom) { - html = `${html}` - } - - return html; - } - }, - { - field: 'installed', - title: '{% trans "Installed" %}', - sortable: false, - formatter: function(value, row, index, field) { - // Construct a progress showing how many items have been installed - - var installed = row.installed_count || 0; - var required = row.quantity || 0; - - required *= options.quantity; - - var progress = makeProgressBar(installed, required, { - id: row.sub_part.pk, - }); - - return progress; - } - }, - { - field: 'actions', - switchable: false, - formatter: function(value, row) { - var pk = row.sub_part; - - var html = `
`; - - html += makeIconButton('fa-link', 'button-install', pk, '{% trans "Install item" %}'); - - html += `
`; - - return html; - } + return html; } - ], - onLoadSuccess: function() { - // Grab a list of parts which are actually installed in this stock item + }, + { + field: 'quantity', + title: '{% trans "Quantity" %}', + formatter: function(value, row) { - inventreeGet( - "{% url 'api-stock-list' %}", + var html = ''; + + if (row.serial && row.quantity == 1) { + html += `{% trans "Serial" %}: ${row.serial}`; + } else { + html += `${row.quantity}`; + } + + return renderLink(html, `/stock/item/${row.pk}/`); + } + }, + { + field: 'status', + title: '{% trans "Status" %}', + formatter: function(value, row) { + return stockStatusDisplay(value); + } + }, + { + field: 'batch', + title: '{% trans "Batch" %}', + }, + { + field: 'buttons', + title: '', + switchable: false, + formatter: function(value, row) { + var pk = row.pk; + var html = ''; + + html += `
`; + html += makeIconButton('fa-unlink', 'button-uninstall', pk, '{% trans "Uninstall Stock Item" %}'); + html += `
`; + + return html; + } + } + ], + onPostBody: function() { + // Assign callbacks to the buttons + table.find('.button-uninstall').click(function() { + var pk = $(this).attr('pk'); + + launchModalForm( + '{% url "stock-item-uninstall" %}', { - installed_in: options.stock_item, - part_detail: true, - }, - { - success: function(stock_items) { - - var table_data = table.bootstrapTable('getData'); - - stock_items.forEach(function(item) { - - var match = false; - - for (var idx = 0; idx < table_data.length; idx++) { - - var row = table_data[idx]; - - // Check each row in the table to see if this stock item matches - table_data.forEach(function(row) { - - // Match on "sub_part" - if (row.sub_part == item.part) { - - // First time? - if (row.installed_count == null) { - row.installed_count = 0; - row.installed_items = []; - } - - row.installed_count += item.quantity; - row.installed_items.push(item); - - // Push the row back into the table - table.bootstrapTable('updateRow', idx, row, true); - - match = true; - } - - }); - - if (match) { - break; - } - } - - if (!match) { - // The stock item did *not* match any items in the BOM! - // Add a new row to the table... - - // Contruct a new "row" to add to the table - var new_row = { - sub_part: item.part, - sub_part_detail: item.part_detail, - not_in_bom: true, - installed_count: item.quantity, - installed_items: [item], - }; - - table.bootstrapTable('append', [new_row]); - - } - }); - - // Update button callback links - updateCallbacks(); + data: { + 'items[]': pk, + }, + success: function() { + table.bootstrapTable('refresh'); } } - ); - - updateCallbacks(); - }, + ) + }); } - ); + }); } \ No newline at end of file diff --git a/InvenTree/templates/js/table_filters.js b/InvenTree/templates/js/table_filters.js index 5f516e9419..d02fa50d80 100644 --- a/InvenTree/templates/js/table_filters.js +++ b/InvenTree/templates/js/table_filters.js @@ -49,6 +49,10 @@ function getAvailableTableFilters(tableKey) { inherited: { type: 'bool', title: '{% trans "Inherited" %}', + }, + allow_variants: { + type: 'bool', + title: '{% trans "Allow Variant Stock" %}', } }; }