2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-04-28 11:36:44 +00:00

Add new global setting to control auto-upload of test reports (#3137)

* Add new global setting to control auto-upload of test reports

* Adds callback to attach a copy of the test report when printing

* Fix for all attachment API endpoints

- The AttachmentMixin must come first!
- User was not being set, as the custom 'perform_create' function was never called

* Remove duplicated UserSerializer

* display uploading user in attachment table

* Add unit test to check the test report is automatically uploaded
This commit is contained in:
Oliver 2022-06-06 15:20:41 +10:00 committed by GitHub
parent 2b1d8f5b79
commit a066fcc909
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 116 additions and 80 deletions

View File

@ -60,29 +60,6 @@ class InvenTreeMoneySerializer(MoneyField):
return amount return amount
class UserSerializer(serializers.ModelSerializer):
"""Serializer for User - provides all fields."""
class Meta:
"""Metaclass options."""
model = User
fields = 'all'
class UserSerializerBrief(serializers.ModelSerializer):
"""Serializer for User - provides limited information."""
class Meta:
"""Metaclass options."""
model = User
fields = [
'pk',
'username',
]
class InvenTreeModelSerializer(serializers.ModelSerializer): class InvenTreeModelSerializer(serializers.ModelSerializer):
"""Inherits the standard Django ModelSerializer class, but also ensures that the underlying model class data are checked on validation.""" """Inherits the standard Django ModelSerializer class, but also ensures that the underlying model class data are checked on validation."""
@ -218,6 +195,21 @@ class InvenTreeModelSerializer(serializers.ModelSerializer):
return data return data
class UserSerializer(InvenTreeModelSerializer):
"""Serializer for a User."""
class Meta:
"""Metaclass defines serializer fields."""
model = User
fields = [
'pk',
'username',
'first_name',
'last_name',
'email'
]
class ReferenceIndexingSerializerMixin(): class ReferenceIndexingSerializerMixin():
"""This serializer mixin ensures the the reference is not to big / small for the BigIntegerField.""" """This serializer mixin ensures the the reference is not to big / small for the BigIntegerField."""
@ -239,9 +231,7 @@ class InvenTreeAttachmentSerializerField(serializers.FileField):
/media/foo/bar.jpg /media/foo/bar.jpg
Why? You can't handle the why! If the server process is serving the data at 127.0.0.1,
Actually, if the server process is serving the data at 127.0.0.1,
but a proxy service (e.g. nginx) is then providing DNS lookup to the outside world, but a proxy service (e.g. nginx) is then providing DNS lookup to the outside world,
then an attachment which prefixes the "address" of the internal server then an attachment which prefixes the "address" of the internal server
will not be accessible from the outside world. will not be accessible from the outside world.
@ -261,6 +251,8 @@ class InvenTreeAttachmentSerializer(InvenTreeModelSerializer):
The only real addition here is that we support "renaming" of the attachment file. The only real addition here is that we support "renaming" of the attachment file.
""" """
user_detail = UserSerializer(source='user', read_only=True, many=False)
attachment = InvenTreeAttachmentSerializerField( attachment = InvenTreeAttachmentSerializerField(
required=False, required=False,
allow_null=False, allow_null=False,

View File

@ -413,7 +413,7 @@ class BuildItemList(generics.ListCreateAPIView):
] ]
class BuildAttachmentList(generics.ListCreateAPIView, AttachmentMixin): class BuildAttachmentList(AttachmentMixin, generics.ListCreateAPIView):
"""API endpoint for listing (and creating) BuildOrderAttachment objects.""" """API endpoint for listing (and creating) BuildOrderAttachment objects."""
queryset = BuildOrderAttachment.objects.all() queryset = BuildOrderAttachment.objects.all()
@ -428,7 +428,7 @@ class BuildAttachmentList(generics.ListCreateAPIView, AttachmentMixin):
] ]
class BuildAttachmentDetail(generics.RetrieveUpdateDestroyAPIView, AttachmentMixin): class BuildAttachmentDetail(AttachmentMixin, generics.RetrieveUpdateDestroyAPIView):
"""Detail endpoint for a BuildOrderAttachment object.""" """Detail endpoint for a BuildOrderAttachment object."""
queryset = BuildOrderAttachment.objects.all() queryset = BuildOrderAttachment.objects.all()

