Merge remote-tracking branch 'inventree/master'

This commit is contained in:
Oliver Walters 2021-02-23 15:32:11 +11:00
commit 846b7aac84
51 changed files with 876 additions and 259 deletions

View File

@ -204,7 +204,6 @@ INSTALLED_APPS = [
'crispy_forms', # Improved form rendering 'crispy_forms', # Improved form rendering
'import_export', # Import / export tables to file 'import_export', # Import / export tables to file
'django_cleanup.apps.CleanupConfig', # Automatically delete orphaned MEDIA files 'django_cleanup.apps.CleanupConfig', # Automatically delete orphaned MEDIA files
'qr_code', # Generate QR codes
'mptt', # Modified Preorder Tree Traversal 'mptt', # Modified Preorder Tree Traversal
'markdownx', # Markdown editing 'markdownx', # Markdown editing
'markdownify', # Markdown template rendering 'markdownify', # Markdown template rendering
@ -250,6 +249,7 @@ TEMPLATES = [
os.path.join(BASE_DIR, 'templates'), os.path.join(BASE_DIR, 'templates'),
# Allow templates in the reporting directory to be accessed # Allow templates in the reporting directory to be accessed
os.path.join(MEDIA_ROOT, 'report'), os.path.join(MEDIA_ROOT, 'report'),
os.path.join(MEDIA_ROOT, 'label'),
], ],
'APP_DIRS': True, 'APP_DIRS': True,
'OPTIONS': { 'OPTIONS': {
@ -407,8 +407,6 @@ CACHES = {
} }
} }
QR_CODE_CACHE_ALIAS = 'qr-code'
# Password validation # Password validation
# https://docs.djangoproject.com/en/1.10/ref/settings/#auth-password-validators # https://docs.djangoproject.com/en/1.10/ref/settings/#auth-password-validators

View File

