Merge branch 'master' of https://github.com/inventree/InvenTree into price-history

This commit is contained in:
Matthias 2021-04-21 11:12:48 +02:00
commit 07d68f7fde
89 changed files with 47808 additions and 2850 deletions

View File

@ -29,6 +29,7 @@ jobs:
- name: Install Dependencies - name: Install Dependencies
run: | run: |
sudo apt-get update sudo apt-get update
sudo apt-get install gettext
pip3 install invoke pip3 install invoke
invoke install invoke install
- name: Coverage Tests - name: Coverage Tests
@ -42,6 +43,8 @@ jobs:
rm test_db.sqlite rm test_db.sqlite
invoke migrate invoke migrate
invoke import-records -f data.json invoke import-records -f data.json
- name: Test Translations
run: invoke translate
- name: Check Migration Files - name: Check Migration Files
run: python3 ci/check_migration_files.py run: python3 ci/check_migration_files.py
- name: Upload Coverage Report - name: Upload Coverage Report

View File

@ -1,8 +1,11 @@
# Test that the docker file builds correctly # Build and push latest docker image on push to master branch
name: Docker name: Docker Build
on: ["push", "pull_request"] on:
push:
branches:
- 'master'
jobs: jobs:
@ -10,7 +13,19 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v2 - name: Checkout Code
- name: Build Docker Image uses: actions/checkout@v2
run: cd docker && docker build . --tag inventree:$(date +%s) - name: Login to Dockerhub
uses: docker/login-action@v1
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Build and Push
uses: docker/build-push-action@v2
with:
context: ./docker
push: true
repository: inventree/inventree
tags: inventree/inventree:latest
- name: Image Digest
run: echo ${{ steps.docker_build.outputs.digest }}

1
.gitignore vendored
View File

@ -40,6 +40,7 @@ docs/_build
# Local static and media file storage (only when running in development mode) # Local static and media file storage (only when running in development mode)
inventree_media inventree_media
inventree_static inventree_static
static_i18n
# Local config file # Local config file
config.yaml config.yaml

View File

@ -0,0 +1,61 @@
"""
Custom management command to prerender files
"""
from django.core.management.base import BaseCommand
from django.conf import settings
from django.template.loader import render_to_string
from django.utils.module_loading import import_string
from django.http.request import HttpRequest
from django.utils.translation import override as lang_over
import os
def render_file(file_name, source, target, locales, ctx):
""" renders a file into all provided locales """
for locale in locales:
target_file = os.path.join(target, locale + '.' + file_name)
with open(target_file, 'w') as localised_file:
with lang_over(locale):
renderd = render_to_string(os.path.join(source, file_name), ctx)
localised_file.write(renderd)
class Command(BaseCommand):
"""
django command to prerender files
"""
def handle(self, *args, **kwargs):
# static directories
LC_DIR = settings.LOCALE_PATHS[0]
SOURCE_DIR = settings.STATICFILES_I18_SRC
TARGET_DIR = settings.STATICFILES_I18_TRG
# ensure static directory exists
if not os.path.exists(TARGET_DIR):
os.makedirs(TARGET_DIR, exist_ok=True)
# collect locales
locales = {}
for locale in os.listdir(LC_DIR):
path = os.path.join(LC_DIR, locale)
if os.path.exists(path) and os.path.isdir(path):
locales[locale] = locale
# render!
request = HttpRequest()
ctx = {}
processors = tuple(import_string(path) for path in settings.STATFILES_I18_PROCESSORS)
for processor in processors:
ctx.update(processor(request))
for file in os.listdir(SOURCE_DIR, ):
path = os.path.join(SOURCE_DIR, file)
if os.path.exists(path) and os.path.isfile(path):
print(f"render {file}")
render_file(file, SOURCE_DIR, TARGET_DIR, locales, ctx)
else:
raise NotImplementedError('Using multi-level directories is not implemented at this point') # TODO multilevel dir if needed
print(f"rendered all files in {SOURCE_DIR}")

View File

@ -52,6 +52,9 @@ def get_setting(environment_var, backup_val, default_value=None):
# Determine if we are running in "test" mode e.g. "manage.py test" # Determine if we are running in "test" mode e.g. "manage.py test"
TESTING = 'test' in sys.argv TESTING = 'test' in sys.argv
# New requirement for django 3.2+
DEFAULT_AUTO_FIELD = 'django.db.models.AutoField'
# Build paths inside the project like this: os.path.join(BASE_DIR, ...) # Build paths inside the project like this: os.path.join(BASE_DIR, ...)
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
@ -190,6 +193,17 @@ STATICFILES_DIRS = [
os.path.join(BASE_DIR, 'InvenTree', 'static'), os.path.join(BASE_DIR, 'InvenTree', 'static'),
] ]
# Translated Template settings
STATICFILES_I18_PREFIX = 'i18n'
STATICFILES_I18_SRC = os.path.join(BASE_DIR, 'templates', 'js')
STATICFILES_I18_TRG = STATICFILES_DIRS[0] + '_' + STATICFILES_I18_PREFIX
STATICFILES_DIRS.append(STATICFILES_I18_TRG)
STATICFILES_I18_TRG = os.path.join(STATICFILES_I18_TRG, STATICFILES_I18_PREFIX)
STATFILES_I18_PROCESSORS = [
'InvenTree.context.status_codes',
]
# Color Themes Directory # Color Themes Directory
STATIC_COLOR_THEMES_DIR = os.path.join(STATIC_ROOT, 'css', 'color-themes') STATIC_COLOR_THEMES_DIR = os.path.join(STATIC_ROOT, 'css', 'color-themes')

View File

@ -56,17 +56,26 @@ def is_email_configured():
configured = True configured = True
if not settings.EMAIL_HOST: if not settings.EMAIL_HOST:
logger.warning("EMAIL_HOST is not configured")
configured = False configured = False
# Display warning unless in test mode
if not settings.TESTING:
logger.warning("EMAIL_HOST is not configured")
if not settings.EMAIL_HOST_USER: if not settings.EMAIL_HOST_USER:
logger.warning("EMAIL_HOST_USER is not configured")
configured = False configured = False
# Display warning unless in test mode
if not settings.TESTING:
logger.warning("EMAIL_HOST_USER is not configured")
if not settings.EMAIL_HOST_PASSWORD: if not settings.EMAIL_HOST_PASSWORD:
logger.warning("EMAIL_HOST_PASSWORD is not configured")
configured = False configured = False
# Display warning unless in test mode
if not settings.TESTING:
logger.warning("EMAIL_HOST_PASSWORD is not configured")
return configured return configured

View File

@ -62,6 +62,14 @@ class StatusCode:
def items(cls): def items(cls):
return cls.options.items() return cls.options.items()
@classmethod
def keys(cls):
return cls.options.keys()
@classmethod
def labels(cls):
return cls.options.values()
@classmethod @classmethod
def label(cls, value): def label(cls, value):
""" Return the status code label associated with the provided value """ """ Return the status code label associated with the provided value """

View File

@ -19,6 +19,14 @@ def inventreeInstanceName():
return common.models.InvenTreeSetting.get_setting("INVENTREE_INSTANCE", "") return common.models.InvenTreeSetting.get_setting("INVENTREE_INSTANCE", "")
def inventreeInstanceTitle():
""" Returns the InstanceTitle for the current database """
if common.models.InvenTreeSetting.get_setting("INVENTREE_INSTANCE_TITLE", False):
return common.models.InvenTreeSetting.get_setting("INVENTREE_INSTANCE", "")
else:
return 'InvenTree'
def inventreeVersion(): def inventreeVersion():
""" Returns the InvenTree version string """ """ Returns the InvenTree version string """
return INVENTREE_SW_VERSION return INVENTREE_SW_VERSION

View File

@ -11,7 +11,7 @@ from rest_framework import generics
from django.conf.urls import url, include from django.conf.urls import url, include
from InvenTree.helpers import str2bool from InvenTree.helpers import str2bool, isNull
from InvenTree.status_codes import BuildStatus from InvenTree.status_codes import BuildStatus
from .models import Build, BuildItem from .models import Build, BuildItem
@ -194,7 +194,11 @@ class BuildItemList(generics.ListCreateAPIView):
output = params.get('output', None) output = params.get('output', None)
if output: if output:
queryset = queryset.filter(install_into=output)
if isNull(output):
queryset = queryset.filter(install_into=None)
else:
queryset = queryset.filter(install_into=output)
return queryset return queryset

View File

