mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +00:00
Merge remote-tracking branch 'inventree/master'
This commit is contained in:
commit
03cc6892ea
@ -5,6 +5,7 @@ Helper forms which subclass Django forms to provide additional functionality
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
from __future__ import unicode_literals
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
|
from django.utils.translation import ugettext as _
|
||||||
from django import forms
|
from django import forms
|
||||||
from crispy_forms.helper import FormHelper
|
from crispy_forms.helper import FormHelper
|
||||||
from crispy_forms.layout import Layout, Field
|
from crispy_forms.layout import Layout, Field
|
||||||
@ -92,6 +93,20 @@ class HelperForm(forms.ModelForm):
|
|||||||
self.helper.layout = Layout(*layouts)
|
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):
|
class DeleteForm(forms.Form):
|
||||||
""" Generic deletion form which provides simple user confirmation
|
""" Generic deletion form which provides simple user confirmation
|
||||||
"""
|
"""
|
||||||
@ -99,7 +114,7 @@ class DeleteForm(forms.Form):
|
|||||||
confirm_delete = forms.BooleanField(
|
confirm_delete = forms.BooleanField(
|
||||||
required=False,
|
required=False,
|
||||||
initial=False,
|
initial=False,
|
||||||
help_text='Confirm item deletion'
|
help_text=_('Confirm item deletion')
|
||||||
)
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
@ -131,14 +146,14 @@ class SetPasswordForm(HelperForm):
|
|||||||
required=True,
|
required=True,
|
||||||
initial='',
|
initial='',
|
||||||
widget=forms.PasswordInput(attrs={'autocomplete': 'off'}),
|
widget=forms.PasswordInput(attrs={'autocomplete': 'off'}),
|
||||||
help_text='Enter new password')
|
help_text=_('Enter new password'))
|
||||||
|
|
||||||
confirm_password = forms.CharField(max_length=100,
|
confirm_password = forms.CharField(max_length=100,
|
||||||
min_length=8,
|
min_length=8,
|
||||||
required=True,
|
required=True,
|
||||||
initial='',
|
initial='',
|
||||||
widget=forms.PasswordInput(attrs={'autocomplete': 'off'}),
|
widget=forms.PasswordInput(attrs={'autocomplete': 'off'}),
|
||||||
help_text='Confirm new password')
|
help_text=_('Confirm new password'))
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = User
|
model = User
|
||||||
|
@ -72,6 +72,27 @@ if DEBUG:
|
|||||||
format='%(asctime)s %(levelname)s %(message)s',
|
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?
|
# Does the user wish to use the sentry.io integration?
|
||||||
sentry_opts = CONFIG.get('sentry', {})
|
sentry_opts = CONFIG.get('sentry', {})
|
||||||
|
|
||||||
@ -106,12 +127,13 @@ INSTALLED_APPS = [
|
|||||||
'django.contrib.staticfiles',
|
'django.contrib.staticfiles',
|
||||||
|
|
||||||
# InvenTree apps
|
# InvenTree apps
|
||||||
'common.apps.CommonConfig',
|
|
||||||
'part.apps.PartConfig',
|
|
||||||
'stock.apps.StockConfig',
|
|
||||||
'company.apps.CompanyConfig',
|
|
||||||
'build.apps.BuildConfig',
|
'build.apps.BuildConfig',
|
||||||
|
'common.apps.CommonConfig',
|
||||||
|
'company.apps.CompanyConfig',
|
||||||
'order.apps.OrderConfig',
|
'order.apps.OrderConfig',
|
||||||
|
'part.apps.PartConfig',
|
||||||
|
'report.apps.ReportConfig',
|
||||||
|
'stock.apps.StockConfig',
|
||||||
|
|
||||||
# Third part add-ons
|
# Third part add-ons
|
||||||
'django_filters', # Extended filter functionality
|
'django_filters', # Extended filter functionality
|
||||||
@ -126,6 +148,7 @@ INSTALLED_APPS = [
|
|||||||
'mptt', # Modified Preorder Tree Traversal
|
'mptt', # Modified Preorder Tree Traversal
|
||||||
'markdownx', # Markdown editing
|
'markdownx', # Markdown editing
|
||||||
'markdownify', # Markdown template rendering
|
'markdownify', # Markdown template rendering
|
||||||
|
'django_tex', # LaTeX output
|
||||||
]
|
]
|
||||||
|
|
||||||
LOGGING = {
|
LOGGING = {
|
||||||
@ -160,7 +183,11 @@ ROOT_URLCONF = 'InvenTree.urls'
|
|||||||
TEMPLATES = [
|
TEMPLATES = [
|
||||||
{
|
{
|
||||||
'BACKEND': 'django.template.backends.django.DjangoTemplates',
|
'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,
|
'APP_DIRS': True,
|
||||||
'OPTIONS': {
|
'OPTIONS': {
|
||||||
'context_processors': [
|
'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 = {
|
REST_FRAMEWORK = {
|
||||||
@ -315,31 +350,22 @@ DATE_INPUT_FORMATS = [
|
|||||||
"%Y-%m-%d",
|
"%Y-%m-%d",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# LaTeX rendering settings (django-tex)
|
||||||
|
LATEX_SETTINGS = CONFIG.get('latex', {})
|
||||||
|
|
||||||
# Static files (CSS, JavaScript, Images)
|
# Is LaTeX rendering enabled? (Off by default)
|
||||||
# https://docs.djangoproject.com/en/1.10/howto/static-files/
|
LATEX_ENABLED = LATEX_SETTINGS.get('enabled', False)
|
||||||
|
|
||||||
# Web URL endpoint for served static files
|
# Set the latex interpreter in the config.yaml settings file
|
||||||
STATIC_URL = '/static/'
|
LATEX_INTERPRETER = LATEX_SETTINGS.get('interpreter', 'pdflatex')
|
||||||
|
|
||||||
# The filesystem location for served static files
|
LATEX_INTERPRETER_OPTIONS = LATEX_SETTINGS.get('options', '')
|
||||||
STATIC_ROOT = os.path.abspath(CONFIG.get('static_root', os.path.join(BASE_DIR, 'static')))
|
|
||||||
|
|
||||||
STATICFILES_DIRS = [
|
LATEX_GRAPHICSPATH = [
|
||||||
os.path.join(BASE_DIR, 'InvenTree', 'static'),
|
# 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 forms use the bootstrap templates
|
||||||
CRISPY_TEMPLATE_PACK = 'bootstrap3'
|
CRISPY_TEMPLATE_PACK = 'bootstrap3'
|
||||||
|
|
||||||
|
@ -272,8 +272,9 @@ function setupFilterList(tableKey, table, target) {
|
|||||||
for (var key in filters) {
|
for (var key in filters) {
|
||||||
var value = getFilterOptionValue(tableKey, key, filters[key]);
|
var value = getFilterOptionValue(tableKey, key, filters[key]);
|
||||||
var title = getFilterTitle(tableKey, 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
|
// 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.
|
* Return a description for the given table and filter selection.
|
||||||
*/
|
*/
|
||||||
|
@ -166,6 +166,13 @@ class AjaxMixin(object):
|
|||||||
except AttributeError:
|
except AttributeError:
|
||||||
context = {}
|
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:
|
if form:
|
||||||
context['form'] = form
|
context['form'] = form
|
||||||
else:
|
else:
|
||||||
|
@ -73,3 +73,16 @@ log_queries: False
|
|||||||
sentry:
|
sentry:
|
||||||
enabled: False
|
enabled: False
|
||||||
# dsn: add-your-sentry-dsn-here
|
# 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: ''
|
@ -39,7 +39,10 @@ class EditPartTestTemplateForm(HelperForm):
|
|||||||
fields = [
|
fields = [
|
||||||
'part',
|
'part',
|
||||||
'test_name',
|
'test_name',
|
||||||
'required'
|
'description',
|
||||||
|
'required',
|
||||||
|
'requires_value',
|
||||||
|
'requires_attachment',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
33
InvenTree/part/migrations/0042_auto_20200518_0900.py
Normal file
33
InvenTree/part/migrations/0042_auto_20200518_0900.py
Normal 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'),
|
||||||
|
),
|
||||||
|
]
|
@ -41,6 +41,7 @@ from InvenTree.helpers import decimal2string, normalize
|
|||||||
|
|
||||||
from InvenTree.status_codes import BuildStatus, PurchaseOrderStatus
|
from InvenTree.status_codes import BuildStatus, PurchaseOrderStatus
|
||||||
|
|
||||||
|
from report import models as ReportModels
|
||||||
from build import models as BuildModels
|
from build import models as BuildModels
|
||||||
from order import models as OrderModels
|
from order import models as OrderModels
|
||||||
from company.models import SupplierPart
|
from company.models import SupplierPart
|
||||||
@ -358,6 +359,24 @@ class Part(MPTTModel):
|
|||||||
self.category = category
|
self.category = category
|
||||||
self.save()
|
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):
|
def get_absolute_url(self):
|
||||||
""" Return the web URL for viewing this part """
|
""" Return the web URL for viewing this part """
|
||||||
return reverse('part-detail', kwargs={'pk': self.id})
|
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 the tests which are required by this part
|
||||||
return self.getTestTemplates(required=True)
|
return self.getTestTemplates(required=True)
|
||||||
|
|
||||||
|
def requiredTestCount(self):
|
||||||
|
return self.getRequiredTests().count()
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def attachment_count(self):
|
def attachment_count(self):
|
||||||
""" Count the number of attachments for this part.
|
""" Count the number of attachments for this part.
|
||||||
@ -1087,6 +1109,17 @@ class Part(MPTTModel):
|
|||||||
|
|
||||||
return self.parameters.order_by('template__name')
|
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):
|
def attach_file(instance, filename):
|
||||||
""" Function for storing a file for a PartAttachment
|
""" Function for storing a file for a PartAttachment
|
||||||
@ -1204,16 +1237,34 @@ class PartTestTemplate(models.Model):
|
|||||||
|
|
||||||
test_name = models.CharField(
|
test_name = models.CharField(
|
||||||
blank=False, max_length=100,
|
blank=False, max_length=100,
|
||||||
verbose_name=_("Test name"),
|
verbose_name=_("Test Name"),
|
||||||
help_text=_("Enter a name for the test")
|
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(
|
required = models.BooleanField(
|
||||||
default=True,
|
default=True,
|
||||||
verbose_name=_("Required"),
|
verbose_name=_("Required"),
|
||||||
help_text=_("Is this test required to pass?")
|
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):
|
class PartParameterTemplate(models.Model):
|
||||||
"""
|
"""
|
||||||
@ -1299,6 +1350,11 @@ class BomItem(models.Model):
|
|||||||
checksum: Validation checksum for the particular BOM line item
|
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):
|
def get_absolute_url(self):
|
||||||
return reverse('bom-item-detail', kwargs={'pk': self.id})
|
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
|
- 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
|
# A part cannot refer to itself in its BOM
|
||||||
try:
|
try:
|
||||||
if self.sub_part is not None and self.part is not None:
|
if self.sub_part is not None and self.part is not None:
|
||||||
|
@ -62,14 +62,20 @@ class PartTestTemplateSerializer(InvenTreeModelSerializer):
|
|||||||
Serializer for the PartTestTemplate class
|
Serializer for the PartTestTemplate class
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
key = serializers.CharField(read_only=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = PartTestTemplate
|
model = PartTestTemplate
|
||||||
|
|
||||||
fields = [
|
fields = [
|
||||||
'pk',
|
'pk',
|
||||||
|
'key',
|
||||||
'part',
|
'part',
|
||||||
'test_name',
|
'test_name',
|
||||||
'required'
|
'description',
|
||||||
|
'required',
|
||||||
|
'requires_value',
|
||||||
|
'requires_attachment',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@ -99,9 +105,11 @@ class PartBriefSerializer(InvenTreeModelSerializer):
|
|||||||
'thumbnail',
|
'thumbnail',
|
||||||
'active',
|
'active',
|
||||||
'assembly',
|
'assembly',
|
||||||
|
'is_template',
|
||||||
'purchaseable',
|
'purchaseable',
|
||||||
'salable',
|
'salable',
|
||||||
'stock',
|
'stock',
|
||||||
|
'trackable',
|
||||||
'virtual',
|
'virtual',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
@ -33,6 +33,13 @@
|
|||||||
<td>{{ part.revision }}</td>
|
<td>{{ part.revision }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endif %}
|
{% 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>
|
<tr>
|
||||||
<td><span class='fas fa-info-circle'></span></td>
|
<td><span class='fas fa-info-circle'></span></td>
|
||||||
<td><b>{% trans "Description" %}</b></td>
|
<td><b>{% trans "Description" %}</b></td>
|
||||||
|
@ -6,11 +6,14 @@
|
|||||||
|
|
||||||
{% block content %}
|
{% 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 %}
|
{% if part.is_template %}
|
||||||
<div class='alert alert-info alert-block'>
|
<div class='alert alert-info alert-block'>
|
||||||
{% trans "This part is a template part." %}
|
{% 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>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if part.variant_of %}
|
{% if part.variant_of %}
|
||||||
|
@ -13,9 +13,11 @@
|
|||||||
<a href="{% url 'part-variants' part.id %}">{% trans "Variants" %} <span class='badge'>{{ part.variants.count }}</span></span></a>
|
<a href="{% url 'part-variants' part.id %}">{% trans "Variants" %} <span class='badge'>{{ part.variants.count }}</span></span></a>
|
||||||
</li>
|
</li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% if not part.virtual %}
|
||||||
<li{% ifequal tab 'stock' %} class="active"{% endifequal %}>
|
<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>
|
<a href="{% url 'part-stock' part.id %}">{% trans "Stock" %} <span class="badge">{% decimal part.total_stock %}</span></a>
|
||||||
</li>
|
</li>
|
||||||
|
{% endif %}
|
||||||
{% if part.component or part.used_in_count > 0 %}
|
{% if part.component or part.used_in_count > 0 %}
|
||||||
<li{% ifequal tab 'allocation' %} class="active"{% endifequal %}>
|
<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>
|
<a href="{% url 'part-allocation' part.id %}">{% trans "Allocated" %} <span class="badge">{% decimal part.allocation_count %}</span></a>
|
||||||
|
@ -44,6 +44,20 @@ class BomItemTest(TestCase):
|
|||||||
item = BomItem.objects.create(part=self.bob, sub_part=self.bob, quantity=7)
|
item = BomItem.objects.create(part=self.bob, sub_part=self.bob, quantity=7)
|
||||||
item.clean()
|
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):
|
def test_overage(self):
|
||||||
""" Test that BOM line overages are calculated correctly """
|
""" Test that BOM line overages are calculated correctly """
|
||||||
|
|
||||||
|
0
InvenTree/report/__init__.py
Normal file
0
InvenTree/report/__init__.py
Normal file
22
InvenTree/report/admin.py
Normal file
22
InvenTree/report/admin.py
Normal 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
5
InvenTree/report/apps.py
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class ReportConfig(AppConfig):
|
||||||
|
name = 'report'
|
49
InvenTree/report/migrations/0001_initial.py
Normal file
49
InvenTree/report/migrations/0001_initial.py
Normal 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,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
0
InvenTree/report/migrations/__init__.py
Normal file
0
InvenTree/report/migrations/__init__.py
Normal file
269
InvenTree/report/models.py
Normal file
269
InvenTree/report/models.py
Normal 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"))
|
2
InvenTree/report/tests.py
Normal file
2
InvenTree/report/tests.py
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
from __future__ import unicode_literals
|
2
InvenTree/report/views.py
Normal file
2
InvenTree/report/views.py
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
from __future__ import unicode_literals
|
@ -80,21 +80,10 @@ class StockDetail(generics.RetrieveUpdateDestroyAPIView):
|
|||||||
|
|
||||||
def get_serializer(self, *args, **kwargs):
|
def get_serializer(self, *args, **kwargs):
|
||||||
|
|
||||||
try:
|
kwargs['part_detail'] = True
|
||||||
kwargs['part_detail'] = str2bool(self.request.query_params.get('part_detail', False))
|
kwargs['location_detail'] = True
|
||||||
except AttributeError:
|
kwargs['supplier_part_detail'] = True
|
||||||
pass
|
kwargs['test_detail'] = True
|
||||||
|
|
||||||
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['context'] = self.get_serializer_context()
|
kwargs['context'] = self.get_serializer_context()
|
||||||
|
|
||||||
return self.serializer_class(*args, **kwargs)
|
return self.serializer_class(*args, **kwargs)
|
||||||
@ -498,8 +487,21 @@ class StockList(generics.ListCreateAPIView):
|
|||||||
|
|
||||||
if serial_number is not None:
|
if serial_number is not None:
|
||||||
queryset = queryset.filter(serial=serial_number)
|
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:
|
if in_stock is not None:
|
||||||
in_stock = str2bool(in_stock)
|
in_stock = str2bool(in_stock)
|
||||||
@ -512,7 +514,7 @@ class StockList(generics.ListCreateAPIView):
|
|||||||
queryset = queryset.exclude(StockItem.IN_STOCK_FILTER)
|
queryset = queryset.exclude(StockItem.IN_STOCK_FILTER)
|
||||||
|
|
||||||
# Filter by 'allocated' patrs?
|
# Filter by 'allocated' patrs?
|
||||||
allocated = self.request.query_params.get('allocated', None)
|
allocated = params.get('allocated', None)
|
||||||
|
|
||||||
if allocated is not None:
|
if allocated is not None:
|
||||||
allocated = str2bool(allocated)
|
allocated = str2bool(allocated)
|
||||||
@ -531,8 +533,14 @@ class StockList(generics.ListCreateAPIView):
|
|||||||
active = str2bool(active)
|
active = str2bool(active)
|
||||||
queryset = queryset.filter(part__active=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?
|
# 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:
|
if part_id:
|
||||||
try:
|
try:
|
||||||
@ -692,17 +700,14 @@ class StockItemTestResultList(generics.ListCreateAPIView):
|
|||||||
'value',
|
'value',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
ordering = 'date'
|
||||||
|
|
||||||
def get_serializer(self, *args, **kwargs):
|
def get_serializer(self, *args, **kwargs):
|
||||||
try:
|
try:
|
||||||
kwargs['user_detail'] = str2bool(self.request.query_params.get('user_detail', False))
|
kwargs['user_detail'] = str2bool(self.request.query_params.get('user_detail', False))
|
||||||
except:
|
except:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
try:
|
|
||||||
kwargs['attachment_detail'] = str2bool(self.request.query_params.get('attachment_detail', False))
|
|
||||||
except:
|
|
||||||
pass
|
|
||||||
|
|
||||||
kwargs['context'] = self.get_serializer_context()
|
kwargs['context'] = self.get_serializer_context()
|
||||||
|
|
||||||
return self.serializer_class(*args, **kwargs)
|
return self.serializer_class(*args, **kwargs)
|
||||||
@ -718,23 +723,6 @@ class StockItemTestResultList(generics.ListCreateAPIView):
|
|||||||
# Capture the user information
|
# Capture the user information
|
||||||
test_result = serializer.save()
|
test_result = serializer.save()
|
||||||
test_result.user = self.request.user
|
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()
|
test_result.save()
|
||||||
|
|
||||||
|
|
||||||
|
@ -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):
|
class CreateStockItemForm(HelperForm):
|
||||||
""" Form for creating a new StockItem """
|
""" 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):
|
class ExportOptionsForm(HelperForm):
|
||||||
""" Form for selecting stock export options """
|
""" Form for selecting stock export options """
|
||||||
|
|
||||||
|
19
InvenTree/stock/migrations/0042_auto_20200523_0121.py
Normal file
19
InvenTree/stock/migrations/0042_auto_20200523_0121.py
Normal 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'),
|
||||||
|
),
|
||||||
|
]
|
20
InvenTree/stock/migrations/0043_auto_20200525_0420.py
Normal file
20
InvenTree/stock/migrations/0043_auto_20200525_0420.py
Normal 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'),
|
||||||
|
),
|
||||||
|
]
|
@ -331,7 +331,6 @@ class StockItem(MPTTModel):
|
|||||||
verbose_name=_('Base Part'),
|
verbose_name=_('Base Part'),
|
||||||
related_name='stock_items', help_text=_('Base part'),
|
related_name='stock_items', help_text=_('Base part'),
|
||||||
limit_choices_to={
|
limit_choices_to={
|
||||||
'is_template': False,
|
|
||||||
'active': True,
|
'active': True,
|
||||||
'virtual': False
|
'virtual': False
|
||||||
})
|
})
|
||||||
@ -647,6 +646,9 @@ class StockItem(MPTTModel):
|
|||||||
# Copy entire transaction history
|
# Copy entire transaction history
|
||||||
new_item.copyHistoryFrom(self)
|
new_item.copyHistoryFrom(self)
|
||||||
|
|
||||||
|
# Copy test result history
|
||||||
|
new_item.copyTestResultsFrom(self)
|
||||||
|
|
||||||
# Create a new stock tracking item
|
# Create a new stock tracking item
|
||||||
new_item.addTransactionNote(_('Add serial number'), user, notes=notes)
|
new_item.addTransactionNote(_('Add serial number'), user, notes=notes)
|
||||||
|
|
||||||
@ -655,7 +657,7 @@ class StockItem(MPTTModel):
|
|||||||
|
|
||||||
@transaction.atomic
|
@transaction.atomic
|
||||||
def copyHistoryFrom(self, other):
|
def copyHistoryFrom(self, other):
|
||||||
""" Copy stock history from another part """
|
""" Copy stock history from another StockItem """
|
||||||
|
|
||||||
for item in other.tracking_info.all():
|
for item in other.tracking_info.all():
|
||||||
|
|
||||||
@ -663,6 +665,17 @@ class StockItem(MPTTModel):
|
|||||||
item.pk = None
|
item.pk = None
|
||||||
item.save()
|
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
|
@transaction.atomic
|
||||||
def splitStock(self, quantity, location, user):
|
def splitStock(self, quantity, location, user):
|
||||||
""" Split this stock item into two items, in the same location.
|
""" 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
|
# Copy the transaction history of this part into the new one
|
||||||
new_stock.copyHistoryFrom(self)
|
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
|
# Add a new tracking item for the new stock item
|
||||||
new_stock.addTransactionNote(
|
new_stock.addTransactionNote(
|
||||||
"Split from existing stock",
|
"Split from existing stock",
|
||||||
@ -963,6 +979,13 @@ class StockItem(MPTTModel):
|
|||||||
|
|
||||||
return result_map
|
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):
|
def requiredTestStatus(self):
|
||||||
"""
|
"""
|
||||||
Return the status of the tests required for this StockItem.
|
Return the status of the tests required for this StockItem.
|
||||||
@ -1000,6 +1023,10 @@ class StockItem(MPTTModel):
|
|||||||
'failed': failed,
|
'failed': failed,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def required_test_count(self):
|
||||||
|
return self.part.getRequiredTests().count()
|
||||||
|
|
||||||
def hasRequiredTests(self):
|
def hasRequiredTests(self):
|
||||||
return self.part.getRequiredTests().count() > 0
|
return self.part.getRequiredTests().count() > 0
|
||||||
|
|
||||||
@ -1083,6 +1110,11 @@ class StockItemTracking(models.Model):
|
|||||||
# file = models.FileField()
|
# 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):
|
class StockItemTestResult(models.Model):
|
||||||
"""
|
"""
|
||||||
A StockItemTestResult records results of custom tests against individual StockItem objects.
|
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
|
date: Date the test result was recorded
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
def save(self, *args, **kwargs):
|
||||||
|
|
||||||
|
super().clean()
|
||||||
|
super().validate_unique()
|
||||||
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
def clean(self):
|
def clean(self):
|
||||||
|
|
||||||
super().clean()
|
super().clean()
|
||||||
|
|
||||||
# If an attachment is linked to this result, the attachment must also point to the item
|
# If this test result corresponds to a template, check the requirements of the template
|
||||||
try:
|
key = self.key
|
||||||
if self.attachment:
|
|
||||||
if not self.attachment.stock_item == self.stock_item:
|
templates = self.stock_item.part.getTestTemplates()
|
||||||
raise ValidationError({
|
|
||||||
'attachment': _("Test result attachment must be linked to the same StockItem"),
|
for template in templates:
|
||||||
})
|
if key == template.key:
|
||||||
except (StockItem.DoesNotExist, StockItemAttachment.DoesNotExist):
|
|
||||||
pass
|
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(
|
stock_item = models.ForeignKey(
|
||||||
StockItem,
|
StockItem,
|
||||||
@ -1140,10 +1194,9 @@ class StockItemTestResult(models.Model):
|
|||||||
help_text=_('Test output value')
|
help_text=_('Test output value')
|
||||||
)
|
)
|
||||||
|
|
||||||
attachment = models.ForeignKey(
|
attachment = models.FileField(
|
||||||
StockItemAttachment,
|
null=True, blank=True,
|
||||||
on_delete=models.SET_NULL,
|
upload_to=rename_stock_item_test_result_attachment,
|
||||||
blank=True, null=True,
|
|
||||||
verbose_name=_('Attachment'),
|
verbose_name=_('Attachment'),
|
||||||
help_text=_('Test result attachment'),
|
help_text=_('Test result attachment'),
|
||||||
)
|
)
|
||||||
|
@ -108,11 +108,14 @@ class StockItemSerializer(InvenTreeModelSerializer):
|
|||||||
quantity = serializers.FloatField()
|
quantity = serializers.FloatField()
|
||||||
allocated = serializers.FloatField()
|
allocated = serializers.FloatField()
|
||||||
|
|
||||||
|
required_tests = serializers.IntegerField(source='required_test_count', read_only=True)
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
|
|
||||||
part_detail = kwargs.pop('part_detail', False)
|
part_detail = kwargs.pop('part_detail', False)
|
||||||
location_detail = kwargs.pop('location_detail', False)
|
location_detail = kwargs.pop('location_detail', False)
|
||||||
supplier_part_detail = kwargs.pop('supplier_part_detail', False)
|
supplier_part_detail = kwargs.pop('supplier_part_detail', False)
|
||||||
|
test_detail = kwargs.pop('test_detail', False)
|
||||||
|
|
||||||
super(StockItemSerializer, self).__init__(*args, **kwargs)
|
super(StockItemSerializer, self).__init__(*args, **kwargs)
|
||||||
|
|
||||||
@ -125,6 +128,9 @@ class StockItemSerializer(InvenTreeModelSerializer):
|
|||||||
if supplier_part_detail is not True:
|
if supplier_part_detail is not True:
|
||||||
self.fields.pop('supplier_part_detail')
|
self.fields.pop('supplier_part_detail')
|
||||||
|
|
||||||
|
if test_detail is not True:
|
||||||
|
self.fields.pop('required_tests')
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = StockItem
|
model = StockItem
|
||||||
fields = [
|
fields = [
|
||||||
@ -141,6 +147,7 @@ class StockItemSerializer(InvenTreeModelSerializer):
|
|||||||
'part_detail',
|
'part_detail',
|
||||||
'pk',
|
'pk',
|
||||||
'quantity',
|
'quantity',
|
||||||
|
'required_tests',
|
||||||
'sales_order',
|
'sales_order',
|
||||||
'serial',
|
'serial',
|
||||||
'supplier_part',
|
'supplier_part',
|
||||||
@ -222,31 +229,28 @@ class StockItemTestResultSerializer(InvenTreeModelSerializer):
|
|||||||
""" Serializer for the StockItemTestResult model """
|
""" Serializer for the StockItemTestResult model """
|
||||||
|
|
||||||
user_detail = UserSerializerBrief(source='user', read_only=True)
|
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):
|
def __init__(self, *args, **kwargs):
|
||||||
user_detail = kwargs.pop('user_detail', False)
|
user_detail = kwargs.pop('user_detail', False)
|
||||||
attachment_detail = kwargs.pop('attachment_detail', False)
|
|
||||||
|
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
if user_detail is not True:
|
if user_detail is not True:
|
||||||
self.fields.pop('user_detail')
|
self.fields.pop('user_detail')
|
||||||
|
|
||||||
if attachment_detail is not True:
|
|
||||||
self.fields.pop('attachment_detail')
|
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = StockItemTestResult
|
model = StockItemTestResult
|
||||||
|
|
||||||
fields = [
|
fields = [
|
||||||
'pk',
|
'pk',
|
||||||
'stock_item',
|
'stock_item',
|
||||||
|
'key',
|
||||||
'test',
|
'test',
|
||||||
'result',
|
'result',
|
||||||
'value',
|
'value',
|
||||||
'attachment',
|
'attachment',
|
||||||
'attachment_detail',
|
|
||||||
'notes',
|
'notes',
|
||||||
'user',
|
'user',
|
||||||
'user_detail',
|
'user_detail',
|
||||||
@ -255,7 +259,6 @@ class StockItemTestResultSerializer(InvenTreeModelSerializer):
|
|||||||
|
|
||||||
read_only_fields = [
|
read_only_fields = [
|
||||||
'pk',
|
'pk',
|
||||||
'attachment',
|
|
||||||
'user',
|
'user',
|
||||||
'date',
|
'date',
|
||||||
]
|
]
|
||||||
|
@ -93,8 +93,18 @@ InvenTree | {% trans "Stock Item" %} - {{ item }}
|
|||||||
<span class='fas fa-copy'/>
|
<span class='fas fa-copy'/>
|
||||||
</button>
|
</button>
|
||||||
{% endif %}
|
{% 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'>
|
<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>
|
</button>
|
||||||
{% if item.can_delete %}
|
{% if item.can_delete %}
|
||||||
<button type='button' class='btn btn-default' id='stock-delete' title='Edit stock item'>
|
<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() {
|
$("#stock-duplicate").click(function() {
|
||||||
launchModalForm(
|
launchModalForm(
|
||||||
"{% url 'stock-item-create' %}",
|
"{% 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() {
|
$("#stock-move").click(function() {
|
||||||
itemAdjust("move");
|
itemAdjust("move");
|
||||||
});
|
});
|
||||||
|
@ -13,7 +13,13 @@
|
|||||||
<div id='button-toolbar'>
|
<div id='button-toolbar'>
|
||||||
<div class='button-toolbar container-fluid' style="float: right;">
|
<div class='button-toolbar container-fluid' style="float: right;">
|
||||||
<div class='btn-group' role='group'>
|
<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>
|
<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>
|
||||||
<div class='filter-list' id='filter-list-stocktests'>
|
<div class='filter-list' id='filter-list-stocktests'>
|
||||||
<!-- Empty div -->
|
<!-- Empty div -->
|
||||||
@ -40,6 +46,28 @@ function reloadTable() {
|
|||||||
//$("#test-result-table").bootstrapTable("refresh");
|
//$("#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() {
|
$("#add-test-result").click(function() {
|
||||||
launchModalForm(
|
launchModalForm(
|
||||||
"{% url 'stock-item-test-create' %}", {
|
"{% url 'stock-item-test-create' %}", {
|
||||||
|
@ -32,7 +32,7 @@
|
|||||||
<input class='numberinput'
|
<input class='numberinput'
|
||||||
min='0'
|
min='0'
|
||||||
{% if stock_action == 'take' or stock_action == 'move' %} max='{{ item.quantity }}' {% endif %}
|
{% 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 %}
|
{% if item.error %}
|
||||||
<br><span class='help-inline'>{{ item.error }}</span>
|
<br><span class='help-inline'>{{ item.error }}</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
17
InvenTree/stock/templates/stock/stockitem_convert.html
Normal file
17
InvenTree/stock/templates/stock/stockitem_convert.html
Normal 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 %}
|
@ -458,3 +458,68 @@ class TestResultTest(StockTest):
|
|||||||
)
|
)
|
||||||
|
|
||||||
self.assertTrue(item.passedAllRequiredTests())
|
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)
|
||||||
|
@ -18,12 +18,16 @@ stock_location_detail_urls = [
|
|||||||
|
|
||||||
stock_item_detail_urls = [
|
stock_item_detail_urls = [
|
||||||
url(r'^edit/', views.StockItemEdit.as_view(), name='stock-item-edit'),
|
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'^serialize/', views.StockItemSerialize.as_view(), name='stock-item-serialize'),
|
||||||
url(r'^delete/', views.StockItemDelete.as_view(), name='stock-item-delete'),
|
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'^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'^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'^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'^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'),
|
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/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
|
# URLs for StockItem attachments
|
||||||
url(r'^item/attachment/', include([
|
url(r'^item/attachment/', include([
|
||||||
url(r'^new/', views.StockItemAttachmentCreate.as_view(), name='stock-item-attachment-create'),
|
url(r'^new/', views.StockItemAttachmentCreate.as_view(), name='stock-item-attachment-create'),
|
||||||
|
@ -17,6 +17,7 @@ from django.utils.translation import ugettext as _
|
|||||||
from InvenTree.views import AjaxView
|
from InvenTree.views import AjaxView
|
||||||
from InvenTree.views import AjaxUpdateView, AjaxDeleteView, AjaxCreateView
|
from InvenTree.views import AjaxUpdateView, AjaxDeleteView, AjaxCreateView
|
||||||
from InvenTree.views import QRCodeView
|
from InvenTree.views import QRCodeView
|
||||||
|
from InvenTree.forms import ConfirmForm
|
||||||
|
|
||||||
from InvenTree.helpers import str2bool, DownloadFile, GetExportFormats
|
from InvenTree.helpers import str2bool, DownloadFile, GetExportFormats
|
||||||
from InvenTree.helpers import ExtractSerialNumbers
|
from InvenTree.helpers import ExtractSerialNumbers
|
||||||
@ -26,19 +27,12 @@ from datetime import datetime
|
|||||||
|
|
||||||
from company.models import Company, SupplierPart
|
from company.models import Company, SupplierPart
|
||||||
from part.models import Part
|
from part.models import Part
|
||||||
|
from report.models import TestReport
|
||||||
from .models import StockItem, StockLocation, StockItemTracking, StockItemAttachment, StockItemTestResult
|
from .models import StockItem, StockLocation, StockItemTracking, StockItemAttachment, StockItemTestResult
|
||||||
|
|
||||||
from .admin import StockItemResource
|
from .admin import StockItemResource
|
||||||
|
|
||||||
from .forms import EditStockLocationForm
|
from . import forms as StockForms
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
class StockIndex(ListView):
|
class StockIndex(ListView):
|
||||||
@ -113,7 +107,7 @@ class StockLocationEdit(AjaxUpdateView):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
model = StockLocation
|
model = StockLocation
|
||||||
form_class = EditStockLocationForm
|
form_class = StockForms.EditStockLocationForm
|
||||||
context_object_name = 'location'
|
context_object_name = 'location'
|
||||||
ajax_template_name = 'modal_form.html'
|
ajax_template_name = 'modal_form.html'
|
||||||
ajax_form_title = _('Edit Stock Location')
|
ajax_form_title = _('Edit Stock Location')
|
||||||
@ -157,7 +151,7 @@ class StockItemAttachmentCreate(AjaxCreateView):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
model = StockItemAttachment
|
model = StockItemAttachment
|
||||||
form_class = EditStockItemAttachmentForm
|
form_class = StockForms.EditStockItemAttachmentForm
|
||||||
ajax_form_title = _("Add Stock Item Attachment")
|
ajax_form_title = _("Add Stock Item Attachment")
|
||||||
ajax_template_name = "modal_form.html"
|
ajax_template_name = "modal_form.html"
|
||||||
|
|
||||||
@ -202,7 +196,7 @@ class StockItemAttachmentEdit(AjaxUpdateView):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
model = StockItemAttachment
|
model = StockItemAttachment
|
||||||
form_class = EditStockItemAttachmentForm
|
form_class = StockForms.EditStockItemAttachmentForm
|
||||||
ajax_form_title = _("Edit Stock Item Attachment")
|
ajax_form_title = _("Edit Stock Item Attachment")
|
||||||
|
|
||||||
def get_form(self):
|
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):
|
class StockItemTestResultCreate(AjaxCreateView):
|
||||||
"""
|
"""
|
||||||
View for adding a new StockItemTestResult
|
View for adding a new StockItemTestResult
|
||||||
"""
|
"""
|
||||||
|
|
||||||
model = StockItemTestResult
|
model = StockItemTestResult
|
||||||
form_class = EditStockItemTestResultForm
|
form_class = StockForms.EditStockItemTestResultForm
|
||||||
ajax_form_title = _("Add Test Result")
|
ajax_form_title = _("Add Test Result")
|
||||||
|
|
||||||
def post_save(self, **kwargs):
|
def post_save(self, **kwargs):
|
||||||
@ -263,17 +292,6 @@ class StockItemTestResultCreate(AjaxCreateView):
|
|||||||
form = super().get_form()
|
form = super().get_form()
|
||||||
form.fields['stock_item'].widget = HiddenInput()
|
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
|
return form
|
||||||
|
|
||||||
|
|
||||||
@ -283,7 +301,7 @@ class StockItemTestResultEdit(AjaxUpdateView):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
model = StockItemTestResult
|
model = StockItemTestResult
|
||||||
form_class = EditStockItemTestResultForm
|
form_class = StockForms.EditStockItemTestResultForm
|
||||||
ajax_form_title = _("Edit Test Result")
|
ajax_form_title = _("Edit Test Result")
|
||||||
|
|
||||||
def get_form(self):
|
def get_form(self):
|
||||||
@ -291,8 +309,6 @@ class StockItemTestResultEdit(AjaxUpdateView):
|
|||||||
form = super().get_form()
|
form = super().get_form()
|
||||||
|
|
||||||
form.fields['stock_item'].widget = HiddenInput()
|
form.fields['stock_item'].widget = HiddenInput()
|
||||||
|
|
||||||
form.fields['attachment'].queryset = self.object.stock_item.attachments.all()
|
|
||||||
|
|
||||||
return form
|
return form
|
||||||
|
|
||||||
@ -307,12 +323,81 @@ class StockItemTestResultDelete(AjaxDeleteView):
|
|||||||
context_object_name = "result"
|
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):
|
class StockExportOptions(AjaxView):
|
||||||
""" Form for selecting StockExport options """
|
""" Form for selecting StockExport options """
|
||||||
|
|
||||||
model = StockLocation
|
model = StockLocation
|
||||||
ajax_form_title = _('Stock Export Options')
|
ajax_form_title = _('Stock Export Options')
|
||||||
form_class = ExportOptionsForm
|
form_class = StockForms.ExportOptionsForm
|
||||||
|
|
||||||
def post(self, request, *args, **kwargs):
|
def post(self, request, *args, **kwargs):
|
||||||
|
|
||||||
@ -455,7 +540,7 @@ class StockAdjust(AjaxView, FormMixin):
|
|||||||
|
|
||||||
ajax_template_name = 'stock/stock_adjust.html'
|
ajax_template_name = 'stock/stock_adjust.html'
|
||||||
ajax_form_title = _('Adjust Stock')
|
ajax_form_title = _('Adjust Stock')
|
||||||
form_class = AdjustStockForm
|
form_class = StockForms.AdjustStockForm
|
||||||
stock_items = []
|
stock_items = []
|
||||||
|
|
||||||
def get_GET_items(self):
|
def get_GET_items(self):
|
||||||
@ -773,7 +858,7 @@ class StockItemEdit(AjaxUpdateView):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
model = StockItem
|
model = StockItem
|
||||||
form_class = EditStockItemForm
|
form_class = StockForms.EditStockItemForm
|
||||||
context_object_name = 'item'
|
context_object_name = 'item'
|
||||||
ajax_template_name = 'modal_form.html'
|
ajax_template_name = 'modal_form.html'
|
||||||
ajax_form_title = _('Edit Stock Item')
|
ajax_form_title = _('Edit Stock Item')
|
||||||
@ -802,6 +887,30 @@ class StockItemEdit(AjaxUpdateView):
|
|||||||
return form
|
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):
|
class StockLocationCreate(AjaxCreateView):
|
||||||
"""
|
"""
|
||||||
View for creating a new StockLocation
|
View for creating a new StockLocation
|
||||||
@ -809,7 +918,7 @@ class StockLocationCreate(AjaxCreateView):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
model = StockLocation
|
model = StockLocation
|
||||||
form_class = EditStockLocationForm
|
form_class = StockForms.EditStockLocationForm
|
||||||
context_object_name = 'location'
|
context_object_name = 'location'
|
||||||
ajax_template_name = 'modal_form.html'
|
ajax_template_name = 'modal_form.html'
|
||||||
ajax_form_title = _('Create new Stock Location')
|
ajax_form_title = _('Create new Stock Location')
|
||||||
@ -834,7 +943,7 @@ class StockItemSerialize(AjaxUpdateView):
|
|||||||
model = StockItem
|
model = StockItem
|
||||||
ajax_template_name = 'stock/item_serialize.html'
|
ajax_template_name = 'stock/item_serialize.html'
|
||||||
ajax_form_title = _('Serialize Stock')
|
ajax_form_title = _('Serialize Stock')
|
||||||
form_class = SerializeStockForm
|
form_class = StockForms.SerializeStockForm
|
||||||
|
|
||||||
def get_form(self):
|
def get_form(self):
|
||||||
|
|
||||||
@ -843,7 +952,7 @@ class StockItemSerialize(AjaxUpdateView):
|
|||||||
# Pass the StockItem object through to the form
|
# Pass the StockItem object through to the form
|
||||||
context['item'] = self.get_object()
|
context['item'] = self.get_object()
|
||||||
|
|
||||||
form = SerializeStockForm(**context)
|
form = StockForms.SerializeStockForm(**context)
|
||||||
|
|
||||||
return form
|
return form
|
||||||
|
|
||||||
@ -922,11 +1031,41 @@ class StockItemCreate(AjaxCreateView):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
model = StockItem
|
model = StockItem
|
||||||
form_class = CreateStockItemForm
|
form_class = StockForms.CreateStockItemForm
|
||||||
context_object_name = 'item'
|
context_object_name = 'item'
|
||||||
ajax_template_name = 'modal_form.html'
|
ajax_template_name = 'modal_form.html'
|
||||||
ajax_form_title = _('Create new Stock Item')
|
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):
|
def get_form(self):
|
||||||
""" Get form for StockItem creation.
|
""" Get form for StockItem creation.
|
||||||
Overrides the default get_form() method to intelligently limit
|
Overrides the default get_form() method to intelligently limit
|
||||||
@ -935,53 +1074,44 @@ class StockItemCreate(AjaxCreateView):
|
|||||||
|
|
||||||
form = super().get_form()
|
form = super().get_form()
|
||||||
|
|
||||||
part = None
|
part = self.get_part(form=form)
|
||||||
|
|
||||||
# If the user has selected a Part, limit choices for SupplierPart
|
if part is not None:
|
||||||
if form['part'].value():
|
sn = part.getNextSerialNumber()
|
||||||
part_id = form['part'].value()
|
form.field_placeholder['serial_numbers'] = _('Next available serial number is') + ' ' + str(sn)
|
||||||
|
|
||||||
try:
|
form.rebuild_layout()
|
||||||
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()
|
# 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)
|
# trackable parts get special consideration
|
||||||
form.fields['part'].widget = HiddenInput()
|
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 the part is NOT purchaseable, hide the supplier_part field
|
||||||
if part.trackable:
|
if not part.purchaseable:
|
||||||
form.fields['delete_on_deplete'].widget = HiddenInput()
|
form.fields['supplier_part'].widget = HiddenInput()
|
||||||
form.fields['delete_on_deplete'].initial = False
|
else:
|
||||||
else:
|
# Pre-select the allowable SupplierPart options
|
||||||
form.fields.pop('serial_numbers')
|
parts = form.fields['supplier_part'].queryset
|
||||||
|
parts = parts.filter(part=part.id)
|
||||||
|
|
||||||
# If the part is NOT purchaseable, hide the supplier_part field
|
form.fields['supplier_part'].queryset = parts
|
||||||
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
|
# 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
|
if len(all_parts) == 1:
|
||||||
all_parts = parts.all()
|
|
||||||
|
|
||||||
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
|
||||||
# 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
|
|
||||||
|
|
||||||
# Otherwise if the user has selected a SupplierPart, we know what Part they meant!
|
# 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
|
pass
|
||||||
|
|
||||||
return form
|
return form
|
||||||
@ -1004,27 +1134,20 @@ class StockItemCreate(AjaxCreateView):
|
|||||||
else:
|
else:
|
||||||
initials = super(StockItemCreate, self).get_initial().copy()
|
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)
|
loc_id = self.request.GET.get('location', None)
|
||||||
sup_part_id = self.request.GET.get('supplier_part', None)
|
sup_part_id = self.request.GET.get('supplier_part', None)
|
||||||
|
|
||||||
part = None
|
|
||||||
location = None
|
location = None
|
||||||
supplier_part = None
|
supplier_part = None
|
||||||
|
|
||||||
# Part field has been specified
|
if part is not None:
|
||||||
if part_id:
|
# Check that the supplied part is 'valid'
|
||||||
try:
|
if not part.is_template and part.active and not part.virtual:
|
||||||
part = Part.objects.get(pk=part_id)
|
initials['part'] = part
|
||||||
|
initials['location'] = part.get_default_location()
|
||||||
# Check that the supplied part is 'valid'
|
initials['supplier_part'] = part.default_supplier
|
||||||
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
|
|
||||||
|
|
||||||
# SupplierPart field has been specified
|
# SupplierPart field has been specified
|
||||||
# It must match the Part, if that has been supplied
|
# It must match the Part, if that has been supplied
|
||||||
@ -1229,7 +1352,7 @@ class StockItemTrackingEdit(AjaxUpdateView):
|
|||||||
|
|
||||||
model = StockItemTracking
|
model = StockItemTracking
|
||||||
ajax_form_title = _('Edit Stock Tracking Entry')
|
ajax_form_title = _('Edit Stock Tracking Entry')
|
||||||
form_class = TrackingEntryForm
|
form_class = StockForms.TrackingEntryForm
|
||||||
|
|
||||||
|
|
||||||
class StockItemTrackingCreate(AjaxCreateView):
|
class StockItemTrackingCreate(AjaxCreateView):
|
||||||
@ -1238,7 +1361,7 @@ class StockItemTrackingCreate(AjaxCreateView):
|
|||||||
|
|
||||||
model = StockItemTracking
|
model = StockItemTracking
|
||||||
ajax_form_title = _("Add Stock Tracking Entry")
|
ajax_form_title = _("Add Stock Tracking Entry")
|
||||||
form_class = TrackingEntryForm
|
form_class = StockForms.TrackingEntryForm
|
||||||
|
|
||||||
def post(self, request, *args, **kwargs):
|
def post(self, request, *args, **kwargs):
|
||||||
|
|
||||||
|
@ -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) {
|
function loadPartTestTemplateTable(table, options) {
|
||||||
/*
|
/*
|
||||||
@ -332,16 +340,30 @@ function loadPartTestTemplateTable(table, options) {
|
|||||||
title: "{% trans "Test Name" %}",
|
title: "{% trans "Test Name" %}",
|
||||||
sortable: true,
|
sortable: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
field: 'description',
|
||||||
|
title: "{% trans "Description" %}",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
field: 'required',
|
field: 'required',
|
||||||
title: "{% trans 'Required' %}",
|
title: "{% trans 'Required' %}",
|
||||||
sortable: true,
|
sortable: true,
|
||||||
formatter: function(value) {
|
formatter: function(value) {
|
||||||
if (value) {
|
return yesNoLabel(value);
|
||||||
return `<span class='label label-green'>{% trans "YES" %}</span>`;
|
}
|
||||||
} else {
|
},
|
||||||
return `<span class='label label-yellow'>{% trans "NO" %}</span>`;
|
{
|
||||||
}
|
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);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
@ -32,17 +32,6 @@ function noResultBadge() {
|
|||||||
return `<span class='label label-blue float-right'>{% trans "NO RESULT" %}</span>`;
|
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) {
|
function loadStockTestResultsTable(table, options) {
|
||||||
/*
|
/*
|
||||||
* Load StockItemTestResult table
|
* Load StockItemTestResult table
|
||||||
@ -56,8 +45,8 @@ function loadStockTestResultsTable(table, options) {
|
|||||||
html += `<span class='badge'>${row.user_detail.username}</span>`;
|
html += `<span class='badge'>${row.user_detail.username}</span>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (row.attachment_detail) {
|
if (row.attachment) {
|
||||||
html += `<a href='${row.attachment_detail.attachment}'><span class='fas fa-file-alt label-right'></span></a>`;
|
html += `<a href='${row.attachment}'><span class='fas fa-file-alt label-right'></span></a>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return html;
|
return html;
|
||||||
@ -177,14 +166,14 @@ function loadStockTestResultsTable(table, options) {
|
|||||||
var match = false;
|
var match = false;
|
||||||
var override = false;
|
var override = false;
|
||||||
|
|
||||||
var key = testKey(item.test);
|
var key = item.key;
|
||||||
|
|
||||||
// Try to associate this result with a test row
|
// Try to associate this result with a test row
|
||||||
tableData.forEach(function(row, index) {
|
tableData.forEach(function(row, index) {
|
||||||
|
|
||||||
|
|
||||||
// The result matches the test template row
|
// The result matches the test template row
|
||||||
if (key == testKey(row.test_name)) {
|
if (key == row.key) {
|
||||||
|
|
||||||
// Force the names to be the same!
|
// Force the names to be the same!
|
||||||
item.test_name = row.test_name;
|
item.test_name = row.test_name;
|
||||||
@ -348,12 +337,21 @@ function loadStockTable(table, options) {
|
|||||||
} else {
|
} else {
|
||||||
return '-';
|
return '-';
|
||||||
}
|
}
|
||||||
} else if (field == 'location__path') {
|
} else if (field == 'location_detail.pathstring') {
|
||||||
/* Determine how many locations */
|
/* Determine how many locations */
|
||||||
var locations = [];
|
var locations = [];
|
||||||
|
|
||||||
data.forEach(function(item) {
|
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)) {
|
if (!locations.includes(loc)) {
|
||||||
locations.push(loc);
|
locations.push(loc);
|
||||||
@ -364,7 +362,11 @@ function loadStockTable(table, options) {
|
|||||||
return "In " + locations.length + " locations";
|
return "In " + locations.length + " locations";
|
||||||
} else {
|
} else {
|
||||||
// A single location!
|
// 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') {
|
} else if (field == 'notes') {
|
||||||
var notes = [];
|
var notes = [];
|
||||||
|
@ -34,6 +34,14 @@ function getAvailableTableFilters(tableKey) {
|
|||||||
title: '{% trans "Is allocated" %}',
|
title: '{% trans "Is allocated" %}',
|
||||||
description: '{% trans "Item has been alloacted" %}',
|
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" %}",
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
4
Makefile
4
Makefile
@ -51,12 +51,12 @@ style:
|
|||||||
# Run unit tests
|
# Run unit tests
|
||||||
test:
|
test:
|
||||||
cd InvenTree && python3 manage.py check
|
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
|
# Run code coverage
|
||||||
coverage:
|
coverage:
|
||||||
cd InvenTree && python3 manage.py check
|
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
|
coverage html
|
||||||
|
|
||||||
# Install packages required to generate code docs
|
# Install packages required to generate code docs
|
||||||
|
@ -33,3 +33,9 @@ For code documentation, refer to the [developer documentation](http://inventree.
|
|||||||
## Contributing
|
## 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).
|
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)
|
||||||
|
@ -19,4 +19,6 @@ flake8==3.3.0 # PEP checking
|
|||||||
coverage==4.0.3 # Unit test coverage
|
coverage==4.0.3 # Unit test coverage
|
||||||
python-coveralls==2.9.1 # Coveralls linking (for Travis)
|
python-coveralls==2.9.1 # Coveralls linking (for Travis)
|
||||||
rapidfuzz==0.7.6 # Fuzzy string matching
|
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
|
Loading…
Reference in New Issue
Block a user