Merge remote-tracking branch 'inventree/master'

This commit is contained in:
Oliver Walters 2021-01-15 09:58:02 +11:00
commit 05cfb1a3ab
50 changed files with 3602 additions and 1508 deletions

View File

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

View File

@ -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'

View File

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

View File

@ -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'),

View File

@ -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

View File

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

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

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,24 @@
# Generated by Django 3.0.7 on 2021-01-13 12:02
from django.db import migrations, models
import label.models
class Migration(migrations.Migration):
dependencies = [
('label', '0004_auto_20210111_2302'),
]
operations = [
migrations.AlterField(
model_name='stockitemlabel',
name='filters',
field=models.CharField(blank=True, help_text='Query filters (comma-separated list of key=value pairs', max_length=250, validators=[label.models.validate_stock_item_filters], verbose_name='Filters'),
),
migrations.AlterField(
model_name='stocklocationlabel',
name='filters',
field=models.CharField(blank=True, help_text='Query filters (comma-separated list of key=value pairs', max_length=250, validators=[label.models.validate_stock_location_filters], verbose_name='Filters'),
),
]

View File

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

View File

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

View File

@ -0,0 +1,16 @@
<style>
@page {
width: 24mm;
height: 24mm;
padding: 1mm;
}
.qr {
margin: 2px;
width: 22mm;
height: 22mm;
}
</style>
<img class='qr' src="{{ label_tools.qr_code(item.barcode) }}"/>

View File

@ -0,0 +1,16 @@
<style>
@page {
width: 24mm;
height: 24mm;
padding: 1mm;
}
.qr {
margin: 2px;
width: 22mm;
height: 22mm;
}
</style>
<img class='qr' src="{{ label_tools.qr_code(location.barcode) }}"/>

View File

@ -0,0 +1,43 @@
<style>
@page {
width: 75mm;
height: 24mm;
padding: 1mm;
}
.location {
padding: 5px;
font-weight: bold;
font-family: Arial, Helvetica, sans-serif;
height: 100%;
vertical-align: middle;
float: right;
display: inline;
font-size: 125%;
position: absolute;
top: 0mm;
left: 23mm;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.qr {
margin: 2px;
width: 22mm;
height: 22mm;
}
</style>
<img class='qr' src="{{ label_tools.qr_code(location.barcode) }}"/>
<div class='location'>
{{ location.name }}
<br>
<br>
<hr>
Location ID: {{ location.pk }}
</div>
</div>

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -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)

View File

@ -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

View 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

View File

@ -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',
]

View 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'),
),
]

View File

@ -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):

View File

@ -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',
]

View File

@ -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>

View File

@ -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}`;

View File

@ -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')

View File

@ -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 """

View File

@ -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):
"""

View File

@ -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):

View File

@ -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)
)

View File

@ -32,6 +32,7 @@ from InvenTree import helpers
import common.models
import report.models
import label.models
from InvenTree.status_codes import StockStatus
from InvenTree.models import InvenTreeTree, InvenTreeAttachment
@ -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')

View File

@ -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() {

View File

@ -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() {

View File

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

View File

@ -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

View File

@ -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" %}

View 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 %}

View File

@ -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" %}

View File

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

View File

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

View File

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

View File

@ -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,
},
],
});

View File

@ -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');
});

View File

@ -214,6 +214,10 @@ function getAvailableTableFilters(tableKey) {
type: 'bool',
title: '{% trans "Outstanding" %}',
},
overdue: {
type: 'bool',
title: '{% trans "Overdue" %}',
},
};
}

View File

@ -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>

View File

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