mirror of
https://github.com/inventree/InvenTree.git
synced 2025-04-27 19:16:44 +00:00
Refactor fix formatting exclusion (#8746)
* fix ruff exclusions * aut-format * Fix docstrings * more fixes * ignore error(s) * fix imports * adjust descriptions for build
This commit is contained in:
parent
1fec41cb71
commit
fe68dc7318
@ -3,8 +3,6 @@
|
||||
exclude = [
|
||||
".git",
|
||||
"__pycache__",
|
||||
"dist",
|
||||
"build",
|
||||
"test.py",
|
||||
"tests",
|
||||
"venv",
|
||||
|
@ -1,13 +1,16 @@
|
||||
"""InvenTree API version information."""
|
||||
|
||||
# InvenTree API version
|
||||
INVENTREE_API_VERSION = 294
|
||||
INVENTREE_API_VERSION = 295
|
||||
|
||||
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
|
||||
|
||||
|
||||
INVENTREE_API_TEXT = """
|
||||
|
||||
v295 - 2024-12-23 : https://github.com/inventree/InvenTree/pull/8746
|
||||
- Improve API documentation for build APIs
|
||||
|
||||
v294 - 2024-12-23 : https://github.com/inventree/InvenTree/pull/8738
|
||||
- Extends registration API documentation
|
||||
|
||||
|
@ -1,71 +1,36 @@
|
||||
"""Admin functionality for the BuildOrder app"""
|
||||
"""Admin functionality for the BuildOrder app."""
|
||||
|
||||
from django.contrib import admin
|
||||
|
||||
from build.models import Build, BuildLine, BuildItem
|
||||
from build.models import Build, BuildItem, BuildLine
|
||||
|
||||
|
||||
@admin.register(Build)
|
||||
class BuildAdmin(admin.ModelAdmin):
|
||||
"""Class for managing the Build model via the admin interface"""
|
||||
"""Class for managing the Build model via the admin interface."""
|
||||
|
||||
exclude = [
|
||||
'reference_int',
|
||||
]
|
||||
exclude = ['reference_int']
|
||||
|
||||
list_display = (
|
||||
'reference',
|
||||
'title',
|
||||
'part',
|
||||
'status',
|
||||
'batch',
|
||||
'quantity',
|
||||
)
|
||||
list_display = ('reference', 'title', 'part', 'status', 'batch', 'quantity')
|
||||
|
||||
search_fields = [
|
||||
'reference',
|
||||
'title',
|
||||
'part__name',
|
||||
'part__description',
|
||||
]
|
||||
search_fields = ['reference', 'title', 'part__name', 'part__description']
|
||||
|
||||
autocomplete_fields = [
|
||||
'parent',
|
||||
'part',
|
||||
'sales_order',
|
||||
'take_from',
|
||||
'destination',
|
||||
]
|
||||
autocomplete_fields = ['parent', 'part', 'sales_order', 'take_from', 'destination']
|
||||
|
||||
|
||||
@admin.register(BuildItem)
|
||||
class BuildItemAdmin(admin.ModelAdmin):
|
||||
"""Class for managing the BuildItem model via the admin interface."""
|
||||
|
||||
list_display = (
|
||||
'stock_item',
|
||||
'quantity'
|
||||
)
|
||||
list_display = ('stock_item', 'quantity')
|
||||
|
||||
autocomplete_fields = [
|
||||
'build_line',
|
||||
'stock_item',
|
||||
'install_into',
|
||||
]
|
||||
autocomplete_fields = ['build_line', 'stock_item', 'install_into']
|
||||
|
||||
|
||||
@admin.register(BuildLine)
|
||||
class BuildLineAdmin(admin.ModelAdmin):
|
||||
"""Class for managing the BuildLine model via the admin interface"""
|
||||
"""Class for managing the BuildLine model via the admin interface."""
|
||||
|
||||
list_display = (
|
||||
'build',
|
||||
'bom_item',
|
||||
'quantity',
|
||||
)
|
||||
list_display = ('build', 'bom_item', 'quantity')
|
||||
|
||||
search_fields = [
|
||||
'build__title',
|
||||
'build__reference',
|
||||
'bom_item__sub_part__name',
|
||||
]
|
||||
search_fields = ['build__title', 'build__reference', 'bom_item__sub_part__name']
|
||||
|
@ -1,29 +1,28 @@
|
||||
"""JSON API for the Build app."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from django.contrib.auth.models import User
|
||||
from django.db.models import F, Q
|
||||
from django.urls import include, path
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.contrib.auth.models import User
|
||||
|
||||
from django_filters import rest_framework as rest_filters
|
||||
from rest_framework.exceptions import ValidationError
|
||||
|
||||
from importer.mixins import DataExportViewMixin
|
||||
|
||||
from InvenTree.api import BulkDeleteMixin, MetadataView
|
||||
from generic.states.api import StatusView
|
||||
from InvenTree.helpers import str2bool, isNull
|
||||
from build.status_codes import BuildStatus, BuildStatusGroups
|
||||
from InvenTree.mixins import CreateAPI, RetrieveUpdateDestroyAPI, ListCreateAPI
|
||||
|
||||
import common.models
|
||||
import build.admin
|
||||
import build.serializers
|
||||
from build.models import Build, BuildLine, BuildItem
|
||||
import common.models
|
||||
import part.models
|
||||
from build.models import Build, BuildItem, BuildLine
|
||||
from build.status_codes import BuildStatus, BuildStatusGroups
|
||||
from generic.states.api import StatusView
|
||||
from importer.mixins import DataExportViewMixin
|
||||
from InvenTree.api import BulkDeleteMixin, MetadataView
|
||||
from InvenTree.filters import SEARCH_ORDER_FILTER_ALIAS, InvenTreeDateFilter
|
||||
from InvenTree.helpers import isNull, str2bool
|
||||
from InvenTree.mixins import CreateAPI, ListCreateAPI, RetrieveUpdateDestroyAPI
|
||||
from users.models import Owner
|
||||
from InvenTree.filters import InvenTreeDateFilter, SEARCH_ORDER_FILTER_ALIAS
|
||||
|
||||
|
||||
class BuildFilter(rest_filters.FilterSet):
|
||||
@ -31,17 +30,18 @@ class BuildFilter(rest_filters.FilterSet):
|
||||
|
||||
class Meta:
|
||||
"""Metaclass options."""
|
||||
|
||||
model = Build
|
||||
fields = [
|
||||
'sales_order',
|
||||
]
|
||||
fields = ['sales_order']
|
||||
|
||||
status = rest_filters.NumberFilter(label='Status')
|
||||
|
||||
active = rest_filters.BooleanFilter(label='Build is active', method='filter_active')
|
||||
|
||||
# 'outstanding' is an alias for 'active' here
|
||||
outstanding = rest_filters.BooleanFilter(label='Build is outstanding', method='filter_active')
|
||||
outstanding = rest_filters.BooleanFilter(
|
||||
label='Build is outstanding', method='filter_active'
|
||||
)
|
||||
|
||||
def filter_active(self, queryset, name, value):
|
||||
"""Filter the queryset to either include or exclude orders which are active."""
|
||||
@ -50,12 +50,12 @@ class BuildFilter(rest_filters.FilterSet):
|
||||
return queryset.exclude(status__in=BuildStatusGroups.ACTIVE_CODES)
|
||||
|
||||
parent = rest_filters.ModelChoiceFilter(
|
||||
queryset=Build.objects.all(),
|
||||
label=_('Parent Build'),
|
||||
field_name='parent',
|
||||
queryset=Build.objects.all(), label=_('Parent Build'), field_name='parent'
|
||||
)
|
||||
|
||||
include_variants = rest_filters.BooleanFilter(label=_('Include Variants'), method='filter_include_variants')
|
||||
include_variants = rest_filters.BooleanFilter(
|
||||
label=_('Include Variants'), method='filter_include_variants'
|
||||
)
|
||||
|
||||
def filter_include_variants(self, queryset, name, value):
|
||||
"""Filter by whether or not to include variants of the selected part.
|
||||
@ -64,13 +64,10 @@ class BuildFilter(rest_filters.FilterSet):
|
||||
- This filter does nothing by itself, and requires the 'part' filter to be set.
|
||||
- Refer to the 'filter_part' method for more information.
|
||||
"""
|
||||
|
||||
return queryset
|
||||
|
||||
part = rest_filters.ModelChoiceFilter(
|
||||
queryset=part.models.Part.objects.all(),
|
||||
field_name='part',
|
||||
method='filter_part'
|
||||
queryset=part.models.Part.objects.all(), field_name='part', method='filter_part'
|
||||
)
|
||||
|
||||
def filter_part(self, queryset, name, part):
|
||||
@ -80,7 +77,6 @@ class BuildFilter(rest_filters.FilterSet):
|
||||
- If "include_variants" is True, include all variants of the selected part.
|
||||
- Otherwise, just filter by the selected part.
|
||||
"""
|
||||
|
||||
include_variants = str2bool(self.data.get('include_variants', False))
|
||||
|
||||
if include_variants:
|
||||
@ -91,16 +87,17 @@ class BuildFilter(rest_filters.FilterSet):
|
||||
ancestor = rest_filters.ModelChoiceFilter(
|
||||
queryset=Build.objects.all(),
|
||||
label=_('Ancestor Build'),
|
||||
method='filter_ancestor'
|
||||
method='filter_ancestor',
|
||||
)
|
||||
|
||||
def filter_ancestor(self, queryset, name, parent):
|
||||
"""Filter by 'parent' build order."""
|
||||
|
||||
builds = parent.get_descendants(include_self=False)
|
||||
return queryset.filter(pk__in=[b.pk for b in builds])
|
||||
|
||||
overdue = rest_filters.BooleanFilter(label='Build is overdue', method='filter_overdue')
|
||||
overdue = rest_filters.BooleanFilter(
|
||||
label='Build is overdue', method='filter_overdue'
|
||||
)
|
||||
|
||||
def filter_overdue(self, queryset, name, value):
|
||||
"""Filter the queryset to either include or exclude orders which are overdue."""
|
||||
@ -109,8 +106,7 @@ class BuildFilter(rest_filters.FilterSet):
|
||||
return queryset.exclude(Build.OVERDUE_FILTER)
|
||||
|
||||
assigned_to_me = rest_filters.BooleanFilter(
|
||||
label=_('Assigned to me'),
|
||||
method='filter_assigned_to_me'
|
||||
label=_('Assigned to me'), method='filter_assigned_to_me'
|
||||
)
|
||||
|
||||
def filter_assigned_to_me(self, queryset, name, value):
|
||||
@ -125,14 +121,11 @@ class BuildFilter(rest_filters.FilterSet):
|
||||
return queryset.exclude(responsible__in=owners)
|
||||
|
||||
issued_by = rest_filters.ModelChoiceFilter(
|
||||
queryset=Owner.objects.all(),
|
||||
label=_('Issued By'),
|
||||
method='filter_issued_by'
|
||||
queryset=Owner.objects.all(), label=_('Issued By'), method='filter_issued_by'
|
||||
)
|
||||
|
||||
def filter_issued_by(self, queryset, name, owner):
|
||||
"""Filter by 'owner' which issued the order."""
|
||||
|
||||
if owner.label() == 'user':
|
||||
user = User.objects.get(pk=owner.owner_id)
|
||||
return queryset.filter(issued_by=user)
|
||||
@ -143,70 +136,62 @@ class BuildFilter(rest_filters.FilterSet):
|
||||
return queryset.none()
|
||||
|
||||
assigned_to = rest_filters.ModelChoiceFilter(
|
||||
queryset=Owner.objects.all(),
|
||||
field_name='responsible',
|
||||
label=_('Assigned To')
|
||||
queryset=Owner.objects.all(), field_name='responsible', label=_('Assigned To')
|
||||
)
|
||||
|
||||
def filter_responsible(self, queryset, name, owner):
|
||||
"""Filter by orders which are assigned to the specified owner."""
|
||||
|
||||
owners = list(Owner.objects.filter(pk=owner))
|
||||
|
||||
# if we query by a user, also find all ownerships through group memberships
|
||||
if len(owners) > 0 and owners[0].label() == 'user':
|
||||
owners = Owner.get_owners_matching_user(User.objects.get(pk=owners[0].owner_id))
|
||||
owners = Owner.get_owners_matching_user(
|
||||
User.objects.get(pk=owners[0].owner_id)
|
||||
)
|
||||
|
||||
return queryset.filter(responsible__in=owners)
|
||||
|
||||
# Exact match for reference
|
||||
reference = rest_filters.CharFilter(
|
||||
label='Filter by exact reference',
|
||||
field_name='reference',
|
||||
lookup_expr="iexact"
|
||||
label='Filter by exact reference', field_name='reference', lookup_expr='iexact'
|
||||
)
|
||||
|
||||
project_code = rest_filters.ModelChoiceFilter(
|
||||
queryset=common.models.ProjectCode.objects.all(),
|
||||
field_name='project_code'
|
||||
queryset=common.models.ProjectCode.objects.all(), field_name='project_code'
|
||||
)
|
||||
|
||||
has_project_code = rest_filters.BooleanFilter(label='has_project_code', method='filter_has_project_code')
|
||||
has_project_code = rest_filters.BooleanFilter(
|
||||
label='has_project_code', method='filter_has_project_code'
|
||||
)
|
||||
|
||||
def filter_has_project_code(self, queryset, name, value):
|
||||
"""Filter by whether or not the order has a project code"""
|
||||
"""Filter by whether or not the order has a project code."""
|
||||
if str2bool(value):
|
||||
return queryset.exclude(project_code=None)
|
||||
return queryset.filter(project_code=None)
|
||||
|
||||
created_before = InvenTreeDateFilter(
|
||||
label=_('Created before'),
|
||||
field_name='creation_date', lookup_expr='lt'\
|
||||
label=_('Created before'), field_name='creation_date', lookup_expr='lt'
|
||||
)
|
||||
|
||||
created_after = InvenTreeDateFilter(
|
||||
label=_('Created after'),
|
||||
field_name='creation_date', lookup_expr='gt'
|
||||
label=_('Created after'), field_name='creation_date', lookup_expr='gt'
|
||||
)
|
||||
|
||||
target_date_before = InvenTreeDateFilter(
|
||||
label=_('Target date before'),
|
||||
field_name='target_date', lookup_expr='lt'
|
||||
label=_('Target date before'), field_name='target_date', lookup_expr='lt'
|
||||
)
|
||||
|
||||
target_date_after = InvenTreeDateFilter(
|
||||
label=_('Target date after'),
|
||||
field_name='target_date', lookup_expr='gt'
|
||||
label=_('Target date after'), field_name='target_date', lookup_expr='gt'
|
||||
)
|
||||
|
||||
completed_before = InvenTreeDateFilter(
|
||||
label=_('Completed before'),
|
||||
field_name='completion_date', lookup_expr='lt'
|
||||
label=_('Completed before'), field_name='completion_date', lookup_expr='lt'
|
||||
)
|
||||
|
||||
completed_after = InvenTreeDateFilter(
|
||||
label=_('Completed after'),
|
||||
field_name='completion_date', lookup_expr='gt'
|
||||
label=_('Completed after'), field_name='completion_date', lookup_expr='gt'
|
||||
)
|
||||
|
||||
|
||||
@ -227,7 +212,7 @@ class BuildMixin:
|
||||
'build_lines__bom_item',
|
||||
'build_lines__build',
|
||||
'part',
|
||||
'part__pricing_data'
|
||||
'part__pricing_data',
|
||||
)
|
||||
|
||||
return queryset
|
||||
@ -295,7 +280,6 @@ class BuildList(DataExportViewMixin, BuildMixin, ListCreateAPI):
|
||||
exclude_tree = params.get('exclude_tree', None)
|
||||
|
||||
if exclude_tree is not None:
|
||||
|
||||
try:
|
||||
build = Build.objects.get(pk=exclude_tree)
|
||||
|
||||
@ -332,12 +316,14 @@ class BuildDetail(BuildMixin, RetrieveUpdateDestroyAPI):
|
||||
"""API endpoint for detail view of a Build object."""
|
||||
|
||||
def destroy(self, request, *args, **kwargs):
|
||||
"""Only allow deletion of a BuildOrder if the build status is CANCELLED"""
|
||||
"""Only allow deletion of a BuildOrder if the build status is CANCELLED."""
|
||||
build = self.get_object()
|
||||
|
||||
if build.status != BuildStatus.CANCELLED:
|
||||
raise ValidationError({
|
||||
"non_field_errors": [_("Build must be cancelled before it can be deleted")]
|
||||
'non_field_errors': [
|
||||
_('Build must be cancelled before it can be deleted')
|
||||
]
|
||||
})
|
||||
|
||||
return super().destroy(request, *args, **kwargs)
|
||||
@ -374,18 +360,26 @@ class BuildLineFilter(rest_filters.FilterSet):
|
||||
|
||||
class Meta:
|
||||
"""Meta information for the BuildLineFilter class."""
|
||||
|
||||
model = BuildLine
|
||||
fields = [
|
||||
'build',
|
||||
'bom_item',
|
||||
]
|
||||
fields = ['build', 'bom_item']
|
||||
|
||||
# Fields on related models
|
||||
consumable = rest_filters.BooleanFilter(label=_('Consumable'), field_name='bom_item__consumable')
|
||||
optional = rest_filters.BooleanFilter(label=_('Optional'), field_name='bom_item__optional')
|
||||
assembly = rest_filters.BooleanFilter(label=_('Assembly'), field_name='bom_item__sub_part__assembly')
|
||||
tracked = rest_filters.BooleanFilter(label=_('Tracked'), field_name='bom_item__sub_part__trackable')
|
||||
testable = rest_filters.BooleanFilter(label=_('Testable'), field_name='bom_item__sub_part__testable')
|
||||
consumable = rest_filters.BooleanFilter(
|
||||
label=_('Consumable'), field_name='bom_item__consumable'
|
||||
)
|
||||
optional = rest_filters.BooleanFilter(
|
||||
label=_('Optional'), field_name='bom_item__optional'
|
||||
)
|
||||
assembly = rest_filters.BooleanFilter(
|
||||
label=_('Assembly'), field_name='bom_item__sub_part__assembly'
|
||||
)
|
||||
tracked = rest_filters.BooleanFilter(
|
||||
label=_('Tracked'), field_name='bom_item__sub_part__trackable'
|
||||
)
|
||||
testable = rest_filters.BooleanFilter(
|
||||
label=_('Testable'), field_name='bom_item__sub_part__testable'
|
||||
)
|
||||
|
||||
part = rest_filters.ModelChoiceFilter(
|
||||
queryset=part.models.Part.objects.all(),
|
||||
@ -394,8 +388,7 @@ class BuildLineFilter(rest_filters.FilterSet):
|
||||
)
|
||||
|
||||
order_outstanding = rest_filters.BooleanFilter(
|
||||
label=_('Order Outstanding'),
|
||||
method='filter_order_outstanding'
|
||||
label=_('Order Outstanding'), method='filter_order_outstanding'
|
||||
)
|
||||
|
||||
def filter_order_outstanding(self, queryset, name, value):
|
||||
@ -404,18 +397,22 @@ class BuildLineFilter(rest_filters.FilterSet):
|
||||
return queryset.filter(build__status__in=BuildStatusGroups.ACTIVE_CODES)
|
||||
return queryset.exclude(build__status__in=BuildStatusGroups.ACTIVE_CODES)
|
||||
|
||||
allocated = rest_filters.BooleanFilter(label=_('Allocated'), method='filter_allocated')
|
||||
allocated = rest_filters.BooleanFilter(
|
||||
label=_('Allocated'), method='filter_allocated'
|
||||
)
|
||||
|
||||
def filter_allocated(self, queryset, name, value):
|
||||
"""Filter by whether each BuildLine is fully allocated"""
|
||||
"""Filter by whether each BuildLine is fully allocated."""
|
||||
if str2bool(value):
|
||||
return queryset.filter(allocated__gte=F('quantity'))
|
||||
return queryset.filter(allocated__lt=F('quantity'))
|
||||
|
||||
available = rest_filters.BooleanFilter(label=_('Available'), method='filter_available')
|
||||
available = rest_filters.BooleanFilter(
|
||||
label=_('Available'), method='filter_available'
|
||||
)
|
||||
|
||||
def filter_available(self, queryset, name, value):
|
||||
"""Filter by whether there is sufficient stock available for each BuildLine:
|
||||
"""Filter by whether there is sufficient stock available for each BuildLine.
|
||||
|
||||
To determine this, we need to know:
|
||||
|
||||
@ -423,14 +420,18 @@ class BuildLineFilter(rest_filters.FilterSet):
|
||||
- The quantity available for each BuildLine (including variants and substitutes)
|
||||
- The quantity allocated for each BuildLine
|
||||
"""
|
||||
flt = Q(quantity__lte=F('allocated') + F('available_stock') + F('available_substitute_stock') + F('available_variant_stock'))
|
||||
flt = Q(
|
||||
quantity__lte=F('allocated')
|
||||
+ F('available_stock')
|
||||
+ F('available_substitute_stock')
|
||||
+ F('available_variant_stock')
|
||||
)
|
||||
|
||||
if str2bool(value):
|
||||
return queryset.filter(flt)
|
||||
return queryset.exclude(flt)
|
||||
|
||||
|
||||
|
||||
class BuildLineEndpoint:
|
||||
"""Mixin class for BuildLine API endpoints."""
|
||||
|
||||
@ -439,7 +440,6 @@ class BuildLineEndpoint:
|
||||
|
||||
def get_serializer(self, *args, **kwargs):
|
||||
"""Return the serializer instance for this endpoint."""
|
||||
|
||||
kwargs['context'] = self.get_serializer_context()
|
||||
|
||||
try:
|
||||
@ -460,10 +460,12 @@ class BuildLineEndpoint:
|
||||
- If this is a "detail" view, use the build associated with the line
|
||||
- If this is a "list" view, use the build associated with the request
|
||||
"""
|
||||
raise NotImplementedError("get_source_build must be implemented in the child class")
|
||||
raise NotImplementedError(
|
||||
'get_source_build must be implemented in the child class'
|
||||
)
|
||||
|
||||
def get_queryset(self):
|
||||
"""Override queryset to select-related and annotate"""
|
||||
"""Override queryset to select-related and annotate."""
|
||||
queryset = super().get_queryset()
|
||||
|
||||
if not hasattr(self, 'source_build'):
|
||||
@ -471,11 +473,13 @@ class BuildLineEndpoint:
|
||||
|
||||
source_build = self.source_build
|
||||
|
||||
return build.serializers.BuildLineSerializer.annotate_queryset(queryset, build=source_build)
|
||||
return build.serializers.BuildLineSerializer.annotate_queryset(
|
||||
queryset, build=source_build
|
||||
)
|
||||
|
||||
|
||||
class BuildLineList(BuildLineEndpoint, DataExportViewMixin, ListCreateAPI):
|
||||
"""API endpoint for accessing a list of BuildLine objects"""
|
||||
"""API endpoint for accessing a list of BuildLine objects."""
|
||||
|
||||
filterset_class = BuildLineFilter
|
||||
filter_backends = SEARCH_ORDER_FILTER_ALIAS
|
||||
@ -514,7 +518,6 @@ class BuildLineList(BuildLineEndpoint, DataExportViewMixin, ListCreateAPI):
|
||||
|
||||
def get_source_build(self) -> Build | None:
|
||||
"""Return the target build for the BuildLine queryset."""
|
||||
|
||||
source_build = None
|
||||
|
||||
try:
|
||||
@ -532,7 +535,6 @@ class BuildLineDetail(BuildLineEndpoint, RetrieveUpdateDestroyAPI):
|
||||
|
||||
def get_source_build(self) -> Build | None:
|
||||
"""Return the target source location for the BuildLine queryset."""
|
||||
|
||||
return None
|
||||
|
||||
|
||||
@ -607,12 +609,8 @@ class BuildFinish(BuildOrderContextMixin, CreateAPI):
|
||||
|
||||
def get_queryset(self):
|
||||
"""Return the queryset for the BuildFinish API endpoint."""
|
||||
|
||||
queryset = super().get_queryset()
|
||||
queryset = queryset.prefetch_related(
|
||||
'build_lines',
|
||||
'build_lines__allocations'
|
||||
)
|
||||
queryset = queryset.prefetch_related('build_lines', 'build_lines__allocations')
|
||||
|
||||
return queryset
|
||||
|
||||
@ -658,6 +656,7 @@ class BuildHold(BuildOrderContextMixin, CreateAPI):
|
||||
queryset = Build.objects.all()
|
||||
serializer_class = build.serializers.BuildHoldSerializer
|
||||
|
||||
|
||||
class BuildCancel(BuildOrderContextMixin, CreateAPI):
|
||||
"""API endpoint for cancelling a BuildOrder."""
|
||||
|
||||
@ -673,16 +672,13 @@ class BuildItemDetail(RetrieveUpdateDestroyAPI):
|
||||
|
||||
|
||||
class BuildItemFilter(rest_filters.FilterSet):
|
||||
"""Custom filterset for the BuildItemList API endpoint"""
|
||||
"""Custom filterset for the BuildItemList API endpoint."""
|
||||
|
||||
class Meta:
|
||||
"""Metaclass option"""
|
||||
"""Metaclass option."""
|
||||
|
||||
model = BuildItem
|
||||
fields = [
|
||||
'build_line',
|
||||
'stock_item',
|
||||
'install_into',
|
||||
]
|
||||
fields = ['build_line', 'stock_item', 'install_into']
|
||||
|
||||
include_variants = rest_filters.BooleanFilter(
|
||||
label=_('Include Variants'), method='filter_include_variants'
|
||||
@ -695,7 +691,6 @@ class BuildItemFilter(rest_filters.FilterSet):
|
||||
- This filter does nothing by itself, and requires the 'part' filter to be set.
|
||||
- Refer to the 'filter_part' method for more information.
|
||||
"""
|
||||
|
||||
return queryset
|
||||
|
||||
part = rest_filters.ModelChoiceFilter(
|
||||
@ -712,11 +707,12 @@ class BuildItemFilter(rest_filters.FilterSet):
|
||||
- If "include_variants" is True, include all variants of the selected part.
|
||||
- Otherwise, just filter by the selected part.
|
||||
"""
|
||||
|
||||
include_variants = str2bool(self.data.get('include_variants', False))
|
||||
|
||||
if include_variants:
|
||||
return queryset.filter(stock_item__part__in=part.get_descendants(include_self=True))
|
||||
return queryset.filter(
|
||||
stock_item__part__in=part.get_descendants(include_self=True)
|
||||
)
|
||||
else:
|
||||
return queryset.filter(stock_item__part=part)
|
||||
|
||||
@ -729,7 +725,7 @@ class BuildItemFilter(rest_filters.FilterSet):
|
||||
tracked = rest_filters.BooleanFilter(label='Tracked', method='filter_tracked')
|
||||
|
||||
def filter_tracked(self, queryset, name, value):
|
||||
"""Filter the queryset based on whether build items are tracked"""
|
||||
"""Filter the queryset based on whether build items are tracked."""
|
||||
if str2bool(value):
|
||||
return queryset.exclude(install_into=None)
|
||||
return queryset.filter(install_into=None)
|
||||
@ -752,7 +748,12 @@ class BuildItemList(DataExportViewMixin, BulkDeleteMixin, ListCreateAPI):
|
||||
try:
|
||||
params = self.request.query_params
|
||||
|
||||
for key in ['part_detail', 'location_detail', 'stock_detail', 'build_detail']:
|
||||
for key in [
|
||||
'part_detail',
|
||||
'location_detail',
|
||||
'stock_detail',
|
||||
'build_detail',
|
||||
]:
|
||||
if key in params:
|
||||
kwargs[key] = str2bool(params.get(key, False))
|
||||
except AttributeError:
|
||||
@ -778,9 +779,7 @@ class BuildItemList(DataExportViewMixin, BulkDeleteMixin, ListCreateAPI):
|
||||
'stock_item__supplier_part__supplier',
|
||||
'stock_item__supplier_part__manufacturer_part',
|
||||
'stock_item__supplier_part__manufacturer_part__manufacturer',
|
||||
).prefetch_related(
|
||||
'stock_item__location__tags',
|
||||
)
|
||||
).prefetch_related('stock_item__location__tags')
|
||||
|
||||
return queryset
|
||||
|
||||
@ -794,7 +793,6 @@ class BuildItemList(DataExportViewMixin, BulkDeleteMixin, ListCreateAPI):
|
||||
output = params.get('output', None)
|
||||
|
||||
if output:
|
||||
|
||||
if isNull(output):
|
||||
queryset = queryset.filter(install_into=None)
|
||||
else:
|
||||
@ -802,14 +800,7 @@ class BuildItemList(DataExportViewMixin, BulkDeleteMixin, ListCreateAPI):
|
||||
|
||||
return queryset
|
||||
|
||||
ordering_fields = [
|
||||
'part',
|
||||
'sku',
|
||||
'quantity',
|
||||
'location',
|
||||
'reference',
|
||||
'IPN',
|
||||
]
|
||||
ordering_fields = ['part', 'sku', 'quantity', 'location', 'reference', 'IPN']
|
||||
|
||||
ordering_field_aliases = {
|
||||
'part': 'stock_item__part__name',
|
||||
@ -828,42 +819,84 @@ class BuildItemList(DataExportViewMixin, BulkDeleteMixin, ListCreateAPI):
|
||||
|
||||
|
||||
build_api_urls = [
|
||||
|
||||
# Build lines
|
||||
path('line/', include([
|
||||
path('<int:pk>/', BuildLineDetail.as_view(), name='api-build-line-detail'),
|
||||
path('', BuildLineList.as_view(), name='api-build-line-list'),
|
||||
])),
|
||||
|
||||
path(
|
||||
'line/',
|
||||
include([
|
||||
path('<int:pk>/', BuildLineDetail.as_view(), name='api-build-line-detail'),
|
||||
path('', BuildLineList.as_view(), name='api-build-line-list'),
|
||||
]),
|
||||
),
|
||||
# Build Items
|
||||
path('item/', include([
|
||||
path('<int:pk>/', include([
|
||||
path('metadata/', MetadataView.as_view(), {'model': BuildItem}, name='api-build-item-metadata'),
|
||||
path('', BuildItemDetail.as_view(), name='api-build-item-detail'),
|
||||
])),
|
||||
path('', BuildItemList.as_view(), name='api-build-item-list'),
|
||||
])),
|
||||
|
||||
path(
|
||||
'item/',
|
||||
include([
|
||||
path(
|
||||
'<int:pk>/',
|
||||
include([
|
||||
path(
|
||||
'metadata/',
|
||||
MetadataView.as_view(),
|
||||
{'model': BuildItem},
|
||||
name='api-build-item-metadata',
|
||||
),
|
||||
path('', BuildItemDetail.as_view(), name='api-build-item-detail'),
|
||||
]),
|
||||
),
|
||||
path('', BuildItemList.as_view(), name='api-build-item-list'),
|
||||
]),
|
||||
),
|
||||
# Build Detail
|
||||
path('<int:pk>/', include([
|
||||
path('allocate/', BuildAllocate.as_view(), name='api-build-allocate'),
|
||||
path('auto-allocate/', BuildAutoAllocate.as_view(), name='api-build-auto-allocate'),
|
||||
path('complete/', BuildOutputComplete.as_view(), name='api-build-output-complete'),
|
||||
path('create-output/', BuildOutputCreate.as_view(), name='api-build-output-create'),
|
||||
path('delete-outputs/', BuildOutputDelete.as_view(), name='api-build-output-delete'),
|
||||
path('scrap-outputs/', BuildOutputScrap.as_view(), name='api-build-output-scrap'),
|
||||
path('issue/', BuildIssue.as_view(), name='api-build-issue'),
|
||||
path('hold/', BuildHold.as_view(), name='api-build-hold'),
|
||||
path('finish/', BuildFinish.as_view(), name='api-build-finish'),
|
||||
path('cancel/', BuildCancel.as_view(), name='api-build-cancel'),
|
||||
path('unallocate/', BuildUnallocate.as_view(), name='api-build-unallocate'),
|
||||
path('metadata/', MetadataView.as_view(), {'model': Build}, name='api-build-metadata'),
|
||||
path('', BuildDetail.as_view(), name='api-build-detail'),
|
||||
])),
|
||||
|
||||
path(
|
||||
'<int:pk>/',
|
||||
include([
|
||||
path('allocate/', BuildAllocate.as_view(), name='api-build-allocate'),
|
||||
path(
|
||||
'auto-allocate/',
|
||||
BuildAutoAllocate.as_view(),
|
||||
name='api-build-auto-allocate',
|
||||
),
|
||||
path(
|
||||
'complete/',
|
||||
BuildOutputComplete.as_view(),
|
||||
name='api-build-output-complete',
|
||||
),
|
||||
path(
|
||||
'create-output/',
|
||||
BuildOutputCreate.as_view(),
|
||||
name='api-build-output-create',
|
||||
),
|
||||
path(
|
||||
'delete-outputs/',
|
||||
BuildOutputDelete.as_view(),
|
||||
name='api-build-output-delete',
|
||||
),
|
||||
path(
|
||||
'scrap-outputs/',
|
||||
BuildOutputScrap.as_view(),
|
||||
name='api-build-output-scrap',
|
||||
),
|
||||
path('issue/', BuildIssue.as_view(), name='api-build-issue'),
|
||||
path('hold/', BuildHold.as_view(), name='api-build-hold'),
|
||||
path('finish/', BuildFinish.as_view(), name='api-build-finish'),
|
||||
path('cancel/', BuildCancel.as_view(), name='api-build-cancel'),
|
||||
path('unallocate/', BuildUnallocate.as_view(), name='api-build-unallocate'),
|
||||
path(
|
||||
'metadata/',
|
||||
MetadataView.as_view(),
|
||||
{'model': Build},
|
||||
name='api-build-metadata',
|
||||
),
|
||||
path('', BuildDetail.as_view(), name='api-build-detail'),
|
||||
]),
|
||||
),
|
||||
# Build order status code information
|
||||
path('status/', StatusView.as_view(), {StatusView.MODEL_REF: BuildStatus}, name='api-build-status-codes'),
|
||||
|
||||
path(
|
||||
'status/',
|
||||
StatusView.as_view(),
|
||||
{StatusView.MODEL_REF: BuildStatus},
|
||||
name='api-build-status-codes',
|
||||
),
|
||||
# Build List
|
||||
path('', BuildList.as_view(), name='api-build-list'),
|
||||
]
|
||||
|
@ -1,8 +1,9 @@
|
||||
"""Django app for the BuildOrder module"""
|
||||
"""Django app for the BuildOrder module."""
|
||||
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class BuildConfig(AppConfig):
|
||||
"""BuildOrder app config class"""
|
||||
"""BuildOrder app config class."""
|
||||
|
||||
name = 'build'
|
||||
|
@ -1,25 +1,21 @@
|
||||
"""Queryset filtering helper functions for the Build app."""
|
||||
|
||||
|
||||
from django.db import models
|
||||
from django.db.models import Sum, Q
|
||||
from django.db.models import Q, Sum
|
||||
from django.db.models.functions import Coalesce
|
||||
|
||||
|
||||
def annotate_allocated_quantity(queryset: Q) -> Q:
|
||||
"""
|
||||
Annotate the 'allocated' quantity for each build item in the queryset.
|
||||
"""Annotate the 'allocated' quantity for each build item in the queryset.
|
||||
|
||||
Arguments:
|
||||
queryset: The BuildLine queryset to annotate
|
||||
|
||||
"""
|
||||
|
||||
queryset = queryset.prefetch_related('allocations')
|
||||
|
||||
return queryset.annotate(
|
||||
allocated=Coalesce(
|
||||
Sum('allocations__quantity'), 0,
|
||||
output_field=models.DecimalField()
|
||||
Sum('allocations__quantity'), 0, output_field=models.DecimalField()
|
||||
)
|
||||
)
|
||||
|
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -24,6 +24,4 @@ class BuildStatusGroups:
|
||||
BuildStatus.PRODUCTION.value,
|
||||
]
|
||||
|
||||
COMPLETE = [
|
||||
BuildStatus.COMPLETE.value,
|
||||
]
|
||||
COMPLETE = [BuildStatus.COMPLETE.value]
|
||||
|
@ -3,13 +3,12 @@
|
||||
import logging
|
||||
import random
|
||||
import time
|
||||
|
||||
from datetime import timedelta
|
||||
from decimal import Decimal
|
||||
|
||||
from django.contrib.auth.models import User
|
||||
from django.template.loader import render_to_string
|
||||
from django.db import transaction
|
||||
from django.template.loader import render_to_string
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from allauth.account.models import EmailAddress
|
||||
@ -34,7 +33,10 @@ def auto_allocate_build(build_id: int, **kwargs):
|
||||
build_order = build_models.Build.objects.filter(pk=build_id).first()
|
||||
|
||||
if not build_order:
|
||||
logger.warning("Could not auto-allocate BuildOrder <%s> - BuildOrder does not exist", build_id)
|
||||
logger.warning(
|
||||
'Could not auto-allocate BuildOrder <%s> - BuildOrder does not exist',
|
||||
build_id,
|
||||
)
|
||||
return
|
||||
|
||||
build_order.auto_allocate_stock(**kwargs)
|
||||
@ -48,13 +50,19 @@ def complete_build_allocations(build_id: int, user_id: int):
|
||||
try:
|
||||
user = User.objects.get(pk=user_id)
|
||||
except User.DoesNotExist:
|
||||
logger.warning("Could not complete build allocations for BuildOrder <%s> - User does not exist", build_id)
|
||||
logger.warning(
|
||||
'Could not complete build allocations for BuildOrder <%s> - User does not exist',
|
||||
build_id,
|
||||
)
|
||||
return
|
||||
else:
|
||||
user = None
|
||||
|
||||
if not build_order:
|
||||
logger.warning("Could not complete build allocations for BuildOrder <%s> - BuildOrder does not exist", build_id)
|
||||
logger.warning(
|
||||
'Could not complete build allocations for BuildOrder <%s> - BuildOrder does not exist',
|
||||
build_id,
|
||||
)
|
||||
return
|
||||
|
||||
build_order.complete_allocations(user)
|
||||
@ -65,7 +73,7 @@ def update_build_order_lines(bom_item_pk: int):
|
||||
|
||||
This task is triggered when a BomItem is created or updated.
|
||||
"""
|
||||
logger.info("Updating build order lines for BomItem %s", bom_item_pk)
|
||||
logger.info('Updating build order lines for BomItem %s', bom_item_pk)
|
||||
|
||||
bom_item = part_models.BomItem.objects.filter(pk=bom_item_pk).first()
|
||||
|
||||
@ -77,16 +85,14 @@ def update_build_order_lines(bom_item_pk: int):
|
||||
|
||||
# Find all active builds which reference any of the parts
|
||||
builds = build_models.Build.objects.filter(
|
||||
part__in=list(assemblies),
|
||||
status__in=BuildStatusGroups.ACTIVE_CODES
|
||||
part__in=list(assemblies), status__in=BuildStatusGroups.ACTIVE_CODES
|
||||
)
|
||||
|
||||
# Iterate through each build, and update the relevant line items
|
||||
for bo in builds:
|
||||
# Try to find a matching build order line
|
||||
line = build_models.BuildLine.objects.filter(
|
||||
build=bo,
|
||||
bom_item=bom_item,
|
||||
build=bo, bom_item=bom_item
|
||||
).first()
|
||||
|
||||
q = bom_item.get_required_quantity(bo.quantity)
|
||||
@ -99,13 +105,13 @@ def update_build_order_lines(bom_item_pk: int):
|
||||
else:
|
||||
# Create a new line item
|
||||
build_models.BuildLine.objects.create(
|
||||
build=bo,
|
||||
bom_item=bom_item,
|
||||
quantity=q,
|
||||
build=bo, bom_item=bom_item, quantity=q
|
||||
)
|
||||
|
||||
if builds.count() > 0:
|
||||
logger.info("Updated %s build orders for part %s", builds.count(), bom_item.part)
|
||||
logger.info(
|
||||
'Updated %s build orders for part %s', builds.count(), bom_item.part
|
||||
)
|
||||
|
||||
|
||||
def check_build_stock(build: build_models.Build):
|
||||
@ -133,7 +139,6 @@ def check_build_stock(build: build_models.Build):
|
||||
return
|
||||
|
||||
for bom_item in part.get_bom_items():
|
||||
|
||||
sub_part = bom_item.sub_part
|
||||
|
||||
# The 'in stock' quantity depends on whether the bom_item allows variants
|
||||
@ -149,7 +154,9 @@ def check_build_stock(build: build_models.Build):
|
||||
# There is not sufficient stock for this part
|
||||
|
||||
lines.append({
|
||||
'link': InvenTree.helpers_model.construct_absolute_url(sub_part.get_absolute_url()),
|
||||
'link': InvenTree.helpers_model.construct_absolute_url(
|
||||
sub_part.get_absolute_url()
|
||||
),
|
||||
'part': sub_part,
|
||||
'in_stock': in_stock,
|
||||
'allocated': allocated,
|
||||
@ -164,29 +171,32 @@ def check_build_stock(build: build_models.Build):
|
||||
# Are there any users subscribed to these parts?
|
||||
subscribers = build.part.get_subscribers()
|
||||
|
||||
emails = EmailAddress.objects.filter(
|
||||
user__in=subscribers,
|
||||
)
|
||||
emails = EmailAddress.objects.filter(user__in=subscribers)
|
||||
|
||||
if len(emails) > 0:
|
||||
|
||||
logger.info("Notifying users of stock required for build %s", build.pk)
|
||||
logger.info('Notifying users of stock required for build %s', build.pk)
|
||||
|
||||
context = {
|
||||
'link': InvenTree.helpers_model.construct_absolute_url(build.get_absolute_url()),
|
||||
'link': InvenTree.helpers_model.construct_absolute_url(
|
||||
build.get_absolute_url()
|
||||
),
|
||||
'build': build,
|
||||
'part': build.part,
|
||||
'lines': lines,
|
||||
}
|
||||
|
||||
# Render the HTML message
|
||||
html_message = render_to_string('email/build_order_required_stock.html', context)
|
||||
html_message = render_to_string(
|
||||
'email/build_order_required_stock.html', context
|
||||
)
|
||||
|
||||
subject = _("Stock required for build order")
|
||||
subject = _('Stock required for build order')
|
||||
|
||||
recipients = emails.values_list('email', flat=True)
|
||||
|
||||
InvenTree.helpers_email.send_email(subject, '', recipients, html_message=html_message)
|
||||
InvenTree.helpers_email.send_email(
|
||||
subject, '', recipients, html_message=html_message
|
||||
)
|
||||
|
||||
|
||||
def create_child_builds(build_id: int) -> None:
|
||||
@ -195,7 +205,6 @@ def create_child_builds(build_id: int) -> None:
|
||||
- Will create a build order for each assembly part in the BOM
|
||||
- Runs recursively, also creating child builds for each sub-assembly part
|
||||
"""
|
||||
|
||||
try:
|
||||
build_order = build_models.Build.objects.get(pk=build_id)
|
||||
except (build_models.Build.DoesNotExist, ValueError):
|
||||
@ -215,13 +224,12 @@ def create_child_builds(build_id: int) -> None:
|
||||
for item in assembly_items:
|
||||
quantity = item.quantity * build_order.quantity
|
||||
|
||||
|
||||
# Check if the child build order has already been created
|
||||
if build_models.Build.objects.filter(
|
||||
part=item.sub_part,
|
||||
parent=build_order,
|
||||
quantity=quantity,
|
||||
status__in=BuildStatusGroups.ACTIVE_CODES
|
||||
status__in=BuildStatusGroups.ACTIVE_CODES,
|
||||
).exists():
|
||||
continue
|
||||
|
||||
@ -241,11 +249,7 @@ def create_child_builds(build_id: int) -> None:
|
||||
|
||||
for pk in sub_build_ids:
|
||||
# Offload the child build order creation to the background task queue
|
||||
InvenTree.tasks.offload_task(
|
||||
create_child_builds,
|
||||
pk,
|
||||
group='build'
|
||||
)
|
||||
InvenTree.tasks.offload_task(create_child_builds, pk, group='build')
|
||||
|
||||
|
||||
def notify_overdue_build_order(bo: build_models.Build):
|
||||
@ -263,24 +267,16 @@ def notify_overdue_build_order(bo: build_models.Build):
|
||||
context = {
|
||||
'order': bo,
|
||||
'name': name,
|
||||
'message': _(f"Build order {bo} is now overdue"),
|
||||
'link': InvenTree.helpers_model.construct_absolute_url(
|
||||
bo.get_absolute_url(),
|
||||
),
|
||||
'template': {
|
||||
'html': 'email/overdue_build_order.html',
|
||||
'subject': name,
|
||||
}
|
||||
'message': _(f'Build order {bo} is now overdue'),
|
||||
'link': InvenTree.helpers_model.construct_absolute_url(bo.get_absolute_url()),
|
||||
'template': {'html': 'email/overdue_build_order.html', 'subject': name},
|
||||
}
|
||||
|
||||
event_name = BuildEvents.OVERDUE
|
||||
|
||||
# Send a notification to the appropriate users
|
||||
common.notifications.trigger_notification(
|
||||
bo,
|
||||
event_name,
|
||||
targets=targets,
|
||||
context=context
|
||||
bo, event_name, targets=targets, context=context
|
||||
)
|
||||
|
||||
# Register a matching event to the plugin system
|
||||
@ -298,8 +294,7 @@ def check_overdue_build_orders():
|
||||
yesterday = InvenTree.helpers.current_date() - timedelta(days=1)
|
||||
|
||||
overdue_orders = build_models.Build.objects.filter(
|
||||
target_date=yesterday,
|
||||
status__in=BuildStatusGroups.ACTIVE_CODES
|
||||
target_date=yesterday, status__in=BuildStatusGroups.ACTIVE_CODES
|
||||
)
|
||||
|
||||
for bo in overdue_orders:
|
||||
|
File diff suppressed because it is too large
Load Diff
@ -1,38 +1,34 @@
|
||||
"""Unit tests for the 'build' models"""
|
||||
"""Unit tests for the 'build' models."""
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from django.test import TestCase
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.auth.models import Group
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db.models import Sum
|
||||
from django.test import TestCase
|
||||
from django.test.utils import override_settings
|
||||
|
||||
from InvenTree import status_codes as status
|
||||
from InvenTree.unit_test import findOffloadedEvent
|
||||
|
||||
import common.models
|
||||
from common.settings import set_global_setting
|
||||
import build.tasks
|
||||
import common.models
|
||||
from build.models import Build, BuildItem, BuildLine, generate_next_build_reference
|
||||
from build.status_codes import BuildStatus
|
||||
from part.models import Part, BomItem, BomItemSubstitute, PartTestTemplate
|
||||
from common.settings import set_global_setting
|
||||
from InvenTree import status_codes as status
|
||||
from InvenTree.unit_test import findOffloadedEvent
|
||||
from part.models import BomItem, BomItemSubstitute, Part, PartTestTemplate
|
||||
from stock.models import StockItem, StockItemTestResult
|
||||
from users.models import Owner
|
||||
|
||||
import logging
|
||||
logger = logging.getLogger('inventree')
|
||||
|
||||
|
||||
class BuildTestBase(TestCase):
|
||||
"""Run some tests to ensure that the Build model is working properly."""
|
||||
|
||||
fixtures = [
|
||||
'users',
|
||||
]
|
||||
fixtures = ['users']
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
@ -54,8 +50,8 @@ class BuildTestBase(TestCase):
|
||||
|
||||
# Create a base "Part"
|
||||
cls.assembly = Part.objects.create(
|
||||
name="An assembled part",
|
||||
description="Why does it matter what my description is?",
|
||||
name='An assembled part',
|
||||
description='Why does it matter what my description is?',
|
||||
assembly=True,
|
||||
trackable=True,
|
||||
testable=True,
|
||||
@ -63,8 +59,8 @@ class BuildTestBase(TestCase):
|
||||
|
||||
# create one build with one required test template
|
||||
cls.tested_part_with_required_test = Part.objects.create(
|
||||
name="Part having required tests",
|
||||
description="Why does it matter what my description is?",
|
||||
name='Part having required tests',
|
||||
description='Why does it matter what my description is?',
|
||||
assembly=True,
|
||||
trackable=True,
|
||||
testable=True,
|
||||
@ -72,18 +68,18 @@ class BuildTestBase(TestCase):
|
||||
|
||||
cls.test_template_required = PartTestTemplate.objects.create(
|
||||
part=cls.tested_part_with_required_test,
|
||||
test_name="Required test",
|
||||
description="Required test template description",
|
||||
test_name='Required test',
|
||||
description='Required test template description',
|
||||
required=True,
|
||||
requires_value=False,
|
||||
requires_attachment=False
|
||||
requires_attachment=False,
|
||||
)
|
||||
|
||||
ref = generate_next_build_reference()
|
||||
|
||||
cls.build_w_tests_trackable = Build.objects.create(
|
||||
reference=ref,
|
||||
title="This is a build",
|
||||
title='This is a build',
|
||||
part=cls.tested_part_with_required_test,
|
||||
quantity=1,
|
||||
issued_by=get_user_model().objects.get(pk=1),
|
||||
@ -94,13 +90,13 @@ class BuildTestBase(TestCase):
|
||||
quantity=1,
|
||||
is_building=True,
|
||||
serial=uuid.uuid4(),
|
||||
build=cls.build_w_tests_trackable
|
||||
build=cls.build_w_tests_trackable,
|
||||
)
|
||||
|
||||
# now create a part with a non-required test template
|
||||
cls.tested_part_wo_required_test = Part.objects.create(
|
||||
name="Part with one non.required test",
|
||||
description="Why does it matter what my description is?",
|
||||
name='Part with one non.required test',
|
||||
description='Why does it matter what my description is?',
|
||||
assembly=True,
|
||||
trackable=True,
|
||||
testable=True,
|
||||
@ -108,18 +104,18 @@ class BuildTestBase(TestCase):
|
||||
|
||||
cls.test_template_non_required = PartTestTemplate.objects.create(
|
||||
part=cls.tested_part_wo_required_test,
|
||||
test_name="Required test template",
|
||||
description="Required test template description",
|
||||
test_name='Required test template',
|
||||
description='Required test template description',
|
||||
required=False,
|
||||
requires_value=False,
|
||||
requires_attachment=False
|
||||
requires_attachment=False,
|
||||
)
|
||||
|
||||
ref = generate_next_build_reference()
|
||||
|
||||
cls.build_wo_tests_trackable = Build.objects.create(
|
||||
reference=ref,
|
||||
title="This is a build",
|
||||
title='This is a build',
|
||||
part=cls.tested_part_wo_required_test,
|
||||
quantity=1,
|
||||
issued_by=get_user_model().objects.get(pk=1),
|
||||
@ -130,47 +126,33 @@ class BuildTestBase(TestCase):
|
||||
quantity=1,
|
||||
is_building=True,
|
||||
serial=uuid.uuid4(),
|
||||
build=cls.build_wo_tests_trackable
|
||||
build=cls.build_wo_tests_trackable,
|
||||
)
|
||||
|
||||
cls.sub_part_1 = Part.objects.create(
|
||||
name="Widget A",
|
||||
description="A widget",
|
||||
component=True
|
||||
name='Widget A', description='A widget', component=True
|
||||
)
|
||||
|
||||
cls.sub_part_2 = Part.objects.create(
|
||||
name="Widget B",
|
||||
description="A widget",
|
||||
component=True
|
||||
name='Widget B', description='A widget', component=True
|
||||
)
|
||||
|
||||
cls.sub_part_3 = Part.objects.create(
|
||||
name="Widget C",
|
||||
description="A widget",
|
||||
component=True,
|
||||
trackable=True
|
||||
name='Widget C', description='A widget', component=True, trackable=True
|
||||
)
|
||||
|
||||
# Create BOM item links for the parts
|
||||
cls.bom_item_1 = BomItem.objects.create(
|
||||
part=cls.assembly,
|
||||
sub_part=cls.sub_part_1,
|
||||
quantity=5
|
||||
part=cls.assembly, sub_part=cls.sub_part_1, quantity=5
|
||||
)
|
||||
|
||||
cls.bom_item_2 = BomItem.objects.create(
|
||||
part=cls.assembly,
|
||||
sub_part=cls.sub_part_2,
|
||||
quantity=3,
|
||||
optional=True
|
||||
part=cls.assembly, sub_part=cls.sub_part_2, quantity=3, optional=True
|
||||
)
|
||||
|
||||
# sub_part_3 is trackable!
|
||||
cls.bom_item_3 = BomItem.objects.create(
|
||||
part=cls.assembly,
|
||||
sub_part=cls.sub_part_3,
|
||||
quantity=2
|
||||
part=cls.assembly, sub_part=cls.sub_part_3, quantity=2
|
||||
)
|
||||
|
||||
ref = generate_next_build_reference()
|
||||
@ -178,7 +160,7 @@ class BuildTestBase(TestCase):
|
||||
# Create a "Build" object to make 10x objects
|
||||
cls.build = Build.objects.create(
|
||||
reference=ref,
|
||||
title="This is a build",
|
||||
title='This is a build',
|
||||
part=cls.assembly,
|
||||
quantity=10,
|
||||
issued_by=get_user_model().objects.get(pk=1),
|
||||
@ -192,17 +174,11 @@ class BuildTestBase(TestCase):
|
||||
|
||||
# Create some build output (StockItem) objects
|
||||
cls.output_1 = StockItem.objects.create(
|
||||
part=cls.assembly,
|
||||
quantity=3,
|
||||
is_building=True,
|
||||
build=cls.build
|
||||
part=cls.assembly, quantity=3, is_building=True, build=cls.build
|
||||
)
|
||||
|
||||
cls.output_2 = StockItem.objects.create(
|
||||
part=cls.assembly,
|
||||
quantity=7,
|
||||
is_building=True,
|
||||
build=cls.build,
|
||||
part=cls.assembly, quantity=7, is_building=True, build=cls.build
|
||||
)
|
||||
|
||||
# Create some stock items to assign to the build
|
||||
@ -219,12 +195,14 @@ class BuildTestBase(TestCase):
|
||||
|
||||
|
||||
class BuildTest(BuildTestBase):
|
||||
"""Unit testing class for the Build model"""
|
||||
"""Unit testing class for the Build model."""
|
||||
|
||||
def test_ref_int(self):
|
||||
"""Test the "integer reference" field used for natural sorting"""
|
||||
"""Test the "integer reference" field used for natural sorting."""
|
||||
# Set build reference to new value
|
||||
set_global_setting('BUILDORDER_REFERENCE_PATTERN', 'BO-{ref}-???', change_user=None)
|
||||
set_global_setting(
|
||||
'BUILDORDER_REFERENCE_PATTERN', 'BO-{ref}-???', change_user=None
|
||||
)
|
||||
|
||||
refs = {
|
||||
'BO-123-456': 123,
|
||||
@ -236,10 +214,7 @@ class BuildTest(BuildTestBase):
|
||||
|
||||
for ref, ref_int in refs.items():
|
||||
build = Build(
|
||||
reference=ref,
|
||||
quantity=1,
|
||||
part=self.assembly,
|
||||
title='Making some parts',
|
||||
reference=ref, quantity=1, part=self.assembly, title='Making some parts'
|
||||
)
|
||||
|
||||
self.assertEqual(build.reference_int, 0)
|
||||
@ -247,18 +222,17 @@ class BuildTest(BuildTestBase):
|
||||
self.assertEqual(build.reference_int, ref_int)
|
||||
|
||||
# Set build reference back to default value
|
||||
set_global_setting('BUILDORDER_REFERENCE_PATTERN', 'BO-{ref:04d}', change_user=None)
|
||||
set_global_setting(
|
||||
'BUILDORDER_REFERENCE_PATTERN',
|
||||
'BO-{ref:04d}', # noqa: RUF027
|
||||
change_user=None,
|
||||
)
|
||||
|
||||
def test_ref_validation(self):
|
||||
"""Test that the reference field validation works as expected"""
|
||||
"""Test that the reference field validation works as expected."""
|
||||
# Default reference pattern = 'BO-{ref:04d}
|
||||
# These patterns should fail
|
||||
for ref in [
|
||||
'BO-1234x',
|
||||
'BO1234',
|
||||
'OB-1234',
|
||||
'BO--1234'
|
||||
]:
|
||||
for ref in ['BO-1234x', 'BO1234', 'OB-1234', 'BO--1234']:
|
||||
with self.assertRaises(ValidationError):
|
||||
Build.objects.create(
|
||||
part=self.assembly,
|
||||
@ -267,63 +241,53 @@ class BuildTest(BuildTestBase):
|
||||
title='Invalid reference',
|
||||
)
|
||||
|
||||
for ref in [
|
||||
'BO-1234',
|
||||
'BO-9999',
|
||||
'BO-123'
|
||||
]:
|
||||
for ref in ['BO-1234', 'BO-9999', 'BO-123']:
|
||||
Build.objects.create(
|
||||
part=self.assembly,
|
||||
quantity=10,
|
||||
reference=ref,
|
||||
title='Valid reference',
|
||||
part=self.assembly, quantity=10, reference=ref, title='Valid reference'
|
||||
)
|
||||
|
||||
# Try a new validator pattern
|
||||
set_global_setting('BUILDORDER_REFERENCE_PATTERN', '{ref}-BO', change_user=None)
|
||||
set_global_setting('BUILDORDER_REFERENCE_PATTERN', '{ref}-BO', change_user=None) # noqa: RUF027
|
||||
|
||||
for ref in [
|
||||
'1234-BO',
|
||||
'9999-BO'
|
||||
]:
|
||||
for ref in ['1234-BO', '9999-BO']:
|
||||
Build.objects.create(
|
||||
part=self.assembly,
|
||||
quantity=10,
|
||||
reference=ref,
|
||||
title='Valid reference',
|
||||
part=self.assembly, quantity=10, reference=ref, title='Valid reference'
|
||||
)
|
||||
|
||||
# Set build reference back to default value
|
||||
set_global_setting('BUILDORDER_REFERENCE_PATTERN', 'BO-{ref:04d}', change_user=None)
|
||||
set_global_setting(
|
||||
'BUILDORDER_REFERENCE_PATTERN',
|
||||
'BO-{ref:04d}', # noqa: RUF027
|
||||
change_user=None,
|
||||
)
|
||||
|
||||
def test_next_ref(self):
|
||||
"""Test that the next reference is automatically generated"""
|
||||
set_global_setting('BUILDORDER_REFERENCE_PATTERN', 'XYZ-{ref:06d}', change_user=None)
|
||||
"""Test that the next reference is automatically generated."""
|
||||
set_global_setting(
|
||||
'BUILDORDER_REFERENCE_PATTERN', 'XYZ-{ref:06d}', change_user=None
|
||||
)
|
||||
|
||||
build = Build.objects.create(
|
||||
part=self.assembly,
|
||||
quantity=5,
|
||||
reference='XYZ-987',
|
||||
title='Some thing',
|
||||
part=self.assembly, quantity=5, reference='XYZ-987', title='Some thing'
|
||||
)
|
||||
|
||||
self.assertEqual(build.reference_int, 987)
|
||||
|
||||
# Now create one *without* specifying the reference
|
||||
build = Build.objects.create(
|
||||
part=self.assembly,
|
||||
quantity=1,
|
||||
title='Some new title',
|
||||
part=self.assembly, quantity=1, title='Some new title'
|
||||
)
|
||||
|
||||
self.assertEqual(build.reference, 'XYZ-000988')
|
||||
self.assertEqual(build.reference_int, 988)
|
||||
|
||||
# Set build reference back to default value
|
||||
set_global_setting('BUILDORDER_REFERENCE_PATTERN', 'BO-{ref:04d}', change_user=None)
|
||||
set_global_setting(
|
||||
'BUILDORDER_REFERENCE_PATTERN', 'BO-{ref:04d}', change_user=None
|
||||
)
|
||||
|
||||
def test_init(self):
|
||||
"""Perform some basic tests before we start the ball rolling"""
|
||||
"""Perform some basic tests before we start the ball rolling."""
|
||||
self.assertEqual(StockItem.objects.count(), 12)
|
||||
|
||||
# Build is PENDING
|
||||
@ -348,7 +312,7 @@ class BuildTest(BuildTestBase):
|
||||
self.assertFalse(self.build.is_complete)
|
||||
|
||||
def test_build_item_clean(self):
|
||||
"""Ensure that dodgy BuildItem objects cannot be created"""
|
||||
"""Ensure that dodgy BuildItem objects cannot be created."""
|
||||
stock = StockItem.objects.create(part=self.assembly, quantity=99)
|
||||
|
||||
# Create a BuiltItem which points to an invalid StockItem
|
||||
@ -358,7 +322,9 @@ class BuildTest(BuildTestBase):
|
||||
b.save()
|
||||
|
||||
# Create a BuildItem which has too much stock assigned
|
||||
b = BuildItem(stock_item=self.stock_1_1, build_line=self.line_1, quantity=9999999)
|
||||
b = BuildItem(
|
||||
stock_item=self.stock_1_1, build_line=self.line_1, quantity=9999999
|
||||
)
|
||||
|
||||
with self.assertRaises(ValidationError):
|
||||
b.clean()
|
||||
@ -370,19 +336,22 @@ class BuildTest(BuildTestBase):
|
||||
b.clean()
|
||||
|
||||
# Ok, what about we make one that does *not* fail?
|
||||
b = BuildItem(stock_item=self.stock_1_2, build_line=self.line_1, install_into=self.output_1, quantity=10)
|
||||
b = BuildItem(
|
||||
stock_item=self.stock_1_2,
|
||||
build_line=self.line_1,
|
||||
install_into=self.output_1,
|
||||
quantity=10,
|
||||
)
|
||||
b.save()
|
||||
|
||||
def test_duplicate_bom_line(self):
|
||||
"""Try to add a duplicate BOM item - it should be allowed"""
|
||||
"""Try to add a duplicate BOM item - it should be allowed."""
|
||||
BomItem.objects.create(
|
||||
part=self.assembly,
|
||||
sub_part=self.sub_part_1,
|
||||
quantity=99
|
||||
part=self.assembly, sub_part=self.sub_part_1, quantity=99
|
||||
)
|
||||
|
||||
def allocate_stock(self, output, allocations):
|
||||
"""Allocate stock to this build, against a particular output
|
||||
"""Allocate stock to this build, against a particular output.
|
||||
|
||||
Args:
|
||||
output: StockItem object (or None)
|
||||
@ -391,52 +360,36 @@ class BuildTest(BuildTestBase):
|
||||
items_to_create = []
|
||||
|
||||
for item, quantity in allocations.items():
|
||||
|
||||
# Find an appropriate BuildLine to allocate against
|
||||
line = BuildLine.objects.filter(
|
||||
build=self.build,
|
||||
bom_item__sub_part=item.part
|
||||
build=self.build, bom_item__sub_part=item.part
|
||||
).first()
|
||||
|
||||
items_to_create.append(BuildItem(
|
||||
build_line=line,
|
||||
stock_item=item,
|
||||
quantity=quantity,
|
||||
install_into=output
|
||||
))
|
||||
items_to_create.append(
|
||||
BuildItem(
|
||||
build_line=line,
|
||||
stock_item=item,
|
||||
quantity=quantity,
|
||||
install_into=output,
|
||||
)
|
||||
)
|
||||
|
||||
BuildItem.objects.bulk_create(items_to_create)
|
||||
|
||||
def test_partial_allocation(self):
|
||||
"""Test partial allocation of stock"""
|
||||
"""Test partial allocation of stock."""
|
||||
# Fully allocate tracked stock against build output 1
|
||||
self.allocate_stock(
|
||||
self.output_1,
|
||||
{
|
||||
self.stock_3_1: 6,
|
||||
}
|
||||
)
|
||||
self.allocate_stock(self.output_1, {self.stock_3_1: 6})
|
||||
|
||||
self.assertTrue(self.build.is_output_fully_allocated(self.output_1))
|
||||
|
||||
# Partially allocate tracked stock against build output 2
|
||||
self.allocate_stock(
|
||||
self.output_2,
|
||||
{
|
||||
self.stock_3_1: 1,
|
||||
}
|
||||
)
|
||||
self.allocate_stock(self.output_2, {self.stock_3_1: 1})
|
||||
|
||||
self.assertFalse(self.build.is_output_fully_allocated(self.output_2))
|
||||
|
||||
# Partially allocate untracked stock against build
|
||||
self.allocate_stock(
|
||||
None,
|
||||
{
|
||||
self.stock_1_1: 1,
|
||||
self.stock_2_1: 1
|
||||
}
|
||||
)
|
||||
self.allocate_stock(None, {self.stock_1_1: 1, self.stock_2_1: 1})
|
||||
|
||||
self.assertFalse(self.build.is_output_fully_allocated(None))
|
||||
|
||||
@ -445,12 +398,7 @@ class BuildTest(BuildTestBase):
|
||||
|
||||
self.assertEqual(len(unallocated), 3)
|
||||
|
||||
self.allocate_stock(
|
||||
None,
|
||||
{
|
||||
self.stock_1_2: 100,
|
||||
}
|
||||
)
|
||||
self.allocate_stock(None, {self.stock_1_2: 100})
|
||||
|
||||
self.assertFalse(self.build.is_fully_allocated(None))
|
||||
|
||||
@ -470,44 +418,21 @@ class BuildTest(BuildTestBase):
|
||||
self.stock_2_1.save()
|
||||
|
||||
# Now we "fully" allocate the untracked untracked items
|
||||
self.allocate_stock(
|
||||
None,
|
||||
{
|
||||
self.stock_1_2: 50,
|
||||
self.stock_2_1: 50,
|
||||
}
|
||||
)
|
||||
self.allocate_stock(None, {self.stock_1_2: 50, self.stock_2_1: 50})
|
||||
|
||||
self.assertTrue(self.build.is_fully_allocated(tracked=False))
|
||||
|
||||
def test_overallocation_and_trim(self):
|
||||
"""Test overallocation of stock and trim function"""
|
||||
|
||||
"""Test overallocation of stock and trim function."""
|
||||
self.assertEqual(self.build.status, status.BuildStatus.PENDING)
|
||||
self.build.issue_build()
|
||||
self.assertEqual(self.build.status, status.BuildStatus.PRODUCTION)
|
||||
|
||||
# Fully allocate tracked stock (not eligible for trimming)
|
||||
self.allocate_stock(
|
||||
self.output_1,
|
||||
{
|
||||
self.stock_3_1: 6,
|
||||
}
|
||||
)
|
||||
self.allocate_stock(
|
||||
self.output_2,
|
||||
{
|
||||
self.stock_3_1: 14,
|
||||
}
|
||||
)
|
||||
self.allocate_stock(self.output_1, {self.stock_3_1: 6})
|
||||
self.allocate_stock(self.output_2, {self.stock_3_1: 14})
|
||||
# Fully allocate part 1 (should be left alone)
|
||||
self.allocate_stock(
|
||||
None,
|
||||
{
|
||||
self.stock_1_1: 3,
|
||||
self.stock_1_2: 47,
|
||||
}
|
||||
)
|
||||
self.allocate_stock(None, {self.stock_1_1: 3, self.stock_1_2: 47})
|
||||
|
||||
extra_2_1 = StockItem.objects.create(part=self.sub_part_2, quantity=6)
|
||||
extra_2_2 = StockItem.objects.create(part=self.sub_part_2, quantity=4)
|
||||
@ -521,9 +446,9 @@ class BuildTest(BuildTestBase):
|
||||
self.stock_2_3: 5,
|
||||
self.stock_2_4: 5,
|
||||
self.stock_2_5: 5, # 25
|
||||
extra_2_1: 6, # 31
|
||||
extra_2_2: 4, # 35
|
||||
}
|
||||
extra_2_1: 6, # 31
|
||||
extra_2_2: 4, # 35
|
||||
},
|
||||
)
|
||||
|
||||
self.assertTrue(self.build.is_overallocated())
|
||||
@ -550,19 +475,28 @@ class BuildTest(BuildTestBase):
|
||||
self.assertEqual(items.aggregate(Sum('quantity'))['quantity__sum'], 35)
|
||||
|
||||
# However, the "available" stock quantity has been decreased
|
||||
self.assertEqual(items.filter(consumed_by=None).aggregate(Sum('quantity'))['quantity__sum'], 5)
|
||||
self.assertEqual(
|
||||
items.filter(consumed_by=None).aggregate(Sum('quantity'))['quantity__sum'],
|
||||
5,
|
||||
)
|
||||
|
||||
# And the "consumed_by" quantity has been increased
|
||||
self.assertEqual(items.filter(consumed_by=self.build).aggregate(Sum('quantity'))['quantity__sum'], 30)
|
||||
self.assertEqual(
|
||||
items.filter(consumed_by=self.build).aggregate(Sum('quantity'))[
|
||||
'quantity__sum'
|
||||
],
|
||||
30,
|
||||
)
|
||||
|
||||
self.assertEqual(StockItem.objects.get(pk=self.stock_3_1.pk).quantity, 980)
|
||||
|
||||
# Check that the "consumed_by" item count has increased
|
||||
self.assertEqual(StockItem.objects.filter(consumed_by=self.build).count(), n + 8)
|
||||
self.assertEqual(
|
||||
StockItem.objects.filter(consumed_by=self.build).count(), n + 8
|
||||
)
|
||||
|
||||
def test_change_part(self):
|
||||
"""Try to change target part after creating a build"""
|
||||
|
||||
"""Try to change target part after creating a build."""
|
||||
bo = Build.objects.create(
|
||||
reference='BO-9999',
|
||||
title='Some new build',
|
||||
@ -572,9 +506,7 @@ class BuildTest(BuildTestBase):
|
||||
)
|
||||
|
||||
assembly_2 = Part.objects.create(
|
||||
name="Another assembly",
|
||||
description="A different assembly",
|
||||
assembly=True,
|
||||
name='Another assembly', description='A different assembly', assembly=True
|
||||
)
|
||||
|
||||
# Should not be able to change the part after the Build is saved
|
||||
@ -583,7 +515,7 @@ class BuildTest(BuildTestBase):
|
||||
bo.clean()
|
||||
|
||||
def test_cancel(self):
|
||||
"""Test cancellation of the build"""
|
||||
"""Test cancellation of the build."""
|
||||
# TODO
|
||||
"""
|
||||
self.allocate_stock(50, 50, 200, self.output_1)
|
||||
@ -591,10 +523,9 @@ class BuildTest(BuildTestBase):
|
||||
|
||||
self.assertEqual(BuildItem.objects.count(), 0)
|
||||
"""
|
||||
pass
|
||||
|
||||
def test_complete(self):
|
||||
"""Test completion of a build output"""
|
||||
"""Test completion of a build output."""
|
||||
self.stock_1_1.quantity = 1000
|
||||
self.stock_1_1.save()
|
||||
|
||||
@ -609,25 +540,15 @@ class BuildTest(BuildTestBase):
|
||||
{
|
||||
self.stock_1_1: self.stock_1_1.quantity, # Allocate *all* stock from this item
|
||||
self.stock_1_2: 10,
|
||||
self.stock_2_1: 30
|
||||
}
|
||||
self.stock_2_1: 30,
|
||||
},
|
||||
)
|
||||
|
||||
# Allocate tracked parts to output_1
|
||||
self.allocate_stock(
|
||||
self.output_1,
|
||||
{
|
||||
self.stock_3_1: 6
|
||||
}
|
||||
)
|
||||
self.allocate_stock(self.output_1, {self.stock_3_1: 6})
|
||||
|
||||
# Allocate tracked parts to output_2
|
||||
self.allocate_stock(
|
||||
self.output_2,
|
||||
{
|
||||
self.stock_3_1: 14
|
||||
}
|
||||
)
|
||||
self.allocate_stock(self.output_2, {self.stock_3_1: 14})
|
||||
|
||||
self.assertTrue(self.build.is_fully_allocated(None))
|
||||
self.assertTrue(self.build.is_fully_allocated(self.output_1))
|
||||
@ -665,25 +586,32 @@ class BuildTest(BuildTestBase):
|
||||
self.assertFalse(output.is_building)
|
||||
|
||||
def test_complete_with_required_tests(self):
|
||||
"""Test the prevention completion when a required test is missing feature"""
|
||||
|
||||
"""Test the prevention completion when a required test is missing feature."""
|
||||
# with required tests incompleted the save should fail
|
||||
set_global_setting('PREVENT_BUILD_COMPLETION_HAVING_INCOMPLETED_TESTS', True, change_user=None)
|
||||
set_global_setting(
|
||||
'PREVENT_BUILD_COMPLETION_HAVING_INCOMPLETED_TESTS', True, change_user=None
|
||||
)
|
||||
|
||||
with self.assertRaises(ValidationError):
|
||||
self.build_w_tests_trackable.complete_build_output(self.stockitem_with_required_test, None)
|
||||
self.build_w_tests_trackable.complete_build_output(
|
||||
self.stockitem_with_required_test, None
|
||||
)
|
||||
|
||||
# let's complete the required test and see if it could be saved
|
||||
StockItemTestResult.objects.create(
|
||||
stock_item=self.stockitem_with_required_test,
|
||||
template=self.test_template_required,
|
||||
result=True
|
||||
result=True,
|
||||
)
|
||||
|
||||
self.build_w_tests_trackable.complete_build_output(self.stockitem_with_required_test, None)
|
||||
self.build_w_tests_trackable.complete_build_output(
|
||||
self.stockitem_with_required_test, None
|
||||
)
|
||||
|
||||
# let's see if a non required test could be saved
|
||||
self.build_wo_tests_trackable.complete_build_output(self.stockitem_wo_required_test, None)
|
||||
self.build_wo_tests_trackable.complete_build_output(
|
||||
self.stockitem_wo_required_test, None
|
||||
)
|
||||
|
||||
def test_overdue_notification(self):
|
||||
"""Test sending of notifications when a build order is overdue."""
|
||||
@ -694,26 +622,25 @@ class BuildTest(BuildTestBase):
|
||||
build.tasks.check_overdue_build_orders()
|
||||
|
||||
message = common.models.NotificationMessage.objects.get(
|
||||
category='build.overdue_build_order',
|
||||
user__id=1,
|
||||
category='build.overdue_build_order', user__id=1
|
||||
)
|
||||
|
||||
self.assertEqual(message.name, 'Overdue Build Order')
|
||||
|
||||
def test_new_build_notification(self):
|
||||
"""Test that a notification is sent when a new build is created"""
|
||||
"""Test that a notification is sent when a new build is created."""
|
||||
Build.objects.create(
|
||||
reference='BO-9999',
|
||||
title='Some new build',
|
||||
part=self.assembly,
|
||||
quantity=5,
|
||||
issued_by=get_user_model().objects.get(pk=2),
|
||||
responsible=Owner.create(obj=Group.objects.get(pk=3))
|
||||
responsible=Owner.create(obj=Group.objects.get(pk=3)),
|
||||
)
|
||||
|
||||
# Two notifications should have been sent
|
||||
messages = common.models.NotificationMessage.objects.filter(
|
||||
category='build.new_build',
|
||||
category='build.new_build'
|
||||
)
|
||||
|
||||
self.assertEqual(messages.count(), 1)
|
||||
@ -728,12 +655,12 @@ class BuildTest(BuildTestBase):
|
||||
@override_settings(
|
||||
TESTING_TABLE_EVENTS=True,
|
||||
PLUGIN_TESTING_EVENTS=True,
|
||||
PLUGIN_TESTING_EVENTS_ASYNC=True
|
||||
PLUGIN_TESTING_EVENTS_ASYNC=True,
|
||||
)
|
||||
def test_events(self):
|
||||
"""Test that build events are triggered correctly."""
|
||||
|
||||
from django_q.models import OrmQ
|
||||
|
||||
from build.events import BuildEvents
|
||||
|
||||
set_global_setting('ENABLE_PLUGINS_EVENTS', True)
|
||||
@ -747,7 +674,7 @@ class BuildTest(BuildTestBase):
|
||||
part=self.assembly,
|
||||
quantity=5,
|
||||
issued_by=get_user_model().objects.get(pk=2),
|
||||
responsible=Owner.create(obj=Group.objects.get(pk=3))
|
||||
responsible=Owner.create(obj=Group.objects.get(pk=3)),
|
||||
)
|
||||
|
||||
# Check that the 'build.created' event was triggered
|
||||
@ -769,9 +696,7 @@ class BuildTest(BuildTestBase):
|
||||
|
||||
# Check that the 'build.issued' event was triggered
|
||||
task = findOffloadedEvent(
|
||||
BuildEvents.ISSUED,
|
||||
matching_kwargs=['id'],
|
||||
clear_after=True,
|
||||
BuildEvents.ISSUED, matching_kwargs=['id'], clear_after=True
|
||||
)
|
||||
|
||||
self.assertIsNotNone(task)
|
||||
@ -781,7 +706,12 @@ class BuildTest(BuildTestBase):
|
||||
def test_metadata(self):
|
||||
"""Unit tests for the metadata field."""
|
||||
# Make sure a BuildItem exists before trying to run this test
|
||||
b = BuildItem(stock_item=self.stock_1_2, build_line=self.line_1, install_into=self.output_1, quantity=10)
|
||||
b = BuildItem(
|
||||
stock_item=self.stock_1_2,
|
||||
build_line=self.line_1,
|
||||
install_into=self.output_1,
|
||||
quantity=10,
|
||||
)
|
||||
b.save()
|
||||
|
||||
for model in [Build, BuildItem]:
|
||||
@ -802,28 +732,20 @@ class BuildTest(BuildTestBase):
|
||||
|
||||
|
||||
class AutoAllocationTests(BuildTestBase):
|
||||
"""Tests for auto allocating stock against a build order"""
|
||||
"""Tests for auto allocating stock against a build order."""
|
||||
|
||||
def setUp(self):
|
||||
"""Init routines for this unit test class"""
|
||||
"""Init routines for this unit test class."""
|
||||
super().setUp()
|
||||
|
||||
# Add a "substitute" part for bom_item_2
|
||||
alt_part = Part.objects.create(
|
||||
name="alt part",
|
||||
description="An alternative part!",
|
||||
component=True,
|
||||
name='alt part', description='An alternative part!', component=True
|
||||
)
|
||||
|
||||
BomItemSubstitute.objects.create(
|
||||
bom_item=self.bom_item_2,
|
||||
part=alt_part,
|
||||
)
|
||||
BomItemSubstitute.objects.create(bom_item=self.bom_item_2, part=alt_part)
|
||||
|
||||
StockItem.objects.create(
|
||||
part=alt_part,
|
||||
quantity=500,
|
||||
)
|
||||
StockItem.objects.create(part=alt_part, quantity=500)
|
||||
|
||||
def test_auto_allocate(self):
|
||||
"""Run the 'auto-allocate' function. What do we expect to happen?
|
||||
@ -840,10 +762,7 @@ class AutoAllocationTests(BuildTestBase):
|
||||
self.assertFalse(self.build.is_fully_allocated(tracked=False))
|
||||
|
||||
# Stock is not interchangeable, nothing will happen
|
||||
self.build.auto_allocate_stock(
|
||||
interchangeable=False,
|
||||
substitutes=False,
|
||||
)
|
||||
self.build.auto_allocate_stock(interchangeable=False, substitutes=False)
|
||||
|
||||
self.assertFalse(self.build.is_fully_allocated(tracked=False))
|
||||
|
||||
@ -857,9 +776,7 @@ class AutoAllocationTests(BuildTestBase):
|
||||
|
||||
# This time we expect stock to be allocated!
|
||||
self.build.auto_allocate_stock(
|
||||
interchangeable=True,
|
||||
substitutes=False,
|
||||
optional_items=True,
|
||||
interchangeable=True, substitutes=False, optional_items=True
|
||||
)
|
||||
|
||||
self.assertFalse(self.build.is_fully_allocated(tracked=False))
|
||||
@ -873,10 +790,7 @@ class AutoAllocationTests(BuildTestBase):
|
||||
self.assertEqual(self.line_2.unallocated_quantity(), 5)
|
||||
|
||||
# This time, allow substitute parts to be used!
|
||||
self.build.auto_allocate_stock(
|
||||
interchangeable=True,
|
||||
substitutes=True,
|
||||
)
|
||||
self.build.auto_allocate_stock(interchangeable=True, substitutes=True)
|
||||
|
||||
self.assertEqual(self.line_1.unallocated_quantity(), 0)
|
||||
self.assertEqual(self.line_2.unallocated_quantity(), 5)
|
||||
@ -885,11 +799,9 @@ class AutoAllocationTests(BuildTestBase):
|
||||
self.assertFalse(self.line_2.is_fully_allocated())
|
||||
|
||||
def test_fully_auto(self):
|
||||
"""We should be able to auto-allocate against a build in a single go"""
|
||||
"""We should be able to auto-allocate against a build in a single go."""
|
||||
self.build.auto_allocate_stock(
|
||||
interchangeable=True,
|
||||
substitutes=True,
|
||||
optional_items=True,
|
||||
interchangeable=True, substitutes=True, optional_items=True
|
||||
)
|
||||
|
||||
self.assertTrue(self.build.is_fully_allocated(tracked=False))
|
||||
|
@ -16,21 +16,17 @@ class TestForwardMigrations(MigratorTestCase):
|
||||
Part = self.old_state.apps.get_model('part', 'part')
|
||||
|
||||
buildable_part = Part.objects.create(
|
||||
name='Widget',
|
||||
description='Buildable Part',
|
||||
active=True,
|
||||
name='Widget', description='Buildable Part', active=True
|
||||
)
|
||||
|
||||
Build = self.old_state.apps.get_model('build', 'build')
|
||||
|
||||
Build.objects.create(
|
||||
part=buildable_part,
|
||||
title='A build of some stuff',
|
||||
quantity=50,
|
||||
part=buildable_part, title='A build of some stuff', quantity=50
|
||||
)
|
||||
|
||||
def test_items_exist(self):
|
||||
"""Test to ensure that the 'assembly' field is correctly configured"""
|
||||
"""Test to ensure that the 'assembly' field is correctly configured."""
|
||||
Part = self.new_state.apps.get_model('part', 'part')
|
||||
|
||||
self.assertEqual(Part.objects.count(), 1)
|
||||
@ -57,30 +53,15 @@ class TestReferenceMigration(MigratorTestCase):
|
||||
"""Create some builds."""
|
||||
Part = self.old_state.apps.get_model('part', 'part')
|
||||
|
||||
part = Part.objects.create(
|
||||
name='Part',
|
||||
description='A test part',
|
||||
)
|
||||
part = Part.objects.create(name='Part', description='A test part')
|
||||
|
||||
Build = self.old_state.apps.get_model('build', 'build')
|
||||
|
||||
Build.objects.create(
|
||||
part=part,
|
||||
title='My very first build',
|
||||
quantity=10
|
||||
)
|
||||
Build.objects.create(part=part, title='My very first build', quantity=10)
|
||||
|
||||
Build.objects.create(
|
||||
part=part,
|
||||
title='My very second build',
|
||||
quantity=10
|
||||
)
|
||||
Build.objects.create(part=part, title='My very second build', quantity=10)
|
||||
|
||||
Build.objects.create(
|
||||
part=part,
|
||||
title='My very third build',
|
||||
quantity=10
|
||||
)
|
||||
Build.objects.create(part=part, title='My very third build', quantity=10)
|
||||
|
||||
# Ensure that the builds *do not* have a 'reference' field
|
||||
for build in Build.objects.all():
|
||||
@ -88,7 +69,7 @@ class TestReferenceMigration(MigratorTestCase):
|
||||
print(build.reference)
|
||||
|
||||
def test_build_reference(self):
|
||||
"""Test that the build reference is correctly assigned to the PK of the Build"""
|
||||
"""Test that the build reference is correctly assigned to the PK of the Build."""
|
||||
Build = self.new_state.apps.get_model('build', 'build')
|
||||
|
||||
self.assertEqual(Build.objects.count(), 3)
|
||||
@ -108,21 +89,16 @@ class TestReferencePatternMigration(MigratorTestCase):
|
||||
migrate_to = ('build', unit_test.getNewestMigrationFile('build'))
|
||||
|
||||
def prepare(self):
|
||||
"""Create some initial data prior to migration"""
|
||||
"""Create some initial data prior to migration."""
|
||||
Setting = self.old_state.apps.get_model('common', 'inventreesetting')
|
||||
|
||||
# Create a custom existing prefix so we can confirm the operation is working
|
||||
Setting.objects.create(
|
||||
key='BUILDORDER_REFERENCE_PREFIX',
|
||||
value='BuildOrder-',
|
||||
)
|
||||
Setting.objects.create(key='BUILDORDER_REFERENCE_PREFIX', value='BuildOrder-')
|
||||
|
||||
Part = self.old_state.apps.get_model('part', 'part')
|
||||
|
||||
assembly = Part.objects.create(
|
||||
name='Assy 1',
|
||||
description='An assembly',
|
||||
level=0, lft=0, rght=0, tree_id=0,
|
||||
name='Assy 1', description='An assembly', level=0, lft=0, rght=0, tree_id=0
|
||||
)
|
||||
|
||||
Build = self.old_state.apps.get_model('build', 'build')
|
||||
@ -130,14 +106,17 @@ class TestReferencePatternMigration(MigratorTestCase):
|
||||
for idx in range(1, 11):
|
||||
Build.objects.create(
|
||||
part=assembly,
|
||||
title=f"Build {idx}",
|
||||
title=f'Build {idx}',
|
||||
quantity=idx,
|
||||
reference=f"{idx + 100}",
|
||||
level=0, lft=0, rght=0, tree_id=0,
|
||||
reference=f'{idx + 100}',
|
||||
level=0,
|
||||
lft=0,
|
||||
rght=0,
|
||||
tree_id=0,
|
||||
)
|
||||
|
||||
def test_reference_migration(self):
|
||||
"""Test that the reference fields have been correctly updated"""
|
||||
"""Test that the reference fields have been correctly updated."""
|
||||
Build = self.new_state.apps.get_model('build', 'build')
|
||||
|
||||
for build in Build.objects.all():
|
||||
@ -165,7 +144,7 @@ class TestBuildLineCreation(MigratorTestCase):
|
||||
migrate_to = ('build', '0047_auto_20230606_1058')
|
||||
|
||||
def prepare(self):
|
||||
"""Create data to work with"""
|
||||
"""Create data to work with."""
|
||||
# Model references
|
||||
Part = self.old_state.apps.get_model('part', 'part')
|
||||
BomItem = self.old_state.apps.get_model('part', 'bomitem')
|
||||
@ -182,40 +161,44 @@ class TestBuildLineCreation(MigratorTestCase):
|
||||
name='Assembly',
|
||||
description='An assembly',
|
||||
assembly=True,
|
||||
level=0, lft=0, rght=0, tree_id=0,
|
||||
level=0,
|
||||
lft=0,
|
||||
rght=0,
|
||||
tree_id=0,
|
||||
)
|
||||
|
||||
# Create components
|
||||
for idx in range(1, 11):
|
||||
part = Part.objects.create(
|
||||
name=f"Part {idx}",
|
||||
description=f"Part {idx}",
|
||||
level=0, lft=0, rght=0, tree_id=0,
|
||||
name=f'Part {idx}',
|
||||
description=f'Part {idx}',
|
||||
level=0,
|
||||
lft=0,
|
||||
rght=0,
|
||||
tree_id=0,
|
||||
)
|
||||
|
||||
# Create plentiful stock
|
||||
StockItem.objects.create(
|
||||
part=part,
|
||||
quantity=1000,
|
||||
level=0, lft=0, rght=0, tree_id=0,
|
||||
part=part, quantity=1000, level=0, lft=0, rght=0, tree_id=0
|
||||
)
|
||||
|
||||
# Create a BOM item
|
||||
BomItem.objects.create(
|
||||
part=assembly,
|
||||
sub_part=part,
|
||||
quantity=idx,
|
||||
reference=f"REF-{idx}",
|
||||
part=assembly, sub_part=part, quantity=idx, reference=f'REF-{idx}'
|
||||
)
|
||||
|
||||
# Create some builds
|
||||
for idx in range(1, 4):
|
||||
build = Build.objects.create(
|
||||
part=assembly,
|
||||
title=f"Build {idx}",
|
||||
title=f'Build {idx}',
|
||||
quantity=idx * 10,
|
||||
reference=f"REF-{idx}",
|
||||
level=0, lft=0, rght=0, tree_id=0,
|
||||
reference=f'REF-{idx}',
|
||||
level=0,
|
||||
lft=0,
|
||||
rght=0,
|
||||
tree_id=0,
|
||||
)
|
||||
|
||||
# Allocate stock to the build
|
||||
@ -229,7 +212,7 @@ class TestBuildLineCreation(MigratorTestCase):
|
||||
)
|
||||
|
||||
def test_build_line_creation(self):
|
||||
"""Test that the BuildLine objects have been created correctly"""
|
||||
"""Test that the BuildLine objects have been created correctly."""
|
||||
Build = self.new_state.apps.get_model('build', 'build')
|
||||
BomItem = self.new_state.apps.get_model('part', 'bomitem')
|
||||
BuildLine = self.new_state.apps.get_model('build', 'buildline')
|
||||
@ -254,10 +237,7 @@ class TestBuildLineCreation(MigratorTestCase):
|
||||
# Check that each BuildItem has been linked to a BuildLine
|
||||
for item in BuildItem.objects.all():
|
||||
self.assertIsNotNone(item.build_line)
|
||||
self.assertEqual(
|
||||
item.stock_item.part,
|
||||
item.build_line.bom_item.sub_part,
|
||||
)
|
||||
self.assertEqual(item.stock_item.part, item.build_line.bom_item.sub_part)
|
||||
|
||||
item = BuildItem.objects.first()
|
||||
|
||||
@ -273,12 +253,8 @@ class TestBuildLineCreation(MigratorTestCase):
|
||||
for line in BuildLine.objects.all():
|
||||
# Check that the quantity is correct
|
||||
self.assertEqual(
|
||||
line.quantity,
|
||||
line.build.quantity * line.bom_item.quantity,
|
||||
line.quantity, line.build.quantity * line.bom_item.quantity
|
||||
)
|
||||
|
||||
# Check that the linked parts are correct
|
||||
self.assertEqual(
|
||||
line.build.part,
|
||||
line.bom_item.part,
|
||||
)
|
||||
self.assertEqual(line.build.part, line.bom_item.part)
|
||||
|
@ -1,39 +1,26 @@
|
||||
"""Basic unit tests for the BuildOrder app"""
|
||||
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.test import tag
|
||||
from django.urls import reverse
|
||||
"""Basic unit tests for the BuildOrder app."""
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
from build.status_codes import BuildStatus
|
||||
from common.settings import set_global_setting
|
||||
from InvenTree.unit_test import InvenTreeTestCase
|
||||
from part.models import BomItem, Part
|
||||
|
||||
from .models import Build
|
||||
from part.models import Part, BomItem
|
||||
from stock.models import StockItem
|
||||
|
||||
from common.settings import set_global_setting
|
||||
from build.status_codes import BuildStatus
|
||||
|
||||
|
||||
class BuildTestSimple(InvenTreeTestCase):
|
||||
"""Basic set of tests for the BuildOrder model functionality"""
|
||||
"""Basic set of tests for the BuildOrder model functionality."""
|
||||
|
||||
fixtures = [
|
||||
'category',
|
||||
'part',
|
||||
'location',
|
||||
'build',
|
||||
]
|
||||
fixtures = ['category', 'part', 'location', 'build']
|
||||
|
||||
roles = [
|
||||
'build.change',
|
||||
'build.add',
|
||||
'build.delete',
|
||||
]
|
||||
roles = ['build.change', 'build.add', 'build.delete']
|
||||
|
||||
def test_build_objects(self):
|
||||
"""Ensure the Build objects were correctly created"""
|
||||
"""Ensure the Build objects were correctly created."""
|
||||
self.assertEqual(Build.objects.count(), 5)
|
||||
b = Build.objects.get(pk=2)
|
||||
self.assertEqual(b.batch, 'B2')
|
||||
@ -42,12 +29,12 @@ class BuildTestSimple(InvenTreeTestCase):
|
||||
self.assertEqual(str(b), 'BO-0002')
|
||||
|
||||
def test_url(self):
|
||||
"""Test URL lookup"""
|
||||
"""Test URL lookup."""
|
||||
b1 = Build.objects.get(pk=1)
|
||||
self.assertEqual(b1.get_absolute_url(), '/platform/manufacturing/build-order/1')
|
||||
|
||||
def test_is_complete(self):
|
||||
"""Test build completion status"""
|
||||
"""Test build completion status."""
|
||||
b1 = Build.objects.get(pk=1)
|
||||
b2 = Build.objects.get(pk=2)
|
||||
|
||||
@ -72,7 +59,7 @@ class BuildTestSimple(InvenTreeTestCase):
|
||||
self.assertFalse(build.is_overdue)
|
||||
|
||||
def test_is_active(self):
|
||||
"""Test active / inactive build status"""
|
||||
"""Test active / inactive build status."""
|
||||
b1 = Build.objects.get(pk=1)
|
||||
b2 = Build.objects.get(pk=2)
|
||||
|
||||
@ -91,7 +78,6 @@ class BuildTestSimple(InvenTreeTestCase):
|
||||
|
||||
def test_build_create(self):
|
||||
"""Test creation of build orders via API."""
|
||||
|
||||
n = Build.objects.count()
|
||||
|
||||
# Find an assembly part
|
||||
@ -105,13 +91,9 @@ class BuildTestSimple(InvenTreeTestCase):
|
||||
|
||||
# Let's create some BOM items for this assembly
|
||||
for component in Part.objects.filter(assembly=False, component=True)[:15]:
|
||||
|
||||
try:
|
||||
BomItem.objects.create(
|
||||
part=assembly,
|
||||
sub_part=component,
|
||||
reference='xxx',
|
||||
quantity=5
|
||||
part=assembly, sub_part=component, reference='xxx', quantity=5
|
||||
)
|
||||
except ValidationError:
|
||||
pass
|
||||
|
@ -1,15 +1,15 @@
|
||||
"""Validation methods for the build app"""
|
||||
"""Validation methods for the build app."""
|
||||
|
||||
|
||||
def generate_next_build_reference():
|
||||
"""Generate the next available BuildOrder reference"""
|
||||
"""Generate the next available BuildOrder reference."""
|
||||
from build.models import Build
|
||||
|
||||
return Build.generate_reference()
|
||||
|
||||
|
||||
def validate_build_order_reference_pattern(pattern):
|
||||
"""Validate the BuildOrder reference 'pattern' setting"""
|
||||
"""Validate the BuildOrder reference 'pattern' setting."""
|
||||
from build.models import Build
|
||||
|
||||
Build.validate_reference_pattern(pattern)
|
||||
|
Loading…
x
Reference in New Issue
Block a user