Skip to content

Commit

Permalink
Added test statistics
Browse files Browse the repository at this point in the history
Fixed #5995
  • Loading branch information
martonmiklos committed May 8, 2024
1 parent 6700a46 commit 2e08a3c
Show file tree
Hide file tree
Showing 17 changed files with 524 additions and 2 deletions.
5 changes: 5 additions & 0 deletions src/backend/InvenTree/InvenTree/static/css/inventree.css
Original file line number Diff line number Diff line change
Expand Up @@ -1101,3 +1101,8 @@ a {
.large-treeview-icon {
font-size: 1em;
}

.test-statistics-table-total-row {
font-weight: bold;
border-top-style: double;
}
6 changes: 6 additions & 0 deletions src/backend/InvenTree/build/templates/build/build_base.html
Original file line number Diff line number Diff line change
Expand Up @@ -302,6 +302,12 @@ <h3>
build: {{ build.pk }},
});
});

{% if build.part.trackable > 0 %}
onPanelLoad("test-statistics", function() {
prepareTestStatisticsTable('build', '{% url "api-stock-test-statistics" "by-build" build.pk %}')
});
{% endif %}
{% endif %}
{% endif %}

Expand Down
15 changes: 15 additions & 0 deletions src/backend/InvenTree/build/templates/build/detail.html
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,21 @@ <h4>
</div>
</div>

<div class='panel panel-hidden' id='panel-test-statistics'>
<div class='panel-heading'>
<h4>
{% trans "Build test statistics" %}
</h4>
</div>

<div class='panel-content'>
<div id='teststatistics-button-toolbar'>
{% include "filter_list.html" with id="buildteststatistics" %}
</div>
{% include "test_statistics_table.html" with prefix="build-" %}
</div>
</div>

<div class='panel panel-hidden' id='panel-attachments'>
<div class='panel-heading'>
<div class='d-flex flex-wrap'>
Expand Down
4 changes: 4 additions & 0 deletions src/backend/InvenTree/build/templates/build/sidebar.html
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@
{% include "sidebar_item.html" with label='consumed' text=text icon="fa-list" %}
{% trans "Child Build Orders" as text %}
{% include "sidebar_item.html" with label='children' text=text icon="fa-sitemap" %}
{% if build.part.trackable %}
{% trans "Test Statistics" as text %}
{% include "sidebar_item.html" with label='test-statistics' text=text icon="fa-chart-line" %}
{% endif %}
{% trans "Attachments" as text %}
{% include "sidebar_item.html" with label='attachments' text=text icon="fa-paperclip" %}
{% trans "Notes" as text %}
Expand Down
19 changes: 19 additions & 0 deletions src/backend/InvenTree/part/templates/part/detail.html
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,22 @@ <h4>{% trans "Part Test Templates" %}</h4>
</div>
</div>

<div class='panel panel-hidden' id='panel-test-statistics'>
<div class='panel-heading'>
<div class='d-flex flex-wrap'>
<h4>{% trans "Part Test Statistics" %}</h4>
{% include "spacer.html" %}
</div>
</div>
<div class='panel-content'>
<div id='teststatistics-button-toolbar'>
{% include "filter_list.html" with id="partteststatistics" %}
</div>

{% include "test_statistics_table.html" with prefix="part-" %}
</div>
</div>

<div class='panel panel-hidden' id='panel-purchase-orders'>
<div class='panel-heading'>
<div class='d-flex flex-wrap'>
Expand Down Expand Up @@ -754,6 +770,9 @@ <h4>{% trans "Part Manufacturers" %}</h4>
});
});
});
onPanelLoad("test-statistics", function() {
prepareTestStatisticsTable('part', '{% url "api-stock-test-statistics" "by-part" part.pk %}')
});

