2
0
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:
Matthias Mair 2024-12-24 21:16:24 +01:00 committed by GitHub
parent 1fec41cb71
commit fe68dc7318
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 1375 additions and 1675 deletions

View File

@ -3,8 +3,6 @@
exclude = [
".git",
"__pycache__",
"dist",
"build",
"test.py",
"tests",
"venv",

View File

@ -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

View File

@ -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']

View File

@ -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'),
]

View File

@ -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'

View File

@ -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

View File

@ -24,6 +24,4 @@ class BuildStatusGroups:
BuildStatus.PRODUCTION.value,
]
COMPLETE = [
BuildStatus.COMPLETE.value,
]
COMPLETE = [BuildStatus.COMPLETE.value]

View File

@ -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

View File

@ -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))

View File

@ -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)

View File

@ -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

View File

@ -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)