View File

@ -11,7 +11,7 @@ from rest_framework import serializers
from rest_framework.serializers import ValidationError from rest_framework.serializers import ValidationError
from InvenTree.serializers import InvenTreeModelSerializer, InvenTreeAttachmentSerializer from InvenTree.serializers import InvenTreeModelSerializer, InvenTreeAttachmentSerializer
from InvenTree.serializers import UserSerializerBrief, ReferenceIndexingSerializerMixin from InvenTree.serializers import ReferenceIndexingSerializerMixin, UserSerializer
import InvenTree.helpers import InvenTree.helpers
from InvenTree.helpers import extract_serial_numbers from InvenTree.helpers import extract_serial_numbers
@ -40,7 +40,7 @@ class BuildSerializer(ReferenceIndexingSerializerMixin, InvenTreeModelSerializer
overdue = serializers.BooleanField(required=False, read_only=True) overdue = serializers.BooleanField(required=False, read_only=True)
issued_by_detail = UserSerializerBrief(source='issued_by', read_only=True) issued_by_detail = UserSerializer(source='issued_by', read_only=True)
responsible_detail = OwnerSerializer(source='responsible', read_only=True) responsible_detail = OwnerSerializer(source='responsible', read_only=True)
@ -860,6 +860,8 @@ class BuildAttachmentSerializer(InvenTreeAttachmentSerializer):
'filename', 'filename',
'comment', 'comment',
'upload_date', 'upload_date',
'user',
'user_detail',
] ]
read_only_fields = [ read_only_fields = [

View File

@ -965,12 +965,19 @@ class InvenTreeSetting(BaseInvenTreeSetting):
}, },
'REPORT_ENABLE_TEST_REPORT': { 'REPORT_ENABLE_TEST_REPORT': {
'name': _('Test Reports'), 'name': _('Enable Test Reports'),
'description': _('Enable generation of test reports'), 'description': _('Enable generation of test reports'),
'default': True, 'default': True,
'validator': bool, 'validator': bool,
}, },
'REPORT_ATTACH_TEST_REPORT': {
'name': _('Attach Test Reports'),
'description': _('When printing a Test Report, attach a copy of the Test Report to the associated Stock Item'),
'default': False,
'validator': bool,
},
'STOCK_BATCH_CODE_TEMPLATE': { 'STOCK_BATCH_CODE_TEMPLATE': {
'name': _('Batch Code Template'), 'name': _('Batch Code Template'),
'description': _('Template for generating default batch codes for stock items'), 'description': _('Template for generating default batch codes for stock items'),

View File

@ -158,6 +158,8 @@ class ManufacturerPartAttachmentSerializer(InvenTreeAttachmentSerializer):
'link', 'link',
'comment', 'comment',
'upload_date', 'upload_date',
'user',
'user_detail',
] ]
read_only_fields = [ read_only_fields = [

View File

@ -527,7 +527,7 @@ class PurchaseOrderExtraLineDetail(generics.RetrieveUpdateDestroyAPIView):
serializer_class = serializers.PurchaseOrderExtraLineSerializer serializer_class = serializers.PurchaseOrderExtraLineSerializer
class SalesOrderAttachmentList(generics.ListCreateAPIView, AttachmentMixin): class SalesOrderAttachmentList(AttachmentMixin, generics.ListCreateAPIView):
"""API endpoint for listing (and creating) a SalesOrderAttachment (file upload)""" """API endpoint for listing (and creating) a SalesOrderAttachment (file upload)"""
queryset = models.SalesOrderAttachment.objects.all() queryset = models.SalesOrderAttachment.objects.all()
@ -542,7 +542,7 @@ class SalesOrderAttachmentList(generics.ListCreateAPIView, AttachmentMixin):
] ]
class SalesOrderAttachmentDetail(generics.RetrieveUpdateDestroyAPIView, AttachmentMixin): class SalesOrderAttachmentDetail(AttachmentMixin, generics.RetrieveUpdateDestroyAPIView):
"""Detail endpoint for SalesOrderAttachment.""" """Detail endpoint for SalesOrderAttachment."""
queryset = models.SalesOrderAttachment.objects.all() queryset = models.SalesOrderAttachment.objects.all()
@ -1056,7 +1056,7 @@ class SalesOrderShipmentComplete(generics.CreateAPIView):
return ctx return ctx
class PurchaseOrderAttachmentList(generics.ListCreateAPIView, AttachmentMixin): class PurchaseOrderAttachmentList(AttachmentMixin, generics.ListCreateAPIView):
"""API endpoint for listing (and creating) a PurchaseOrderAttachment (file upload)""" """API endpoint for listing (and creating) a PurchaseOrderAttachment (file upload)"""
queryset = models.PurchaseOrderAttachment.objects.all() queryset = models.PurchaseOrderAttachment.objects.all()
@ -1071,7 +1071,7 @@ class PurchaseOrderAttachmentList(generics.ListCreateAPIView, AttachmentMixin):
] ]
class PurchaseOrderAttachmentDetail(generics.RetrieveUpdateDestroyAPIView, AttachmentMixin): class PurchaseOrderAttachmentDetail(AttachmentMixin, generics.RetrieveUpdateDestroyAPIView):
"""Detail endpoint for a PurchaseOrderAttachment.""" """Detail endpoint for a PurchaseOrderAttachment."""
queryset = models.PurchaseOrderAttachment.objects.all() queryset = models.PurchaseOrderAttachment.objects.all()

