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
run: |
sudo apt-get update
sudo apt-get install gettext
pip3 install invoke
invoke install
- name: Coverage Tests
@ -42,6 +43,8 @@ jobs:
rm test_db.sqlite
invoke migrate
invoke import-records -f data.json
- name: Test Translations
run: invoke translate
- name: Check Migration Files
run: python3 ci/check_migration_files.py
- 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:
@ -10,7 +13,19 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Build Docker Image
run: cd docker && docker build . --tag inventree:$(date +%s)
- name: Checkout Code
uses: actions/checkout@v2
- 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)
inventree_media
inventree_static
static_i18n
# Local config file
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"
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, ...)
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'),
]
# 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
STATIC_COLOR_THEMES_DIR = os.path.join(STATIC_ROOT, 'css', 'color-themes')

View File

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

View File

@ -62,6 +62,14 @@ class StatusCode:
def items(cls):
return cls.options.items()
@classmethod
def keys(cls):
return cls.options.keys()
@classmethod
def labels(cls):
return cls.options.values()
@classmethod
def label(cls, 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", "")
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():
""" Returns the InvenTree version string """
return INVENTREE_SW_VERSION

View File

@ -11,7 +11,7 @@ from rest_framework import generics
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 .models import Build, BuildItem
@ -194,7 +194,11 @@ class BuildItemList(generics.ListCreateAPIView):
output = params.get('output', None)
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

View File

@ -3,6 +3,7 @@ Django Forms for interacting with Build objects
"""
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
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 DatePickerFormField
from InvenTree.status_codes import StockStatus
from .models import Build, BuildItem, BuildOrderAttachment
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'))
# Keep track of which build output we are interested in
output = forms.ModelChoiceField(
queryset=StockItem.objects.all(),
)
class Meta:
model = Build
fields = [
'confirm',
'output',
]
@ -214,6 +211,13 @@ class CompleteBuildOutputForm(HelperForm):
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(
required=False,
label=_('Confirm incomplete'),
@ -232,10 +236,15 @@ class CompleteBuildOutputForm(HelperForm):
fields = [
'location',
'output',
'stock_status',
'confirm',
'confirm_incomplete',
]
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
class CancelBuildForm(HelperForm):
""" Form for cancelling a build """

View File

@ -22,7 +22,7 @@ from markdownx.models import MarkdownxField
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.validators import validate_build_order_reference
from InvenTree.models import InvenTreeAttachment
@ -314,6 +314,42 @@ class Build(MPTTModel):
'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
def remaining(self):
"""
@ -449,6 +485,9 @@ class Build(MPTTModel):
if self.completed < self.quantity:
return False
if not self.areUntrackedPartsFullyAllocated():
return False
# No issues!
return True
@ -458,7 +497,7 @@ class Build(MPTTModel):
Mark this build as complete
"""
if not self.can_complete:
if self.incomplete_count > 0:
return
self.completion_date = datetime.now().date()
@ -466,6 +505,9 @@ class Build(MPTTModel):
self.status = BuildStatus.COMPLETE
self.save()
# Remove untracked allocated stock
self.subtractUntrackedStock(user)
# Ensure that there are no longer any BuildItem objects
# which point to thie Build Order
self.allocated_stock.all().delete()
@ -489,7 +531,7 @@ class Build(MPTTModel):
self.status = BuildStatus.CANCELLED
self.save()
def getAutoAllocations(self, output):
def getAutoAllocations(self):
"""
Return a list of StockItem objects which will be allocated
using the 'AutoAllocate' function.
@ -521,15 +563,19 @@ class Build(MPTTModel):
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
if self.isPartFullyAllocated(part, output):
if self.isPartFullyAllocated(part, None):
continue
# 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
stock_items = self.availableStockItems(part, output)
stock_items = self.availableStockItems(part, None)
# Ensure that the available stock items are in the correct location
if self.take_from is not None:
@ -544,7 +590,6 @@ class Build(MPTTModel):
build_items = BuildItem.objects.filter(
build=self,
stock_item=stock_item,
install_into=output
)
if len(build_items) > 0:
@ -567,24 +612,45 @@ class Build(MPTTModel):
return allocations
@transaction.atomic
def unallocateStock(self, output=None, part=None):
def unallocateOutput(self, output, part=None):
"""
Deletes all stock allocations for this build.
Args:
output: Specify which build output to delete allocations (optional)
Unallocate all stock which are allocated against the provided "output" (StockItem)
"""
allocations = BuildItem.objects.filter(build=self.pk)
if output:
allocations = allocations.filter(install_into=output.pk)
allocations = BuildItem.objects.filter(
build=self,
install_into=output
)
if 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()
@transaction.atomic
@ -679,13 +745,13 @@ class Build(MPTTModel):
raise ValidationError(_("Build output does not match Build Order"))
# Unallocate all build items against the output
self.unallocateStock(output)
self.unallocateOutput(output)
# Remove the build output from the database
output.delete()
@transaction.atomic
def autoAllocate(self, output):
def autoAllocate(self):
"""
Run auto-allocation routine to allocate StockItems to this Build.
@ -702,7 +768,7 @@ class Build(MPTTModel):
See: getAutoAllocations()
"""
allocations = self.getAutoAllocations(output)
allocations = self.getAutoAllocations()
for item in allocations:
# Create a new allocation
@ -710,11 +776,29 @@ class Build(MPTTModel):
build=self,
stock_item=item['stock_item'],
quantity=item['quantity'],
install_into=output,
install_into=None
)
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
def completeBuildOutput(self, output, user, **kwargs):
"""
@ -726,6 +810,7 @@ class Build(MPTTModel):
# Select the location for the build output
location = kwargs.get('location', self.destination)
status = kwargs.get('status', StockStatus.OK)
# List the allocated BuildItem objects for the given output
allocated_items = output.items_to_install.all()
@ -733,9 +818,7 @@ class Build(MPTTModel):
for build_item in allocated_items:
# 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...
# 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
# TODO: Use the background worker process to handle this task!
# Complete the allocation of stock for that item
build_item.complete_allocation(user)
@ -747,6 +830,7 @@ class Build(MPTTModel):
output.build = self
output.is_building = False
output.location = location
output.status = status
output.save()
@ -779,7 +863,7 @@ class Build(MPTTModel):
if output:
quantity *= output.quantity
else:
quantity *= self.remaining
quantity *= self.quantity
return quantity
@ -807,7 +891,13 @@ class Build(MPTTModel):
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']
@ -828,19 +918,39 @@ class Build(MPTTModel):
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.
"""
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
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!
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):
"""
@ -849,7 +959,13 @@ class Build(MPTTModel):
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
if self.isPartFullyAllocated(part, output):
@ -864,7 +980,13 @@ class Build(MPTTModel):
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
if not self.isPartFullyAllocated(part, output):
@ -1014,10 +1136,12 @@ class BuildItem(models.Model):
errors = {}
if not self.install_into:
raise ValidationError(_('Build item must specify a build output'))
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
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)]

View File

@ -4,7 +4,7 @@
{% load inventree_extras %}
{% block page_title %}
InvenTree | Allocate Parts
{% inventree_title %} | {% trans "Allocate Parts" %}
{% endblock %}
{% block menubar %}
@ -12,48 +12,41 @@ InvenTree | Allocate Parts
{% endblock %}
{% block heading %}
{% trans "Incomplete Build Ouputs" %}
{% trans "Allocate Stock to Build" %}
{% endblock %}
{% block details %}
{% if build.is_complete %}
<div class='alert alert-block alert-success'>
{% trans "Build order has been completed" %}
</div>
{% else %}
{% if build.has_untracked_bom_items %}
{% if build.active %}
<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 class='btn btn-success' type='button' id='btn-auto-allocate' title='{% trans "Allocate stock to build" %}'>
<span class='fas fa-magic'></span> {% trans "Auto Allocate" %}
</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" %}'>
<span class='fas fa-minus-circle'></span> {% trans "Unallocate Stock" %}
</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>
<hr>
{% 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 %}
{% endfor %}
{% if build.areUntrackedPartsFullyAllocated %}
<div class='alert alert-block alert-success'>
{% trans "Untracked stock has been fully allocated for this Build Order" %}
</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 class='alert alert-block alert-danger'>
{% trans "Untracked stock has not been fully allocated for this Build Order" %}
</div>
{% 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>
{% endif %}
{% endif %}
{% endblock %}
{% block js_ready %}
@ -66,19 +59,17 @@ InvenTree | Allocate Parts
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 %}
{% if build.has_untracked_bom_items %}
// Load allocation table for un-tracked parts
loadBuildOutputAllocationTable(buildInfo, null);
{% endif %}
function reloadTable() {
$('#allocation-table-untracked').bootstrapTable('refresh');
}
{% if build.active %}
$("#btn-allocate").on('click', function() {
$("#btn-auto-allocate").on('click', function() {
launchModalForm(
"{% url 'build-auto-allocate' build.id %}",
{
@ -86,20 +77,12 @@ InvenTree | Allocate Parts
}
);
});
$('#btn-unallocate').on('click', function() {
launchModalForm(
"{% url 'build-unallocate' build.id %}",
{
reload: true,
}
);
});
$('#btn-create-output').click(function() {
launchModalForm('{% url "build-output-create" build.id %}',
{
reload: true,
success: reloadTable,
}
);
});

View File

@ -7,23 +7,31 @@
<div class="panel-heading" role="tab" id="heading-{{ pk }}">
<div class="panel-title">
<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 }}">
{% endif %}
<div class='col-sm-4'>
{% if tracked_items %}
<span class='fas fa-caret-right'></span>
{% endif %}
{{ item.part.full_name }}
</div>
<div class='col-sm-2'>
{% if item.serial %}
# {{ item.serial }}
{% trans "Serial Number" %}: {{ item.serial }}
{% else %}
{% decimal item.quantity %}
{% trans "Quantity" %}: {% decimal item.quantity %}
{% endif %}
</div>
{% if tracked_items %}
</a>
{% endif %}
<div class='col-sm-3'>
<div>
<div id='output-progress-{{ pk }}'>
{% if tracked_items %}
<span class='fas fa-spin fa-spinner'></span>
{% endif %}
</div>
</div>
</div>

View File

@ -6,10 +6,10 @@
{% load inventree_extras %}
{% block page_title %}
InvenTree | {% trans "Build Order" %} - {{ build }}
{% inventree_title %} | {% trans "Build Order" %} - {{ build }}
{% endblock %}
{% block pre_content %}
{% block header_pre_content %}
{% if build.sales_order %}
<div class='alert alert-block alert-info'>
{% object_link 'so-detail' build.sales_order.id build.sales_order as link %}
@ -24,6 +24,31 @@ InvenTree | {% trans "Build Order" %} - {{ build }}
{% endif %}
{% 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 %}
<img class="part-thumb"
{% if build.part.image %}
@ -61,6 +86,11 @@ src="{% static 'img/blank_image.png' %}"
</div>
<!-- Build actions -->
{% 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'>
<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>
@ -68,12 +98,11 @@ src="{% static 'img/blank_image.png' %}"
<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>
{% 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>
{% endif %}
{% 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>
{% endif %}
{% endif %}
</ul>
</div>
{% endif %}
@ -172,6 +201,13 @@ src="{% static 'img/blank_image.png' %}"
});
$("#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(
"{% url 'build-complete' build.id %}",
{
@ -179,6 +215,7 @@ src="{% static 'img/blank_image.png' %}"
submit_text: '{% trans "Complete Build" %}',
}
);
{% endif %}
});
$('#print-build-report').click(function() {

View File

@ -6,19 +6,68 @@
{% include "build/navbar.html" with tab='output' %}
{% endblock %}
{% block heading %}
{% trans "Build Outputs" %}
{% endblock %}
{% block content_panels %}
{% 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 %}
{% block js_ready %}
{{ block.super }}
$('#btn-create-output').click(function() {
launchModalForm('{% url "build-output-create" build.id %}',
{
reload: true,
}
);
});
loadStockTable($("#stock-table"), {
params: {
location_detail: true,
@ -32,4 +81,23 @@ loadStockTable($("#stock-table"), {
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 %}

View File

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

View File

@ -4,9 +4,10 @@
{% block pre_form_content %}
{% if fully_allocated %}
<div class='alert alert-block alert-info'>
<h4>{% trans "Stock allocation is complete" %}</h4>
{% if not build.has_tracked_bom_items %}
{% elif fully_allocated %}
<div class='alert alert-block alert-success'>
{% trans "Stock allocation is complete for this output" %}
</div>
{% else %}
<div class='alert alert-block alert-danger'>
@ -16,7 +17,7 @@
<div class='panel panel-default'>
<div class='panel panel-heading'>
<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>
</div>
<div class='panel-collapse collapse' id='collapse-unallocated'>
@ -41,7 +42,11 @@
</div>
<div class='panel-content'>
{% 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 }}
{% endif %}
</div>
</div>

View File

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

View File

@ -17,17 +17,11 @@
</li>
{% if build.active %}
<li class='list-group-item {% if tab == "parts" %}active{% endif %}' title='{% trans "Required Parts" %}'>
<a href='{% url "build-parts" build.id %}'>
<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" %}'>
<li class='list-group-item {% if tab == "allocate" %}active{% endif %}' title='{% trans "Allocate Stock" %}'>
<a href='{% url "build-allocate" build.id %}'>
<span class='fas fa-tools'></span>
{% trans "In Progress" %}
{% trans "Allocate Stock" %}
</a>
</li>
{% 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):
"""
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"
@ -41,17 +53,31 @@ class BuildTest(TestCase):
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
BomItem.objects.create(
part=self.assembly,
sub_part=self.sub_part_1,
quantity=10
quantity=5
)
BomItem.objects.create(
part=self.assembly,
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
@ -64,14 +90,14 @@ class BuildTest(TestCase):
# Create some build output (StockItem) objects
self.output_1 = StockItem.objects.create(
part=self.assembly,
quantity=5,
quantity=3,
is_building=True,
build=self.build
)
self.output_2 = StockItem.objects.create(
part=self.assembly,
quantity=5,
quantity=7,
is_building=True,
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_3_1 = StockItem.objects.create(part=self.sub_part_3, quantity=1000)
def test_init(self):
# 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
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_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_2), 50)
self.assertEqual(self.build.unallocatedQuantity(self.sub_part_2, self.output_1), 125)
self.assertEqual(self.build.unallocatedQuantity(self.sub_part_2, self.output_2), 125)
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), 35)
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), 21)
self.assertFalse(self.build.is_complete)
@ -144,84 +172,113 @@ class BuildTest(TestCase):
quantity=99
)
def allocate_stock(self, q11, q12, q21, output):
# Assign stock to this build
def allocate_stock(self, output, allocations):
"""
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(
build=self.build,
stock_item=self.stock_1_1,
quantity=q11,
stock_item=item,
quantity=quantity,
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):
"""
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))
# 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.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
self.assertEqual(self.build.allocatedQuantity(self.sub_part_1, self.output_1), 100)
# Partially allocate untracked stock against build
self.allocate_stock(
None,
{
self.stock_1_1: 1,
self.stock_2_1: 1
}
)
self.build.unallocateStock(output=self.output_1)
self.assertEqual(BuildItem.objects.count(), 0)
self.assertFalse(self.build.isFullyAllocated(None, verbose=True))
# Check that the part has been unallocated
self.assertEqual(self.build.allocatedQuantity(self.sub_part_1, self.output_1), 0)
unallocated = self.build.unallocatedParts(None)
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):
"""
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.build.autoAllocate(self.output_1)
self.build.autoAllocate()
self.assertEqual(BuildItem.objects.count(), 1)
# Check that one part has been fully allocated to the build output
self.assertTrue(self.build.isPartFullyAllocated(self.sub_part_2, self.output_1))
# Check that one un-tracked part has been fully allocated to the build
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_2, self.output_2))
self.assertFalse(self.build.isPartFullyAllocated(self.sub_part_1, None))
def test_cancel(self):
"""
@ -243,9 +300,33 @@ class BuildTest(TestCase):
Test completion of a build output
"""
self.allocate_stock(50, 50, 250, self.output_1)
self.allocate_stock(50, 50, 250, self.output_2)
# Allocate non-tracked parts
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_2))
@ -265,19 +346,16 @@ class BuildTest(TestCase):
self.assertEqual(BuildItem.objects.count(), 0)
# New stock items should have been created!
self.assertEqual(StockItem.objects.count(), 4)
a = StockItem.objects.get(pk=self.stock_1_1.pk)
self.assertEqual(StockItem.objects.count(), 7)
# This stock item has been depleted!
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(a.quantity, 900)
self.assertEqual(c.quantity, 4500)
self.assertEqual(x.quantity, 4970)
# And 10 new stock items created for the build output
outputs = StockItem.objects.filter(build=self.build)

View File

@ -15,7 +15,7 @@ from datetime import datetime, timedelta
from .models import Build
from stock.models import StockItem
from InvenTree.status_codes import BuildStatus
from InvenTree.status_codes import BuildStatus, StockStatus
class BuildTestSimple(TestCase):
@ -335,6 +335,7 @@ class TestBuildViews(TestCase):
'confirm_incomplete': 1,
'location': 1,
'output': self.output.pk,
'stock_status': StockStatus.DAMAGED
},
HTTP_X_REQUESTED_WITH='XMLHttpRequest'
)
@ -342,6 +343,7 @@ class TestBuildViews(TestCase):
self.assertEqual(response.status_code, 200)
data = json.loads(response.content)
self.assertTrue(data['form_valid'])
# 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'^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'^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 InvenTreeRoleMixin
from InvenTree.helpers import str2bool, extract_serial_numbers, normalize
from InvenTree.status_codes import BuildStatus
from InvenTree.helpers import str2bool, extract_serial_numbers, normalize, isNull
from InvenTree.status_codes import BuildStatus, StockStatus
class BuildIndex(InvenTreeRoleMixin, ListView):
@ -98,16 +98,6 @@ class BuildAutoAllocate(AjaxUpdateView):
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
def get_context_data(self, *args, **kwargs):
@ -119,18 +109,7 @@ class BuildAutoAllocate(AjaxUpdateView):
build = self.get_object()
form = self.get_form()
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['allocations'] = build.getAutoAllocations()
context['build'] = build
@ -140,18 +119,11 @@ class BuildAutoAllocate(AjaxUpdateView):
form = super().get_form()
if form['output'].value():
# Hide the 'output' field
form.fields['output'].widget = HiddenInput()
return form
def validate(self, build, form, **kwargs):
output = form.cleaned_data.get('output', None)
if not output:
form.add_error(None, _('Build output must be specified'))
pass
def save(self, build, form, **kwargs):
"""
@ -159,9 +131,7 @@ class BuildAutoAllocate(AjaxUpdateView):
perform auto-allocations
"""
output = form.cleaned_data.get('output', None)
build.autoAllocate(output)
build.autoAllocate()
def get_data(self):
return {
@ -242,7 +212,7 @@ class BuildOutputCreate(AjaxUpdateView):
# Calculate the required quantity
quantity = max(0, build.remaining - build.incomplete_count)
initials['quantity'] = quantity
initials['output_quantity'] = quantity
return initials
@ -365,10 +335,16 @@ class BuildUnallocate(AjaxUpdateView):
output_id = request.POST.get('output_id', None)
try:
output = StockItem.objects.get(pk=output_id)
except (ValueError, StockItem.DoesNotExist):
output = None
if output_id:
# If a "null" output is provided, we are trying to unallocate "untracked" stock
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)
@ -383,9 +359,19 @@ class BuildUnallocate(AjaxUpdateView):
form.add_error('confirm', _('Confirm unallocation of build stock'))
form.add_error(None, _('Check the confirmation box'))
else:
build.unallocateStock(output=output, part=part)
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 = {
'form_valid': valid,
}
@ -410,8 +396,8 @@ class BuildComplete(AjaxUpdateView):
def validate(self, build, form, **kwargs):
if not build.can_complete:
form.add_error(None, _('Build order cannot be completed'))
if build.incomplete_count > 0:
form.add_error(None, _('Build order cannot be completed - incomplete outputs remain'))
def save(self, build, form, **kwargs):
"""
@ -431,7 +417,7 @@ class BuildOutputComplete(AjaxUpdateView):
View to mark a particular build output as Complete.
- 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
"""
@ -463,11 +449,25 @@ class BuildOutputComplete(AjaxUpdateView):
return form
def validate(self, build, form, **kwargs):
"""
Custom validation steps for the BuildOutputComplete" form
"""
data = form.cleaned_data
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:
quantity = data.get('quantity', None)
@ -559,12 +559,20 @@ class BuildOutputComplete(AjaxUpdateView):
location = data.get('location', 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
build.completeBuildOutput(
output,
self.request.user,
location=location,
status=stock_status,
)
def get_data(self):
@ -632,10 +640,12 @@ class BuildAllocate(InvenTreeRoleMixin, DetailView):
build = self.get_object()
part = build.part
bom_items = part.bom_items
bom_items = build.bom_items
context['part'] = part
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['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'),
},
'INVENTREE_INSTANCE_TITLE': {
'name': _('Use instance name'),
'description': _('Use the instance name in the title-bar'),
'validator': bool,
'default': False,
},
'INVENTREE_COMPANY_NAME': {
'name': _('Company name'),
'description': _('Internal company name'),

View File

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

View File

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

View File

@ -1,9 +1,10 @@
{% extends "two_column.html" %}
{% load static %}
{% load i18n %}
{% load inventree_extras %}
{% block page_title %}
InvenTree | {% trans "Supplier Part" %}
{% inventree_title %} | {% trans "Supplier Part" %}
{% endblock %}
{% 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 %}
{% block page_title %}
InvenTree | {% trans "Purchase Order" %}
{% inventree_title %} | {% trans "Purchase Order" %}
{% endblock %}
{% block thumbnail %}

View File

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

View File

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

View File

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

View File

@ -60,28 +60,43 @@ class CategoryList(generics.ListCreateAPIView):
queryset = PartCategory.objects.all()
serializer_class = part_serializers.CategorySerializer
def get_queryset(self):
def filter_queryset(self, queryset):
"""
Custom filtering:
- 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 isNull(cat_id):
if not cascade:
queryset = queryset.filter(parent=None)
else:
try:
cat_id = int(cat_id)
queryset = queryset.filter(parent=cat_id)
except ValueError:
pass
else:
try:
category = PartCategory.objects.get(pk=cat_id)
if cascade:
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

