Skip to content

Commit

Permalink
[PUI/Feature] Integrate Part "Default Location" into UX (#5972)
Browse files Browse the repository at this point in the history
* Add default parts to location page

* Fix name strings

* Add Stock Transfer modal

* Add ApiForm Table field

* temp

* Add stock transfer form to part, stock item and location

* All stock operations for Item, Part, and Location added (except order new)

* Add default_location category traversal, and initial PO Line Item Receive form

* .

* Remove debug values

* Added PO line receive form

* Add functionality to PO receive extra fields

* .

* Forgot to bump API version

* Add Category Default to details panel

* Fix stockItem query count

* Fix reviewed issues

* .

* .

* .

* Prevent root category from checking parent for default location
  • Loading branch information
LavissaWoW authored Mar 15, 2024
1 parent 6abd33f commit 0196dd2
Show file tree
Hide file tree
Showing 22 changed files with 1,785 additions and 57 deletions.
6 changes: 3 additions & 3 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ repos:
- id: check-yaml
- id: mixed-line-ending
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.2.2
rev: v0.3.0
hooks:
- id: ruff-format
args: [--preview]
Expand All @@ -26,7 +26,7 @@ repos:
--preview
]
- repo: https://github.com/matmair/ruff-pre-commit
rev: 830893bf46db844d9c99b6c468e285199adf2de6 # uv-018
rev: 8bed1087452bdf816b840ea7b6848b21d32b7419 # uv-018
hooks:
- id: pip-compile
name: pip-compile requirements-dev.in
Expand Down Expand Up @@ -60,7 +60,7 @@ repos:
- "prettier@^2.4.1"
- "@trivago/prettier-plugin-sort-imports"
- repo: https://github.com/pre-commit/mirrors-eslint
rev: "v9.0.0-beta.0"
rev: "v9.0.0-beta.1"
hooks:
- id: eslint
additional_dependencies:
Expand Down
10 changes: 8 additions & 2 deletions InvenTree/InvenTree/api_version.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,18 @@
"""InvenTree API version information."""

# InvenTree API version
INVENTREE_API_VERSION = 182
INVENTREE_API_VERSION = 183
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""

INVENTREE_API_TEXT = """
v182 - 2024-03-15 : https://github.com/inventree/InvenTree/pull/6714
v183 - 2024-03-14 : https://github.com/inventree/InvenTree/pull/5972
- Adds "category_default_location" annotated field to part serializer
- Adds "part_detail.category_default_location" annotated field to stock item serializer
- Adds "part_detail.category_default_location" annotated field to purchase order line serializer
- Adds "parent_default_location" annotated field to category serializer
v182 - 2024-03-13 : https://github.com/inventree/InvenTree/pull/6714
- Expose ReportSnippet model to the /report/snippet/ API endpoint
- Expose ReportAsset model to the /report/asset/ API endpoint
Expand Down
24 changes: 23 additions & 1 deletion InvenTree/order/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,16 @@

from django.core.exceptions import ValidationError as DjangoValidationError
from django.db import models, transaction
from django.db.models import BooleanField, Case, ExpressionWrapper, F, Q, Value, When
from django.db.models import (
BooleanField,
Case,
ExpressionWrapper,
F,
Prefetch,
Q,
Value,
When,
)
from django.utils.translation import gettext_lazy as _

from rest_framework import serializers
Expand All @@ -14,6 +23,8 @@

import order.models
import part.filters
import part.filters as part_filters
import part.models as part_models
import stock.models
import stock.serializers
from common.serializers import ProjectCodeSerializer
Expand Down Expand Up @@ -375,6 +386,17 @@ def annotate_queryset(queryset):
- "total_price" = purchase_price * quantity
- "overdue" status (boolean field)
"""
queryset = queryset.prefetch_related(
Prefetch(
'part__part',
queryset=part_models.Part.objects.annotate(
category_default_location=part_filters.annotate_default_location(
'category__'
)
).prefetch_related(None),
)
)

