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

View File

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

View File

@ -9,7 +9,6 @@ from django.conf.urls import url, include
from django.urls import path
from django.contrib import admin
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 supplier_part_urls
@ -21,7 +20,7 @@ from stock.urls import stock_urls
from build.urls import build_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 part.api import part_api_urls, bom_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/', 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'^search/', SearchView.as_view(), name='search'),
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.serializers import StockItemSerializer
from barcode.barcode import load_barcode_plugins, hash_barcode
from barcodes.barcode import load_barcode_plugins, hash_barcode
class BarcodeScan(APIView):

View File

@ -5,7 +5,7 @@ import hashlib
import logging
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.serializers import StockItemSerializer, LocationSerializer

View File

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

View File

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

View File

@ -56,6 +56,28 @@ class BuildList(generics.ListCreateAPIView):
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?
status = params.get('status', None)

View File

@ -259,6 +259,27 @@ class Build(MPTTModel):
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
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>
</a>
</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 %}>
<a href="{% url 'build-notes' build.id %}">
{% 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'^children/', views.BuildDetail.as_view(template_name='build/build_children.html'), name='build-children'),
url(r'^parts/', views.BuildDetail.as_view(template_name='build/parts.html'), name='build-parts'),
url(r'^attachments/', views.BuildDetail.as_view(template_name='build/attachments.html'), name='build-attachments'),
url(r'^output/', views.BuildDetail.as_view(template_name='build/build_output.html'), name='build-output'),

View File

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

View File

@ -125,6 +125,13 @@ class InvenTreeSetting(models.Model):
'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': {
'name': _('Template'),
'description': _('Parts are templates by default'),
@ -249,6 +256,13 @@ class InvenTreeSetting(models.Model):
'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': {
'name': _('Build Order Reference Prefix'),
'description': _('Prefix value for build order reference'),
@ -521,12 +535,18 @@ class InvenTreeSetting(models.Model):
validator = InvenTreeSetting.get_setting_validator(self.key)
if validator is not None:
self.run_validator(validator)
if self.is_bool():
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):
"""
Run a validator against the 'value' field for this InvenTreeSetting object.
@ -535,39 +555,39 @@ class InvenTreeSetting(models.Model):
if validator is None:
return
# 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)
return
if callable(validator):
# We can accept function validators with a single argument
print("Running validator function")
validator(self.value)
value = self.value
# Boolean validator
if validator == bool:
if self.is_bool():
# 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"
self.value = str(InvenTree.helpers.str2bool(self.value))
value = InvenTree.helpers.str2bool(value)
else:
raise ValidationError({
'value': _('Value must be a boolean value')
})
# Integer validator
if validator == int:
if self.is_int():
try:
# Coerce into an integer value
self.value = str(int(self.value))
value = int(value)
except (ValueError, TypeError):
raise ValidationError({
'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):
""" Ensure that the key:value pair is unique.
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)
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):
"""
@ -623,6 +649,8 @@ class InvenTreeSetting(models.Model):
if v == int:
return True
return False
def as_int(self):
"""
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 %}
<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>
{% if roles.purchase_order.change %}

View File