View File

@ -1163,7 +1163,16 @@ class Part(MPTTModel):
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']
@ -1179,7 +1188,16 @@ class Part(MPTTModel):
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']
@ -1189,10 +1207,12 @@ class Part(MPTTModel):
against both build orders and sales orders.
"""
return sum([
self.build_order_allocation_count(),
self.sales_order_allocation_count(),
])
return sum(
[
self.build_order_allocation_count(),
self.sales_order_allocation_count(),
],
)
def stock_entries(self, include_variants=True, in_stock=None):
""" Return all stock entries for this Part.

View File

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

View File

@ -2,6 +2,10 @@
{% load static %}
{% load i18n %}
{% block menubar %}
{% include 'part/category_navbar.html' with tab='parts' %}
{% endblock %}
{% block content %}
<div class='panel panel-default panel-inventree'>
@ -100,14 +104,10 @@
</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>
{% block category_content %}
<div id='button-toolbar'>
<div class='btn-group'>
<button class='btn btn-default' id='part-export' title='{% trans "Export Part Data" %}'>
@ -150,6 +150,8 @@
</div>
</div>
{% endblock %}
{% block category_tables %}
{% endblock category_tables %}
@ -162,24 +164,10 @@
{% block js_ready %}
{{ block.super }}
{% if category %}
enableNavbar({
label: 'category',
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() {
launchModalForm(

View File

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

View File

@ -1,14 +1,15 @@
{% extends "base.html" %}
{% load static %}
{% load i18n %}
{% load inventree_extras %}
{% block page_title %}
{% if part %}
InvenTree | {% trans "Part" %} - {{ part.full_name }}
{% inventree_title %} | {% trans "Part" %} - {{ part.full_name }}
{% elif category %}
InvenTree | {% trans "Part Category" %} - {{ category }}
{% inventree_title %} | {% trans "Part Category" %} - {{ category }}
{% else %}
InvenTree | {% trans "Part List" %}
{% inventree_title %} | {% trans "Part List" %}
{% endif %}
{% 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.urls import reverse
from django.utils.safestring import mark_safe
from django.templatetags.static import StaticNode
from InvenTree import version, settings
import InvenTree.helpers
@ -73,6 +74,12 @@ def inventree_instance_name(*args, **kwargs):
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()
def inventree_version(*args, **kwargs):
""" Return InvenTree version string """
@ -174,3 +181,31 @@ def object_link(url_name, pk, ref):
ref_url = reverse(url_name, kwargs={'pk': pk})
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()
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')
# Request *all* part categories
response = self.client.get(url, format='json')
self.assertEqual(response.status_code, status.HTTP_200_OK)
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):
""" Check that we can add categories """
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'),
]
part_category_urls = [
url(r'^edit/?', views.CategoryEdit.as_view(), name='category-edit'),
url(r'^delete/?', views.CategoryDelete.as_view(), name='category-delete'),
category_urls = [
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'),
url(r'^.*$', views.CategoryDetail.as_view(), name='category-detail'),
# Top level subcategory display
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 = [
@ -106,9 +118,6 @@ part_bom_urls = [
# URL list for part web interface
part_urls = [
# Create a new category
url(r'^category/new/?', views.CategoryCreate.as_view(), name='category-create'),
# Create a new part
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)),
# Part category
url(r'^category/(?P<pk>\d+)/', include(part_category_urls)),
url(r'^category/', include(category_urls)),
# Part related
url(r'^related-parts/', include(part_related_urls)),

View File

@ -281,28 +281,46 @@ class StockLocationList(generics.ListCreateAPIView):
queryset = StockLocation.objects.all()
serializer_class = LocationSerializer
def get_queryset(self):
def filter_queryset(self, queryset):
"""
Custom filtering:
- 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
if isNull(loc_id):
# Do not filter by location
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)
else:
try:
loc_id = int(loc_id)
queryset = queryset.filter(parent=loc_id)
except ValueError:
pass
else:
try:
location = StockLocation.objects.get(pk=loc_id)
# 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
@ -320,6 +338,11 @@ class StockLocationList(generics.ListCreateAPIView):
'description',
]
ordering_fields = [
'name',
'items',
]
class StockList(generics.ListCreateAPIView):
""" API endpoint for list view of Stock objects

View File

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

View File

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

View File

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

View File

@ -2,8 +2,15 @@
{% load static %}
{% load inventree_extras %}
{% load i18n %}
{% block menubar %}
{% include "stock/location_navbar.html" with tab="stock" %}
{% endblock %}
{% block content %}
<div class='panel panel-default panel-inventree'>
{% setting_object 'STOCK_OWNERSHIP_CONTROL' as owner_control %}
{% if owner_control.value == "True" %}
{% authorized_owners location.owner as owners %}
@ -120,36 +127,29 @@
</div>
</h3>
</div>
</div>
{% if location and location.children.all|length > 0 %}
{% 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 %}
{% block location_content %}
<hr>
{% include "stock_table.html" %}
<div class='panel panel-default panel-inventree'>
<div class='panel-heading'>
<h4>{% trans "Stock Items" %}</h4>
</div>
{% include "stock_table.html" %}
</div>
{% endblock %}
{% block js_load %}
{{ block.super }}
</div>
{% endblock %}
{% block js_ready %}
{{ block.super }}
if (inventreeLoadInt("show-part-locs") == 1) {
$("#collapse-item-locations").collapse('show');
}
$("#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');
enableNavbar({
label: 'location',
toggleId: '#location-menu-toggle'
});
{% if location %}
@ -261,7 +261,7 @@
],
params: {
{% if location %}
location: {{ location.id }},
location: {{ location.pk }},
{% endif %}
part_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" %}
{% load static %}
{% load i18n %}
{% load inventree_extras %}
{% block page_title %}
{% if location %}
InvenTree | {% trans "Stock Location" %} - {{ location }}
{% inventree_title %} | {% trans "Stock Location" %} - {{ location }}
{% else %}
InvenTree | {% trans "Stock" %}
{% inventree_title %} | {% trans "Stock" %}
{% endif %}
{% 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
# URL list for web interface
stock_location_detail_urls = [
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'),
location_urls = [
url(r'^new/', views.StockLocationCreate.as_view(), name='stock-location-create'),
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 = [
@ -49,9 +56,7 @@ stock_tracking_urls = [
stock_urls = [
# Stock location
url(r'^location/(?P<pk>\d+)/', include(stock_location_detail_urls)),
url(r'^location/new/', views.StockLocationCreate.as_view(), name='stock-location-create'),
url(r'^location/', include(location_urls)),
url(r'^item/new/?', views.StockItemCreate.as_view(), name='stock-item-create'),
@ -81,5 +86,7 @@ stock_urls = [
# Individual stock items
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'),
]

View File

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

View File

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

View File

@ -16,6 +16,7 @@
{% include "InvenTree/settings/header.html" %}
<tbody>
{% 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_COMPANY_NAME" icon="fa-building" %}
{% include "InvenTree/settings/setting.html" with key="INVENTREE_DEFAULT_CURRENCY" icon="fa-dollar-sign" %}

View File

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

View File

@ -60,7 +60,7 @@
<title>
{% block page_title %}
InvenTree
{% inventree_title %}
{% endblock %}
</title>
</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/sidenav.js' %}"></script>
<script type='text/javascript' src="{% url 'barcode.js' %}"></script>
<script type='text/javascript' src="{% url 'bom.js' %}"></script>
<script type='text/javascript' src="{% url 'company.js' %}"></script>
<script type='text/javascript' src="{% url 'part.js' %}"></script>
<script type='text/javascript' src="{% url 'modals.js' %}"></script>
<script type='text/javascript' src="{% url 'label.js' %}"></script>
<script type='text/javascript' src="{% url 'report.js' %}"></script>
<script type='text/javascript' src="{% url 'stock.js' %}"></script>
<script type='text/javascript' src="{% url 'build.js' %}"></script>
<script type='text/javascript' src="{% url 'order.js' %}"></script>
<script type='text/javascript' src="{% url 'calendar.js' %}"></script>
<script type='text/javascript' src="{% url 'tables.js' %}"></script>
<script type='text/javascript' src="{% url 'table_filters.js' %}"></script>
<script type='text/javascript' src="{% url 'filters.js' %}"></script>
<!-- translated -->
<script type='text/javascript' src="{% i18n_static 'barcode.js' %}"></script>
<script type='text/javascript' src="{% i18n_static 'bom.js' %}"></script>
<script type='text/javascript' src="{% i18n_static 'company.js' %}"></script>
<script type='text/javascript' src="{% i18n_static 'part.js' %}"></script>
<script type='text/javascript' src="{% i18n_static 'modals.js' %}"></script>
<script type='text/javascript' src="{% i18n_static 'label.js' %}"></script>
<script type='text/javascript' src="{% i18n_static 'report.js' %}"></script>
<script type='text/javascript' src="{% i18n_static 'stock.js' %}"></script>
<script type='text/javascript' src="{% i18n_static 'build.js' %}"></script>
<script type='text/javascript' src="{% i18n_static 'order.js' %}"></script>
<script type='text/javascript' src="{% i18n_static 'calendar.js' %}"></script>
<script type='text/javascript' src="{% i18n_static 'tables.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/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.
*/
var buildId = buildInfo.pk;
var outputId = output.pk;
if (output) {
outputId = output.pk;
} else {
outputId = 'untracked';
}
var panel = `#allocation-panel-${outputId}`;
@ -50,35 +55,42 @@ function makeBuildOutputActionButtons(output, buildInfo) {
var html = `<div class='btn-group float-right' role='group'>`;
// Add a button to "auto allocate" against the build
html += makeIconButton(
'fa-magic icon-blue', 'button-output-auto', outputId,
'{% trans "Auto-allocate stock items to this output" %}',
);
// "Auto" allocation only works for untracked stock items
if (!output && lines > 0) {
html += makeIconButton(
'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
html += makeIconButton(
'fa-check icon-green', 'button-output-complete', outputId,
'{% trans "Complete build output" %}',
{
//disabled: true
}
);
if (lines > 0) {
// 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 "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
html += makeIconButton(
'fa-trash-alt icon-red', 'button-output-delete', outputId,
'{% trans "Delete build output" %}',
);
if (output) {
// Add a button to "destroy" the particular build output (mark as damaged, scrap)
// TODO
// Add a button to "complete" the particular build output
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>';
@ -90,7 +102,6 @@ function makeBuildOutputActionButtons(output, buildInfo) {
launchModalForm(`/build/${buildId}/auto-allocate/`,
{
data: {
output: outputId,
},
success: reloadTable,
}
@ -98,11 +109,14 @@ function makeBuildOutputActionButtons(output, buildInfo) {
});
$(panel).find(`#button-output-complete-${outputId}`).click(function() {
var pk = $(this).attr('pk');
launchModalForm(
`/build/${buildId}/complete-output/`,
{
data: {
output: outputId,
output: pk,
},
reload: true,
}
@ -110,24 +124,30 @@ function makeBuildOutputActionButtons(output, buildInfo) {
});
$(panel).find(`#button-output-unallocate-${outputId}`).click(function() {
var pk = $(this).attr('pk');
launchModalForm(
`/build/${buildId}/unallocate/`,
{
success: reloadTable,
data: {
output: outputId,
output: pk,
}
}
);
});
$(panel).find(`#button-output-delete-${outputId}`).click(function() {
var pk = $(this).attr('pk');
launchModalForm(
`/build/${buildId}/delete-output/`,
{
reload: true,
data: {
output: outputId
output: pk
}
}
);
@ -152,13 +172,21 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
var outputId = null;
outputId = output.pk;
if (output) {
outputId = output.pk;
} else {
outputId = 'untracked';
}
var table = options.table;
if (options.table == null) {
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() {
// Reload the entire build allocation table
@ -168,7 +196,13 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
function requiredQuantity(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) {
@ -300,6 +334,7 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
queryParams: {
part: partId,
sub_part_detail: true,
sub_part_trackable: trackable,
},
formatNoMatches: function() {
return '{% trans "No BOM items found" %}';
@ -310,11 +345,19 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
onLoadSuccess: function(tableData) {
// 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/',
{
build: buildId,
output: outputId,
},
params,
{
success: function(data) {
// 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
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?
if (allocatedQuantity >= (tableRow.quantity * output.quantity)) {
if (allocatedQuantity >= requiredQuantity) {
allocatedLines += 1;
}
@ -367,16 +418,21 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
// Update the total progress for this build output
var buildProgress = $(`#allocation-panel-${outputId}`).find($(`#output-progress-${outputId}`));
var progress = makeProgressBar(
allocatedLines,
totalLines
);
if (totalLines > 0) {
buildProgress.html(progress);
var progress = makeProgressBar(
allocatedLines,
totalLines
);
buildProgress.html(progress);
} else {
buildProgress.html('');
}
// 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',
title: '{% trans "Build" %}',
sortable: true,
switchable: false,
switchable: true,
formatter: function(value, row, index, field) {
var prefix = "{% settings_value 'BUILDORDER_REFERENCE_PREFIX' %}";
@ -675,6 +734,7 @@ function loadBuildTable(table, options) {
{
field: 'title',
title: '{% trans "Description" %}',
switchable: true,
},
{
field: 'part',
@ -725,7 +785,7 @@ function loadBuildTable(table, options) {
},
{
field: 'completion_date',
title: '{% trans "Completed" %}',
title: '{% trans "Completion Date" %}',
sortable: true,
},
],

View File

@ -1,4 +1,5 @@
{% load i18n %}
{% load inventree_extras %}
/* Part API functions
* 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) {
if (value) {
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) {
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")
if (tableKey == "customerstock") {
return {

View File

@ -1,6 +1,7 @@
{% load static %}
{% load i18n %}
{% load crispy_forms_tags %}
{% load crispy_forms_tags %}
{% load inventree_extras %}
<!DOCTYPE html>
<html lang="en">
@ -29,7 +30,7 @@
</style>
<title>
InvenTree
{% inventree_title %}
</title>
</head>
@ -42,7 +43,7 @@
<div class='container-fluid'>
<div class='clearfix content-heading login-header'>
<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>
<hr>

View File

@ -1,5 +1,6 @@
{% load static %}
{% load i18n %}
{% load inventree_extras %}
<!DOCTYPE html>
<html lang="en">
@ -28,7 +29,7 @@
</style>
<title>
InvenTree
{% inventree_title %}
</title>
</head>
@ -44,7 +45,7 @@
<div class='container-fluid'>
<div class='clearfix content-heading login-header'>
<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>
<hr>

View File

@ -1,6 +1,7 @@
{% load static %}
{% load i18n %}
{% load crispy_forms_tags %}
{% load crispy_forms_tags %}
{% load inventree_extras %}
<!DOCTYPE html>
<html lang="en">
@ -29,7 +30,7 @@
</style>
<title>
InvenTree
{% inventree_title %}
</title>
</head>
@ -42,7 +43,7 @@
<div class='container-fluid'>
<div class='clearfix content-heading login-header'>
<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>
<hr>

View File

@ -1,6 +1,7 @@
{% load static %}
{% load i18n %}
{% load crispy_forms_tags %}
{% load crispy_forms_tags %}
{% load inventree_extras %}
<!DOCTYPE html>
<html lang="en">
@ -29,7 +30,7 @@
</style>
<title>
InvenTree
{% inventree_title %}
</title>
</head>
@ -42,7 +43,7 @@
<div class='container-fluid'>
<div class='clearfix content-heading login-header'>
<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>
<hr>

View File

@ -1,6 +1,7 @@
{% load static %}
{% load i18n %}
{% load crispy_forms_tags %}
{% load crispy_forms_tags %}
{% load inventree_extras %}
<!DOCTYPE html>
<html lang="en">
@ -29,7 +30,7 @@
</style>
<title>
InvenTree
{% inventree_title %}
</title>
</head>
@ -42,7 +43,7 @@
<div class='container-fluid'>
<div class='clearfix content-heading login-header'>
<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>
<hr>
<div class='container-fluid'>

View File

@ -1,6 +1,7 @@
{% load static %}
{% load i18n %}
{% load crispy_forms_tags %}
{% load crispy_forms_tags %}
{% load inventree_extras %}
<!DOCTYPE html>
<html lang="en">
@ -29,7 +30,7 @@
</style>
<title>
InvenTree
{% inventree_title %}
</title>
</head>
@ -42,7 +43,7 @@
<div class='container-fluid'>
<div class='clearfix content-heading login-header'>
<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>
<hr>

View File

@ -32,6 +32,7 @@
</ul>
</div>
{% endif %}
<!-- Printing actions menu -->
<div class='btn-group'>
<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>

View File

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

View File

@ -57,6 +57,7 @@ class RuleSet(models.Model):
'auth_user',
'auth_permission',
'authtoken_token',
'authtoken_tokenproxy',
'users_ruleset',
],
'part_category': [
@ -199,7 +200,8 @@ class RuleSet(models.Model):
if check_user_role(user, role, permission):
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
@staticmethod

View File

@ -1,11 +1,11 @@
invoke>=1.4.0 # Invoke build tool
wheel>=0.34.2 # Wheel
Django==3.0.7 # Django package
Django==3.2 # Django package
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-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-sql-utils==0.5.0 # Advanced query annotation / aggregation
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
pygments==2.7.4 # Syntax highlighting
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-cleanup==5.1.0 # Manage deletion of old / unused uploaded files
flake8==3.8.3 # PEP checking

View File

@ -154,6 +154,7 @@ def static(c):
as per Django requirements.
"""
manage(c, "prerender")
manage(c, "collectstatic --no-input")
@ -173,7 +174,7 @@ def update(c):
"""
pass
@task
@task(post=[static])
def translate(c):
"""
Regenerate translation files.
@ -183,7 +184,7 @@ def translate(c):
"""
# 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")
path = os.path.join('InvenTree', 'script', 'translation_stats.py')