View File

@ -630,6 +630,8 @@ class PurchaseOrderAttachmentSerializer(InvenTreeAttachmentSerializer):
'filename', 'filename',
'comment', 'comment',
'upload_date', 'upload_date',
'user',
'user_detail',
] ]
read_only_fields = [ read_only_fields = [
@ -1348,6 +1350,8 @@ class SalesOrderAttachmentSerializer(InvenTreeAttachmentSerializer):
'link', 'link',
'comment', 'comment',
'upload_date', 'upload_date',
'user',
'user_detail',
] ]
read_only_fields = [ read_only_fields = [

View File

@ -302,7 +302,7 @@ class PartInternalPriceList(generics.ListCreateAPIView):
] ]
class PartAttachmentList(generics.ListCreateAPIView, AttachmentMixin): class PartAttachmentList(AttachmentMixin, generics.ListCreateAPIView):
"""API endpoint for listing (and creating) a PartAttachment (file upload).""" """API endpoint for listing (and creating) a PartAttachment (file upload)."""
queryset = PartAttachment.objects.all() queryset = PartAttachment.objects.all()
@ -317,7 +317,7 @@ class PartAttachmentList(generics.ListCreateAPIView, AttachmentMixin):
] ]
class PartAttachmentDetail(generics.RetrieveUpdateDestroyAPIView, AttachmentMixin): class PartAttachmentDetail(AttachmentMixin, generics.RetrieveUpdateDestroyAPIView):
"""Detail endpoint for PartAttachment model.""" """Detail endpoint for PartAttachment model."""
queryset = PartAttachment.objects.all() queryset = PartAttachment.objects.all()

View File