@ -125,6 +125,7 @@
.qr-container { .qr-container {
width: 100%; width: 100%;
align-content: center; align-content: center;
object-fit: fill;
} }
.navbar-brand { .navbar-brand {

View File

@ -9,7 +9,6 @@ from django.conf.urls import url, include
from django.urls import path from django.urls import path
from django.contrib import admin from django.contrib import admin
from django.contrib.auth import views as auth_views from django.contrib.auth import views as auth_views
from qr_code import urls as qr_code_urls
from company.urls import company_urls from company.urls import company_urls
from company.urls import supplier_part_urls from company.urls import supplier_part_urls
@ -21,7 +20,7 @@ from stock.urls import stock_urls
from build.urls import build_urls from build.urls import build_urls
from order.urls import order_urls from order.urls import order_urls
from barcode.api import barcode_api_urls from barcodes.api import barcode_api_urls
from common.api import common_api_urls from common.api import common_api_urls
from part.api import part_api_urls, bom_api_urls from part.api import part_api_urls, bom_api_urls
from company.api import company_api_urls from company.api import company_api_urls
@ -141,8 +140,6 @@ urlpatterns = [
url(r'^admin/shell/', include('django_admin_shell.urls')), url(r'^admin/shell/', include('django_admin_shell.urls')),
url(r'^admin/', admin.site.urls, name='inventree-admin'), url(r'^admin/', admin.site.urls, name='inventree-admin'),
url(r'^qr_code/', include(qr_code_urls, namespace='qr_code')),
url(r'^index/', IndexView.as_view(), name='index'), url(r'^index/', IndexView.as_view(), name='index'),
url(r'^search/', SearchView.as_view(), name='search'), url(r'^search/', SearchView.as_view(), name='search'),
url(r'^stats/', DatabaseStatsView.as_view(), name='stats'), url(r'^stats/', DatabaseStatsView.as_view(), name='stats'),

View File

@ -12,7 +12,7 @@ from rest_framework.views import APIView
from stock.models import StockItem from stock.models import StockItem
from stock.serializers import StockItemSerializer from stock.serializers import StockItemSerializer
from barcode.barcode import load_barcode_plugins, hash_barcode from barcodes.barcode import load_barcode_plugins, hash_barcode
class BarcodeScan(APIView): class BarcodeScan(APIView):

View File

@ -5,7 +5,7 @@ import hashlib
import logging import logging
from InvenTree import plugins as InvenTreePlugins from InvenTree import plugins as InvenTreePlugins
from barcode import plugins as BarcodePlugins from barcodes import plugins as BarcodePlugins
from stock.models import StockItem from stock.models import StockItem
from stock.serializers import StockItemSerializer, LocationSerializer from stock.serializers import StockItemSerializer, LocationSerializer

View File

@ -4,7 +4,7 @@
DigiKey barcode decoding DigiKey barcode decoding
""" """
from barcode.barcode import BarcodePlugin from barcodes.barcode import BarcodePlugin
class DigikeyBarcodePlugin(BarcodePlugin): class DigikeyBarcodePlugin(BarcodePlugin):

View File

@ -13,7 +13,7 @@ references model objects actually exist in the database.
import json import json
from barcode.barcode import BarcodePlugin from barcodes.barcode import BarcodePlugin
from stock.models import StockItem, StockLocation from stock.models import StockItem, StockLocation
from part.models import Part from part.models import Part

View File

@ -56,6 +56,28 @@ class BuildList(generics.ListCreateAPIView):
params = self.request.query_params params = self.request.query_params
# Filter by "parent"
parent = params.get('parent', None)
if parent is not None:
queryset = queryset.filter(parent=parent)
# Filter by "ancestor" builds
ancestor = params.get('ancestor', None)
if ancestor is not None:
try:
ancestor = Build.objects.get(pk=ancestor)
descendants = ancestor.get_descendants(include_self=True)
queryset = queryset.filter(
parent__pk__in=[b.pk for b in descendants]
)
except (ValueError, Build.DoesNotExist):
pass
# Filter by build status? # Filter by build status?
status = params.get('status', None) status = params.get('status', None)

View File

@ -259,6 +259,27 @@ class Build(MPTTModel):
blank=True, help_text=_('Extra build notes') blank=True, help_text=_('Extra build notes')
) )
def sub_builds(self, cascade=True):
"""
Return all Build Order objects under this one.
"""
if cascade:
return Build.objects.filter(parent=self.pk)
else:
descendants = self.get_descendants(include_self=True)
Build.objects.filter(parent__pk__in=[d.pk for d in descendants])
def sub_build_count(self, cascade=True):
"""
Return the number of sub builds under this one.
Args:
cascade: If True (defualt), include cascading builds under sub builds
"""
return self.sub_builds(cascade=cascade).count()
@property @property
def is_overdue(self): def is_overdue(self):
""" """

View File

@ -0,0 +1,39 @@
{% extends "build/build_base.html" %}
{% load static %}
{% load i18n %}
{% block details %}
{% include "build/tabs.html" with tab="children" %}
<h4>{% trans "Child Build Orders" %}</h4>
<hr>
<div id='button-toolbar'>
<div class='button-toolbar container-fluid float-right'>
<div class='filter-list' id='filter-list-sub-build'>
<!-- Empty div for filters -->
</div>
</div>
</div>
<table class='table table-striped table-condensed' id='sub-build-table' data-toolbar='#button-toolbar'></table>
{% endblock %}
{% block js_ready %}
{{ block.super }}
loadBuildTable($('#sub-build-table'), {
url: '{% url "api-build-list" %}',
filterTarget: "#filter-list-sub-build",
params: {
part_detail: true,
ancestor: {{ build.pk }},
}
});
{% endblock %}

View File

@ -24,6 +24,12 @@
<span class='badge'>{{ build.output_count }}</span> <span class='badge'>{{ build.output_count }}</span>
</a> </a>
</li> </li>
<li {% if tab == 'children' %} class='active'{% endif %}>
<a href='{% url "build-children" build.id %}'>
{% trans "Child Builds" %}
<span class='badge'>{{ build.sub_build_count }}</span>
</a>
</li>
<li{% if tab == 'notes' %} class='active'{% endif %}> <li{% if tab == 'notes' %} class='active'{% endif %}>
<a href="{% url 'build-notes' build.id %}"> <a href="{% url 'build-notes' build.id %}">
{% trans "Notes" %} {% trans "Notes" %}

162
InvenTree/build/test_api.py Normal file
View File

@ -0,0 +1,162 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from datetime import datetime, timedelta
from rest_framework.test import APITestCase
from django.urls import reverse
from django.contrib.auth import get_user_model
from django.contrib.auth.models import Group
from part.models import Part
from build.models import Build
from InvenTree.status_codes import BuildStatus
class BuildAPITest(APITestCase):
"""
Series of tests for the Build DRF API
"""
fixtures = [
'category',
'part',
'location',
'bom',
'build',
]
def setUp(self):
# Create a user for auth
user = get_user_model()
self.user = user.objects.create_user(
username='testuser',
email='test@testing.com',
password='password'
)
# Put the user into a group with the correct permissions
group = Group.objects.create(name='mygroup')
self.user.groups.add(group)
# Give the group *all* the permissions!
for rule in group.rule_sets.all():
rule.can_view = True
rule.can_change = True
rule.can_add = True
rule.can_delete = True
rule.save()
group.save()
self.client.login(username='testuser', password='password')
class BuildListTest(BuildAPITest):
"""
Tests for the BuildOrder LIST API
"""
url = reverse('api-build-list')
def get(self, status_code=200, data={}):
response = self.client.get(self.url, data, format='json')
self.assertEqual(response.status_code, status_code)
return response.data
def test_get_all_builds(self):
"""
Retrieve *all* builds via the API
"""
builds = self.get()
self.assertEqual(len(builds), 5)
builds = self.get(data={'active': True})
self.assertEqual(len(builds), 1)
builds = self.get(data={'status': BuildStatus.COMPLETE})
self.assertEqual(len(builds), 4)
builds = self.get(data={'overdue': False})
self.assertEqual(len(builds), 5)
builds = self.get(data={'overdue': True})
self.assertEqual(len(builds), 0)
def test_overdue(self):
"""
Create a new build, in the past
"""
in_the_past = datetime.now().date() - timedelta(days=50)
part = Part.objects.get(pk=50)
Build.objects.create(
part=part,
quantity=10,
title='Just some thing',
status=BuildStatus.PRODUCTION,
target_date=in_the_past
)
builds = self.get(data={'overdue': True})
self.assertEqual(len(builds), 1)
def test_sub_builds(self):
"""
Test the build / sub-build relationship
"""
parent = Build.objects.get(pk=5)
part = Part.objects.get(pk=50)
n = Build.objects.count()
# Make some sub builds
for i in range(5):
Build.objects.create(
part=part,
quantity=10,
reference=f"build-000{i}",
title=f"Sub build {i}",
parent=parent
)
# And some sub-sub builds
for sub_build in Build.objects.filter(parent=parent):
for i in range(3):
Build.objects.create(
part=part,
reference=f"{sub_build.reference}-00{i}-sub",
quantity=40,
title=f"sub sub build {i}",
parent=sub_build
)
# 20 new builds should have been created!
self.assertEqual(Build.objects.count(), (n + 20))
Build.objects.rebuild()
# Search by parent
builds = self.get(data={'parent': parent.pk})
self.assertEqual(len(builds), 5)
# Search by ancestor
builds = self.get(data={'ancestor': parent.pk})
self.assertEqual(len(builds), 20)

View File

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

View File

@ -618,6 +618,7 @@ class BuildDetail(DetailView):
ctx['bom_price'] = build.part.get_price_info(build.quantity, buy=False) ctx['bom_price'] = build.part.get_price_info(build.quantity, buy=False)
ctx['BuildStatus'] = BuildStatus ctx['BuildStatus'] = BuildStatus
ctx['sub_build_count'] = build.sub_build_count()
return ctx return ctx

View File

@ -125,6 +125,13 @@ class InvenTreeSetting(models.Model):
'validator': bool 'validator': bool
}, },
'PART_RECENT_COUNT': {
'name': _('Recent Part Count'),
'description': _('Number of recent parts to display on index page'),
'default': 10,
'validator': [int, MinValueValidator(1)]
},
'PART_TEMPLATE': { 'PART_TEMPLATE': {
'name': _('Template'), 'name': _('Template'),
'description': _('Parts are templates by default'), 'description': _('Parts are templates by default'),
@ -249,6 +256,13 @@ class InvenTreeSetting(models.Model):
'validator': bool, 'validator': bool,
}, },
'STOCK_RECENT_COUNT': {
'name': _('Recent Stock Count'),
'description': _('Number of recent stock items to display on index page'),
'default': 10,
'validator': [int, MinValueValidator(1)]
},
'BUILDORDER_REFERENCE_PREFIX': { 'BUILDORDER_REFERENCE_PREFIX': {
'name': _('Build Order Reference Prefix'), 'name': _('Build Order Reference Prefix'),
'description': _('Prefix value for build order reference'), 'description': _('Prefix value for build order reference'),
@ -521,12 +535,18 @@ class InvenTreeSetting(models.Model):
validator = InvenTreeSetting.get_setting_validator(self.key) validator = InvenTreeSetting.get_setting_validator(self.key)
if validator is not None:
self.run_validator(validator)
if self.is_bool(): if self.is_bool():
self.value = InvenTree.helpers.str2bool(self.value) self.value = InvenTree.helpers.str2bool(self.value)
if self.is_int():
try:
self.value = int(self.value)
except (ValueError):
raise ValidationError(_('Must be an integer value'))
if validator is not None:
self.run_validator(validator)
def run_validator(self, validator): def run_validator(self, validator):
""" """
Run a validator against the 'value' field for this InvenTreeSetting object. Run a validator against the 'value' field for this InvenTreeSetting object.
@ -535,39 +555,39 @@ class InvenTreeSetting(models.Model):
if validator is None: if validator is None:
return return
# If a list of validators is supplied, iterate through each one value = self.value
if type(validator) in [list, tuple]:
for v in validator:
self.run_validator(v)
return
if callable(validator):
# We can accept function validators with a single argument
print("Running validator function")
validator(self.value)
# Boolean validator # Boolean validator
if validator == bool: if self.is_bool():
# Value must "look like" a boolean value # Value must "look like" a boolean value
if InvenTree.helpers.is_bool(self.value): if InvenTree.helpers.is_bool(value):
# Coerce into either "True" or "False" # Coerce into either "True" or "False"
self.value = str(InvenTree.helpers.str2bool(self.value)) value = InvenTree.helpers.str2bool(value)
else: else:
raise ValidationError({ raise ValidationError({
'value': _('Value must be a boolean value') 'value': _('Value must be a boolean value')
}) })
# Integer validator # Integer validator
if validator == int: if self.is_int():
try: try:
# Coerce into an integer value # Coerce into an integer value
self.value = str(int(self.value)) value = int(value)
except (ValueError, TypeError): except (ValueError, TypeError):
raise ValidationError({ raise ValidationError({
'value': _('Value must be an integer value'), 'value': _('Value must be an integer value'),
}) })
# If a list of validators is supplied, iterate through each one
if type(validator) in [list, tuple]:
for v in validator:
self.run_validator(v)
if callable(validator):
# We can accept function validators with a single argument
validator(self.value)
def validate_unique(self, exclude=None): def validate_unique(self, exclude=None):
""" Ensure that the key:value pair is unique. """ Ensure that the key:value pair is unique.
In addition to the base validators, this ensures that the 'key' In addition to the base validators, this ensures that the 'key'
@ -597,7 +617,13 @@ class InvenTreeSetting(models.Model):
validator = InvenTreeSetting.get_setting_validator(self.key) validator = InvenTreeSetting.get_setting_validator(self.key)
return validator == bool if validator == bool:
return True
if type(validator) in [list, tuple]:
for v in validator:
if v == bool:
return True
def as_bool(self): def as_bool(self):
""" """
@ -623,6 +649,8 @@ class InvenTreeSetting(models.Model):
if v == int: if v == int:
return True return True
return False
def as_int(self): def as_int(self):
""" """
Return the value of this setting converted to a boolean value. Return the value of this setting converted to a boolean value.

View File

@ -17,6 +17,15 @@ src="{% static 'img/blank_image.png' %}"
{% block page_data %} {% block page_data %}
<h3>{% trans "Supplier Part" %}</h3> <h3>{% trans "Supplier Part" %}</h3>
<hr>
<h4>
{{ part.part.full_name }}
{% if user.is_staff and perms.company.change_company %}
<a href="{% url 'admin:company_supplierpart_change' part.pk %}">
<span title='{% trans "Admin view" %}' class='fas fa-user-shield'></span>
</a>
{% endif %}
</h4>
<p>{{ part.supplier.name }} - {{ part.SKU }}</p> <p>{{ part.supplier.name }} - {{ part.SKU }}</p>
{% if roles.purchase_order.change %} {% if roles.purchase_order.change %}

View File

@ -1,11 +1,10 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from __future__ import unicode_literals from __future__ import unicode_literals
import sys
from django.utils.translation import ugettext as _ from django.utils.translation import ugettext as _
from django.conf.urls import url, include from django.conf.urls import url, include
from django.core.exceptions import ValidationError, FieldError from django.core.exceptions import ValidationError, FieldError
from django.http import HttpResponse
from django_filters.rest_framework import DjangoFilterBackend from django_filters.rest_framework import DjangoFilterBackend
@ -13,6 +12,7 @@ from rest_framework import generics, filters
from rest_framework.response import Response from rest_framework.response import Response
import InvenTree.helpers import InvenTree.helpers
import common.models
from stock.models import StockItem, StockLocation from stock.models import StockItem, StockLocation
@ -40,6 +40,74 @@ class LabelListView(generics.ListAPIView):
] ]
class LabelPrintMixin:
"""
Mixin for printing labels
"""
def print(self, request, items_to_print):
"""
Print this label template against a number of pre-validated items
"""
if len(items_to_print) == 0:
# No valid items provided, return an error message
data = {
'error': _('No valid objects provided to template'),
}
return Response(data, status=400)
outputs = []
# In debug mode, generate single HTML output, rather than PDF
debug_mode = common.models.InvenTreeSetting.get_setting('REPORT_DEBUG_MODE')
# Merge one or more PDF files into a single download
for item in items_to_print:
label = self.get_object()
label.object_to_print = item
if debug_mode:
outputs.append(label.render_as_string(request))
else:
outputs.append(label.render(request))
if debug_mode:
"""
Contatenate all rendered templates into a single HTML string,
and return the string as a HTML response.
"""
html = "\n".join(outputs)
return HttpResponse(html)
else:
"""
Concatenate all rendered pages into a single PDF object,
and return the resulting document!
"""
pages = []
if len(outputs) > 1:
# If more than one output is generated, merge them into a single file
for output in outputs:
doc = output.get_document()
for page in doc.pages:
pages.append(page)
pdf = outputs[0].get_document().copy(pages).write_pdf()
else:
pdf = outputs[0].get_document().write_pdf()
return InvenTree.helpers.DownloadFile(
pdf,
'inventree_label.pdf',
content_type='application/pdf'
)
class StockItemLabelMixin: class StockItemLabelMixin:
""" """
Mixin for extracting stock items from query params Mixin for extracting stock items from query params
@ -158,7 +226,7 @@ class StockItemLabelDetail(generics.RetrieveUpdateDestroyAPIView):
serializer_class = StockItemLabelSerializer serializer_class = StockItemLabelSerializer
class StockItemLabelPrint(generics.RetrieveAPIView, StockItemLabelMixin): class StockItemLabelPrint(generics.RetrieveAPIView, StockItemLabelMixin, LabelPrintMixin):
""" """
API endpoint for printing a StockItemLabel object API endpoint for printing a StockItemLabel object
""" """
@ -173,34 +241,7 @@ class StockItemLabelPrint(generics.RetrieveAPIView, StockItemLabelMixin):
items = self.get_items() items = self.get_items()
if len(items) == 0: return self.print(request, items)
# No valid items provided, return an error message
data = {
'error': _('Must provide valid StockItem(s)'),
}
return Response(data, status=400)
label = self.get_object()
try:
pdf = label.render(items)
except:
e = sys.exc_info()[1]
data = {
'error': _('Error during label rendering'),
'message': str(e),
}
return Response(data, status=400)
return InvenTree.helpers.DownloadFile(
pdf.getbuffer(),
'stock_item_label.pdf',
content_type='application/pdf'
)
class StockLocationLabelMixin: class StockLocationLabelMixin:
@ -320,7 +361,7 @@ class StockLocationLabelDetail(generics.RetrieveUpdateDestroyAPIView):
serializer_class = StockLocationLabelSerializer serializer_class = StockLocationLabelSerializer
class StockLocationLabelPrint(generics.RetrieveAPIView, StockLocationLabelMixin): class StockLocationLabelPrint(generics.RetrieveAPIView, StockLocationLabelMixin, LabelPrintMixin):
""" """
API endpoint for printing a StockLocationLabel object API endpoint for printing a StockLocationLabel object
""" """
@ -332,35 +373,7 @@ class StockLocationLabelPrint(generics.RetrieveAPIView, StockLocationLabelMixin)
locations = self.get_locations() locations = self.get_locations()
if len(locations) == 0: return self.print(request, locations)
# No valid locations provided - return an error message
return Response(
{
'error': _('Must provide valid StockLocation(s)'),
},
status=400,
)
label = self.get_object()
try:
pdf = label.render(locations)
except:
e = sys.exc_info()[1]
data = {
'error': _('Error during label rendering'),
'message': str(e),
}
return Response(data, status=400)
return InvenTree.helpers.DownloadFile(
pdf.getbuffer(),
'stock_location_label.pdf',
content_type='application/pdf'
)
label_api_urls = [ label_api_urls = [

View File

@ -1,6 +1,7 @@
import os import os
import shutil import shutil
import logging import logging
import hashlib
from django.apps import AppConfig from django.apps import AppConfig
from django.conf import settings from django.conf import settings
@ -9,6 +10,20 @@ from django.conf import settings
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def hashFile(filename):
"""
Calculate the MD5 hash of a file
"""
md5 = hashlib.md5()
with open(filename, 'rb') as f:
data = f.read()
md5.update(data)
return md5.hexdigest()
class LabelConfig(AppConfig): class LabelConfig(AppConfig):
name = 'label' name = 'label'
@ -35,6 +50,7 @@ class LabelConfig(AppConfig):
src_dir = os.path.join( src_dir = os.path.join(
os.path.dirname(os.path.realpath(__file__)), os.path.dirname(os.path.realpath(__file__)),
'templates', 'templates',
'label',
'stockitem', 'stockitem',
) )
@ -54,6 +70,8 @@ class LabelConfig(AppConfig):
'file': 'qr.html', 'file': 'qr.html',
'name': 'QR Code', 'name': 'QR Code',
'description': 'Simple QR code label', 'description': 'Simple QR code label',
'width': 24,
'height': 24,
}, },
] ]
@ -70,7 +88,21 @@ class LabelConfig(AppConfig):
src_file = os.path.join(src_dir, label['file']) src_file = os.path.join(src_dir, label['file'])
dst_file = os.path.join(settings.MEDIA_ROOT, filename) dst_file = os.path.join(settings.MEDIA_ROOT, filename)
if not os.path.exists(dst_file): to_copy = False
if os.path.exists(dst_file):
# File already exists - let's see if it is the "same",
# or if we need to overwrite it with a newer copy!
if not hashFile(dst_file) == hashFile(src_file):
logger.info(f"Hash differs for '{filename}'")
to_copy = True
else:
logger.info(f"Label template '{filename}' is not present")
to_copy = True
if to_copy:
logger.info(f"Copying label template '{dst_file}'") logger.info(f"Copying label template '{dst_file}'")
shutil.copyfile(src_file, dst_file) shutil.copyfile(src_file, dst_file)
@ -86,7 +118,9 @@ class LabelConfig(AppConfig):
description=label['description'], description=label['description'],
label=filename, label=filename,
filters='', filters='',
enabled=True enabled=True,
width=label['width'],
height=label['height'],
) )
except: except:
pass pass
@ -106,6 +140,7 @@ class LabelConfig(AppConfig):
src_dir = os.path.join( src_dir = os.path.join(
os.path.dirname(os.path.realpath(__file__)), os.path.dirname(os.path.realpath(__file__)),
'templates', 'templates',
'label',
'stocklocation', 'stocklocation',
) )
@ -125,11 +160,15 @@ class LabelConfig(AppConfig):
'file': 'qr.html', 'file': 'qr.html',
'name': 'QR Code', 'name': 'QR Code',
'description': 'Simple QR code label', 'description': 'Simple QR code label',
'width': 24,
'height': 24,
}, },
{ {
'file': 'qr_and_text.html', 'file': 'qr_and_text.html',
'name': 'QR and text', 'name': 'QR and text',
'description': 'Label with QR code and name of location', 'description': 'Label with QR code and name of location',
'width': 50,
'height': 24,
} }
] ]
@ -146,7 +185,21 @@ class LabelConfig(AppConfig):
src_file = os.path.join(src_dir, label['file']) src_file = os.path.join(src_dir, label['file'])
dst_file = os.path.join(settings.MEDIA_ROOT, filename) dst_file = os.path.join(settings.MEDIA_ROOT, filename)
if not os.path.exists(dst_file): to_copy = False
if os.path.exists(dst_file):
# File already exists - let's see if it is the "same",
# or if we need to overwrite it with a newer copy!
if not hashFile(dst_file) == hashFile(src_file):
logger.info(f"Hash differs for '{filename}'")
to_copy = True
else:
logger.info(f"Label template '{filename}' is not present")
to_copy = True
if to_copy:
logger.info(f"Copying label template '{dst_file}'") logger.info(f"Copying label template '{dst_file}'")
shutil.copyfile(src_file, dst_file) shutil.copyfile(src_file, dst_file)
@ -162,7 +215,9 @@ class LabelConfig(AppConfig):
description=label['description'], description=label['description'],
label=filename, label=filename,
filters='', filters='',
enabled=True enabled=True,
width=label['width'],
height=label['height'],
) )
except: except:
pass pass

