2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-04-29 20:16:44 +00:00

Merge pull request #906 from SchrodingersGat/blabel

Label Printing Functionality
This commit is contained in:
Oliver 2020-08-16 14:02:38 +10:00 committed by GitHub
commit b5b882d3b6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
24 changed files with 1816 additions and 1066 deletions

View File

@ -242,7 +242,7 @@ def WrapWithQuotes(text, quote='"'):
return text
def MakeBarcode(object_name, object_data):
def MakeBarcode(object_name, object_pk, object_data, **kwargs):
""" Generate a string for a barcode. Adds some global InvenTree parameters.
Args:
@ -255,12 +255,20 @@ def MakeBarcode(object_name, object_data):
json string of the supplied data plus some other data
"""
data = {
'tool': 'InvenTree',
'version': inventreeVersion(),
'instance': inventreeInstanceName(),
object_name: object_data
}
brief = kwargs.get('brief', False)
data = {}
if brief:
data[object_name] = object_pk
else:
data['tool'] = 'InvenTree'
data['version'] = inventreeVersion()
data['instance'] = inventreeInstanceName()
# Ensure PK is included
object_data['id'] = object_pk
data[object_name] = object_data
return json.dumps(data, sort_keys=True)
@ -383,3 +391,56 @@ def ExtractSerialNumbers(serials, expected_quantity):
raise ValidationError([_("Number of unique serial number ({s}) must match quantity ({q})".format(s=len(numbers), q=expected_quantity))])
return numbers
def validateFilterString(value):
"""
Validate that a provided filter string looks like a list of comma-separated key=value pairs
These should nominally match to a valid database filter based on the model being filtered.
e.g. "category=6, IPN=12"
e.g. "part__name=widget"
The ReportTemplate class uses the filter string to work out which items a given report applies to.
For example, an acceptance test report template might only apply to stock items with a given IPN,
so the string could be set to:
filters = "IPN = ACME0001"
Returns a map of key:value pairs
"""
# Empty results map
results = {}
value = str(value).strip()
if not value or len(value) == 0:
return results
groups = value.split(',')
for group in groups:
group = group.strip()
pair = group.split('=')
if not len(pair) == 2:
raise ValidationError(
"Invalid group: {g}".format(g=group)
)
k, v = pair
k = k.strip()
v = v.strip()
if not k or not v:
raise ValidationError(
"Invalid group: {g}".format(g=group)
)
results[k] = v
return results

View File

