Merge remote-tracking branch 'inventree/master'

This commit is contained in:
Oliver Walters 2020-05-26 12:52:17 +10:00
commit 03cc6892ea
40 changed files with 1199 additions and 210 deletions

View File

@ -5,6 +5,7 @@ Helper forms which subclass Django forms to provide additional functionality
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.utils.translation import ugettext as _
from django import forms
from crispy_forms.helper import FormHelper
from crispy_forms.layout import Layout, Field
@ -92,6 +93,20 @@ class HelperForm(forms.ModelForm):
self.helper.layout = Layout(*layouts)
class ConfirmForm(forms.Form):
""" Generic confirmation form """
confirm = forms.BooleanField(
required=False, initial=False,
help_text=_("Confirm")
)
class Meta:
fields = [
'confirm'
]
class DeleteForm(forms.Form):
""" Generic deletion form which provides simple user confirmation
"""
@ -99,7 +114,7 @@ class DeleteForm(forms.Form):
confirm_delete = forms.BooleanField(
required=False,
initial=False,
help_text='Confirm item deletion'
help_text=_('Confirm item deletion')
)
class Meta:
@ -131,14 +146,14 @@ class SetPasswordForm(HelperForm):
required=True,
initial='',
widget=forms.PasswordInput(attrs={'autocomplete': 'off'}),
help_text='Enter new password')
help_text=_('Enter new password'))
confirm_password = forms.CharField(max_length=100,
min_length=8,
required=True,
initial='',
widget=forms.PasswordInput(attrs={'autocomplete': 'off'}),
help_text='Confirm new password')
help_text=_('Confirm new password'))
class Meta:
model = User

View File