View File

@ -0,0 +1,34 @@
# Generated by Django 3.0.7 on 2021-02-22 04:35
import django.core.validators
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('label', '0005_auto_20210113_2302'),
]
operations = [
migrations.AddField(
model_name='stockitemlabel',
name='height',
field=models.FloatField(default=20, help_text='Label height, specified in mm', validators=[django.core.validators.MinValueValidator(2)], verbose_name='Height [mm]'),
),
migrations.AddField(
model_name='stockitemlabel',
name='width',
field=models.FloatField(default=50, help_text='Label width, specified in mm', validators=[django.core.validators.MinValueValidator(2)], verbose_name='Width [mm]'),
),
migrations.AddField(
model_name='stocklocationlabel',
name='height',
field=models.FloatField(default=20, help_text='Label height, specified in mm', validators=[django.core.validators.MinValueValidator(2)], verbose_name='Height [mm]'),
),
migrations.AddField(
model_name='stocklocationlabel',
name='width',
field=models.FloatField(default=50, help_text='Label width, specified in mm', validators=[django.core.validators.MinValueValidator(2)], verbose_name='Width [mm]'),
),
]

View File

@ -5,21 +5,35 @@ Label printing models
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from __future__ import unicode_literals from __future__ import unicode_literals
import sys
import os import os
import io import logging
import datetime
from blabel import LabelWriter
from django.conf import settings
from django.db import models from django.db import models
from django.core.validators import FileExtensionValidator from django.core.validators import FileExtensionValidator, MinValueValidator
from django.core.exceptions import ValidationError, FieldError from django.core.exceptions import ValidationError, FieldError
from django.template.loader import render_to_string
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from InvenTree.helpers import validateFilterString, normalize from InvenTree.helpers import validateFilterString, normalize
import common.models
import stock.models import stock.models
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)
logger = logging.getLogger(__name__)
def rename_label(instance, filename): def rename_label(instance, filename):
""" Place the label file into the correct subdirectory """ """ Place the label file into the correct subdirectory """
@ -43,6 +57,21 @@ def validate_stock_location_filters(filters):
return filters return filters
class WeasyprintLabelMixin(WeasyTemplateResponseMixin):
"""
Class for rendering a label to a PDF
"""
pdf_filename = 'label.pdf'
pdf_attachment = True
def __init__(self, request, template, **kwargs):
self.request = request
self.template_name = template
self.pdf_filename = kwargs.get('filename', 'label.pdf')
class LabelTemplate(models.Model): class LabelTemplate(models.Model):
""" """
Base class for generic, filterable labels. Base class for generic, filterable labels.
@ -53,6 +82,9 @@ class LabelTemplate(models.Model):
# Each class of label files will be stored in a separate subdirectory # Each class of label files will be stored in a separate subdirectory
SUBDIR = "label" SUBDIR = "label"
# Object we will be printing against (will be filled out later)
object_to_print = None
@property @property
def template(self): def template(self):
@ -92,38 +124,90 @@ class LabelTemplate(models.Model):
help_text=_('Label template is enabled'), help_text=_('Label template is enabled'),
) )
def get_record_data(self, items): width = models.FloatField(
default=50,
verbose_name=('Width [mm]'),
help_text=_('Label width, specified in mm'),
validators=[MinValueValidator(2)]
)
height = models.FloatField(
default=20,
verbose_name=_('Height [mm]'),
help_text=_('Label height, specified in mm'),
validators=[MinValueValidator(2)]
)
@property
def template_name(self):
""" """
Return a list of dict objects, one for each item. Returns the file system path to the template file.
Required for passing the file to an external process
""" """
return [] template = self.label.name
template = template.replace('/', os.path.sep)
template = template.replace('\\', os.path.sep)
def render_to_file(self, filename, items, **kwargs): template = os.path.join(settings.MEDIA_ROOT, template)
return template
def get_context_data(self, request):
""" """
Render labels to a PDF file Supply custom context data to the template for rendering.
Note: Override this in any subclass
""" """
records = self.get_record_data(items) return {}
writer = LabelWriter(self.template) def context(self, request):
writer.write_labels(records, filename)
def render(self, items, **kwargs):
""" """
Render labels to an in-memory PDF object, and return it Provides context data to the template.
""" """
records = self.get_record_data(items) context = self.get_context_data(request)
writer = LabelWriter(self.template) # Add "basic" context data which gets passed to every label
context['base_url'] = common.models.InvenTreeSetting.get_setting('INVENTREE_BASE_URL')
context['date'] = datetime.datetime.now().date()
context['datetime'] = datetime.datetime.now()
context['request'] = request
context['user'] = request.user
context['width'] = self.width
context['height'] = self.height
buffer = io.BytesIO() return context
writer.write_labels(records, buffer) def render_as_string(self, request, **kwargs):
"""
Render the label to a HTML string
return buffer Useful for debug mode (viewing generated code)
"""
return render_to_string(self.template_name, self.context(request), request)
def render(self, request, **kwargs):
"""
Render the label template to a PDF file
Uses django-weasyprint plugin to render HTML template
"""
wp = WeasyprintLabelMixin(
request,
self.template_name,
base_url=request.build_absolute_uri("/"),
presentational_hints=True,
**kwargs
)
return wp.render_to_response(
self.context(request),
**kwargs
)
class StockItemLabel(LabelTemplate): class StockItemLabel(LabelTemplate):
@ -157,29 +241,24 @@ class StockItemLabel(LabelTemplate):
return items.exists() return items.exists()
def get_record_data(self, items): def get_context_data(self, request):
""" """
Generate context data for each provided StockItem Generate context data for each provided StockItem
""" """
records = []
for item in items:
# Add some basic information stock_item = self.object_to_print
records.append({
'item': item,
'part': item.part,
'name': item.part.name,
'ipn': item.part.IPN,
'quantity': normalize(item.quantity),
'serial': item.serial,
'uid': item.uid,
'pk': item.pk,
'qr_data': item.format_barcode(brief=True),
'tests': item.testResultMap()
})
return records return {
'item': stock_item,
'part': stock_item.part,
'name': stock_item.part.full_name,
'ipn': stock_item.part.IPN,
'quantity': normalize(stock_item.quantity),
'serial': stock_item.serial,
'uid': stock_item.uid,
'qr_data': stock_item.format_barcode(brief=True),
'tests': stock_item.testResultMap()
}
class StockLocationLabel(LabelTemplate): class StockLocationLabel(LabelTemplate):
@ -212,17 +291,14 @@ class StockLocationLabel(LabelTemplate):
return locs.exists() return locs.exists()
def get_record_data(self, locations): def get_context_data(self, request):
""" """
Generate context data for each provided StockLocation Generate context data for each provided StockLocation
""" """
records = [] location = self.object_to_print
for loc in locations:
records.append({ return {
'location': loc, 'location': location,
}) 'qr_data': location.format_barcode(brief=True),
}
return records