@ -94,6 +94,8 @@ class PartAttachmentSerializer(InvenTreeAttachmentSerializer):
'link', 'link',
'comment', 'comment',
'upload_date', 'upload_date',
'user',
'user_detail',
] ]
read_only_fields = [ read_only_fields = [

View File

@ -1,6 +1,7 @@
"""API functionality for the 'report' app""" """API functionality for the 'report' app"""
from django.core.exceptions import FieldError, ValidationError from django.core.exceptions import FieldError, ValidationError
from django.core.files.base import ContentFile
from django.http import HttpResponse from django.http import HttpResponse
from django.template.exceptions import TemplateDoesNotExist from django.template.exceptions import TemplateDoesNotExist
from django.urls import include, path, re_path from django.urls import include, path, re_path
@ -15,7 +16,7 @@ import common.models
import InvenTree.helpers import InvenTree.helpers
import order.models import order.models
import part.models import part.models
from stock.models import StockItem from stock.models import StockItem, StockItemAttachment
from .models import (BillOfMaterialsReport, BuildReport, PurchaseOrderReport, from .models import (BillOfMaterialsReport, BuildReport, PurchaseOrderReport,
SalesOrderReport, TestReport) SalesOrderReport, TestReport)
@ -158,6 +159,18 @@ class PartReportMixin:
class ReportPrintMixin: class ReportPrintMixin:
"""Mixin for printing reports.""" """Mixin for printing reports."""
def report_callback(self, object, report, request):
"""Callback function for each object/report combination.
Allows functionality to be performed before returning the consolidated PDF
Arguments:
object: The model instance to be printed
report: The individual PDF file object
request: The request instance associated with this print call
"""
...
def print(self, request, items_to_print): def print(self, request, items_to_print):
"""Print this report template against a number of pre-validated items.""" """Print this report template against a number of pre-validated items."""
if len(items_to_print) == 0: if len(items_to_print) == 0:
@ -182,12 +195,16 @@ class ReportPrintMixin:
report.object_to_print = item report.object_to_print = item
report_name = report.generate_filename(request) report_name = report.generate_filename(request)
output = report.render(request)
# Run report callback for each generated report
self.report_callback(item, output, request)
try: try:
if debug_mode: if debug_mode:
outputs.append(report.render_as_string(request)) outputs.append(report.render_as_string(request))
else: else:
outputs.append(report.render(request)) outputs.append(output)
except TemplateDoesNotExist as e: except TemplateDoesNotExist as e:
template = str(e) template = str(e)
if not template: if not template:
@ -326,6 +343,22 @@ class StockItemTestReportPrint(generics.RetrieveAPIView, StockItemReportMixin, R
queryset = TestReport.objects.all() queryset = TestReport.objects.all()
serializer_class = TestReportSerializer serializer_class = TestReportSerializer
def report_callback(self, item, report, request):
"""Callback to (optionally) save a copy of the generated report"""
if common.models.InvenTreeSetting.get_setting('REPORT_ATTACH_TEST_REPORT'):
# Construct a PDF file object
pdf = report.get_document().write_pdf()
pdf_content = ContentFile(pdf, "test_report.pdf")
StockItemAttachment.objects.create(
attachment=pdf_content,
stock_item=item,
user=request.user,
comment=_("Test report")
)
def get(self, request, *args, **kwargs): def get(self, request, *args, **kwargs):
"""Check if valid stock item(s) have been provided.""" """Check if valid stock item(s) have been provided."""
items = self.get_items() items = self.get_items()

View File

@ -9,9 +9,9 @@ from django.urls import reverse
import report.models as report_models import report.models as report_models
from build.models import Build from build.models import Build
from common.models import InvenTreeUserSetting from common.models import InvenTreeSetting, InvenTreeUserSetting
from InvenTree.api_tester import InvenTreeAPITestCase from InvenTree.api_tester import InvenTreeAPITestCase
from stock.models import StockItem from stock.models import StockItem, StockItemAttachment
class ReportTest(InvenTreeAPITestCase): class ReportTest(InvenTreeAPITestCase):
@ -141,15 +141,28 @@ class TestReportTest(ReportTest):
# Now print with a valid StockItem # Now print with a valid StockItem
item = StockItem.objects.first() item = StockItem.objects.first()
response = self.get(url, {'item': item.pk}) response = self.get(url, {'item': item.pk}, expected_code=200)
# Response should be a StreamingHttpResponse (PDF file) # Response should be a StreamingHttpResponse (PDF file)
self.assertEqual(type(response), StreamingHttpResponse) self.assertEqual(type(response), StreamingHttpResponse)
headers = response.headers headers = response.headers
self.assertEqual(headers['Content-Type'], 'application/pdf') self.assertEqual(headers['Content-Type'], 'application/pdf')
# By default, this should *not* have created an attachment against this stockitem
self.assertFalse(StockItemAttachment.objects.filter(stock_item=item).exists())
# Change the setting, now the test report should be attached automatically
InvenTreeSetting.set_setting('REPORT_ATTACH_TEST_REPORT', True, None)
response = self.get(url, {'item': item.pk}, expected_code=200)
headers = response.headers
self.assertEqual(headers['Content-Type'], 'application/pdf')
# Check that a report has been uploaded
attachment = StockItemAttachment.objects.filter(stock_item=item).first()
self.assertIsNotNone(attachment)
class BuildReportTest(ReportTest): class BuildReportTest(ReportTest):
"""Unit test class for the BuildReport model""" """Unit test class for the BuildReport model"""

View File

@ -1043,7 +1043,7 @@ class StockList(APIDownloadMixin, generics.ListCreateAPIView):
] ]
class StockAttachmentList(generics.ListCreateAPIView, AttachmentMixin): class StockAttachmentList(AttachmentMixin, generics.ListCreateAPIView):
"""API endpoint for listing (and creating) a StockItemAttachment (file upload).""" """API endpoint for listing (and creating) a StockItemAttachment (file upload)."""
queryset = StockItemAttachment.objects.all() queryset = StockItemAttachment.objects.all()
@ -1060,7 +1060,7 @@ class StockAttachmentList(generics.ListCreateAPIView, AttachmentMixin):
] ]
class StockAttachmentDetail(generics.RetrieveUpdateDestroyAPIView, AttachmentMixin): class StockAttachmentDetail(AttachmentMixin, generics.RetrieveUpdateDestroyAPIView):
"""Detail endpoint for StockItemAttachment.""" """Detail endpoint for StockItemAttachment."""
queryset = StockItemAttachment.objects.all() queryset = StockItemAttachment.objects.all()

