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
846b7aac84
@ -204,7 +204,6 @@ INSTALLED_APPS = [
|
|||||||
'crispy_forms', # Improved form rendering
|
'crispy_forms', # Improved form rendering
|
||||||
'import_export', # Import / export tables to file
|
'import_export', # Import / export tables to file
|
||||||
'django_cleanup.apps.CleanupConfig', # Automatically delete orphaned MEDIA files
|
'django_cleanup.apps.CleanupConfig', # Automatically delete orphaned MEDIA files
|
||||||
'qr_code', # Generate QR codes
|
|
||||||
'mptt', # Modified Preorder Tree Traversal
|
'mptt', # Modified Preorder Tree Traversal
|
||||||
'markdownx', # Markdown editing
|
'markdownx', # Markdown editing
|
||||||
'markdownify', # Markdown template rendering
|
'markdownify', # Markdown template rendering
|
||||||
@ -250,6 +249,7 @@ TEMPLATES = [
|
|||||||
os.path.join(BASE_DIR, 'templates'),
|
os.path.join(BASE_DIR, 'templates'),
|
||||||
# Allow templates in the reporting directory to be accessed
|
# Allow templates in the reporting directory to be accessed
|
||||||
os.path.join(MEDIA_ROOT, 'report'),
|
os.path.join(MEDIA_ROOT, 'report'),
|
||||||
|
os.path.join(MEDIA_ROOT, 'label'),
|
||||||
],
|
],
|
||||||
'APP_DIRS': True,
|
'APP_DIRS': True,
|
||||||
'OPTIONS': {
|
'OPTIONS': {
|
||||||
@ -407,8 +407,6 @@ CACHES = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
QR_CODE_CACHE_ALIAS = 'qr-code'
|
|
||||||
|
|
||||||
# Password validation
|
# Password validation
|
||||||
# https://docs.djangoproject.com/en/1.10/ref/settings/#auth-password-validators
|
# https://docs.djangoproject.com/en/1.10/ref/settings/#auth-password-validators
|
||||||
|
|
||||||
|
@ -125,6 +125,7 @@
|
|||||||
.qr-container {
|
.qr-container {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
align-content: center;
|
align-content: center;
|
||||||
|
object-fit: fill;
|
||||||
}
|
}
|
||||||
|
|
||||||
.navbar-brand {
|
.navbar-brand {
|
||||||
|
@ -9,7 +9,6 @@ from django.conf.urls import url, include
|
|||||||
from django.urls import path
|
from django.urls import path
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
from django.contrib.auth import views as auth_views
|
from django.contrib.auth import views as auth_views
|
||||||
from qr_code import urls as qr_code_urls
|
|
||||||
|
|
||||||
from company.urls import company_urls
|
from company.urls import company_urls
|
||||||
from company.urls import supplier_part_urls
|
from company.urls import supplier_part_urls
|
||||||
@ -21,7 +20,7 @@ from stock.urls import stock_urls
|
|||||||
from build.urls import build_urls
|
from build.urls import build_urls
|
||||||
from order.urls import order_urls
|
from order.urls import order_urls
|
||||||
|
|
||||||
from barcode.api import barcode_api_urls
|
from barcodes.api import barcode_api_urls
|
||||||
from common.api import common_api_urls
|
from common.api import common_api_urls
|
||||||
from part.api import part_api_urls, bom_api_urls
|
from part.api import part_api_urls, bom_api_urls
|
||||||
from company.api import company_api_urls
|
from company.api import company_api_urls
|
||||||
@ -141,8 +140,6 @@ urlpatterns = [
|
|||||||
url(r'^admin/shell/', include('django_admin_shell.urls')),
|
url(r'^admin/shell/', include('django_admin_shell.urls')),
|
||||||
url(r'^admin/', admin.site.urls, name='inventree-admin'),
|
url(r'^admin/', admin.site.urls, name='inventree-admin'),
|
||||||
|
|
||||||
url(r'^qr_code/', include(qr_code_urls, namespace='qr_code')),
|
|
||||||
|
|
||||||
url(r'^index/', IndexView.as_view(), name='index'),
|
url(r'^index/', IndexView.as_view(), name='index'),
|
||||||
url(r'^search/', SearchView.as_view(), name='search'),
|
url(r'^search/', SearchView.as_view(), name='search'),
|
||||||
url(r'^stats/', DatabaseStatsView.as_view(), name='stats'),
|
url(r'^stats/', DatabaseStatsView.as_view(), name='stats'),
|
||||||
|
@ -12,7 +12,7 @@ from rest_framework.views import APIView
|
|||||||
from stock.models import StockItem
|
from stock.models import StockItem
|
||||||
from stock.serializers import StockItemSerializer
|
from stock.serializers import StockItemSerializer
|
||||||
|
|
||||||
from barcode.barcode import load_barcode_plugins, hash_barcode
|
from barcodes.barcode import load_barcode_plugins, hash_barcode
|
||||||
|
|
||||||
|
|
||||||
class BarcodeScan(APIView):
|
class BarcodeScan(APIView):
|
@ -5,7 +5,7 @@ import hashlib
|
|||||||
import logging
|
import logging
|
||||||
|
|
||||||
from InvenTree import plugins as InvenTreePlugins
|
from InvenTree import plugins as InvenTreePlugins
|
||||||
from barcode import plugins as BarcodePlugins
|
from barcodes import plugins as BarcodePlugins
|
||||||
|
|
||||||
from stock.models import StockItem
|
from stock.models import StockItem
|
||||||
from stock.serializers import StockItemSerializer, LocationSerializer
|
from stock.serializers import StockItemSerializer, LocationSerializer
|
@ -4,7 +4,7 @@
|
|||||||
DigiKey barcode decoding
|
DigiKey barcode decoding
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from barcode.barcode import BarcodePlugin
|
from barcodes.barcode import BarcodePlugin
|
||||||
|
|
||||||
|
|
||||||
class DigikeyBarcodePlugin(BarcodePlugin):
|
class DigikeyBarcodePlugin(BarcodePlugin):
|
@ -13,7 +13,7 @@ references model objects actually exist in the database.
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
|
|
||||||
from barcode.barcode import BarcodePlugin
|
from barcodes.barcode import BarcodePlugin
|
||||||
|
|
||||||
from stock.models import StockItem, StockLocation
|
from stock.models import StockItem, StockLocation
|
||||||
from part.models import Part
|
from part.models import Part
|
@ -56,6 +56,28 @@ class BuildList(generics.ListCreateAPIView):
|
|||||||
|
|
||||||
params = self.request.query_params
|
params = self.request.query_params
|
||||||
|
|
||||||
|
# Filter by "parent"
|
||||||
|
parent = params.get('parent', None)
|
||||||
|
|
||||||
|
if parent is not None:
|
||||||
|
queryset = queryset.filter(parent=parent)
|
||||||
|
|
||||||
|
# Filter by "ancestor" builds
|
||||||
|
ancestor = params.get('ancestor', None)
|
||||||
|
|
||||||
|
if ancestor is not None:
|
||||||
|
try:
|
||||||
|
ancestor = Build.objects.get(pk=ancestor)
|
||||||
|
|
||||||
|
descendants = ancestor.get_descendants(include_self=True)
|
||||||
|
|
||||||
|
queryset = queryset.filter(
|
||||||
|
parent__pk__in=[b.pk for b in descendants]
|
||||||
|
)
|
||||||
|
|
||||||
|
except (ValueError, Build.DoesNotExist):
|
||||||
|
pass
|
||||||
|
|
||||||
# Filter by build status?
|
# Filter by build status?
|
||||||
status = params.get('status', None)
|
status = params.get('status', None)
|
||||||
|
|
||||||
|
@ -259,6 +259,27 @@ class Build(MPTTModel):
|
|||||||
blank=True, help_text=_('Extra build notes')
|
blank=True, help_text=_('Extra build notes')
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def sub_builds(self, cascade=True):
|
||||||
|
"""
|
||||||
|
Return all Build Order objects under this one.
|
||||||
|
"""
|
||||||
|
|
||||||
|
if cascade:
|
||||||
|
return Build.objects.filter(parent=self.pk)
|
||||||
|
else:
|
||||||
|
descendants = self.get_descendants(include_self=True)
|
||||||
|
Build.objects.filter(parent__pk__in=[d.pk for d in descendants])
|
||||||
|
|
||||||
|
def sub_build_count(self, cascade=True):
|
||||||
|
"""
|
||||||
|
Return the number of sub builds under this one.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
cascade: If True (defualt), include cascading builds under sub builds
|
||||||
|
"""
|
||||||
|
|
||||||
|
return self.sub_builds(cascade=cascade).count()
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_overdue(self):
|
def is_overdue(self):
|
||||||
"""
|
"""
|
||||||
|
39
InvenTree/build/templates/build/build_children.html
Normal file
39
InvenTree/build/templates/build/build_children.html
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
{% extends "build/build_base.html" %}
|
||||||
|
{% load static %}
|
||||||
|
{% load i18n %}
|
||||||
|
|
||||||
|
{% block details %}
|
||||||
|
|
||||||
|
{% include "build/tabs.html" with tab="children" %}
|
||||||
|
|
||||||
|
<h4>{% trans "Child Build Orders" %}</h4>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<div id='button-toolbar'>
|
||||||
|
<div class='button-toolbar container-fluid float-right'>
|
||||||
|
<div class='filter-list' id='filter-list-sub-build'>
|
||||||
|
<!-- Empty div for filters -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table class='table table-striped table-condensed' id='sub-build-table' data-toolbar='#button-toolbar'></table>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block js_ready %}
|
||||||
|
|
||||||
|
{{ block.super }}
|
||||||
|
|
||||||
|
loadBuildTable($('#sub-build-table'), {
|
||||||
|
url: '{% url "api-build-list" %}',
|
||||||
|
filterTarget: "#filter-list-sub-build",
|
||||||
|
params: {
|
||||||
|
part_detail: true,
|
||||||
|
ancestor: {{ build.pk }},
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
{% endblock %}
|
@ -24,6 +24,12 @@
|
|||||||
<span class='badge'>{{ build.output_count }}</span>
|
<span class='badge'>{{ build.output_count }}</span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
<li {% if tab == 'children' %} class='active'{% endif %}>
|
||||||
|
<a href='{% url "build-children" build.id %}'>
|
||||||
|
{% trans "Child Builds" %}
|
||||||
|
<span class='badge'>{{ build.sub_build_count }}</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
<li{% if tab == 'notes' %} class='active'{% endif %}>
|
<li{% if tab == 'notes' %} class='active'{% endif %}>
|
||||||
<a href="{% url 'build-notes' build.id %}">
|
<a href="{% url 'build-notes' build.id %}">
|
||||||
{% trans "Notes" %}
|
{% trans "Notes" %}
|
||||||
|
162
InvenTree/build/test_api.py
Normal file
162
InvenTree/build/test_api.py
Normal file
@ -0,0 +1,162 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
from rest_framework.test import APITestCase
|
||||||
|
|
||||||
|
from django.urls import reverse
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.contrib.auth.models import Group
|
||||||
|
|
||||||
|
from part.models import Part
|
||||||
|
from build.models import Build
|
||||||
|
|
||||||
|
from InvenTree.status_codes import BuildStatus
|
||||||
|
|
||||||
|
|
||||||
|
class BuildAPITest(APITestCase):
|
||||||
|
"""
|
||||||
|
Series of tests for the Build DRF API
|
||||||
|
"""
|
||||||
|
|
||||||
|
fixtures = [
|
||||||
|
'category',
|
||||||
|
'part',
|
||||||
|
'location',
|
||||||
|
'bom',
|
||||||
|
'build',
|
||||||
|
]
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
# Create a user for auth
|
||||||
|
user = get_user_model()
|
||||||
|
|
||||||
|
self.user = user.objects.create_user(
|
||||||
|
username='testuser',
|
||||||
|
email='test@testing.com',
|
||||||
|
password='password'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Put the user into a group with the correct permissions
|
||||||
|
group = Group.objects.create(name='mygroup')
|
||||||
|
self.user.groups.add(group)
|
||||||
|
|
||||||
|
# Give the group *all* the permissions!
|
||||||
|
for rule in group.rule_sets.all():
|
||||||
|
rule.can_view = True
|
||||||
|
rule.can_change = True
|
||||||
|
rule.can_add = True
|
||||||
|
rule.can_delete = True
|
||||||
|
|
||||||
|
rule.save()
|
||||||
|
|
||||||
|
group.save()
|
||||||
|
|
||||||
|
self.client.login(username='testuser', password='password')
|
||||||
|
|
||||||
|
|
||||||
|
class BuildListTest(BuildAPITest):
|
||||||
|
"""
|
||||||
|
Tests for the BuildOrder LIST API
|
||||||
|
"""
|
||||||
|
|
||||||
|
url = reverse('api-build-list')
|
||||||
|
|
||||||
|
def get(self, status_code=200, data={}):
|
||||||
|
|
||||||
|
response = self.client.get(self.url, data, format='json')
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, status_code)
|
||||||
|
|
||||||
|
return response.data
|
||||||
|
|
||||||
|
def test_get_all_builds(self):
|
||||||
|
"""
|
||||||
|
Retrieve *all* builds via the API
|
||||||
|
"""
|
||||||
|
|
||||||
|
builds = self.get()
|
||||||
|
|
||||||
|
self.assertEqual(len(builds), 5)
|
||||||
|
|
||||||
|
builds = self.get(data={'active': True})
|
||||||
|
self.assertEqual(len(builds), 1)
|
||||||
|
|
||||||
|
builds = self.get(data={'status': BuildStatus.COMPLETE})
|
||||||
|
self.assertEqual(len(builds), 4)
|
||||||
|
|
||||||
|
builds = self.get(data={'overdue': False})
|
||||||
|
self.assertEqual(len(builds), 5)
|
||||||
|
|
||||||
|
builds = self.get(data={'overdue': True})
|
||||||
|
self.assertEqual(len(builds), 0)
|
||||||
|
|
||||||
|
def test_overdue(self):
|
||||||
|
"""
|
||||||
|
Create a new build, in the past
|
||||||
|
"""
|
||||||
|
|
||||||
|
in_the_past = datetime.now().date() - timedelta(days=50)
|
||||||
|
|
||||||
|
part = Part.objects.get(pk=50)
|
||||||
|
|
||||||
|
Build.objects.create(
|
||||||
|
part=part,
|
||||||
|
quantity=10,
|
||||||
|
title='Just some thing',
|
||||||
|
status=BuildStatus.PRODUCTION,
|
||||||
|
target_date=in_the_past
|
||||||
|
)
|
||||||
|
|
||||||
|
builds = self.get(data={'overdue': True})
|
||||||
|
|
||||||
|
self.assertEqual(len(builds), 1)
|
||||||
|
|
||||||
|
def test_sub_builds(self):
|
||||||
|
"""
|
||||||
|
Test the build / sub-build relationship
|
||||||
|
"""
|
||||||
|
|
||||||
|
parent = Build.objects.get(pk=5)
|
||||||
|
|
||||||
|
part = Part.objects.get(pk=50)
|
||||||
|
|
||||||
|
n = Build.objects.count()
|
||||||
|
|
||||||
|
# Make some sub builds
|
||||||
|
for i in range(5):
|
||||||
|
Build.objects.create(
|
||||||
|
part=part,
|
||||||
|
quantity=10,
|
||||||
|
reference=f"build-000{i}",
|
||||||
|
title=f"Sub build {i}",
|
||||||
|
parent=parent
|
||||||
|
)
|
||||||
|
|
||||||
|
# And some sub-sub builds
|
||||||
|
for sub_build in Build.objects.filter(parent=parent):
|
||||||
|
|
||||||
|
for i in range(3):
|
||||||
|
Build.objects.create(
|
||||||
|
part=part,
|
||||||
|
reference=f"{sub_build.reference}-00{i}-sub",
|
||||||
|
quantity=40,
|
||||||
|
title=f"sub sub build {i}",
|
||||||
|
parent=sub_build
|
||||||
|
)
|
||||||
|
|
||||||
|
# 20 new builds should have been created!
|
||||||
|
self.assertEqual(Build.objects.count(), (n + 20))
|
||||||
|
|
||||||
|
Build.objects.rebuild()
|
||||||
|
|
||||||
|
# Search by parent
|
||||||
|
builds = self.get(data={'parent': parent.pk})
|
||||||
|
|
||||||
|
self.assertEqual(len(builds), 5)
|
||||||
|
|
||||||
|
# Search by ancestor
|
||||||
|
builds = self.get(data={'ancestor': parent.pk})
|
||||||
|
|
||||||
|
self.assertEqual(len(builds), 20)
|
@ -20,6 +20,7 @@ build_detail_urls = [
|
|||||||
|
|
||||||
url(r'^notes/', views.BuildNotes.as_view(), name='build-notes'),
|
url(r'^notes/', views.BuildNotes.as_view(), name='build-notes'),
|
||||||
|
|
||||||
|
url(r'^children/', views.BuildDetail.as_view(template_name='build/build_children.html'), name='build-children'),
|
||||||
url(r'^parts/', views.BuildDetail.as_view(template_name='build/parts.html'), name='build-parts'),
|
url(r'^parts/', views.BuildDetail.as_view(template_name='build/parts.html'), name='build-parts'),
|
||||||
url(r'^attachments/', views.BuildDetail.as_view(template_name='build/attachments.html'), name='build-attachments'),
|
url(r'^attachments/', views.BuildDetail.as_view(template_name='build/attachments.html'), name='build-attachments'),
|
||||||
url(r'^output/', views.BuildDetail.as_view(template_name='build/build_output.html'), name='build-output'),
|
url(r'^output/', views.BuildDetail.as_view(template_name='build/build_output.html'), name='build-output'),
|
||||||
|
@ -618,6 +618,7 @@ class BuildDetail(DetailView):
|
|||||||
|
|
||||||
ctx['bom_price'] = build.part.get_price_info(build.quantity, buy=False)
|
ctx['bom_price'] = build.part.get_price_info(build.quantity, buy=False)
|
||||||
ctx['BuildStatus'] = BuildStatus
|
ctx['BuildStatus'] = BuildStatus
|
||||||
|
ctx['sub_build_count'] = build.sub_build_count()
|
||||||
|
|
||||||
return ctx
|
return ctx
|
||||||
|
|
||||||
|
@ -125,6 +125,13 @@ class InvenTreeSetting(models.Model):
|
|||||||
'validator': bool
|
'validator': bool
|
||||||
},
|
},
|
||||||
|
|
||||||
|
'PART_RECENT_COUNT': {
|
||||||
|
'name': _('Recent Part Count'),
|
||||||
|
'description': _('Number of recent parts to display on index page'),
|
||||||
|
'default': 10,
|
||||||
|
'validator': [int, MinValueValidator(1)]
|
||||||
|
},
|
||||||
|
|
||||||
'PART_TEMPLATE': {
|
'PART_TEMPLATE': {
|
||||||
'name': _('Template'),
|
'name': _('Template'),
|
||||||
'description': _('Parts are templates by default'),
|
'description': _('Parts are templates by default'),
|
||||||
@ -249,6 +256,13 @@ class InvenTreeSetting(models.Model):
|
|||||||
'validator': bool,
|
'validator': bool,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
'STOCK_RECENT_COUNT': {
|
||||||
|
'name': _('Recent Stock Count'),
|
||||||
|
'description': _('Number of recent stock items to display on index page'),
|
||||||
|
'default': 10,
|
||||||
|
'validator': [int, MinValueValidator(1)]
|
||||||
|
},
|
||||||
|
|
||||||
'BUILDORDER_REFERENCE_PREFIX': {
|
'BUILDORDER_REFERENCE_PREFIX': {
|
||||||
'name': _('Build Order Reference Prefix'),
|
'name': _('Build Order Reference Prefix'),
|
||||||
'description': _('Prefix value for build order reference'),
|
'description': _('Prefix value for build order reference'),
|
||||||
@ -521,12 +535,18 @@ class InvenTreeSetting(models.Model):
|
|||||||
|
|
||||||
validator = InvenTreeSetting.get_setting_validator(self.key)
|
validator = InvenTreeSetting.get_setting_validator(self.key)
|
||||||
|
|
||||||
if validator is not None:
|
|
||||||
self.run_validator(validator)
|
|
||||||
|
|
||||||
if self.is_bool():
|
if self.is_bool():
|
||||||
self.value = InvenTree.helpers.str2bool(self.value)
|
self.value = InvenTree.helpers.str2bool(self.value)
|
||||||
|
|
||||||
|
if self.is_int():
|
||||||
|
try:
|
||||||
|
self.value = int(self.value)
|
||||||
|
except (ValueError):
|
||||||
|
raise ValidationError(_('Must be an integer value'))
|
||||||
|
|
||||||
|
if validator is not None:
|
||||||
|
self.run_validator(validator)
|
||||||
|
|
||||||
def run_validator(self, validator):
|
def run_validator(self, validator):
|
||||||
"""
|
"""
|
||||||
Run a validator against the 'value' field for this InvenTreeSetting object.
|
Run a validator against the 'value' field for this InvenTreeSetting object.
|
||||||
@ -535,39 +555,39 @@ class InvenTreeSetting(models.Model):
|
|||||||
if validator is None:
|
if validator is None:
|
||||||
return
|
return
|
||||||
|
|
||||||
# If a list of validators is supplied, iterate through each one
|
value = self.value
|
||||||
if type(validator) in [list, tuple]:
|
|
||||||
for v in validator:
|
|
||||||
self.run_validator(v)
|
|
||||||
|
|
||||||
return
|
|
||||||
|
|
||||||
if callable(validator):
|
|
||||||
# We can accept function validators with a single argument
|
|
||||||
print("Running validator function")
|
|
||||||
validator(self.value)
|
|
||||||
|
|
||||||
# Boolean validator
|
# Boolean validator
|
||||||
if validator == bool:
|
if self.is_bool():
|
||||||
# Value must "look like" a boolean value
|
# Value must "look like" a boolean value
|
||||||
if InvenTree.helpers.is_bool(self.value):
|
if InvenTree.helpers.is_bool(value):
|
||||||
# Coerce into either "True" or "False"
|
# Coerce into either "True" or "False"
|
||||||
self.value = str(InvenTree.helpers.str2bool(self.value))
|
value = InvenTree.helpers.str2bool(value)
|
||||||
else:
|
else:
|
||||||
raise ValidationError({
|
raise ValidationError({
|
||||||
'value': _('Value must be a boolean value')
|
'value': _('Value must be a boolean value')
|
||||||
})
|
})
|
||||||
|
|
||||||
# Integer validator
|
# Integer validator
|
||||||
if validator == int:
|
if self.is_int():
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Coerce into an integer value
|
# Coerce into an integer value
|
||||||
self.value = str(int(self.value))
|
value = int(value)
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
raise ValidationError({
|
raise ValidationError({
|
||||||
'value': _('Value must be an integer value'),
|
'value': _('Value must be an integer value'),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
# If a list of validators is supplied, iterate through each one
|
||||||
|
if type(validator) in [list, tuple]:
|
||||||
|
for v in validator:
|
||||||
|
self.run_validator(v)
|
||||||
|
|
||||||
|
if callable(validator):
|
||||||
|
# We can accept function validators with a single argument
|
||||||
|
validator(self.value)
|
||||||
|
|
||||||
def validate_unique(self, exclude=None):
|
def validate_unique(self, exclude=None):
|
||||||
""" Ensure that the key:value pair is unique.
|
""" Ensure that the key:value pair is unique.
|
||||||
In addition to the base validators, this ensures that the 'key'
|
In addition to the base validators, this ensures that the 'key'
|
||||||
@ -597,7 +617,13 @@ class InvenTreeSetting(models.Model):
|
|||||||
|
|
||||||
validator = InvenTreeSetting.get_setting_validator(self.key)
|
validator = InvenTreeSetting.get_setting_validator(self.key)
|
||||||
|
|
||||||
return validator == bool
|
if validator == bool:
|
||||||
|
return True
|
||||||
|
|
||||||
|
if type(validator) in [list, tuple]:
|
||||||
|
for v in validator:
|
||||||
|
if v == bool:
|
||||||
|
return True
|
||||||
|
|
||||||
def as_bool(self):
|
def as_bool(self):
|
||||||
"""
|
"""
|
||||||
@ -623,6 +649,8 @@ class InvenTreeSetting(models.Model):
|
|||||||
if v == int:
|
if v == int:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
def as_int(self):
|
def as_int(self):
|
||||||
"""
|
"""
|
||||||
Return the value of this setting converted to a boolean value.
|
Return the value of this setting converted to a boolean value.
|
||||||
|
@ -17,6 +17,15 @@ src="{% static 'img/blank_image.png' %}"
|
|||||||
|
|
||||||
{% block page_data %}
|
{% block page_data %}
|
||||||
<h3>{% trans "Supplier Part" %}</h3>
|
<h3>{% trans "Supplier Part" %}</h3>
|
||||||
|
<hr>
|
||||||
|
<h4>
|
||||||
|
{{ part.part.full_name }}
|
||||||
|
{% if user.is_staff and perms.company.change_company %}
|
||||||
|
<a href="{% url 'admin:company_supplierpart_change' part.pk %}">
|
||||||
|
<span title='{% trans "Admin view" %}' class='fas fa-user-shield'></span>
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</h4>
|
||||||
<p>{{ part.supplier.name }} - {{ part.SKU }}</p>
|
<p>{{ part.supplier.name }} - {{ part.SKU }}</p>
|
||||||
|
|
||||||
{% if roles.purchase_order.change %}
|
{% if roles.purchase_order.change %}
|
||||||
|
@ -1,11 +1,10 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
from __future__ import unicode_literals
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
import sys
|
|
||||||
|
|
||||||
from django.utils.translation import ugettext as _
|
from django.utils.translation import ugettext as _
|
||||||
from django.conf.urls import url, include
|
from django.conf.urls import url, include
|
||||||
from django.core.exceptions import ValidationError, FieldError
|
from django.core.exceptions import ValidationError, FieldError
|
||||||
|
from django.http import HttpResponse
|
||||||
|
|
||||||
from django_filters.rest_framework import DjangoFilterBackend
|
from django_filters.rest_framework import DjangoFilterBackend
|
||||||
|
|
||||||
@ -13,6 +12,7 @@ from rest_framework import generics, filters
|
|||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
|
|
||||||
import InvenTree.helpers
|
import InvenTree.helpers
|
||||||
|
import common.models
|
||||||
|
|
||||||
from stock.models import StockItem, StockLocation
|
from stock.models import StockItem, StockLocation
|
||||||
|
|
||||||
@ -40,6 +40,74 @@ class LabelListView(generics.ListAPIView):
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class LabelPrintMixin:
|
||||||
|
"""
|
||||||
|
Mixin for printing labels
|
||||||
|
"""
|
||||||
|
|
||||||
|
def print(self, request, items_to_print):
|
||||||
|
"""
|
||||||
|
Print this label template against a number of pre-validated items
|
||||||
|
"""
|
||||||
|
|
||||||
|
if len(items_to_print) == 0:
|
||||||
|
# No valid items provided, return an error message
|
||||||
|
data = {
|
||||||
|
'error': _('No valid objects provided to template'),
|
||||||
|
}
|
||||||
|
|
||||||
|
return Response(data, status=400)
|
||||||
|
|
||||||
|
outputs = []
|
||||||
|
|
||||||
|
# In debug mode, generate single HTML output, rather than PDF
|
||||||
|
debug_mode = common.models.InvenTreeSetting.get_setting('REPORT_DEBUG_MODE')
|
||||||
|
|
||||||
|
# Merge one or more PDF files into a single download
|
||||||
|
for item in items_to_print:
|
||||||
|
label = self.get_object()
|
||||||
|
label.object_to_print = item
|
||||||
|
|
||||||
|
if debug_mode:
|
||||||
|
outputs.append(label.render_as_string(request))
|
||||||
|
else:
|
||||||
|
outputs.append(label.render(request))
|
||||||
|
|
||||||
|
if debug_mode:
|
||||||
|
"""
|
||||||
|
Contatenate all rendered templates into a single HTML string,
|
||||||
|
and return the string as a HTML response.
|
||||||
|
"""
|
||||||
|
|
||||||
|
html = "\n".join(outputs)
|
||||||
|
|
||||||
|
return HttpResponse(html)
|
||||||
|
else:
|
||||||
|
"""
|
||||||
|
Concatenate all rendered pages into a single PDF object,
|
||||||
|
and return the resulting document!
|
||||||
|
"""
|
||||||
|
|
||||||
|
pages = []
|
||||||
|
|
||||||
|
if len(outputs) > 1:
|
||||||
|
# If more than one output is generated, merge them into a single file
|
||||||
|
for output in outputs:
|
||||||
|
doc = output.get_document()
|
||||||
|
for page in doc.pages:
|
||||||
|
pages.append(page)
|
||||||
|
|
||||||
|
pdf = outputs[0].get_document().copy(pages).write_pdf()
|
||||||
|
else:
|
||||||
|
pdf = outputs[0].get_document().write_pdf()
|
||||||
|
|
||||||
|
return InvenTree.helpers.DownloadFile(
|
||||||
|
pdf,
|
||||||
|
'inventree_label.pdf',
|
||||||
|
content_type='application/pdf'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class StockItemLabelMixin:
|
class StockItemLabelMixin:
|
||||||
"""
|
"""
|
||||||
Mixin for extracting stock items from query params
|
Mixin for extracting stock items from query params
|
||||||
@ -158,7 +226,7 @@ class StockItemLabelDetail(generics.RetrieveUpdateDestroyAPIView):
|
|||||||
serializer_class = StockItemLabelSerializer
|
serializer_class = StockItemLabelSerializer
|
||||||
|
|
||||||
|
|
||||||
class StockItemLabelPrint(generics.RetrieveAPIView, StockItemLabelMixin):
|
class StockItemLabelPrint(generics.RetrieveAPIView, StockItemLabelMixin, LabelPrintMixin):
|
||||||
"""
|
"""
|
||||||
API endpoint for printing a StockItemLabel object
|
API endpoint for printing a StockItemLabel object
|
||||||
"""
|
"""
|
||||||
@ -173,34 +241,7 @@ class StockItemLabelPrint(generics.RetrieveAPIView, StockItemLabelMixin):
|
|||||||
|
|
||||||
items = self.get_items()
|
items = self.get_items()
|
||||||
|
|
||||||
if len(items) == 0:
|
return self.print(request, items)
|
||||||
# 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:
|
class StockLocationLabelMixin:
|
||||||
@ -320,7 +361,7 @@ class StockLocationLabelDetail(generics.RetrieveUpdateDestroyAPIView):
|
|||||||
serializer_class = StockLocationLabelSerializer
|
serializer_class = StockLocationLabelSerializer
|
||||||
|
|
||||||
|
|
||||||
class StockLocationLabelPrint(generics.RetrieveAPIView, StockLocationLabelMixin):
|
class StockLocationLabelPrint(generics.RetrieveAPIView, StockLocationLabelMixin, LabelPrintMixin):
|
||||||
"""
|
"""
|
||||||
API endpoint for printing a StockLocationLabel object
|
API endpoint for printing a StockLocationLabel object
|
||||||
"""
|
"""
|
||||||
@ -332,35 +373,7 @@ class StockLocationLabelPrint(generics.RetrieveAPIView, StockLocationLabelMixin)
|
|||||||
|
|
||||||
locations = self.get_locations()
|
locations = self.get_locations()
|
||||||
|
|
||||||
if len(locations) == 0:
|
return self.print(request, locations)
|
||||||
# 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 = [
|
label_api_urls = [
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
import logging
|
import logging
|
||||||
|
import hashlib
|
||||||
|
|
||||||
from django.apps import AppConfig
|
from django.apps import AppConfig
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
@ -9,6 +10,20 @@ from django.conf import settings
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def hashFile(filename):
|
||||||
|
"""
|
||||||
|
Calculate the MD5 hash of a file
|
||||||
|
"""
|
||||||
|
|
||||||
|
md5 = hashlib.md5()
|
||||||
|
|
||||||
|
with open(filename, 'rb') as f:
|
||||||
|
data = f.read()
|
||||||
|
md5.update(data)
|
||||||
|
|
||||||
|
return md5.hexdigest()
|
||||||
|
|
||||||
|
|
||||||
class LabelConfig(AppConfig):
|
class LabelConfig(AppConfig):
|
||||||
name = 'label'
|
name = 'label'
|
||||||
|
|
||||||
@ -35,6 +50,7 @@ class LabelConfig(AppConfig):
|
|||||||
src_dir = os.path.join(
|
src_dir = os.path.join(
|
||||||
os.path.dirname(os.path.realpath(__file__)),
|
os.path.dirname(os.path.realpath(__file__)),
|
||||||
'templates',
|
'templates',
|
||||||
|
'label',
|
||||||
'stockitem',
|
'stockitem',
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -54,6 +70,8 @@ class LabelConfig(AppConfig):
|
|||||||
'file': 'qr.html',
|
'file': 'qr.html',
|
||||||
'name': 'QR Code',
|
'name': 'QR Code',
|
||||||
'description': 'Simple QR code label',
|
'description': 'Simple QR code label',
|
||||||
|
'width': 24,
|
||||||
|
'height': 24,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
@ -70,7 +88,21 @@ class LabelConfig(AppConfig):
|
|||||||
src_file = os.path.join(src_dir, label['file'])
|
src_file = os.path.join(src_dir, label['file'])
|
||||||
dst_file = os.path.join(settings.MEDIA_ROOT, filename)
|
dst_file = os.path.join(settings.MEDIA_ROOT, filename)
|
||||||
|
|
||||||
if not os.path.exists(dst_file):
|
to_copy = False
|
||||||
|
|
||||||
|
if os.path.exists(dst_file):
|
||||||
|
# File already exists - let's see if it is the "same",
|
||||||
|
# or if we need to overwrite it with a newer copy!
|
||||||
|
|
||||||
|
if not hashFile(dst_file) == hashFile(src_file):
|
||||||
|
logger.info(f"Hash differs for '{filename}'")
|
||||||
|
to_copy = True
|
||||||
|
|
||||||
|
else:
|
||||||
|
logger.info(f"Label template '{filename}' is not present")
|
||||||
|
to_copy = True
|
||||||
|
|
||||||
|
if to_copy:
|
||||||
logger.info(f"Copying label template '{dst_file}'")
|
logger.info(f"Copying label template '{dst_file}'")
|
||||||
shutil.copyfile(src_file, dst_file)
|
shutil.copyfile(src_file, dst_file)
|
||||||
|
|
||||||
@ -86,7 +118,9 @@ class LabelConfig(AppConfig):
|
|||||||
description=label['description'],
|
description=label['description'],
|
||||||
label=filename,
|
label=filename,
|
||||||
filters='',
|
filters='',
|
||||||
enabled=True
|
enabled=True,
|
||||||
|
width=label['width'],
|
||||||
|
height=label['height'],
|
||||||
)
|
)
|
||||||
except:
|
except:
|
||||||
pass
|
pass
|
||||||
@ -106,6 +140,7 @@ class LabelConfig(AppConfig):
|
|||||||
src_dir = os.path.join(
|
src_dir = os.path.join(
|
||||||
os.path.dirname(os.path.realpath(__file__)),
|
os.path.dirname(os.path.realpath(__file__)),
|
||||||
'templates',
|
'templates',
|
||||||
|
'label',
|
||||||
'stocklocation',
|
'stocklocation',
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -125,11 +160,15 @@ class LabelConfig(AppConfig):
|
|||||||
'file': 'qr.html',
|
'file': 'qr.html',
|
||||||
'name': 'QR Code',
|
'name': 'QR Code',
|
||||||
'description': 'Simple QR code label',
|
'description': 'Simple QR code label',
|
||||||
|
'width': 24,
|
||||||
|
'height': 24,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
'file': 'qr_and_text.html',
|
'file': 'qr_and_text.html',
|
||||||
'name': 'QR and text',
|
'name': 'QR and text',
|
||||||
'description': 'Label with QR code and name of location',
|
'description': 'Label with QR code and name of location',
|
||||||
|
'width': 50,
|
||||||
|
'height': 24,
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
@ -146,7 +185,21 @@ class LabelConfig(AppConfig):
|
|||||||
src_file = os.path.join(src_dir, label['file'])
|
src_file = os.path.join(src_dir, label['file'])
|
||||||
dst_file = os.path.join(settings.MEDIA_ROOT, filename)
|
dst_file = os.path.join(settings.MEDIA_ROOT, filename)
|
||||||
|
|
||||||
if not os.path.exists(dst_file):
|
to_copy = False
|
||||||
|
|
||||||
|
if os.path.exists(dst_file):
|
||||||
|
# File already exists - let's see if it is the "same",
|
||||||
|
# or if we need to overwrite it with a newer copy!
|
||||||
|
|
||||||
|
if not hashFile(dst_file) == hashFile(src_file):
|
||||||
|
logger.info(f"Hash differs for '{filename}'")
|
||||||
|
to_copy = True
|
||||||
|
|
||||||
|
else:
|
||||||
|
logger.info(f"Label template '{filename}' is not present")
|
||||||
|
to_copy = True
|
||||||
|
|
||||||
|
if to_copy:
|
||||||
logger.info(f"Copying label template '{dst_file}'")
|
logger.info(f"Copying label template '{dst_file}'")
|
||||||
shutil.copyfile(src_file, dst_file)
|
shutil.copyfile(src_file, dst_file)
|
||||||
|
|
||||||
@ -162,7 +215,9 @@ class LabelConfig(AppConfig):
|
|||||||
description=label['description'],
|
description=label['description'],
|
||||||
label=filename,
|
label=filename,
|
||||||
filters='',
|
filters='',
|
||||||
enabled=True
|
enabled=True,
|
||||||
|
width=label['width'],
|
||||||
|
height=label['height'],
|
||||||
)
|
)
|
||||||
except:
|
except:
|
||||||
pass
|
pass
|
||||||
|
34
InvenTree/label/migrations/0006_auto_20210222_1535.py
Normal file
34
InvenTree/label/migrations/0006_auto_20210222_1535.py
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
# Generated by Django 3.0.7 on 2021-02-22 04:35
|
||||||
|
|
||||||
|
import django.core.validators
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('label', '0005_auto_20210113_2302'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='stockitemlabel',
|
||||||
|
name='height',
|
||||||
|
field=models.FloatField(default=20, help_text='Label height, specified in mm', validators=[django.core.validators.MinValueValidator(2)], verbose_name='Height [mm]'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='stockitemlabel',
|
||||||
|
name='width',
|
||||||
|
field=models.FloatField(default=50, help_text='Label width, specified in mm', validators=[django.core.validators.MinValueValidator(2)], verbose_name='Width [mm]'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='stocklocationlabel',
|
||||||
|
name='height',
|
||||||
|
field=models.FloatField(default=20, help_text='Label height, specified in mm', validators=[django.core.validators.MinValueValidator(2)], verbose_name='Height [mm]'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='stocklocationlabel',
|
||||||
|
name='width',
|
||||||
|
field=models.FloatField(default=50, help_text='Label width, specified in mm', validators=[django.core.validators.MinValueValidator(2)], verbose_name='Width [mm]'),
|
||||||
|
),
|
||||||
|
]
|
@ -5,21 +5,35 @@ Label printing models
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
from __future__ import unicode_literals
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
|
import sys
|
||||||
import os
|
import os
|
||||||
import io
|
import logging
|
||||||
|
import datetime
|
||||||
from blabel import LabelWriter
|
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.core.validators import FileExtensionValidator
|
from django.core.validators import FileExtensionValidator, MinValueValidator
|
||||||
from django.core.exceptions import ValidationError, FieldError
|
from django.core.exceptions import ValidationError, FieldError
|
||||||
|
|
||||||
|
from django.template.loader import render_to_string
|
||||||
|
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
from InvenTree.helpers import validateFilterString, normalize
|
from InvenTree.helpers import validateFilterString, normalize
|
||||||
|
|
||||||
|
import common.models
|
||||||
import stock.models
|
import stock.models
|
||||||
|
|
||||||
|
try:
|
||||||
|
from django_weasyprint import WeasyTemplateResponseMixin
|
||||||
|
except OSError as err:
|
||||||
|
print("OSError: {e}".format(e=err))
|
||||||
|
print("You may require some further system packages to be installed.")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def rename_label(instance, filename):
|
def rename_label(instance, filename):
|
||||||
""" Place the label file into the correct subdirectory """
|
""" Place the label file into the correct subdirectory """
|
||||||
@ -43,6 +57,21 @@ def validate_stock_location_filters(filters):
|
|||||||
return filters
|
return filters
|
||||||
|
|
||||||
|
|
||||||
|
class WeasyprintLabelMixin(WeasyTemplateResponseMixin):
|
||||||
|
"""
|
||||||
|
Class for rendering a label to a PDF
|
||||||
|
"""
|
||||||
|
|
||||||
|
pdf_filename = 'label.pdf'
|
||||||
|
pdf_attachment = True
|
||||||
|
|
||||||
|
def __init__(self, request, template, **kwargs):
|
||||||
|
|
||||||
|
self.request = request
|
||||||
|
self.template_name = template
|
||||||
|
self.pdf_filename = kwargs.get('filename', 'label.pdf')
|
||||||
|
|
||||||
|
|
||||||
class LabelTemplate(models.Model):
|
class LabelTemplate(models.Model):
|
||||||
"""
|
"""
|
||||||
Base class for generic, filterable labels.
|
Base class for generic, filterable labels.
|
||||||
@ -53,6 +82,9 @@ class LabelTemplate(models.Model):
|
|||||||
|
|
||||||
# Each class of label files will be stored in a separate subdirectory
|
# Each class of label files will be stored in a separate subdirectory
|
||||||
SUBDIR = "label"
|
SUBDIR = "label"
|
||||||
|
|
||||||
|
# Object we will be printing against (will be filled out later)
|
||||||
|
object_to_print = None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def template(self):
|
def template(self):
|
||||||
@ -92,38 +124,90 @@ class LabelTemplate(models.Model):
|
|||||||
help_text=_('Label template is enabled'),
|
help_text=_('Label template is enabled'),
|
||||||
)
|
)
|
||||||
|
|
||||||
def get_record_data(self, items):
|
width = models.FloatField(
|
||||||
|
default=50,
|
||||||
|
verbose_name=('Width [mm]'),
|
||||||
|
help_text=_('Label width, specified in mm'),
|
||||||
|
validators=[MinValueValidator(2)]
|
||||||
|
)
|
||||||
|
|
||||||
|
height = models.FloatField(
|
||||||
|
default=20,
|
||||||
|
verbose_name=_('Height [mm]'),
|
||||||
|
help_text=_('Label height, specified in mm'),
|
||||||
|
validators=[MinValueValidator(2)]
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def template_name(self):
|
||||||
"""
|
"""
|
||||||
Return a list of dict objects, one for each item.
|
Returns the file system path to the template file.
|
||||||
|
Required for passing the file to an external process
|
||||||
"""
|
"""
|
||||||
|
|
||||||
return []
|
template = self.label.name
|
||||||
|
template = template.replace('/', os.path.sep)
|
||||||
|
template = template.replace('\\', os.path.sep)
|
||||||
|
|
||||||
def render_to_file(self, filename, items, **kwargs):
|
template = os.path.join(settings.MEDIA_ROOT, template)
|
||||||
|
|
||||||
|
return template
|
||||||
|
|
||||||
|
def get_context_data(self, request):
|
||||||
"""
|
"""
|
||||||
Render labels to a PDF file
|
Supply custom context data to the template for rendering.
|
||||||
|
|
||||||
|
Note: Override this in any subclass
|
||||||
"""
|
"""
|
||||||
|
|
||||||
records = self.get_record_data(items)
|
return {}
|
||||||
|
|
||||||
writer = LabelWriter(self.template)
|
def context(self, request):
|
||||||
|
|
||||||
writer.write_labels(records, filename)
|
|
||||||
|
|
||||||
def render(self, items, **kwargs):
|
|
||||||
"""
|
"""
|
||||||
Render labels to an in-memory PDF object, and return it
|
Provides context data to the template.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
records = self.get_record_data(items)
|
context = self.get_context_data(request)
|
||||||
|
|
||||||
writer = LabelWriter(self.template)
|
# Add "basic" context data which gets passed to every label
|
||||||
|
context['base_url'] = common.models.InvenTreeSetting.get_setting('INVENTREE_BASE_URL')
|
||||||
|
context['date'] = datetime.datetime.now().date()
|
||||||
|
context['datetime'] = datetime.datetime.now()
|
||||||
|
context['request'] = request
|
||||||
|
context['user'] = request.user
|
||||||
|
context['width'] = self.width
|
||||||
|
context['height'] = self.height
|
||||||
|
|
||||||
buffer = io.BytesIO()
|
return context
|
||||||
|
|
||||||
writer.write_labels(records, buffer)
|
def render_as_string(self, request, **kwargs):
|
||||||
|
"""
|
||||||
|
Render the label to a HTML string
|
||||||
|
|
||||||
return buffer
|
Useful for debug mode (viewing generated code)
|
||||||
|
"""
|
||||||
|
|
||||||
|
return render_to_string(self.template_name, self.context(request), request)
|
||||||
|
|
||||||
|
def render(self, request, **kwargs):
|
||||||
|
"""
|
||||||
|
Render the label template to a PDF file
|
||||||
|
|
||||||
|
Uses django-weasyprint plugin to render HTML template
|
||||||
|
"""
|
||||||
|
|
||||||
|
wp = WeasyprintLabelMixin(
|
||||||
|
request,
|
||||||
|
self.template_name,
|
||||||
|
base_url=request.build_absolute_uri("/"),
|
||||||
|
presentational_hints=True,
|
||||||
|
**kwargs
|
||||||
|
)
|
||||||
|
|
||||||
|
return wp.render_to_response(
|
||||||
|
self.context(request),
|
||||||
|
**kwargs
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class StockItemLabel(LabelTemplate):
|
class StockItemLabel(LabelTemplate):
|
||||||
@ -157,29 +241,24 @@ class StockItemLabel(LabelTemplate):
|
|||||||
|
|
||||||
return items.exists()
|
return items.exists()
|
||||||
|
|
||||||
def get_record_data(self, items):
|
def get_context_data(self, request):
|
||||||
"""
|
"""
|
||||||
Generate context data for each provided StockItem
|
Generate context data for each provided StockItem
|
||||||
"""
|
"""
|
||||||
records = []
|
|
||||||
|
|
||||||
for item in items:
|
|
||||||
|
|
||||||
# Add some basic information
|
stock_item = self.object_to_print
|
||||||
records.append({
|
|
||||||
'item': item,
|
|
||||||
'part': item.part,
|
|
||||||
'name': item.part.name,
|
|
||||||
'ipn': item.part.IPN,
|
|
||||||
'quantity': normalize(item.quantity),
|
|
||||||
'serial': item.serial,
|
|
||||||
'uid': item.uid,
|
|
||||||
'pk': item.pk,
|
|
||||||
'qr_data': item.format_barcode(brief=True),
|
|
||||||
'tests': item.testResultMap()
|
|
||||||
})
|
|
||||||
|
|
||||||
return records
|
return {
|
||||||
|
'item': stock_item,
|
||||||
|
'part': stock_item.part,
|
||||||
|
'name': stock_item.part.full_name,
|
||||||
|
'ipn': stock_item.part.IPN,
|
||||||
|
'quantity': normalize(stock_item.quantity),
|
||||||
|
'serial': stock_item.serial,
|
||||||
|
'uid': stock_item.uid,
|
||||||
|
'qr_data': stock_item.format_barcode(brief=True),
|
||||||
|
'tests': stock_item.testResultMap()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class StockLocationLabel(LabelTemplate):
|
class StockLocationLabel(LabelTemplate):
|
||||||
@ -212,17 +291,14 @@ class StockLocationLabel(LabelTemplate):
|
|||||||
|
|
||||||
return locs.exists()
|
return locs.exists()
|
||||||
|
|
||||||
def get_record_data(self, locations):
|
def get_context_data(self, request):
|
||||||
"""
|
"""
|
||||||
Generate context data for each provided StockLocation
|
Generate context data for each provided StockLocation
|
||||||
"""
|
"""
|
||||||
|
|
||||||
records = []
|
location = self.object_to_print
|
||||||
|
|
||||||
for loc in locations:
|
|
||||||
|
|
||||||
records.append({
|
return {
|
||||||
'location': loc,
|
'location': location,
|
||||||
})
|
'qr_data': location.format_barcode(brief=True),
|
||||||
|
}
|
||||||
return records
|
|
||||||
|
28
InvenTree/label/templates/label/label_base.html
Normal file
28
InvenTree/label/templates/label/label_base.html
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
{% load report %}
|
||||||
|
{% load barcode %}
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<style>
|
||||||
|
@page {
|
||||||
|
size: {{ width }}mm {{ height }}mm;
|
||||||
|
{% block margin %}
|
||||||
|
margin: 0mm;
|
||||||
|
{% endblock %}
|
||||||
|
}
|
||||||
|
|
||||||
|
img {
|
||||||
|
display: inline-block;
|
||||||
|
image-rendering: pixelated;
|
||||||
|
}
|
||||||
|
|
||||||
|
{% block style %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
{% block content %}
|
||||||
|
<!-- Label data rendered here! -->
|
||||||
|
{% endblock %}
|
||||||
|
</body>
|
20
InvenTree/label/templates/label/stockitem/qr.html
Normal file
20
InvenTree/label/templates/label/stockitem/qr.html
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
{% extends "label/label_base.html" %}
|
||||||
|
|
||||||
|
{% load barcode %}
|
||||||
|
|
||||||
|
{% block style %}
|
||||||
|
|
||||||
|
.qr {
|
||||||
|
position: fixed;
|
||||||
|
left: 0mm;
|
||||||
|
top: 0mm;
|
||||||
|
height: {{ height }}mm;
|
||||||
|
width: {{ height }}mm;
|
||||||
|
}
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<img class='qr' src='{% qrcode qr_data %}'>
|
||||||
|
{% endblock %}
|
20
InvenTree/label/templates/label/stocklocation/qr.html
Normal file
20
InvenTree/label/templates/label/stocklocation/qr.html
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
{% extends "label/label_base.html" %}
|
||||||
|
|
||||||
|
{% load barcode %}
|
||||||
|
|
||||||
|
{% block style %}
|
||||||
|
|
||||||
|
.qr {
|
||||||
|
position: fixed;
|
||||||
|
left: 0mm;
|
||||||
|
top: 0mm;
|
||||||
|
height: {{ height }}mm;
|
||||||
|
width: {{ height }}mm;
|
||||||
|
}
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<img class='qr' src='{% qrcode qr_data %}'>
|
||||||
|
{% endblock %}
|
@ -0,0 +1,33 @@
|
|||||||
|
{% extends "label/label_base.html" %}
|
||||||
|
|
||||||
|
{% load barcode %}
|
||||||
|
|
||||||
|
{% block style %}
|
||||||
|
|
||||||
|
.qr {
|
||||||
|
position: fixed;
|
||||||
|
left: 0mm;
|
||||||
|
top: 0mm;
|
||||||
|
height: {{ height }}mm;
|
||||||
|
width: {{ height }}mm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loc {
|
||||||
|
font-family: Arial, Helvetica, sans-serif;
|
||||||
|
display: inline;
|
||||||
|
position: absolute;
|
||||||
|
left: {{ height }}mm;
|
||||||
|
top: 2mm;
|
||||||
|
}
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<img class='qr' src='{% qrcode qr_data %}'>
|
||||||
|
|
||||||
|
<div class='loc'>
|
||||||
|
{{ location.name }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% endblock %}
|
@ -1,16 +0,0 @@
|
|||||||
<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) }}"/>
|
|
@ -1,16 +0,0 @@
|
|||||||
<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) }}"/>
|
|
@ -1,43 +0,0 @@
|
|||||||
<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>
|
|
||||||
|
|
@ -626,7 +626,7 @@ class PartList(generics.ListCreateAPIView):
|
|||||||
|
|
||||||
queryset = queryset.filter(pk__in=parts_need_stock)
|
queryset = queryset.filter(pk__in=parts_need_stock)
|
||||||
|
|
||||||
# Limit choices
|
# Limit number of results
|
||||||
limit = params.get('limit', None)
|
limit = params.get('limit', None)
|
||||||
|
|
||||||
if limit is not None:
|
if limit is not None:
|
||||||
|
@ -245,6 +245,14 @@ class PartSerializer(InvenTreeModelSerializer):
|
|||||||
Decimal(0),
|
Decimal(0),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Annotate with the number of 'suppliers'
|
||||||
|
queryset = queryset.annotate(
|
||||||
|
suppliers=Coalesce(
|
||||||
|
SubqueryCount('supplier_parts'),
|
||||||
|
Decimal(0),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
return queryset
|
return queryset
|
||||||
|
|
||||||
@ -263,6 +271,7 @@ class PartSerializer(InvenTreeModelSerializer):
|
|||||||
ordering = serializers.FloatField(read_only=True)
|
ordering = serializers.FloatField(read_only=True)
|
||||||
building = serializers.FloatField(read_only=True)
|
building = serializers.FloatField(read_only=True)
|
||||||
stock_item_count = serializers.IntegerField(read_only=True)
|
stock_item_count = serializers.IntegerField(read_only=True)
|
||||||
|
suppliers = serializers.IntegerField(read_only=True)
|
||||||
|
|
||||||
image = serializers.CharField(source='get_image_url', read_only=True)
|
image = serializers.CharField(source='get_image_url', read_only=True)
|
||||||
thumbnail = serializers.CharField(source='get_thumbnail_url', read_only=True)
|
thumbnail = serializers.CharField(source='get_thumbnail_url', read_only=True)
|
||||||
@ -308,6 +317,7 @@ class PartSerializer(InvenTreeModelSerializer):
|
|||||||
'salable',
|
'salable',
|
||||||
'starred',
|
'starred',
|
||||||
'stock_item_count',
|
'stock_item_count',
|
||||||
|
'suppliers',
|
||||||
'thumbnail',
|
'thumbnail',
|
||||||
'trackable',
|
'trackable',
|
||||||
'units',
|
'units',
|
||||||
|
@ -9,7 +9,7 @@
|
|||||||
<hr>
|
<hr>
|
||||||
|
|
||||||
<div id='button-toolbar'>
|
<div id='button-toolbar'>
|
||||||
<div class='button-toolbar container-flui' style='float: right';>
|
<div class='button-toolbar container-fluid' style='float: right';>
|
||||||
{% if part.active %}
|
{% if part.active %}
|
||||||
{% if roles.build.add %}
|
{% if roles.build.add %}
|
||||||
<button class="btn btn-success" id='start-build'><span class='fas fa-tools'></span> {% trans "Start New Build" %}</button>
|
<button class="btn btn-success" id='start-build'><span class='fas fa-tools'></span> {% trans "Start New Build" %}</button>
|
||||||
|
@ -13,11 +13,9 @@
|
|||||||
<a href="{% url 'part-variants' part.id %}">{% trans "Variants" %} <span class='badge'>{{ part.variants.count }}</span></span></a>
|
<a href="{% url 'part-variants' part.id %}">{% trans "Variants" %} <span class='badge'>{{ part.variants.count }}</span></span></a>
|
||||||
</li>
|
</li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if not part.virtual %}
|
|
||||||
<li{% ifequal tab 'stock' %} class="active"{% endifequal %}>
|
<li{% ifequal tab 'stock' %} class="active"{% endifequal %}>
|
||||||
<a href="{% url 'part-stock' part.id %}">{% trans "Stock" %} <span class="badge">{% decimal total_stock %}</span></a>
|
<a href="{% url 'part-stock' part.id %}">{% trans "Stock" %} <span class="badge">{% decimal total_stock %}</span></a>
|
||||||
</li>
|
</li>
|
||||||
{% endif %}
|
|
||||||
{% if part.component or part.salable or part.used_in_count > 0 %}
|
{% if part.component or part.salable or part.used_in_count > 0 %}
|
||||||
<li{% ifequal tab 'allocation' %} class="active"{% endifequal %}>
|
<li{% ifequal tab 'allocation' %} class="active"{% endifequal %}>
|
||||||
<a href="{% url 'part-allocation' part.id %}">{% trans "Allocated" %} <span class="badge">{% decimal allocated %}</span></a>
|
<a href="{% url 'part-allocation' part.id %}">{% trans "Allocated" %} <span class="badge">{% decimal allocated %}</span></a>
|
||||||
|
@ -22,12 +22,13 @@
|
|||||||
{% block js_ready %}
|
{% block js_ready %}
|
||||||
{{ block.super }}
|
{{ block.super }}
|
||||||
|
|
||||||
loadSimplePartTable('#used-table',
|
loadPartTable('#used-table',
|
||||||
'{% url "api-part-list" %}',
|
'{% url "api-part-list" %}',
|
||||||
{
|
{
|
||||||
params: {
|
params: {
|
||||||
uses: {{ part.pk }},
|
uses: {{ part.pk }},
|
||||||
}
|
},
|
||||||
|
filterTarget: '#filter-list-usedin',
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -35,18 +35,6 @@
|
|||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block js_load %}
|
|
||||||
{{ block.super }}
|
|
||||||
|
|
||||||
<!-- jquery-treegrid -->
|
|
||||||
<script type='text/javascript' src='{% static "treegrid/js/jquery.treegrid.js" %}'></script>
|
|
||||||
<script type='text/javascript' src='{% static "treegrid/js/jquery.treegrid.bootstrap3.js" %}'></script>
|
|
||||||
|
|
||||||
<!-- boostrap-table-treegrid -->
|
|
||||||
<script type='text/javascript' src='{% static "bootstrap-table/extensions/treegrid/bootstrap-table-treegrid.js" %}'></script>
|
|
||||||
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block js_ready %}
|
{% block js_ready %}
|
||||||
{{ block.super }}
|
{{ block.super }}
|
||||||
|
|
||||||
|
@ -270,14 +270,12 @@ class PartQRTest(PartViewTestCase):
|
|||||||
data = str(response.content)
|
data = str(response.content)
|
||||||
|
|
||||||
self.assertIn('Part QR Code', data)
|
self.assertIn('Part QR Code', data)
|
||||||
self.assertIn('<img class=', data)
|
self.assertIn('<img src=', data)
|
||||||
|
|
||||||
def test_invalid_part(self):
|
def test_invalid_part(self):
|
||||||
response = self.client.get(reverse('part-qr', args=(9999,)), HTTP_X_REQUESTED_WITH='XMLHttpRequest')
|
response = self.client.get(reverse('part-qr', args=(9999,)), HTTP_X_REQUESTED_WITH='XMLHttpRequest')
|
||||||
|
|
||||||
data = str(response.content)
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
self.assertIn('Error:', data)
|
|
||||||
|
|
||||||
|
|
||||||
class CategoryTest(PartViewTestCase):
|
class CategoryTest(PartViewTestCase):
|
||||||
|
@ -164,7 +164,7 @@ class ReportPrintMixin:
|
|||||||
report.object_to_print = item
|
report.object_to_print = item
|
||||||
|
|
||||||
if debug_mode:
|
if debug_mode:
|
||||||
outputs.append(report.render_to_string(request))
|
outputs.append(report.render_as_string(request))
|
||||||
else:
|
else:
|
||||||
outputs.append(report.render(request))
|
outputs.append(report.render(request))
|
||||||
|
|
||||||
|
@ -221,7 +221,7 @@ class ReportTemplateBase(ReportBase):
|
|||||||
|
|
||||||
return context
|
return context
|
||||||
|
|
||||||
def render_to_string(self, request, **kwargs):
|
def render_as_string(self, request, **kwargs):
|
||||||
"""
|
"""
|
||||||
Render the report to a HTML stiring.
|
Render the report to a HTML stiring.
|
||||||
|
|
||||||
|
@ -2,9 +2,9 @@
|
|||||||
|
|
||||||
{% load i18n %}
|
{% load i18n %}
|
||||||
{% load report %}
|
{% load report %}
|
||||||
|
{% load barcode %}
|
||||||
{% load inventree_extras %}
|
{% load inventree_extras %}
|
||||||
{% load markdownify %}
|
{% load markdownify %}
|
||||||
{% load qr_code %}
|
|
||||||
|
|
||||||
{% block page_margin %}
|
{% block page_margin %}
|
||||||
margin: 2cm;
|
margin: 2cm;
|
||||||
|
81
InvenTree/report/templatetags/barcode.py
Normal file
81
InvenTree/report/templatetags/barcode.py
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
"""
|
||||||
|
Template tags for rendering various barcodes
|
||||||
|
"""
|
||||||
|
|
||||||
|
import base64
|
||||||
|
from io import BytesIO
|
||||||
|
|
||||||
|
from django import template
|
||||||
|
|
||||||
|
import qrcode as python_qrcode
|
||||||
|
import barcode as python_barcode
|
||||||
|
|
||||||
|
register = template.Library()
|
||||||
|
|
||||||
|
|
||||||
|
def image_data(img, fmt='PNG'):
|
||||||
|
"""
|
||||||
|
Convert an image into HTML renderable data
|
||||||
|
|
||||||
|
Returns a string ``data:image/FMT;base64,xxxxxxxxx`` which can be rendered to an <img> tag
|
||||||
|
"""
|
||||||
|
|
||||||
|
buffered = BytesIO()
|
||||||
|
img.save(buffered, format=fmt)
|
||||||
|
|
||||||
|
img_str = base64.b64encode(buffered.getvalue())
|
||||||
|
|
||||||
|
return f"data:image/{fmt.lower()};charset=utf-8;base64," + img_str.decode()
|
||||||
|
|
||||||
|
|
||||||
|
@register.simple_tag()
|
||||||
|
def qrcode(data, **kwargs):
|
||||||
|
"""
|
||||||
|
Return a byte-encoded QR code image
|
||||||
|
|
||||||
|
Optional kwargs
|
||||||
|
---------------
|
||||||
|
|
||||||
|
fill_color: Fill color (default = black)
|
||||||
|
back_color: Background color (default = white)
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Construct "default" values
|
||||||
|
params = dict(
|
||||||
|
box_size=20,
|
||||||
|
border=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
fill_color = kwargs.pop('fill_color', 'black')
|
||||||
|
back_color = kwargs.pop('back_color', 'white')
|
||||||
|
|
||||||
|
params.update(**kwargs)
|
||||||
|
|
||||||
|
qr = python_qrcode.QRCode(**params)
|
||||||
|
|
||||||
|
qr.add_data(data, optimize=20)
|
||||||
|
qr.make(fit=True)
|
||||||
|
|
||||||
|
qri = qr.make_image(fill_color=fill_color, back_color=back_color)
|
||||||
|
|
||||||
|
return image_data(qri)
|
||||||
|
|
||||||
|
|
||||||
|
@register.simple_tag()
|
||||||
|
def barcode(data, barcode_class='code128', **kwargs):
|
||||||
|
"""
|
||||||
|
Render a barcode
|
||||||
|
"""
|
||||||
|
|
||||||
|
constructor = python_barcode.get_barcode_class(barcode_class)
|
||||||
|
|
||||||
|
data = str(data).zfill(constructor.digits)
|
||||||
|
|
||||||
|
writer = python_barcode.writer.ImageWriter
|
||||||
|
|
||||||
|
barcode_image = constructor(data, writer=writer())
|
||||||
|
|
||||||
|
image = barcode_image.render(writer_options=kwargs)
|
||||||
|
|
||||||
|
# Render to byte-encoded PNG
|
||||||
|
return image_data(image)
|
@ -808,6 +808,19 @@ class StockList(generics.ListCreateAPIView):
|
|||||||
print("After error:", str(updated_after))
|
print("After error:", str(updated_after))
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
# Limit number of results
|
||||||
|
limit = params.get('limit', None)
|
||||||
|
|
||||||
|
if limit is not None:
|
||||||
|
try:
|
||||||
|
limit = int(limit)
|
||||||
|
|
||||||
|
if limit > 0:
|
||||||
|
queryset = queryset[:limit]
|
||||||
|
|
||||||
|
except (ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
# Also ensure that we pre-fecth all the related items
|
# Also ensure that we pre-fecth all the related items
|
||||||
queryset = queryset.prefetch_related(
|
queryset = queryset.prefetch_related(
|
||||||
'part',
|
'part',
|
||||||
@ -815,8 +828,6 @@ class StockList(generics.ListCreateAPIView):
|
|||||||
'location'
|
'location'
|
||||||
)
|
)
|
||||||
|
|
||||||
queryset = queryset.order_by('part__name')
|
|
||||||
|
|
||||||
return queryset
|
return queryset
|
||||||
|
|
||||||
filter_backends = [
|
filter_backends = [
|
||||||
@ -828,6 +839,15 @@ class StockList(generics.ListCreateAPIView):
|
|||||||
filter_fields = [
|
filter_fields = [
|
||||||
]
|
]
|
||||||
|
|
||||||
|
ordering_fields = [
|
||||||
|
'part__name',
|
||||||
|
'updated',
|
||||||
|
'stocktake_date',
|
||||||
|
'expiry_date',
|
||||||
|
]
|
||||||
|
|
||||||
|
ordering = ['part__name']
|
||||||
|
|
||||||
search_fields = [
|
search_fields = [
|
||||||
'serial',
|
'serial',
|
||||||
'batch',
|
'batch',
|
||||||
|
@ -105,9 +105,6 @@ class StockItemTest(StockViewTestCase):
|
|||||||
response = self.client.get(reverse('stock-item-qr', args=(9999,)), HTTP_X_REQUESTED_WITH='XMLHttpRequest')
|
response = self.client.get(reverse('stock-item-qr', args=(9999,)), HTTP_X_REQUESTED_WITH='XMLHttpRequest')
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
data = str(response.content)
|
|
||||||
self.assertIn('Error:', data)
|
|
||||||
|
|
||||||
def test_adjust_items(self):
|
def test_adjust_items(self):
|
||||||
url = reverse('stock-adjust')
|
url = reverse('stock-adjust')
|
||||||
|
|
||||||
|
@ -102,7 +102,7 @@ addHeaderAction('bom-validation', '{% trans "BOM Waiting Validation" %}', 'fa-ti
|
|||||||
loadSimplePartTable("#table-latest-parts", "{% url 'api-part-list' %}", {
|
loadSimplePartTable("#table-latest-parts", "{% url 'api-part-list' %}", {
|
||||||
params: {
|
params: {
|
||||||
ordering: "-creation_date",
|
ordering: "-creation_date",
|
||||||
limit: 10,
|
limit: {% settings_value "PART_RECENT_COUNT" %},
|
||||||
},
|
},
|
||||||
name: 'latest_parts',
|
name: 'latest_parts',
|
||||||
});
|
});
|
||||||
@ -125,8 +125,19 @@ loadSimplePartTable("#table-bom-validation", "{% url 'api-part-list' %}", {
|
|||||||
|
|
||||||
{% if roles.stock.view %}
|
{% if roles.stock.view %}
|
||||||
addHeaderTitle('{% trans "Stock" %}');
|
addHeaderTitle('{% trans "Stock" %}');
|
||||||
|
addHeaderAction('recently-updated-stock', '{% trans "Recently Updated" %}', 'fa-clock');
|
||||||
addHeaderAction('low-stock', '{% trans "Low Stock" %}', 'fa-shopping-cart');
|
addHeaderAction('low-stock', '{% trans "Low Stock" %}', 'fa-shopping-cart');
|
||||||
addHeaderAction('stock-to-build', '{% trans "Required for Build Orders" %}', 'fa-bullhorn');
|
addHeaderAction('stock-to-build', '{% trans "Required for Build Orders" %}', 'fa-bullhorn');
|
||||||
|
|
||||||
|
loadStockTable($('#table-recently-updated-stock'), {
|
||||||
|
params: {
|
||||||
|
ordering: "-updated",
|
||||||
|
limit: {% settings_value "STOCK_RECENT_COUNT" %},
|
||||||
|
},
|
||||||
|
name: 'recently-updated-stock',
|
||||||
|
grouping: false,
|
||||||
|
});
|
||||||
|
|
||||||
{% settings_value "STOCK_ENABLE_EXPIRY" as expiry %}
|
{% settings_value "STOCK_ENABLE_EXPIRY" as expiry %}
|
||||||
{% if expiry %}
|
{% if expiry %}
|
||||||
addHeaderAction('expired-stock', '{% trans "Expired Stock" %}', 'fa-calendar-times');
|
addHeaderAction('expired-stock', '{% trans "Expired Stock" %}', 'fa-calendar-times');
|
||||||
|
@ -19,6 +19,7 @@
|
|||||||
{% include "InvenTree/settings/setting.html" with key="PART_IPN_REGEX" %}
|
{% 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_ALLOW_DUPLICATE_IPN" %}
|
||||||
{% include "InvenTree/settings/setting.html" with key="PART_SHOW_QUANTITY_IN_FORMS" icon="fa-hashtag" %}
|
{% include "InvenTree/settings/setting.html" with key="PART_SHOW_QUANTITY_IN_FORMS" icon="fa-hashtag" %}
|
||||||
|
{% include "InvenTree/settings/setting.html" with key="PART_RECENT_COUNT" icon="fa-clock" %}
|
||||||
<tr><td colspan='5 '></td></tr>
|
<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_TEMPLATE" icon="fa-clone" %}
|
||||||
{% include "InvenTree/settings/setting.html" with key="PART_ASSEMBLY" icon="fa-tools" %}
|
{% include "InvenTree/settings/setting.html" with key="PART_ASSEMBLY" icon="fa-tools" %}
|
||||||
|
@ -16,6 +16,7 @@
|
|||||||
{% include "InvenTree/settings/header.html" %}
|
{% include "InvenTree/settings/header.html" %}
|
||||||
<tbody>
|
<tbody>
|
||||||
{% include "InvenTree/settings/setting.html" with key="STOCK_GROUP_BY_PART" icon="fa-layer-group" %}
|
{% include "InvenTree/settings/setting.html" with key="STOCK_GROUP_BY_PART" icon="fa-layer-group" %}
|
||||||
|
{% include "InvenTree/settings/setting.html" with key="STOCK_RECENT_COUNT" icon="fa-clock" %}
|
||||||
{% include "InvenTree/settings/setting.html" with key="STOCK_ENABLE_EXPIRY" icon="fa-stopwatch" %}
|
{% include "InvenTree/settings/setting.html" with key="STOCK_ENABLE_EXPIRY" icon="fa-stopwatch" %}
|
||||||
{% include "InvenTree/settings/setting.html" with key="STOCK_STALE_DAYS" icon="fa-calendar" %}
|
{% include "InvenTree/settings/setting.html" with key="STOCK_STALE_DAYS" icon="fa-calendar" %}
|
||||||
{% include "InvenTree/settings/setting.html" with key="STOCK_ALLOW_EXPIRED_SALE" icon="fa-truck" %}
|
{% include "InvenTree/settings/setting.html" with key="STOCK_ALLOW_EXPIRED_SALE" icon="fa-truck" %}
|
||||||
|
@ -618,7 +618,9 @@ function loadBuildTable(table, options) {
|
|||||||
filters[key] = params[key];
|
filters[key] = params[key];
|
||||||
}
|
}
|
||||||
|
|
||||||
setupFilterList("build", table);
|
var filterTarget = options.filterTarget || null;
|
||||||
|
|
||||||
|
setupFilterList("build", table, filterTarget);
|
||||||
|
|
||||||
$(table).inventreeTable({
|
$(table).inventreeTable({
|
||||||
method: 'get',
|
method: 'get',
|
||||||
|
@ -114,6 +114,18 @@ function selectLabel(labels, items, options={}) {
|
|||||||
* (via AJAX) from the server.
|
* (via AJAX) from the server.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
// If only a single label template is provided,
|
||||||
|
// just run with that!
|
||||||
|
|
||||||
|
if (labels.length == 1) {
|
||||||
|
if (options.success) {
|
||||||
|
options.success(labels[0].pk);
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
var modal = options.modal || '#modal-form';
|
var modal = options.modal || '#modal-form';
|
||||||
|
|
||||||
var label_list = makeOptionsList(
|
var label_list = makeOptionsList(
|
||||||
|
@ -325,7 +325,7 @@ function loadPartTable(table, url, options={}) {
|
|||||||
filters[key] = params[key];
|
filters[key] = params[key];
|
||||||
}
|
}
|
||||||
|
|
||||||
setupFilterList("parts", $(table));
|
setupFilterList("parts", $(table), options.filterTarget || null);
|
||||||
|
|
||||||
var columns = [
|
var columns = [
|
||||||
{
|
{
|
||||||
|
@ -319,6 +319,12 @@ function loadStockTable(table, options) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var grouping = true;
|
||||||
|
|
||||||
|
if ('grouping' in options) {
|
||||||
|
grouping = options.grouping;
|
||||||
|
}
|
||||||
|
|
||||||
table.inventreeTable({
|
table.inventreeTable({
|
||||||
method: 'get',
|
method: 'get',
|
||||||
formatNoMatches: function() {
|
formatNoMatches: function() {
|
||||||
@ -333,7 +339,7 @@ function loadStockTable(table, options) {
|
|||||||
{% settings_value 'STOCK_GROUP_BY_PART' as group_by_part %}
|
{% settings_value 'STOCK_GROUP_BY_PART' as group_by_part %}
|
||||||
{% if group_by_part %}
|
{% if group_by_part %}
|
||||||
groupByField: options.groupByField || 'part',
|
groupByField: options.groupByField || 'part',
|
||||||
groupBy: true,
|
groupBy: grouping,
|
||||||
groupByFormatter: function(field, id, data) {
|
groupByFormatter: function(field, id, data) {
|
||||||
|
|
||||||
var row = data[0];
|
var row = data[0];
|
||||||
|
@ -1,12 +1,14 @@
|
|||||||
{% load qr_code %}
|
{% load barcode %}
|
||||||
|
{% load i18n %}
|
||||||
|
|
||||||
<div class='container' style='width: 80%;'>
|
<div class='container' style='width: 80%;'>
|
||||||
{% if qr_data %}
|
{% if qr_data %}
|
||||||
<div class='qr-container'>
|
<div class='qr-container'>
|
||||||
<img class='qr-code' src="{% qr_url_from_text qr_data size='m' image_format='png' error_correction='m' %}" alt="QR Code">
|
<img src="{% qrcode qr_data %}">
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<b>Error:</b><br>
|
<div class='alert alert-block alert-warning'>
|
||||||
{{ error_msg }}
|
{% trans "QR data not provided" %}
|
||||||
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
@ -1,7 +1,6 @@
|
|||||||
wheel>=0.34.2 # Wheel
|
wheel>=0.34.2 # Wheel
|
||||||
Django==3.0.7 # Django package
|
Django==3.0.7 # Django package
|
||||||
pillow==7.1.0 # Image manipulation
|
pillow==7.1.0 # Image manipulation
|
||||||
blabel==0.1.3 # Simple PDF label printing
|
|
||||||
djangorestframework==3.10.3 # DRF framework
|
djangorestframework==3.10.3 # DRF framework
|
||||||
django-dbbackup==3.3.0 # Database backup / restore functionality
|
django-dbbackup==3.3.0 # Database backup / restore functionality
|
||||||
django-cors-headers==3.2.0 # CORS headers extension for DRF
|
django-cors-headers==3.2.0 # CORS headers extension for DRF
|
||||||
@ -16,7 +15,6 @@ tablib==0.13.0 # Import / export data files
|
|||||||
django-crispy-forms==1.8.1 # Form helpers
|
django-crispy-forms==1.8.1 # Form helpers
|
||||||
django-import-export==2.0.0 # Data import / export for admin interface
|
django-import-export==2.0.0 # Data import / export for admin interface
|
||||||
django-cleanup==5.1.0 # Manage deletion of old / unused uploaded files
|
django-cleanup==5.1.0 # Manage deletion of old / unused uploaded files
|
||||||
django-qr-code==1.2.0 # Generate QR codes
|
|
||||||
flake8==3.8.3 # PEP checking
|
flake8==3.8.3 # PEP checking
|
||||||
pep8-naming==0.11.1 # PEP naming convention extension
|
pep8-naming==0.11.1 # PEP naming convention extension
|
||||||
coverage==5.3 # Unit test coverage
|
coverage==5.3 # Unit test coverage
|
||||||
@ -31,5 +29,7 @@ certifi # Certifi is (most likely) installed through one
|
|||||||
django-error-report==0.2.0 # Error report viewer for the admin interface
|
django-error-report==0.2.0 # Error report viewer for the admin interface
|
||||||
django-test-migrations==1.1.0 # Unit testing for database migrations
|
django-test-migrations==1.1.0 # Unit testing for database migrations
|
||||||
django-migration-linter==2.5.0 # Linting checks for database migrations
|
django-migration-linter==2.5.0 # Linting checks for database migrations
|
||||||
|
python-barcode[images]==0.13.1 # Barcode generator
|
||||||
|
qrcode[pil]==6.1 # QR code generator
|
||||||
|
|
||||||
inventree # Install the latest version of the InvenTree API python library
|
inventree # Install the latest version of the InvenTree API python library
|
||||||
|
Loading…
Reference in New Issue
Block a user