View File

@ -0,0 +1,28 @@
{% load report %}
{% load barcode %}
<head>
<style>
@page {
size: {{ width }}mm {{ height }}mm;
{% block margin %}
margin: 0mm;
{% endblock %}
}
img {
display: inline-block;
image-rendering: pixelated;
}
{% block style %}
{% endblock %}
</style>
</head>
<body>
{% block content %}
<!-- Label data rendered here! -->
{% endblock %}
</body>

View File

@ -0,0 +1,20 @@
{% extends "label/label_base.html" %}
{% load barcode %}
{% block style %}
.qr {
position: fixed;
left: 0mm;
top: 0mm;
height: {{ height }}mm;
width: {{ height }}mm;
}
{% endblock %}
{% block content %}
<img class='qr' src='{% qrcode qr_data %}'>
{% endblock %}

View File

@ -0,0 +1,20 @@
{% extends "label/label_base.html" %}
{% load barcode %}
{% block style %}
.qr {
position: fixed;
left: 0mm;
top: 0mm;
height: {{ height }}mm;
width: {{ height }}mm;
}
{% endblock %}
{% block content %}
<img class='qr' src='{% qrcode qr_data %}'>
{% endblock %}

View File

@ -0,0 +1,33 @@
{% extends "label/label_base.html" %}
{% load barcode %}
{% block style %}
.qr {
position: fixed;
left: 0mm;
top: 0mm;
height: {{ height }}mm;
width: {{ height }}mm;
}
.loc {
font-family: Arial, Helvetica, sans-serif;
display: inline;
position: absolute;
left: {{ height }}mm;
top: 2mm;
}
{% endblock %}
{% block content %}
<img class='qr' src='{% qrcode qr_data %}'>
<div class='loc'>
{{ location.name }}
</div>
{% endblock %}