@ -1,11 +1,10 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
import sys
from django.utils.translation import ugettext as _
from django.conf.urls import url, include
from django.core.exceptions import ValidationError, FieldError
from django.http import HttpResponse
from django_filters.rest_framework import DjangoFilterBackend
@ -13,6 +12,7 @@ from rest_framework import generics, filters
from rest_framework.response import Response
import InvenTree.helpers
import common.models
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:
"""
Mixin for extracting stock items from query params
@ -158,7 +226,7 @@ class StockItemLabelDetail(generics.RetrieveUpdateDestroyAPIView):
serializer_class = StockItemLabelSerializer
class StockItemLabelPrint(generics.RetrieveAPIView, StockItemLabelMixin):
class StockItemLabelPrint(generics.RetrieveAPIView, StockItemLabelMixin, LabelPrintMixin):
"""
API endpoint for printing a StockItemLabel object
"""
@ -173,34 +241,7 @@ class StockItemLabelPrint(generics.RetrieveAPIView, StockItemLabelMixin):
items = self.get_items()
if len(items) == 0:
# 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'
)
return self.print(request, items)
class StockLocationLabelMixin:
@ -320,7 +361,7 @@ class StockLocationLabelDetail(generics.RetrieveUpdateDestroyAPIView):
serializer_class = StockLocationLabelSerializer
class StockLocationLabelPrint(generics.RetrieveAPIView, StockLocationLabelMixin):
class StockLocationLabelPrint(generics.RetrieveAPIView, StockLocationLabelMixin, LabelPrintMixin):
"""
API endpoint for printing a StockLocationLabel object
"""
@ -332,35 +373,7 @@ class StockLocationLabelPrint(generics.RetrieveAPIView, StockLocationLabelMixin)
locations = self.get_locations()
if len(locations) == 0:
# 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'
)
return self.print(request, locations)
label_api_urls = [

View File

@ -1,6 +1,7 @@
import os
import shutil
import logging
import hashlib
from django.apps import AppConfig
from django.conf import settings
@ -9,6 +10,20 @@ from django.conf import settings
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):
name = 'label'
@ -35,6 +50,7 @@ class LabelConfig(AppConfig):
src_dir = os.path.join(
os.path.dirname(os.path.realpath(__file__)),
'templates',
'label',
'stockitem',
)
@ -54,6 +70,8 @@ class LabelConfig(AppConfig):
'file': 'qr.html',
'name': 'QR Code',
'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'])
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}'")
shutil.copyfile(src_file, dst_file)
@ -86,7 +118,9 @@ class LabelConfig(AppConfig):
description=label['description'],
label=filename,
filters='',
enabled=True
enabled=True,
width=label['width'],
height=label['height'],
)
except:
pass
@ -106,6 +140,7 @@ class LabelConfig(AppConfig):
src_dir = os.path.join(
os.path.dirname(os.path.realpath(__file__)),
'templates',
'label',
'stocklocation',
)
@ -125,11 +160,15 @@ class LabelConfig(AppConfig):
'file': 'qr.html',
'name': 'QR Code',
'description': 'Simple QR code label',
'width': 24,
'height': 24,
},
{
'file': 'qr_and_text.html',
'name': 'QR and text',
'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'])
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}'")
shutil.copyfile(src_file, dst_file)
@ -162,7 +215,9 @@ class LabelConfig(AppConfig):
description=label['description'],
label=filename,
filters='',
enabled=True
enabled=True,
width=label['width'],
height=label['height'],
)
except:
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 -*-
from __future__ import unicode_literals
import sys
import os
import io
from blabel import LabelWriter
import logging
import datetime
from django.conf import settings
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.template.loader import render_to_string
from django.utils.translation import gettext_lazy as _
from InvenTree.helpers import validateFilterString, normalize
import common.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):
""" Place the label file into the correct subdirectory """
@ -43,6 +57,21 @@ def validate_stock_location_filters(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):
"""
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
SUBDIR = "label"
# Object we will be printing against (will be filled out later)
object_to_print = None
@property
def template(self):
@ -92,38 +124,90 @@ class LabelTemplate(models.Model):
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)
writer.write_labels(records, filename)
def render(self, items, **kwargs):
def context(self, request):
"""
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):
@ -157,29 +241,24 @@ class StockItemLabel(LabelTemplate):
return items.exists()
def get_record_data(self, items):
def get_context_data(self, request):
"""
Generate context data for each provided StockItem
"""
records = []
for item in items:
# Add some basic information
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()
})
stock_item = self.object_to_print
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):
@ -212,17 +291,14 @@ class StockLocationLabel(LabelTemplate):
return locs.exists()
def get_record_data(self, locations):
def get_context_data(self, request):
"""
Generate context data for each provided StockLocation
"""
records = []
for loc in locations:
location = self.object_to_print
records.append({
'location': loc,
})
return records
return {
'location': location,
'qr_data': location.format_barcode(brief=True),
}

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)
# Limit choices
# Limit number of results
limit = params.get('limit', None)
if limit is not None:

View File

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

View File

@ -9,7 +9,7 @@
<hr>
<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 roles.build.add %}
<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>
</li>
{% endif %}
{% if not part.virtual %}
<li{% ifequal tab 'stock' %} class="active"{% endifequal %}>
<a href="{% url 'part-stock' part.id %}">{% trans "Stock" %} <span class="badge">{% decimal total_stock %}</span></a>
</li>
{% endif %}
{% if part.component or part.salable or part.used_in_count > 0 %}
<li{% ifequal tab 'allocation' %} class="active"{% endifequal %}>
<a href="{% url 'part-allocation' part.id %}">{% trans "Allocated" %} <span class="badge">{% decimal allocated %}</span></a>

View File

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

View File

@ -35,18 +35,6 @@
{% 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.super }}

View File

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

View File

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

View File

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

View File

@ -2,9 +2,9 @@
{% load i18n %}
{% load report %}
{% load barcode %}
{% load inventree_extras %}
{% load markdownify %}
{% load qr_code %}
{% block page_margin %}
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))
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
queryset = queryset.prefetch_related(
'part',
@ -815,8 +828,6 @@ class StockList(generics.ListCreateAPIView):
'location'
)
queryset = queryset.order_by('part__name')
return queryset
filter_backends = [
@ -828,6 +839,15 @@ class StockList(generics.ListCreateAPIView):
filter_fields = [
]
ordering_fields = [
'part__name',
'updated',
'stocktake_date',
'expiry_date',
]
ordering = ['part__name']
search_fields = [
'serial',
'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')
self.assertEqual(response.status_code, 200)
data = str(response.content)
self.assertIn('Error:', data)
def test_adjust_items(self):
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' %}", {
params: {
ordering: "-creation_date",
limit: 10,
limit: {% settings_value "PART_RECENT_COUNT" %},
},
name: 'latest_parts',
});
@ -125,8 +125,19 @@ loadSimplePartTable("#table-bom-validation", "{% url 'api-part-list' %}", {
{% if roles.stock.view %}
addHeaderTitle('{% trans "Stock" %}');
addHeaderAction('recently-updated-stock', '{% trans "Recently Updated" %}', 'fa-clock');
addHeaderAction('low-stock', '{% trans "Low Stock" %}', 'fa-shopping-cart');
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 %}
{% if expiry %}
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_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_RECENT_COUNT" icon="fa-clock" %}
<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_ASSEMBLY" icon="fa-tools" %}

View File

@ -16,6 +16,7 @@
{% include "InvenTree/settings/header.html" %}
<tbody>
{% 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_STALE_DAYS" icon="fa-calendar" %}
{% 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];
}
setupFilterList("build", table);
var filterTarget = options.filterTarget || null;
setupFilterList("build", table, filterTarget);
$(table).inventreeTable({
method: 'get',

View File

@ -114,6 +114,18 @@ function selectLabel(labels, items, options={}) {
* (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 label_list = makeOptionsList(

View File

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

View File

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

View File

@ -1,12 +1,14 @@
{% load qr_code %}
{% load barcode %}
{% load i18n %}
<div class='container' style='width: 80%;'>
{% if qr_data %}
<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>
{% else %}
<b>Error:</b><br>
{{ error_msg }}
<div class='alert alert-block alert-warning'>
{% trans "QR data not provided" %}
</div>
{% endif %}
</div>

View File

@ -1,7 +1,6 @@
wheel>=0.34.2 # Wheel
Django==3.0.7 # Django package
pillow==7.1.0 # Image manipulation
blabel==0.1.3 # Simple PDF label printing
djangorestframework==3.10.3 # DRF framework
django-dbbackup==3.3.0 # Database backup / restore functionality
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-import-export==2.0.0 # Data import / export for admin interface
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
pep8-naming==0.11.1 # PEP naming convention extension
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-test-migrations==1.1.0 # Unit testing 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