diff --git a/contrib/container/.env b/contrib/container/.env index 33ffc7e5ee..9ded6efa30 100644 --- a/contrib/container/.env +++ b/contrib/container/.env @@ -9,8 +9,10 @@ COMPOSE_PROJECT_NAME=inventree INVENTREE_TAG=stable # InvenTree server URL - update this to match your server URL -INVENTREE_SITE_URL="http://inventree.localhost" -#INVENTREE_SITE_URL="http://192.168.1.2" # You can specify a local IP address here +# Local: http://localhost or http://inventree.localhost (requires hosts entry) +# LAN: http://192.168.133.37 +INVENTREE_SITE_URL="http://localhost" +#INVENTREE_SITE_URL="http://inventree.localhost" #INVENTREE_SITE_URL="https://inventree.my-domain.com" # Or a public domain name (which you control) INVENTREE_WEB_PORT=8000 @@ -34,10 +36,10 @@ INVENTREE_PLUGINS_ENABLED=True INVENTREE_AUTO_UPDATE=True # InvenTree superuser account details -# Un-comment (and complete) these lines to auto-create an admin account -#INVENTREE_ADMIN_USER= -#INVENTREE_ADMIN_PASSWORD= -#INVENTREE_ADMIN_EMAIL= +# Demo dataset includes its own users — see docs/demo.md +#INVENTREE_ADMIN_USER=admin +#INVENTREE_ADMIN_PASSWORD=admin +#INVENTREE_ADMIN_EMAIL=admin@localhost # Database configuration options # DO NOT CHANGE THESE SETTINGS (unless you really know what you are doing) diff --git a/contrib/container/docker-compose.yml b/contrib/container/docker-compose.yml index 9b8abc5b05..6f5d15f434 100644 --- a/contrib/container/docker-compose.yml +++ b/contrib/container/docker-compose.yml @@ -60,12 +60,12 @@ services: inventree-cache: image: redis:7-alpine container_name: inventree-cache + # Disable disk persistence — avoids permission issues on Windows bind mounts + command: redis-server --save "" --appendonly no env_file: - .env expose: - ${INVENTREE_CACHE_PORT:-6379} - volumes: - - ${INVENTREE_EXT_VOLUME}/redis:/data restart: always # InvenTree web server service diff --git a/src/backend/InvenTree/stock/test_migrations.py b/src/backend/InvenTree/stock/test_migrations.py index 510df16972..f2269a54a3 100644 --- a/src/backend/InvenTree/stock/test_migrations.py +++ b/src/backend/InvenTree/stock/test_migrations.py @@ -599,6 +599,8 @@ class TestRemoveMpttFieldsMigration(MigratorTestCase): def prepare(self): """Create a tree of StockItem objects with parent-child relationships.""" + from django.db import connection + Part = self.old_state.apps.get_model('part', 'part') StockItem = self.old_state.apps.get_model('stock', 'stockitem') @@ -606,33 +608,38 @@ class TestRemoveMpttFieldsMigration(MigratorTestCase): name='Migration Test Part', level=0, tree_id=0, lft=0, rght=0 ) + def make_item(quantity, parent_id=None): + """Insert via raw SQL to avoid duplicate status_custom_key ORM bug.""" + with connection.cursor() as cursor: + cursor.execute( + """ + INSERT INTO stock_stockitem + (part_id, quantity, level, tree_id, lft, rght, + status, delete_on_deplete, is_building, + link, serial_int, barcode_data, barcode_hash, parent_id) + VALUES (%s, %s, 0, 0, 0, 0, 10, false, false, '', 0, '', '', %s) + RETURNING id + """, + [part.pk, quantity, parent_id], + ) + return cursor.fetchone()[0] + # Root stock item, with no parent - root = StockItem.objects.create( - part=part, quantity=100, level=0, tree_id=0, lft=0, rght=0 - ) + root_pk = make_item(100) # A set of child items, parented to the root item - children = [ - StockItem.objects.create( - part=part, quantity=10, parent=root, level=1, tree_id=0, lft=0, rght=0 - ) - for _ in range(3) - ] + child_pks = [make_item(10, root_pk) for _ in range(3)] # A grandchild item, parented to the first child item - grandchild = StockItem.objects.create( - part=part, quantity=1, parent=children[0], level=2, tree_id=0, lft=0, rght=0 - ) + grandchild_pk = make_item(1, child_pks[0]) # An unrelated top-level item, with no parent - orphan = StockItem.objects.create( - part=part, quantity=5, level=0, tree_id=0, lft=0, rght=0 - ) + orphan_pk = make_item(5) - self.root_pk = root.pk - self.child_pks = [child.pk for child in children] - self.grandchild_pk = grandchild.pk - self.orphan_pk = orphan.pk + self.root_pk = root_pk + self.child_pks = child_pks + self.grandchild_pk = grandchild_pk + self.orphan_pk = orphan_pk self.assertEqual(StockItem.objects.count(), 6) diff --git a/src/frontend/src/forms/PartForms.tsx b/src/frontend/src/forms/PartForms.tsx index e0bfa28e0e..970c3990b7 100644 --- a/src/frontend/src/forms/PartForms.tsx +++ b/src/frontend/src/forms/PartForms.tsx @@ -121,7 +121,7 @@ export function usePartFields({ if (create && !virtual) { fields.copy_category_parameters = {}; - if (virtual != false) { + if (globalSettings.isSet('PART_CREATE_INITIAL')) { fields.initial_stock = { icon: , children: { diff --git a/src/frontend/tests/pui_forms.spec.ts b/src/frontend/tests/pui_forms.spec.ts index bb8ea88def..3f0d155ac8 100644 --- a/src/frontend/tests/pui_forms.spec.ts +++ b/src/frontend/tests/pui_forms.spec.ts @@ -2,8 +2,15 @@ import { createApi } from './api'; /** Unit tests for form validation, rendering, etc */ import { expect, test } from './baseFixtures'; import { stevenuser } from './defaults'; -import { clickOnRowMenu, loadTab, navigate, openDetailAction } from './helpers'; +import { + clickOnRowMenu, + deletePart, + loadTab, + navigate, + openDetailAction +} from './helpers'; import { doCachedLogin } from './login'; +import { setSettingState } from './settings'; // Test hover form action in related fields test('Forms - Hover', async ({ browser }) => { @@ -315,3 +322,72 @@ test('Forms - Nested Object Field', async ({ browser }) => { // Confirm the part was created with the expected name await page.getByText(partName).first().waitFor(); }); + +// Regression for #12266: initial stock fields respect PART_CREATE_INITIAL setting +test('Forms - Initial Stock Field', async ({ browser }) => { + await setSettingState({ setting: 'PART_CREATE_INITIAL', value: true }); + + const partName = `Initial Stock Part ${Date.now()}`; + await deletePart(partName); + + const page = await doCachedLogin(browser, { + user: stevenuser, + url: 'part/category/index/parts' + }); + await page.waitForURL('**/part/category/index/**'); + + await page.getByRole('button', { name: 'action-menu-add-parts' }).click(); + await page + .getByRole('menuitem', { name: 'action-menu-add-parts-create-part' }) + .click(); + + await page.getByLabel('text-field-name', { exact: true }).fill(partName); + + const quantityField = page.getByLabel('number-field-initial_stock.quantity'); + await expect(quantityField).toBeVisible(); + + await quantityField.fill('25'); + await page.getByLabel('tree-field-initial_stock.location').fill('production'); + await page.getByText('Electronics production facility').click(); + + await page.getByRole('button', { name: 'Submit' }).click(); + await page.getByText('Item Created').waitFor(); + + await page.getByText(partName).first().click(); + await page + .getByLabel('panel-tabs-part') + .getByRole('tab', { name: 'Stock', exact: true }) + .click(); + await page.getByText('25').first().waitFor(); + + await deletePart(partName); + await setSettingState({ setting: 'PART_CREATE_INITIAL', value: false }); +}); + +test('Forms - Initial Stock hidden when setting disabled', async ({ + browser +}) => { + await setSettingState({ setting: 'PART_CREATE_INITIAL', value: false }); + + const page = await doCachedLogin(browser, { + user: stevenuser, + url: 'part/category/index/parts' + }); + await page.waitForURL('**/part/category/index/**'); + + await page.getByRole('button', { name: 'action-menu-add-parts' }).click(); + await page + .getByRole('menuitem', { name: 'action-menu-add-parts-create-part' }) + .click(); + + await page + .getByLabel('text-field-name', { exact: true }) + .fill('No Initial Stock'); + + await expect( + page.getByLabel('number-field-initial_stock.quantity') + ).toBeHidden(); + await expect( + page.getByLabel('tree-field-initial_stock.location') + ).toBeHidden(); +});