Merged master and updated stock_table.html

This commit is contained in:
eeintech 2021-01-17 13:11:59 -05:00
commit 72c7ceb553
33 changed files with 4998 additions and 2133 deletions

View File

@ -12,7 +12,7 @@ from decimal import Decimal
from wsgiref.util import FileWrapper
from django.http import StreamingHttpResponse
from django.core.exceptions import ValidationError
from django.core.exceptions import ValidationError, FieldError
from django.utils.translation import ugettext as _
from django.contrib.auth.models import Permission
@ -414,7 +414,7 @@ def extract_serial_numbers(serials, expected_quantity):
return numbers
def validateFilterString(value):
def validateFilterString(value, model=None):
"""
Validate that a provided filter string looks like a list of comma-separated key=value pairs
@ -464,6 +464,15 @@ def validateFilterString(value):
results[k] = v
# If a model is provided, verify that the provided filters can be used against it
if model is not None:
try:
model.objects.filter(**results)
except FieldError as e:
raise ValidationError(
str(e),
)
return results

View File

@ -154,7 +154,7 @@ $.fn.inventreeTable = function(options) {
// Which columns are currently visible?
var visible = table.bootstrapTable('getVisibleColumns');
if (visible) {
if (visible && Array.isArray(visible)) {
visible.forEach(function(column) {
// Visible field should *not* be visible! (hide it!)

View File

@ -28,6 +28,7 @@ from company.api import company_api_urls
from stock.api import stock_api_urls
from build.api import build_api_urls
from order.api import order_api_urls
from label.api import label_api_urls
from django.conf import settings
from django.conf.urls.static import static
@ -58,6 +59,7 @@ apipatterns = [
url(r'^stock/', include(stock_api_urls)),
url(r'^build/', include(build_api_urls)),
url(r'^order/', include(order_api_urls)),
url(r'^label/', include(label_api_urls)),
# User URLs
url(r'^user/', include(user_urls)),
@ -90,6 +92,7 @@ settings_urls = [
# Some javascript files are served 'dynamically', allowing them to pass through the Django translation layer
dynamic_javascript_urls = [
url(r'^modals.js', DynamicJsView.as_view(template_name='js/modals.js'), name='modals.js'),
url(r'^barcode.js', DynamicJsView.as_view(template_name='js/barcode.js'), name='barcode.js'),
url(r'^bom.js', DynamicJsView.as_view(template_name='js/bom.js'), name='bom.js'),
url(r'^build.js', DynamicJsView.as_view(template_name='js/build.js'), name='build.js'),
@ -97,6 +100,7 @@ dynamic_javascript_urls = [
url(r'^company.js', DynamicJsView.as_view(template_name='js/company.js'), name='company.js'),
url(r'^order.js', DynamicJsView.as_view(template_name='js/order.js'), name='order.js'),
url(r'^part.js', DynamicJsView.as_view(template_name='js/part.js'), name='part.js'),
url(r'^label.js', DynamicJsView.as_view(template_name='js/label.js'), name='label.js'),
url(r'^stock.js', DynamicJsView.as_view(template_name='js/stock.js'), name='stock.js'),
url(r'^table_filters.js', DynamicJsView.as_view(template_name='js/table_filters.js'), name='table_filters.js'),
]

View File

@ -3,12 +3,13 @@ from __future__ import unicode_literals
from django.contrib import admin
from .models import StockItemLabel
from .models import StockItemLabel, StockLocationLabel
class StockItemLabelAdmin(admin.ModelAdmin):
class LabelAdmin(admin.ModelAdmin):
list_display = ('name', 'description', 'label', 'filters', 'enabled')
admin.site.register(StockItemLabel, StockItemLabelAdmin)
admin.site.register(StockItemLabel, LabelAdmin)
admin.site.register(StockLocationLabel, LabelAdmin)

375
InvenTree/label/api.py Normal file
View File

@ -0,0 +1,375 @@
# -*- 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_filters.rest_framework import DjangoFilterBackend
from rest_framework import generics, filters
from rest_framework.response import Response
import InvenTree.helpers
from stock.models import StockItem, StockLocation
from .models import StockItemLabel, StockLocationLabel
from .serializers import StockItemLabelSerializer, StockLocationLabelSerializer
class LabelListView(generics.ListAPIView):
"""
Generic API class for label templates
"""
filter_backends = [
DjangoFilterBackend,
filters.SearchFilter
]
filter_fields = [
'enabled',
]
search_fields = [
'name',
'description',
]
class StockItemLabelMixin:
"""
Mixin for extracting stock items from query params
"""
def get_items(self):
"""
Return a list of requested stock items
"""
items = []
params = self.request.query_params
if 'items[]' in params:
items = params.getlist('items[]', [])
elif 'item' in params:
items = [params.get('item', None)]
if type(items) not in [list, tuple]:
items = [items]
valid_ids = []
for item in items:
try:
valid_ids.append(int(item))
except (ValueError):
pass
# List of StockItems which match provided values
valid_items = StockItem.objects.filter(pk__in=valid_ids)
return valid_items
class StockItemLabelList(LabelListView, StockItemLabelMixin):
"""
API endpoint for viewing list of StockItemLabel objects.
Filterable by:
- enabled: Filter by enabled / disabled status
- item: Filter by single stock item
- items: Filter by list of stock items
"""
queryset = StockItemLabel.objects.all()
serializer_class = StockItemLabelSerializer
def filter_queryset(self, queryset):
"""
Filter the StockItem label queryset.
"""
queryset = super().filter_queryset(queryset)
# List of StockItem objects to match against
items = self.get_items()
# We wish to filter by stock items
if len(items) > 0:
"""
At this point, we are basically forced to be inefficient,
as we need to compare the 'filters' string of each label,
and see if it matches against each of the requested items.
TODO: In the future, if this becomes excessively slow, it
will need to be readdressed.
"""
# Keep track of which labels match every specified stockitem
valid_label_ids = set()
for label in queryset.all():
matches = True
# Filter string defined for the StockItemLabel object
filters = InvenTree.helpers.validateFilterString(label.filters)
for item in items:
item_query = StockItem.objects.filter(pk=item.pk)
if not item_query.filter(**filters).exists():
matches = False
break
# Matched all items
if matches:
valid_label_ids.add(label.pk)
else:
continue
# Reduce queryset to only valid matches
queryset = queryset.filter(pk__in=[pk for pk in valid_label_ids])
return queryset
class StockItemLabelDetail(generics.RetrieveUpdateDestroyAPIView):
"""
API endpoint for a single StockItemLabel object
"""
queryset = StockItemLabel.objects.all()
serializer_class = StockItemLabelSerializer
class StockItemLabelPrint(generics.RetrieveAPIView, StockItemLabelMixin):
"""
API endpoint for printing a StockItemLabel object
"""
queryset = StockItemLabel.objects.all()
serializer_class = StockItemLabelSerializer
def get(self, request, *args, **kwargs):
"""
Check if valid stock item(s) have been provided.
"""
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'
)
class StockLocationLabelMixin:
"""
Mixin for extracting stock locations from query params
"""
def get_locations(self):
"""
Return a list of requested stock locations
"""
locations = []
params = self.request.query_params
if 'locations[]' in params:
locations = params.getlist('locations[]', [])
elif 'location' in params:
locations = [params.get('location', None)]
if type(locations) not in [list, tuple]:
locations = [locations]
valid_ids = []
for loc in locations:
try:
valid_ids.append(int(loc))
except (ValueError):
pass
# List of StockLocation objects which match provided values
valid_locations = StockLocation.objects.filter(pk__in=valid_ids)
return valid_locations
class StockLocationLabelList(LabelListView, StockLocationLabelMixin):
"""
API endpoint for viewiing list of StockLocationLabel objects.
Filterable by:
- enabled: Filter by enabled / disabled status
- location: Filter by a single stock location
- locations: Filter by list of stock locations
"""
queryset = StockLocationLabel.objects.all()
serializer_class = StockLocationLabelSerializer
def filter_queryset(self, queryset):
"""
Filter the StockLocationLabel queryset
"""
queryset = super().filter_queryset(queryset)
# List of StockLocation objects to match against
locations = self.get_locations()
# We wish to filter by stock location(s)
if len(locations) > 0:
"""
At this point, we are basically forced to be inefficient,
as we need to compare the 'filters' string of each label,
and see if it matches against each of the requested items.
TODO: In the future, if this becomes excessively slow, it
will need to be readdressed.
"""
valid_label_ids = set()
for label in queryset.all():
matches = True
# Filter string defined for the StockLocationLabel object
filters = InvenTree.helpers.validateFilterString(label.filters)
for loc in locations:
loc_query = StockLocation.objects.filter(pk=loc.pk)
if not loc_query.filter(**filters).exists():
matches = False
break
# Matched all items
if matches:
valid_label_ids.add(label.pk)
else:
continue
# Reduce queryset to only valid matches
queryset = queryset.filter(pk__in=[pk for pk in valid_label_ids])
return queryset
class StockLocationLabelDetail(generics.RetrieveUpdateDestroyAPIView):
"""
API endpoint for a single StockLocationLabel object
"""
queryset = StockLocationLabel.objects.all()
serializer_class = StockLocationLabelSerializer
class StockLocationLabelPrint(generics.RetrieveAPIView, StockLocationLabelMixin):
"""
API endpoint for printing a StockLocationLabel object
"""
queryset = StockLocationLabel.objects.all()
seiralizers_class = StockLocationLabelSerializer
def get(self, request, *args, **kwargs):
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'
)
label_api_urls = [
# Stock item labels
url(r'stock/', include([
# Detail views
url(r'^(?P<pk>\d+)/', include([
url(r'print/?', StockItemLabelPrint.as_view(), name='api-stockitem-label-print'),
url(r'^.*$', StockItemLabelDetail.as_view(), name='api-stockitem-label-detail'),
])),
# List view
url(r'^.*$', StockItemLabelList.as_view(), name='api-stockitem-label-list'),
])),
# Stock location labels
url(r'location/', include([
# Detail views
url(r'^(?P<pk>\d+)/', include([
url(r'print/?', StockLocationLabelPrint.as_view(), name='api-stocklocation-label-print'),
url(r'^.*$', StockLocationLabelDetail.as_view(), name='api-stocklocation-label-detail'),
])),
# List view
url(r'^.*$', StockLocationLabelList.as_view(), name='api-stocklocation-label-list'),
])),
]

View File

@ -1,5 +1,168 @@
import os
import shutil
import logging
from django.apps import AppConfig
from django.conf import settings
logger = logging.getLogger(__name__)
class LabelConfig(AppConfig):
name = 'label'
def ready(self):
"""
This function is called whenever the label app is loaded
"""
self.create_stock_item_labels()
self.create_stock_location_labels()
def create_stock_item_labels(self):
"""
Create database entries for the default StockItemLabel templates,
if they do not already exist
"""
try:
from .models import StockItemLabel
except:
# Database might not by ready yet
return
src_dir = os.path.join(
os.path.dirname(os.path.realpath(__file__)),
'templates',
'stockitem',
)
dst_dir = os.path.join(
settings.MEDIA_ROOT,
'label',
'inventree',
'stockitem',
)
if not os.path.exists(dst_dir):
logger.info(f"Creating missing directory: '{dst_dir}'")
os.makedirs(dst_dir, exist_ok=True)
labels = [
{
'file': 'qr.html',
'name': 'QR Code',
'description': 'Simple QR code label',
},
]
for label in labels:
filename = os.path.join(
'label',
'inventree',
'stockitem',
label['file'],
)
# Check if the file exists in the media directory
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):
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
logger.info(f"Creating entry for StockItemLabel '{label['name']}'")
StockItemLabel.objects.create(
name=label['name'],
description=label['description'],
label=filename,
filters='',
enabled=True
)
except:
pass
def create_stock_location_labels(self):
"""
Create database entries for the default StockItemLocation templates,
if they do not already exist
"""
try:
from .models import StockLocationLabel
except:
# Database might not yet be ready
return
src_dir = os.path.join(
os.path.dirname(os.path.realpath(__file__)),
'templates',
'stocklocation',
)
dst_dir = os.path.join(
settings.MEDIA_ROOT,
'label',
'inventree',
'stocklocation',
)
if not os.path.exists(dst_dir):
logger.info(f"Creating missing directory: '{dst_dir}'")
os.makedirs(dst_dir, exist_ok=True)
labels = [
{
'file': 'qr.html',
'name': 'QR Code',
'description': 'Simple QR code label',
},
{
'file': 'qr_and_text.html',
'name': 'QR and text',
'description': 'Label with QR code and name of location',
}
]
for label in labels:
filename = os.path.join(
'label',
'inventree',
'stocklocation',
label['file'],
)
# Check if the file exists in the media directory
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):
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
logger.info(f"Creating entry for StockLocationLabel '{label['name']}'")
StockLocationLabel.objects.create(
name=label['name'],
description=label['description'],
label=filename,
filters='',
enabled=True
)
except:
pass

View File

@ -0,0 +1,30 @@
# Generated by Django 3.0.7 on 2021-01-08 12:06
import InvenTree.helpers
import django.core.validators
from django.db import migrations, models
import label.models
class Migration(migrations.Migration):
dependencies = [
('label', '0002_stockitemlabel_enabled'),
]
operations = [
migrations.CreateModel(
name='StockLocationLabel',
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])),
('enabled', models.BooleanField(default=True, help_text='Label template is enabled', verbose_name='Enabled')),
],
options={
'abstract': False,
},
),
]

View File

@ -0,0 +1,56 @@
# Generated by Django 3.0.7 on 2021-01-11 12:02
import InvenTree.helpers
import django.core.validators
from django.db import migrations, models
import label.models
class Migration(migrations.Migration):
dependencies = [
('label', '0003_stocklocationlabel'),
]
operations = [
migrations.AlterField(
model_name='stockitemlabel',
name='description',
field=models.CharField(blank=True, help_text='Label description', max_length=250, null=True, verbose_name='Description'),
),
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=[InvenTree.helpers.validateFilterString], verbose_name='Filters'),
),
migrations.AlterField(
model_name='stockitemlabel',
name='label',
field=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'),
),
migrations.AlterField(
model_name='stockitemlabel',
name='name',
field=models.CharField(help_text='Label name', max_length=100, verbose_name='Name'),
),
migrations.AlterField(
model_name='stocklocationlabel',
name='description',
field=models.CharField(blank=True, help_text='Label description', max_length=250, null=True, verbose_name='Description'),
),
migrations.AlterField(
model_name='stocklocationlabel',
name='filters',
field=models.CharField(blank=True, help_text='Query filters (comma-separated list of key=value pairs', max_length=250, validators=[InvenTree.helpers.validateFilterString], verbose_name='Filters'),
),
migrations.AlterField(
model_name='stocklocationlabel',
name='label',
field=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'),
),
migrations.AlterField(
model_name='stocklocationlabel',
name='name',
field=models.CharField(help_text='Label name', max_length=100, verbose_name='Name'),
),
]

View File

@ -0,0 +1,24 @@
# Generated by Django 3.0.7 on 2021-01-13 12:02
from django.db import migrations, models
import label.models
class Migration(migrations.Migration):
dependencies = [
('label', '0004_auto_20210111_2302'),
]
operations = [
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'),
),
migrations.AlterField(
model_name='stocklocationlabel',
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_location_filters], verbose_name='Filters'),
),
]

View File

@ -17,7 +17,7 @@ from django.utils.translation import gettext_lazy as _
from InvenTree.helpers import validateFilterString, normalize
from stock.models import StockItem
import stock.models
def rename_label(instance, filename):
@ -28,6 +28,20 @@ def rename_label(instance, filename):
return os.path.join('label', 'template', instance.SUBDIR, filename)
def validate_stock_item_filters(filters):
filters = validateFilterString(filters, model=stock.models.StockItem)
return filters
def validate_stock_location_filters(filters):
filters = validateFilterString(filters, model=stock.models.StockLocation)
return filters
class LabelTemplate(models.Model):
"""
Base class for generic, filterable labels.
@ -50,30 +64,31 @@ class LabelTemplate(models.Model):
)
name = models.CharField(
unique=True,
blank=False, max_length=100,
verbose_name=_('Name'),
help_text=_('Label name'),
)
description = models.CharField(max_length=250, help_text=_('Label description'), blank=True, null=True)
description = models.CharField(
max_length=250,
blank=True, null=True,
verbose_name=_('Description'),
help_text=_('Label description'),
)
label = models.FileField(
upload_to=rename_label,
unique=True,
blank=False, null=False,
verbose_name=_('Label'),
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]
)
enabled = models.BooleanField(
default=True,
verbose_name=_('Enabled'),
help_text=_('Label template is enabled'),
verbose_name=_('Enabled')
)
def get_record_data(self, items):
@ -117,6 +132,14 @@ class StockItemLabel(LabelTemplate):
SUBDIR = "stockitem"
filters = models.CharField(
blank=True, max_length=250,
help_text=_('Query filters (comma-separated list of key=value pairs'),
verbose_name=_('Filters'),
validators=[
validate_stock_item_filters]
)
def matches_stock_item(self, item):
"""
Test if this label template matches a given StockItem object
@ -124,7 +147,7 @@ class StockItemLabel(LabelTemplate):
filters = validateFilterString(self.filters)
items = StockItem.objects.filter(**filters)
items = stock.models.StockItem.objects.filter(**filters)
items = items.filter(pk=item.pk)
@ -153,3 +176,47 @@ class StockItemLabel(LabelTemplate):
})
return records
class StockLocationLabel(LabelTemplate):
"""
Template for printing StockLocation labels
"""
SUBDIR = "stocklocation"
filters = models.CharField(
blank=True, max_length=250,
help_text=_('Query filters (comma-separated list of key=value pairs'),
verbose_name=_('Filters'),
validators=[
validate_stock_location_filters]
)
def matches_stock_location(self, location):
"""
Test if this label template matches a given StockLocation object
"""
filters = validateFilterString(self.filters)
locs = stock.models.StockLocation.objects.filter(**filters)
locs = locs.filter(pk=location.pk)
return locs.exists()
def get_record_data(self, locations):
"""
Generate context data for each provided StockLocation
"""
records = []
for loc in locations:
records.append({
'location': loc,
})
return records

View File

@ -0,0 +1,45 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from InvenTree.serializers import InvenTreeModelSerializer
from InvenTree.serializers import InvenTreeAttachmentSerializerField
from .models import StockItemLabel, StockLocationLabel
class StockItemLabelSerializer(InvenTreeModelSerializer):
"""
Serializes a StockItemLabel object.
"""
label = InvenTreeAttachmentSerializerField(required=True)
class Meta:
model = StockItemLabel
fields = [
'pk',
'name',
'description',
'label',
'filters',
'enabled',
]
class StockLocationLabelSerializer(InvenTreeModelSerializer):
"""
Serializes a StockLocationLabel object
"""
label = InvenTreeAttachmentSerializerField(required=True)
class Meta:
model = StockLocationLabel
fields = [
'pk',
'name',
'description',
'label',
'filters',
'enabled',
]

View File

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

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

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

@ -1 +1,76 @@
# Create your tests here.
# Tests for Part Parameters
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
import os
from django.test import TestCase
from django.conf import settings
from django.core.exceptions import ValidationError
from InvenTree.helpers import validateFilterString
from .models import StockItemLabel, StockLocationLabel
from stock.models import StockItem
class LabelTest(TestCase):
# TODO - Implement this test properly. Looks like apps.py is not run first
def _test_default_labels(self):
"""
Test that the default label templates are copied across
"""
labels = StockItemLabel.objects.all()
self.assertTrue(labels.count() > 0)
labels = StockLocationLabel.objects.all()
self.assertTrue(labels.count() > 0)
# TODO - Implement this test properly. Looks like apps.py is not run first
def _test_default_files(self):
"""
Test that label files exist in the MEDIA directory
"""
item_dir = os.path.join(
settings.MEDIA_ROOT,
'label',
'inventree',
'stockitem',
)
files = os.listdir(item_dir)
self.assertTrue(len(files) > 0)
loc_dir = os.path.join(
settings.MEDIA_ROOT,
'label',
'inventree',
'stocklocation',
)
files = os.listdir(loc_dir)
self.assertTrue(len(files) > 0)
def test_filters(self):
"""
Test the label filters
"""
filter_string = "part__pk=10"
filters = validateFilterString(filter_string, model=StockItem)
self.assertEqual(type(filters), dict)
bad_filter_string = "part_pk=10"
with self.assertRaises(ValidationError):
validateFilterString(bad_filter_string, model=StockItem)

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

@ -655,7 +655,9 @@ class PartCreate(AjaxCreateView):
matches = match_part_names(name)
if len(matches) > 0:
context['matches'] = matches
# Limit to the top 5 matches (to prevent clutter)
context['matches'] = matches[:5]
# Enforce display of the checkbox
form.fields['confirm_creation'].widget = CheckboxInput()

View File

@ -644,7 +644,7 @@ class StockList(generics.ListCreateAPIView):
queryset = queryset.filter(Q(sales_order_allocations__isnull=True) & Q(allocations__isnull=True))
# Do we wish to filter by "active parts"
active = self.request.query_params.get('active', None)
active = params.get('active', None)
if active is not None:
active = str2bool(active)
@ -683,7 +683,7 @@ class StockList(generics.ListCreateAPIView):
raise ValidationError({"part": "Invalid Part ID specified"})
# Does the client wish to filter by the 'ancestor'?
anc_id = self.request.query_params.get('ancestor', None)
anc_id = params.get('ancestor', None)
if anc_id:
try:
@ -696,9 +696,9 @@ class StockList(generics.ListCreateAPIView):
raise ValidationError({"ancestor": "Invalid ancestor ID specified"})
# Does the client wish to filter by stock location?
loc_id = self.request.query_params.get('location', None)
loc_id = params.get('location', None)
cascade = str2bool(self.request.query_params.get('cascade', True))
cascade = str2bool(params.get('cascade', True))
if loc_id is not None:
@ -718,7 +718,7 @@ class StockList(generics.ListCreateAPIView):
pass
# Does the client wish to filter by part category?
cat_id = self.request.query_params.get('category', None)
cat_id = params.get('category', None)
if cat_id:
try:
@ -729,35 +729,68 @@ class StockList(generics.ListCreateAPIView):
raise ValidationError({"category": "Invalid category id specified"})
# Filter by StockItem status
status = self.request.query_params.get('status', None)
status = params.get('status', None)
if status:
queryset = queryset.filter(status=status)
# Filter by supplier_part ID
supplier_part_id = self.request.query_params.get('supplier_part', None)
supplier_part_id = params.get('supplier_part', None)
if supplier_part_id:
queryset = queryset.filter(supplier_part=supplier_part_id)
# Filter by company (either manufacturer or supplier)
company = self.request.query_params.get('company', None)
company = params.get('company', None)
if company is not None:
queryset = queryset.filter(Q(supplier_part__supplier=company) | Q(supplier_part__manufacturer=company))
# Filter by supplier
supplier = self.request.query_params.get('supplier', None)
supplier = params.get('supplier', None)
if supplier is not None:
queryset = queryset.filter(supplier_part__supplier=supplier)
# Filter by manufacturer
manufacturer = self.request.query_params.get('manufacturer', None)
manufacturer = params.get('manufacturer', None)
if manufacturer is not None:
queryset = queryset.filter(supplier_part__manufacturer=manufacturer)
"""
Filter by the 'last updated' date of the stock item(s):
- updated_before=? : Filter stock items which were last updated *before* the provided date
- updated_after=? : Filter stock items which were last updated *after* the provided date
"""
date_fmt = '%Y-%m-%d' # ISO format date string
updated_before = params.get('updated_before', None)
updated_after = params.get('updated_after', None)
if updated_before:
try:
updated_before = datetime.strptime(str(updated_before), date_fmt).date()
queryset = queryset.filter(updated__lte=updated_before)
print("Before:", updated_before.isoformat())
except (ValueError, TypeError):
# Account for improperly formatted date string
print("After before:", str(updated_before))
pass
if updated_after:
try:
updated_after = datetime.strptime(str(updated_after), date_fmt).date()
queryset = queryset.filter(updated__gte=updated_after)
print("After:", updated_after.isoformat())
except (ValueError, TypeError):
# Account for improperly formatted date string
print("After error:", str(updated_after))
pass
# Also ensure that we pre-fecth all the related items
queryset = queryset.prefetch_related(
'part',

View File

@ -32,6 +32,7 @@ from InvenTree import helpers
import common.models
import report.models
import label.models
from InvenTree.status_codes import StockStatus
from InvenTree.models import InvenTreeTree, InvenTreeAttachment
@ -69,6 +70,13 @@ class StockLocation(InvenTreeTree):
**kwargs
)
@property
def barcode(self):
"""
Brief payload data (e.g. for labels)
"""
return self.format_barcode(brief=True)
def get_stock_items(self, cascade=True):
""" Return a queryset for all stock items under this category.
@ -336,6 +344,13 @@ class StockItem(MPTTModel):
**kwargs
)
@property
def barcode(self):
"""
Brief payload data (e.g. for labels)
"""
return self.format_barcode(brief=True)
uid = models.CharField(blank=True, max_length=128, help_text=("Unique identifier field"))
parent = TreeForeignKey(
@ -1343,14 +1358,31 @@ class StockItem(MPTTModel):
return len(self.available_test_reports()) > 0
def available_labels(self):
"""
Return a list of Label objects which match this StockItem
"""
labels = []
item_query = StockItem.objects.filter(pk=self.pk)
for lbl in label.models.StockItemLabel.objects.filter(enabled=True):
filters = helpers.validateFilterString(lbl.filters)
if item_query.filter(**filters).exists():
labels.append(lbl)
return labels
@property
def has_labels(self):
"""
Return True if there are any label templates available for this stock item
"""
# TODO - Implement this
return True
return len(self.available_labels()) > 0
@receiver(pre_delete, sender=StockItem, dispatch_uid='stock_item_pre_delete_log')

View File

@ -213,6 +213,7 @@ class StockItemSerializer(InvenTreeModelSerializer):
'supplier_part_detail',
'tracking_items',
'uid',
'updated',
]
""" These fields are read-only in this context.

View File

@ -423,12 +423,7 @@ $("#stock-test-report").click(function() {
});
$("#print-label").click(function() {
launchModalForm(
"{% url 'stock-item-label-select' item.id %}",
{
follow: true,
}
)
printStockItemLabels([{{ item.pk }}]);
});
$("#stock-duplicate").click(function() {

View File

@ -43,7 +43,7 @@
<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>
<li><a href='#' id='barcode-check-in'><span class='fas fa-arrow-right'></span> {% trans "Check-in Items" %}</a></li>
</ul>
</div>
@ -222,6 +222,15 @@
}
});
});
$('#print-label').click(function() {
var locs = [{{ location.pk }}];
printStockLocationLabels(locs);
});
{% endif %}
$('#show-qr-code').click(function() {

View File

@ -30,7 +30,6 @@ 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'),
@ -64,7 +63,6 @@ stock_urls = [
url(r'^item/uninstall/', views.StockItemUninstall.as_view(), name='stock-item-uninstall'),
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

@ -33,7 +33,6 @@ from datetime import datetime, timedelta
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
import common.settings
@ -406,92 +405,6 @@ class StockItemReturnToStock(AjaxUpdateView):
}
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')
role_required = 'stock.view'
def get_form(self):
item = StockItem.objects.get(pk=self.kwargs['pk'])
labels = []
# Construct a list of StockItemLabel objects which are enabled, and the filters match the selected StockItem
for label in StockItemLabel.objects.filter(enabled=True):
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
"""
role_required = 'stock.view'
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