@ -72,6 +72,27 @@ if DEBUG:
format='%(asctime)s %(levelname)s %(message)s',
)
# Web URL endpoint for served static files
STATIC_URL = '/static/'
# The filesystem location for served static files
STATIC_ROOT = os.path.abspath(CONFIG.get('static_root', os.path.join(BASE_DIR, 'static')))
STATICFILES_DIRS = [
os.path.join(BASE_DIR, 'InvenTree', 'static'),
]
# Web URL endpoint for served media files
MEDIA_URL = '/media/'
# The filesystem location for served static files
MEDIA_ROOT = os.path.abspath(CONFIG.get('media_root', os.path.join(BASE_DIR, 'media')))
if DEBUG:
print("InvenTree running in DEBUG mode")
print("MEDIA_ROOT:", MEDIA_ROOT)
print("STATIC_ROOT:", STATIC_ROOT)
# Does the user wish to use the sentry.io integration?
sentry_opts = CONFIG.get('sentry', {})
@ -106,12 +127,13 @@ INSTALLED_APPS = [
'django.contrib.staticfiles',
# InvenTree apps
'common.apps.CommonConfig',
'part.apps.PartConfig',
'stock.apps.StockConfig',
'company.apps.CompanyConfig',
'build.apps.BuildConfig',
'common.apps.CommonConfig',
'company.apps.CompanyConfig',
'order.apps.OrderConfig',
'part.apps.PartConfig',
'report.apps.ReportConfig',
'stock.apps.StockConfig',
# Third part add-ons
'django_filters', # Extended filter functionality
@ -126,6 +148,7 @@ INSTALLED_APPS = [
'mptt', # Modified Preorder Tree Traversal
'markdownx', # Markdown editing
'markdownify', # Markdown template rendering
'django_tex', # LaTeX output
]
LOGGING = {
@ -160,7 +183,11 @@ ROOT_URLCONF = 'InvenTree.urls'
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [os.path.join(BASE_DIR, 'templates')],
'DIRS': [
os.path.join(BASE_DIR, 'templates'),
# Allow templates in the reporting directory to be accessed
os.path.join(MEDIA_ROOT, 'report'),
],
'APP_DIRS': True,
'OPTIONS': {
'context_processors': [
@ -173,6 +200,14 @@ TEMPLATES = [
],
},
},
# Backend for LaTeX report rendering
{
'NAME': 'tex',
'BACKEND': 'django_tex.engine.TeXEngine',
'DIRS': [
os.path.join(MEDIA_ROOT, 'report'),
]
},
]
REST_FRAMEWORK = {
@ -315,31 +350,22 @@ DATE_INPUT_FORMATS = [
"%Y-%m-%d",
]
# LaTeX rendering settings (django-tex)
LATEX_SETTINGS = CONFIG.get('latex', {})
# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/1.10/howto/static-files/
# Is LaTeX rendering enabled? (Off by default)
LATEX_ENABLED = LATEX_SETTINGS.get('enabled', False)
# Web URL endpoint for served static files
STATIC_URL = '/static/'
# Set the latex interpreter in the config.yaml settings file
LATEX_INTERPRETER = LATEX_SETTINGS.get('interpreter', 'pdflatex')
# The filesystem location for served static files
STATIC_ROOT = os.path.abspath(CONFIG.get('static_root', os.path.join(BASE_DIR, 'static')))
LATEX_INTERPRETER_OPTIONS = LATEX_SETTINGS.get('options', '')
STATICFILES_DIRS = [
os.path.join(BASE_DIR, 'InvenTree', 'static'),
LATEX_GRAPHICSPATH = [
# Allow LaTeX files to access the report assets directory
os.path.join(MEDIA_ROOT, "report", "assets"),
]
# Web URL endpoint for served media files
MEDIA_URL = '/media/'
# The filesystem location for served static files
MEDIA_ROOT = os.path.abspath(CONFIG.get('media_root', os.path.join(BASE_DIR, 'media')))
if DEBUG:
print("InvenTree running in DEBUG mode")
print("MEDIA_ROOT:", MEDIA_ROOT)
print("STATIC_ROOT:", STATIC_ROOT)
# crispy forms use the bootstrap templates
CRISPY_TEMPLATE_PACK = 'bootstrap3'

View File

@ -272,8 +272,9 @@ function setupFilterList(tableKey, table, target) {
for (var key in filters) {
var value = getFilterOptionValue(tableKey, key, filters[key]);
var title = getFilterTitle(tableKey, key);
var description = getFilterDescription(tableKey, key);
element.append(`<div class='filter-tag'>${title} = ${value}<span ${tag}='${key}' class='close'>x</span></div>`);
element.append(`<div title='${description}' class='filter-tag'>${title} = ${value}<span ${tag}='${key}' class='close'>x</span></div>`);
}
// Add a callback for adding a new filter
@ -362,6 +363,15 @@ function getFilterTitle(tableKey, filterKey) {
}
/**
* Return the pretty description for the given table and filter selection
*/
function getFilterDescription(tableKey, filterKey) {
var settings = getFilterSettings(tableKey, filterKey);
return settings.title;
}
/*
* Return a description for the given table and filter selection.
*/

View File

@ -166,6 +166,13 @@ class AjaxMixin(object):
except AttributeError:
context = {}
# If no 'form' argument is supplied, look at the underlying class
if form is None:
try:
form = self.get_form()
except AttributeError:
pass
if form:
context['form'] = form
else:

View File

@ -73,3 +73,16 @@ log_queries: False
sentry:
enabled: False
# dsn: add-your-sentry-dsn-here
# LaTeX report rendering
# InvenTree uses the django-tex plugin to enable LaTeX report rendering
# Ref: https://pypi.org/project/django-tex/
# Note: Ensure that a working LaTeX toolchain is installed and working *before* starting the server
latex:
# Select the LaTeX interpreter to use for PDF rendering
# Note: The intepreter needs to be installed on the system!
# e.g. to install pdflatex: apt-get texlive-latex-base
enabled: False
interpreter: pdflatex
# Extra options to pass through to the LaTeX interpreter
options: ''

View File

@ -39,7 +39,10 @@ class EditPartTestTemplateForm(HelperForm):
fields = [
'part',
'test_name',
'required'
'description',
'required',
'requires_value',
'requires_attachment',
]

View File

@ -0,0 +1,33 @@
# Generated by Django 3.0.5 on 2020-05-18 09:00
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('part', '0041_auto_20200517_0348'),
]
operations = [
migrations.AddField(
model_name='parttesttemplate',
name='description',
field=models.CharField(help_text='Enter description for this test', max_length=100, null=True, verbose_name='Test Description'),
),
migrations.AddField(
model_name='parttesttemplate',
name='requires_attachment',
field=models.BooleanField(default=False, help_text='Does this test require a file attachment when adding a test result?', verbose_name='Requires Attachment'),
),
migrations.AddField(
model_name='parttesttemplate',
name='requires_value',
field=models.BooleanField(default=False, help_text='Does this test require a value when adding a test result?', verbose_name='Requires Value'),
),
migrations.AlterField(
model_name='parttesttemplate',
name='test_name',
field=models.CharField(help_text='Enter a name for the test', max_length=100, verbose_name='Test Name'),
),
]

View File

@ -41,6 +41,7 @@ from InvenTree.helpers import decimal2string, normalize
from InvenTree.status_codes import BuildStatus, PurchaseOrderStatus
from report import models as ReportModels
from build import models as BuildModels
from order import models as OrderModels
from company.models import SupplierPart
@ -358,6 +359,24 @@ class Part(MPTTModel):
self.category = category
self.save()
def get_test_report_templates(self):
"""
Return all the TestReport template objects which map to this Part.
"""
templates = []
for report in ReportModels.TestReport.objects.all():
if report.matches_part(self):
templates.append(report)
return templates
def has_test_report_templates(self):
""" Return True if this part has a TestReport defined """
return len(self.get_test_report_templates()) > 0
def get_absolute_url(self):
""" Return the web URL for viewing this part """
return reverse('part-detail', kwargs={'pk': self.id})
@ -1014,6 +1033,9 @@ class Part(MPTTModel):
# Return the tests which are required by this part
return self.getTestTemplates(required=True)
def requiredTestCount(self):
return self.getRequiredTests().count()
@property
def attachment_count(self):
""" Count the number of attachments for this part.
@ -1087,6 +1109,17 @@ class Part(MPTTModel):
return self.parameters.order_by('template__name')
@property
def has_variants(self):
""" Check if this Part object has variants underneath it. """
return self.get_all_variants().count() > 0
def get_all_variants(self):
""" Return all Part object which exist as a variant under this part. """
return self.get_descendants(include_self=False)
def attach_file(instance, filename):
""" Function for storing a file for a PartAttachment
@ -1204,16 +1237,34 @@ class PartTestTemplate(models.Model):
test_name = models.CharField(
blank=False, max_length=100,
verbose_name=_("Test name"),
verbose_name=_("Test Name"),
help_text=_("Enter a name for the test")
)
description = models.CharField(
blank=False, null=True, max_length=100,
verbose_name=_("Test Description"),
help_text=_("Enter description for this test")
)
required = models.BooleanField(
default=True,
verbose_name=_("Required"),
help_text=_("Is this test required to pass?")
)
requires_value = models.BooleanField(
default=False,
verbose_name=_("Requires Value"),
help_text=_("Does this test require a value when adding a test result?")
)
requires_attachment = models.BooleanField(
default=False,
verbose_name=_("Requires Attachment"),
help_text=_("Does this test require a file attachment when adding a test result?")
)
class PartParameterTemplate(models.Model):
"""
@ -1299,6 +1350,11 @@ class BomItem(models.Model):
checksum: Validation checksum for the particular BOM line item
"""
def save(self, *args, **kwargs):
self.clean()
super().save(*args, **kwargs)
def get_absolute_url(self):
return reverse('bom-item-detail', kwargs={'pk': self.id})
@ -1391,6 +1447,16 @@ class BomItem(models.Model):
- A part cannot refer to a part which refers to it
"""
# If the sub_part is 'trackable' then the 'quantity' field must be an integer
try:
if self.sub_part.trackable:
if not self.quantity == int(self.quantity):
raise ValidationError({
"quantity": _("Quantity must be integer value for trackable parts")
})
except Part.DoesNotExist:
pass
# A part cannot refer to itself in its BOM
try:
if self.sub_part is not None and self.part is not None:

View File

@ -62,14 +62,20 @@ class PartTestTemplateSerializer(InvenTreeModelSerializer):
Serializer for the PartTestTemplate class
"""
key = serializers.CharField(read_only=True)
class Meta:
model = PartTestTemplate
fields = [
'pk',
'key',
'part',
'test_name',
'required'
'description',
'required',
'requires_value',
'requires_attachment',
]
@ -99,9 +105,11 @@ class PartBriefSerializer(InvenTreeModelSerializer):
'thumbnail',
'active',
'assembly',
'is_template',
'purchaseable',
'salable',
'stock',
'trackable',
'virtual',
]

View File

@ -33,6 +33,13 @@
<td>{{ part.revision }}</td>
</tr>
{% endif %}
{% if part.trackable %}
<tr>
<td><span class='fas fa-hashtag'></span></td>
<td><b>{% trans "Next Serial Number" %}</b></td>
<td>{{ part.getNextSerialNumber }}</td>
</tr>
{% endif %}
<tr>
<td><span class='fas fa-info-circle'></span></td>
<td><b>{% trans "Description" %}</b></td>

View File

@ -6,11 +6,14 @@
{% block content %}
{% if part.virtual %}
<div class='alert alert-info alert-block'>
{% trans "This part is a virtual part" %}
</div>
{% endif %}
{% if part.is_template %}
<div class='alert alert-info alert-block'>
{% trans "This part is a template part." %}
<br>
{% trans "It is not a real part, but real parts can be based on this template." %}
</div>
{% endif %}
{% if part.variant_of %}

View File

@ -13,9 +13,11 @@
<a href="{% url 'part-variants' part.id %}">{% trans "Variants" %} <span class='badge'>{{ part.variants.count }}</span></span></a>
</li>
{% endif %}
{% if not part.virtual %}
<li{% ifequal tab 'stock' %} class="active"{% endifequal %}>
<a href="{% url 'part-stock' part.id %}">{% trans "Stock" %} <span class="badge">{% decimal part.total_stock %}</span></a>
</li>
{% endif %}
{% if part.component or part.used_in_count > 0 %}
<li{% ifequal tab 'allocation' %} class="active"{% endifequal %}>
<a href="{% url 'part-allocation' part.id %}">{% trans "Allocated" %} <span class="badge">{% decimal part.allocation_count %}</span></a>

View File

@ -44,6 +44,20 @@ class BomItemTest(TestCase):
item = BomItem.objects.create(part=self.bob, sub_part=self.bob, quantity=7)
item.clean()
def test_integer_quantity(self):
"""
Test integer validation for BomItem
"""
p = Part.objects.create(name="test", description="d", component=True, trackable=True)
# Creation of a BOMItem with a non-integer quantity of a trackable Part should fail
with self.assertRaises(django_exceptions.ValidationError):
BomItem.objects.create(part=self.bob, sub_part=p, quantity=21.7)
# But with an integer quantity, should be fine
BomItem.objects.create(part=self.bob, sub_part=p, quantity=21)
def test_overage(self):
""" Test that BOM line overages are calculated correctly """

View File

22
InvenTree/report/admin.py Normal file
View File

@ -0,0 +1,22 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.contrib import admin
from .models import ReportTemplate, ReportAsset
from .models import TestReport
class ReportTemplateAdmin(admin.ModelAdmin):
list_display = ('name', 'description', 'template')
class ReportAssetAdmin(admin.ModelAdmin):
list_display = ('asset', 'description')
admin.site.register(ReportTemplate, ReportTemplateAdmin)
admin.site.register(TestReport, ReportTemplateAdmin)
admin.site.register(ReportAsset, ReportAssetAdmin)

5
InvenTree/report/apps.py Normal file
View File

@ -0,0 +1,5 @@
from django.apps import AppConfig
class ReportConfig(AppConfig):
name = 'report'

View File

@ -0,0 +1,49 @@
# Generated by Django 3.0.5 on 2020-05-22 11:00
import django.core.validators
from django.db import migrations, models
import report.models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='ReportAsset',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('asset', models.FileField(help_text='Report asset file', upload_to=report.models.rename_asset)),
('description', models.CharField(help_text='Asset file description', max_length=250)),
],
),
migrations.CreateModel(
name='ReportTemplate',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(help_text='Template name', max_length=100, unique=True)),
('template', models.FileField(help_text='Report template file', upload_to=report.models.rename_template, validators=[django.core.validators.FileExtensionValidator(allowed_extensions=['html', 'htm', 'tex'])])),
('description', models.CharField(help_text='Report template description', max_length=250)),
],
options={
'abstract': False,
},
),
migrations.CreateModel(
name='TestReport',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(help_text='Template name', max_length=100, unique=True)),
('template', models.FileField(help_text='Report template file', upload_to=report.models.rename_template, validators=[django.core.validators.FileExtensionValidator(allowed_extensions=['html', 'htm', 'tex'])])),
('description', models.CharField(help_text='Report template description', max_length=250)),
('part_filters', models.CharField(blank=True, help_text='Part query filters (comma-separated list of key=value pairs)', max_length=250, validators=[report.models.validateFilterString])),
],
options={
'abstract': False,
},
),
]