queryset = queryset.annotate(
total_price=ExpressionWrapper(
F('purchase_price') * F('quantity'), output_field=models.DecimalField()
Expand Down
26 changes: 26 additions & 0 deletions InvenTree/part/filters.py
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,32 @@ def annotate_category_parts():
)


def annotate_default_location(reference=''):
"""Construct a queryset that finds the closest default location in the part's category tree.
If the part's category has its own default_location, this is returned.
If not, the category tree is traversed until a value is found.
"""
subquery = part.models.PartCategory.objects.filter(
tree_id=OuterRef(f'{reference}tree_id'),
lft__lt=OuterRef(f'{reference}lft'),
rght__gt=OuterRef(f'{reference}rght'),
level__lte=OuterRef(f'{reference}level'),
parent__isnull=False,
)

return Coalesce(
F(f'{reference}default_location'),
Subquery(
subquery.order_by('-level')
.filter(default_location__isnull=False)
.values('default_location')
),
Value(None),
output_field=IntegerField(),
)


def annotate_sub_categories():
"""Construct a queryset annotation which returns the number of subcategories for each provided category."""
subquery = part.models.PartCategory.objects.filter(
Expand Down
18 changes: 18 additions & 0 deletions InvenTree/part/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ class Meta:
'url',
'structural',
'icon',
'parent_default_location',
]

def __init__(self, *args, **kwargs):
Expand All @@ -105,6 +106,10 @@ def annotate_queryset(queryset):
subcategories=part.filters.annotate_sub_categories(),
)

queryset = queryset.annotate(
parent_default_location=part.filters.annotate_default_location('parent__')
)

return queryset

url = serializers.CharField(source='get_absolute_url', read_only=True)
Expand All @@ -121,6 +126,8 @@ def annotate_queryset(queryset):
child=serializers.DictField(), source='get_path', read_only=True
)

parent_default_location = serializers.IntegerField(read_only=True)


class CategoryTree(InvenTree.serializers.InvenTreeModelSerializer):
"""Serializer for PartCategory tree."""
Expand Down Expand Up @@ -283,6 +290,7 @@ class Meta:
'pk',
'IPN',
'barcode_hash',
'category_default_location',
'default_location',
'name',
'revision',
Expand Down Expand Up @@ -314,6 +322,8 @@ def __init__(self, *args, **kwargs):
self.fields.pop('pricing_min')
self.fields.pop('pricing_max')

category_default_location = serializers.IntegerField(read_only=True)

image = InvenTree.serializers.InvenTreeImageSerializerField(read_only=True)
thumbnail = serializers.CharField(source='get_thumbnail_url', read_only=True)

Expand Down Expand Up @@ -611,6 +621,7 @@ class Meta:
'allocated_to_build_orders',
'allocated_to_sales_orders',
'building',
'category_default_location',
'in_stock',
'ordering',
'required_for_build_orders',
Expand Down Expand Up @@ -766,6 +777,12 @@ def annotate_queryset(queryset):
required_for_sales_orders=part.filters.annotate_sales_order_requirements(),
)

queryset = queryset.annotate(
category_default_location=part.filters.annotate_default_location(
'category__'
)
)

return queryset

def get_starred(self, part) -> bool:
Expand Down Expand Up @@ -805,6 +822,7 @@ def get_starred(self, part) -> bool:
unallocated_stock = serializers.FloatField(
read_only=True, label=_('Unallocated Stock')
)
category_default_location = serializers.IntegerField(read_only=True)
variant_stock = serializers.FloatField(read_only=True, label=_('Variant Stock'))

minimum_stock = serializers.FloatField()
Expand Down
12 changes: 10 additions & 2 deletions InvenTree/stock/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

from django.core.exceptions import ValidationError as DjangoValidationError
from django.db import transaction
from django.db.models import BooleanField, Case, Count, Q, Value, When
from django.db.models import BooleanField, Case, Count, Prefetch, Q, Value, When
from django.db.models.functions import Coalesce
from django.utils.translation import gettext_lazy as _