View File

@ -1,16 +0,0 @@
<style>
@page {
width: 24mm;
height: 24mm;
padding: 1mm;
}
.qr {
margin: 2px;
width: 22mm;
height: 22mm;
}
</style>
<img class='qr' src="{{ label_tools.qr_code(item.barcode) }}"/>

View File

@ -1,16 +0,0 @@
<style>
@page {
width: 24mm;
height: 24mm;
padding: 1mm;
}
.qr {
margin: 2px;
width: 22mm;
height: 22mm;
}
</style>
<img class='qr' src="{{ label_tools.qr_code(location.barcode) }}"/>

View File

@ -1,43 +0,0 @@
<style>
@page {
width: 75mm;
height: 24mm;
padding: 1mm;
}
.location {
padding: 5px;
font-weight: bold;
font-family: Arial, Helvetica, sans-serif;
height: 100%;
vertical-align: middle;
float: right;
display: inline;
font-size: 125%;
position: absolute;
top: 0mm;
left: 23mm;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.qr {
margin: 2px;
width: 22mm;
height: 22mm;
}
</style>
<img class='qr' src="{{ label_tools.qr_code(location.barcode) }}"/>
<div class='location'>
{{ location.name }}
<br>
<br>
<hr>
Location ID: {{ location.pk }}
</div>
</div>

View File

@ -626,7 +626,7 @@ class PartList(generics.ListCreateAPIView):
queryset = queryset.filter(pk__in=parts_need_stock) queryset = queryset.filter(pk__in=parts_need_stock)
# Limit choices # Limit number of results
limit = params.get('limit', None) limit = params.get('limit', None)
if limit is not None: if limit is not None:

View File

@ -245,6 +245,14 @@ class PartSerializer(InvenTreeModelSerializer):
Decimal(0), Decimal(0),
) )
) )
# Annotate with the number of 'suppliers'
queryset = queryset.annotate(
suppliers=Coalesce(
SubqueryCount('supplier_parts'),
Decimal(0),
),
)
return queryset return queryset
@ -263,6 +271,7 @@ class PartSerializer(InvenTreeModelSerializer):
ordering = serializers.FloatField(read_only=True) ordering = serializers.FloatField(read_only=True)
building = serializers.FloatField(read_only=True) building = serializers.FloatField(read_only=True)
stock_item_count = serializers.IntegerField(read_only=True) stock_item_count = serializers.IntegerField(read_only=True)
suppliers = serializers.IntegerField(read_only=True)
image = serializers.CharField(source='get_image_url', read_only=True) image = serializers.CharField(source='get_image_url', read_only=True)
thumbnail = serializers.CharField(source='get_thumbnail_url', read_only=True) thumbnail = serializers.CharField(source='get_thumbnail_url', read_only=True)
@ -308,6 +317,7 @@ class PartSerializer(InvenTreeModelSerializer):
'salable', 'salable',
'starred', 'starred',
'stock_item_count', 'stock_item_count',
'suppliers',
'thumbnail', 'thumbnail',
'trackable', 'trackable',
'units', 'units',