onPanelLoad("part-stock", function() {
$('#new-stock-item').click(function () {
Expand Down
2 changes: 2 additions & 0 deletions src/backend/InvenTree/part/templates/part/part_sidebar.html
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@
{% if part.trackable %}
{% trans "Test Templates" as text %}
{% include "sidebar_item.html" with label="test-templates" text=text icon="fa-vial" %}
{% trans "Test Statistics" as text %}
{% include "sidebar_item.html" with label="test-statistics" text=text icon="fa-chart-line" %}
{% endif %}
{% if show_related %}
{% trans "Related Parts" as text %}
Expand Down
64 changes: 63 additions & 1 deletion src/backend/InvenTree/stock/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,9 @@

from django_filters import rest_framework as rest_filters
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import extend_schema_field
from drf_spectacular.utils import extend_schema, extend_schema_field
from rest_framework import status
from rest_framework.generics import GenericAPIView
from rest_framework.response import Response
from rest_framework.serializers import ValidationError

Expand Down Expand Up @@ -1308,6 +1309,51 @@ def filter_test_name(self, queryset, name, value):
return queryset.filter(template__key=key)


class TestStatisticsFilter(rest_filters.FilterSet):
"""API filter for the filtering the test results belonging to a specific build."""

class Meta:
"""Metaclass options."""

model = StockItemTestResult
fields = []

# Created date filters
finished_before = InvenTreeDateFilter(
label='Finished before', field_name='finished_datetime', lookup_expr='lte'
)
finished_after = InvenTreeDateFilter(
label='Finished after', field_name='finished_datetime', lookup_expr='gte'
)


class TestStatistics(GenericAPIView):
"""API endpoint for accessing a test statistics broken down by test templates."""

queryset = StockItemTestResult.objects.all()
serializer_class = StockSerializers.TestStatisticsSerializer
pagination_class = None
filterset_class = TestStatisticsFilter
filter_backends = SEARCH_ORDER_FILTER_ALIAS

@extend_schema(
responses={200: StockSerializers.TestStatisticsSerializer(many=False)}
)
def get(self, request, pk, *args, **kwargs):
"""Return test execution count matrix broken downs by test result."""
instance = self.get_object()
serializer = self.get_serializer(instance)
serializer.context['type'] = kwargs['type']
serializer.context['finished_datetime_after'] = self.request.query_params.get(
'finished_datetime_after'
)
serializer.context['finished_datetime_before'] = self.request.query_params.get(
'finished_datetime_before'
)
serializer.context['pk'] = pk
return Response([serializer.data])


class StockItemTestResultList(StockItemTestResultMixin, ListCreateDestroyAPIView):
"""API endpoint for listing (and creating) a StockItemTestResult object."""

Expand Down Expand Up @@ -1672,6 +1718,22 @@ def destroy(self, request, *args, **kwargs):
),
]),
),
# Test statistics endpoints
path(
'test-statistics/',
include([
path(
'<str:type>/',
include([
path(
'<int:pk>/',
TestStatistics.as_view(),
name='api-stock-test-statistics',
)
]),
)
]),
),
# StockItemTracking API endpoints
path(
'track/',
Expand Down
63 changes: 63 additions & 0 deletions src/backend/InvenTree/stock/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
import InvenTree.tasks
import label.models
import report.models
from build import models as BuildModels
from company import models as CompanyModels
from InvenTree.fields import InvenTreeModelMoneyField, InvenTreeURLField
from InvenTree.status_codes import (
Expand All @@ -43,6 +44,7 @@
)
from part import models as PartModels
from plugin.events import trigger_event
from stock import models as StockModels
from users.models import Owner

logger = logging.getLogger('inventree')
Expand Down Expand Up @@ -2410,6 +2412,67 @@ def key(self):
"""Return key for test."""
return InvenTree.helpers.generateTestKey(self.test_name)

def calculate_test_statistics_for_test_template(
self, query_base, test_template, ret, start, end
):
"""Helper function to calculate the passed/failed/total tests count per test template type."""
query = query_base & Q(template=test_template.pk)
if start is not None and end is not None:
query = query & Q(started_datetime__range=(start, end))
elif start is not None and end is None:
query = query & Q(started_datetime__gt=start)
elif start is None and end is not None:
query = query & Q(started_datetime__lt=end)

passed = StockModels.StockItemTestResult.objects.filter(
query & Q(result=True)
).count()
failed = StockModels.StockItemTestResult.objects.filter(
query & ~Q(result=True)
).count()
if test_template.test_name not in ret:
ret[test_template.test_name] = {'passed': 0, 'failed': 0, 'total': 0}
ret[test_template.test_name]['passed'] += passed
ret[test_template.test_name]['failed'] += failed
ret[test_template.test_name]['total'] += passed + failed
ret['total']['passed'] += passed
ret['total']['failed'] += failed
ret['total']['total'] += passed + failed
return ret

def build_test_statistics(self, build_order_pk, start, end):
"""Generate a statistics matrix for each test template based on the test executions result counts."""
build = BuildModels.Build.objects.get(pk=build_order_pk)
if not build or not build.part.trackable:
return {}

test_templates = build.part.getTestTemplates()
ret = {'total': {'passed': 0, 'failed': 0, 'total': 0}}
for build_item in build.get_build_outputs():
for test_template in test_templates:
query_base = Q(stock_item=build_item)
ret = self.calculate_test_statistics_for_test_template(
query_base, test_template, ret, start, end
)
return ret

def part_test_statistics(self, part_pk, start, end):
"""Generate a statistics matrix for each test template based on the test executions result counts."""
part = PartModels.Part.objects.get(pk=part_pk)

if not part or not part.trackable:
return {}

test_templates = part.getTestTemplates()
ret = {'total': {'passed': 0, 'failed': 0, 'total': 0}}
for bo in part.stock_entries():
for test_template in test_templates:
query_base = Q(stock_item=bo)
ret = self.calculate_test_statistics_for_test_template(
query_base, test_template, ret, start, end
)
return ret

stock_item = models.ForeignKey(
StockItem, on_delete=models.CASCADE, related_name='test_results'
)
Expand Down
30 changes: 30 additions & 0 deletions src/backend/InvenTree/stock/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -676,6 +676,36 @@ def save(self):
item.uninstall_into_location(location, request.user, note)


class TestStatisticsLineField(serializers.DictField):
"""DRF field definition for one column of the test statistics."""

test_name = serializers.CharField()
results = serializers.DictField(child=serializers.IntegerField(min_value=0))


class TestStatisticsSerializer(serializers.Serializer):
"""DRF serializer class for the test statistics."""

results = serializers.ListField(child=TestStatisticsLineField(), read_only=True)

def to_representation(self, obj):
"""Just pass through the test statistics from the model."""
if self.context['type'] == 'by-part':
return obj.part_test_statistics(
self.context['pk'],
self.context['finished_datetime_after'],
self.context['finished_datetime_before'],
)
elif self.context['type'] == 'by-build':
return obj.build_test_statistics(
self.context['pk'],
self.context['finished_datetime_after'],
self.context['finished_datetime_before'],
)

raise ValidationError(_('Unsupported statistic type: ' + self.context['type']))


class ConvertStockItemSerializer(serializers.Serializer):
"""DRF serializer class for converting a StockItem to a valid variant part."""

Expand Down
1 change: 0 additions & 1 deletion src/backend/InvenTree/templates/js/translated/part.js
Original file line number Diff line number Diff line change
Expand Up @@ -2976,7 +2976,6 @@ function loadPartTestTemplateTable(table, options) {
});
}


/*
* Load a chart which displays projected scheduling information for a particular part.
* This takes into account:
Expand Down
Loading

0 comments on commit 2e08a3c

Please sign in to comment.