Expand All @@ -20,6 +20,7 @@
import InvenTree.helpers
import InvenTree.serializers
import InvenTree.status_codes
import part.filters as part_filters
import part.models as part_models
import stock.filters
from company.serializers import SupplierPartSerializer
Expand Down Expand Up @@ -289,7 +290,14 @@ def annotate_queryset(queryset):
'location',
'sales_order',
'purchase_order',
'part',
Prefetch(
'part',
queryset=part_models.Part.objects.annotate(
category_default_location=part_filters.annotate_default_location(
'category__'
)
).prefetch_related(None),
),
'part__category',
'part__pricing_data',
'supplier_part',
Expand Down
2 changes: 1 addition & 1 deletion src/frontend/src/components/forms/ApiForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -443,7 +443,7 @@ export function ApiForm({ id, props }: { id: string; props: ApiFormProps }) {
))}
<Button
onClick={form.handleSubmit(submitForm, onFormError)}
variant="outline"
variant="filled"
radius="sm"
color={props.submitColor ?? 'green'}
disabled={isLoading || (props.fetchInitialData && !isDirty)}
Expand Down
13 changes: 12 additions & 1 deletion src/frontend/src/components/forms/fields/ApiFormField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { ChoiceField } from './ChoiceField';
import DateField from './DateField';
import { NestedObjectField } from './NestedObjectField';
import { RelatedModelField } from './RelatedModelField';
import { TableField } from './TableField';

export type ApiFormData = UseFormReturnType<Record<string, unknown>>;

Expand Down Expand Up @@ -69,7 +70,8 @@ export type ApiFormFieldType = {
| 'number'
| 'choice'
| 'file upload'
| 'nested object';
| 'nested object'
| 'table';
api_url?: string;
model?: ModelType;
modelRenderer?: (instance: any) => ReactNode;
Expand All @@ -86,6 +88,7 @@ export type ApiFormFieldType = {
postFieldContent?: JSX.Element;
onValueChange?: (value: any) => void;
adjustFilters?: (value: ApiFormAdjustFilterType) => any;
headers?: string[];
};

/**
Expand Down Expand Up @@ -266,6 +269,14 @@ export function ApiFormField({
control={control}
/>
);
case 'table':
return (
<TableField
definition={definition}
fieldName={fieldName}
control={controller}
/>
);
default:
return (
<Alert color="red" title={t`Error`}>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,6 @@ export function RelatedModelField({
limit?: number;
}) {
const fieldId = useId();

const {
field,
fieldState: { error }
Expand Down Expand Up @@ -60,7 +59,6 @@ export function RelatedModelField({
field.value !== ''
) {
const url = `${definition.api_url}${field.value}/`;

api.get(url).then((response) => {
if (response.data && response.data.pk) {
const value = {
Expand Down
80 changes: 80 additions & 0 deletions src/frontend/src/components/forms/fields/TableField.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { Trans, t } from '@lingui/macro';
import { Table } from '@mantine/core';
import { FieldValues, UseControllerReturn } from 'react-hook-form';

import { InvenTreeIcon } from '../../../functions/icons';
import { ApiFormFieldType } from './ApiFormField';

export function TableField({
definition,
fieldName,
control
}: {
definition: ApiFormFieldType;
fieldName: string;
control: UseControllerReturn<FieldValues, any>;
}) {
const {
field,
fieldState: { error }
} = control;
const { value, ref } = field;

const onRowFieldChange = (idx: number, key: string, value: any) => {
const val = field.value;
val[idx][key] = value;
field.onChange(val);
};

const removeRow = (idx: number) => {
const val = field.value;
val.splice(idx, 1);
field.onChange(val);
};

return (
<Table highlightOnHover striped>
<thead>
<tr>
{definition.headers?.map((header) => {
return <th key={header}>{header}</th>;
})}
</tr>
</thead>
<tbody>
{value.length > 0 ? (
value.map((item: any, idx: number) => {
// Table fields require render function
if (!definition.modelRenderer) {
return <tr>{t`modelRenderer entry required for tables`}</tr>;
}
return definition.modelRenderer({
item: item,
idx: idx,
changeFn: onRowFieldChange,
removeFn: removeRow
});
})
) : (
<tr>
<td
style={{ textAlign: 'center' }}
colSpan={definition.headers?.length}
>
<span
style={{
display: 'flex',
justifyContent: 'center',
gap: '5px'
}}
>
<InvenTreeIcon icon="info" />
<Trans>No entries available</Trans>
</span>
</td>
</tr>
)}
</tbody>
</Table>
);
}
Loading

0 comments on commit 0196dd2

Please sign in to comment.