@ -3,6 +3,7 @@ Django Forms for interacting with Build objects
""" """
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from __future__ import unicode_literals from __future__ import unicode_literals
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
@ -12,6 +13,8 @@ from InvenTree.forms import HelperForm
from InvenTree.fields import RoundingDecimalFormField from InvenTree.fields import RoundingDecimalFormField
from InvenTree.fields import DatePickerFormField from InvenTree.fields import DatePickerFormField
from InvenTree.status_codes import StockStatus
from .models import Build, BuildItem, BuildOrderAttachment from .models import Build, BuildItem, BuildOrderAttachment
from stock.models import StockLocation, StockItem from stock.models import StockLocation, StockItem
@ -165,16 +168,10 @@ class AutoAllocateForm(HelperForm):
confirm = forms.BooleanField(required=True, label=_('Confirm'), help_text=_('Confirm stock allocation')) confirm = forms.BooleanField(required=True, label=_('Confirm'), help_text=_('Confirm stock allocation'))
# Keep track of which build output we are interested in
output = forms.ModelChoiceField(
queryset=StockItem.objects.all(),
)
class Meta: class Meta:
model = Build model = Build
fields = [ fields = [
'confirm', 'confirm',
'output',
] ]
@ -214,6 +211,13 @@ class CompleteBuildOutputForm(HelperForm):
help_text=_('Location of completed parts'), help_text=_('Location of completed parts'),
) )
stock_status = forms.ChoiceField(
label=_('Status'),
help_text=_('Build output stock status'),
initial=StockStatus.OK,
choices=StockStatus.items(),
)
confirm_incomplete = forms.BooleanField( confirm_incomplete = forms.BooleanField(
required=False, required=False,
label=_('Confirm incomplete'), label=_('Confirm incomplete'),
@ -232,10 +236,15 @@ class CompleteBuildOutputForm(HelperForm):
fields = [ fields = [
'location', 'location',
'output', 'output',
'stock_status',
'confirm', 'confirm',
'confirm_incomplete', 'confirm_incomplete',
] ]
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
class CancelBuildForm(HelperForm): class CancelBuildForm(HelperForm):
""" Form for cancelling a build """ """ Form for cancelling a build """

View File

@ -22,7 +22,7 @@ from markdownx.models import MarkdownxField
from mptt.models import MPTTModel, TreeForeignKey from mptt.models import MPTTModel, TreeForeignKey
from InvenTree.status_codes import BuildStatus from InvenTree.status_codes import BuildStatus, StockStatus
from InvenTree.helpers import increment, getSetting, normalize, MakeBarcode from InvenTree.helpers import increment, getSetting, normalize, MakeBarcode
from InvenTree.validators import validate_build_order_reference from InvenTree.validators import validate_build_order_reference
from InvenTree.models import InvenTreeAttachment from InvenTree.models import InvenTreeAttachment
@ -314,6 +314,42 @@ class Build(MPTTModel):
'sub_part' 'sub_part'
) )
@property
def tracked_bom_items(self):
"""
Returns the "trackable" BOM items for this BuildOrder
"""
items = self.bom_items
items = items.filter(sub_part__trackable=True)
return items
def has_tracked_bom_items(self):
"""
Returns True if this BuildOrder has trackable BomItems
"""
return self.tracked_bom_items.count() > 0
@property
def untracked_bom_items(self):
"""
Returns the "non trackable" BOM items for this BuildOrder
"""
items = self.bom_items
items = items.filter(sub_part__trackable=False)
return items
def has_untracked_bom_items(self):
"""
Returns True if this BuildOrder has non trackable BomItems
"""
return self.untracked_bom_items.count() > 0
@property @property
def remaining(self): def remaining(self):
""" """
@ -449,6 +485,9 @@ class Build(MPTTModel):
if self.completed < self.quantity: if self.completed < self.quantity:
return False return False
if not self.areUntrackedPartsFullyAllocated():
return False
# No issues! # No issues!
return True return True
@ -458,7 +497,7 @@ class Build(MPTTModel):
Mark this build as complete Mark this build as complete
""" """
if not self.can_complete: if self.incomplete_count > 0:
return return
self.completion_date = datetime.now().date() self.completion_date = datetime.now().date()
@ -466,6 +505,9 @@ class Build(MPTTModel):
self.status = BuildStatus.COMPLETE self.status = BuildStatus.COMPLETE
self.save() self.save()
# Remove untracked allocated stock
self.subtractUntrackedStock(user)
# Ensure that there are no longer any BuildItem objects # Ensure that there are no longer any BuildItem objects
# which point to thie Build Order # which point to thie Build Order
self.allocated_stock.all().delete() self.allocated_stock.all().delete()
@ -489,7 +531,7 @@ class Build(MPTTModel):
self.status = BuildStatus.CANCELLED self.status = BuildStatus.CANCELLED
self.save() self.save()
def getAutoAllocations(self, output): def getAutoAllocations(self):
""" """
Return a list of StockItem objects which will be allocated Return a list of StockItem objects which will be allocated
using the 'AutoAllocate' function. using the 'AutoAllocate' function.
@ -521,15 +563,19 @@ class Build(MPTTModel):
part = bom_item.sub_part part = bom_item.sub_part
# If the part is "trackable" it cannot be auto-allocated
if part.trackable:
continue
# Skip any parts which are already fully allocated # Skip any parts which are already fully allocated
if self.isPartFullyAllocated(part, output): if self.isPartFullyAllocated(part, None):
continue continue
# How many parts are required to complete the output? # How many parts are required to complete the output?
required = self.unallocatedQuantity(part, output) required = self.unallocatedQuantity(part, None)
# Grab a list of stock items which are available # Grab a list of stock items which are available
stock_items = self.availableStockItems(part, output) stock_items = self.availableStockItems(part, None)
# Ensure that the available stock items are in the correct location # Ensure that the available stock items are in the correct location
if self.take_from is not None: if self.take_from is not None:
@ -544,7 +590,6 @@ class Build(MPTTModel):
build_items = BuildItem.objects.filter( build_items = BuildItem.objects.filter(
build=self, build=self,
stock_item=stock_item, stock_item=stock_item,
install_into=output
) )
if len(build_items) > 0: if len(build_items) > 0:
@ -567,24 +612,45 @@ class Build(MPTTModel):
return allocations return allocations
@transaction.atomic @transaction.atomic
def unallocateStock(self, output=None, part=None): def unallocateOutput(self, output, part=None):
""" """
Deletes all stock allocations for this build. Unallocate all stock which are allocated against the provided "output" (StockItem)
Args:
output: Specify which build output to delete allocations (optional)
""" """
allocations = BuildItem.objects.filter(build=self.pk) allocations = BuildItem.objects.filter(
build=self,
if output: install_into=output
allocations = allocations.filter(install_into=output.pk) )
if part: if part:
allocations = allocations.filter(stock_item__part=part) allocations = allocations.filter(stock_item__part=part)
# Remove all the allocations allocations.delete()
@transaction.atomic
def unallocateUntracked(self, part=None):
"""
Unallocate all "untracked" stock
"""
allocations = BuildItem.objects.filter(
build=self,
install_into=None
)
if part:
allocations = allocations.filter(stock_item__part=part)
allocations.delete()
@transaction.atomic
def unallocateAll(self):
"""
Deletes all stock allocations for this build.
"""
allocations = BuildItem.objects.filter(build=self)
allocations.delete() allocations.delete()
@transaction.atomic @transaction.atomic
@ -679,13 +745,13 @@ class Build(MPTTModel):
raise ValidationError(_("Build output does not match Build Order")) raise ValidationError(_("Build output does not match Build Order"))
# Unallocate all build items against the output # Unallocate all build items against the output
self.unallocateStock(output) self.unallocateOutput(output)
# Remove the build output from the database # Remove the build output from the database
output.delete() output.delete()
@transaction.atomic @transaction.atomic
def autoAllocate(self, output): def autoAllocate(self):
""" """
Run auto-allocation routine to allocate StockItems to this Build. Run auto-allocation routine to allocate StockItems to this Build.
@ -702,7 +768,7 @@ class Build(MPTTModel):
See: getAutoAllocations() See: getAutoAllocations()
""" """
allocations = self.getAutoAllocations(output) allocations = self.getAutoAllocations()
for item in allocations: for item in allocations:
# Create a new allocation # Create a new allocation
@ -710,11 +776,29 @@ class Build(MPTTModel):
build=self, build=self,
stock_item=item['stock_item'], stock_item=item['stock_item'],
quantity=item['quantity'], quantity=item['quantity'],
install_into=output, install_into=None
) )
build_item.save() build_item.save()
@transaction.atomic
def subtractUntrackedStock(self, user):
"""
Called when the Build is marked as "complete",
this function removes the allocated untracked items from stock.
"""
items = self.allocated_stock.filter(
stock_item__part__trackable=False
)
# Remove stock
for item in items:
item.complete_allocation(user)
# Delete allocation
items.all().delete()
@transaction.atomic @transaction.atomic
def completeBuildOutput(self, output, user, **kwargs): def completeBuildOutput(self, output, user, **kwargs):
""" """
@ -726,6 +810,7 @@ class Build(MPTTModel):
# Select the location for the build output # Select the location for the build output
location = kwargs.get('location', self.destination) location = kwargs.get('location', self.destination)
status = kwargs.get('status', StockStatus.OK)
# List the allocated BuildItem objects for the given output # List the allocated BuildItem objects for the given output
allocated_items = output.items_to_install.all() allocated_items = output.items_to_install.all()
@ -733,9 +818,7 @@ class Build(MPTTModel):
for build_item in allocated_items: for build_item in allocated_items:
# TODO: This is VERY SLOW as each deletion from the database takes ~1 second to complete # TODO: This is VERY SLOW as each deletion from the database takes ~1 second to complete
# TODO: Use celery / redis to offload the actual object deletion... # TODO: Use the background worker process to handle this task!
# REF: https://www.botreetechnologies.com/blog/implementing-celery-using-django-for-background-task-processing
# REF: https://code.tutsplus.com/tutorials/using-celery-with-django-for-background-task-processing--cms-28732
# Complete the allocation of stock for that item # Complete the allocation of stock for that item
build_item.complete_allocation(user) build_item.complete_allocation(user)
@ -747,6 +830,7 @@ class Build(MPTTModel):
output.build = self output.build = self
output.is_building = False output.is_building = False
output.location = location output.location = location
output.status = status
output.save() output.save()
@ -779,7 +863,7 @@ class Build(MPTTModel):
if output: if output:
quantity *= output.quantity quantity *= output.quantity
else: else:
quantity *= self.remaining quantity *= self.quantity
return quantity return quantity
@ -807,7 +891,13 @@ class Build(MPTTModel):
allocations = self.allocatedItems(part, output) allocations = self.allocatedItems(part, output)
allocated = allocations.aggregate(q=Coalesce(Sum('quantity'), 0)) allocated = allocations.aggregate(
q=Coalesce(
Sum('quantity'),
0,
output_field=models.DecimalField(),
)
)
return allocated['q'] return allocated['q']
@ -828,19 +918,39 @@ class Build(MPTTModel):
return self.unallocatedQuantity(part, output) == 0 return self.unallocatedQuantity(part, output) == 0
def isFullyAllocated(self, output): def isFullyAllocated(self, output, verbose=False):
""" """
Returns True if the particular build output is fully allocated. Returns True if the particular build output is fully allocated.
""" """
for bom_item in self.bom_items: # If output is not specified, we are talking about "untracked" items
if output is None:
bom_items = self.untracked_bom_items
else:
bom_items = self.tracked_bom_items
fully_allocated = True
for bom_item in bom_items:
part = bom_item.sub_part part = bom_item.sub_part
if not self.isPartFullyAllocated(part, output): if not self.isPartFullyAllocated(part, output):
return False fully_allocated = False
if verbose:
print(f"Part {part} is not fully allocated for output {output}")
else:
break
# All parts must be fully allocated! # All parts must be fully allocated!
return True return fully_allocated
def areUntrackedPartsFullyAllocated(self):
"""
Returns True if the un-tracked parts are fully allocated for this BuildOrder
"""
return self.isFullyAllocated(None)
def allocatedParts(self, output): def allocatedParts(self, output):
""" """
@ -849,7 +959,13 @@ class Build(MPTTModel):
allocated = [] allocated = []
for bom_item in self.bom_items: # If output is not specified, we are talking about "untracked" items
if output is None:
bom_items = self.untracked_bom_items
else:
bom_items = self.tracked_bom_items
for bom_item in bom_items:
part = bom_item.sub_part part = bom_item.sub_part
if self.isPartFullyAllocated(part, output): if self.isPartFullyAllocated(part, output):
@ -864,7 +980,13 @@ class Build(MPTTModel):
unallocated = [] unallocated = []
for bom_item in self.bom_items: # If output is not specified, we are talking about "untracked" items
if output is None:
bom_items = self.untracked_bom_items
else:
bom_items = self.tracked_bom_items
for bom_item in bom_items:
part = bom_item.sub_part part = bom_item.sub_part
if not self.isPartFullyAllocated(part, output): if not self.isPartFullyAllocated(part, output):
@ -1014,10 +1136,12 @@ class BuildItem(models.Model):
errors = {} errors = {}
if not self.install_into:
raise ValidationError(_('Build item must specify a build output'))
try: try:
# If the 'part' is trackable, then the 'install_into' field must be set!
if self.stock_item.part and self.stock_item.part.trackable and not self.install_into:
raise ValidationError(_('Build item must specify a build output, as master part is marked as trackable'))
# Allocated part must be in the BOM for the master part # Allocated part must be in the BOM for the master part
if self.stock_item.part not in self.build.part.getRequiredParts(recursive=False): if self.stock_item.part not in self.build.part.getRequiredParts(recursive=False):
errors['stock_item'] = [_("Selected stock item not found in BOM for part '{p}'").format(p=self.build.part.full_name)] errors['stock_item'] = [_("Selected stock item not found in BOM for part '{p}'").format(p=self.build.part.full_name)]

View File

@ -4,7 +4,7 @@
{% load inventree_extras %} {% load inventree_extras %}
{% block page_title %} {% block page_title %}
InvenTree | Allocate Parts {% inventree_title %} | {% trans "Allocate Parts" %}
{% endblock %} {% endblock %}
{% block menubar %} {% block menubar %}
@ -12,48 +12,41 @@ InvenTree | Allocate Parts
{% endblock %} {% endblock %}
{% block heading %} {% block heading %}
{% trans "Incomplete Build Ouputs" %} {% trans "Allocate Stock to Build" %}
{% endblock %} {% endblock %}
{% block details %} {% block details %}
{% if build.is_complete %} {% if build.has_untracked_bom_items %}
<div class='alert alert-block alert-success'> {% if build.active %}
{% trans "Build order has been completed" %}
</div>
{% else %}
<div class='btn-group' role='group'> <div class='btn-group' role='group'>
{% if build.active %} <button class='btn btn-success' type='button' id='btn-auto-allocate' title='{% trans "Allocate stock to build" %}'>
<button class='btn btn-primary' type='button' id='btn-create-output' title='{% trans "Create new build output" %}'> <span class='fas fa-magic'></span> {% trans "Auto Allocate" %}
<span class='fas fa-plus-circle'></span> {% trans "Create New Output" %}
</button> </button>
<!--
<button class='btn btn-primary' type='button' id='btn-order-parts' title='{% trans "Order required parts" %}'>
<span class='fas fa-shopping-cart'></span> {% trans "Order Parts" %}
</button>
-->
<button class='btn btn-danger' type='button' id='btn-unallocate' title='{% trans "Unallocate stock" %}'> <button class='btn btn-danger' type='button' id='btn-unallocate' title='{% trans "Unallocate stock" %}'>
<span class='fas fa-minus-circle'></span> {% trans "Unallocate Stock" %} <span class='fas fa-minus-circle'></span> {% trans "Unallocate Stock" %}
</button> </button>
{% endif %} <!--
<button class='btn btn-primary' type='button' id='btn-order-parts' title='{% trans "Order required parts" %}'>
<span class='fas fa-shopping-cart'></span> {% trans "Order Parts" %}
</button>
-->
</div> </div>
{% if build.areUntrackedPartsFullyAllocated %}
<hr> <div class='alert alert-block alert-success'>
{% if build.incomplete_outputs %} {% trans "Untracked stock has been fully allocated for this Build Order" %}
<div class="panel-group" id="build-output-accordion" role="tablist" aria-multiselectable="true">
{% for item in build.incomplete_outputs %}
{% include "build/allocation_card.html" with item=item %}
{% endfor %}
</div> </div>
{% else %} {% else %}
<div class='alert alert-block alert-info'> <div class='alert alert-block alert-danger'>
<b>{% trans "Create a new build output" %}</b><br> {% trans "Untracked stock has not been fully allocated for this Build Order" %}
{% trans "No incomplete build outputs remain." %}<br> </div>
{% trans "Create a new build output using the button above" %} {% endif %}
{% endif %}
<table class='table table-striped table-condensed' id='allocation-table-untracked'></table>
{% else %}
<div class='alert alert-block alert-info'>
{% trans "This Build Order does not have any associated untracked BOM items" %}
</div> </div>
{% endif %} {% endif %}
{% endif %}
{% endblock %} {% endblock %}
{% block js_ready %} {% block js_ready %}
@ -66,19 +59,17 @@ InvenTree | Allocate Parts
part: {{ build.part.pk }}, part: {{ build.part.pk }},
}; };
{% for item in build.incomplete_outputs %} {% if build.has_untracked_bom_items %}
// Get the build output as a javascript object // Load allocation table for un-tracked parts
inventreeGet('{% url 'api-stock-detail' item.pk %}', {}, loadBuildOutputAllocationTable(buildInfo, null);
{ {% endif %}
success: function(response) {
loadBuildOutputAllocationTable(buildInfo, response); function reloadTable() {
} $('#allocation-table-untracked').bootstrapTable('refresh');
} }
);
{% endfor %}
{% if build.active %} {% if build.active %}
$("#btn-allocate").on('click', function() { $("#btn-auto-allocate").on('click', function() {
launchModalForm( launchModalForm(
"{% url 'build-auto-allocate' build.id %}", "{% url 'build-auto-allocate' build.id %}",
{ {
@ -86,20 +77,12 @@ InvenTree | Allocate Parts
} }
); );
}); });
$('#btn-unallocate').on('click', function() { $('#btn-unallocate').on('click', function() {
launchModalForm( launchModalForm(
"{% url 'build-unallocate' build.id %}", "{% url 'build-unallocate' build.id %}",
{ {
reload: true, success: reloadTable,
}
);
});
$('#btn-create-output').click(function() {
launchModalForm('{% url "build-output-create" build.id %}',
{
reload: true,
} }
); );
}); });

View File

@ -7,23 +7,31 @@
<div class="panel-heading" role="tab" id="heading-{{ pk }}"> <div class="panel-heading" role="tab" id="heading-{{ pk }}">
<div class="panel-title"> <div class="panel-title">
<div class='row'> <div class='row'>
{% if tracked_items %}
<a class='collapsed' aria-expanded='false' role="button" data-toggle="collapse" data-parent="#build-output-accordion" href="#collapse-{{ pk }}" aria-controls="collapse-{{ pk }}"> <a class='collapsed' aria-expanded='false' role="button" data-toggle="collapse" data-parent="#build-output-accordion" href="#collapse-{{ pk }}" aria-controls="collapse-{{ pk }}">
{% endif %}
<div class='col-sm-4'> <div class='col-sm-4'>
{% if tracked_items %}
<span class='fas fa-caret-right'></span> <span class='fas fa-caret-right'></span>
{% endif %}
{{ item.part.full_name }} {{ item.part.full_name }}
</div> </div>
<div class='col-sm-2'> <div class='col-sm-2'>
{% if item.serial %} {% if item.serial %}
# {{ item.serial }} {% trans "Serial Number" %}: {{ item.serial }}
{% else %} {% else %}
{% decimal item.quantity %} {% trans "Quantity" %}: {% decimal item.quantity %}
{% endif %} {% endif %}
</div> </div>
{% if tracked_items %}
</a> </a>
{% endif %}
<div class='col-sm-3'> <div class='col-sm-3'>
<div> <div>
<div id='output-progress-{{ pk }}'> <div id='output-progress-{{ pk }}'>
{% if tracked_items %}
<span class='fas fa-spin fa-spinner'></span> <span class='fas fa-spin fa-spinner'></span>
{% endif %}
</div> </div>
</div> </div>
</div> </div>

View File