View File

@ -9,7 +9,7 @@
<hr> <hr>
<div id='button-toolbar'> <div id='button-toolbar'>
<div class='button-toolbar container-flui' style='float: right';> <div class='button-toolbar container-fluid' style='float: right';>
{% if part.active %} {% if part.active %}
{% if roles.build.add %} {% if roles.build.add %}
<button class="btn btn-success" id='start-build'><span class='fas fa-tools'></span> {% trans "Start New Build" %}</button> <button class="btn btn-success" id='start-build'><span class='fas fa-tools'></span> {% trans "Start New Build" %}</button>

View File

@ -13,11 +13,9 @@
<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 total_stock %}</span></a> <a href="{% url 'part-stock' part.id %}">{% trans "Stock" %} <span class="badge">{% decimal total_stock %}</span></a>
</li> </li>
{% endif %}
{% if part.component or part.salable or part.used_in_count > 0 %} {% if part.component or part.salable 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 allocated %}</span></a> <a href="{% url 'part-allocation' part.id %}">{% trans "Allocated" %} <span class="badge">{% decimal allocated %}</span></a>

View File

@ -22,12 +22,13 @@
{% block js_ready %} {% block js_ready %}
{{ block.super }} {{ block.super }}
loadSimplePartTable('#used-table', loadPartTable('#used-table',
'{% url "api-part-list" %}', '{% url "api-part-list" %}',
{ {
params: { params: {
uses: {{ part.pk }}, uses: {{ part.pk }},
} },
filterTarget: '#filter-list-usedin',
} }
); );

View File

@ -35,18 +35,6 @@
{% endblock %} {% endblock %}
{% block js_load %}
{{ block.super }}
<!-- jquery-treegrid -->
<script type='text/javascript' src='{% static "treegrid/js/jquery.treegrid.js" %}'></script>
<script type='text/javascript' src='{% static "treegrid/js/jquery.treegrid.bootstrap3.js" %}'></script>
<!-- boostrap-table-treegrid -->
<script type='text/javascript' src='{% static "bootstrap-table/extensions/treegrid/bootstrap-table-treegrid.js" %}'></script>
{% endblock %}
{% block js_ready %} {% block js_ready %}
{{ block.super }} {{ block.super }}

View File

@ -270,14 +270,12 @@ class PartQRTest(PartViewTestCase):
data = str(response.content) data = str(response.content)
self.assertIn('Part QR Code', data) self.assertIn('Part QR Code', data)
self.assertIn('<img class=', data) self.assertIn('<img src=', data)
def test_invalid_part(self): def test_invalid_part(self):
response = self.client.get(reverse('part-qr', args=(9999,)), HTTP_X_REQUESTED_WITH='XMLHttpRequest') response = self.client.get(reverse('part-qr', args=(9999,)), HTTP_X_REQUESTED_WITH='XMLHttpRequest')
data = str(response.content) self.assertEqual(response.status_code, 200)
self.assertIn('Error:', data)
class CategoryTest(PartViewTestCase): class CategoryTest(PartViewTestCase):

View File