@ -112,7 +112,6 @@ InvenTree
<script type='text/javascript' src="{% static 'script/inventree/api.js' %}"></script>
<script type='text/javascript' src="{% static 'script/inventree/filters.js' %}"></script>
<script type='text/javascript' src="{% static 'script/inventree/tables.js' %}"></script>
<script type='text/javascript' src="{% static 'script/inventree/modals.js' %}"></script>
<script type='text/javascript' src="{% static 'script/inventree/notification.js' %}"></script>
<script type='text/javascript' src="{% static 'script/inventree/sidenav.js' %}"></script>
@ -120,6 +119,8 @@ InvenTree
<script type='text/javascript' src="{% url 'bom.js' %}"></script>
<script type='text/javascript' src="{% url 'company.js' %}"></script>
<script type='text/javascript' src="{% url 'part.js' %}"></script>
<script type='text/javascript' src="{% url 'modals.js' %}"></script>
<script type='text/javascript' src="{% url 'label.js' %}"></script>
<script type='text/javascript' src="{% url 'stock.js' %}"></script>
<script type='text/javascript' src="{% url 'build.js' %}"></script>
<script type='text/javascript' src="{% url 'order.js' %}"></script>

View File

@ -0,0 +1,173 @@
{% load i18n %}
function printStockItemLabels(items, options={}) {
/**
* Print stock item labels for the given stock items
*/
if (items.length == 0) {
showAlertDialog(
'{% trans "Select Stock Items" %}',
'{% trans "Stock item(s) must be selected before printing labels" %}'
);
return;
}
// Request available labels from the server
inventreeGet(
'{% url "api-stockitem-label-list" %}',
{
enabled: true,
items: items,
},
{
success: function(response) {
if (response.length == 0) {
showAlertDialog(
'{% trans "No Labels Found" %}',
'{% trans "No labels found which match selected stock item(s)" %}',
);
return;
}
// Select label to print
selectLabel(
response,
items,
{
success: function(pk) {
var href = `/api/label/stock/${pk}/print/?`;
items.forEach(function(item) {
href += `items[]=${item}&`;
});
window.location.href = href;
}
}
);
}
}
);
}
function printStockLocationLabels(locations, options={}) {
if (locations.length == 0) {
showAlertDialog(
'{% trans "Select Stock Locations" %}',
'{% trans "Stock location(s) must be selected before printing labels" %}'
);
return;
}
// Request available labels from the server
inventreeGet(
'{% url "api-stocklocation-label-list" %}',
{
enabled: true,
locations: locations,
},
{
success: function(response) {
if (response.length == 0) {
showAlertDialog(
'{% trans "No Labels Found" %}',
'{% trans "No labels found which match selected stock location(s)" %}',
);
return;
}
// Select label to print
selectLabel(
response,
locations,
{
success: function(pk) {
var href = `/api/label/location/${pk}/print/?`;
locations.forEach(function(location) {
href += `locations[]=${location}&`;
});
window.location.href = href;
}
}
);
}
}
)
}
function selectLabel(labels, items, options={}) {
/**
* Present the user with the available labels,
* and allow them to select which label to print.
*
* The intent is that the available labels have been requested
* (via AJAX) from the server.
*/
var modal = options.modal || '#modal-form';
var label_list = makeOptionsList(
labels,
function(item) {
var text = item.name;
if (item.description) {
text += ` - ${item.description}`;
}
return text;
},
function(item) {
return item.pk;
}
);
// Construct form
var html = `
<form method='post' action='' class='js-modal-form' enctype='multipart/form-data'>
<div class='form-group'>
<label class='control-label requiredField' for='id_label'>
{% trans "Select Label" %}
</label>
<div class='controls'>
<select id='id_label' class='select form-control name='label'>
${label_list}
</select>
</div>
</div>
</form>`;
openModal({
modal: modal,
});
modalEnable(modal, true);
modalSetTitle(modal, '{% trans "Select Label Template" %}');
modalSetContent(modal, html);
attachSelect(modal);
modalSubmit(modal, function() {
var label = $(modal).find('#id_label');
var pk = label.val();
closeModal(modal);
if (options.success) {
options.success(pk);
}
});
}