@ -6,10 +6,10 @@
{% load inventree_extras %} {% load inventree_extras %}
{% block page_title %} {% block page_title %}
InvenTree | {% trans "Build Order" %} - {{ build }} {% inventree_title %} | {% trans "Build Order" %} - {{ build }}
{% endblock %} {% endblock %}
{% block pre_content %} {% block header_pre_content %}
{% if build.sales_order %} {% if build.sales_order %}
<div class='alert alert-block alert-info'> <div class='alert alert-block alert-info'>
{% object_link 'so-detail' build.sales_order.id build.sales_order as link %} {% object_link 'so-detail' build.sales_order.id build.sales_order as link %}
@ -24,6 +24,31 @@ InvenTree | {% trans "Build Order" %} - {{ build }}
{% endif %} {% endif %}
{% endblock %} {% endblock %}
{% block header_post_content %}
{% if build.active %}
{% if build.can_complete %}
<div class='alert alert-block alert-success'>
{% trans "Build Order is ready to mark as completed" %}
</div>
{% endif %}
{% if build.incomplete_count > 0 %}
<div class='alert alert-block alert-danger'>
{% trans "Build Order cannot be completed as outstanding outputs remain" %}
</div>
{% endif %}
{% if build.completed < build.quantity %}
<div class='alert alert-block alert-warning'>
{% trans "Required build quantity has not yet been completed" %}
</div>
{% endif %}
{% if not build.areUntrackedPartsFullyAllocated %}
<div class='alert alert-block alert-warning'>
{% trans "Stock has not been fully allocated to this Build Order" %}
</div>
{% endif %}
{% endif %}
{% endblock %}
{% block thumbnail %} {% block thumbnail %}
<img class="part-thumb" <img class="part-thumb"
{% if build.part.image %} {% if build.part.image %}
@ -61,6 +86,11 @@ src="{% static 'img/blank_image.png' %}"
</div> </div>
<!-- Build actions --> <!-- Build actions -->
{% if roles.build.change %} {% if roles.build.change %}
{% if build.active %}
<button id='build-complete' title='{% trans "Complete Build" %}' class='btn btn-success'>
<span class='fas fa-paper-plane'></span>
</button>
{% endif %}
<div class='btn-group'> <div class='btn-group'>
<button id='build-options' title='{% trans "Build actions" %}' class='btn btn-default dropdown-toggle' type='button' data-toggle='dropdown'> <button id='build-options' title='{% trans "Build actions" %}' class='btn btn-default dropdown-toggle' type='button' data-toggle='dropdown'>
<span class='fas fa-tools'></span> <span class='caret'></span> <span class='fas fa-tools'></span> <span class='caret'></span>
@ -68,12 +98,11 @@ src="{% static 'img/blank_image.png' %}"
<ul class='dropdown-menu' role='menu'> <ul class='dropdown-menu' role='menu'>
<li><a href='#' id='build-edit'><span class='fas fa-edit icon-green'></span> {% trans "Edit Build" %}</a></li> <li><a href='#' id='build-edit'><span class='fas fa-edit icon-green'></span> {% trans "Edit Build" %}</a></li>
{% if build.is_active %} {% if build.is_active %}
<li><a href='#' id='build-complete'><span class='fas fa-tools'></span> {% trans "Complete Build" %}</a></li>
<li><a href='#' id='build-cancel'><span class='fas fa-times-circle icon-red'></span> {% trans "Cancel Build" %}</a></li> <li><a href='#' id='build-cancel'><span class='fas fa-times-circle icon-red'></span> {% trans "Cancel Build" %}</a></li>
{% endif %} {% endif %}
{% if build.status == BuildStatus.CANCELLED and roles.build.delete %} {% if build.status == BuildStatus.CANCELLED and roles.build.delete %}
<li><a href='#' id='build-delete'><span class='fas fa-trash-alt'></span> {% trans "Delete Build"% }</a> <li><a href='#' id='build-delete'><span class='fas fa-trash-alt'></span> {% trans "Delete Build"% }</a>
{% endif %} {% endif %}
</ul> </ul>
</div> </div>
{% endif %} {% endif %}
@ -172,6 +201,13 @@ src="{% static 'img/blank_image.png' %}"
}); });
$("#build-complete").on('click', function() { $("#build-complete").on('click', function() {
{% if build.incomplete_count > 0 %}
showAlertDialog(
'{% trans "Incomplete Outputs" %}',
'{% trans "Build Order cannot be completed as incomplete build outputs remain" %}'
);
{% else %}
launchModalForm( launchModalForm(
"{% url 'build-complete' build.id %}", "{% url 'build-complete' build.id %}",
{ {
@ -179,6 +215,7 @@ src="{% static 'img/blank_image.png' %}"
submit_text: '{% trans "Complete Build" %}', submit_text: '{% trans "Complete Build" %}',
} }
); );
{% endif %}
}); });
$('#print-build-report').click(function() { $('#print-build-report').click(function() {

View File

@ -6,19 +6,68 @@
{% include "build/navbar.html" with tab='output' %} {% include "build/navbar.html" with tab='output' %}
{% endblock %} {% endblock %}
{% block heading %} {% block content_panels %}
{% trans "Build Outputs" %}
{% endblock %}
{% block details %} {% if not build.is_complete %}
<div class='panel panel-default panel-inventree'>
<div class='panel-heading'>
<h4>
{% trans "Incomplete Build Outputs" %}
</h4>
</div>
{% include "stock_table.html" with read_only=True %} <div class='panel-content'>
<div class='btn-group' role='group'>
{% if build.active %}
<button class='btn btn-primary' type='button' id='btn-create-output' title='{% trans "Create new build output" %}'>
<span class='fas fa-plus-circle'></span> {% trans "Create New Output" %}
</button>
{% endif %}
</div>
{% if build.incomplete_outputs %}
<div class="panel-group" id="build-output-accordion" role="tablist" aria-multiselectable="true">
{% for item in build.incomplete_outputs %}
{% include "build/allocation_card.html" with item=item tracked_items=build.has_tracked_bom_items %}
{% endfor %}
</div>
{% else %}
<div class='alert alert-block alert-info'>
<b>{% trans "Create a new build output" %}</b><br>
{% trans "No incomplete build outputs remain." %}<br>
{% trans "Create a new build output using the button above" %}
</div>
{% endif %}
</div>
</div>
{% endif %}
<div class='panel panel-default panel-inventree'>
<div class='panel-heading'>
<h4>
{% trans "Completed Build Outputs" %}
</h4>
</div>
<div class='panel-content'>
{% include "stock_table.html" with read_only=True %}
</div>
</div>
{% endblock %} {% endblock %}
{% block js_ready %} {% block js_ready %}
{{ block.super }} {{ block.super }}
$('#btn-create-output').click(function() {
launchModalForm('{% url "build-output-create" build.id %}',
{
reload: true,
}
);
});
loadStockTable($("#stock-table"), { loadStockTable($("#stock-table"), {
params: { params: {
location_detail: true, location_detail: true,
@ -32,4 +81,23 @@ loadStockTable($("#stock-table"), {
url: "{% url 'api-stock-list' %}", url: "{% url 'api-stock-list' %}",
}); });
var buildInfo = {
pk: {{ build.pk }},
quantity: {{ build.quantity }},
completed: {{ build.completed }},
part: {{ build.part.pk }},
};
{% for item in build.incomplete_outputs %}
// Get the build output as a javascript object
inventreeGet('{% url 'api-stock-detail' item.pk %}', {},
{
success: function(response) {
loadBuildOutputAllocationTable(buildInfo, response);
}
}
);
{% endfor %}
{% endblock %} {% endblock %}

View File

@ -5,11 +5,11 @@
{% if build.can_complete %} {% if build.can_complete %}
<div class='alert alert-block alert-success'> <div class='alert alert-block alert-success'>
{% trans "Build can be completed" %} {% trans "Build Order is complete" %}
</div> </div>
{% else %} {% else %}
<div class='alert alert-block alert-danger'> <div class='alert alert-block alert-danger'>
<b>{% trans "Build cannot be completed" %}</b><br> <b>{% trans "Build Order is incomplete" %}</b><br>
<ul> <ul>
{% if build.incomplete_count > 0 %} {% if build.incomplete_count > 0 %}
<li>{% trans "Incompleted build outputs remain" %}</li> <li>{% trans "Incompleted build outputs remain" %}</li>
@ -17,6 +17,9 @@
{% if build.completed < build.quantity %} {% if build.completed < build.quantity %}
<li>{% trans "Required build quantity has not been completed" %}</li> <li>{% trans "Required build quantity has not been completed" %}</li>
{% endif %} {% endif %}
{% if not build.areUntrackedPartsFullyAllocated %}
<li>{% trans "Required stock has not been fully allocated" %}</li>
{% endif %}
</ul> </ul>
</div> </div>
{% endif %} {% endif %}

View File

@ -4,9 +4,10 @@
{% block pre_form_content %} {% block pre_form_content %}
{% if fully_allocated %} {% if not build.has_tracked_bom_items %}
<div class='alert alert-block alert-info'> {% elif fully_allocated %}
<h4>{% trans "Stock allocation is complete" %}</h4> <div class='alert alert-block alert-success'>
{% trans "Stock allocation is complete for this output" %}
</div> </div>
{% else %} {% else %}
<div class='alert alert-block alert-danger'> <div class='alert alert-block alert-danger'>
@ -16,7 +17,7 @@
<div class='panel panel-default'> <div class='panel panel-default'>
<div class='panel panel-heading'> <div class='panel panel-heading'>
<a data-toggle='collapse' href='#collapse-unallocated'> <a data-toggle='collapse' href='#collapse-unallocated'>
{{ unallocated_parts|length }} {% trans "parts have not been fully allocated" %} {{ unallocated_parts|length }} {% trans "tracked parts have not been fully allocated" %}
</a> </a>
</div> </div>
<div class='panel-collapse collapse' id='collapse-unallocated'> <div class='panel-collapse collapse' id='collapse-unallocated'>
@ -41,7 +42,11 @@
</div> </div>
<div class='panel-content'> <div class='panel-content'>
{% include "hover_image.html" with image=build.part.image hover=True %} {% include "hover_image.html" with image=build.part.image hover=True %}
{% if output.serialized %}
{{ output.part.full_name }} - {% trans "Serial Number" %} {{ output.serial }}
{% else %}
{% decimal output.quantity %} x {{ output.part.full_name }} {% decimal output.quantity %} x {{ output.part.full_name }}
{% endif %}
</div> </div>
</div> </div>

View File

@ -5,7 +5,7 @@
{% load i18n %} {% load i18n %}
{% block page_title %} {% block page_title %}
InvenTree | {% trans "Build Orders" %} {% inventree_title %} | {% trans "Build Orders" %}
{% endblock %} {% endblock %}
{% block content %} {% block content %}

View File

@ -17,17 +17,11 @@
</li> </li>
{% if build.active %} {% if build.active %}
<li class='list-group-item {% if tab == "parts" %}active{% endif %}' title='{% trans "Required Parts" %}'>
<a href='{% url "build-parts" build.id %}'> <li class='list-group-item {% if tab == "allocate" %}active{% endif %}' title='{% trans "Allocate Stock" %}'>
<span class='fas fa-shapes'></span>
{% trans "Required Parts" %}
</a>
</li>
<li class='list-group-item {% if tab == "allocate" %}active{% endif %}' title='{% trans "In Progress" %}'>
<a href='{% url "build-allocate" build.id %}'> <a href='{% url "build-allocate" build.id %}'>
<span class='fas fa-tools'></span> <span class='fas fa-tools'></span>
{% trans "In Progress" %} {% trans "Allocate Stock" %}
</a> </a>
</li> </li>
{% endif %} {% endif %}

View File

@ -1,30 +0,0 @@
{% extends "build/build_base.html" %}
{% load static %}
{% load i18n %}
{% load status_codes %}
{% block menubar %}
{% include "build/navbar.html" with tab='parts' %}
{% endblock %}
{% block heading %}
{% trans "Required Parts" %}
{% endblock %}
{% block details %}
<table class='table table-striped table-condensed' id='parts-table'></table>
{% endblock %}
{% block js_ready %}
{{ block.super }}
loadBuildPartsTable($('#parts-table'), {
part: {{ build.part.pk }},
build: {{ build.pk }},
build_quantity: {{ build.quantity }},
build_remaining: {{ build.remaining }},
});
{% endblock %}

View File

@ -19,6 +19,18 @@ class BuildTest(TestCase):
def setUp(self): def setUp(self):
""" """
Initialize data to use for these tests. Initialize data to use for these tests.
The base Part 'assembly' has a BOM consisting of three parts:
- 5 x sub_part_1
- 3 x sub_part_2
- 2 x sub_part_3 (trackable)
We will build 10x 'assembly' parts, in two build outputs:
- 3 x output_1
- 7 x output_2
""" """
# Create a base "Part" # Create a base "Part"
@ -41,17 +53,31 @@ class BuildTest(TestCase):
component=True component=True
) )
self.sub_part_3 = Part.objects.create(
name="Widget C",
description="A widget",
component=True,
trackable=True
)
# Create BOM item links for the parts # Create BOM item links for the parts
BomItem.objects.create( BomItem.objects.create(
part=self.assembly, part=self.assembly,
sub_part=self.sub_part_1, sub_part=self.sub_part_1,
quantity=10 quantity=5
) )
BomItem.objects.create( BomItem.objects.create(
part=self.assembly, part=self.assembly,
sub_part=self.sub_part_2, sub_part=self.sub_part_2,
quantity=25 quantity=3
)
# sub_part_3 is trackable!
BomItem.objects.create(
part=self.assembly,
sub_part=self.sub_part_3,
quantity=2
) )
# Create a "Build" object to make 10x objects # Create a "Build" object to make 10x objects
@ -64,14 +90,14 @@ class BuildTest(TestCase):
# Create some build output (StockItem) objects # Create some build output (StockItem) objects
self.output_1 = StockItem.objects.create( self.output_1 = StockItem.objects.create(
part=self.assembly, part=self.assembly,
quantity=5, quantity=3,
is_building=True, is_building=True,
build=self.build build=self.build
) )
self.output_2 = StockItem.objects.create( self.output_2 = StockItem.objects.create(
part=self.assembly, part=self.assembly,
quantity=5, quantity=7,
is_building=True, is_building=True,
build=self.build, build=self.build,
) )
@ -82,10 +108,12 @@ class BuildTest(TestCase):
self.stock_2_1 = StockItem.objects.create(part=self.sub_part_2, quantity=5000) self.stock_2_1 = StockItem.objects.create(part=self.sub_part_2, quantity=5000)
self.stock_3_1 = StockItem.objects.create(part=self.sub_part_3, quantity=1000)
def test_init(self): 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(), 5) self.assertEqual(StockItem.objects.count(), 6)
# Build is PENDING # Build is PENDING
self.assertEqual(self.build.status, status.BuildStatus.PENDING) self.assertEqual(self.build.status, status.BuildStatus.PENDING)
@ -100,10 +128,10 @@ class BuildTest(TestCase):
self.assertFalse(self.build.isPartFullyAllocated(self.sub_part_1, self.output_1)) self.assertFalse(self.build.isPartFullyAllocated(self.sub_part_1, self.output_1))
self.assertFalse(self.build.isPartFullyAllocated(self.sub_part_2, self.output_2)) self.assertFalse(self.build.isPartFullyAllocated(self.sub_part_2, self.output_2))
self.assertEqual(self.build.unallocatedQuantity(self.sub_part_1, self.output_1), 50) self.assertEqual(self.build.unallocatedQuantity(self.sub_part_1, self.output_1), 15)
self.assertEqual(self.build.unallocatedQuantity(self.sub_part_1, self.output_2), 50) self.assertEqual(self.build.unallocatedQuantity(self.sub_part_1, self.output_2), 35)
self.assertEqual(self.build.unallocatedQuantity(self.sub_part_2, self.output_1), 125) self.assertEqual(self.build.unallocatedQuantity(self.sub_part_2, self.output_1), 9)
self.assertEqual(self.build.unallocatedQuantity(self.sub_part_2, self.output_2), 125) self.assertEqual(self.build.unallocatedQuantity(self.sub_part_2, self.output_2), 21)
self.assertFalse(self.build.is_complete) self.assertFalse(self.build.is_complete)
@ -144,84 +172,113 @@ class BuildTest(TestCase):
quantity=99 quantity=99
) )
def allocate_stock(self, q11, q12, q21, output): def allocate_stock(self, output, allocations):
# Assign stock to this build """
Allocate stock to this build, against a particular output
if q11 > 0: Args:
output - StockItem object (or None)
allocations - Map of {StockItem: quantity}
"""
for item, quantity in allocations.items():
BuildItem.objects.create( BuildItem.objects.create(
build=self.build, build=self.build,
stock_item=self.stock_1_1, stock_item=item,
quantity=q11, quantity=quantity,
install_into=output install_into=output
) )
if q12 > 0:
BuildItem.objects.create(
build=self.build,
stock_item=self.stock_1_2,
quantity=q12,
install_into=output
)
if q21 > 0:
BuildItem.objects.create(
build=self.build,
stock_item=self.stock_2_1,
quantity=q21,
install_into=output,
)
# Attempt to create another identical BuildItem
b = BuildItem(
build=self.build,
stock_item=self.stock_2_1,
quantity=q21
)
with self.assertRaises(ValidationError):
b.clean()
def test_partial_allocation(self): def test_partial_allocation(self):
""" """
Partially allocate against output 1 Test partial allocation of stock
""" """
self.allocate_stock(50, 50, 200, self.output_1) # Fully allocate tracked stock against build output 1
self.allocate_stock(
self.output_1,
{
self.stock_3_1: 6,
}
)
self.assertTrue(self.build.isFullyAllocated(self.output_1)) self.assertTrue(self.build.isFullyAllocated(self.output_1))
# Partially allocate tracked stock against build output 2
self.allocate_stock(
self.output_2,
{
self.stock_3_1: 1,
}
)
self.assertFalse(self.build.isFullyAllocated(self.output_2)) self.assertFalse(self.build.isFullyAllocated(self.output_2))
self.assertTrue(self.build.isPartFullyAllocated(self.sub_part_1, self.output_1))
self.assertTrue(self.build.isPartFullyAllocated(self.sub_part_2, self.output_1))
self.assertFalse(self.build.isPartFullyAllocated(self.sub_part_1, self.output_2))
self.assertFalse(self.build.isPartFullyAllocated(self.sub_part_2, self.output_2))
# Check that the part has been allocated # Partially allocate untracked stock against build
self.assertEqual(self.build.allocatedQuantity(self.sub_part_1, self.output_1), 100) self.allocate_stock(
None,
{
self.stock_1_1: 1,
self.stock_2_1: 1
}
)
self.build.unallocateStock(output=self.output_1) self.assertFalse(self.build.isFullyAllocated(None, verbose=True))
self.assertEqual(BuildItem.objects.count(), 0)
# Check that the part has been unallocated unallocated = self.build.unallocatedParts(None)
self.assertEqual(self.build.allocatedQuantity(self.sub_part_1, self.output_1), 0)
self.assertEqual(len(unallocated), 2)
self.allocate_stock(
None,
{
self.stock_1_2: 100,
}
)
self.assertFalse(self.build.isFullyAllocated(None, verbose=True))
unallocated = self.build.unallocatedParts(None)
self.assertEqual(len(unallocated), 1)
self.build.unallocateUntracked()
unallocated = self.build.unallocatedParts(None)
self.assertEqual(len(unallocated), 2)
self.assertFalse(self.build.areUntrackedPartsFullyAllocated())
# Now we "fully" allocate the untracked untracked items
self.allocate_stock(
None,
{
self.stock_1_1: 50,
self.stock_2_1: 50,
}
)
self.assertTrue(self.build.areUntrackedPartsFullyAllocated())
def test_auto_allocate(self): def test_auto_allocate(self):
""" """
Test auto-allocation functionality against the build outputs Test auto-allocation functionality against the build outputs.
Note: auto-allocations only work for un-tracked stock!
""" """
allocations = self.build.getAutoAllocations(self.output_1) allocations = self.build.getAutoAllocations()
self.assertEqual(len(allocations), 1) self.assertEqual(len(allocations), 1)
self.build.autoAllocate(self.output_1) self.build.autoAllocate()
self.assertEqual(BuildItem.objects.count(), 1) self.assertEqual(BuildItem.objects.count(), 1)
# Check that one part has been fully allocated to the build output # Check that one un-tracked part has been fully allocated to the build
self.assertTrue(self.build.isPartFullyAllocated(self.sub_part_2, self.output_1)) self.assertTrue(self.build.isPartFullyAllocated(self.sub_part_2, None))
# But, the *other* build output has not been allocated against self.assertFalse(self.build.isPartFullyAllocated(self.sub_part_1, None))
self.assertFalse(self.build.isPartFullyAllocated(self.sub_part_2, self.output_2))
def test_cancel(self): def test_cancel(self):
""" """
@ -243,9 +300,33 @@ class BuildTest(TestCase):
Test completion of a build output Test completion of a build output
""" """
self.allocate_stock(50, 50, 250, self.output_1) # Allocate non-tracked parts
self.allocate_stock(50, 50, 250, self.output_2) self.allocate_stock(
None,
{
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
}
)
# Allocate tracked parts to output_1
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.assertTrue(self.build.isFullyAllocated(None, verbose=True))
self.assertTrue(self.build.isFullyAllocated(self.output_1)) self.assertTrue(self.build.isFullyAllocated(self.output_1))
self.assertTrue(self.build.isFullyAllocated(self.output_2)) self.assertTrue(self.build.isFullyAllocated(self.output_2))
@ -265,19 +346,16 @@ class BuildTest(TestCase):
self.assertEqual(BuildItem.objects.count(), 0) self.assertEqual(BuildItem.objects.count(), 0)
# New stock items should have been created! # New stock items should have been created!
self.assertEqual(StockItem.objects.count(), 4) self.assertEqual(StockItem.objects.count(), 7)
a = StockItem.objects.get(pk=self.stock_1_1.pk)
# This stock item has been depleted! # This stock item has been depleted!
with self.assertRaises(StockItem.DoesNotExist): with self.assertRaises(StockItem.DoesNotExist):
StockItem.objects.get(pk=self.stock_1_2.pk) StockItem.objects.get(pk=self.stock_1_1.pk)
c = StockItem.objects.get(pk=self.stock_2_1.pk) # This stock item has *not* been depleted
x = StockItem.objects.get(pk=self.stock_2_1.pk)
# Stock should have been subtracted from the original items self.assertEqual(x.quantity, 4970)
self.assertEqual(a.quantity, 900)
self.assertEqual(c.quantity, 4500)
# And 10 new stock items created for the build output # And 10 new stock items created for the build output
outputs = StockItem.objects.filter(build=self.build) outputs = StockItem.objects.filter(build=self.build)

View File

@ -15,7 +15,7 @@ from datetime import datetime, timedelta
from .models import Build from .models import Build
from stock.models import StockItem from stock.models import StockItem
from InvenTree.status_codes import BuildStatus from InvenTree.status_codes import BuildStatus, StockStatus
class BuildTestSimple(TestCase): class BuildTestSimple(TestCase):
@ -335,6 +335,7 @@ class TestBuildViews(TestCase):
'confirm_incomplete': 1, 'confirm_incomplete': 1,
'location': 1, 'location': 1,
'output': self.output.pk, 'output': self.output.pk,
'stock_status': StockStatus.DAMAGED
}, },
HTTP_X_REQUESTED_WITH='XMLHttpRequest' HTTP_X_REQUESTED_WITH='XMLHttpRequest'
) )
@ -342,6 +343,7 @@ class TestBuildViews(TestCase):
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
data = json.loads(response.content) data = json.loads(response.content)
self.assertTrue(data['form_valid']) self.assertTrue(data['form_valid'])
# Now the build should be able to be completed # Now the build should be able to be completed

View File

@ -21,7 +21,6 @@ build_detail_urls = [
url(r'^notes/', views.BuildNotes.as_view(), name='build-notes'), url(r'^notes/', views.BuildNotes.as_view(), name='build-notes'),
url(r'^children/', views.BuildDetail.as_view(template_name='build/build_children.html'), name='build-children'), url(r'^children/', views.BuildDetail.as_view(template_name='build/build_children.html'), name='build-children'),
url(r'^parts/', views.BuildDetail.as_view(template_name='build/parts.html'), name='build-parts'),
url(r'^attachments/', views.BuildDetail.as_view(template_name='build/attachments.html'), name='build-attachments'), url(r'^attachments/', views.BuildDetail.as_view(template_name='build/attachments.html'), name='build-attachments'),
url(r'^output/', views.BuildDetail.as_view(template_name='build/build_output.html'), name='build-output'), url(r'^output/', views.BuildDetail.as_view(template_name='build/build_output.html'), name='build-output'),

View File

@ -18,8 +18,8 @@ from stock.models import StockLocation, StockItem
from InvenTree.views import AjaxUpdateView, AjaxCreateView, AjaxDeleteView from InvenTree.views import AjaxUpdateView, AjaxCreateView, AjaxDeleteView
from InvenTree.views import InvenTreeRoleMixin from InvenTree.views import InvenTreeRoleMixin
from InvenTree.helpers import str2bool, extract_serial_numbers, normalize from InvenTree.helpers import str2bool, extract_serial_numbers, normalize, isNull
from InvenTree.status_codes import BuildStatus from InvenTree.status_codes import BuildStatus, StockStatus
class BuildIndex(InvenTreeRoleMixin, ListView): class BuildIndex(InvenTreeRoleMixin, ListView):
@ -98,16 +98,6 @@ class BuildAutoAllocate(AjaxUpdateView):
initials = super().get_initial() initials = super().get_initial()
# Pointing to a particular build output?
output = self.get_param('output')
if output:
try:
output = StockItem.objects.get(pk=output)
initials['output'] = output
except (ValueError, StockItem.DoesNotExist):
pass
return initials return initials
def get_context_data(self, *args, **kwargs): def get_context_data(self, *args, **kwargs):
@ -119,18 +109,7 @@ class BuildAutoAllocate(AjaxUpdateView):
build = self.get_object() build = self.get_object()
form = self.get_form() context['allocations'] = build.getAutoAllocations()
output_id = form['output'].value()
try:
output = StockItem.objects.get(pk=output_id)
except (ValueError, StockItem.DoesNotExist):
output = None
if output:
context['output'] = output
context['allocations'] = build.getAutoAllocations(output)
context['build'] = build context['build'] = build
@ -140,18 +119,11 @@ class BuildAutoAllocate(AjaxUpdateView):
form = super().get_form() form = super().get_form()
if form['output'].value():
# Hide the 'output' field
form.fields['output'].widget = HiddenInput()
return form return form
def validate(self, build, form, **kwargs): def validate(self, build, form, **kwargs):
output = form.cleaned_data.get('output', None) pass
if not output:
form.add_error(None, _('Build output must be specified'))
def save(self, build, form, **kwargs): def save(self, build, form, **kwargs):
""" """
@ -159,9 +131,7 @@ class BuildAutoAllocate(AjaxUpdateView):
perform auto-allocations perform auto-allocations
""" """
output = form.cleaned_data.get('output', None) build.autoAllocate()
build.autoAllocate(output)
def get_data(self): def get_data(self):
return { return {
@ -242,7 +212,7 @@ class BuildOutputCreate(AjaxUpdateView):
# Calculate the required quantity # Calculate the required quantity
quantity = max(0, build.remaining - build.incomplete_count) quantity = max(0, build.remaining - build.incomplete_count)
initials['quantity'] = quantity initials['output_quantity'] = quantity
return initials return initials
@ -365,10 +335,16 @@ class BuildUnallocate(AjaxUpdateView):
output_id = request.POST.get('output_id', None) output_id = request.POST.get('output_id', None)
try: if output_id:
output = StockItem.objects.get(pk=output_id)
except (ValueError, StockItem.DoesNotExist): # If a "null" output is provided, we are trying to unallocate "untracked" stock
output = None if isNull(output_id):
output = None
else:
try:
output = StockItem.objects.get(pk=output_id)
except (ValueError, StockItem.DoesNotExist):
output = None
part_id = request.POST.get('part_id', None) part_id = request.POST.get('part_id', None)
@ -383,9 +359,19 @@ class BuildUnallocate(AjaxUpdateView):
form.add_error('confirm', _('Confirm unallocation of build stock')) form.add_error('confirm', _('Confirm unallocation of build stock'))
form.add_error(None, _('Check the confirmation box')) form.add_error(None, _('Check the confirmation box'))
else: else:
build.unallocateStock(output=output, part=part)
valid = True valid = True
# Unallocate the entire build
if not output_id:
build.unallocateAll()
# Unallocate a single output
elif output:
build.unallocateOutput(output, part=part)
# Unallocate "untracked" parts
else:
build.unallocateUntracked(part=part)
data = { data = {
'form_valid': valid, 'form_valid': valid,
} }
@ -410,8 +396,8 @@ class BuildComplete(AjaxUpdateView):
def validate(self, build, form, **kwargs): def validate(self, build, form, **kwargs):
if not build.can_complete: if build.incomplete_count > 0:
form.add_error(None, _('Build order cannot be completed')) form.add_error(None, _('Build order cannot be completed - incomplete outputs remain'))
def save(self, build, form, **kwargs): def save(self, build, form, **kwargs):
""" """
@ -431,7 +417,7 @@ class BuildOutputComplete(AjaxUpdateView):
View to mark a particular build output as Complete. View to mark a particular build output as Complete.
- Notifies the user of which parts will be removed from stock. - Notifies the user of which parts will be removed from stock.
- Removes allocated items from stock - Assignes (tracked) allocated items from stock to the build output
- Deletes pending BuildItem objects - Deletes pending BuildItem objects
""" """
@ -463,11 +449,25 @@ class BuildOutputComplete(AjaxUpdateView):
return form return form
def validate(self, build, form, **kwargs): def validate(self, build, form, **kwargs):
"""
Custom validation steps for the BuildOutputComplete" form
"""
data = form.cleaned_data data = form.cleaned_data
output = data.get('output', None) output = data.get('output', None)
stock_status = data.get('stock_status', StockStatus.OK)
# Any "invalid" stock status defaults to OK
try:
stock_status = int(stock_status)
except (ValueError):
stock_status = StockStatus.OK
if int(stock_status) not in StockStatus.keys():
form.add_error('stock_status', _('Invalid stock status value selected'))
if output: if output:
quantity = data.get('quantity', None) quantity = data.get('quantity', None)
@ -559,12 +559,20 @@ class BuildOutputComplete(AjaxUpdateView):
location = data.get('location', None) location = data.get('location', None)
output = data.get('output', None) output = data.get('output', None)
stock_status = data.get('stock_status', StockStatus.OK)
# Any "invalid" stock status defaults to OK
try:
stock_status = int(stock_status)
except (ValueError):
stock_status = StockStatus.OK
# Complete the build output # Complete the build output
build.completeBuildOutput( build.completeBuildOutput(
output, output,
self.request.user, self.request.user,
location=location, location=location,
status=stock_status,
) )
def get_data(self): def get_data(self):
@ -632,10 +640,12 @@ class BuildAllocate(InvenTreeRoleMixin, DetailView):
build = self.get_object() build = self.get_object()
part = build.part part = build.part
bom_items = part.bom_items bom_items = build.bom_items
context['part'] = part context['part'] = part
context['bom_items'] = bom_items context['bom_items'] = bom_items
context['has_tracked_bom_items'] = build.has_tracked_bom_items()
context['has_untracked_bom_items'] = build.has_untracked_bom_items()
context['BuildStatus'] = BuildStatus context['BuildStatus'] = BuildStatus
context['bom_price'] = build.part.get_price_info(build.quantity, buy=False) context['bom_price'] = build.part.get_price_info(build.quantity, buy=False)

View File

@ -58,6 +58,13 @@ class InvenTreeSetting(models.Model):
'description': _('String descriptor for the server instance'), 'description': _('String descriptor for the server instance'),
}, },
'INVENTREE_INSTANCE_TITLE': {
'name': _('Use instance name'),
'description': _('Use the instance name in the title-bar'),
'validator': bool,
'default': False,
},
'INVENTREE_COMPANY_NAME': { 'INVENTREE_COMPANY_NAME': {
'name': _('Company name'), 'name': _('Company name'),
'description': _('Internal company name'), 'description': _('Internal company name'),

View File

@ -6,7 +6,7 @@
{% block page_title %} {% block page_title %}
InvenTree | {% trans "Company" %} - {{ company.name }} {% inventree_title %} | {% trans "Company" %} - {{ company.name }}
{% endblock %} {% endblock %}

View File

@ -2,9 +2,10 @@
{% load static %} {% load static %}
{% load i18n %} {% load i18n %}
{% load inventree_extras %}
{% block page_title %} {% block page_title %}
InvenTree | {% trans "Supplier List" %} {% inventree_title %} | {% trans "Supplier List" %}
{% endblock %} {% endblock %}
{% block content %} {% block content %}

View File

@ -1,9 +1,10 @@
{% extends "two_column.html" %} {% extends "two_column.html" %}
{% load static %} {% load static %}
{% load i18n %} {% load i18n %}
{% load inventree_extras %}
{% block page_title %} {% block page_title %}
InvenTree | {% trans "Supplier Part" %} {% inventree_title %} | {% trans "Supplier Part" %}
{% endblock %} {% endblock %}
{% block thumbnail %} {% block thumbnail %}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

Binary file not shown.

File diff suppressed because it is too large Load Diff

Binary file not shown.

File diff suppressed because it is too large Load Diff

Binary file not shown.

File diff suppressed because it is too large Load Diff

Binary file not shown.

File diff suppressed because it is too large Load Diff

Binary file not shown.

File diff suppressed because it is too large Load Diff

Binary file not shown.

File diff suppressed because it is too large Load Diff

View File

@ -6,7 +6,7 @@
{% load status_codes %} {% load status_codes %}
{% block page_title %} {% block page_title %}
InvenTree | {% trans "Purchase Order" %} {% inventree_title %} | {% trans "Purchase Order" %}
{% endblock %} {% endblock %}
{% block thumbnail %} {% block thumbnail %}

View File

@ -5,7 +5,7 @@
{% load i18n %} {% load i18n %}
{% block page_title %} {% block page_title %}
InvenTree | {% trans "Purchase Orders" %} {% inventree_title %} | {% trans "Purchase Orders" %}
{% endblock %} {% endblock %}
{% block content %} {% block content %}

View File

@ -6,7 +6,7 @@
{% load status_codes %} {% load status_codes %}
{% block page_title %} {% block page_title %}
InvenTree | {% trans "Sales Order" %} {% inventree_title %} | {% trans "Sales Order" %}
{% endblock %} {% endblock %}
{% block pre_content %} {% block pre_content %}

View File

@ -5,7 +5,7 @@
{% load i18n %} {% load i18n %}
{% block page_title %} {% block page_title %}
InvenTree | {% trans "Sales Orders" %} {% inventree_title %} | {% trans "Sales Orders" %}
{% endblock %} {% endblock %}
{% block content %} {% block content %}

View File

@ -60,28 +60,43 @@ class CategoryList(generics.ListCreateAPIView):
queryset = PartCategory.objects.all() queryset = PartCategory.objects.all()
serializer_class = part_serializers.CategorySerializer serializer_class = part_serializers.CategorySerializer
def get_queryset(self): def filter_queryset(self, queryset):
""" """
Custom filtering: Custom filtering:
- Allow filtering by "null" parent to retrieve top-level part categories - Allow filtering by "null" parent to retrieve top-level part categories
""" """
cat_id = self.request.query_params.get('parent', None) queryset = super().filter_queryset(queryset)
queryset = super().get_queryset() params = self.request.query_params
if cat_id is not None: cat_id = params.get('parent', None)
cascade = str2bool(params.get('cascade', False))
# Do not filter by category
if cat_id is None:
pass
# Look for top-level categories
elif isNull(cat_id):
# Look for top-level categories if not cascade:
if isNull(cat_id):
queryset = queryset.filter(parent=None) queryset = queryset.filter(parent=None)
else: else:
try: try:
cat_id = int(cat_id) category = PartCategory.objects.get(pk=cat_id)
queryset = queryset.filter(parent=cat_id)
except ValueError: if cascade:
pass parents = category.get_descendants(include_self=True)
parent_ids = [p.id for p in parents]
queryset = queryset.filter(parent__in=parent_ids)
else:
queryset = queryset.filter(parent=category)
except (ValueError, PartCategory.DoesNotExist):
pass
return queryset return queryset

View File

@ -1163,7 +1163,16 @@ class Part(MPTTModel):
Return the total amount of this part allocated to build orders Return the total amount of this part allocated to build orders
""" """
query = self.build_order_allocations().aggregate(total=Coalesce(Sum('quantity'), 0)) query = self.build_order_allocations().aggregate(
total=Coalesce(
Sum(
'quantity',
output_field=models.DecimalField()
),
0,
output_field=models.DecimalField(),
)
)
return query['total'] return query['total']
@ -1179,7 +1188,16 @@ class Part(MPTTModel):
Return the tutal quantity of this part allocated to sales orders Return the tutal quantity of this part allocated to sales orders
""" """
query = self.sales_order_allocations().aggregate(total=Coalesce(Sum('quantity'), 0)) query = self.sales_order_allocations().aggregate(
total=Coalesce(
Sum(
'quantity',
output_field=models.DecimalField(),
),
0,
output_field=models.DecimalField(),
)
)
return query['total'] return query['total']
@ -1189,10 +1207,12 @@ class Part(MPTTModel):
against both build orders and sales orders. against both build orders and sales orders.
""" """
return sum([ return sum(
self.build_order_allocation_count(), [
self.sales_order_allocation_count(), self.build_order_allocation_count(),
]) self.sales_order_allocation_count(),
],
)
def stock_entries(self, include_variants=True, in_stock=None): def stock_entries(self, include_variants=True, in_stock=None):
""" Return all stock entries for this Part. """ Return all stock entries for this Part.

View File

@ -4,6 +4,7 @@ JSON serializers for Part app
import imghdr import imghdr
from decimal import Decimal from decimal import Decimal
from django.db import models
from django.db.models import Q from django.db.models import Q
from django.db.models.functions import Coalesce from django.db.models.functions import Coalesce
from InvenTree.serializers import (InvenTreeAttachmentSerializerField, from InvenTree.serializers import (InvenTreeAttachmentSerializerField,
@ -208,7 +209,8 @@ class PartSerializer(InvenTreeModelSerializer):
queryset = queryset.annotate( queryset = queryset.annotate(
in_stock=Coalesce( in_stock=Coalesce(
SubquerySum('stock_items__quantity', filter=StockItem.IN_STOCK_FILTER), SubquerySum('stock_items__quantity', filter=StockItem.IN_STOCK_FILTER),
Decimal(0) Decimal(0),
output_field=models.DecimalField(),
), ),
) )
@ -227,6 +229,7 @@ class PartSerializer(InvenTreeModelSerializer):
building=Coalesce( building=Coalesce(
SubquerySum('builds__quantity', filter=build_filter), SubquerySum('builds__quantity', filter=build_filter),
Decimal(0), Decimal(0),
output_field=models.DecimalField(),
) )
) )
@ -240,9 +243,11 @@ class PartSerializer(InvenTreeModelSerializer):
ordering=Coalesce( ordering=Coalesce(
SubquerySum('supplier_parts__purchase_order_line_items__quantity', filter=order_filter), SubquerySum('supplier_parts__purchase_order_line_items__quantity', filter=order_filter),
Decimal(0), Decimal(0),
output_field=models.DecimalField(),
) - Coalesce( ) - Coalesce(
SubquerySum('supplier_parts__purchase_order_line_items__received', filter=order_filter), SubquerySum('supplier_parts__purchase_order_line_items__received', filter=order_filter),
Decimal(0), Decimal(0),
output_field=models.DecimalField(),
) )
) )
@ -251,6 +256,7 @@ class PartSerializer(InvenTreeModelSerializer):
suppliers=Coalesce( suppliers=Coalesce(
SubqueryCount('supplier_parts'), SubqueryCount('supplier_parts'),
Decimal(0), Decimal(0),
output_field=models.DecimalField(),
), ),
) )

View File

@ -2,6 +2,10 @@
{% load static %} {% load static %}
{% load i18n %} {% load i18n %}
{% block menubar %}
{% include 'part/category_navbar.html' with tab='parts' %}
{% endblock %}
{% block content %} {% block content %}
<div class='panel panel-default panel-inventree'> <div class='panel panel-default panel-inventree'>
@ -100,14 +104,10 @@
</div> </div>
</div> </div>
{% if category and category.children.all|length > 0 %}
{% include "part/subcategories.html" with children=category.children.all collapse_id="categories" %}
{% elif children|length > 0 %}
{% include "part/subcategories.html" with children=children collapse_id="categories" %}
{% endif %}
</div> </div>
{% block category_content %}
<div id='button-toolbar'> <div id='button-toolbar'>
<div class='btn-group'> <div class='btn-group'>
<button class='btn btn-default' id='part-export' title='{% trans "Export Part Data" %}'> <button class='btn btn-default' id='part-export' title='{% trans "Export Part Data" %}'>
@ -150,6 +150,8 @@
</div> </div>
</div> </div>
{% endblock %}
{% block category_tables %} {% block category_tables %}
{% endblock category_tables %} {% endblock category_tables %}
@ -162,24 +164,10 @@
{% block js_ready %} {% block js_ready %}
{{ block.super }} {{ block.super }}
{% if category %}
enableNavbar({ enableNavbar({
label: 'category', label: 'category',
toggleId: '#category-menu-toggle', toggleId: '#category-menu-toggle',
}); });
{% endif %}
if (inventreeLoadInt("show-part-cats") == 1) {
$("#collapse-item-categories").collapse('show');
}
$("#collapse-item-categories").on('shown.bs.collapse', function() {
inventreeSave('show-part-cats', 1);
});
$("#collapse-item-categories").on('hidden.bs.collapse', function() {
inventreeDel('show-part-cats');
});
$("#cat-create").click(function() { $("#cat-create").click(function() {
launchModalForm( launchModalForm(

View File

@ -8,17 +8,34 @@
</a> </a>
</li> </li>
<li class='list-group-item {% if tab == "subcategories" %}active{% endif %}' title='{% trans "Subcategories" %}'>
{% if category %}
<a href='{% url "category-subcategory" category.id %}'>
{% else %}
<a href='{% url "category-index-subcategory" %}'>
{% endif %}
<span class='fas fa-sitemap'></span>
{% trans "Subcategories" %}
</a>
</li>
<li class='list-group-item {% if tab == "parts" %}active{% endif %}' title='{% trans "Parts" %}'> <li class='list-group-item {% if tab == "parts" %}active{% endif %}' title='{% trans "Parts" %}'>
{% if category %}
<a href='{% url "category-detail" category.id %}'> <a href='{% url "category-detail" category.id %}'>
{% else %}
<a href='{% url "part-index" %}'>
{% endif %}
<span class='fas fa-shapes'></span> <span class='fas fa-shapes'></span>
{% trans "Parts" %} {% trans "Parts" %}
</a> </a>
</li> </li>
{% if category %}
<li class='list-group-item {% if tab == "parameters" %}active{% endif %}' title='{% trans "Parameters" %}'> <li class='list-group-item {% if tab == "parameters" %}active{% endif %}' title='{% trans "Parameters" %}'>
<a href='{% url "category-parametric" category.id %}'> <a href='{% url "category-parametric" category.id %}'>
<span class='fas fa-tasks'></span> <span class='fas fa-tasks'></span>
{% trans "Parameters" %} {% trans "Parameters" %}
</a> </a>
</li> </li>
{% endif %}
</ul> </ul>

View File

@ -1,14 +1,15 @@
{% extends "base.html" %} {% extends "base.html" %}
{% load static %} {% load static %}
{% load i18n %} {% load i18n %}
{% load inventree_extras %}
{% block page_title %} {% block page_title %}
{% if part %} {% if part %}
InvenTree | {% trans "Part" %} - {{ part.full_name }} {% inventree_title %} | {% trans "Part" %} - {{ part.full_name }}
{% elif category %} {% elif category %}
InvenTree | {% trans "Part Category" %} - {{ category }} {% inventree_title %} | {% trans "Part Category" %} - {{ category }}
{% else %} {% else %}
InvenTree | {% trans "Part List" %} {% inventree_title %} | {% trans "Part List" %}
{% endif %} {% endif %}
{% endblock %} {% endblock %}

View File

@ -1,22 +0,0 @@
{% extends "collapse.html" %}
{% load i18n %}
{% block collapse_title %}
{{ children | length }} {% trans 'Child Categories' %}
{% endblock %}
{% block collapse_content %}
<ul class="list-group">
{% for child in children %}
<li class="list-group-item">
<strong><a href="{% url 'category-detail' child.id %}">{{ child.name }}</a></strong>
{% if child.description %}
<em> - {{ child.description }}</em>
{% endif %}
{% if child.partcount > 0 %}
<span class='badge'>{{ child.partcount }} {% trans 'Part' %}{% if child.partcount > 1 %}s{% endif %}</span>
{% endif %}
</li>
{% endfor %}
</ul>
{% endblock %}

View File

@ -0,0 +1,51 @@
{% extends "part/category.html" %}
{% load i18n %}
{% load inventree_extras %}
{% load static %}
{% block menubar %}
{% include 'part/category_navbar.html' with tab='subcategories' %}
{% endblock %}
{% block category_content %}
<div class='panel panel-default panel-inventree'>
<div class='panel-heading'>
<h4>{% trans "Subcategories" %}</h4>
</div>
<div id='button-toolbar'>
<div class='button-toolbar container-fluid' style='float: right;'>
<div class='filter-list' id='filter-list-category'>
<!-- An empty div in which the filter list will be constructed -->
</div>
</div>
</div>
<table class='table table-striped table-condensed' id='subcategory-table' data-toolbar='#button-toolbar'></table>
</div>
{% endblock %}
{% block js_ready %}
{{ block.super }}
enableNavbar({
label: 'category',
toggleId: '#category-menu-toggle',
});
loadPartCategoryTable($('#subcategory-table'), {
params: {
{% if category %}
parent: {{ category.pk }}
{% else %}
parent: 'null'
{% endif %}
}
});
{% endblock %}

View File

@ -6,6 +6,7 @@ import os
from django import template from django import template
from django.urls import reverse from django.urls import reverse
from django.utils.safestring import mark_safe from django.utils.safestring import mark_safe
from django.templatetags.static import StaticNode
from InvenTree import version, settings from InvenTree import version, settings
import InvenTree.helpers import InvenTree.helpers
@ -73,6 +74,12 @@ def inventree_instance_name(*args, **kwargs):
return version.inventreeInstanceName() return version.inventreeInstanceName()
@register.simple_tag()
def inventree_title(*args, **kwargs):
""" Return the title for the current instance - respecting the settings """
return version.inventreeInstanceTitle()
@register.simple_tag() @register.simple_tag()
def inventree_version(*args, **kwargs): def inventree_version(*args, **kwargs):
""" Return InvenTree version string """ """ Return InvenTree version string """
@ -174,3 +181,31 @@ def object_link(url_name, pk, ref):
ref_url = reverse(url_name, kwargs={'pk': pk}) ref_url = reverse(url_name, kwargs={'pk': pk})
return mark_safe('<b><a href="{}">{}</a></b>'.format(ref_url, ref)) return mark_safe('<b><a href="{}">{}</a></b>'.format(ref_url, ref))
class I18nStaticNode(StaticNode):
"""
custom StaticNode
replaces a variable named *lng* in the path with the current language
"""
def render(self, context):
self.path.var = self.path.var.format(lng=context.request.LANGUAGE_CODE)
ret = super().render(context)
return ret
@register.tag('i18n_static')
def do_i18n_static(parser, token):
"""
Overrides normal static, adds language - lookup for prerenderd files #1485
usage (like static):
{% i18n_static path [as varname] %}
"""
bits = token.split_contents()
loc_name = settings.STATICFILES_I18_PREFIX
# change path to called ressource
bits[1] = f"'{loc_name}/{{lng}}.{bits[1][1:-1]}'"
token.contents = ' '.join(bits)
return I18nStaticNode.handle_token(parser, token)

View File

@ -37,12 +37,54 @@ class PartAPITest(InvenTreeAPITestCase):
super().setUp() super().setUp()
def test_get_categories(self): def test_get_categories(self):
""" Test that we can retrieve list of part categories """ """
Test that we can retrieve list of part categories,
with various filtering options.
"""
url = reverse('api-part-category-list') url = reverse('api-part-category-list')
# Request *all* part categories
response = self.client.get(url, format='json') response = self.client.get(url, format='json')
self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(len(response.data), 8) self.assertEqual(len(response.data), 8)
# Request top-level part categories only
response = self.client.get(
url,
{
'parent': 'null',
},
format='json'
)
self.assertEqual(len(response.data), 2)
# Children of PartCategory<1>, cascade
response = self.client.get(
url,
{
'parent': 1,
'cascade': 'true',
},
format='json',
)
self.assertEqual(len(response.data), 5)
# Children of PartCategory<1>, do not cascade
response = self.client.get(
url,
{
'parent': 1,
'cascade': 'false',
},
format='json',
)
self.assertEqual(len(response.data), 3)
def test_add_categories(self): def test_add_categories(self):
""" Check that we can add categories """ """ Check that we can add categories """
data = { data = {

View File

@ -88,14 +88,26 @@ category_parameter_urls = [
url(r'^(?P<pid>\d+)/delete/', views.CategoryParameterTemplateDelete.as_view(), name='category-param-template-delete'), url(r'^(?P<pid>\d+)/delete/', views.CategoryParameterTemplateDelete.as_view(), name='category-param-template-delete'),
] ]
part_category_urls = [ category_urls = [
url(r'^edit/?', views.CategoryEdit.as_view(), name='category-edit'),
url(r'^delete/?', views.CategoryDelete.as_view(), name='category-delete'),
url(r'^parameters/', include(category_parameter_urls)), # Create a new category
url(r'^new/', views.CategoryCreate.as_view(), name='category-create'),
url(r'^parametric/?', views.CategoryParametric.as_view(), name='category-parametric'), # Top level subcategory display
url(r'^.*$', views.CategoryDetail.as_view(), name='category-detail'), url(r'^subcategory/', views.PartIndex.as_view(template_name='part/subcategory.html'), name='category-index-subcategory'),
# Category detail views
url(r'(?P<pk>\d+)/', include([
url(r'^edit/', views.CategoryEdit.as_view(), name='category-edit'),
url(r'^delete/', views.CategoryDelete.as_view(), name='category-delete'),
url(r'^parameters/', include(category_parameter_urls)),
url(r'^subcategory/', views.CategoryDetail.as_view(template_name='part/subcategory.html'), name='category-subcategory'),
url(r'^parametric/', views.CategoryParametric.as_view(), name='category-parametric'),
# Anything else
url(r'^.*$', views.CategoryDetail.as_view(), name='category-detail'),
]))
] ]
part_bom_urls = [ part_bom_urls = [
@ -106,9 +118,6 @@ part_bom_urls = [
# URL list for part web interface # URL list for part web interface
part_urls = [ part_urls = [
# Create a new category
url(r'^category/new/?', views.CategoryCreate.as_view(), name='category-create'),
# Create a new part # Create a new part
url(r'^new/?', views.PartCreate.as_view(), name='part-create'), url(r'^new/?', views.PartCreate.as_view(), name='part-create'),
@ -125,7 +134,7 @@ part_urls = [
url(r'^(?P<pk>\d+)/', include(part_detail_urls)), url(r'^(?P<pk>\d+)/', include(part_detail_urls)),
# Part category # Part category
url(r'^category/(?P<pk>\d+)/', include(part_category_urls)), url(r'^category/', include(category_urls)),
# Part related # Part related
url(r'^related-parts/', include(part_related_urls)), url(r'^related-parts/', include(part_related_urls)),

View File

@ -281,28 +281,46 @@ class StockLocationList(generics.ListCreateAPIView):
queryset = StockLocation.objects.all() queryset = StockLocation.objects.all()
serializer_class = LocationSerializer serializer_class = LocationSerializer
def get_queryset(self): def filter_queryset(self, queryset):
""" """
Custom filtering: Custom filtering:
- Allow filtering by "null" parent to retrieve top-level stock locations - Allow filtering by "null" parent to retrieve top-level stock locations
""" """
queryset = super().get_queryset() queryset = super().filter_queryset(queryset)
loc_id = self.request.query_params.get('parent', None) params = self.request.query_params
if loc_id is not None: loc_id = params.get('parent', None)
cascade = str2bool(params.get('cascade', False))
# Look for top-level locations # Do not filter by location
if isNull(loc_id): if loc_id is None:
pass
# Look for top-level locations
elif isNull(loc_id):
# If we allow "cascade" at the top-level, this essentially means *all* locations
if not cascade:
queryset = queryset.filter(parent=None) queryset = queryset.filter(parent=None)
else: else:
try:
loc_id = int(loc_id) try:
queryset = queryset.filter(parent=loc_id) location = StockLocation.objects.get(pk=loc_id)
except ValueError:
pass # All sub-locations to be returned too?
if cascade:
parents = location.get_descendants(include_self=True)
parent_ids = [p.id for p in parents]
queryset = queryset.filter(parent__in=parent_ids)
else:
queryset = queryset.filter(parent=location)
except (ValueError, StockLocation.DoesNotExist):
pass
return queryset return queryset
@ -320,6 +338,11 @@ class StockLocationList(generics.ListCreateAPIView):
'description', 'description',
] ]
ordering_fields = [
'name',
'items',
]
class StockList(generics.ListCreateAPIView): class StockList(generics.ListCreateAPIView):
""" API endpoint for list view of Stock objects """ API endpoint for list view of Stock objects

View File

@ -634,6 +634,7 @@ class StockItem(MPTTModel):
self.customer = None self.customer = None
self.location = location self.location = location
self.sales_order = None
self.save() self.save()

View File

@ -5,7 +5,7 @@
{% load i18n %} {% load i18n %}
{% block page_title %} {% block page_title %}
InvenTree | {% trans "Stock Item" %} - {{ item }} {% inventree_title %} | {% trans "Stock Item" %} - {{ item }}
{% endblock %} {% endblock %}
{% block sidenav %} {% block sidenav %}

View File

@ -30,7 +30,7 @@
loadStockTable($("#stock-table"), { loadStockTable($("#stock-table"), {
params: { params: {
location_detail: true, location_detail: true,
part_details: true, part_detail: false,
ancestor: {{ item.id }}, ancestor: {{ item.id }},
}, },
name: 'item-childs', name: 'item-childs',

View File

@ -2,8 +2,15 @@
{% load static %} {% load static %}
{% load inventree_extras %} {% load inventree_extras %}
{% load i18n %} {% load i18n %}
{% block menubar %}
{% include "stock/location_navbar.html" with tab="stock" %}
{% endblock %}
{% block content %} {% block content %}
<div class='panel panel-default panel-inventree'>
{% setting_object 'STOCK_OWNERSHIP_CONTROL' as owner_control %} {% setting_object 'STOCK_OWNERSHIP_CONTROL' as owner_control %}
{% if owner_control.value == "True" %} {% if owner_control.value == "True" %}
{% authorized_owners location.owner as owners %} {% authorized_owners location.owner as owners %}
@ -120,36 +127,29 @@
</div> </div>
</h3> </h3>
</div> </div>
</div>
{% if location and location.children.all|length > 0 %} {% block location_content %}
{% include 'stock/location_list.html' with children=location.children.all collapse_id="locations" %}
{% elif locations|length > 0 %}
{% include 'stock/location_list.html' with children=locations collapse_id="locations" %}
{% endif %}
<hr> <div class='panel panel-default panel-inventree'>
<div class='panel-heading'>
{% include "stock_table.html" %} <h4>{% trans "Stock Items" %}</h4>
</div>
{% include "stock_table.html" %}
</div> </div>
{% endblock %} {% endblock %}
{% block js_load %} </div>
{{ block.super }}
{% endblock %} {% endblock %}
{% block js_ready %} {% block js_ready %}
{{ block.super }} {{ block.super }}
if (inventreeLoadInt("show-part-locs") == 1) { enableNavbar({
$("#collapse-item-locations").collapse('show'); label: 'location',
} toggleId: '#location-menu-toggle'
$("#collapse-item-locations").on('shown.bs.collapse', function() {
inventreeSave('show-part-locs', 1);
});
$("#collapse-item-locations").on('hidden.bs.collapse', function() {
inventreeDel('show-part-locs');
}); });
{% if location %} {% if location %}
@ -261,7 +261,7 @@
], ],
params: { params: {
{% if location %} {% if location %}
location: {{ location.id }}, location: {{ location.pk }},
{% endif %} {% endif %}
part_detail: true, part_detail: true,
location_detail: true, location_detail: true,

View File

@ -1,24 +0,0 @@
{% extends "collapse.html" %}
{% load i18n %}
{% if roles.stock_location.view or roles.stock.view %}
{% block collapse_title %}
{% trans 'Sub-Locations' %}<span class='badge'>{{ children|length }}</span>
{% endblock %}
{% block collapse_content %}
<ul class="list-group">
{% for child in children %}
<li class="list-group-item"><a href="{% url 'stock-location-detail' child.id %}">{{ child.name }}</a> - <i>{{ child.description }}</i>
{% if child.item_count > 0 %}
<!-- span class='badge'>{{ child.item_count }} Item{% if child.item_count > 1 %}s{% endif %}</span> -->
<span class='badge'>
{% comment %}Translators: pluralize with counter{% endcomment %}
{% blocktrans count counter=child.item_count %}{{ counter }} Item{% plural %}{{ counter }} Items{% endblocktrans %}
</span>
{% endif %}
</li>
{% endfor %}
</ul>
{% endblock %}
{% endif %}

View File

@ -0,0 +1,33 @@
{% load i18n %}
<ul class='list-group'>
<li class='list-group-item'>
<a href='#' id='location-menu-toggle'>
<span class='menu-tab-icon fas fa-expand-arrows-alt'></span>
</a>
</li>
<li class='list-group-item {% if tab == "sublocations" %}active{% endif %}' title='{% trans "Sublocations" %}'>
{% if location %}
<a href='{% url "stock-location-sublocation" location.id %}'>
{% else %}
<a href='{% url "stock-sublocations" %}'>
{% endif %}
<span class='fas fa-sitemap'></span>
{% trans "Sublocations" %}
</a>
</li>
<li class='list-group-item {% if tab == "stock" %}active{% endif %}' title='{% trans "Stock Items" %}'>
{% if location %}
<a href='{% url "stock-location-detail" location.id %}'>
{% else %}
<a href='{% url "stock-index" %}'>
{% endif %}
<span class='fas fa-boxes'></span>
{% trans "Stock Items" %}
</a>
</li>
</ul>

View File

@ -1,12 +1,13 @@
{% extends "base.html" %} {% extends "base.html" %}
{% load static %} {% load static %}
{% load i18n %} {% load i18n %}
{% load inventree_extras %}
{% block page_title %} {% block page_title %}
{% if location %} {% if location %}
InvenTree | {% trans "Stock Location" %} - {{ location }} {% inventree_title %} | {% trans "Stock Location" %} - {{ location }}
{% else %} {% else %}
InvenTree | {% trans "Stock" %} {% inventree_title %} | {% trans "Stock" %}
{% endif %} {% endif %}
{% endblock %} {% endblock %}

View File

@ -0,0 +1,74 @@
{% extends "stock/location.html" %}
{% load static %}
{% load i18n %}
{% load inventree_extras %}
{% block menubar %}
{% include "stock/location_navbar.html" with tab="sublocations" %}
{% endblock %}
{% block location_content %}
<div class='panel panel-default panel-inventree'>
<div class='panel-heading'>
<h4>{% trans "Sublocations" %}</h4>
</div>
<div id='button-toolbar'>
<div class='button-toolbar container-fluid' style='float: right;'>
<!-- Printing actions menu -->
<div class='btn-group'>
<button id='location-print-options' class='btn btn-primary dropdown-toggle' type='button' data-toggle="dropdown" title='{% trans "Printing Actions" %}'>
<span class='fas fa-print'></span> <span class='caret'></span>
</button>
<ul class='dropdown-menu'>
<li><a href='#' id='multi-location-print-label' title='{% trans "Print labels" %}'><span class='fas fa-tags'></span> {% trans "Print labels" %}</a></li>
</ul>
</div>
<div class='filter-list' id='filter-list-location'>
<!-- An empty div in which the filter list will be constructed -->
</div>
</div>
</div>
<table class='table table-striped table-condensed' data-toolbar='#button-toolbar' id='sublocation-table'></table>
</div>
{% endblock %}
{% block js_ready %}
{{ block.super }}
loadStockLocationTable($('#sublocation-table'), {
params: {
{% if location %}
parent: {{ location.pk }},
{% else %}
parent: 'null',
{% endif %}
}
});
linkButtonsToSelection(
$('#sublocation-table'),
[
'#location-print-options',
]
);
$('#multi-location-print-label').click(function() {
var selections = $('#sublocation-table').bootstrapTable('getSelections');
var locations = [];
selections.forEach(function(loc) {
locations.push(loc.pk);
});
printStockLocationLabels(locations);
})
{% endblock %}

View File

@ -6,14 +6,21 @@ from django.conf.urls import url, include
from . import views from . import views
# URL list for web interface location_urls = [
stock_location_detail_urls = [
url(r'^edit/?', views.StockLocationEdit.as_view(), name='stock-location-edit'), url(r'^new/', views.StockLocationCreate.as_view(), name='stock-location-create'),
url(r'^delete/?', views.StockLocationDelete.as_view(), name='stock-location-delete'),
url(r'^qr_code/?', views.StockLocationQRCode.as_view(), name='stock-location-qr'), url(r'^(?P<pk>\d+)/', include([
url(r'^edit/?', views.StockLocationEdit.as_view(), name='stock-location-edit'),
url(r'^delete/?', views.StockLocationDelete.as_view(), name='stock-location-delete'),
url(r'^qr_code/?', views.StockLocationQRCode.as_view(), name='stock-location-qr'),
url(r'sublocation/', views.StockLocationDetail.as_view(template_name='stock/sublocation.html'), name='stock-location-sublocation'),
# Anything else
url('^.*$', views.StockLocationDetail.as_view(), name='stock-location-detail'),
])),
# Anything else
url('^.*$', views.StockLocationDetail.as_view(), name='stock-location-detail'),
] ]
stock_item_detail_urls = [ stock_item_detail_urls = [
@ -49,9 +56,7 @@ stock_tracking_urls = [
stock_urls = [ stock_urls = [
# Stock location # Stock location
url(r'^location/(?P<pk>\d+)/', include(stock_location_detail_urls)), url(r'^location/', include(location_urls)),
url(r'^location/new/', views.StockLocationCreate.as_view(), name='stock-location-create'),
url(r'^item/new/?', views.StockItemCreate.as_view(), name='stock-item-create'), url(r'^item/new/?', views.StockItemCreate.as_view(), name='stock-item-create'),
@ -81,5 +86,7 @@ stock_urls = [
# Individual stock items # Individual stock items
url(r'^item/(?P<pk>\d+)/', include(stock_item_detail_urls)), url(r'^item/(?P<pk>\d+)/', include(stock_item_detail_urls)),
url(r'^sublocations/', views.StockIndex.as_view(template_name='stock/sublocation.html'), name='stock-sublocations'),
url(r'^.*$', views.StockIndex.as_view(), name='stock-index'), url(r'^.*$', views.StockIndex.as_view(), name='stock-index'),
] ]

View File

@ -2,12 +2,13 @@
{% load i18n %} {% load i18n %}
{% load static %} {% load static %}
{% load inventree_extras %} {% load inventree_extras %}
{% block page_title %} {% block page_title %}
InvenTree | {% trans "Index" %} {% inventree_title %} | {% trans "Index" %}
{% endblock %} {% endblock %}
{% block content %} {% block content %}
<h3>InvenTree</h3> <h3>{% inventree_title %} </h3>
<hr> <hr>
<div class='col-sm-3' id='item-panel'> <div class='col-sm-3' id='item-panel'>

View File

@ -5,7 +5,7 @@
{% load inventree_extras %} {% load inventree_extras %}
{% block page_title %} {% block page_title %}
InvenTree | {% trans "Search Results" %} {% inventree_title %} | {% trans "Search Results" %}
{% endblock %} {% endblock %}
{% block content %} {% block content %}

View File

@ -16,6 +16,7 @@
{% include "InvenTree/settings/header.html" %} {% include "InvenTree/settings/header.html" %}
<tbody> <tbody>
{% include "InvenTree/settings/setting.html" with key="INVENTREE_INSTANCE" icon="fa-info-circle" %} {% include "InvenTree/settings/setting.html" with key="INVENTREE_INSTANCE" icon="fa-info-circle" %}
{% include "InvenTree/settings/setting.html" with key="INVENTREE_INSTANCE_TITLE" icon="fa-info-circle" %}
{% include "InvenTree/settings/setting.html" with key="INVENTREE_BASE_URL" icon="fa-globe" %} {% include "InvenTree/settings/setting.html" with key="INVENTREE_BASE_URL" icon="fa-globe" %}
{% include "InvenTree/settings/setting.html" with key="INVENTREE_COMPANY_NAME" icon="fa-building" %} {% include "InvenTree/settings/setting.html" with key="INVENTREE_COMPANY_NAME" icon="fa-building" %}
{% include "InvenTree/settings/setting.html" with key="INVENTREE_DEFAULT_CURRENCY" icon="fa-dollar-sign" %} {% include "InvenTree/settings/setting.html" with key="INVENTREE_DEFAULT_CURRENCY" icon="fa-dollar-sign" %}

View File

@ -2,9 +2,10 @@
{% load i18n %} {% load i18n %}
{% load static %} {% load static %}
{% load inventree_extras %}
{% block page_title %} {% block page_title %}
InvenTree | {% trans "Settings" %} {% inventree_title %} | {% trans "Settings" %}
{% endblock %} {% endblock %}
{% block content %} {% block content %}

View File

@ -60,7 +60,7 @@
<title> <title>
{% block page_title %} {% block page_title %}
InvenTree {% inventree_title %}
{% endblock %} {% endblock %}
</title> </title>
</head> </head>
@ -144,20 +144,21 @@ InvenTree
<script type='text/javascript' src="{% static 'script/inventree/notification.js' %}"></script> <script type='text/javascript' src="{% static 'script/inventree/notification.js' %}"></script>
<script type='text/javascript' src="{% static 'script/inventree/sidenav.js' %}"></script> <script type='text/javascript' src="{% static 'script/inventree/sidenav.js' %}"></script>
<script type='text/javascript' src="{% url 'barcode.js' %}"></script> <!-- translated -->
<script type='text/javascript' src="{% url 'bom.js' %}"></script> <script type='text/javascript' src="{% i18n_static 'barcode.js' %}"></script>
<script type='text/javascript' src="{% url 'company.js' %}"></script> <script type='text/javascript' src="{% i18n_static 'bom.js' %}"></script>
<script type='text/javascript' src="{% url 'part.js' %}"></script> <script type='text/javascript' src="{% i18n_static 'company.js' %}"></script>
<script type='text/javascript' src="{% url 'modals.js' %}"></script> <script type='text/javascript' src="{% i18n_static 'part.js' %}"></script>
<script type='text/javascript' src="{% url 'label.js' %}"></script> <script type='text/javascript' src="{% i18n_static 'modals.js' %}"></script>
<script type='text/javascript' src="{% url 'report.js' %}"></script> <script type='text/javascript' src="{% i18n_static 'label.js' %}"></script>
<script type='text/javascript' src="{% url 'stock.js' %}"></script> <script type='text/javascript' src="{% i18n_static 'report.js' %}"></script>
<script type='text/javascript' src="{% url 'build.js' %}"></script> <script type='text/javascript' src="{% i18n_static 'stock.js' %}"></script>
<script type='text/javascript' src="{% url 'order.js' %}"></script> <script type='text/javascript' src="{% i18n_static 'build.js' %}"></script>
<script type='text/javascript' src="{% url 'calendar.js' %}"></script> <script type='text/javascript' src="{% i18n_static 'order.js' %}"></script>
<script type='text/javascript' src="{% url 'tables.js' %}"></script> <script type='text/javascript' src="{% i18n_static 'calendar.js' %}"></script>
<script type='text/javascript' src="{% url 'table_filters.js' %}"></script> <script type='text/javascript' src="{% i18n_static 'tables.js' %}"></script>
<script type='text/javascript' src="{% url 'filters.js' %}"></script> <script type='text/javascript' src="{% i18n_static 'table_filters.js' %}"></script>
<script type='text/javascript' src="{% i18n_static 'filters.js' %}"></script>
<script type='text/javascript' src="{% static 'fontawesome/js/solid.js' %}"></script> <script type='text/javascript' src="{% static 'fontawesome/js/solid.js' %}"></script>
<script type='text/javascript' src="{% static 'fontawesome/js/brands.js' %}"></script> <script type='text/javascript' src="{% static 'fontawesome/js/brands.js' %}"></script>

View File

@ -32,12 +32,17 @@ function newBuildOrder(options={}) {
} }
function makeBuildOutputActionButtons(output, buildInfo) { function makeBuildOutputActionButtons(output, buildInfo, lines) {
/* Generate action buttons for a build output. /* Generate action buttons for a build output.
*/ */
var buildId = buildInfo.pk; var buildId = buildInfo.pk;
var outputId = output.pk;
if (output) {
outputId = output.pk;
} else {
outputId = 'untracked';
}
var panel = `#allocation-panel-${outputId}`; var panel = `#allocation-panel-${outputId}`;
@ -50,35 +55,42 @@ function makeBuildOutputActionButtons(output, buildInfo) {
var html = `<div class='btn-group float-right' role='group'>`; var html = `<div class='btn-group float-right' role='group'>`;
// Add a button to "auto allocate" against the build // "Auto" allocation only works for untracked stock items
html += makeIconButton( if (!output && lines > 0) {
'fa-magic icon-blue', 'button-output-auto', outputId, html += makeIconButton(
'{% trans "Auto-allocate stock items to this output" %}', 'fa-magic icon-blue', 'button-output-auto', outputId,
); '{% trans "Auto-allocate stock items to this output" %}',
);
}
// Add a button to "complete" the particular build output if (lines > 0) {
html += makeIconButton( // Add a button to "cancel" the particular build output (unallocate)
'fa-check icon-green', 'button-output-complete', outputId, html += makeIconButton(
'{% trans "Complete build output" %}', 'fa-minus-circle icon-red', 'button-output-unallocate', outputId,
{ '{% trans "Unallocate stock from build output" %}',
//disabled: true );
} }
);
// Add a button to "cancel" the particular build output (unallocate)
html += makeIconButton(
'fa-minus-circle icon-red', 'button-output-unallocate', outputId,
'{% trans "Unallocate stock from build output" %}',
);
// Add a button to "delete" the particular build output if (output) {
html += makeIconButton(
'fa-trash-alt icon-red', 'button-output-delete', outputId,
'{% trans "Delete build output" %}',
);
// Add a button to "destroy" the particular build output (mark as damaged, scrap) // Add a button to "complete" the particular build output
// TODO html += makeIconButton(
'fa-check icon-green', 'button-output-complete', outputId,
'{% trans "Complete build output" %}',
{
//disabled: true
}
);
// Add a button to "delete" the particular build output
html += makeIconButton(
'fa-trash-alt icon-red', 'button-output-delete', outputId,
'{% trans "Delete build output" %}',
);
// TODO - Add a button to "destroy" the particular build output (mark as damaged, scrap)
}
html += '</div>'; html += '</div>';
@ -90,7 +102,6 @@ function makeBuildOutputActionButtons(output, buildInfo) {
launchModalForm(`/build/${buildId}/auto-allocate/`, launchModalForm(`/build/${buildId}/auto-allocate/`,
{ {
data: { data: {
output: outputId,
}, },
success: reloadTable, success: reloadTable,
} }
@ -98,11 +109,14 @@ function makeBuildOutputActionButtons(output, buildInfo) {
}); });
$(panel).find(`#button-output-complete-${outputId}`).click(function() { $(panel).find(`#button-output-complete-${outputId}`).click(function() {
var pk = $(this).attr('pk');
launchModalForm( launchModalForm(
`/build/${buildId}/complete-output/`, `/build/${buildId}/complete-output/`,
{ {
data: { data: {
output: outputId, output: pk,
}, },
reload: true, reload: true,
} }
@ -110,24 +124,30 @@ function makeBuildOutputActionButtons(output, buildInfo) {
}); });
$(panel).find(`#button-output-unallocate-${outputId}`).click(function() { $(panel).find(`#button-output-unallocate-${outputId}`).click(function() {
var pk = $(this).attr('pk');
launchModalForm( launchModalForm(
`/build/${buildId}/unallocate/`, `/build/${buildId}/unallocate/`,
{ {
success: reloadTable, success: reloadTable,
data: { data: {
output: outputId, output: pk,
} }
} }
); );
}); });
$(panel).find(`#button-output-delete-${outputId}`).click(function() { $(panel).find(`#button-output-delete-${outputId}`).click(function() {
var pk = $(this).attr('pk');
launchModalForm( launchModalForm(
`/build/${buildId}/delete-output/`, `/build/${buildId}/delete-output/`,
{ {
reload: true, reload: true,
data: { data: {
output: outputId output: pk
} }
} }
); );
@ -152,13 +172,21 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
var outputId = null; var outputId = null;
outputId = output.pk; if (output) {
outputId = output.pk;
} else {
outputId = 'untracked';
}
var table = options.table; var table = options.table;
if (options.table == null) { if (options.table == null) {
table = `#allocation-table-${outputId}`; table = `#allocation-table-${outputId}`;
} }
// If an "output" is specified, then only "trackable" parts are allocated
// Otherwise, only "untrackable" parts are allowed
var trackable = ! !output;
function reloadTable() { function reloadTable() {
// Reload the entire build allocation table // Reload the entire build allocation table
@ -168,7 +196,13 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
function requiredQuantity(row) { function requiredQuantity(row) {
// Return the requied quantity for a given row // Return the requied quantity for a given row
return row.quantity * output.quantity; if (output) {
// "Tracked" parts are calculated against individual build outputs
return row.quantity * output.quantity;
} else {
// "Untracked" parts are specified against the build itself
return row.quantity * buildInfo.quantity;
}
} }
function sumAllocations(row) { function sumAllocations(row) {
@ -300,6 +334,7 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
queryParams: { queryParams: {
part: partId, part: partId,
sub_part_detail: true, sub_part_detail: true,
sub_part_trackable: trackable,
}, },
formatNoMatches: function() { formatNoMatches: function() {
return '{% trans "No BOM items found" %}'; return '{% trans "No BOM items found" %}';
@ -310,11 +345,19 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
onLoadSuccess: function(tableData) { onLoadSuccess: function(tableData) {
// Once the BOM data are loaded, request allocation data for this build output // Once the BOM data are loaded, request allocation data for this build output
var params = {
build: buildId,
}
if (output) {
params.sub_part_trackable = true;
params.output = outputId;
} else {
params.sub_part_trackable = false;
}
inventreeGet('/api/build/item/', inventreeGet('/api/build/item/',
{ params,
build: buildId,
output: outputId,
},
{ {
success: function(data) { success: function(data) {
// Iterate through the returned data, and group by the part they point to // Iterate through the returned data, and group by the part they point to
@ -355,8 +398,16 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
// Calculate the total allocated quantity // Calculate the total allocated quantity
var allocatedQuantity = sumAllocations(tableRow); var allocatedQuantity = sumAllocations(tableRow);
var requiredQuantity = 0;
if (output) {
requiredQuantity = tableRow.quantity * output.quantity;
} else {
requiredQuantity = tableRow.quantity * buildInfo.quantity;
}
// Is this line item fully allocated? // Is this line item fully allocated?
if (allocatedQuantity >= (tableRow.quantity * output.quantity)) { if (allocatedQuantity >= requiredQuantity) {
allocatedLines += 1; allocatedLines += 1;
} }
@ -367,16 +418,21 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
// Update the total progress for this build output // Update the total progress for this build output
var buildProgress = $(`#allocation-panel-${outputId}`).find($(`#output-progress-${outputId}`)); var buildProgress = $(`#allocation-panel-${outputId}`).find($(`#output-progress-${outputId}`));
var progress = makeProgressBar( if (totalLines > 0) {
allocatedLines,
totalLines
);
buildProgress.html(progress); var progress = makeProgressBar(
allocatedLines,
totalLines
);
buildProgress.html(progress);
} else {
buildProgress.html('');
}
// Update the available actions for this build output // Update the available actions for this build output
makeBuildOutputActionButtons(output, buildInfo); makeBuildOutputActionButtons(output, buildInfo, totalLines);
} }
} }
); );
@ -600,6 +656,9 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
}, },
] ]
}); });
// Initialize the action buttons
makeBuildOutputActionButtons(output, buildInfo, 0);
} }
@ -654,7 +713,7 @@ function loadBuildTable(table, options) {
field: 'reference', field: 'reference',
title: '{% trans "Build" %}', title: '{% trans "Build" %}',
sortable: true, sortable: true,
switchable: false, switchable: true,
formatter: function(value, row, index, field) { formatter: function(value, row, index, field) {
var prefix = "{% settings_value 'BUILDORDER_REFERENCE_PREFIX' %}"; var prefix = "{% settings_value 'BUILDORDER_REFERENCE_PREFIX' %}";
@ -675,6 +734,7 @@ function loadBuildTable(table, options) {
{ {
field: 'title', field: 'title',
title: '{% trans "Description" %}', title: '{% trans "Description" %}',
switchable: true,
}, },
{ {
field: 'part', field: 'part',
@ -725,7 +785,7 @@ function loadBuildTable(table, options) {
}, },
{ {
field: 'completion_date', field: 'completion_date',
title: '{% trans "Completed" %}', title: '{% trans "Completion Date" %}',
sortable: true, sortable: true,
}, },
], ],

View File

@ -1,4 +1,5 @@
{% load i18n %} {% load i18n %}
{% load inventree_extras %}
/* Part API functions /* Part API functions
* Requires api.js to be loaded first * Requires api.js to be loaded first
@ -506,6 +507,82 @@ function loadPartTable(table, url, options={}) {
}); });
} }
function loadPartCategoryTable(table, options) {
/* Display a table of part categories */
var params = options.params || {};
var filterListElement = options.filterList || '#filter-list-category';
var filters = {};
var filterKey = options.filterKey || options.name || 'category';
if (!options.disableFilters) {
filters = loadTableFilters(filterKey);
}
var original = {};
for (var key in params) {
original[key] = params[key];
filters[key] = params[key];
}
setupFilterList(filterKey, table, filterListElement);
table.inventreeTable({
method: 'get',
url: options.url || '{% url "api-part-category-list" %}',
queryParams: filters,
sidePagination: 'server',
name: 'category',
original: original,
showColumns: true,
columns: [
{
checkbox: true,
title: '{% trans "Select" %}',
searchable: false,
switchable: false,
visible: false,
},
{
field: 'name',
title: '{% trans "Name" %}',
switchable: true,
sortable: true,
formatter: function(value, row) {
return renderLink(
value,
`/part/category/${row.pk}/`
);
}
},
{
field: 'description',
title: '{% trans "Description" %}',
switchable: true,
sortable: false,
},
{
field: 'pathstring',
title: '{% trans "Path" %}',
switchable: true,
sortable: false,
},
{
field: 'parts',
title: '{% trans "Parts" %}',
switchable: true,
sortable: false,
}
]
});
}
function yesNoLabel(value) { function yesNoLabel(value) {
if (value) { if (value) {
return `<span class='label label-green'>{% trans "YES" %}</span>`; return `<span class='label label-green'>{% trans "YES" %}</span>`;

View File

@ -897,6 +897,83 @@ function loadStockTable(table, options) {
}); });
} }
function loadStockLocationTable(table, options) {
/* Display a table of stock locations */
var params = options.params || {};
var filterListElement = options.filterList || '#filter-list-location';
var filters = {};
var filterKey = options.filterKey || options.name || 'location';
if (!options.disableFilters) {
filters = loadTableFilters(filterKey);
}
var original = {};
for (var key in params) {
original[key] = params[key];
}
setupFilterList(filterKey, table, filterListElement);
for (var key in params) {
filters[key] = params[key];
}
table.inventreeTable({
method: 'get',
url: options.url || '{% url "api-location-list" %}',
queryParams: filters,
sidePagination: 'server',
name: 'location',
original: original,
showColumns: true,
columns: [
{
checkbox: true,
title: '{% trans "Select" %}',
searchable: false,
switchable: false,
},
{
field: 'name',
title: '{% trans "Name" %}',
switchable: true,
sortable: true,
formatter: function(value, row) {
return renderLink(
value,
`/stock/location/${row.pk}/`
);
},
},
{
field: 'description',
title: '{% trans "Description" %}',
switchable: true,
sortable: false,
},
{
field: 'pathstring',
title: '{% trans "Path" %}',
switchable: true,
sortable: false,
},
{
field: 'items',
title: '{% trans "Stock Items" %}',
switchable: true,
sortable: false,
sortName: 'item_count',
}
]
});
}
function loadStockTrackingTable(table, options) { function loadStockTrackingTable(table, options) {
var cols = [ var cols = [

View File

@ -62,6 +62,28 @@ function getAvailableTableFilters(tableKey) {
}; };
} }
// Filters for "stock location" table
if (tableKey == "location") {
return {
cascade: {
type: 'bool',
title: '{% trans "Include sublocations" %}',
description: '{% trans "Include locations" %}',
}
};
}
// Filters for "part category" table
if (tableKey == "category") {
return {
cascade: {
type: 'bool',
title: '{% trans "Include subcategories" %}',
description: '{% trans "Include subcategories" %}',
}
};
}
// Filters for the "customer stock" table (really a subset of "stock") // Filters for the "customer stock" table (really a subset of "stock")
if (tableKey == "customerstock") { if (tableKey == "customerstock") {
return { return {

View File

@ -1,6 +1,7 @@
{% load static %} {% load static %}
{% load i18n %} {% load i18n %}
{% load crispy_forms_tags %} {% load crispy_forms_tags %}
{% load inventree_extras %}
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
@ -29,7 +30,7 @@
</style> </style>
<title> <title>
InvenTree {% inventree_title %}
</title> </title>
</head> </head>
@ -42,7 +43,7 @@
<div class='container-fluid'> <div class='container-fluid'>
<div class='clearfix content-heading login-header'> <div class='clearfix content-heading login-header'>
<img class="pull-left" src="{% static 'img/inventree.png' %}" width="60" height="60"/> <img class="pull-left" src="{% static 'img/inventree.png' %}" width="60" height="60"/>
<span><h3>InvenTree</h3></span> <span><h3>{% inventree_title %} </h3></span>
</div> </div>
<hr> <hr>

View File

@ -1,5 +1,6 @@
{% load static %} {% load static %}
{% load i18n %} {% load i18n %}
{% load inventree_extras %}
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
@ -28,7 +29,7 @@
</style> </style>
<title> <title>
InvenTree {% inventree_title %}
</title> </title>
</head> </head>
@ -44,7 +45,7 @@
<div class='container-fluid'> <div class='container-fluid'>
<div class='clearfix content-heading login-header'> <div class='clearfix content-heading login-header'>
<img class="pull-left" src="{% static 'img/inventree.png' %}" width="60" height="60"/> <img class="pull-left" src="{% static 'img/inventree.png' %}" width="60" height="60"/>
<span><h3>InvenTree</h3></span> <span><h3>{% inventree_title %} </h3></span>
</div> </div>
<hr> <hr>

View File

@ -1,6 +1,7 @@
{% load static %} {% load static %}
{% load i18n %} {% load i18n %}
{% load crispy_forms_tags %} {% load crispy_forms_tags %}
{% load inventree_extras %}
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
@ -29,7 +30,7 @@
</style> </style>
<title> <title>
InvenTree {% inventree_title %}
</title> </title>
</head> </head>
@ -42,7 +43,7 @@
<div class='container-fluid'> <div class='container-fluid'>
<div class='clearfix content-heading login-header'> <div class='clearfix content-heading login-header'>
<img class="pull-left" src="{% static 'img/inventree.png' %}" width="60" height="60"/> <img class="pull-left" src="{% static 'img/inventree.png' %}" width="60" height="60"/>
<span><h3>InvenTree</h3></span> <span><h3>{% inventree_title %} </h3></span>
</div> </div>
<hr> <hr>

View File

@ -1,6 +1,7 @@
{% load static %} {% load static %}
{% load i18n %} {% load i18n %}
{% load crispy_forms_tags %} {% load crispy_forms_tags %}
{% load inventree_extras %}
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
@ -29,7 +30,7 @@
</style> </style>
<title> <title>
InvenTree {% inventree_title %}
</title> </title>
</head> </head>
@ -42,7 +43,7 @@
<div class='container-fluid'> <div class='container-fluid'>
<div class='clearfix content-heading login-header'> <div class='clearfix content-heading login-header'>
<img class="pull-left" src="{% static 'img/inventree.png' %}" width="60" height="60"/> <img class="pull-left" src="{% static 'img/inventree.png' %}" width="60" height="60"/>
<span><h3>InvenTree</h3></span> <span><h3>{% inventree_title %} </h3></span>
</div> </div>
<hr> <hr>

View File

@ -1,6 +1,7 @@
{% load static %} {% load static %}
{% load i18n %} {% load i18n %}
{% load crispy_forms_tags %} {% load crispy_forms_tags %}
{% load inventree_extras %}
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
@ -29,7 +30,7 @@
</style> </style>
<title> <title>
InvenTree {% inventree_title %}
</title> </title>
</head> </head>
@ -42,7 +43,7 @@
<div class='container-fluid'> <div class='container-fluid'>
<div class='clearfix content-heading login-header'> <div class='clearfix content-heading login-header'>
<img class="pull-left" src="{% static 'img/inventree.png' %}" width="60" height="60"/> <img class="pull-left" src="{% static 'img/inventree.png' %}" width="60" height="60"/>
<span><h3>InvenTree</h3></span> <span><h3>{% inventree_title %} </h3></span>
</div> </div>
<hr> <hr>
<div class='container-fluid'> <div class='container-fluid'>

View File

@ -1,6 +1,7 @@
{% load static %} {% load static %}
{% load i18n %} {% load i18n %}
{% load crispy_forms_tags %} {% load crispy_forms_tags %}
{% load inventree_extras %}
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
@ -29,7 +30,7 @@
</style> </style>
<title> <title>
InvenTree {% inventree_title %}
</title> </title>
</head> </head>
@ -42,7 +43,7 @@
<div class='container-fluid'> <div class='container-fluid'>
<div class='clearfix content-heading login-header'> <div class='clearfix content-heading login-header'>
<img class="pull-left" src="{% static 'img/inventree.png' %}" width="60" height="60"/> <img class="pull-left" src="{% static 'img/inventree.png' %}" width="60" height="60"/>
<span><h3>InvenTree</h3></span> <span><h3>{% inventree_title %} </h3></span>
</div> </div>
<hr> <hr>

View File

@ -32,6 +32,7 @@
</ul> </ul>
</div> </div>
{% endif %} {% endif %}
<!-- Printing actions menu -->
<div class='btn-group'> <div class='btn-group'>
<button id='stock-print-options' class='btn btn-primary dropdown-toggle' type='button' data-toggle="dropdown" title='{% trans "Printing Actions" %}'> <button id='stock-print-options' class='btn btn-primary dropdown-toggle' type='button' data-toggle="dropdown" title='{% trans "Printing Actions" %}'>
<span class='fas fa-print'></span> <span class='caret'></span> <span class='fas fa-print'></span> <span class='caret'></span>

View File

@ -9,8 +9,12 @@
{% block content %} {% block content %}
{% block header_panel %}
<div class='panel panel-default panel-inventree'> <div class='panel panel-default panel-inventree'>
{% block header_pre_content %}
{% endblock %}
<div class='row'> <div class='row'>
<div class='col-sm-6'> <div class='col-sm-6'>
<div class='media-left'> <div class='media-left'>
@ -30,8 +34,14 @@
{% endblock %} {% endblock %}
</div> </div>
</div> </div>
</div>
{% block header_post_content %}
{% endblock %}
</div>
{% endblock %}
{% block content_panels %}
<div class='panel panel-default panel-inventree'> <div class='panel panel-default panel-inventree'>
<div class='panel-heading'> <div class='panel-heading'>
<h4> <h4>
@ -41,12 +51,15 @@
</h4> </h4>
</div> </div>
{% block details_panel %}
<div class='panel-content'> <div class='panel-content'>
{% block details %} {% block details %}
<!-- Particular page detail views go here --> <!-- Particular page detail views go here -->
{% endblock %} {% endblock %}
</div> </div>
{% endblock %}
</div> </div>
{% endblock %}
{% endblock %} {% endblock %}

View File

@ -57,6 +57,7 @@ class RuleSet(models.Model):
'auth_user', 'auth_user',
'auth_permission', 'auth_permission',
'authtoken_token', 'authtoken_token',
'authtoken_tokenproxy',
'users_ruleset', 'users_ruleset',
], ],
'part_category': [ 'part_category': [
@ -199,7 +200,8 @@ class RuleSet(models.Model):
if check_user_role(user, role, permission): if check_user_role(user, role, permission):
return True return True
print("failed permission check for", table, permission) # Print message instead of throwing an error
print("Failed permission check for", table, permission)
return False return False
@staticmethod @staticmethod

View File

@ -1,11 +1,11 @@
invoke>=1.4.0 # Invoke build tool invoke>=1.4.0 # Invoke build tool
wheel>=0.34.2 # Wheel wheel>=0.34.2 # Wheel
Django==3.0.7 # Django package Django==3.2 # Django package
pillow==8.1.1 # Image manipulation pillow==8.1.1 # Image manipulation
djangorestframework==3.11.2 # DRF framework djangorestframework==3.12.4 # DRF framework
django-dbbackup==3.3.0 # Database backup / restore functionality django-dbbackup==3.3.0 # Database backup / restore functionality
django-cors-headers==3.2.0 # CORS headers extension for DRF django-cors-headers==3.2.0 # CORS headers extension for DRF
django_filter==2.2.0 # Extended filtering options django-filter==2.4.0 # Extended filtering options
django-mptt==0.11.0 # Modified Preorder Tree Traversal django-mptt==0.11.0 # Modified Preorder Tree Traversal
django-sql-utils==0.5.0 # Advanced query annotation / aggregation django-sql-utils==0.5.0 # Advanced query annotation / aggregation
django-markdownx==3.0.1 # Markdown form fields django-markdownx==3.0.1 # Markdown form fields
@ -13,7 +13,7 @@ django-markdownify==0.8.0 # Markdown rendering
coreapi==2.3.0 # API documentation coreapi==2.3.0 # API documentation
pygments==2.7.4 # Syntax highlighting pygments==2.7.4 # Syntax highlighting
tablib==0.13.0 # Import / export data files tablib==0.13.0 # Import / export data files
django-crispy-forms==1.8.1 # Form helpers django-crispy-forms==1.11.2 # Form helpers
django-import-export==2.0.0 # Data import / export for admin interface django-import-export==2.0.0 # Data import / export for admin interface
django-cleanup==5.1.0 # Manage deletion of old / unused uploaded files django-cleanup==5.1.0 # Manage deletion of old / unused uploaded files
flake8==3.8.3 # PEP checking flake8==3.8.3 # PEP checking

View File

@ -154,6 +154,7 @@ def static(c):
as per Django requirements. as per Django requirements.
""" """
manage(c, "prerender")
manage(c, "collectstatic --no-input") manage(c, "collectstatic --no-input")
@ -173,7 +174,7 @@ def update(c):
""" """
pass pass
@task @task(post=[static])
def translate(c): def translate(c):
""" """
Regenerate translation files. Regenerate translation files.
@ -183,7 +184,7 @@ def translate(c):
""" """
# Translate applicable .py / .html / .js files # Translate applicable .py / .html / .js files
manage(c, "makemessages -e py -e html -e js") manage(c, "makemessages --all -e py,html,js")
manage(c, "compilemessages") manage(c, "compilemessages")
path = os.path.join('InvenTree', 'script', 'translation_stats.py') path = os.path.join('InvenTree', 'script', 'translation_stats.py')