Merge remote-tracking branch 'inventree/master'

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

View File

@ -5,6 +5,7 @@ Helper forms which subclass Django forms to provide additional functionality
# -*- coding: utf-8 -*- # -*- 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

View File

@ -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'

View File

@ -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.
*/ */

View File

@ -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:

View File

@ -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: ''

View File

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

View File

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

View File

@ -41,6 +41,7 @@ from InvenTree.helpers import decimal2string, normalize
from InvenTree.status_codes import BuildStatus, PurchaseOrderStatus from 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:

View File

@ -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',
] ]

View File

@ -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>

View File

@ -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 %}

View File

@ -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>

View File

@ -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 """

View File

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

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

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

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

View File

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

View File

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

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

View File

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

View File

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

View File

@ -80,21 +80,10 @@ class StockDetail(generics.RetrieveUpdateDestroyAPIView):
def get_serializer(self, *args, **kwargs): 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()

View File

@ -63,6 +63,18 @@ class EditStockLocationForm(HelperForm):
] ]
class ConvertStockItemForm(HelperForm):
"""
Form for converting a StockItem to a variant of its current part.
"""
class Meta:
model = StockItem
fields = [
'part'
]
class CreateStockItemForm(HelperForm): 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 """

View File

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

View File

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

View File

@ -331,7 +331,6 @@ class StockItem(MPTTModel):
verbose_name=_('Base Part'), 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'),
) )

View File

@ -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',
] ]

View File

@ -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");
}); });

View File

@ -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' %}", {

View File

@ -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 %}

View File

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

View File

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

View File

@ -18,12 +18,16 @@ stock_location_detail_urls = [
stock_item_detail_urls = [ 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'),

View File

@ -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):

View File

@ -286,6 +286,14 @@ function loadPartTable(table, url, options={}) {
}); });
} }
function yesNoLabel(value) {
if (value) {
return `<span class='label label-green'>{% trans "YES" %}</span>`;
} else {
return `<span class='label label-yellow'>{% trans "NO" %}</span>`;
}
}
function loadPartTestTemplateTable(table, options) { 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);
} }
}, },
{ {

View File

@ -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 = [];

View File

@ -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" %}",
},
}; };
} }

View File

@ -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

View File

@ -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)

View File

@ -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