View File

269
InvenTree/report/models.py Normal file
View File

@ -0,0 +1,269 @@
"""
Report template model definitions
"""
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
import os
import sys
from django.db import models
from django.conf import settings
from django.core.validators import FileExtensionValidator
from django.core.exceptions import ValidationError
from django.utils.translation import gettext_lazy as _
from part import models as PartModels
try:
from django_weasyprint import WeasyTemplateResponseMixin
except OSError as err:
print("OSError: {e}".format(e=err))
print("You may require some further system packages to be installed.")
sys.exit(1)
# Conditional import if LaTeX templating is enabled
if settings.LATEX_ENABLED:
try:
from django_tex.shortcuts import render_to_pdf
except OSError as err:
print("OSError: {e}".format(e=err))
print("You may not have a working LaTeX toolchain installed?")
sys.exit(1)
def rename_template(instance, filename):
filename = os.path.basename(filename)
return os.path.join('report', 'report_template', instance.getSubdir(), filename)
def validateFilterString(value):
"""
Validate that a provided filter string looks like a list of comma-separated key=value pairs
These should nominally match to a valid database filter based on the model being filtered.
e.g. "category=6, IPN=12"
e.g. "part__name=widget"
The ReportTemplate class uses the filter string to work out which items a given report applies to.
For example, an acceptance test report template might only apply to stock items with a given IPN,
so the string could be set to:
filters = "IPN = ACME0001"
Returns a map of key:value pairs
"""
# Empty results map
results = {}
value = str(value).strip()
if not value or len(value) == 0:
return results
groups = value.split(',')
for group in groups:
group = group.strip()
pair = group.split('=')
if not len(pair) == 2:
raise ValidationError(
"Invalid group: {g}".format(g=group)
)
k, v = pair
k = k.strip()
v = v.strip()
if not k or not v:
raise ValidationError(
"Invalid group: {g}".format(g=group)
)
results[k] = v
return results
class WeasyprintReportMixin(WeasyTemplateResponseMixin):
"""
Class for rendering a HTML template to a PDF.
"""
pdf_filename = 'report.pdf'
pdf_attachment = True
def __init__(self, request, template, **kwargs):
self.request = request
self.template_name = template
self.pdf_filename = kwargs.get('filename', 'report.pdf')
class ReportTemplateBase(models.Model):
"""
Reporting template model.
"""
def __str__(self):
return "{n} - {d}".format(n=self.name, d=self.description)
def getSubdir(self):
return ''
@property
def extension(self):
return os.path.splitext(self.template.name)[1].lower()
@property
def template_name(self):
return os.path.join('report_template', self.getSubdir(), os.path.basename(self.template.name))
def get_context_data(self, request):
"""
Supply context data to the template for rendering
"""
return {}
def render(self, request, **kwargs):
"""
Render the template to a PDF file.
Supported template formats:
.tex - Uses django-tex plugin to render LaTeX template against an installed LaTeX engine
.html - Uses django-weasyprint plugin to render HTML template against Weasyprint
"""
filename = kwargs.get('filename', 'report.pdf')
context = self.get_context_data(request)
context['request'] = request
if self.extension == '.tex':
# Render LaTeX template to PDF
if settings.LATEX_ENABLED:
return render_to_pdf(request, self.template_name, context, filename=filename)
else:
return ValidationError("Enable LaTeX support in config.yaml")
elif self.extension in ['.htm', '.html']:
# Render HTML template to PDF
wp = WeasyprintReportMixin(request, self.template_name, **kwargs)
return wp.render_to_response(context, **kwargs)
name = models.CharField(
blank=False, max_length=100,
help_text=_('Template name'),
unique=True,
)
template = models.FileField(
upload_to=rename_template,
help_text=_("Report template file"),
validators=[FileExtensionValidator(allowed_extensions=['html', 'htm', 'tex'])],
)
description = models.CharField(max_length=250, help_text=_("Report template description"))
class Meta:
abstract = True
class ReportTemplate(ReportTemplateBase):
"""
A simple reporting template which is used to upload template files,
which can then be used in other concrete template classes.
"""
pass
class PartFilterMixin(models.Model):
"""
A model mixin used for matching a report type against a Part object.
Used to assign a report to a given part using custom filters.
"""
class Meta:
abstract = True
def matches_part(self, part):
"""
Test if this report matches a given part.
"""
filters = self.get_part_filters()
parts = PartModels.Part.objects.filter(**filters)
parts = parts.filter(pk=part.pk)
return parts.exists()
def get_part_filters(self):
""" Return a map of filters to be used for Part filtering """
return validateFilterString(self.part_filters)
part_filters = models.CharField(
blank=True,
max_length=250,
help_text=_("Part query filters (comma-separated list of key=value pairs)"),
validators=[validateFilterString]
)
class TestReport(ReportTemplateBase, PartFilterMixin):
"""
Render a TestReport against a StockItem object.
"""
def getSubdir(self):
return 'test'
# Requires a stock_item object to be given to it before rendering
stock_item = None
def get_context_data(self, request):
return {
'stock_item': self.stock_item,
'part': self.stock_item.part,
'results': self.stock_item.testResultMap(),
'result_list': self.stock_item.testResultList()
}
def rename_asset(instance, filename):
filename = os.path.basename(filename)
return os.path.join('report', 'assets', filename)
class ReportAsset(models.Model):
"""
Asset file for use in report templates.
For example, an image to use in a header file.
Uploaded asset files appear in MEDIA_ROOT/report/assets,
and can be loaded in a template using the {% report_asset <filename> %} tag.
"""
def __str__(self):
return os.path.basename(self.asset.name)
asset = models.FileField(
upload_to=rename_asset,
help_text=_("Report asset file"),
)
description = models.CharField(max_length=250, help_text=_("Asset file description"))

View File

@ -0,0 +1,2 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals

View File

@ -0,0 +1,2 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals

