Merge pull request #1776 from SchrodingersGat/part-labels

Part labels
This commit is contained in:
Oliver 2021-07-08 23:34:59 +10:00 committed by GitHub
commit 522432f4aa
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 454 additions and 40 deletions

View File

@ -3,7 +3,7 @@ from __future__ import unicode_literals
from django.contrib import admin
from .models import StockItemLabel, StockLocationLabel
from .models import StockItemLabel, StockLocationLabel, PartLabel
class LabelAdmin(admin.ModelAdmin):
@ -13,3 +13,4 @@ class LabelAdmin(admin.ModelAdmin):
admin.site.register(StockItemLabel, LabelAdmin)
admin.site.register(StockLocationLabel, LabelAdmin)
admin.site.register(PartLabel, LabelAdmin)

View File

@ -15,9 +15,10 @@ import InvenTree.helpers
import common.models
from stock.models import StockItem, StockLocation
from part.models import Part
from .models import StockItemLabel, StockLocationLabel
from .serializers import StockItemLabelSerializer, StockLocationLabelSerializer
from .models import StockItemLabel, StockLocationLabel, PartLabel
from .serializers import StockItemLabelSerializer, StockLocationLabelSerializer, PartLabelSerializer
class LabelListView(generics.ListAPIView):
@ -132,6 +133,7 @@ class StockItemLabelMixin:
for key in ['item', 'item[]', 'items', 'items[]']:
if key in params:
items = params.getlist(key, [])
break
valid_ids = []
@ -376,6 +378,112 @@ class StockLocationLabelPrint(generics.RetrieveAPIView, StockLocationLabelMixin,
return self.print(request, locations)
class PartLabelMixin:
"""
Mixin for extracting Part objects from query parameters
"""
def get_parts(self):
"""
Return a list of requested Part objects
"""
parts = []
params = self.request.query_params
for key in ['part', 'part[]', 'parts', 'parts[]']:
if key in params:
parts = params.getlist(key, [])
break
valid_ids = []
for part in parts:
try:
valid_ids.append(int(part))
except (ValueError):
pass
# List of Part objects which match provided values
return Part.objects.filter(pk__in=valid_ids)
class PartLabelList(LabelListView, PartLabelMixin):
"""
API endpoint for viewing list of PartLabel objects
"""
queryset = PartLabel.objects.all()
serializer_class = PartLabelSerializer
def filter_queryset(self, queryset):
queryset = super().filter_queryset(queryset)
parts = self.get_parts()
if len(parts) > 0:
valid_label_ids = set()
for label in queryset.all():
matches = True
try:
filters = InvenTree.helpers.validateFilterString(label.filters)
except ValidationError:
continue
for part in parts:
part_query = Part.objects.filter(pk=part.pk)
try:
if not part_query.filter(**filters).exists():
matches = False
break
except FieldError:
matches = False
break
if matches:
valid_label_ids.add(label.pk)
# Reduce queryset to only valid matches
queryset = queryset.filter(pk__in=[pk for pk in valid_label_ids])
return queryset
class PartLabelDetail(generics.RetrieveUpdateDestroyAPIView):
"""
API endpoint for a single PartLabel object
"""
queryset = PartLabel.objects.all()
serializer_class = PartLabelSerializer
class PartLabelPrint(generics.RetrieveAPIView, PartLabelMixin, LabelPrintMixin):
"""
API endpoint for printing a PartLabel object
"""
queryset = PartLabel.objects.all()
serializer_class = PartLabelSerializer
def get(self, request, *args, **kwargs):
"""
Check if valid part(s) have been provided
"""
parts = self.get_parts()
return self.print(request, parts)
label_api_urls = [
# Stock item labels
@ -401,4 +509,16 @@ label_api_urls = [
# List view
url(r'^.*$', StockLocationLabelList.as_view(), name='api-stocklocation-label-list'),
])),
# Part labels
url(r'^part/', include([
# Detail views
url(r'^(?P<pk>\d+)/', include([
url(r'^print/', PartLabelPrint.as_view(), name='api-part-label-print'),
url(r'^.*$', PartLabelDetail.as_view(), name='api-part-label-detail'),
])),
# List view
url(r'^.*$', PartLabelList.as_view(), name='api-part-label-list'),
])),
]

View File