@ -164,7 +164,7 @@ class ReportPrintMixin:
report.object_to_print = item report.object_to_print = item
if debug_mode: if debug_mode:
outputs.append(report.render_to_string(request)) outputs.append(report.render_as_string(request))
else: else:
outputs.append(report.render(request)) outputs.append(report.render(request))

View File

@ -221,7 +221,7 @@ class ReportTemplateBase(ReportBase):
return context return context
def render_to_string(self, request, **kwargs): def render_as_string(self, request, **kwargs):
""" """
Render the report to a HTML stiring. Render the report to a HTML stiring.

View File

@ -2,9 +2,9 @@
{% load i18n %} {% load i18n %}
{% load report %} {% load report %}
{% load barcode %}
{% load inventree_extras %} {% load inventree_extras %}
{% load markdownify %} {% load markdownify %}
{% load qr_code %}
{% block page_margin %} {% block page_margin %}
margin: 2cm; margin: 2cm;

View File

@ -0,0 +1,81 @@
"""
Template tags for rendering various barcodes
"""
import base64
from io import BytesIO
from django import template
import qrcode as python_qrcode
import barcode as python_barcode
register = template.Library()
def image_data(img, fmt='PNG'):
"""
Convert an image into HTML renderable data
Returns a string ``data:image/FMT;base64,xxxxxxxxx`` which can be rendered to an <img> tag
"""
buffered = BytesIO()
img.save(buffered, format=fmt)
img_str = base64.b64encode(buffered.getvalue())
return f"data:image/{fmt.lower()};charset=utf-8;base64," + img_str.decode()
@register.simple_tag()
def qrcode(data, **kwargs):
"""
Return a byte-encoded QR code image
Optional kwargs
---------------
fill_color: Fill color (default = black)
back_color: Background color (default = white)
"""
# Construct "default" values
params = dict(
box_size=20,
border=1,
)
fill_color = kwargs.pop('fill_color', 'black')
back_color = kwargs.pop('back_color', 'white')
params.update(**kwargs)
qr = python_qrcode.QRCode(**params)
qr.add_data(data, optimize=20)
qr.make(fit=True)
qri = qr.make_image(fill_color=fill_color, back_color=back_color)
return image_data(qri)
@register.simple_tag()
def barcode(data, barcode_class='code128', **kwargs):
"""
Render a barcode
"""
constructor = python_barcode.get_barcode_class(barcode_class)
data = str(data).zfill(constructor.digits)
writer = python_barcode.writer.ImageWriter
barcode_image = constructor(data, writer=writer())
image = barcode_image.render(writer_options=kwargs)
# Render to byte-encoded PNG
return image_data(image)

View File