View File

@ -80,21 +80,10 @@ class StockDetail(generics.RetrieveUpdateDestroyAPIView):
def get_serializer(self, *args, **kwargs):
try:
kwargs['part_detail'] = str2bool(self.request.query_params.get('part_detail', False))
except AttributeError:
pass
try:
kwargs['location_detail'] = str2bool(self.request.query_params.get('location_detail', False))
except AttributeError:
pass
try:
kwargs['supplier_part_detail'] = str2bool(self.request.query_params.get('supplier_detail', False))
except AttributeError:
pass
kwargs['part_detail'] = True
kwargs['location_detail'] = True
kwargs['supplier_part_detail'] = True
kwargs['test_detail'] = True
kwargs['context'] = self.get_serializer_context()
return self.serializer_class(*args, **kwargs)
@ -498,8 +487,21 @@ class StockList(generics.ListCreateAPIView):
if serial_number is not None:
queryset = queryset.filter(serial=serial_number)
# Filter by range of serial numbers?
serial_number_gte = params.get('serial_gte', None)
serial_number_lte = params.get('serial_lte', None)
if serial_number_gte is not None or serial_number_lte is not None:
queryset = queryset.exclude(serial=None)
if serial_number_gte is not None:
queryset = queryset.filter(serial__gte=serial_number_gte)
in_stock = self.request.query_params.get('in_stock', None)
if serial_number_lte is not None:
queryset = queryset.filter(serial__lte=serial_number_lte)
in_stock = params.get('in_stock', None)
if in_stock is not None:
in_stock = str2bool(in_stock)
@ -512,7 +514,7 @@ class StockList(generics.ListCreateAPIView):
queryset = queryset.exclude(StockItem.IN_STOCK_FILTER)
# Filter by 'allocated' patrs?
allocated = self.request.query_params.get('allocated', None)
allocated = params.get('allocated', None)
if allocated is not None:
allocated = str2bool(allocated)
@ -531,8 +533,14 @@ class StockList(generics.ListCreateAPIView):
active = str2bool(active)
queryset = queryset.filter(part__active=active)
# Filter by internal part number
IPN = params.get('IPN', None)
if IPN:
queryset = queryset.filter(part__IPN=IPN)
# Does the client wish to filter by the Part ID?
part_id = self.request.query_params.get('part', None)
part_id = params.get('part', None)
if part_id:
try:
@ -692,17 +700,14 @@ class StockItemTestResultList(generics.ListCreateAPIView):
'value',
]
ordering = 'date'
def get_serializer(self, *args, **kwargs):
try:
kwargs['user_detail'] = str2bool(self.request.query_params.get('user_detail', False))
except:
pass
try:
kwargs['attachment_detail'] = str2bool(self.request.query_params.get('attachment_detail', False))
except:
pass
kwargs['context'] = self.get_serializer_context()
return self.serializer_class(*args, **kwargs)
@ -718,23 +723,6 @@ class StockItemTestResultList(generics.ListCreateAPIView):
# Capture the user information
test_result = serializer.save()
test_result.user = self.request.user
# Check if a file has been attached to the request
attachment_file = self.request.FILES.get('attachment', None)
if attachment_file:
# Create a new attachment associated with the stock item
attachment = StockItemAttachment(
attachment=attachment_file,
stock_item=test_result.stock_item,
user=test_result.user
)
attachment.save()
# Link the attachment back to the test result
test_result.attachment = attachment
test_result.save()

View File

@ -63,6 +63,18 @@ class EditStockLocationForm(HelperForm):
]
class ConvertStockItemForm(HelperForm):
"""
Form for converting a StockItem to a variant of its current part.
"""
class Meta:
model = StockItem
fields = [
'part'
]
class CreateStockItemForm(HelperForm):
""" Form for creating a new StockItem """
@ -142,6 +154,34 @@ class SerializeStockForm(HelperForm):
]
class TestReportFormatForm(HelperForm):
""" Form for selection a test report template """
class Meta:
model = StockItem
fields = [
'template',
]
def __init__(self, stock_item, *args, **kwargs):
self.stock_item = stock_item
super().__init__(*args, **kwargs)
self.fields['template'].choices = self.get_template_choices()
def get_template_choices(self):
""" Available choices """
choices = []
for report in self.stock_item.part.get_test_report_templates():
choices.append((report.pk, report))
return choices
template = forms.ChoiceField(label=_('Template'), help_text=_('Select test report template'))
class ExportOptionsForm(HelperForm):
""" Form for selecting stock export options """

View File

@ -0,0 +1,19 @@
# Generated by Django 3.0.5 on 2020-05-23 01:21
from django.db import migrations, models
import stock.models
class Migration(migrations.Migration):
dependencies = [
('stock', '0041_stockitemtestresult_notes'),
]
operations = [
migrations.AlterField(
model_name='stockitemtestresult',
name='attachment',
field=models.FileField(blank=True, help_text='Test result attachment', null=True, upload_to=stock.models.rename_stock_item_test_result_attachment, verbose_name='Attachment'),
),
]

View File

@ -0,0 +1,20 @@
# Generated by Django 3.0.5 on 2020-05-25 04:20
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('part', '0042_auto_20200518_0900'),
('stock', '0042_auto_20200523_0121'),
]
operations = [
migrations.AlterField(
model_name='stockitem',
name='part',
field=models.ForeignKey(help_text='Base part', limit_choices_to={'active': True, 'virtual': False}, on_delete=django.db.models.deletion.CASCADE, related_name='stock_items', to='part.Part', verbose_name='Base Part'),
),
]

View File