View File

@ -1,3 +1,5 @@
{% load i18n %}
function makeOption(text, value, title) {
/* Format an option for a select element
*/
@ -164,6 +166,15 @@ function setFieldValue(fieldName, value, options={}) {
field.val(value);
}
function getFieldValue(fieldName, options={}) {
var modal = options.modal || '#modal-form';
var field = getFieldByName(modal, fieldName);
return field.val();
}
function partialMatcher(params, data) {
/* Replacement function for the 'matcher' parameter for a select2 dropdown.
@ -392,7 +403,7 @@ function renderErrorMessage(xhr) {
<div class='panel panel-default'>
<div class='panel panel-heading'>
<div class='panel-title'>
<a data-toggle='collapse' href="#collapse-error-info">Show Error Information</a>
<a data-toggle='collapse' href="#collapse-error-info">{% trans "Show Error Information" %}</a>
</div>
</div>
<div class='panel-collapse collapse' id='collapse-error-info'>
@ -459,8 +470,8 @@ function showQuestionDialog(title, content, options={}) {
modalSetTitle(modal, title);
modalSetContent(modal, content);
var accept_text = options.accept_text || 'Accept';
var cancel_text = options.cancel_text || 'Cancel';
var accept_text = options.accept_text || '{% trans "Accept" %}';
var cancel_text = options.cancel_text || '{% trans "Cancel" %}';
$(modal).find('#modal-form-cancel').html(cancel_text);
$(modal).find('#modal-form-accept').html(accept_text);
@ -524,7 +535,7 @@ function openModal(options) {
if (options.title) {
modalSetTitle(modal, options.title);
} else {
modalSetTitle(modal, 'Loading Data...');
modalSetTitle(modal, '{% trans "Loading Data" %}...');
}
// Unless the content is explicitly set, display loading message
@ -535,8 +546,8 @@ function openModal(options) {
}
// Default labels for 'Submit' and 'Close' buttons in the form
var submit_text = options.submit_text || 'Submit';
var close_text = options.close_text || 'Close';
var submit_text = options.submit_text || '{% trans "Submit" %}';
var close_text = options.close_text || '{% trans "Close" %}';
modalSetButtonText(modal, submit_text, close_text);
@ -745,7 +756,7 @@ function handleModalForm(url, options) {
}
else {
$(modal).modal('hide');
showAlertDialog('Invalid response from server', 'Form data missing from server response');
showAlertDialog('{% trans "Invalid response from server" %}', '{% trans "Form data missing from server response" %}');
}
}
}
@ -758,7 +769,7 @@ function handleModalForm(url, options) {
// There was an error submitting form data via POST
$(modal).modal('hide');
showAlertDialog('Error posting form data', renderErrorMessage(xhr));
showAlertDialog('{% trans "Error posting form data" %}', renderErrorMessage(xhr));
},
complete: function(xhr) {
//TODO
@ -793,8 +804,8 @@ function launchModalForm(url, options = {}) {
var modal = options.modal || '#modal-form';
// Default labels for 'Submit' and 'Close' buttons in the form
var submit_text = options.submit_text || 'Submit';
var close_text = options.close_text || 'Close';
var submit_text = options.submit_text || '{% trans "Submit" %}';
var close_text = options.close_text || '{% trans "Close" %}';
// Form the ajax request to retrieve the django form data
ajax_data = {
@ -842,7 +853,7 @@ function launchModalForm(url, options = {}) {
} else {
$(modal).modal('hide');
showAlertDialog('Invalid server response', 'JSON response missing form data');
showAlertDialog('{% trans "Invalid server response" %}', '{% trans "JSON response missing form data" %}');
}
},
error: function (xhr, ajaxOptions, thrownError) {
@ -852,36 +863,36 @@ function launchModalForm(url, options = {}) {
if (xhr.status == 0) {
// No response from the server
showAlertDialog(
"No Response",
"No response from the InvenTree server",
'{% trans "No Response" %}',
'{% trans "No response from the InvenTree server" %}',
);
} else if (xhr.status == 400) {
showAlertDialog(
"Error 400: Bad Request",
"Server returned error code 400"
'{% trans "Error 400: Bad Request" %}',
'{% trans "Server returned error code 400" %}',
);
} else if (xhr.status == 401) {
showAlertDialog(
"Error 401: Not Authenticated",
"Authentication credentials not supplied"
'{% trans "Error 401: Not Authenticated" %}',
'{% trans "Authentication credentials not supplied" %}',
);
} else if (xhr.status == 403) {
showAlertDialog(
"Error 403: Permission Denied",
"You do not have the required permissions to access this function"
'{% trans "Error 403: Permission Denied" %}',
'{% trans "You do not have the required permissions to access this function" %}',
);
} else if (xhr.status == 404) {
showAlertDialog(
"Error 404: Resource Not Found",
"The requested resource could not be located on the server"
'{% trans "Error 404: Resource Not Found" %}',
'{% trans "The requested resource could not be located on the server" %}',
);
} else if (xhr.status == 408) {
showAlertDialog(
"Error 408: Timeout",
"Connection timeout while requesting data from server"
'{% trans "Error 408: Timeout" %}',
'{% trans "Connection timeout while requesting data from server" %}',
);
} else {
showAlertDialog('Error requesting form data', renderErrorMessage(xhr));
showAlertDialog('{% trans "Error requesting form data" %}', renderErrorMessage(xhr));
}
console.log("Modal form error: " + xhr.status);

View File

@ -613,6 +613,11 @@ function loadStockTable(table, options) {
sortable: true,
},
{% endif %}
{
field: 'updated',
title: '{% trans "Last Updated" %}',
sortable: true,
},
{
field: 'notes',
title: '{% trans "Notes" %}',
@ -660,6 +665,18 @@ function loadStockTable(table, options) {
}
// Automatically link button callbacks
$('#multi-item-print-label').click(function() {
var selections = $('#stock-table').bootstrapTable('getSelections');
var items = [];
selections.forEach(function(item) {
items.push(item.pk);
});
printStockItemLabels(items);
});
$('#multi-item-stocktake').click(function() {
stockAdjustment('count');
});

View File

@ -15,16 +15,20 @@
{% if read_only %}
{% else %}
<!-- Check permissions and owner -->
{% if owner_control.value == "False" or owner_control.value == "True" and user in owners or user.is_superuser %}
{% if owner_control.value == "True" and user in owners or user.is_superuser or owner_control.value == "False" %}
{% if roles.stock.add %}
<button class="btn btn-success" id='item-create'>
<span class='fas fa-plus-circle'></span> {% trans "New Stock Item" %}
</button>
{% endif %}
{% endif %}
{% if roles.stock.change or roles.stock.delete %}
<div class="btn-group">
<button id='stock-options' class="btn btn-primary dropdown-toggle" type="button" data-toggle="dropdown">{% trans "Options" %}<span class="caret"></span></button>
<ul class="dropdown-menu">
<li><a href='#' id='multi-item-print-label' title='{% trans "Print labels" %}'><span class='fas fa-tags'></span> {% trans "Print labels" %}</a></li>
<!-- Check permissions and owner -->
{% if owner_control.value == "True" and user in owners or user.is_superuser or owner_control.value == "False" %}
{% if roles.stock.change %}
<li><a href="#" id='multi-item-add' title='{% trans "Add to selected stock items" %}'><span class='fas fa-plus-circle'></span> {% trans "Add stock" %}</a></li>
<li><a href="#" id='multi-item-remove' title='{% trans "Remove from selected stock items" %}'><span class='fas fa-minus-circle'></span> {% trans "Remove stock" %}</a></li>
@ -36,11 +40,11 @@
{% if roles.stock.delete %}
<li><a href='#' id='multi-item-delete' title='{% trans "Delete selected items" %}'><span class='fas fa-trash-alt'></span> {% trans "Delete Stock" %}</a></li>
{% endif %}
{% endif %}
</ul>
</div>
{% endif %}
{% endif %}
{% endif %}
</div>
<div class='filter-list' id='filter-list-stock'>
<!-- An empty div in which the filter list will be constructed -->

View File

@ -116,6 +116,7 @@ class RuleSet(models.Model):
'common_inventreesetting',
'company_contact',
'label_stockitemlabel',
'label_stocklocationlabel',
'report_reportasset',
'report_testreport',
'part_partstar',