@ -37,6 +37,7 @@ class LabelConfig(AppConfig):
if canAppAccessDatabase():
self.create_stock_item_labels()
self.create_stock_location_labels()
self.create_part_labels()
def create_stock_item_labels(self):
"""
@ -65,7 +66,7 @@ class LabelConfig(AppConfig):
)
if not os.path.exists(dst_dir):
logger.info(f"Creating missing directory: '{dst_dir}'")
logger.info(f"Creating required directory: '{dst_dir}'")
os.makedirs(dst_dir, exist_ok=True)
labels = [
@ -109,24 +110,21 @@ class LabelConfig(AppConfig):
logger.info(f"Copying label template '{dst_file}'")
shutil.copyfile(src_file, dst_file)
try:
# Check if a label matching the template already exists
if StockItemLabel.objects.filter(label=filename).exists():
continue
# Check if a label matching the template already exists
if StockItemLabel.objects.filter(label=filename).exists():
continue
logger.info(f"Creating entry for StockItemLabel '{label['name']}'")
logger.info(f"Creating entry for StockItemLabel '{label['name']}'")
StockItemLabel.objects.create(
name=label['name'],
description=label['description'],
label=filename,
filters='',
enabled=True,
width=label['width'],
height=label['height'],
)
except:
pass
StockItemLabel.objects.create(
name=label['name'],
description=label['description'],
label=filename,
filters='',
enabled=True,
width=label['width'],
height=label['height'],
)
def create_stock_location_labels(self):
"""
@ -155,7 +153,7 @@ class LabelConfig(AppConfig):
)
if not os.path.exists(dst_dir):
logger.info(f"Creating missing directory: '{dst_dir}'")
logger.info(f"Creating required directory: '{dst_dir}'")
os.makedirs(dst_dir, exist_ok=True)
labels = [
@ -206,21 +204,103 @@ class LabelConfig(AppConfig):
logger.info(f"Copying label template '{dst_file}'")
shutil.copyfile(src_file, dst_file)
try:
# Check if a label matching the template already exists
if StockLocationLabel.objects.filter(label=filename).exists():
continue
# Check if a label matching the template already exists
if StockLocationLabel.objects.filter(label=filename).exists():
continue
logger.info(f"Creating entry for StockLocationLabel '{label['name']}'")
logger.info(f"Creating entry for StockLocationLabel '{label['name']}'")
StockLocationLabel.objects.create(
name=label['name'],
description=label['description'],
label=filename,
filters='',
enabled=True,
width=label['width'],
height=label['height'],
)
except:
pass
StockLocationLabel.objects.create(
name=label['name'],
description=label['description'],
label=filename,
filters='',
enabled=True,
width=label['width'],
height=label['height'],
)
def create_part_labels(self):
"""
Create database entries for the default PartLabel templates,
if they do not already exist.
"""
try:
from .models import PartLabel
except:
# Database might not yet be ready
return
src_dir = os.path.join(
os.path.dirname(os.path.realpath(__file__)),
'templates',
'label',
'part',
)
dst_dir = os.path.join(
settings.MEDIA_ROOT,
'label',
'inventree',
'part',
)
if not os.path.exists(dst_dir):
logger.info(f"Creating required directory: '{dst_dir}'")
os.makedirs(dst_dir, exist_ok=True)
labels = [
{
'file': 'part_label.html',
'name': 'Part Label',
'description': 'Simple part label',
'width': 70,
'height': 24,
},
]
for label in labels:
filename = os.path.join(
'label',
'inventree',
'part',
label['file']
)
src_file = os.path.join(src_dir, label['file'])
dst_file = os.path.join(settings.MEDIA_ROOT, filename)
to_copy = False
if os.path.exists(dst_file):
# File already exists - let's see if it is the "same"
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)
# Check if a label matching the template already exists
if PartLabel.objects.filter(label=filename).exists():
continue
logger.info(f"Creating entry for PartLabel '{label['name']}'")
PartLabel.objects.create(
name=label['name'],
description=label['description'],
label=filename,
filters='',
enabled=True,
width=label['width'],
height=label['height'],
)

View File

@ -0,0 +1,37 @@
# Generated by Django 3.2.4 on 2021-07-08 11:06
import django.core.validators
from django.db import migrations, models
import label.models
class Migration(migrations.Migration):
dependencies = [
('label', '0007_auto_20210513_1327'),
]
operations = [
migrations.CreateModel(
name='PartLabel',
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, verbose_name='Name')),
('description', models.CharField(blank=True, help_text='Label description', max_length=250, null=True, verbose_name='Description')),
('label', models.FileField(help_text='Label template file', unique=True, upload_to=label.models.rename_label, validators=[django.core.validators.FileExtensionValidator(allowed_extensions=['html'])], verbose_name='Label')),
('enabled', models.BooleanField(default=True, help_text='Label template is enabled', verbose_name='Enabled')),
('width', models.FloatField(default=50, help_text='Label width, specified in mm', validators=[django.core.validators.MinValueValidator(2)], verbose_name='Width [mm]')),
('height', models.FloatField(default=20, help_text='Label height, specified in mm', validators=[django.core.validators.MinValueValidator(2)], verbose_name='Height [mm]')),
('filename_pattern', models.CharField(default='label.pdf', help_text='Pattern for generating label filenames', max_length=100, verbose_name='Filename Pattern')),
('filters', models.CharField(blank=True, help_text='Part query filters (comma-separated value of key=value pairs)', max_length=250, validators=[label.models.validate_part_filters], verbose_name='Filters')),
],
options={
'abstract': False,
},
),
migrations.AlterField(
model_name='stockitemlabel',
name='filters',
field=models.CharField(blank=True, help_text='Query filters (comma-separated list of key=value pairs),', max_length=250, validators=[label.models.validate_stock_item_filters], verbose_name='Filters'),
),
]

