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
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):
"""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
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():
"""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
Why? You can't handle the why!
Actually, if the server process is serving the data at 127.0.0.1,
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,
then an attachment which prefixes the "address" of the internal server
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.
"""
user_detail = UserSerializer(source='user', read_only=True, many=False)
attachment = InvenTreeAttachmentSerializerField(
required=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."""
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."""
queryset = BuildOrderAttachment.objects.all()

View File

@ -11,7 +11,7 @@ from rest_framework import serializers
from rest_framework.serializers import ValidationError
from InvenTree.serializers import InvenTreeModelSerializer, InvenTreeAttachmentSerializer
from InvenTree.serializers import UserSerializerBrief, ReferenceIndexingSerializerMixin
from InvenTree.serializers import ReferenceIndexingSerializerMixin, UserSerializer
import InvenTree.helpers
from InvenTree.helpers import extract_serial_numbers
@ -40,7 +40,7 @@ class BuildSerializer(ReferenceIndexingSerializerMixin, InvenTreeModelSerializer
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)
@ -860,6 +860,8 @@ class BuildAttachmentSerializer(InvenTreeAttachmentSerializer):
'filename',
'comment',
'upload_date',
'user',
'user_detail',
]
read_only_fields = [

View File

@ -965,12 +965,19 @@ class InvenTreeSetting(BaseInvenTreeSetting):
},
'REPORT_ENABLE_TEST_REPORT': {
'name': _('Test Reports'),
'name': _('Enable Test Reports'),
'description': _('Enable generation of test reports'),
'default': True,
'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': {
'name': _('Batch Code Template'),
'description': _('Template for generating default batch codes for stock items'),

View File

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

View File

@ -527,7 +527,7 @@ class PurchaseOrderExtraLineDetail(generics.RetrieveUpdateDestroyAPIView):
serializer_class = serializers.PurchaseOrderExtraLineSerializer
class SalesOrderAttachmentList(generics.ListCreateAPIView, AttachmentMixin):
class SalesOrderAttachmentList(AttachmentMixin, generics.ListCreateAPIView):
"""API endpoint for listing (and creating) a SalesOrderAttachment (file upload)"""
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."""
queryset = models.SalesOrderAttachment.objects.all()
@ -1056,7 +1056,7 @@ class SalesOrderShipmentComplete(generics.CreateAPIView):
return ctx
class PurchaseOrderAttachmentList(generics.ListCreateAPIView, AttachmentMixin):
class PurchaseOrderAttachmentList(AttachmentMixin, generics.ListCreateAPIView):
"""API endpoint for listing (and creating) a PurchaseOrderAttachment (file upload)"""
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."""
queryset = models.PurchaseOrderAttachment.objects.all()

View File

@ -630,6 +630,8 @@ class PurchaseOrderAttachmentSerializer(InvenTreeAttachmentSerializer):
'filename',
'comment',
'upload_date',
'user',
'user_detail',
]
read_only_fields = [
@ -1348,6 +1350,8 @@ class SalesOrderAttachmentSerializer(InvenTreeAttachmentSerializer):
'link',
'comment',
'upload_date',
'user',
'user_detail',
]
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)."""
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."""
queryset = PartAttachment.objects.all()

View File

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

View File

@ -1,6 +1,7 @@
"""API functionality for the 'report' app"""
from django.core.exceptions import FieldError, ValidationError
from django.core.files.base import ContentFile
from django.http import HttpResponse
from django.template.exceptions import TemplateDoesNotExist
from django.urls import include, path, re_path
@ -15,7 +16,7 @@ import common.models
import InvenTree.helpers
import order.models
import part.models
from stock.models import StockItem
from stock.models import StockItem, StockItemAttachment
from .models import (BillOfMaterialsReport, BuildReport, PurchaseOrderReport,
SalesOrderReport, TestReport)
@ -158,6 +159,18 @@ class PartReportMixin:
class ReportPrintMixin:
"""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):
"""Print this report template against a number of pre-validated items."""
if len(items_to_print) == 0:
@ -182,12 +195,16 @@ class ReportPrintMixin:
report.object_to_print = item
report_name = report.generate_filename(request)
output = report.render(request)
# Run report callback for each generated report
self.report_callback(item, output, request)
try:
if debug_mode:
outputs.append(report.render_as_string(request))
else:
outputs.append(report.render(request))
outputs.append(output)
except TemplateDoesNotExist as e:
template = str(e)
if not template:
@ -326,6 +343,22 @@ class StockItemTestReportPrint(generics.RetrieveAPIView, StockItemReportMixin, R
queryset = TestReport.objects.all()
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):
"""Check if valid stock item(s) have been provided."""
items = self.get_items()

View File

@ -9,9 +9,9 @@ from django.urls import reverse
import report.models as report_models
from build.models import Build
from common.models import InvenTreeUserSetting
from common.models import InvenTreeSetting, InvenTreeUserSetting
from InvenTree.api_tester import InvenTreeAPITestCase
from stock.models import StockItem
from stock.models import StockItem, StockItemAttachment
class ReportTest(InvenTreeAPITestCase):
@ -141,15 +141,28 @@ class TestReportTest(ReportTest):
# Now print with a valid StockItem
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)
self.assertEqual(type(response), StreamingHttpResponse)
headers = response.headers
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):
"""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)."""
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."""
queryset = StockItemAttachment.objects.all()

View File

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

View File

@ -549,19 +549,6 @@ class LocationSerializer(InvenTree.serializers.InvenTreeModelSerializer):
class StockItemAttachmentSerializer(InvenTree.serializers.InvenTreeAttachmentSerializer):
"""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:
"""Metaclass options."""
@ -589,7 +576,7 @@ class StockItemAttachmentSerializer(InvenTree.serializers.InvenTreeAttachmentSer
class StockItemTestResultSerializer(InvenTree.serializers.InvenTreeModelSerializer):
"""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)
@ -650,7 +637,7 @@ class StockTrackingSerializer(InvenTree.serializers.InvenTreeModelSerializer):
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)

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_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_ATTACH_TEST_REPORT" icon="fa-file-upload" %}
</tbody>
</table>

View File

@ -244,8 +244,14 @@ function loadAttachmentTable(url, options) {
{
field: 'upload_date',
title: '{% trans "Upload Date" %}',
formatter: function(value) {
return renderDate(value);
formatter: function(value, row) {
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.views import APIView
from InvenTree.serializers import UserSerializer
from users.models import Owner, RuleSet, check_user_role
from users.serializers import OwnerSerializer, UserSerializer
from users.serializers import OwnerSerializer
class OwnerList(generics.ListAPIView):

View File

@ -1,6 +1,5 @@
"""DRF API serializers for the 'users' app"""
from django.contrib.auth.models import User
from rest_framework import serializers
@ -9,19 +8,6 @@ from InvenTree.serializers import InvenTreeModelSerializer
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):
"""Serializer for an "Owner" (either a "user" or a "group")"""