@@ -754,6 +770,9 @@
{% trans "Part Manufacturers" %}
});
});
});
+ onPanelLoad("test-statistics", function() {
+ prepareTestStatisticsTable('part', '{% url "api-stock-test-statistics" "by-part" part.pk %}')
+ });
onPanelLoad("part-stock", function() {
$('#new-stock-item').click(function () {
diff --git a/src/backend/InvenTree/part/templates/part/part_sidebar.html b/src/backend/InvenTree/part/templates/part/part_sidebar.html
index 368110d7294a..8579cbd5cce9 100644
--- a/src/backend/InvenTree/part/templates/part/part_sidebar.html
+++ b/src/backend/InvenTree/part/templates/part/part_sidebar.html
@@ -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 %}
diff --git a/src/backend/InvenTree/stock/api.py b/src/backend/InvenTree/stock/api.py
index f2b26743bcc2..952c11ef5ed7 100644
--- a/src/backend/InvenTree/stock/api.py
+++ b/src/backend/InvenTree/stock/api.py
@@ -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
@@ -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."""
@@ -1672,6 +1718,22 @@ def destroy(self, request, *args, **kwargs):
),
]),
),
+ # Test statistics endpoints
+ path(
+ 'test-statistics/',
+ include([
+ path(
+ '
/',
+ include([
+ path(
+ '/',
+ TestStatistics.as_view(),
+ name='api-stock-test-statistics',
+ )
+ ]),
+ )
+ ]),
+ ),
# StockItemTracking API endpoints
path(
'track/',
diff --git a/src/backend/InvenTree/stock/models.py b/src/backend/InvenTree/stock/models.py
index 2d5cfaeaff09..614327b5bf20 100644
--- a/src/backend/InvenTree/stock/models.py
+++ b/src/backend/InvenTree/stock/models.py
@@ -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 (
@@ -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')
@@ -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'
)
diff --git a/src/backend/InvenTree/stock/serializers.py b/src/backend/InvenTree/stock/serializers.py
index a820de9ccc68..534a0a114eaa 100644
--- a/src/backend/InvenTree/stock/serializers.py
+++ b/src/backend/InvenTree/stock/serializers.py
@@ -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."""
diff --git a/src/backend/InvenTree/templates/js/translated/part.js b/src/backend/InvenTree/templates/js/translated/part.js
index ffcea223d372..f16f81239993 100644
--- a/src/backend/InvenTree/templates/js/translated/part.js
+++ b/src/backend/InvenTree/templates/js/translated/part.js
@@ -2976,7 +2976,6 @@ function loadPartTestTemplateTable(table, options) {
});
}
-
/*
* Load a chart which displays projected scheduling information for a particular part.
* This takes into account:
diff --git a/src/backend/InvenTree/templates/js/translated/stock.js b/src/backend/InvenTree/templates/js/translated/stock.js
index e88f0602bbdc..5050d21bbd94 100644
--- a/src/backend/InvenTree/templates/js/translated/stock.js
+++ b/src/backend/InvenTree/templates/js/translated/stock.js
@@ -36,6 +36,7 @@
makeIconBadge,
makeIconButton,
makeRemoveButton,
+ moment,
orderParts,
partDetail,
renderClipboard,
@@ -68,6 +69,7 @@
duplicateStockItem,
editStockItem,
editStockLocation,
+ filterTestStatisticsTableDateRange,
findStockItemBySerialNumber,
installStockItem,
loadInstalledInTable,
@@ -76,6 +78,7 @@
loadStockTestResultsTable,
loadStockTrackingTable,
loadTableFilters,
+ prepareTestStatisticsTable,
mergeStockItems,
removeStockRow,
serializeStockItem,
@@ -3337,3 +3340,105 @@ function setStockStatus(items, options={}) {
}
});
}
+
+
+/*
+ * Load TestStatistics table.
+ */
+function loadTestStatisticsTable(table, prefix, url, options, filters = {}) {
+ inventreeGet(url, filters, {
+ async: true,
+ success: function(data) {
+ const keys = ['passed', 'failed', 'total']
+ header = '';
+ rows = []
+ passed= '';
+ failed = '';
+ total = '';
+ $('.test-stat-result-cell').remove();
+ $.each(data[0], function(key, value){
+ if (key != "total") {
+ header += '' + key + ' | ';
+ keys.forEach(function(keyName) {
+ var tdText = '-';
+ if (value['total'] != '0' && value[keyName] != '0') {
+ percentage = ''
+ if (keyName != 'total' && value[total] != 0) {
+ percentage = ' (' + (100.0 * (parseFloat(value[keyName]) / parseFloat(value['total']))).toLocaleString(undefined, {minimumFractionDigits: 0, maximumFractionDigits: 2}) + '%)';
+ }
+ tdText = value[keyName] + percentage;
+ }
+ rows[keyName] += '' + tdText + ' | ';
+ })
+ }
+ });
+ $('#' + prefix + '-test-statistics-table-header-id').after(header);
+
+ keys.forEach(function(keyName) {
+ let valueStr = data[0]['total'][keyName];
+ if (keyName != 'total' && data[0]['total']['total'] != '0') {
+ valueStr += ' (' + (100.0 * (parseFloat(valueStr) / parseFloat(data[0]['total']['total']))).toLocaleString(undefined, {minimumFractionDigits: 0, maximumFractionDigits: 2}) + '%)';
+ }
+ rows[keyName] += '' + valueStr + ' | ';
+ $('#' + prefix + '-test-statistics-table-body-' + keyName).after(rows[keyName]);
+ });
+ $('#' + prefix + '-test-statistics-table').show();
+ setupFilterList(prefix + "teststatistics", table, "#filter-list-" + prefix + "teststatistics", options);
+ },
+ });
+}
+
+function prepareTestStatisticsTable(keyName, apiUrl)
+{
+ let options = {
+ custom_actions: [
+ {
+ icon: 'fa-calendar-week',
+ actions: [
+ {
+ icon: 'fa-calendar-week',
+ title: '{% trans "This week" %}',
+ label: 'this-week',
+ callback: function(data) {
+ filterTestStatisticsTableDateRange(data, 'this-week', $("#test-statistics-table"), 'buildteststatistics', options);
+ }
+ },
+ {
+ icon: 'fa-calendar-week',
+ title: '{% trans "This month" %}',
+ label: 'this-month',
+ callback: function(data) {
+ filterTestStatisticsTableDateRange(data, 'this-month', $("#test-statistics-table"), 'buildteststatistics', options);
+ }
+ },
+ ],
+ }
+ ],
+ callback: function(table, filters, options) {
+ loadTestStatisticsTable($("#test-statistics-table"), keyName, apiUrl, options, filters);
+ }
+ }
+ setupFilterList('buildteststatistics', $("#test-statistics-table"), '#filter-list-buildteststatistics', options);
+
+ // Load test statistics table
+ loadTestStatisticsTable($("#test-statistics-table"), keyName, apiUrl, options);
+}
+
+function filterTestStatisticsTableDateRange(data, range, table, tableKey, options)
+{
+ var startDateString = '';
+ var d = new Date();
+ if (range == "this-week") {
+ startDateString = moment(new Date(d.getFullYear(), d.getMonth(), d.getDate() - (d.getDay() == 0 ? 6 : d.getDay() - 1))).format('YYYY-MM-DD');
+ } else if (range == "this-month") {
+ startDateString = moment(new Date(d.getFullYear(), d.getMonth(), 1)).format('YYYY-MM-DD');
+ } else {
+ console.warn(`Invalid range specified for filterTestStatisticsTableDateRange`);
+ return;
+ }
+ var filters = addTableFilter(tableKey, 'finished_datetime_after', startDateString);
+ removeTableFilter(tableKey, 'finished_datetime_before')
+
+ reloadTableFilters(table, filters, options);
+ setupFilterList(tableKey, table, "#filter-list-" + tableKey, options);
+}
diff --git a/src/backend/InvenTree/templates/js/translated/table_filters.js b/src/backend/InvenTree/templates/js/translated/table_filters.js
index 0c10b06f3325..2447c1289207 100644
--- a/src/backend/InvenTree/templates/js/translated/table_filters.js
+++ b/src/backend/InvenTree/templates/js/translated/table_filters.js
@@ -462,6 +462,20 @@ function getStockTestTableFilters() {
};
}
+// Return a dictionary of filters for the "test statistics" table
+function getTestStatisticsTableFilters() {
+
+ return {
+ finished_datetime_after: {
+ type: 'date',
+ title: '{% trans "Interval start" %}',
+ },
+ finished_datetime_before: {
+ type: 'date',
+ title: '{% trans "Interval end" %}',
+ }
+ };
+}
// Return a dictionary of filters for the "stocktracking" table
function getStockTrackingTableFilters() {
@@ -858,6 +872,8 @@ function getAvailableTableFilters(tableKey) {
return getBuildItemTableFilters();
case 'buildlines':
return getBuildLineTableFilters();
+ case 'buildteststatistics':
+ return getTestStatisticsTableFilters();
case 'bom':
return getBOMTableFilters();
case 'category':
@@ -880,6 +896,8 @@ function getAvailableTableFilters(tableKey) {
return getPartTableFilters();
case 'parttests':
return getPartTestTemplateFilters();
+ case 'partteststatistics':
+ return getTestStatisticsTableFilters();
case 'plugins':
return getPluginTableFilters();
case 'purchaseorder':
diff --git a/src/backend/InvenTree/templates/test_statistics_table.html b/src/backend/InvenTree/templates/test_statistics_table.html
new file mode 100644
index 000000000000..24361a3bf932
--- /dev/null
+++ b/src/backend/InvenTree/templates/test_statistics_table.html
@@ -0,0 +1,22 @@
+{% load i18n %}
+{% load inventree_extras %}
+
+
+
+
+
+
+
+
+
+
+ {% trans "Passed" %} |
+
+
+ {% trans "Failed" %} |
+
+
+ {% trans "Total" %} |
+
+
+
diff --git a/src/frontend/src/enums/ApiEndpoints.tsx b/src/frontend/src/enums/ApiEndpoints.tsx
index e8e5ebc844fd..48ef56918ae8 100644
--- a/src/frontend/src/enums/ApiEndpoints.tsx
+++ b/src/frontend/src/enums/ApiEndpoints.tsx
@@ -106,6 +106,8 @@ export enum ApiEndpoints {
stock_assign = 'stock/assign/',
stock_status = 'stock/status/',
stock_install = 'stock/:id/install',
+ build_test_statistics = 'stock/test-statistics/by-build/:id',
+ part_test_statistics = 'stock/test-statistics/by-part/:id',
// Order API endpoints
purchase_order_list = 'order/po/',
diff --git a/src/frontend/src/pages/build/BuildDetail.tsx b/src/frontend/src/pages/build/BuildDetail.tsx
index fbbffa5e8199..c5407f95ee33 100644
--- a/src/frontend/src/pages/build/BuildDetail.tsx
+++ b/src/frontend/src/pages/build/BuildDetail.tsx
@@ -12,6 +12,7 @@ import {
IconPaperclip,
IconPrinter,
IconQrcode,
+ IconReportAnalytics,
IconSitemap
} from '@tabler/icons-react';
import { useMemo } from 'react';
@@ -49,6 +50,7 @@ import { BuildOrderTable } from '../../tables/build/BuildOrderTable';
import BuildOutputTable from '../../tables/build/BuildOutputTable';
import { AttachmentTable } from '../../tables/general/AttachmentTable';
import { StockItemTable } from '../../tables/stock/StockItemTable';
+import { TestStatisticsTable } from '../../tables/stock/TestStatisticsTable';
/**
* Detail page for a single Build Order
@@ -283,6 +285,20 @@ export default function BuildDetail() {
)
},
+ {
+ name: 'test-statistics',
+ label: t`Test Statistics`,
+ icon: ,
+ content: (
+
+ ),
+ hidden: !build?.part_detail?.trackable
+ },
{
name: 'attachments',
label: t`Attachments`,
diff --git a/src/frontend/src/pages/part/PartDetail.tsx b/src/frontend/src/pages/part/PartDetail.tsx
index d72907186b7a..7e8e6fb498d0 100644
--- a/src/frontend/src/pages/part/PartDetail.tsx
+++ b/src/frontend/src/pages/part/PartDetail.tsx
@@ -15,6 +15,7 @@ import {
IconNotes,
IconPackages,
IconPaperclip,
+ IconReportAnalytics,
IconShoppingCart,
IconStack2,
IconTestPipe,
@@ -77,6 +78,7 @@ import { ManufacturerPartTable } from '../../tables/purchasing/ManufacturerPartT
import { SupplierPartTable } from '../../tables/purchasing/SupplierPartTable';
import { SalesOrderTable } from '../../tables/sales/SalesOrderTable';
import { StockItemTable } from '../../tables/stock/StockItemTable';
+import { TestStatisticsTable } from '../../tables/stock/TestStatisticsTable';
import PartPricingPanel from './PartPricingPanel';
/**
@@ -590,6 +592,22 @@ export default function PartDetail() {
)
},
+ {
+ name: 'test_statistics',
+ label: t`Test Statistics`,
+ icon: ,
+ hidden: !part.trackable,
+ content: part?.pk ? (
+
+ ) : (
+
+ )
+ },
{
name: 'related_parts',
label: t`Related Parts`,
diff --git a/src/frontend/src/tables/stock/TestStatisticsTable.tsx b/src/frontend/src/tables/stock/TestStatisticsTable.tsx
new file mode 100644
index 000000000000..c7706ec0934f
--- /dev/null
+++ b/src/frontend/src/tables/stock/TestStatisticsTable.tsx
@@ -0,0 +1,136 @@
+import { t } from '@lingui/macro';
+import { useCallback, useMemo, useState } from 'react';
+import { useNavigate } from 'react-router-dom';
+
+import { AddItemButton } from '../../components/buttons/AddItemButton';
+import { ApiEndpoints } from '../../enums/ApiEndpoints';
+import { ModelType } from '../../enums/ModelType';
+import { UserRoles } from '../../enums/Roles';
+import { stockLocationFields } from '../../forms/StockForms';
+import { getDetailUrl } from '../../functions/urls';
+import {
+ useCreateApiFormModal,
+ useEditApiFormModal
+} from '../../hooks/UseForm';
+import { useTable } from '../../hooks/UseTable';
+import { apiUrl } from '../../states/ApiState';
+import { useUserState } from '../../states/UserState';
+import { TableColumn } from '../Column';
+import { BooleanColumn, DescriptionColumn } from '../ColumnRenderers';
+import { TableFilter } from '../Filter';
+import { InvenTreeTable } from '../InvenTreeTable';
+
+export function TestStatisticsTable({ params = {} }: { params?: any }) {
+ const initialColumns: TableColumn[] = [];
+ const [templateColumnList, setTemplateColumnList] = useState(initialColumns);
+
+ const testTemplateColumns: TableColumn[] = useMemo(() => {
+ let data = templateColumnList ?? [];
+ return data;
+ }, [templateColumnList]);
+
+ const tableColumns: TableColumn[] = useMemo(() => {
+ const firstColumn: TableColumn = {
+ accessor: 'col_0',
+ title: '',
+ sortable: true,
+ switchable: false,
+ noWrap: true
+ };
+
+ const lastColumn: TableColumn = {
+ accessor: 'col_total',
+ sortable: true,
+ switchable: false,
+ noWrap: true,
+ title: t`Total`
+ };
+
+ return [firstColumn, ...testTemplateColumns, lastColumn];
+ }, [testTemplateColumns]);
+
+ function statCountString(count: number, total: number) {
+ if (count > 0) {
+ let percentage =
+ ' (' +
+ ((100.0 * count) / total).toLocaleString(undefined, {
+ minimumFractionDigits: 0,
+ maximumFractionDigits: 2
+ }) +
+ '%)';
+ return count.toString() + percentage;
+ }
+ return '-';
+ }
+
+ // Format the test results based on the returned data
+ const formatRecords = useCallback((records: any[]): any[] => {
+ // interface needed to being able to dynamically assign keys
+ interface ResultRow {
+ [key: string]: string;
+ }
+ // Construct a list of test templates
+ let results: ResultRow[] = [
+ { id: 'row_passed', col_0: t`Passed` },
+ { id: 'row_failed', col_0: t`Failed` },
+ { id: 'row_total', col_0: t`Total` }
+ ];
+ let columnIndex = 0;
+
+ columnIndex = 1;
+
+ let newColumns: TableColumn[] = [];
+ for (let key in records[0]) {
+ if (key == 'total') continue;
+ let acc = 'col_' + columnIndex.toString();
+
+ const resultKeys = ['passed', 'failed', 'total'];
+
+ results[0][acc] = statCountString(
+ records[0][key]['passed'],
+ records[0][key]['total']
+ );
+ results[1][acc] = statCountString(
+ records[0][key]['failed'],
+ records[0][key]['total']
+ );
+ results[2][acc] = records[0][key]['total'].toString();
+
+ newColumns.push({
+ accessor: 'col_' + columnIndex.toString(),
+ title: key
+ });
+ columnIndex++;
+ }
+
+ setTemplateColumnList(newColumns);
+
+ results[0]['col_total'] = statCountString(
+ records[0]['total']['passed'],
+ records[0]['total']['total']
+ );
+ results[1]['col_total'] = statCountString(
+ records[0]['total']['failed'],
+ records[0]['total']['total']
+ );
+ results[2]['col_total'] = records[0]['total']['total'].toString();
+
+ return results;
+ }, []);
+
+ const table = useTable('teststatistics');
+
+ return (
+
+ );
+}