diff --git a/InvenTree/InvenTree/settings.py b/InvenTree/InvenTree/settings.py index 7e3ed2c7a6..70760624c6 100644 --- a/InvenTree/InvenTree/settings.py +++ b/InvenTree/InvenTree/settings.py @@ -204,7 +204,6 @@ INSTALLED_APPS = [ 'crispy_forms', # Improved form rendering 'import_export', # Import / export tables to file 'django_cleanup.apps.CleanupConfig', # Automatically delete orphaned MEDIA files - 'qr_code', # Generate QR codes 'mptt', # Modified Preorder Tree Traversal 'markdownx', # Markdown editing 'markdownify', # Markdown template rendering @@ -250,6 +249,7 @@ TEMPLATES = [ os.path.join(BASE_DIR, 'templates'), # Allow templates in the reporting directory to be accessed os.path.join(MEDIA_ROOT, 'report'), + os.path.join(MEDIA_ROOT, 'label'), ], 'APP_DIRS': True, 'OPTIONS': { @@ -407,8 +407,6 @@ CACHES = { } } -QR_CODE_CACHE_ALIAS = 'qr-code' - # Password validation # https://docs.djangoproject.com/en/1.10/ref/settings/#auth-password-validators diff --git a/InvenTree/InvenTree/static/css/inventree.css b/InvenTree/InvenTree/static/css/inventree.css index a0053e5f53..34c101e86d 100644 --- a/InvenTree/InvenTree/static/css/inventree.css +++ b/InvenTree/InvenTree/static/css/inventree.css @@ -125,6 +125,7 @@ .qr-container { width: 100%; align-content: center; + object-fit: fill; } .navbar-brand { diff --git a/InvenTree/InvenTree/urls.py b/InvenTree/InvenTree/urls.py index 1df5e83d5d..c5b439c0be 100644 --- a/InvenTree/InvenTree/urls.py +++ b/InvenTree/InvenTree/urls.py @@ -9,7 +9,6 @@ from django.conf.urls import url, include from django.urls import path from django.contrib import admin 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 supplier_part_urls @@ -21,7 +20,7 @@ from stock.urls import stock_urls from build.urls import build_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 part.api import part_api_urls, bom_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/', 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'^search/', SearchView.as_view(), name='search'), url(r'^stats/', DatabaseStatsView.as_view(), name='stats'), diff --git a/InvenTree/barcode/__init__.py b/InvenTree/barcodes/__init__.py similarity index 100% rename from InvenTree/barcode/__init__.py rename to InvenTree/barcodes/__init__.py diff --git a/InvenTree/barcode/api.py b/InvenTree/barcodes/api.py similarity index 99% rename from InvenTree/barcode/api.py rename to InvenTree/barcodes/api.py index f8a2c37329..d727f5a778 100644 --- a/InvenTree/barcode/api.py +++ b/InvenTree/barcodes/api.py @@ -12,7 +12,7 @@ from rest_framework.views import APIView from stock.models import StockItem 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): diff --git a/InvenTree/barcode/barcode.py b/InvenTree/barcodes/barcode.py similarity index 98% rename from InvenTree/barcode/barcode.py rename to InvenTree/barcodes/barcode.py index 004182e0be..412065bf75 100644 --- a/InvenTree/barcode/barcode.py +++ b/InvenTree/barcodes/barcode.py @@ -5,7 +5,7 @@ import hashlib import logging 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.serializers import StockItemSerializer, LocationSerializer diff --git a/InvenTree/barcode/plugins/digikey_barcode.py b/InvenTree/barcodes/plugins/digikey_barcode.py similarity index 85% rename from InvenTree/barcode/plugins/digikey_barcode.py rename to InvenTree/barcodes/plugins/digikey_barcode.py index 1a0825e03c..656cdfa6f4 100644 --- a/InvenTree/barcode/plugins/digikey_barcode.py +++ b/InvenTree/barcodes/plugins/digikey_barcode.py @@ -4,7 +4,7 @@ DigiKey barcode decoding """ -from barcode.barcode import BarcodePlugin +from barcodes.barcode import BarcodePlugin class DigikeyBarcodePlugin(BarcodePlugin): diff --git a/InvenTree/barcode/plugins/inventree_barcode.py b/InvenTree/barcodes/plugins/inventree_barcode.py similarity index 98% rename from InvenTree/barcode/plugins/inventree_barcode.py rename to InvenTree/barcodes/plugins/inventree_barcode.py index 93fb58cbc0..842f9029aa 100644 --- a/InvenTree/barcode/plugins/inventree_barcode.py +++ b/InvenTree/barcodes/plugins/inventree_barcode.py @@ -13,7 +13,7 @@ references model objects actually exist in the database. import json -from barcode.barcode import BarcodePlugin +from barcodes.barcode import BarcodePlugin from stock.models import StockItem, StockLocation from part.models import Part diff --git a/InvenTree/barcode/tests.py b/InvenTree/barcodes/tests.py similarity index 100% rename from InvenTree/barcode/tests.py rename to InvenTree/barcodes/tests.py diff --git a/InvenTree/build/api.py b/InvenTree/build/api.py index d76c0a4b51..cb6b3f6b2b 100644 --- a/InvenTree/build/api.py +++ b/InvenTree/build/api.py @@ -56,6 +56,28 @@ class BuildList(generics.ListCreateAPIView): 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? status = params.get('status', None) diff --git a/InvenTree/build/models.py b/InvenTree/build/models.py index 2872fecb55..ada9db1e08 100644 --- a/InvenTree/build/models.py +++ b/InvenTree/build/models.py @@ -259,6 +259,27 @@ class Build(MPTTModel): 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 def is_overdue(self): """ diff --git a/InvenTree/build/templates/build/build_children.html b/InvenTree/build/templates/build/build_children.html new file mode 100644 index 0000000000..c996aaa84f --- /dev/null +++ b/InvenTree/build/templates/build/build_children.html @@ -0,0 +1,39 @@ +{% extends "build/build_base.html" %} +{% load static %} +{% load i18n %} + +{% block details %} + +{% include "build/tabs.html" with tab="children" %} + +

{% trans "Child Build Orders" %}

+ +
+ +
+
+
+ +
+
+ +
+ +
+ +{% 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 %} \ No newline at end of file diff --git a/InvenTree/build/templates/build/tabs.html b/InvenTree/build/templates/build/tabs.html index c6d2893620..6b36c3d052 100644 --- a/InvenTree/build/templates/build/tabs.html +++ b/InvenTree/build/templates/build/tabs.html @@ -24,6 +24,12 @@ {{ build.output_count }} +
  • + + {% trans "Child Builds" %} + {{ build.sub_build_count }} + +
  • {% trans "Notes" %} diff --git a/InvenTree/build/test_api.py b/InvenTree/build/test_api.py new file mode 100644 index 0000000000..bcfd600e9e --- /dev/null +++ b/InvenTree/build/test_api.py @@ -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) diff --git a/InvenTree/build/urls.py b/InvenTree/build/urls.py index 6f681f5488..877b368817 100644 --- a/InvenTree/build/urls.py +++ b/InvenTree/build/urls.py @@ -20,6 +20,7 @@ build_detail_urls = [ 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'^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'), diff --git a/InvenTree/build/views.py b/InvenTree/build/views.py index 3c4b94c43d..272cd1e5d8 100644 --- a/InvenTree/build/views.py +++ b/InvenTree/build/views.py @@ -618,6 +618,7 @@ class BuildDetail(DetailView): ctx['bom_price'] = build.part.get_price_info(build.quantity, buy=False) ctx['BuildStatus'] = BuildStatus + ctx['sub_build_count'] = build.sub_build_count() return ctx diff --git a/InvenTree/common/models.py b/InvenTree/common/models.py index 52f38bea18..06c06bde05 100644 --- a/InvenTree/common/models.py +++ b/InvenTree/common/models.py @@ -125,6 +125,13 @@ class InvenTreeSetting(models.Model): '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': { 'name': _('Template'), 'description': _('Parts are templates by default'), @@ -249,6 +256,13 @@ class InvenTreeSetting(models.Model): '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': { 'name': _('Build Order Reference Prefix'), 'description': _('Prefix value for build order reference'), @@ -521,12 +535,18 @@ class InvenTreeSetting(models.Model): validator = InvenTreeSetting.get_setting_validator(self.key) - if validator is not None: - self.run_validator(validator) - if self.is_bool(): 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): """ Run a validator against the 'value' field for this InvenTreeSetting object. @@ -535,39 +555,39 @@ class InvenTreeSetting(models.Model): if validator is None: return - # 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) - - return - - if callable(validator): - # We can accept function validators with a single argument - print("Running validator function") - validator(self.value) + value = self.value # Boolean validator - if validator == bool: + if self.is_bool(): # 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" - self.value = str(InvenTree.helpers.str2bool(self.value)) + value = InvenTree.helpers.str2bool(value) else: raise ValidationError({ 'value': _('Value must be a boolean value') }) # Integer validator - if validator == int: + if self.is_int(): + try: # Coerce into an integer value - self.value = str(int(self.value)) + value = int(value) except (ValueError, TypeError): raise ValidationError({ '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): """ Ensure that the key:value pair is unique. 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) - 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): """ @@ -623,6 +649,8 @@ class InvenTreeSetting(models.Model): if v == int: return True + return False + def as_int(self): """ Return the value of this setting converted to a boolean value. diff --git a/InvenTree/company/templates/company/supplier_part_base.html b/InvenTree/company/templates/company/supplier_part_base.html index fde4e745d5..b03a8944e8 100644 --- a/InvenTree/company/templates/company/supplier_part_base.html +++ b/InvenTree/company/templates/company/supplier_part_base.html @@ -17,6 +17,15 @@ src="{% static 'img/blank_image.png' %}" {% block page_data %}

    {% trans "Supplier Part" %}

    +
    +

    + {{ part.part.full_name }} + {% if user.is_staff and perms.company.change_company %} + + + + {% endif %} +

    {{ part.supplier.name }} - {{ part.SKU }}

    {% if roles.purchase_order.change %} diff --git a/InvenTree/label/api.py b/InvenTree/label/api.py index b2bfe9164f..a4800cd5d9 100644 --- a/InvenTree/label/api.py +++ b/InvenTree/label/api.py @@ -1,11 +1,10 @@ # -*- 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.core.exceptions import ValidationError, FieldError +from django.http import HttpResponse from django_filters.rest_framework import DjangoFilterBackend @@ -13,6 +12,7 @@ from rest_framework import generics, filters from rest_framework.response import Response import InvenTree.helpers +import common.models 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: """ Mixin for extracting stock items from query params @@ -158,7 +226,7 @@ class StockItemLabelDetail(generics.RetrieveUpdateDestroyAPIView): serializer_class = StockItemLabelSerializer -class StockItemLabelPrint(generics.RetrieveAPIView, StockItemLabelMixin): +class StockItemLabelPrint(generics.RetrieveAPIView, StockItemLabelMixin, LabelPrintMixin): """ API endpoint for printing a StockItemLabel object """ @@ -173,34 +241,7 @@ class StockItemLabelPrint(generics.RetrieveAPIView, StockItemLabelMixin): 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' - ) + return self.print(request, items) class StockLocationLabelMixin: @@ -320,7 +361,7 @@ class StockLocationLabelDetail(generics.RetrieveUpdateDestroyAPIView): serializer_class = StockLocationLabelSerializer -class StockLocationLabelPrint(generics.RetrieveAPIView, StockLocationLabelMixin): +class StockLocationLabelPrint(generics.RetrieveAPIView, StockLocationLabelMixin, LabelPrintMixin): """ API endpoint for printing a StockLocationLabel object """ @@ -332,35 +373,7 @@ class StockLocationLabelPrint(generics.RetrieveAPIView, StockLocationLabelMixin) 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' - ) + return self.print(request, locations) label_api_urls = [ diff --git a/InvenTree/label/apps.py b/InvenTree/label/apps.py index 321047a551..9f2d3ea9c4 100644 --- a/InvenTree/label/apps.py +++ b/InvenTree/label/apps.py @@ -1,6 +1,7 @@ import os import shutil import logging +import hashlib from django.apps import AppConfig from django.conf import settings @@ -9,6 +10,20 @@ from django.conf import settings 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): name = 'label' @@ -35,6 +50,7 @@ class LabelConfig(AppConfig): src_dir = os.path.join( os.path.dirname(os.path.realpath(__file__)), 'templates', + 'label', 'stockitem', ) @@ -54,6 +70,8 @@ class LabelConfig(AppConfig): 'file': 'qr.html', 'name': 'QR Code', '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']) 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}'") shutil.copyfile(src_file, dst_file) @@ -86,7 +118,9 @@ class LabelConfig(AppConfig): description=label['description'], label=filename, filters='', - enabled=True + enabled=True, + width=label['width'], + height=label['height'], ) except: pass @@ -106,6 +140,7 @@ class LabelConfig(AppConfig): src_dir = os.path.join( os.path.dirname(os.path.realpath(__file__)), 'templates', + 'label', 'stocklocation', ) @@ -125,11 +160,15 @@ class LabelConfig(AppConfig): 'file': 'qr.html', 'name': 'QR Code', 'description': 'Simple QR code label', + 'width': 24, + 'height': 24, }, { 'file': 'qr_and_text.html', 'name': 'QR and text', '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']) 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}'") shutil.copyfile(src_file, dst_file) @@ -162,7 +215,9 @@ class LabelConfig(AppConfig): description=label['description'], label=filename, filters='', - enabled=True + enabled=True, + width=label['width'], + height=label['height'], ) except: pass diff --git a/InvenTree/label/migrations/0006_auto_20210222_1535.py b/InvenTree/label/migrations/0006_auto_20210222_1535.py new file mode 100644 index 0000000000..ea3441b64f --- /dev/null +++ b/InvenTree/label/migrations/0006_auto_20210222_1535.py @@ -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]'), + ), + ] diff --git a/InvenTree/label/models.py b/InvenTree/label/models.py index 9a98810d28..0c6a108f31 100644 --- a/InvenTree/label/models.py +++ b/InvenTree/label/models.py @@ -5,21 +5,35 @@ Label printing models # -*- coding: utf-8 -*- from __future__ import unicode_literals +import sys import os -import io - -from blabel import LabelWriter +import logging +import datetime +from django.conf import settings 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.template.loader import render_to_string + from django.utils.translation import gettext_lazy as _ from InvenTree.helpers import validateFilterString, normalize +import common.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): """ Place the label file into the correct subdirectory """ @@ -43,6 +57,21 @@ def validate_stock_location_filters(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): """ 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 SUBDIR = "label" + + # Object we will be printing against (will be filled out later) + object_to_print = None @property def template(self): @@ -92,38 +124,90 @@ class LabelTemplate(models.Model): 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) - - writer.write_labels(records, filename) - - def render(self, items, **kwargs): + def context(self, request): """ - 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): @@ -157,29 +241,24 @@ class StockItemLabel(LabelTemplate): return items.exists() - def get_record_data(self, items): + def get_context_data(self, request): """ Generate context data for each provided StockItem """ - records = [] - - for item in items: - # Add some basic information - 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() - }) + stock_item = self.object_to_print - 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): @@ -212,17 +291,14 @@ class StockLocationLabel(LabelTemplate): return locs.exists() - def get_record_data(self, locations): + def get_context_data(self, request): """ Generate context data for each provided StockLocation """ - records = [] - - for loc in locations: + location = self.object_to_print - records.append({ - 'location': loc, - }) - - return records + return { + 'location': location, + 'qr_data': location.format_barcode(brief=True), + } diff --git a/InvenTree/label/templates/label/label_base.html b/InvenTree/label/templates/label/label_base.html new file mode 100644 index 0000000000..2c564d1132 --- /dev/null +++ b/InvenTree/label/templates/label/label_base.html @@ -0,0 +1,28 @@ +{% load report %} +{% load barcode %} + + + + + + + {% block content %} + + {% endblock %} + diff --git a/InvenTree/label/templates/label/stockitem/qr.html b/InvenTree/label/templates/label/stockitem/qr.html new file mode 100644 index 0000000000..8f489f81b8 --- /dev/null +++ b/InvenTree/label/templates/label/stockitem/qr.html @@ -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 %} + + +{% endblock %} diff --git a/InvenTree/label/templates/label/stocklocation/qr.html b/InvenTree/label/templates/label/stocklocation/qr.html new file mode 100644 index 0000000000..8f489f81b8 --- /dev/null +++ b/InvenTree/label/templates/label/stocklocation/qr.html @@ -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 %} + + +{% endblock %} diff --git a/InvenTree/label/templates/label/stocklocation/qr_and_text.html b/InvenTree/label/templates/label/stocklocation/qr_and_text.html new file mode 100644 index 0000000000..2ff54dc817 --- /dev/null +++ b/InvenTree/label/templates/label/stocklocation/qr_and_text.html @@ -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 %} + + + +
    + {{ location.name }} +
    + +{% endblock %} diff --git a/InvenTree/label/templates/stockitem/qr.html b/InvenTree/label/templates/stockitem/qr.html deleted file mode 100644 index 9cd9d20769..0000000000 --- a/InvenTree/label/templates/stockitem/qr.html +++ /dev/null @@ -1,16 +0,0 @@ - - - diff --git a/InvenTree/label/templates/stocklocation/qr.html b/InvenTree/label/templates/stocklocation/qr.html deleted file mode 100644 index 8fb22fbb40..0000000000 --- a/InvenTree/label/templates/stocklocation/qr.html +++ /dev/null @@ -1,16 +0,0 @@ - - - diff --git a/InvenTree/label/templates/stocklocation/qr_and_text.html b/InvenTree/label/templates/stocklocation/qr_and_text.html deleted file mode 100644 index 9a555aca15..0000000000 --- a/InvenTree/label/templates/stocklocation/qr_and_text.html +++ /dev/null @@ -1,43 +0,0 @@ - - - - -
    - {{ location.name }} -
    -
    -
    - Location ID: {{ location.pk }} -
    - - \ No newline at end of file diff --git a/InvenTree/part/api.py b/InvenTree/part/api.py index 04a37e5fff..12bfd2de1d 100644 --- a/InvenTree/part/api.py +++ b/InvenTree/part/api.py @@ -626,7 +626,7 @@ class PartList(generics.ListCreateAPIView): queryset = queryset.filter(pk__in=parts_need_stock) - # Limit choices + # Limit number of results limit = params.get('limit', None) if limit is not None: diff --git a/InvenTree/part/serializers.py b/InvenTree/part/serializers.py index 103d0202f1..1ada816357 100644 --- a/InvenTree/part/serializers.py +++ b/InvenTree/part/serializers.py @@ -245,6 +245,14 @@ class PartSerializer(InvenTreeModelSerializer): Decimal(0), ) ) + + # Annotate with the number of 'suppliers' + queryset = queryset.annotate( + suppliers=Coalesce( + SubqueryCount('supplier_parts'), + Decimal(0), + ), + ) return queryset @@ -263,6 +271,7 @@ class PartSerializer(InvenTreeModelSerializer): ordering = serializers.FloatField(read_only=True) building = serializers.FloatField(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) thumbnail = serializers.CharField(source='get_thumbnail_url', read_only=True) @@ -308,6 +317,7 @@ class PartSerializer(InvenTreeModelSerializer): 'salable', 'starred', 'stock_item_count', + 'suppliers', 'thumbnail', 'trackable', 'units', diff --git a/InvenTree/part/templates/part/build.html b/InvenTree/part/templates/part/build.html index 9641181af8..46685ad064 100644 --- a/InvenTree/part/templates/part/build.html +++ b/InvenTree/part/templates/part/build.html @@ -9,7 +9,7 @@
    -
    +
    {% if part.active %} {% if roles.build.add %} diff --git a/InvenTree/part/templates/part/tabs.html b/InvenTree/part/templates/part/tabs.html index 32a62f94e9..946a0efe47 100644 --- a/InvenTree/part/templates/part/tabs.html +++ b/InvenTree/part/templates/part/tabs.html @@ -13,11 +13,9 @@ {% trans "Variants" %} {{ part.variants.count }} {% endif %} - {% if not part.virtual %} {% trans "Stock" %} {% decimal total_stock %} - {% endif %} {% if part.component or part.salable or part.used_in_count > 0 %} {% trans "Allocated" %} {% decimal allocated %} diff --git a/InvenTree/part/templates/part/used_in.html b/InvenTree/part/templates/part/used_in.html index 686578a93a..3c8c0c2928 100644 --- a/InvenTree/part/templates/part/used_in.html +++ b/InvenTree/part/templates/part/used_in.html @@ -22,12 +22,13 @@ {% block js_ready %} {{ block.super }} - loadSimplePartTable('#used-table', + loadPartTable('#used-table', '{% url "api-part-list" %}', { params: { uses: {{ part.pk }}, - } + }, + filterTarget: '#filter-list-usedin', } ); diff --git a/InvenTree/part/templates/part/variants.html b/InvenTree/part/templates/part/variants.html index 56aacdd1dc..e94ccf1196 100644 --- a/InvenTree/part/templates/part/variants.html +++ b/InvenTree/part/templates/part/variants.html @@ -35,18 +35,6 @@ {% endblock %} -{% block js_load %} -{{ block.super }} - - - - - - - - -{% endblock %} - {% block js_ready %} {{ block.super }} diff --git a/InvenTree/part/test_views.py b/InvenTree/part/test_views.py index 13349aae15..45fd39ceb0 100644 --- a/InvenTree/part/test_views.py +++ b/InvenTree/part/test_views.py @@ -270,14 +270,12 @@ class PartQRTest(PartViewTestCase): data = str(response.content) self.assertIn('Part QR Code', data) - self.assertIn(' 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) diff --git a/InvenTree/stock/api.py b/InvenTree/stock/api.py index 4e532b90b7..75b0d9de3b 100644 --- a/InvenTree/stock/api.py +++ b/InvenTree/stock/api.py @@ -808,6 +808,19 @@ class StockList(generics.ListCreateAPIView): print("After error:", str(updated_after)) 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 queryset = queryset.prefetch_related( 'part', @@ -815,8 +828,6 @@ class StockList(generics.ListCreateAPIView): 'location' ) - queryset = queryset.order_by('part__name') - return queryset filter_backends = [ @@ -828,6 +839,15 @@ class StockList(generics.ListCreateAPIView): filter_fields = [ ] + ordering_fields = [ + 'part__name', + 'updated', + 'stocktake_date', + 'expiry_date', + ] + + ordering = ['part__name'] + search_fields = [ 'serial', 'batch', diff --git a/InvenTree/stock/test_views.py b/InvenTree/stock/test_views.py index 8789e9f76e..261598ae22 100644 --- a/InvenTree/stock/test_views.py +++ b/InvenTree/stock/test_views.py @@ -105,9 +105,6 @@ class StockItemTest(StockViewTestCase): response = self.client.get(reverse('stock-item-qr', args=(9999,)), HTTP_X_REQUESTED_WITH='XMLHttpRequest') self.assertEqual(response.status_code, 200) - data = str(response.content) - self.assertIn('Error:', data) - def test_adjust_items(self): url = reverse('stock-adjust') diff --git a/InvenTree/templates/InvenTree/index.html b/InvenTree/templates/InvenTree/index.html index 687ceaf0ee..cb2cda0a30 100644 --- a/InvenTree/templates/InvenTree/index.html +++ b/InvenTree/templates/InvenTree/index.html @@ -102,7 +102,7 @@ addHeaderAction('bom-validation', '{% trans "BOM Waiting Validation" %}', 'fa-ti loadSimplePartTable("#table-latest-parts", "{% url 'api-part-list' %}", { params: { ordering: "-creation_date", - limit: 10, + limit: {% settings_value "PART_RECENT_COUNT" %}, }, name: 'latest_parts', }); @@ -125,8 +125,19 @@ loadSimplePartTable("#table-bom-validation", "{% url 'api-part-list' %}", { {% if roles.stock.view %} addHeaderTitle('{% trans "Stock" %}'); +addHeaderAction('recently-updated-stock', '{% trans "Recently Updated" %}', 'fa-clock'); addHeaderAction('low-stock', '{% trans "Low Stock" %}', 'fa-shopping-cart'); 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 %} {% if expiry %} addHeaderAction('expired-stock', '{% trans "Expired Stock" %}', 'fa-calendar-times'); diff --git a/InvenTree/templates/InvenTree/settings/part.html b/InvenTree/templates/InvenTree/settings/part.html index 9174b2f127..bef951e203 100644 --- a/InvenTree/templates/InvenTree/settings/part.html +++ b/InvenTree/templates/InvenTree/settings/part.html @@ -19,6 +19,7 @@ {% 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" %} + {% include "InvenTree/settings/setting.html" with key="PART_RECENT_COUNT" icon="fa-clock" %} {% include "InvenTree/settings/setting.html" with key="PART_TEMPLATE" icon="fa-clone" %} {% include "InvenTree/settings/setting.html" with key="PART_ASSEMBLY" icon="fa-tools" %} diff --git a/InvenTree/templates/InvenTree/settings/stock.html b/InvenTree/templates/InvenTree/settings/stock.html index 9c82202a02..7909e11a60 100644 --- a/InvenTree/templates/InvenTree/settings/stock.html +++ b/InvenTree/templates/InvenTree/settings/stock.html @@ -16,6 +16,7 @@ {% include "InvenTree/settings/header.html" %} {% 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_STALE_DAYS" icon="fa-calendar" %} {% include "InvenTree/settings/setting.html" with key="STOCK_ALLOW_EXPIRED_SALE" icon="fa-truck" %} diff --git a/InvenTree/templates/js/build.js b/InvenTree/templates/js/build.js index cb7e27486b..01d5fcdef0 100644 --- a/InvenTree/templates/js/build.js +++ b/InvenTree/templates/js/build.js @@ -618,7 +618,9 @@ function loadBuildTable(table, options) { filters[key] = params[key]; } - setupFilterList("build", table); + var filterTarget = options.filterTarget || null; + + setupFilterList("build", table, filterTarget); $(table).inventreeTable({ method: 'get', diff --git a/InvenTree/templates/js/label.js b/InvenTree/templates/js/label.js index 63f028fa1d..cbb71c7b08 100644 --- a/InvenTree/templates/js/label.js +++ b/InvenTree/templates/js/label.js @@ -114,6 +114,18 @@ function selectLabel(labels, items, options={}) { * (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 label_list = makeOptionsList( diff --git a/InvenTree/templates/js/part.js b/InvenTree/templates/js/part.js index c9f59ab4eb..9c21034b28 100644 --- a/InvenTree/templates/js/part.js +++ b/InvenTree/templates/js/part.js @@ -325,7 +325,7 @@ function loadPartTable(table, url, options={}) { filters[key] = params[key]; } - setupFilterList("parts", $(table)); + setupFilterList("parts", $(table), options.filterTarget || null); var columns = [ { diff --git a/InvenTree/templates/js/stock.js b/InvenTree/templates/js/stock.js index f1794f3664..b115b8171c 100644 --- a/InvenTree/templates/js/stock.js +++ b/InvenTree/templates/js/stock.js @@ -319,6 +319,12 @@ function loadStockTable(table, options) { } } + var grouping = true; + + if ('grouping' in options) { + grouping = options.grouping; + } + table.inventreeTable({ method: 'get', formatNoMatches: function() { @@ -333,7 +339,7 @@ function loadStockTable(table, options) { {% settings_value 'STOCK_GROUP_BY_PART' as group_by_part %} {% if group_by_part %} groupByField: options.groupByField || 'part', - groupBy: true, + groupBy: grouping, groupByFormatter: function(field, id, data) { var row = data[0]; diff --git a/InvenTree/templates/qr_code.html b/InvenTree/templates/qr_code.html index 8bb288f797..8964ef02be 100644 --- a/InvenTree/templates/qr_code.html +++ b/InvenTree/templates/qr_code.html @@ -1,12 +1,14 @@ -{% load qr_code %} +{% load barcode %} +{% load i18n %}
    {% if qr_data %}
    - QR Code +
    {% else %} - Error:
    - {{ error_msg }} +
    + {% trans "QR data not provided" %} +
    {% endif %}
    \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index e0fdd6dfd9..cf05148ebf 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,6 @@ wheel>=0.34.2 # Wheel Django==3.0.7 # Django package pillow==7.1.0 # Image manipulation -blabel==0.1.3 # Simple PDF label printing djangorestframework==3.10.3 # DRF framework django-dbbackup==3.3.0 # Database backup / restore functionality 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-import-export==2.0.0 # Data import / export for admin interface 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 pep8-naming==0.11.1 # PEP naming convention extension 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-test-migrations==1.1.0 # Unit testing 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