@ -331,7 +331,6 @@ class StockItem(MPTTModel):
verbose_name=_('Base Part'),
related_name='stock_items', help_text=_('Base part'),
limit_choices_to={
'is_template': False,
'active': True,
'virtual': False
})
@ -647,6 +646,9 @@ class StockItem(MPTTModel):
# Copy entire transaction history
new_item.copyHistoryFrom(self)
# Copy test result history
new_item.copyTestResultsFrom(self)
# Create a new stock tracking item
new_item.addTransactionNote(_('Add serial number'), user, notes=notes)
@ -655,7 +657,7 @@ class StockItem(MPTTModel):
@transaction.atomic
def copyHistoryFrom(self, other):
""" Copy stock history from another part """
""" Copy stock history from another StockItem """
for item in other.tracking_info.all():
@ -663,6 +665,17 @@ class StockItem(MPTTModel):
item.pk = None
item.save()
@transaction.atomic
def copyTestResultsFrom(self, other, filters={}):
""" Copy all test results from another StockItem """
for result in other.test_results.all().filter(**filters):
# Create a copy of the test result by nulling-out the pk
result.pk = None
result.stock_item = self
result.save()
@transaction.atomic
def splitStock(self, quantity, location, user):
""" Split this stock item into two items, in the same location.
@ -713,6 +726,9 @@ class StockItem(MPTTModel):
# Copy the transaction history of this part into the new one
new_stock.copyHistoryFrom(self)
# Copy the test results of this part to the new one
new_stock.copyTestResultsFrom(self)
# Add a new tracking item for the new stock item
new_stock.addTransactionNote(
"Split from existing stock",
@ -963,6 +979,13 @@ class StockItem(MPTTModel):
return result_map
def testResultList(self, **kwargs):
"""
Return a list of test-result objects for this StockItem
"""
return self.testResultMap(**kwargs).values()
def requiredTestStatus(self):
"""
Return the status of the tests required for this StockItem.
@ -1000,6 +1023,10 @@ class StockItem(MPTTModel):
'failed': failed,
}
@property
def required_test_count(self):
return self.part.getRequiredTests().count()
def hasRequiredTests(self):
return self.part.getRequiredTests().count() > 0
@ -1083,6 +1110,11 @@ class StockItemTracking(models.Model):
# file = models.FileField()
def rename_stock_item_test_result_attachment(instance, filename):
return os.path.join('stock_files', str(instance.stock_item.pk), os.path.basename(filename))
class StockItemTestResult(models.Model):
"""
A StockItemTestResult records results of custom tests against individual StockItem objects.
@ -1102,19 +1134,41 @@ class StockItemTestResult(models.Model):
date: Date the test result was recorded
"""
def save(self, *args, **kwargs):
super().clean()
super().validate_unique()
super().save(*args, **kwargs)
def clean(self):
super().clean()
# If an attachment is linked to this result, the attachment must also point to the item
try:
if self.attachment:
if not self.attachment.stock_item == self.stock_item:
raise ValidationError({
'attachment': _("Test result attachment must be linked to the same StockItem"),
})
except (StockItem.DoesNotExist, StockItemAttachment.DoesNotExist):
pass
# If this test result corresponds to a template, check the requirements of the template
key = self.key
templates = self.stock_item.part.getTestTemplates()
for template in templates:
if key == template.key:
if template.requires_value:
if not self.value:
raise ValidationError({
"value": _("Value must be provided for this test"),
})
if template.requires_attachment:
if not self.attachment:
raise ValidationError({
"attachment": _("Attachment must be uploaded for this test"),
})
break
@property
def key(self):
return helpers.generateTestKey(self.test)
stock_item = models.ForeignKey(
StockItem,
@ -1140,10 +1194,9 @@ class StockItemTestResult(models.Model):
help_text=_('Test output value')
)
attachment = models.ForeignKey(
StockItemAttachment,
on_delete=models.SET_NULL,
blank=True, null=True,
attachment = models.FileField(
null=True, blank=True,
upload_to=rename_stock_item_test_result_attachment,
verbose_name=_('Attachment'),
help_text=_('Test result attachment'),
)

View File

@ -108,11 +108,14 @@ class StockItemSerializer(InvenTreeModelSerializer):
quantity = serializers.FloatField()
allocated = serializers.FloatField()
required_tests = serializers.IntegerField(source='required_test_count', read_only=True)
def __init__(self, *args, **kwargs):
part_detail = kwargs.pop('part_detail', False)
location_detail = kwargs.pop('location_detail', False)
supplier_part_detail = kwargs.pop('supplier_part_detail', False)
test_detail = kwargs.pop('test_detail', False)
super(StockItemSerializer, self).__init__(*args, **kwargs)
@ -125,6 +128,9 @@ class StockItemSerializer(InvenTreeModelSerializer):
if supplier_part_detail is not True:
self.fields.pop('supplier_part_detail')
if test_detail is not True:
self.fields.pop('required_tests')
class Meta:
model = StockItem
fields = [
@ -141,6 +147,7 @@ class StockItemSerializer(InvenTreeModelSerializer):
'part_detail',
'pk',
'quantity',
'required_tests',
'sales_order',
'serial',
'supplier_part',
@ -222,31 +229,28 @@ class StockItemTestResultSerializer(InvenTreeModelSerializer):
""" Serializer for the StockItemTestResult model """
user_detail = UserSerializerBrief(source='user', read_only=True)
attachment_detail = StockItemAttachmentSerializer(source='attachment', read_only=True)
key = serializers.CharField(read_only=True)
def __init__(self, *args, **kwargs):
user_detail = kwargs.pop('user_detail', False)
attachment_detail = kwargs.pop('attachment_detail', False)
super().__init__(*args, **kwargs)
if user_detail is not True:
self.fields.pop('user_detail')
if attachment_detail is not True:
self.fields.pop('attachment_detail')
class Meta:
model = StockItemTestResult
fields = [
'pk',
'stock_item',
'key',
'test',
'result',
'value',
'attachment',
'attachment_detail',
'notes',
'user',
'user_detail',
@ -255,7 +259,6 @@ class StockItemTestResultSerializer(InvenTreeModelSerializer):
read_only_fields = [
'pk',
'attachment',
'user',
'date',
]

View File

@ -93,8 +93,18 @@ InvenTree | {% trans "Stock Item" %} - {{ item }}
<span class='fas fa-copy'/>
</button>
{% endif %}
{% if item.part.has_variants %}
<button type='button' class='btn btn-default' id='stock-convert' title="Convert stock to variant">
<span class='fas fa-screwdriver'/>
</button>
{% endif %}
{% if item.part.has_test_report_templates %}
<button type='button' class='btn btn-default' id='stock-test-report' title='Generate test report'>
<span class='fas fa-tasks'/>
</button>
{% endif %}
<button type='button' class='btn btn-default' id='stock-edit' title='Edit stock item'>
<span class='fas fa-edit'/>
<span class='fas fa-edit icon-blue'/>
</button>
{% if item.can_delete %}
<button type='button' class='btn btn-default' id='stock-delete' title='Edit stock item'>
@ -264,6 +274,17 @@ $("#stock-serialize").click(function() {
);
});
{% if item.part.has_test_report_templates %}
$("#stock-test-report").click(function() {
launchModalForm(
"{% url 'stock-item-test-report-select' item.id %}",
{
follow: true,
}
);
});
{% endif %}
$("#stock-duplicate").click(function() {
launchModalForm(
"{% url 'stock-item-create' %}",
@ -308,6 +329,16 @@ function itemAdjust(action) {
);
}
{% if item.part.has_variants %}
$("#stock-convert").click(function() {
launchModalForm("{% url 'stock-item-convert' item.id %}",
{
reload: true,
}
);
});
{% endif %}
$("#stock-move").click(function() {
itemAdjust("move");
});

View File

@ -13,7 +13,13 @@
<div id='button-toolbar'>
<div class='button-toolbar container-fluid' style="float: right;">
<div class='btn-group' role='group'>
{% if user.is_staff %}
<button type='button' class='btn btn-danger' id='delete-test-results'>{% trans "Delete Test Data" %}</button>
{% endif %}
<button type='button' class='btn btn-success' id='add-test-result'>{% trans "Add Test Data" %}</button>
{% if item.part.has_test_report_templates %}
<button type='button' class='btn btn-default' id='test-report'>{% trans "Test Report" %} <span class='fas fa-tasks'></span></button>
{% endif %}
</div>
<div class='filter-list' id='filter-list-stocktests'>
<!-- Empty div -->
@ -40,6 +46,28 @@ function reloadTable() {
//$("#test-result-table").bootstrapTable("refresh");
}
{% if item.part.has_test_report_templates %}
$("#test-report").click(function() {
launchModalForm(
"{% url 'stock-item-test-report-select' item.id %}",
{
follow: true,
}
);
});
{% endif %}
{% if user.is_staff %}
$("#delete-test-results").click(function() {
launchModalForm(
"{% url 'stock-item-delete-test-data' item.id %}",
{
success: reloadTable,
}
);
});
{% endif %}
$("#add-test-result").click(function() {
launchModalForm(
"{% url 'stock-item-test-create' %}", {

View File

@ -32,7 +32,7 @@
<input class='numberinput'
min='0'
{% if stock_action == 'take' or stock_action == 'move' %} max='{{ item.quantity }}' {% endif %}
value='{{ item.new_quantity }}' type='number' name='stock-id-{{ item.id }}' id='stock-id-{{ item.id }}'/>
value='{% decimal item.new_quantity %}' type='number' name='stock-id-{{ item.id }}' id='stock-id-{{ item.id }}'/>
{% if item.error %}
<br><span class='help-inline'>{{ item.error }}</span>
{% endif %}

View File

@ -0,0 +1,17 @@
{% extends "modal_form.html" %}
{% load i18n %}
{% block pre_form_content %}
<div class='alert alert-block alert-info'>
<b>{% trans "Convert Stock Item" %}</b><br>
{% trans "This stock item is current an instance of " %}<i>{{ item.part }}</i><br>
{% trans "It can be converted to one of the part variants listed below." %}
</div>
<div class='alert alert-block alert-warning'>
<b>{% trans "Warning" %}</b>
{% trans "This action cannot be easily undone" %}
</div>
{% endblock %}

View File

@ -458,3 +458,68 @@ class TestResultTest(StockTest):
)
self.assertTrue(item.passedAllRequiredTests())
def test_duplicate_item_tests(self):
# Create an example stock item by copying one from the database (because we are lazy)
item = StockItem.objects.get(pk=522)
item.pk = None
item.serial = None
item.quantity = 50
item.save()
# Do some tests!
StockItemTestResult.objects.create(
stock_item=item,
test="Firmware",
result=True
)
StockItemTestResult.objects.create(
stock_item=item,
test="Paint Color",
result=True,
value="Red"
)
StockItemTestResult.objects.create(
stock_item=item,
test="Applied Sticker",
result=False
)
self.assertEqual(item.test_results.count(), 3)
self.assertEqual(item.quantity, 50)
# Split some items out
item2 = item.splitStock(20, None, None)
self.assertEqual(item.quantity, 30)
self.assertEqual(item.test_results.count(), 3)
self.assertEqual(item2.test_results.count(), 3)
StockItemTestResult.objects.create(
stock_item=item2,
test='A new test'
)
self.assertEqual(item.test_results.count(), 3)
self.assertEqual(item2.test_results.count(), 4)
# Test StockItem serialization
item2.serializeStock(1, [100], self.user)
# Add a test result to the parent *after* serialization
StockItemTestResult.objects.create(
stock_item=item2,
test='abcde'
)
self.assertEqual(item2.test_results.count(), 5)
item3 = StockItem.objects.get(serial=100, part=item2.part)
self.assertEqual(item3.test_results.count(), 4)

View File

@ -18,12 +18,16 @@ stock_location_detail_urls = [
stock_item_detail_urls = [
url(r'^edit/', views.StockItemEdit.as_view(), name='stock-item-edit'),
url(r'^convert/', views.StockItemConvert.as_view(), name='stock-item-convert'),
url(r'^serialize/', views.StockItemSerialize.as_view(), name='stock-item-serialize'),
url(r'^delete/', views.StockItemDelete.as_view(), name='stock-item-delete'),
url(r'^qr_code/', views.StockItemQRCode.as_view(), name='stock-item-qr'),
url(r'^delete_test_data/', views.StockItemDeleteTestData.as_view(), name='stock-item-delete-test-data'),
url(r'^add_tracking/', views.StockItemTrackingCreate.as_view(), name='stock-tracking-create'),
url(r'^test-report-select/', views.StockItemTestReportSelect.as_view(), name='stock-item-test-report-select'),
url(r'^test/', views.StockItemDetail.as_view(template_name='stock/item_tests.html'), name='stock-item-test-results'),
url(r'^children/', views.StockItemDetail.as_view(template_name='stock/item_childs.html'), name='stock-item-children'),
url(r'^attachments/', views.StockItemDetail.as_view(template_name='stock/item_attachments.html'), name='stock-item-attachments'),
@ -52,6 +56,8 @@ stock_urls = [
url(r'^item/new/?', views.StockItemCreate.as_view(), name='stock-item-create'),
url(r'^item/test-report-download/', views.StockItemTestReportDownload.as_view(), name='stock-item-test-report-download'),
# URLs for StockItem attachments
url(r'^item/attachment/', include([
url(r'^new/', views.StockItemAttachmentCreate.as_view(), name='stock-item-attachment-create'),

View File

@ -17,6 +17,7 @@ from django.utils.translation import ugettext as _
from InvenTree.views import AjaxView
from InvenTree.views import AjaxUpdateView, AjaxDeleteView, AjaxCreateView
from InvenTree.views import QRCodeView
from InvenTree.forms import ConfirmForm
from InvenTree.helpers import str2bool, DownloadFile, GetExportFormats
from InvenTree.helpers import ExtractSerialNumbers
@ -26,19 +27,12 @@ from datetime import datetime
from company.models import Company, SupplierPart
from part.models import Part
from report.models import TestReport
from .models import StockItem, StockLocation, StockItemTracking, StockItemAttachment, StockItemTestResult
from .admin import StockItemResource
from .forms import EditStockLocationForm
from .forms import CreateStockItemForm
from .forms import EditStockItemForm
from .forms import AdjustStockForm
from .forms import TrackingEntryForm
from .forms import SerializeStockForm
from .forms import ExportOptionsForm
from .forms import EditStockItemAttachmentForm
from .forms import EditStockItemTestResultForm
from . import forms as StockForms
class StockIndex(ListView):
@ -113,7 +107,7 @@ class StockLocationEdit(AjaxUpdateView):
"""
model = StockLocation
form_class = EditStockLocationForm
form_class = StockForms.EditStockLocationForm
context_object_name = 'location'
ajax_template_name = 'modal_form.html'
ajax_form_title = _('Edit Stock Location')
@ -157,7 +151,7 @@ class StockItemAttachmentCreate(AjaxCreateView):
"""
model = StockItemAttachment
form_class = EditStockItemAttachmentForm
form_class = StockForms.EditStockItemAttachmentForm
ajax_form_title = _("Add Stock Item Attachment")
ajax_template_name = "modal_form.html"
@ -202,7 +196,7 @@ class StockItemAttachmentEdit(AjaxUpdateView):
"""
model = StockItemAttachment
form_class = EditStockItemAttachmentForm
form_class = StockForms.EditStockItemAttachmentForm
ajax_form_title = _("Edit Stock Item Attachment")
def get_form(self):
@ -229,13 +223,48 @@ class StockItemAttachmentDelete(AjaxDeleteView):
}
class StockItemDeleteTestData(AjaxUpdateView):
"""
View for deleting all test data
"""
model = StockItem
form_class = ConfirmForm
ajax_form_title = _("Delete All Test Data")
def get_form(self):
return ConfirmForm()
def post(self, request, *args, **kwargs):
valid = False
stock_item = StockItem.objects.get(pk=self.kwargs['pk'])
form = self.get_form()
confirm = str2bool(request.POST.get('confirm', False))
if confirm is not True:
form.errors['confirm'] = [_('Confirm test data deletion')]
form.non_field_errors = [_('Check the confirmation box')]
else:
stock_item.test_results.all().delete()
valid = True
data = {
'form_valid': valid,
}
return self.renderJsonResponse(request, form, data)
class StockItemTestResultCreate(AjaxCreateView):
"""
View for adding a new StockItemTestResult
"""
model = StockItemTestResult
form_class = EditStockItemTestResultForm
form_class = StockForms.EditStockItemTestResultForm
ajax_form_title = _("Add Test Result")
def post_save(self, **kwargs):
@ -263,17 +292,6 @@ class StockItemTestResultCreate(AjaxCreateView):
form = super().get_form()
form.fields['stock_item'].widget = HiddenInput()
# Extract the StockItem object
item_id = form['stock_item'].value()
# Limit the options for the file attachments
try:
stock_item = StockItem.objects.get(pk=item_id)
form.fields['attachment'].queryset = stock_item.attachments.all()
except (ValueError, StockItem.DoesNotExist):
# Hide the attachments field
form.fields['attachment'].widget = HiddenInput()
return form
@ -283,7 +301,7 @@ class StockItemTestResultEdit(AjaxUpdateView):
"""
model = StockItemTestResult
form_class = EditStockItemTestResultForm
form_class = StockForms.EditStockItemTestResultForm
ajax_form_title = _("Edit Test Result")
def get_form(self):
@ -291,8 +309,6 @@ class StockItemTestResultEdit(AjaxUpdateView):
form = super().get_form()
form.fields['stock_item'].widget = HiddenInput()
form.fields['attachment'].queryset = self.object.stock_item.attachments.all()
return form
@ -307,12 +323,81 @@ class StockItemTestResultDelete(AjaxDeleteView):
context_object_name = "result"
class StockItemTestReportSelect(AjaxView):
"""
View for selecting a TestReport template,
and generating a TestReport as a PDF.
"""
model = StockItem
ajax_form_title = _("Select Test Report Template")
def get_form(self):
stock_item = StockItem.objects.get(pk=self.kwargs['pk'])
return StockForms.TestReportFormatForm(stock_item)
def post(self, request, *args, **kwargs):
template_id = request.POST.get('template', None)
try:
template = TestReport.objects.get(pk=template_id)
except (ValueError, TestReport.DoesNoteExist):
raise ValidationError({'template': _("Select valid template")})
stock_item = StockItem.objects.get(pk=self.kwargs['pk'])
url = reverse('stock-item-test-report-download')
url += '?stock_item={id}'.format(id=stock_item.pk)
url += '&template={id}'.format(id=template.pk)
data = {
'form_valid': True,
'url': url,
}
return self.renderJsonResponse(request, self.get_form(), data=data)
class StockItemTestReportDownload(AjaxView):
"""
Download a TestReport against a StockItem.
Requires the following arguments to be passed as URL params:
stock_item - Valid PK of a StockItem object
template - Valid PK of a TestReport template object
"""
def get(self, request, *args, **kwargs):
template = request.GET.get('template', None)
stock_item = request.GET.get('stock_item', None)
try:
template = TestReport.objects.get(pk=template)
except (ValueError, TestReport.DoesNotExist):
raise ValidationError({'template': 'Invalid template ID'})
try:
stock_item = StockItem.objects.get(pk=stock_item)
except (ValueError, StockItem.DoesNotExist):
raise ValidationError({'stock_item': 'Invalid StockItem ID'})
template.stock_item = stock_item
return template.render(request)
class StockExportOptions(AjaxView):
""" Form for selecting StockExport options """
model = StockLocation
ajax_form_title = _('Stock Export Options')
form_class = ExportOptionsForm
form_class = StockForms.ExportOptionsForm
def post(self, request, *args, **kwargs):
@ -455,7 +540,7 @@ class StockAdjust(AjaxView, FormMixin):
ajax_template_name = 'stock/stock_adjust.html'
ajax_form_title = _('Adjust Stock')
form_class = AdjustStockForm
form_class = StockForms.AdjustStockForm
stock_items = []
def get_GET_items(self):
@ -773,7 +858,7 @@ class StockItemEdit(AjaxUpdateView):
"""
model = StockItem
form_class = EditStockItemForm
form_class = StockForms.EditStockItemForm
context_object_name = 'item'
ajax_template_name = 'modal_form.html'
ajax_form_title = _('Edit Stock Item')
@ -802,6 +887,30 @@ class StockItemEdit(AjaxUpdateView):
return form
class StockItemConvert(AjaxUpdateView):
"""
View for 'converting' a StockItem to a variant of its current part.
"""
model = StockItem
form_class = StockForms.ConvertStockItemForm
ajax_form_title = _('Convert Stock Item')
ajax_template_name = 'stock/stockitem_convert.html'
context_object_name = 'item'
def get_form(self):
"""
Filter the available parts.
"""
form = super().get_form()
item = self.get_object()
form.fields['part'].queryset = item.part.get_all_variants()
return form
class StockLocationCreate(AjaxCreateView):
"""
View for creating a new StockLocation
@ -809,7 +918,7 @@ class StockLocationCreate(AjaxCreateView):
"""
model = StockLocation
form_class = EditStockLocationForm
form_class = StockForms.EditStockLocationForm
context_object_name = 'location'
ajax_template_name = 'modal_form.html'
ajax_form_title = _('Create new Stock Location')
@ -834,7 +943,7 @@ class StockItemSerialize(AjaxUpdateView):
model = StockItem
ajax_template_name = 'stock/item_serialize.html'
ajax_form_title = _('Serialize Stock')
form_class = SerializeStockForm
form_class = StockForms.SerializeStockForm
def get_form(self):
@ -843,7 +952,7 @@ class StockItemSerialize(AjaxUpdateView):
# Pass the StockItem object through to the form
context['item'] = self.get_object()
form = SerializeStockForm(**context)
form = StockForms.SerializeStockForm(**context)
return form
@ -922,11 +1031,41 @@ class StockItemCreate(AjaxCreateView):
"""
model = StockItem
form_class = CreateStockItemForm
form_class = StockForms.CreateStockItemForm
context_object_name = 'item'
ajax_template_name = 'modal_form.html'
ajax_form_title = _('Create new Stock Item')
def get_part(self, form=None):
"""
Attempt to get the "part" associted with this new stockitem.
- May be passed to the form as a query parameter (e.g. ?part=<id>)
- May be passed via the form field itself.
"""
# Try to extract from the URL query
part_id = self.request.GET.get('part', None)
if part_id:
try:
part = Part.objects.get(pk=part_id)
return part
except (Part.DoesNotExist, ValueError):
pass
# Try to get from the form
if form:
try:
part_id = form['part'].value()
part = Part.objects.get(pk=part_id)
return part
except (Part.DoesNotExist, ValueError):
pass
# Could not extract a part object
return None
def get_form(self):
""" Get form for StockItem creation.
Overrides the default get_form() method to intelligently limit
@ -935,53 +1074,44 @@ class StockItemCreate(AjaxCreateView):
form = super().get_form()
part = None
part = self.get_part(form=form)
# If the user has selected a Part, limit choices for SupplierPart
if form['part'].value():
part_id = form['part'].value()
if part is not None:
sn = part.getNextSerialNumber()
form.field_placeholder['serial_numbers'] = _('Next available serial number is') + ' ' + str(sn)
try:
part = Part.objects.get(id=part_id)
sn = part.getNextSerialNumber()
form.field_placeholder['serial_numbers'] = _('Next available serial number is') + ' ' + str(sn)
form.rebuild_layout()
form.rebuild_layout()
# Hide the 'part' field (as a valid part is selected)
form.fields['part'].widget = HiddenInput()
# Hide the 'part' field (as a valid part is selected)
form.fields['part'].widget = HiddenInput()
# trackable parts get special consideration
if part.trackable:
form.fields['delete_on_deplete'].widget = HiddenInput()
form.fields['delete_on_deplete'].initial = False
else:
form.fields.pop('serial_numbers')
# trackable parts get special consideration
if part.trackable:
form.fields['delete_on_deplete'].widget = HiddenInput()
form.fields['delete_on_deplete'].initial = False
else:
form.fields.pop('serial_numbers')
# If the part is NOT purchaseable, hide the supplier_part field
if not part.purchaseable:
form.fields['supplier_part'].widget = HiddenInput()
else:
# Pre-select the allowable SupplierPart options
parts = form.fields['supplier_part'].queryset
parts = parts.filter(part=part.id)
# If the part is NOT purchaseable, hide the supplier_part field
if not part.purchaseable:
form.fields['supplier_part'].widget = HiddenInput()
else:
# Pre-select the allowable SupplierPart options
parts = form.fields['supplier_part'].queryset
parts = parts.filter(part=part.id)
form.fields['supplier_part'].queryset = parts
form.fields['supplier_part'].queryset = parts
# If there is one (and only one) supplier part available, pre-select it
all_parts = parts.all()
# If there is one (and only one) supplier part available, pre-select it
all_parts = parts.all()
if len(all_parts) == 1:
if len(all_parts) == 1:
# TODO - This does NOT work for some reason? Ref build.views.BuildItemCreate
form.fields['supplier_part'].initial = all_parts[0].id
except Part.DoesNotExist:
pass
# TODO - This does NOT work for some reason? Ref build.views.BuildItemCreate
form.fields['supplier_part'].initial = all_parts[0].id
# Otherwise if the user has selected a SupplierPart, we know what Part they meant!
elif form['supplier_part'].value() is not None:
if form['supplier_part'].value() is not None:
pass
return form
@ -1004,27 +1134,20 @@ class StockItemCreate(AjaxCreateView):
else:
initials = super(StockItemCreate, self).get_initial().copy()
part_id = self.request.GET.get('part', None)
part = self.get_part()
loc_id = self.request.GET.get('location', None)
sup_part_id = self.request.GET.get('supplier_part', None)
part = None
location = None
supplier_part = None
# Part field has been specified
if part_id:
try:
part = Part.objects.get(pk=part_id)
# Check that the supplied part is 'valid'
if not part.is_template and part.active and not part.virtual:
initials['part'] = part
initials['location'] = part.get_default_location()
initials['supplier_part'] = part.default_supplier
except (ValueError, Part.DoesNotExist):
pass
if part is not None:
# Check that the supplied part is 'valid'
if not part.is_template and part.active and not part.virtual:
initials['part'] = part
initials['location'] = part.get_default_location()
initials['supplier_part'] = part.default_supplier
# SupplierPart field has been specified
# It must match the Part, if that has been supplied
@ -1229,7 +1352,7 @@ class StockItemTrackingEdit(AjaxUpdateView):
model = StockItemTracking
ajax_form_title = _('Edit Stock Tracking Entry')
form_class = TrackingEntryForm
form_class = StockForms.TrackingEntryForm
class StockItemTrackingCreate(AjaxCreateView):
@ -1238,7 +1361,7 @@ class StockItemTrackingCreate(AjaxCreateView):
model = StockItemTracking
ajax_form_title = _("Add Stock Tracking Entry")
form_class = TrackingEntryForm
form_class = StockForms.TrackingEntryForm
def post(self, request, *args, **kwargs):

View File

@ -286,6 +286,14 @@ function loadPartTable(table, url, options={}) {
});
}
function yesNoLabel(value) {
if (value) {
return `<span class='label label-green'>{% trans "YES" %}</span>`;
} else {
return `<span class='label label-yellow'>{% trans "NO" %}</span>`;
}
}
function loadPartTestTemplateTable(table, options) {
/*
@ -332,16 +340,30 @@ function loadPartTestTemplateTable(table, options) {
title: "{% trans "Test Name" %}",
sortable: true,
},
{
field: 'description',
title: "{% trans "Description" %}",
},
{
field: 'required',
title: "{% trans 'Required' %}",
sortable: true,
formatter: function(value) {
if (value) {
return `<span class='label label-green'>{% trans "YES" %}</span>`;
} else {
return `<span class='label label-yellow'>{% trans "NO" %}</span>`;
}
return yesNoLabel(value);
}
},
{
field: 'requires_value',
title: "{% trans "Requires Value" %}",
formatter: function(value) {
return yesNoLabel(value);
}
},
{
field: 'requires_attachment',
title: "{% trans "Requires Attachment" %}",
formatter: function(value) {
return yesNoLabel(value);
}
},
{

View File

@ -32,17 +32,6 @@ function noResultBadge() {
return `<span class='label label-blue float-right'>{% trans "NO RESULT" %}</span>`;
}
function testKey(test_name) {
// Convert test name to a unique key without any illegal chars
test_name = test_name.trim().toLowerCase();
test_name = test_name.replace(' ', '');
test_name = test_name.replace(/[^0-9a-z]/gi, '');
return test_name;
}
function loadStockTestResultsTable(table, options) {
/*
* Load StockItemTestResult table
@ -56,8 +45,8 @@ function loadStockTestResultsTable(table, options) {
html += `<span class='badge'>${row.user_detail.username}</span>`;
}
if (row.attachment_detail) {
html += `<a href='${row.attachment_detail.attachment}'><span class='fas fa-file-alt label-right'></span></a>`;
if (row.attachment) {
html += `<a href='${row.attachment}'><span class='fas fa-file-alt label-right'></span></a>`;
}
return html;
@ -177,14 +166,14 @@ function loadStockTestResultsTable(table, options) {
var match = false;
var override = false;
var key = testKey(item.test);
var key = item.key;
// Try to associate this result with a test row
tableData.forEach(function(row, index) {
// The result matches the test template row
if (key == testKey(row.test_name)) {
if (key == row.key) {
// Force the names to be the same!
item.test_name = row.test_name;
@ -348,12 +337,21 @@ function loadStockTable(table, options) {
} else {
return '-';
}
} else if (field == 'location__path') {
} else if (field == 'location_detail.pathstring') {
/* Determine how many locations */
var locations = [];
data.forEach(function(item) {
var loc = item.location;
var loc = null;
if (item.location_detail) {
loc = item.location_detail.pathstring;
} else {
loc = "{% trans "Undefined location" %}";
}
console.log("Location: " + loc);
if (!locations.includes(loc)) {
locations.push(loc);
@ -364,7 +362,11 @@ function loadStockTable(table, options) {
return "In " + locations.length + " locations";
} else {
// A single location!
return renderLink(row.location__path, '/stock/location/' + row.location + '/')
if (row.location_detail) {
return renderLink(row.location_detail.pathstring, `/stock/location/${row.location}/`);
} else {
return "<i>{% trans "Undefined location" %}</i>";
}
}
} else if (field == 'notes') {
var notes = [];

View File

@ -34,6 +34,14 @@ function getAvailableTableFilters(tableKey) {
title: '{% trans "Is allocated" %}',
description: '{% trans "Item has been alloacted" %}',
},
serial_gte: {
title: "{% trans "Serial number GTE" %}",
description: "{% trans "Serial number greater than or equal to" %}"
},
serial_lte: {
title: "{% trans "Serial number LTE" %}",
description: "{% trans "Serial number less than or equal to" %}",
},
};
}

View File

@ -51,12 +51,12 @@ style:
# Run unit tests
test:
cd InvenTree && python3 manage.py check
cd InvenTree && python3 manage.py test build common company order part stock
cd InvenTree && python3 manage.py test build common company order part report stock InvenTree
# Run code coverage
coverage:
cd InvenTree && python3 manage.py check
coverage run InvenTree/manage.py test build common company order part stock InvenTree
coverage run InvenTree/manage.py test build common company order part report stock InvenTree
coverage html
# Install packages required to generate code docs

View File

@ -33,3 +33,9 @@ For code documentation, refer to the [developer documentation](http://inventree.
## Contributing
Contributions are welcomed and encouraged. Please help to make this project even better! Refer to the [contribution page](https://inventree.github.io/pages/contribute).
## Donate
If you use InvenTree and find it to be useful, please consider making a donation toward its continued development.
[Donate via PayPal](https://paypal.me/inventree?locale.x=en_AU)

View File

@ -19,4 +19,6 @@ flake8==3.3.0 # PEP checking
coverage==4.0.3 # Unit test coverage
python-coveralls==2.9.1 # Coveralls linking (for Travis)
rapidfuzz==0.7.6 # Fuzzy string matching
django-stdimage==5.1.1 # Advanced ImageField management
django-stdimage==5.1.1 # Advanced ImageField management
django-tex==1.1.7 # LaTeX PDF export
django-weasyprint==1.0.1 # HTML PDF export