@ -808,6 +808,19 @@ class StockList(generics.ListCreateAPIView):
print("After error:", str(updated_after)) print("After error:", str(updated_after))
pass pass
# Limit number of results
limit = params.get('limit', None)
if limit is not None:
try:
limit = int(limit)
if limit > 0:
queryset = queryset[:limit]
except (ValueError):
pass
# Also ensure that we pre-fecth all the related items # Also ensure that we pre-fecth all the related items
queryset = queryset.prefetch_related( queryset = queryset.prefetch_related(
'part', 'part',
@ -815,8 +828,6 @@ class StockList(generics.ListCreateAPIView):
'location' 'location'
) )
queryset = queryset.order_by('part__name')
return queryset return queryset
filter_backends = [ filter_backends = [
@ -828,6 +839,15 @@ class StockList(generics.ListCreateAPIView):
filter_fields = [ filter_fields = [
] ]
ordering_fields = [
'part__name',
'updated',
'stocktake_date',
'expiry_date',
]
ordering = ['part__name']
search_fields = [ search_fields = [
'serial', 'serial',
'batch', 'batch',

View File

@ -105,9 +105,6 @@ class StockItemTest(StockViewTestCase):
response = self.client.get(reverse('stock-item-qr', args=(9999,)), HTTP_X_REQUESTED_WITH='XMLHttpRequest') response = self.client.get(reverse('stock-item-qr', args=(9999,)), HTTP_X_REQUESTED_WITH='XMLHttpRequest')
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
data = str(response.content)
self.assertIn('Error:', data)
def test_adjust_items(self): def test_adjust_items(self):
url = reverse('stock-adjust') url = reverse('stock-adjust')

View File

@ -102,7 +102,7 @@ addHeaderAction('bom-validation', '{% trans "BOM Waiting Validation" %}', 'fa-ti
loadSimplePartTable("#table-latest-parts", "{% url 'api-part-list' %}", { loadSimplePartTable("#table-latest-parts", "{% url 'api-part-list' %}", {
params: { params: {
ordering: "-creation_date", ordering: "-creation_date",
limit: 10, limit: {% settings_value "PART_RECENT_COUNT" %},
}, },
name: 'latest_parts', name: 'latest_parts',
}); });
@ -125,8 +125,19 @@ loadSimplePartTable("#table-bom-validation", "{% url 'api-part-list' %}", {
{% if roles.stock.view %} {% if roles.stock.view %}
addHeaderTitle('{% trans "Stock" %}'); addHeaderTitle('{% trans "Stock" %}');
addHeaderAction('recently-updated-stock', '{% trans "Recently Updated" %}', 'fa-clock');
addHeaderAction('low-stock', '{% trans "Low Stock" %}', 'fa-shopping-cart'); addHeaderAction('low-stock', '{% trans "Low Stock" %}', 'fa-shopping-cart');
addHeaderAction('stock-to-build', '{% trans "Required for Build Orders" %}', 'fa-bullhorn'); addHeaderAction('stock-to-build', '{% trans "Required for Build Orders" %}', 'fa-bullhorn');
loadStockTable($('#table-recently-updated-stock'), {
params: {
ordering: "-updated",
limit: {% settings_value "STOCK_RECENT_COUNT" %},
},
name: 'recently-updated-stock',
grouping: false,
});
{% settings_value "STOCK_ENABLE_EXPIRY" as expiry %} {% settings_value "STOCK_ENABLE_EXPIRY" as expiry %}
{% if expiry %} {% if expiry %}
addHeaderAction('expired-stock', '{% trans "Expired Stock" %}', 'fa-calendar-times'); addHeaderAction('expired-stock', '{% trans "Expired Stock" %}', 'fa-calendar-times');

View File

@ -19,6 +19,7 @@
{% include "InvenTree/settings/setting.html" with key="PART_IPN_REGEX" %} {% include "InvenTree/settings/setting.html" with key="PART_IPN_REGEX" %}
{% include "InvenTree/settings/setting.html" with key="PART_ALLOW_DUPLICATE_IPN" %} {% include "InvenTree/settings/setting.html" with key="PART_ALLOW_DUPLICATE_IPN" %}
{% include "InvenTree/settings/setting.html" with key="PART_SHOW_QUANTITY_IN_FORMS" icon="fa-hashtag" %} {% include "InvenTree/settings/setting.html" with key="PART_SHOW_QUANTITY_IN_FORMS" icon="fa-hashtag" %}
{% include "InvenTree/settings/setting.html" with key="PART_RECENT_COUNT" icon="fa-clock" %}
<tr><td colspan='5 '></td></tr> <tr><td colspan='5 '></td></tr>
{% include "InvenTree/settings/setting.html" with key="PART_TEMPLATE" icon="fa-clone" %} {% include "InvenTree/settings/setting.html" with key="PART_TEMPLATE" icon="fa-clone" %}
{% include "InvenTree/settings/setting.html" with key="PART_ASSEMBLY" icon="fa-tools" %} {% include "InvenTree/settings/setting.html" with key="PART_ASSEMBLY" icon="fa-tools" %}

View File

@ -16,6 +16,7 @@
{% include "InvenTree/settings/header.html" %} {% include "InvenTree/settings/header.html" %}
<tbody> <tbody>
{% include "InvenTree/settings/setting.html" with key="STOCK_GROUP_BY_PART" icon="fa-layer-group" %} {% include "InvenTree/settings/setting.html" with key="STOCK_GROUP_BY_PART" icon="fa-layer-group" %}
{% include "InvenTree/settings/setting.html" with key="STOCK_RECENT_COUNT" icon="fa-clock" %}
{% include "InvenTree/settings/setting.html" with key="STOCK_ENABLE_EXPIRY" icon="fa-stopwatch" %} {% include "InvenTree/settings/setting.html" with key="STOCK_ENABLE_EXPIRY" icon="fa-stopwatch" %}
{% include "InvenTree/settings/setting.html" with key="STOCK_STALE_DAYS" icon="fa-calendar" %} {% include "InvenTree/settings/setting.html" with key="STOCK_STALE_DAYS" icon="fa-calendar" %}
{% include "InvenTree/settings/setting.html" with key="STOCK_ALLOW_EXPIRED_SALE" icon="fa-truck" %} {% include "InvenTree/settings/setting.html" with key="STOCK_ALLOW_EXPIRED_SALE" icon="fa-truck" %}

View File

@ -618,7 +618,9 @@ function loadBuildTable(table, options) {
filters[key] = params[key]; filters[key] = params[key];
} }
setupFilterList("build", table); var filterTarget = options.filterTarget || null;
setupFilterList("build", table, filterTarget);
$(table).inventreeTable({ $(table).inventreeTable({
method: 'get', method: 'get',

View File

@ -114,6 +114,18 @@ function selectLabel(labels, items, options={}) {
* (via AJAX) from the server. * (via AJAX) from the server.
*/ */
// If only a single label template is provided,
// just run with that!
if (labels.length == 1) {
if (options.success) {
options.success(labels[0].pk);
}
return;
}
var modal = options.modal || '#modal-form'; var modal = options.modal || '#modal-form';
var label_list = makeOptionsList( var label_list = makeOptionsList(

View File

@ -325,7 +325,7 @@ function loadPartTable(table, url, options={}) {
filters[key] = params[key]; filters[key] = params[key];
} }
setupFilterList("parts", $(table)); setupFilterList("parts", $(table), options.filterTarget || null);
var columns = [ var columns = [
{ {

View File

@ -319,6 +319,12 @@ function loadStockTable(table, options) {
} }
} }
var grouping = true;
if ('grouping' in options) {
grouping = options.grouping;
}
table.inventreeTable({ table.inventreeTable({
method: 'get', method: 'get',
formatNoMatches: function() { formatNoMatches: function() {
@ -333,7 +339,7 @@ function loadStockTable(table, options) {
{% settings_value 'STOCK_GROUP_BY_PART' as group_by_part %} {% settings_value 'STOCK_GROUP_BY_PART' as group_by_part %}
{% if group_by_part %} {% if group_by_part %}
groupByField: options.groupByField || 'part', groupByField: options.groupByField || 'part',
groupBy: true, groupBy: grouping,
groupByFormatter: function(field, id, data) { groupByFormatter: function(field, id, data) {
var row = data[0]; var row = data[0];

View File

@ -1,12 +1,14 @@
{% load qr_code %} {% load barcode %}
{% load i18n %}
<div class='container' style='width: 80%;'> <div class='container' style='width: 80%;'>
{% if qr_data %} {% if qr_data %}
<div class='qr-container'> <div class='qr-container'>
<img class='qr-code' src="{% qr_url_from_text qr_data size='m' image_format='png' error_correction='m' %}" alt="QR Code"> <img src="{% qrcode qr_data %}">
</div> </div>
{% else %} {% else %}
<b>Error:</b><br> <div class='alert alert-block alert-warning'>
{{ error_msg }} {% trans "QR data not provided" %}
</div>
{% endif %} {% endif %}
</div> </div>

View File

@ -1,7 +1,6 @@
wheel>=0.34.2 # Wheel wheel>=0.34.2 # Wheel
Django==3.0.7 # Django package Django==3.0.7 # Django package
pillow==7.1.0 # Image manipulation pillow==7.1.0 # Image manipulation
blabel==0.1.3 # Simple PDF label printing
djangorestframework==3.10.3 # DRF framework djangorestframework==3.10.3 # DRF framework
django-dbbackup==3.3.0 # Database backup / restore functionality django-dbbackup==3.3.0 # Database backup / restore functionality
django-cors-headers==3.2.0 # CORS headers extension for DRF django-cors-headers==3.2.0 # CORS headers extension for DRF
@ -16,7 +15,6 @@ tablib==0.13.0 # Import / export data files
django-crispy-forms==1.8.1 # Form helpers django-crispy-forms==1.8.1 # Form helpers
django-import-export==2.0.0 # Data import / export for admin interface django-import-export==2.0.0 # Data import / export for admin interface
django-cleanup==5.1.0 # Manage deletion of old / unused uploaded files django-cleanup==5.1.0 # Manage deletion of old / unused uploaded files
django-qr-code==1.2.0 # Generate QR codes
flake8==3.8.3 # PEP checking flake8==3.8.3 # PEP checking
pep8-naming==0.11.1 # PEP naming convention extension pep8-naming==0.11.1 # PEP naming convention extension
coverage==5.3 # Unit test coverage coverage==5.3 # Unit test coverage
@ -31,5 +29,7 @@ certifi # Certifi is (most likely) installed through one
django-error-report==0.2.0 # Error report viewer for the admin interface django-error-report==0.2.0 # Error report viewer for the admin interface
django-test-migrations==1.1.0 # Unit testing for database migrations django-test-migrations==1.1.0 # Unit testing for database migrations
django-migration-linter==2.5.0 # Linting checks for database migrations django-migration-linter==2.5.0 # Linting checks for database migrations
python-barcode[images]==0.13.1 # Barcode generator
qrcode[pil]==6.1 # QR code generator
inventree # Install the latest version of the InvenTree API python library inventree # Install the latest version of the InvenTree API python library