mirror of
				https://github.com/inventree/InvenTree.git
				synced 2025-11-04 07:05:41 +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:
		@@ -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)
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user