@ -130,6 +130,7 @@ INSTALLED_APPS = [
'build.apps.BuildConfig',
'common.apps.CommonConfig',
'company.apps.CompanyConfig',
'label.apps.LabelConfig',
'order.apps.OrderConfig',
'part.apps.PartConfig',
'report.apps.ReportConfig',

View File

@ -138,6 +138,7 @@ class TestMakeBarcode(TestCase):
bc = helpers.MakeBarcode(
"part",
3,
{
"id": 3,
"url": "www.google.com",

View File

@ -47,12 +47,12 @@ class InvenTreeBarcodePlugin(BarcodePlugin):
else:
return False
for key in ['tool', 'version']:
if key not in self.data.keys():
return False
# If any of the following keys are in the JSON data,
# let's go ahead and assume that the code is a valid InvenTree one...
if not self.data['tool'] == 'InvenTree':
return False
for key in ['tool', 'version', 'InvenTree', 'stockitem', 'location', 'part']:
if key in self.data.keys():
return True
return True
@ -60,6 +60,18 @@ class InvenTreeBarcodePlugin(BarcodePlugin):
for k in self.data.keys():
if k.lower() == 'stockitem':
data = self.data[k]
pk = None
# Initially try casting to an integer
try:
pk = int(data)
except (TypeError, ValueError):
pk = None
if pk is None:
try:
pk = self.data[k]['id']
except (AttributeError, KeyError):
@ -77,6 +89,17 @@ class InvenTreeBarcodePlugin(BarcodePlugin):
for k in self.data.keys():
if k.lower() == 'stocklocation':
pk = None
# First try simple integer lookup
try:
pk = int(self.data[k])
except (TypeError, ValueError):
pk = None
if pk is None:
# Lookup by 'id' field
try:
pk = self.data[k]['id']
except (AttributeError, KeyError):
@ -94,6 +117,16 @@ class InvenTreeBarcodePlugin(BarcodePlugin):
for k in self.data.keys():
if k.lower() == 'part':
pk = None
# Try integer lookup first
try:
pk = int(self.data[k])
except (TypeError, ValueError):
pk = None
if pk is None:
try:
pk = self.data[k]['id']
except (AttributeError, KeyError):

View File

14
InvenTree/label/admin.py Normal file
View File

@ -0,0 +1,14 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.contrib import admin
from .models import StockItemLabel
class StockItemLabelAdmin(admin.ModelAdmin):
list_display = ('name', 'description', 'label')
admin.site.register(StockItemLabel, StockItemLabelAdmin)

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

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

View File

@ -0,0 +1,30 @@
# Generated by Django 3.0.7 on 2020-08-15 23:27
import InvenTree.helpers
import django.core.validators
from django.db import migrations, models
import label.models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='StockItemLabel',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(help_text='Label name', max_length=100, unique=True)),
('description', models.CharField(blank=True, help_text='Label description', max_length=250, null=True)),
('label', models.FileField(help_text='Label template file', upload_to=label.models.rename_label, validators=[django.core.validators.FileExtensionValidator(allowed_extensions=['html'])])),
('filters', models.CharField(blank=True, help_text='Query filters (comma-separated list of key=value pairs', max_length=250, validators=[InvenTree.helpers.validateFilterString])),
],
options={
'abstract': False,
},
),
]

View File

149
InvenTree/label/models.py Normal file
View File

