mirror of
https://github.com/inventree/InvenTree.git
synced 2025-12-19 21:31:04 -06:00
Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
47f386e39a | ||
|
|
700d49643d | ||
|
|
2f9cf5f1f1 | ||
|
|
3eb6f12570 | ||
|
|
afc1dad8a7 | ||
|
|
66b71c1f2e | ||
|
|
1a8287824b | ||
|
|
10769ccb04 | ||
|
|
f39b3190e3 | ||
|
|
e1a97b2a39 | ||
|
|
c9a1d9adda | ||
|
|
64fb5c062a |
@@ -1,4 +1,4 @@
|
||||
#!/bin/ash
|
||||
#!/bin/bash
|
||||
|
||||
# exit when any command fails
|
||||
set -e
|
||||
|
||||
@@ -15,7 +15,7 @@ root_command() {
|
||||
no_call=${args[--no-call]}
|
||||
dry_run=${args[--dry-run]}
|
||||
|
||||
REQS="wget apt-transport-https"
|
||||
REQS="wget apt-transport-https curl gpg"
|
||||
|
||||
function do_call() {
|
||||
if [[ $dry_run ]]; then
|
||||
|
||||
@@ -5,7 +5,7 @@ publisher=${args[publisher]}
|
||||
no_call=${args[--no-call]}
|
||||
dry_run=${args[--dry-run]}
|
||||
|
||||
REQS="wget apt-transport-https"
|
||||
REQS="wget apt-transport-https curl gpg"
|
||||
|
||||
function do_call() {
|
||||
if [[ $dry_run ]]; then
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 50 KiB After Width: | Height: | Size: 7.8 KiB |
@@ -38,7 +38,7 @@ First, let's confirm that the gunicorn server is operational.
|
||||
cd /home/InvenTree
|
||||
source ./env/bin/activate
|
||||
|
||||
cd src/InvenTree
|
||||
cd src/src/backend/InvenTree
|
||||
/home/inventree/env/bin/gunicorn -c gunicorn.conf.py InvenTree.wsgi -b 127.0.0.1:8000
|
||||
```
|
||||
|
||||
|
||||
@@ -420,6 +420,13 @@ class InvenTreeMetadata(SimpleMetadata):
|
||||
if field_info['type'] == 'dependent field':
|
||||
field_info['depends_on'] = field.depends_on
|
||||
|
||||
# Extends with extra attributes from the serializer
|
||||
extra_field_attributes = ['allow_blank', 'allow_null']
|
||||
|
||||
for attr in extra_field_attributes:
|
||||
if hasattr(field, attr):
|
||||
field_info[attr] = getattr(field, attr)
|
||||
|
||||
# Extend field info if the field has a get_field_info method
|
||||
if (
|
||||
not field_info.get('read_only')
|
||||
|
||||
@@ -9,6 +9,7 @@ from django.core.exceptions import ValidationError
|
||||
from django.db import models
|
||||
from django.db.models import QuerySet
|
||||
from django.db.models.signals import post_save
|
||||
from django.db.transaction import TransactionManagementError
|
||||
from django.dispatch import receiver
|
||||
from django.urls import resolve, reverse
|
||||
from django.urls.exceptions import NoReverseMatch
|
||||
@@ -757,7 +758,15 @@ class InvenTreeTree(MPTTModel):
|
||||
|
||||
if len(trees) > 0:
|
||||
# A tree update was performed, so we need to refresh the instance
|
||||
self.refresh_from_db()
|
||||
try:
|
||||
self.refresh_from_db()
|
||||
except TransactionManagementError:
|
||||
# If we are inside a transaction block, we cannot refresh from db
|
||||
pass
|
||||
except Exception as e:
|
||||
# Any other error is unexpected
|
||||
InvenTree.sentry.report_exception(e)
|
||||
InvenTree.exceptions.log_error(f'{self.__class__.__name__}.save')
|
||||
|
||||
def partial_rebuild(self, tree_id: int) -> bool:
|
||||
"""Perform a partial rebuild of the tree structure.
|
||||
|
||||
@@ -646,6 +646,11 @@ class InvenTreeDecimalField(serializers.FloatField):
|
||||
|
||||
def to_internal_value(self, data):
|
||||
"""Convert to python type."""
|
||||
if data in [None, '']:
|
||||
if self.allow_null:
|
||||
return None
|
||||
raise serializers.ValidationError(_('This field may not be null.'))
|
||||
|
||||
# Convert the value to a string, and then a decimal
|
||||
try:
|
||||
return Decimal(str(data))
|
||||
|
||||
@@ -18,7 +18,7 @@ from django.conf import settings
|
||||
from .api_version import INVENTREE_API_TEXT, INVENTREE_API_VERSION
|
||||
|
||||
# InvenTree software version
|
||||
INVENTREE_SW_VERSION = '1.1.5'
|
||||
INVENTREE_SW_VERSION = '1.1.8'
|
||||
|
||||
|
||||
logger = logging.getLogger('inventree')
|
||||
|
||||
@@ -1678,6 +1678,10 @@ class StockAddSerializer(StockAdjustmentSerializer):
|
||||
stock_item = item['pk']
|
||||
quantity = item['quantity']
|
||||
|
||||
if quantity is None or quantity <= 0:
|
||||
# Ignore in this case - no stock to add
|
||||
continue
|
||||
|
||||
# Optional fields
|
||||
extra = {}
|
||||
|
||||
@@ -1703,6 +1707,10 @@ class StockRemoveSerializer(StockAdjustmentSerializer):
|
||||
stock_item = item['pk']
|
||||
quantity = item['quantity']
|
||||
|
||||
# Ignore in this case - no stock to remove
|
||||
if quantity is None or quantity <= 0:
|
||||
continue
|
||||
|
||||
# Optional fields
|
||||
extra = {}
|
||||
|
||||
|
||||
@@ -48,6 +48,8 @@ export type ApiFormFieldHeader = {
|
||||
* @param model : The model to use for related fields
|
||||
* @param filters : Optional API filters to apply to related fields
|
||||
* @param required : Whether the field is required
|
||||
* @param allow_null: Whether the field allows null values
|
||||
* @param allow_blank: Whether the field allows blank values
|
||||
* @param hidden : Whether the field is hidden
|
||||
* @param disabled : Whether the field is disabled
|
||||
* @param error : Optional error message to display
|
||||
@@ -103,6 +105,8 @@ export type ApiFormFieldType = {
|
||||
choices?: ApiFormFieldChoice[];
|
||||
hidden?: boolean;
|
||||
disabled?: boolean;
|
||||
allow_null?: boolean;
|
||||
allow_blank?: boolean;
|
||||
exclude?: boolean;
|
||||
read_only?: boolean;
|
||||
placeholder?: string;
|
||||
|
||||
@@ -24,7 +24,11 @@ import { type NavigateFunction, useNavigate } from 'react-router-dom';
|
||||
|
||||
import { isTrue } from '@lib/functions/Conversion';
|
||||
import { getDetailUrl } from '@lib/functions/Navigation';
|
||||
import type { ApiFormFieldSet, ApiFormProps } from '@lib/types/Forms';
|
||||
import type {
|
||||
ApiFormFieldSet,
|
||||
ApiFormFieldType,
|
||||
ApiFormProps
|
||||
} from '@lib/types/Forms';
|
||||
import { useApi } from '../../contexts/ApiContext';
|
||||
import {
|
||||
type NestedDict,
|
||||
@@ -375,16 +379,29 @@ export function ApiForm({
|
||||
|
||||
Object.keys(data).forEach((key: string) => {
|
||||
let value: any = data[key];
|
||||
const field_type = fields[key]?.field_type;
|
||||
const exclude = fields[key]?.exclude;
|
||||
const field: ApiFormFieldType = fields[key] ?? {};
|
||||
const field_type = field?.field_type;
|
||||
const exclude = field?.exclude;
|
||||
|
||||
if (field_type == 'file upload' && !!value) {
|
||||
hasFiles = true;
|
||||
}
|
||||
|
||||
// Ensure any boolean values are actually boolean
|
||||
if (field_type === 'boolean') {
|
||||
value = isTrue(value) || false;
|
||||
// Special consideration for various field types
|
||||
switch (field_type) {
|
||||
case 'boolean':
|
||||
// Ensure boolean values are actually boolean
|
||||
value = isTrue(value) || false;
|
||||
break;
|
||||
case 'string':
|
||||
// Replace null string values with an empty string
|
||||
if (value === null && field?.allow_null == false) {
|
||||
value = '';
|
||||
jsonData[key] = value;
|
||||
}
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
// Stringify any JSON objects
|
||||
@@ -393,7 +410,9 @@ export function ApiForm({
|
||||
case 'file upload':
|
||||
break;
|
||||
default:
|
||||
value = JSON.stringify(value);
|
||||
if (value !== null && value !== undefined) {
|
||||
value = JSON.stringify(value);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { ModelType } from '@lib/index';
|
||||
import type { ApiFormFieldSet } from '@lib/types/Forms';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { IconBuildingStore, IconCopy, IconPackages } from '@tabler/icons-react';
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
|
||||
import { apiUrl } from '@lib/functions/Api';
|
||||
import type { ApiFormFieldSet } from '@lib/types/Forms';
|
||||
import { useApi } from '../contexts/ApiContext';
|
||||
import { useGlobalSettingsState } from '../states/SettingsStates';
|
||||
|
||||
@@ -20,8 +21,12 @@ export function usePartFields({
|
||||
}): ApiFormFieldSet {
|
||||
const settings = useGlobalSettingsState();
|
||||
|
||||
const [virtual, setVirtual] = useState<boolean>(false);
|
||||
const [purchaseable, setPurchaseable] = useState<boolean>(false);
|
||||
const globalSettings = useGlobalSettingsState();
|
||||
|
||||
const [virtual, setVirtual] = useState<boolean | undefined>(undefined);
|
||||
const [purchaseable, setPurchaseable] = useState<boolean | undefined>(
|
||||
undefined
|
||||
);
|
||||
|
||||
return useMemo(() => {
|
||||
const fields: ApiFormFieldSet = {
|
||||
@@ -53,6 +58,13 @@ export function usePartFields({
|
||||
structural: false
|
||||
}
|
||||
},
|
||||
default_supplier: {
|
||||
model: ModelType.company,
|
||||
api_url: apiUrl(ApiEndpoints.company_list),
|
||||
filters: {
|
||||
is_supplier: true
|
||||
}
|
||||
},
|
||||
default_expiry: {},
|
||||
minimum_stock: {},
|
||||
responsible: {
|
||||
@@ -60,19 +72,33 @@ export function usePartFields({
|
||||
is_active: true
|
||||
}
|
||||
},
|
||||
component: {},
|
||||
assembly: {},
|
||||
is_template: {},
|
||||
testable: {},
|
||||
trackable: {},
|
||||
component: {
|
||||
default: globalSettings.isSet('PART_COMPONENT')
|
||||
},
|
||||
assembly: {
|
||||
default: globalSettings.isSet('PART_ASSEMBLY')
|
||||
},
|
||||
is_template: {
|
||||
default: globalSettings.isSet('PART_TEMPLATE')
|
||||
},
|
||||
testable: {
|
||||
default: false
|
||||
},
|
||||
trackable: {
|
||||
default: globalSettings.isSet('PART_TRACKABLE')
|
||||
},
|
||||
purchaseable: {
|
||||
value: purchaseable,
|
||||
default: globalSettings.isSet('PART_PURCHASEABLE'),
|
||||
onValueChange: (value: boolean) => {
|
||||
setPurchaseable(value);
|
||||
}
|
||||
},
|
||||
salable: {},
|
||||
salable: {
|
||||
default: globalSettings.isSet('PART_SALABLE')
|
||||
},
|
||||
virtual: {
|
||||
default: globalSettings.isSet('PART_VIRTUAL'),
|
||||
value: virtual,
|
||||
onValueChange: (value: boolean) => {
|
||||
setVirtual(value);
|
||||
@@ -93,7 +119,7 @@ export function usePartFields({
|
||||
if (create) {
|
||||
fields.copy_category_parameters = {};
|
||||
|
||||
if (!virtual) {
|
||||
if (virtual != false) {
|
||||
fields.initial_stock = {
|
||||
icon: <IconPackages />,
|
||||
children: {
|
||||
@@ -176,7 +202,14 @@ export function usePartFields({
|
||||
}
|
||||
|
||||
return fields;
|
||||
}, [virtual, purchaseable, create, duplicatePartInstance, settings]);
|
||||
}, [
|
||||
virtual,
|
||||
purchaseable,
|
||||
create,
|
||||
globalSettings,
|
||||
duplicatePartInstance,
|
||||
settings
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -862,10 +862,17 @@ function stockRemoveFields(items: any[]): ApiFormFieldSet {
|
||||
|
||||
const records = Object.fromEntries(items.map((item) => [item.pk, item]));
|
||||
|
||||
const initialValue = mapAdjustmentItems(items).map((elem) => {
|
||||
return {
|
||||
...elem,
|
||||
quantity: 0
|
||||
};
|
||||
});
|
||||
|
||||
const fields: ApiFormFieldSet = {
|
||||
items: {
|
||||
field_type: 'table',
|
||||
value: mapAdjustmentItems(items),
|
||||
value: initialValue,
|
||||
modelRenderer: (row: TableFieldRowProps) => {
|
||||
const record = records[row.item.pk];
|
||||
|
||||
@@ -902,10 +909,17 @@ function stockAddFields(items: any[]): ApiFormFieldSet {
|
||||
|
||||
const records = Object.fromEntries(items.map((item) => [item.pk, item]));
|
||||
|
||||
const initialValue = mapAdjustmentItems(items).map((elem) => {
|
||||
return {
|
||||
...elem,
|
||||
quantity: 0
|
||||
};
|
||||
});
|
||||
|
||||
const fields: ApiFormFieldSet = {
|
||||
items: {
|
||||
field_type: 'table',
|
||||
value: mapAdjustmentItems(items),
|
||||
value: initialValue,
|
||||
modelRenderer: (row: TableFieldRowProps) => {
|
||||
const record = records[row.item.pk];
|
||||
|
||||
@@ -941,10 +955,12 @@ function stockCountFields(items: any[]): ApiFormFieldSet {
|
||||
|
||||
const records = Object.fromEntries(items.map((item) => [item.pk, item]));
|
||||
|
||||
const initialValue = mapAdjustmentItems(items);
|
||||
|
||||
const fields: ApiFormFieldSet = {
|
||||
items: {
|
||||
field_type: 'table',
|
||||
value: mapAdjustmentItems(items),
|
||||
value: initialValue,
|
||||
modelRenderer: (row: TableFieldRowProps) => {
|
||||
return (
|
||||
<StockOperationsRow
|
||||
|
||||
@@ -501,6 +501,13 @@ export default function PartDetail() {
|
||||
model: ModelType.stocklocation,
|
||||
hidden: part.default_location || !part.category_default_location
|
||||
},
|
||||
{
|
||||
type: 'link',
|
||||
name: 'default_supplier',
|
||||
label: t`Default Supplier`,
|
||||
model: ModelType.company,
|
||||
hidden: !part.default_supplier
|
||||
},
|
||||
{
|
||||
type: 'string',
|
||||
name: 'units',
|
||||
|
||||
@@ -332,6 +332,11 @@ test('Stock - Stock Actions', async ({ browser }) => {
|
||||
await page.getByRole('button', { name: 'Scan', exact: true }).click();
|
||||
await page.getByText('Scanned stock item into location').waitFor();
|
||||
|
||||
// Add "zero" stock - ensure the quantity stays the same
|
||||
await launchStockAction('add');
|
||||
await page.getByRole('button', { name: 'Submit' }).click();
|
||||
await page.getByText('Quantity: 123').first().waitFor();
|
||||
|
||||
// Add stock, and change status
|
||||
await launchStockAction('add');
|
||||
await page.getByLabel('number-field-quantity').fill('12');
|
||||
@@ -342,6 +347,11 @@ test('Stock - Stock Actions', async ({ browser }) => {
|
||||
await page.getByText('Unavailable').first().waitFor();
|
||||
await page.getByText('135').first().waitFor();
|
||||
|
||||
// Remove "zero" stock - ensure the quantity stays the same
|
||||
await launchStockAction('remove');
|
||||
await page.getByRole('button', { name: 'Submit' }).click();
|
||||
await page.getByText('Quantity: 135').first().waitFor();
|
||||
|
||||
// Remove stock, and change status
|
||||
await launchStockAction('remove');
|
||||
await page.getByLabel('number-field-quantity').fill('99');
|
||||
|
||||
Reference in New Issue
Block a user