mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +00:00
Merge remote-tracking branch 'inventree/master'
This commit is contained in:
commit
05cfb1a3ab
@ -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
|
||||
|
||||
|
||||
|
@ -11,17 +11,20 @@ database setup in this file.
|
||||
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import yaml
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
import yaml
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
|
||||
def _is_true(x):
|
||||
return x in [True, "True", "true", "Y", "y", "1"]
|
||||
|
||||
|
||||
# Build paths inside the project like this: os.path.join(BASE_DIR, ...)
|
||||
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
@ -36,11 +39,14 @@ with open(cfg_filename, 'r') as cfg:
|
||||
|
||||
# Default action is to run the system in Debug mode
|
||||
# SECURITY WARNING: don't run with debug turned on in production!
|
||||
DEBUG = CONFIG.get('debug', True)
|
||||
DEBUG = _is_true(os.getenv("INVENTREE_DEBUG", CONFIG.get("debug", True)))
|
||||
|
||||
# Configure logging settings
|
||||
|
||||
log_level = CONFIG.get('log_level', 'DEBUG').upper()
|
||||
logging.basicConfig(
|
||||
level=log_level,
|
||||
format="%(asctime)s %(levelname)s %(message)s",
|
||||
)
|
||||
|
||||
if log_level not in ['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL']:
|
||||
log_level = 'WARNING'
|
||||
@ -59,20 +65,31 @@ LOGGING = {
|
||||
},
|
||||
}
|
||||
|
||||
logging.basicConfig(
|
||||
level=log_level,
|
||||
format='%(asctime)s %(levelname)s %(message)s',
|
||||
)
|
||||
|
||||
# Get a logger instance for this setup file
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Read the autogenerated key-file
|
||||
key_file_name = os.path.join(BASE_DIR, 'secret_key.txt')
|
||||
logger.info(f'Loading SECRET_KEY from {key_file_name}')
|
||||
key_file = open(key_file_name, 'r')
|
||||
|
||||
SECRET_KEY = key_file.read().strip()
|
||||
if os.getenv("INVENTREE_SECRET_KEY"):
|
||||
# Secret key passed in directly
|
||||
SECRET_KEY = os.getenv("INVENTREE_SECRET_KEY").strip()
|
||||
logger.info("SECRET_KEY loaded by INVENTREE_SECRET_KEY")
|
||||
else:
|
||||
# Secret key passed in by file location
|
||||
key_file = os.getenv("INVENTREE_SECRET_KEY_FILE")
|
||||
if key_file:
|
||||
if os.path.isfile(key_file):
|
||||
logger.info("SECRET_KEY loaded by INVENTREE_SECRET_KEY_FILE")
|
||||
else:
|
||||
logger.error(f"Secret key file {key_file} not found")
|
||||
exit(-1)
|
||||
else:
|
||||
# default secret key location
|
||||
key_file = os.path.join(BASE_DIR, "secret_key.txt")
|
||||
logger.info(f"SECRET_KEY loaded from {key_file}")
|
||||
try:
|
||||
SECRET_KEY = open(key_file, "r").read().strip()
|
||||
except Exception:
|
||||
logger.exception(f"Couldn't load keyfile {key_file}")
|
||||
sys.exit(-1)
|
||||
|
||||
# List of allowed hosts (default = allow all)
|
||||
ALLOWED_HOSTS = CONFIG.get('allowed_hosts', ['*'])
|
||||
@ -112,7 +129,7 @@ MEDIA_ROOT = os.path.abspath(CONFIG.get('media_root', os.path.join(BASE_DIR, 'me
|
||||
|
||||
if DEBUG:
|
||||
logger.info("InvenTree running in DEBUG mode")
|
||||
|
||||
|
||||
logger.info(f"MEDIA_ROOT: '{MEDIA_ROOT}'")
|
||||
logger.info(f"STATIC_ROOT: '{STATIC_ROOT}'")
|
||||
|
||||
@ -315,7 +332,7 @@ else:
|
||||
- However there may be reason to configure the DB via environmental variables
|
||||
- The following code lets the user "mix and match" database configuration
|
||||
"""
|
||||
|
||||
|
||||
logger.info("Configuring database backend:")
|
||||
|
||||
# Extract database configuration from the config.yaml file
|
||||
@ -341,7 +358,7 @@ else:
|
||||
|
||||
# Check that required database configuration options are specified
|
||||
reqiured_keys = ['ENGINE', 'NAME']
|
||||
|
||||
|
||||
for key in reqiured_keys:
|
||||
if key not in db_config:
|
||||
error_msg = f'Missing required database configuration value {key} in config.yaml'
|
||||
|
@ -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'),
|
||||
]
|
||||
|
@ -160,6 +160,13 @@ class InvenTreeSetting(models.Model):
|
||||
'validator': bool,
|
||||
},
|
||||
|
||||
'PART_SHOW_QUANTITY_IN_FORMS': {
|
||||
'name': _('Show Quantity in Forms'),
|
||||
'description': _('Display available part quantity in some forms'),
|
||||
'default': True,
|
||||
'validator': bool,
|
||||
},
|
||||
|
||||
'STOCK_ENABLE_EXPIRY': {
|
||||
'name': _('Stock Expiry'),
|
||||
'description': _('Enable stock expiry functionality'),
|
||||
|
@ -5,20 +5,29 @@
|
||||
fields:
|
||||
name: ACME
|
||||
description: A Cool Military Enterprise
|
||||
|
||||
- model: company.company
|
||||
pk: 2
|
||||
fields:
|
||||
name: Appel Computers
|
||||
description: Think more differenter
|
||||
|
||||
- model: company.company
|
||||
pk: 3
|
||||
fields:
|
||||
name: Zerg Corp
|
||||
description: We eat the competition
|
||||
|
||||
- model: company.company
|
||||
pk: 4
|
||||
fields:
|
||||
name: A customer
|
||||
description: A company that we sell things to!
|
||||
is_customer: True
|
||||
|
||||
|
||||
- model: company.company
|
||||
pk: 5
|
||||
fields:
|
||||
name: Another customer!
|
||||
description: Yet another company
|
||||
is_customer: True
|
||||
|
@ -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)
|
||||
|
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
@ -80,6 +80,17 @@ class POList(generics.ListCreateAPIView):
|
||||
else:
|
||||
queryset = queryset.exclude(status__in=PurchaseOrderStatus.OPEN)
|
||||
|
||||
# Filter by 'overdue' status
|
||||
overdue = params.get('overdue', None)
|
||||
|
||||
if overdue is not None:
|
||||
overdue = str2bool(overdue)
|
||||
|
||||
if overdue:
|
||||
queryset = queryset.filter(PurchaseOrder.OVERDUE_FILTER)
|
||||
else:
|
||||
queryset = queryset.exclude(PurchaseOrder.OVERDUE_FILTER)
|
||||
|
||||
# Special filtering for 'status' field
|
||||
status = params.get('status', None)
|
||||
|
||||
|
@ -7,6 +7,7 @@
|
||||
reference: '0001'
|
||||
description: "Ordering some screws"
|
||||
supplier: 1
|
||||
status: 10 # Pending
|
||||
|
||||
# Ordering some screws from Zerg Corp
|
||||
- model: order.purchaseorder
|
||||
@ -15,6 +16,39 @@
|
||||
reference: '0002'
|
||||
description: "Ordering some more screws"
|
||||
supplier: 3
|
||||
status: 10 # Pending
|
||||
|
||||
- model: order.purchaseorder
|
||||
pk: 3
|
||||
fields:
|
||||
reference: '0003'
|
||||
description: 'Another PO'
|
||||
supplier: 3
|
||||
status: 20 # Placed
|
||||
|
||||
- model: order.purchaseorder
|
||||
pk: 4
|
||||
fields:
|
||||
reference: '0004'
|
||||
description: 'Another PO'
|
||||
supplier: 3
|
||||
status: 20 # Placed
|
||||
|
||||
- model: order.purchaseorder
|
||||
pk: 5
|
||||
fields:
|
||||
reference: '0005'
|
||||
description: 'Another PO'
|
||||
supplier: 3
|
||||
status: 30 # Complete
|
||||
|
||||
- model: order.purchaseorder
|
||||
pk: 6
|
||||
fields:
|
||||
reference: '0006'
|
||||
description: 'Another PO'
|
||||
supplier: 3
|
||||
status: 40 # Cancelled
|
||||
|
||||
# Add some line items against PO 0001
|
||||
|
||||
|
39
InvenTree/order/fixtures/sales_order.yaml
Normal file
39
InvenTree/order/fixtures/sales_order.yaml
Normal file
@ -0,0 +1,39 @@
|
||||
- model: order.salesorder
|
||||
pk: 1
|
||||
fields:
|
||||
reference: 'ABC123'
|
||||
description: "One sales order, please"
|
||||
customer: 4
|
||||
status: 10 # Pending
|
||||
|
||||
- model: order.salesorder
|
||||
pk: 2
|
||||
fields:
|
||||
reference: 'ABC124'
|
||||
description: "One sales order, please"
|
||||
customer: 4
|
||||
status: 10 # Pending
|
||||
|
||||
- model: order.salesorder
|
||||
pk: 3
|
||||
fields:
|
||||
reference: 'ABC125'
|
||||
description: "One sales order, please"
|
||||
customer: 4
|
||||
status: 10 # Pending
|
||||
|
||||
- model: order.salesorder
|
||||
pk: 4
|
||||
fields:
|
||||
reference: 'ABC126'
|
||||
description: "One sales order, please"
|
||||
customer: 5
|
||||
status: 20 # Shipped
|
||||
|
||||
- model: order.salesorder
|
||||
pk: 5
|
||||
fields:
|
||||
reference: 'ABC127'
|
||||
description: "One sales order, please"
|
||||
customer: 5
|
||||
status: 60 # Returned
|
@ -94,6 +94,7 @@ class EditPurchaseOrderForm(HelperForm):
|
||||
self.field_prefix = {
|
||||
'reference': 'PO',
|
||||
'link': 'fa-link',
|
||||
'target_date': 'fa-calendar-alt',
|
||||
}
|
||||
|
||||
self.field_placeholder = {
|
||||
@ -102,6 +103,10 @@ class EditPurchaseOrderForm(HelperForm):
|
||||
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
target_date = DatePickerFormField(
|
||||
help_text=_('Target date for order delivery. Order will be overdue after this date.'),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = PurchaseOrder
|
||||
fields = [
|
||||
@ -109,6 +114,7 @@ class EditPurchaseOrderForm(HelperForm):
|
||||
'supplier',
|
||||
'supplier_reference',
|
||||
'description',
|
||||
'target_date',
|
||||
'link',
|
||||
]
|
||||
|
||||
|
28
InvenTree/order/migrations/0041_auto_20210114_1728.py
Normal file
28
InvenTree/order/migrations/0041_auto_20210114_1728.py
Normal file
@ -0,0 +1,28 @@
|
||||
# Generated by Django 3.0.7 on 2021-01-14 06:28
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('order', '0040_salesorder_target_date'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='purchaseorder',
|
||||
name='target_date',
|
||||
field=models.DateField(blank=True, help_text='Expected date for order delivery. Order will be overdue after this date.', null=True, verbose_name='Target Delivery Date'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='purchaseorder',
|
||||
name='complete_date',
|
||||
field=models.DateField(blank=True, help_text='Date order was completed', null=True, verbose_name='Completion Date'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='purchaseorder',
|
||||
name='issue_date',
|
||||
field=models.DateField(blank=True, help_text='Date order was issued', null=True, verbose_name='Issue Date'),
|
||||
),
|
||||
]
|
@ -119,8 +119,11 @@ class PurchaseOrder(Order):
|
||||
supplier: Reference to the company supplying the goods in the order
|
||||
supplier_reference: Optional field for supplier order reference code
|
||||
received_by: User that received the goods
|
||||
target_date: Expected delivery target date for PurchaseOrder completion (optional)
|
||||
"""
|
||||
|
||||
OVERDUE_FILTER = Q(status__in=PurchaseOrderStatus.OPEN) & ~Q(target_date=None) & Q(target_date__lte=datetime.now().date())
|
||||
|
||||
@staticmethod
|
||||
def filterByDate(queryset, min_date, max_date):
|
||||
"""
|
||||
@ -132,7 +135,7 @@ class PurchaseOrder(Order):
|
||||
|
||||
To be "interesting":
|
||||
- A "received" order where the received date lies within the date range
|
||||
- TODO: A "pending" order where the target date lies within the date range
|
||||
- A "pending" order where the target date lies within the date range
|
||||
- TODO: An "overdue" order where the target date is in the past
|
||||
"""
|
||||
|
||||
@ -149,13 +152,12 @@ class PurchaseOrder(Order):
|
||||
# Construct a queryset for "received" orders within the range
|
||||
received = Q(status=PurchaseOrderStatus.COMPLETE) & Q(complete_date__gte=min_date) & Q(complete_date__lte=max_date)
|
||||
|
||||
# TODO - Construct a queryset for "pending" orders within the range
|
||||
# Construct a queryset for "pending" orders within the range
|
||||
pending = Q(status__in=PurchaseOrderStatus.OPEN) & ~Q(target_date=None) & Q(target_date__gte=min_date) & Q(target_date__lte=max_date)
|
||||
|
||||
# TODO - Construct a queryset for "overdue" orders within the range
|
||||
|
||||
flt = received
|
||||
|
||||
queryset = queryset.filter(flt)
|
||||
queryset = queryset.filter(received | pending)
|
||||
|
||||
return queryset
|
||||
|
||||
@ -186,9 +188,23 @@ class PurchaseOrder(Order):
|
||||
related_name='+'
|
||||
)
|
||||
|
||||
issue_date = models.DateField(blank=True, null=True, help_text=_('Date order was issued'))
|
||||
issue_date = models.DateField(
|
||||
blank=True, null=True,
|
||||
verbose_name=_('Issue Date'),
|
||||
help_text=_('Date order was issued')
|
||||
)
|
||||
|
||||
complete_date = models.DateField(blank=True, null=True, help_text=_('Date order was completed'))
|
||||
target_date = models.DateField(
|
||||
blank=True, null=True,
|
||||
verbose_name=_('Target Delivery Date'),
|
||||
help_text=_('Expected date for order delivery. Order will be overdue after this date.'),
|
||||
)
|
||||
|
||||
complete_date = models.DateField(
|
||||
blank=True, null=True,
|
||||
verbose_name=_('Completion Date'),
|
||||
help_text=_('Date order was completed')
|
||||
)
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('po-detail', kwargs={'pk': self.id})
|
||||
@ -256,8 +272,24 @@ class PurchaseOrder(Order):
|
||||
self.complete_date = datetime.now().date()
|
||||
self.save()
|
||||
|
||||
def is_overdue(self):
|
||||
"""
|
||||
Returns True if this PurchaseOrder is "overdue"
|
||||
|
||||
Makes use of the OVERDUE_FILTER to avoid code duplication.
|
||||
"""
|
||||
|
||||
query = PurchaseOrder.objects.filter(pk=self.pk)
|
||||
query = query.filter(PurchaseOrder.OVERDUE_FILTER)
|
||||
|
||||
return query.exists()
|
||||
|
||||
def can_cancel(self):
|
||||
return self.status not in [
|
||||
"""
|
||||
A PurchaseOrder can only be cancelled under the following circumstances:
|
||||
"""
|
||||
|
||||
return self.status in [
|
||||
PurchaseOrderStatus.PLACED,
|
||||
PurchaseOrderStatus.PENDING
|
||||
]
|
||||
@ -419,17 +451,13 @@ class SalesOrder(Order):
|
||||
"""
|
||||
Returns true if this SalesOrder is "overdue":
|
||||
|
||||
- Not completed
|
||||
- Target date is "in the past"
|
||||
Makes use of the OVERDUE_FILTER to avoid code duplication.
|
||||
"""
|
||||
|
||||
# Order cannot be deemed overdue if target_date is not set
|
||||
if self.target_date is None:
|
||||
return False
|
||||
query = SalesOrder.objects.filter(pk=self.pk)
|
||||
query = query.filer(SalesOrder.OVERDUE_FILTER)
|
||||
|
||||
today = datetime.now().date()
|
||||
|
||||
return self.is_pending and self.target_date < today
|
||||
return query.exists()
|
||||
|
||||
@property
|
||||
def is_pending(self):
|
||||
|
@ -40,12 +40,24 @@ class POSerializer(InvenTreeModelSerializer):
|
||||
def annotate_queryset(queryset):
|
||||
"""
|
||||
Add extra information to the queryset
|
||||
|
||||
- Number of liens in the PurchaseOrder
|
||||
- Overdue status of the PurchaseOrder
|
||||
"""
|
||||
|
||||
queryset = queryset.annotate(
|
||||
line_items=SubqueryCount('lines')
|
||||
)
|
||||
|
||||
queryset = queryset.annotate(
|
||||
overdue=Case(
|
||||
When(
|
||||
PurchaseOrder.OVERDUE_FILTER, then=Value(True, output_field=BooleanField()),
|
||||
),
|
||||
default=Value(False, output_field=BooleanField())
|
||||
)
|
||||
)
|
||||
|
||||
return queryset
|
||||
|
||||
supplier_detail = CompanyBriefSerializer(source='supplier', many=False, read_only=True)
|
||||
@ -54,6 +66,8 @@ class POSerializer(InvenTreeModelSerializer):
|
||||
|
||||
status_text = serializers.CharField(source='get_status_display', read_only=True)
|
||||
|
||||
overdue = serializers.BooleanField(required=False, read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = PurchaseOrder
|
||||
|
||||
@ -65,12 +79,14 @@ class POSerializer(InvenTreeModelSerializer):
|
||||
'description',
|
||||
'line_items',
|
||||
'link',
|
||||
'overdue',
|
||||
'reference',
|
||||
'supplier',
|
||||
'supplier_detail',
|
||||
'supplier_reference',
|
||||
'status',
|
||||
'status_text',
|
||||
'target_date',
|
||||
'notes',
|
||||
]
|
||||
|
||||
|
@ -26,7 +26,12 @@ src="{% static 'img/blank_image.png' %}"
|
||||
<a href="{% url 'admin:order_purchaseorder_change' order.pk %}"><span title='{% trans "Admin view" %}' class='fas fa-user-shield'></span></a>
|
||||
{% endif %}
|
||||
</h3>
|
||||
<h3>{% purchase_order_status_label order.status large=True %}</h3>
|
||||
<h3>
|
||||
{% purchase_order_status_label order.status large=True %}
|
||||
{% if order.is_overdue %}
|
||||
<span class='label label-large label-large-red'>{% trans "Overdue" %}</span>
|
||||
{% endif %}
|
||||
</h3>
|
||||
<hr>
|
||||
<p>{{ order.description }}</p>
|
||||
<div class='btn-row'>
|
||||
@ -47,7 +52,7 @@ src="{% static 'img/blank_image.png' %}"
|
||||
<span class='fas fa-check-circle'></span>
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if order.status == PurchaseOrderStatus.PENDING or order.status == PurchaseOrderStatus.PLACED %}
|
||||
{% if order.can_cancel %}
|
||||
<button type='button' class='btn btn-default' id='cancel-order' title='{% trans "Cancel order" %}'>
|
||||
<span class='fas fa-times-circle icon-red'></span>
|
||||
</button>
|
||||
@ -72,7 +77,12 @@ src="{% static 'img/blank_image.png' %}"
|
||||
<tr>
|
||||
<td><span class='fas fa-info'></span></td>
|
||||
<td>{% trans "Order Status" %}</td>
|
||||
<td>{% purchase_order_status_label order.status %}</td>
|
||||
<td>
|
||||
{% purchase_order_status_label order.status %}
|
||||
{% if order.is_overdue %}
|
||||
<span class='label label-red'>{% trans "Overdue" %}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><span class='fas fa-building'></span></td>
|
||||
@ -105,6 +115,13 @@ src="{% static 'img/blank_image.png' %}"
|
||||
<td>{{ order.issue_date }}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% if order.target_date %}
|
||||
<tr>
|
||||
<td><span class='fas fa-calendar-alt'></span></td>
|
||||
<td>{% trans "Target Date" %}</td>
|
||||
<td>{{ order.target_date }}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% if order.status == PurchaseOrderStatus.COMPLETE %}
|
||||
<tr>
|
||||
<td><span class='fas fa-calendar-alt'></span></td>
|
||||
|
@ -70,6 +70,8 @@ InvenTree | {% trans "Purchase Orders" %}
|
||||
|
||||
if (order.complete_date) {
|
||||
date = order.complete_date;
|
||||
} else if (order.target_date) {
|
||||
date = order.target_date;
|
||||
}
|
||||
|
||||
var title = `${prefix}${order.reference} - ${order.supplier_detail.name}`;
|
||||
|
@ -2,12 +2,16 @@
|
||||
Tests for the Order API
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from rest_framework.test import APITestCase
|
||||
from rest_framework import status
|
||||
|
||||
from django.urls import reverse
|
||||
from django.contrib.auth import get_user_model
|
||||
|
||||
from .models import PurchaseOrder, SalesOrder
|
||||
|
||||
|
||||
class OrderTest(APITestCase):
|
||||
|
||||
@ -18,6 +22,8 @@ class OrderTest(APITestCase):
|
||||
'location',
|
||||
'supplier_part',
|
||||
'stock',
|
||||
'order',
|
||||
'sales_order',
|
||||
]
|
||||
|
||||
def setUp(self):
|
||||
@ -26,21 +32,80 @@ class OrderTest(APITestCase):
|
||||
get_user_model().objects.create_user('testuser', 'test@testing.com', 'password')
|
||||
self.client.login(username='testuser', password='password')
|
||||
|
||||
def doGet(self, url, options=''):
|
||||
def doGet(self, url, data={}):
|
||||
|
||||
return self.client.get(url + "?" + options, format='json')
|
||||
return self.client.get(url, data=data, format='json')
|
||||
|
||||
def doPost(self, url, data={}):
|
||||
return self.client.post(url, data=data, format='json')
|
||||
|
||||
def filter(self, filters, count):
|
||||
"""
|
||||
Test API filters
|
||||
"""
|
||||
|
||||
response = self.doGet(
|
||||
self.LIST_URL,
|
||||
filters
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(len(response.data), count)
|
||||
|
||||
return response
|
||||
|
||||
|
||||
class PurchaseOrderTest(OrderTest):
|
||||
"""
|
||||
Tests for the PurchaseOrder API
|
||||
"""
|
||||
|
||||
LIST_URL = reverse('api-po-list')
|
||||
|
||||
def test_po_list(self):
|
||||
|
||||
url = reverse('api-po-list')
|
||||
|
||||
# List all order items
|
||||
# List *ALL* PO items
|
||||
self.filter({}, 6)
|
||||
|
||||
# Filter by supplier
|
||||
self.filter({'supplier': 1}, 1)
|
||||
self.filter({'supplier': 3}, 5)
|
||||
|
||||
# Filter by "outstanding"
|
||||
self.filter({'outstanding': True}, 4)
|
||||
self.filter({'outstanding': False}, 2)
|
||||
|
||||
# Filter by "status"
|
||||
self.filter({'status': 10}, 2)
|
||||
self.filter({'status': 40}, 1)
|
||||
|
||||
def test_overdue(self):
|
||||
"""
|
||||
Test "overdue" status
|
||||
"""
|
||||
|
||||
self.filter({'overdue': True}, 0)
|
||||
self.filter({'overdue': False}, 6)
|
||||
|
||||
order = PurchaseOrder.objects.get(pk=1)
|
||||
order.target_date = datetime.now().date() - timedelta(days=10)
|
||||
order.save()
|
||||
|
||||
self.filter({'overdue': True}, 1)
|
||||
self.filter({'overdue': False}, 5)
|
||||
|
||||
def test_po_detail(self):
|
||||
|
||||
url = '/api/order/po/1/'
|
||||
|
||||
response = self.doGet(url)
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
# Filter by stuff
|
||||
response = self.doGet(url, 'status=10&part=1&supplier_part=1')
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
data = response.data
|
||||
|
||||
self.assertEqual(data['pk'], 1)
|
||||
self.assertEqual(data['description'], 'Ordering some screws')
|
||||
|
||||
def test_po_attachments(self):
|
||||
|
||||
@ -50,6 +115,60 @@ class OrderTest(APITestCase):
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
|
||||
|
||||
class SalesOrderTest(OrderTest):
|
||||
"""
|
||||
Tests for the SalesOrder API
|
||||
"""
|
||||
|
||||
LIST_URL = reverse('api-so-list')
|
||||
|
||||
def test_so_list(self):
|
||||
|
||||
# All orders
|
||||
self.filter({}, 5)
|
||||
|
||||
# Filter by customer
|
||||
self.filter({'customer': 4}, 3)
|
||||
self.filter({'customer': 5}, 2)
|
||||
|
||||
# Filter by outstanding
|
||||
self.filter({'outstanding': True}, 3)
|
||||
self.filter({'outstanding': False}, 2)
|
||||
|
||||
# Filter by status
|
||||
self.filter({'status': 10}, 3) # PENDING
|
||||
self.filter({'status': 20}, 1) # SHIPPED
|
||||
self.filter({'status': 99}, 0) # Invalid
|
||||
|
||||
def test_overdue(self):
|
||||
"""
|
||||
Test "overdue" status
|
||||
"""
|
||||
|
||||
self.filter({'overdue': True}, 0)
|
||||
self.filter({'overdue': False}, 5)
|
||||
|
||||
for pk in [1, 2]:
|
||||
order = SalesOrder.objects.get(pk=pk)
|
||||
order.target_date = datetime.now().date() - timedelta(days=10)
|
||||
order.save()
|
||||
|
||||
self.filter({'overdue': True}, 2)
|
||||
self.filter({'overdue': False}, 3)
|
||||
|
||||
def test_so_detail(self):
|
||||
|
||||
url = '/api/order/so/1/'
|
||||
|
||||
response = self.doGet(url)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
data = response.data
|
||||
|
||||
self.assertEqual(data['pk'], 1)
|
||||
|
||||
def test_so_attachments(self):
|
||||
|
||||
url = reverse('api-so-attachment-list')
|
||||
|
@ -41,7 +41,7 @@ class OrderTest(TestCase):
|
||||
|
||||
next_ref = PurchaseOrder.getNextOrderNumber()
|
||||
|
||||
self.assertEqual(next_ref, '0003')
|
||||
self.assertEqual(next_ref, '0007')
|
||||
|
||||
def test_on_order(self):
|
||||
""" There should be 3 separate items on order for the M2x4 LPHS part """
|
||||
|
@ -432,7 +432,7 @@ class PurchaseOrderCancel(AjaxUpdateView):
|
||||
form.add_error('confirm', _('Confirm order cancellation'))
|
||||
|
||||
if not order.can_cancel():
|
||||
form.add_error(None, _('Order cannot be cancelled as either pending or placed'))
|
||||
form.add_error(None, _('Order cannot be cancelled'))
|
||||
|
||||
def save(self, order, form, **kwargs):
|
||||
"""
|
||||
|
@ -13,6 +13,8 @@ from mptt.fields import TreeNodeChoiceField
|
||||
from django import forms
|
||||
from django.utils.translation import ugettext as _
|
||||
|
||||
import common.models
|
||||
|
||||
from .models import Part, PartCategory, PartAttachment, PartRelated
|
||||
from .models import BomItem
|
||||
from .models import PartParameterTemplate, PartParameter
|
||||
@ -23,8 +25,16 @@ from .models import PartSellPriceBreak
|
||||
|
||||
class PartModelChoiceField(forms.ModelChoiceField):
|
||||
""" Extending string representation of Part instance with available stock """
|
||||
|
||||
def label_from_instance(self, part):
|
||||
return f'{part} - {part.available_stock}'
|
||||
|
||||
label = str(part)
|
||||
|
||||
# Optionally display available part quantity
|
||||
if common.models.InvenTreeSetting.get_setting('PART_SHOW_QUANTITY_IN_FORMS'):
|
||||
label += f" - {part.available_stock}"
|
||||
|
||||
return label
|
||||
|
||||
|
||||
class PartImageForm(HelperForm):
|
||||
|
@ -1990,7 +1990,13 @@ class BomItem(models.Model):
|
||||
Return the available stock items for the referenced sub_part
|
||||
"""
|
||||
|
||||
query = self.sub_part.stock_items.filter(StockModels.StockItem.IN_STOCK_FILTER).aggregate(
|
||||
query = self.sub_part.stock_items.all()
|
||||
|
||||
query = query.prefetch_related([
|
||||
'sub_part__stock_items',
|
||||
])
|
||||
|
||||
query = query.filter(StockModels.StockItem.IN_STOCK_FILTER).aggregate(
|
||||
available=Coalesce(Sum('quantity'), 0)
|
||||
)
|
||||
|
||||
|
@ -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
|
||||
@ -63,6 +64,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.
|
||||
|
||||
@ -330,6 +338,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(
|
||||
@ -1333,14 +1348,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')
|
||||
|
@ -403,12 +403,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() {
|
||||
|
@ -29,7 +29,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>
|
||||
@ -205,6 +205,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([
|
||||
|
@ -31,7 +31,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
|
||||
@ -304,92 +303,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
|
||||
|
@ -35,6 +35,7 @@ InvenTree | {% trans "Index" %}
|
||||
{% if roles.purchase_order.view %}
|
||||
{% include "InvenTree/po_outstanding.html" with collapse_id="po_outstanding" %}
|
||||
{% endif %}
|
||||
{% include "InvenTree/po_overdue.html" with collapse_id="po_overdue" %}
|
||||
{% if roles.sales_order.view %}
|
||||
{% include "InvenTree/so_outstanding.html" with collapse_id="so_outstanding" %}
|
||||
{% include "InvenTree/so_overdue.html" with collapse_id="so_overdue" %}
|
||||
@ -130,6 +131,14 @@ loadPurchaseOrderTable("#po-outstanding-table", {
|
||||
}
|
||||
});
|
||||
|
||||
loadPurchaseOrderTable("#po-overdue-table", {
|
||||
url: "{% url 'api-po-list' %}",
|
||||
params: {
|
||||
supplier_detail: true,
|
||||
overdue: true,
|
||||
}
|
||||
});
|
||||
|
||||
loadSalesOrderTable("#so-outstanding-table", {
|
||||
url: "{% url 'api-so-list' %}",
|
||||
params: {
|
||||
@ -158,6 +167,7 @@ loadSalesOrderTable("#so-overdue-table", {
|
||||
{% include "InvenTree/index/on_load.html" with label="stock-to-build" %}
|
||||
|
||||
{% include "InvenTree/index/on_load.html" with label="po-outstanding" %}
|
||||
{% include "InvenTree/index/on_load.html" with label="po-overdue" %}
|
||||
{% include "InvenTree/index/on_load.html" with label="so-outstanding" %}
|
||||
{% include "InvenTree/index/on_load.html" with label="so-overdue" %}
|
||||
|
||||
|
15
InvenTree/templates/InvenTree/po_overdue.html
Normal file
15
InvenTree/templates/InvenTree/po_overdue.html
Normal file
@ -0,0 +1,15 @@
|
||||
{% extends "collapse_index.html" %}
|
||||
|
||||
{% load i18n %}
|
||||
|
||||
{% block collapse_title %}
|
||||
<span class='fas fa-calendar-times icon-header'></span>
|
||||
{% trans "Overdue Purchase Orders" %}<span class='badge' id='po-overdue-count'><span class='fas fa-spin fa-spinner'></span></span>
|
||||
{% endblock %}
|
||||
|
||||
{% block collapse_content %}
|
||||
|
||||
<table class='table table-striped table-condensed' id='po-overdue-table'>
|
||||
</table>
|
||||
|
||||
{% endblock %}
|
@ -18,6 +18,7 @@
|
||||
<tbody>
|
||||
{% include "InvenTree/settings/setting.html" with key="PART_IPN_REGEX" %}
|
||||
{% include "InvenTree/settings/setting.html" with key="PART_ALLOW_DUPLICATE_IPN" %}
|
||||
{% include "InvenTree/settings/setting.html" with key="PART_SHOW_QUANTITY_IN_FORMS" icon="fa-hashtag" %}
|
||||
<tr><td colspan='5 '></td></tr>
|
||||
{% include "InvenTree/settings/setting.html" with key="PART_TEMPLATE" icon="fa-clone" %}
|
||||
{% include "InvenTree/settings/setting.html" with key="PART_ASSEMBLY" icon="fa-tools" %}
|
||||
|
@ -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);
|
@ -141,9 +141,9 @@ function loadPurchaseOrderTable(table, options) {
|
||||
switchable: false,
|
||||
},
|
||||
{
|
||||
sortable: true,
|
||||
field: 'reference',
|
||||
title: '{% trans "Purchase Order" %}',
|
||||
sortable: true,
|
||||
switchable: false,
|
||||
formatter: function(value, row, index, field) {
|
||||
|
||||
@ -153,13 +153,19 @@ function loadPurchaseOrderTable(table, options) {
|
||||
value = `${prefix}${value}`;
|
||||
}
|
||||
|
||||
return renderLink(value, `/order/purchase-order/${row.pk}/`);
|
||||
var html = renderLink(value, `/order/purchase-order/${row.pk}/`);
|
||||
|
||||
if (row.overdue) {
|
||||
html += makeIconBadge('fa-calendar-times icon-red', '{% trans "Order is overdue" %}');
|
||||
}
|
||||
|
||||
return html;
|
||||
}
|
||||
},
|
||||
{
|
||||
sortable: true,
|
||||
field: 'supplier_detail',
|
||||
title: '{% trans "Supplier" %}',
|
||||
sortable: true,
|
||||
formatter: function(value, row, index, field) {
|
||||
return imageHoverIcon(row.supplier_detail.image) + renderLink(row.supplier_detail.name, `/company/${row.supplier}/purchase-orders/`);
|
||||
}
|
||||
@ -170,27 +176,32 @@ function loadPurchaseOrderTable(table, options) {
|
||||
sortable: true,
|
||||
},
|
||||
{
|
||||
sortable: true,
|
||||
field: 'description',
|
||||
title: '{% trans "Description" %}',
|
||||
sortable: true,
|
||||
},
|
||||
{
|
||||
sortable: true,
|
||||
field: 'status',
|
||||
title: '{% trans "Status" %}',
|
||||
sortable: true,
|
||||
formatter: function(value, row, index, field) {
|
||||
return purchaseOrderStatusDisplay(row.status, row.status_text);
|
||||
}
|
||||
},
|
||||
{
|
||||
sortable: true,
|
||||
field: 'creation_date',
|
||||
title: '{% trans "Date" %}',
|
||||
sortable: true,
|
||||
},
|
||||
{
|
||||
field: 'target_date',
|
||||
title: '{% trans "Target Date" %}',
|
||||
sortable: true,
|
||||
},
|
||||
{
|
||||
field: 'line_items',
|
||||
title: '{% trans "Items" %}'
|
||||
title: '{% trans "Items" %}',
|
||||
sortable: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
@ -660,6 +660,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');
|
||||
});
|
||||
|
@ -214,6 +214,10 @@ function getAvailableTableFilters(tableKey) {
|
||||
type: 'bool',
|
||||
title: '{% trans "Outstanding" %}',
|
||||
},
|
||||
overdue: {
|
||||
type: 'bool',
|
||||
title: '{% trans "Overdue" %}',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -17,6 +17,7 @@
|
||||
<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>
|
||||
{% 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>
|
||||
|
@ -112,6 +112,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