@ -0,0 +1,149 @@
"""
Label printing models
"""
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
import os
import io
from blabel import LabelWriter
from django.db import models
from django.core.validators import FileExtensionValidator
from django.utils.translation import gettext_lazy as _
from InvenTree.helpers import validateFilterString, normalize
from stock.models import StockItem
def rename_label(instance, filename):
""" Place the label file into the correct subdirectory """
filename = os.path.basename(filename)
return os.path.join('label', 'template', instance.SUBDIR, filename)
class LabelTemplate(models.Model):
"""
Base class for generic, filterable labels.
"""
class Meta:
abstract = True
# Each class of label files will be stored in a separate subdirectory
SUBDIR = "label"
@property
def template(self):
return self.label.path
def __str__(self):
return "{n} - {d}".format(
n=self.name,
d=self.description
)
name = models.CharField(
unique=True,
blank=False, max_length=100,
help_text=_('Label name'),
)
description = models.CharField(max_length=250, help_text=_('Label description'), blank=True, null=True)
label = models.FileField(
upload_to=rename_label,
blank=False, null=False,
help_text=_('Label template file'),
validators=[FileExtensionValidator(allowed_extensions=['html'])],
)
filters = models.CharField(
blank=True, max_length=250,
help_text=_('Query filters (comma-separated list of key=value pairs'),
validators=[validateFilterString]
)
def get_record_data(self, items):
"""
Return a list of dict objects, one for each item.
"""
return []
def render_to_file(self, filename, items, **kwargs):
"""
Render labels to a PDF file
"""
records = self.get_record_data(items)
writer = LabelWriter(self.template)
writer.write_labels(records, filename)
def render(self, items, **kwargs):
"""
Render labels to an in-memory PDF object, and return it
"""
records = self.get_record_data(items)
writer = LabelWriter(self.template)
buffer = io.BytesIO()
writer.write_labels(records, buffer)
return buffer
class StockItemLabel(LabelTemplate):
"""
Template for printing StockItem labels
"""
SUBDIR = "stockitem"
def matches_stock_item(self, item):
"""
Test if this label template matches a given StockItem object
"""
filters = validateFilterString(self.filters)
items = StockItem.objects.filter(**filters)
items = items.filter(pk=item.pk)
return items.exists()
def get_record_data(self, items):
"""
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()
})
return records

1
InvenTree/label/tests.py Normal file
View File

@ -0,0 +1 @@
# Create your tests here.

1
InvenTree/label/views.py Normal file
View File

@ -0,0 +1 @@
# Create your views here.

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -560,16 +560,17 @@ class Part(MPTTModel):
responsible = models.ForeignKey(User, on_delete=models.SET_NULL, blank=True, null=True, related_name='parts_responible')
def format_barcode(self):
def format_barcode(self, **kwargs):
""" Return a JSON string for formatting a barcode for this Part object """
return helpers.MakeBarcode(
"part",
self.id,
{
"id": self.id,
"name": self.full_name,
"url": reverse('api-part-detail', kwargs={'pk': self.id}),
}
},
**kwargs
)
@property

View File

@ -178,6 +178,37 @@ class SerializeStockForm(HelperForm):
]
class StockItemLabelSelectForm(HelperForm):
""" Form for selecting a label template for a StockItem """
label = forms.ChoiceField(
label=_('Label'),
help_text=_('Select test report template')
)
class Meta:
model = StockItem
fields = [
'label',
]
def get_label_choices(self, labels):
choices = []
if len(labels) > 0:
for label in labels:
choices.append((label.pk, label))
return choices
def __init__(self, labels, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['label'].choices = self.get_label_choices(labels)
class TestReportFormatForm(HelperForm):
""" Form for selection a test report template """

View File

@ -45,16 +45,17 @@ class StockLocation(InvenTreeTree):
def get_absolute_url(self):
return reverse('stock-location-detail', kwargs={'pk': self.id})
def format_barcode(self):
def format_barcode(self, **kwargs):
""" Return a JSON string for formatting a barcode for this StockLocation object """
return helpers.MakeBarcode(
'stocklocation',
self.pk,
{
"id": self.id,
"name": self.name,
"url": reverse('api-location-detail', kwargs={'pk': self.id}),
}
},
**kwargs
)
def get_stock_items(self, cascade=True):
@ -283,7 +284,7 @@ class StockItem(MPTTModel):
def get_part_name(self):
return self.part.full_name
def format_barcode(self):
def format_barcode(self, **kwargs):
""" Return a JSON string for formatting a barcode for this StockItem.
Can be used to perform lookup of a stockitem using barcode
@ -296,10 +297,11 @@ class StockItem(MPTTModel):
return helpers.MakeBarcode(
"stockitem",
self.id,
{
"id": self.id,
"url": reverse('api-stock-detail', kwargs={'pk': self.id}),
}
},
**kwargs
)
uid = models.CharField(blank=True, max_length=128, help_text=("Unique identifier field"))

View File

