mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +00:00
Merged master and updated stock_table.html
This commit is contained in:
commit
72c7ceb553
@ -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
|
||||
|
||||
|
||||
|
@ -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!)
|
||||
|
@ -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'),
|
||||
]
|
||||
|
@ -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
375
InvenTree/label/api.py
Normal 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'),
|
||||
])),
|
||||
]
|
@ -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
|
||||
|
30
InvenTree/label/migrations/0003_stocklocationlabel.py
Normal file
30
InvenTree/label/migrations/0003_stocklocationlabel.py
Normal 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,
|
||||
},
|
||||
),
|
||||
]
|
56
InvenTree/label/migrations/0004_auto_20210111_2302.py
Normal file
56
InvenTree/label/migrations/0004_auto_20210111_2302.py
Normal 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'),
|
||||
),
|
||||
]
|
24
InvenTree/label/migrations/0005_auto_20210113_2302.py
Normal file
24
InvenTree/label/migrations/0005_auto_20210113_2302.py
Normal 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'),
|
||||
),
|
||||
]
|
@ -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
|
||||
|
45
InvenTree/label/serializers.py
Normal file
45
InvenTree/label/serializers.py
Normal 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',
|
||||
]
|
16
InvenTree/label/templates/stockitem/qr.html
Normal file
16
InvenTree/label/templates/stockitem/qr.html
Normal 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) }}"/>
|
16
InvenTree/label/templates/stocklocation/qr.html
Normal file
16
InvenTree/label/templates/stocklocation/qr.html
Normal 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) }}"/>
|
43
InvenTree/label/templates/stocklocation/qr_and_text.html
Normal file
43
InvenTree/label/templates/stocklocation/qr_and_text.html
Normal 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>
|
||||
|
@ -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)
|
||||
|
Binary file not shown.
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
@ -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()
|
||||
|
@ -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',
|
||||
|
@ -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')
|
||||
|
@ -213,6 +213,7 @@ class StockItemSerializer(InvenTreeModelSerializer):
|
||||
'supplier_part_detail',
|
||||
'tracking_items',
|
||||
'uid',
|
||||
'updated',
|
||||
]
|
||||
|
||||
""" These fields are read-only in this context.
|
||||
|
@ -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() {
|
||||
|
@ -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() {
|
||||
|
@ -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([
|
||||
|
@ -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
|
||||
|
@ -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>
|
||||
|
173
InvenTree/templates/js/label.js
Normal file
173
InvenTree/templates/js/label.js
Normal 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);
|
||||
}
|
||||
});
|
||||
}
|
@ -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);
|
@ -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');
|
||||
});
|
||||
|
@ -14,32 +14,36 @@
|
||||
</button>
|
||||
{% 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 roles.stock.add %}
|
||||
<button class="btn btn-success" id='item-create'>
|
||||
<span class='fas fa-plus-circle'></span> {% trans "New Stock Item" %}
|
||||
</button>
|
||||
{% 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">
|
||||
{% 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>
|
||||
<li><a href="#" id='multi-item-stocktake' title='{% trans "Stocktake selected stock items" %}'><span class='fas fa-check-circle'></span> {% trans "Count stock" %}</a></li>
|
||||
<li><a href='#' id='multi-item-move' title='{% trans "Move selected stock items" %}'><span class='fas fa-exchange-alt'></span> {% trans "Move stock" %}</a></li>
|
||||
<li><a href='#' id='multi-item-order' title='{% trans "Order selected items" %}'><span class='fas fa-shopping-cart'></span> {% trans "Order stock" %}</a></li>
|
||||
<li><a href='#' id='multi-item-set-status' title='{% trans "Change status" %}'><span class='fas fa-exclamation-circle'></span> {% trans "Change stock status" %}</a></li>
|
||||
{% endif %}
|
||||
{% 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 %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
<!-- 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.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>
|
||||
<li><a href="#" id='multi-item-stocktake' title='{% trans "Stocktake selected stock items" %}'><span class='fas fa-check-circle'></span> {% trans "Count stock" %}</a></li>
|
||||
<li><a href='#' id='multi-item-move' title='{% trans "Move selected stock items" %}'><span class='fas fa-exchange-alt'></span> {% trans "Move stock" %}</a></li>
|
||||
<li><a href='#' id='multi-item-order' title='{% trans "Order selected items" %}'><span class='fas fa-shopping-cart'></span> {% trans "Order stock" %}</a></li>
|
||||
<li><a href='#' id='multi-item-set-status' title='{% trans "Change status" %}'><span class='fas fa-exclamation-circle'></span> {% trans "Change stock status" %}</a></li>
|
||||
{% endif %}
|
||||
{% 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 %}
|
||||
</div>
|
||||
<div class='filter-list' id='filter-list-stock'>
|
||||
|
@ -116,6 +116,7 @@ class RuleSet(models.Model):
|
||||
'common_inventreesetting',
|
||||
'company_contact',
|
||||
'label_stockitemlabel',
|
||||
'label_stocklocationlabel',
|
||||
'report_reportasset',
|
||||
'report_testreport',
|
||||
'part_partstar',
|
||||
|
Loading…
Reference in New Issue
Block a user