View File

@ -20,8 +20,8 @@ def delete_scheduled(apps, schema_editor):
items = StockItem.objects.filter(scheduled_for_deletion=True) items = StockItem.objects.filter(scheduled_for_deletion=True)
if items.count() > 0:
logger.info(f"Removing {items.count()} stock items scheduled for deletion") logger.info(f"Removing {items.count()} stock items scheduled for deletion")
items.delete() items.delete()
Task = apps.get_model('django_q', 'schedule') Task = apps.get_model('django_q', 'schedule')

View File

@ -549,19 +549,6 @@ class LocationSerializer(InvenTree.serializers.InvenTreeModelSerializer):
class StockItemAttachmentSerializer(InvenTree.serializers.InvenTreeAttachmentSerializer): class StockItemAttachmentSerializer(InvenTree.serializers.InvenTreeAttachmentSerializer):
"""Serializer for StockItemAttachment model.""" """Serializer for StockItemAttachment model."""
def __init__(self, *args, **kwargs):
"""Add detail fields."""
user_detail = kwargs.pop('user_detail', False)
super().__init__(*args, **kwargs)
if user_detail is not True:
self.fields.pop('user_detail')
user_detail = InvenTree.serializers.UserSerializerBrief(source='user', read_only=True)
# TODO: Record the uploading user when creating or updating an attachment!
class Meta: class Meta:
"""Metaclass options.""" """Metaclass options."""
@ -589,7 +576,7 @@ class StockItemAttachmentSerializer(InvenTree.serializers.InvenTreeAttachmentSer
class StockItemTestResultSerializer(InvenTree.serializers.InvenTreeModelSerializer): class StockItemTestResultSerializer(InvenTree.serializers.InvenTreeModelSerializer):
"""Serializer for the StockItemTestResult model.""" """Serializer for the StockItemTestResult model."""
user_detail = InvenTree.serializers.UserSerializerBrief(source='user', read_only=True) user_detail = InvenTree.serializers.UserSerializer(source='user', read_only=True)
key = serializers.CharField(read_only=True) key = serializers.CharField(read_only=True)
@ -650,7 +637,7 @@ class StockTrackingSerializer(InvenTree.serializers.InvenTreeModelSerializer):
item_detail = StockItemSerializerBrief(source='item', many=False, read_only=True) item_detail = StockItemSerializerBrief(source='item', many=False, read_only=True)
user_detail = InvenTree.serializers.UserSerializerBrief(source='user', many=False, read_only=True) user_detail = InvenTree.serializers.UserSerializer(source='user', many=False, read_only=True)
deltas = serializers.JSONField(read_only=True) deltas = serializers.JSONField(read_only=True)

View File

@ -16,6 +16,7 @@
{% include "InvenTree/settings/setting.html" with key="REPORT_DEFAULT_PAGE_SIZE" icon="fa-print" %} {% include "InvenTree/settings/setting.html" with key="REPORT_DEFAULT_PAGE_SIZE" icon="fa-print" %}
{% include "InvenTree/settings/setting.html" with key="REPORT_DEBUG_MODE" icon="fa-laptop-code" %} {% include "InvenTree/settings/setting.html" with key="REPORT_DEBUG_MODE" icon="fa-laptop-code" %}
{% include "InvenTree/settings/setting.html" with key="REPORT_ENABLE_TEST_REPORT" icon="fa-vial" %} {% include "InvenTree/settings/setting.html" with key="REPORT_ENABLE_TEST_REPORT" icon="fa-vial" %}
{% include "InvenTree/settings/setting.html" with key="REPORT_ATTACH_TEST_REPORT" icon="fa-file-upload" %}
</tbody> </tbody>
</table> </table>

View File

@ -244,8 +244,14 @@ function loadAttachmentTable(url, options) {
{ {
field: 'upload_date', field: 'upload_date',
title: '{% trans "Upload Date" %}', title: '{% trans "Upload Date" %}',
formatter: function(value) { formatter: function(value, row) {
return renderDate(value); var html = renderDate(value);
if (row.user_detail) {
html += `<span class='badge bg-dark rounded-pill float-right'>${row.user_detail.username}</div>`;
}
return html;
} }
}, },
{ {

View File

@ -10,8 +10,9 @@ from rest_framework.authtoken.models import Token
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.views import APIView from rest_framework.views import APIView
from InvenTree.serializers import UserSerializer
from users.models import Owner, RuleSet, check_user_role from users.models import Owner, RuleSet, check_user_role
from users.serializers import OwnerSerializer, UserSerializer from users.serializers import OwnerSerializer
class OwnerList(generics.ListAPIView): class OwnerList(generics.ListAPIView):

View File

@ -1,6 +1,5 @@
"""DRF API serializers for the 'users' app""" """DRF API serializers for the 'users' app"""
from django.contrib.auth.models import User
from rest_framework import serializers from rest_framework import serializers
@ -9,19 +8,6 @@ from InvenTree.serializers import InvenTreeModelSerializer
from .models import Owner from .models import Owner
class UserSerializer(InvenTreeModelSerializer):
"""Serializer for a User."""
class Meta:
"""Metaclass defines serializer fields."""
model = User
fields = ('pk',
'username',
'first_name',
'last_name',
'email',)
class OwnerSerializer(InvenTreeModelSerializer): class OwnerSerializer(InvenTreeModelSerializer):
"""Serializer for an "Owner" (either a "user" or a "group")""" """Serializer for an "Owner" (either a "user" or a "group")"""