@ -78,7 +78,7 @@ InvenTree | {% trans "Stock Item" %} - {{ item }}
<button id='barcode-options' title='{% trans "Barcode actions" %}' class='btn btn-default dropdown-toggle' type='button' data-toggle='dropdown'><span class='fas fa-qrcode'></span> <span class='caret'></span></button>
<ul class='dropdown-menu' role='menu'>
<li><a href='#' id='show-qr-code'><span class='fas fa-qrcode'></span> {% trans "Show QR Code" %}</a></li>
<li class='disabled'><a href='#' id='print-label'><span class='fas fa-tag'></span> {% trans "Print Label" %}</a></li>
<li><a href='#' id='print-label'><span class='fas fa-tag'></span> {% trans "Print Label" %}</a></li>
{% if item.uid %}
<li><a href='#' id='unlink-barcode'><span class='fas fa-unlink'></span> {% trans "Unlink Barcode" %}</a></li>
{% else %}
@ -126,7 +126,7 @@ InvenTree | {% trans "Stock Item" %} - {{ item }}
</div>
{% if item.part.has_test_report_templates %}
<button type='button' class='btn btn-default' id='stock-test-report' title='{% trans "Generate test report" %}'>
<span class='fas fa-tasks'/>
<span class='fas fa-file-invoice'/>
</button>
{% endif %}
</div>
@ -314,6 +314,15 @@ $("#stock-test-report").click(function() {
});
{% endif %}
$("#print-label").click(function() {
launchModalForm(
"{% url 'stock-item-label-select' item.id %}",
{
follow: true,
}
)
});
$("#stock-duplicate").click(function() {
launchModalForm(
"{% url 'stock-item-create' %}",

View File

@ -29,6 +29,7 @@ stock_item_detail_urls = [
url(r'^add_tracking/', views.StockItemTrackingCreate.as_view(), name='stock-tracking-create'),
url(r'^test-report-select/', views.StockItemTestReportSelect.as_view(), name='stock-item-test-report-select'),
url(r'^label-select/', views.StockItemSelectLabels.as_view(), name='stock-item-label-select'),
url(r'^test/', views.StockItemDetail.as_view(template_name='stock/item_tests.html'), name='stock-item-test-results'),
url(r'^children/', views.StockItemDetail.as_view(template_name='stock/item_childs.html'), name='stock-item-children'),
@ -59,6 +60,7 @@ stock_urls = [
url(r'^item/new/?', views.StockItemCreate.as_view(), name='stock-item-create'),
url(r'^item/test-report-download/', views.StockItemTestReportDownload.as_view(), name='stock-item-test-report-download'),
url(r'^item/print-stock-labels/', views.StockItemPrintLabels.as_view(), name='stock-item-print-labels'),
# URLs for StockItem attachments
url(r'^item/attachment/', include([

View File

@ -28,6 +28,7 @@ from datetime import datetime
from company.models import Company, SupplierPart
from part.models import Part
from report.models import TestReport
from label.models import StockItemLabel
from .models import StockItem, StockLocation, StockItemTracking, StockItemAttachment, StockItemTestResult
from .admin import StockItemResource
@ -295,6 +296,88 @@ class StockItemReturnToStock(AjaxUpdateView):
return self.renderJsonResponse(request, self.get_form(), data)
class StockItemSelectLabels(AjaxView):
"""
View for selecting a template for printing labels for one (or more) StockItem objects
"""
model = StockItem
ajax_form_title = _('Select Label Template')
def get_form(self):
item = StockItem.objects.get(pk=self.kwargs['pk'])
labels = []
for label in StockItemLabel.objects.all():
if label.matches_stock_item(item):
labels.append(label)
return StockForms.StockItemLabelSelectForm(labels)
def post(self, request, *args, **kwargs):
label = request.POST.get('label', None)
try:
label = StockItemLabel.objects.get(pk=label)
except (ValueError, StockItemLabel.DoesNotExist):
raise ValidationError({'label': _("Select valid label")})
stock_item = StockItem.objects.get(pk=self.kwargs['pk'])
url = reverse('stock-item-print-labels')
url += '?label={pk}'.format(pk=label.pk)
url += '&items[]={pk}'.format(pk=stock_item.pk)
data = {
'form_valid': True,
'url': url,
}
return self.renderJsonResponse(request, self.get_form(), data=data)
class StockItemPrintLabels(AjaxView):
"""
View for printing labels and returning a PDF
Requires the following arguments to be passed as URL params:
items: List of valid StockItem pk values
label: Valid pk of a StockItemLabel template
"""
def get(self, request, *args, **kwargs):
label = request.GET.get('label', None)
try:
label = StockItemLabel.objects.get(pk=label)
except (ValueError, StockItemLabel.DoesNotExist):
raise ValidationError({'label': 'Invalid label ID'})
item_pks = request.GET.getlist('items[]')
items = []
for pk in item_pks:
try:
item = StockItem.objects.get(pk=pk)
items.append(item)
except (ValueError, StockItem.DoesNotExist):
pass
if len(items) == 0:
raise ValidationError({'items': 'Must provide valid stockitems'})
pdf = label.render(items).getbuffer()
return DownloadFile(pdf, 'stock_labels.pdf', content_type='application/pdf')
class StockItemDeleteTestData(AjaxUpdateView):
"""
View for deleting all test data

View File

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

View File

@ -1,6 +1,7 @@
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