View File

@ -25,6 +25,8 @@ from InvenTree.helpers import validateFilterString, normalize
import common.models
import stock.models
import part.models
try:
from django_weasyprint import WeasyTemplateResponseMixin
@ -59,6 +61,13 @@ def validate_stock_location_filters(filters):
return filters
def validate_part_filters(filters):
filters = validateFilterString(filters, model=part.models.Part)
return filters
class WeasyprintLabelMixin(WeasyTemplateResponseMixin):
"""
Class for rendering a label to a PDF
@ -246,10 +255,11 @@ class StockItemLabel(LabelTemplate):
filters = models.CharField(
blank=True, max_length=250,
help_text=_('Query filters (comma-separated list of key=value pairs'),
help_text=_('Query filters (comma-separated list of key=value pairs),'),
verbose_name=_('Filters'),
validators=[
validate_stock_item_filters]
validate_stock_item_filters
]
)
def matches_stock_item(self, item):
@ -335,3 +345,57 @@ class StockLocationLabel(LabelTemplate):
'location': location,
'qr_data': location.format_barcode(brief=True),
}
class PartLabel(LabelTemplate):
"""
Template for printing Part labels
"""
@staticmethod
def get_api_url():
return reverse('api-part-label-list')
SUBDIR = 'part'
filters = models.CharField(
blank=True, max_length=250,
help_text=_('Part query filters (comma-separated value of key=value pairs)'),
verbose_name=_('Filters'),
validators=[
validate_part_filters
]
)
def matches_part(self, part):
"""
Test if this label template matches a given Part object
"""
try:
filters = validateFilterString(self.filters)
parts = part.models.Part.objects.filter(**filters)
except (ValidationError, FieldError):
return False
parts = parts.filter(pk=part.pk)
return parts.exists()
def get_context_data(self, request):
"""
Generate context data for each provided Part object
"""
part = self.object_to_print
return {
'part': part,
'category': part.category,
'name': part.name,
'description': part.description,
'IPN': part.IPN,
'revision': part.revision,
'qr_data': part.format_barcode(brief=True),
'qr_url': part.format_barcode(url=True, request=request),
}

View File

@ -4,7 +4,7 @@ from __future__ import unicode_literals
from InvenTree.serializers import InvenTreeModelSerializer
from InvenTree.serializers import InvenTreeAttachmentSerializerField
from .models import StockItemLabel, StockLocationLabel
from .models import StockItemLabel, StockLocationLabel, PartLabel
class StockItemLabelSerializer(InvenTreeModelSerializer):
@ -43,3 +43,22 @@ class StockLocationLabelSerializer(InvenTreeModelSerializer):
'filters',
'enabled',
]
class PartLabelSerializer(InvenTreeModelSerializer):
"""
Serializes a PartLabel object
"""
label = InvenTreeAttachmentSerializerField(required=True)
class Meta:
model = PartLabel
fields = [
'pk',
'name',
'description',
'label',
'filters',
'enabled',
]

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;
}
.part {
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='part'>
{{ part.full_name }}
</div>
{% endblock %}

View File

@ -268,6 +268,10 @@
);
});
$('#print-label').click(function() {
printPartLabels([{{ part.pk }}]);
});
$("#part-count").click(function() {
launchModalForm("/stock/adjust/", {
data: {

View File

@ -105,6 +105,61 @@ function printStockLocationLabels(locations, options={}) {
}
function printPartLabels(parts, options={}) {
/**
* Print labels for the provided parts
*/
if (parts.length == 0) {
showAlertDialog(
'{% trans "Select Parts" %}',
'{% trans "Part(s) must be selected before printing labels" %}',
);
return;
}
// Request available labels from the server
inventreeGet(
'{% url "api-part-label-list" %}',
{
enabled: true,
parts: parts,
},
{
success: function(response) {
if (response.length == 0) {
showAlertDialog(
'{% trans "No Labels Found" %}',
'{% trans "No labels found which match the selected part(s)" %}',
);
return;
}
// Select label to print
selectLabel(
response,
parts,
{
success: function(pk) {
var url = `/api/label/part/${pk}/print/?`;
parts.forEach(function(part) {
url += `parts[]=${part}&`;
});
window.location.href = url;
}
}
);
}
}
);
}
function selectLabel(labels, items, options={}) {
/**
* Present the user with the available labels,

View File

@ -87,6 +87,7 @@ class RuleSet(models.Model):
'company_supplierpart',
'company_manufacturerpart',
'company_manufacturerpartparameter',
'label_partlabel',
],
'stock_location': [
'stock_stocklocation',