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

[Refactor] Remove 'test statistics' (#8487)

* [Refactor] Remove 'test statistics'

* Bump API version
This commit is contained in:
Oliver 2024-11-19 01:05:38 +11:00 committed by GitHub
parent 6bf6733268
commit 03c3417841
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 6 additions and 556 deletions

View File

@ -1,13 +1,17 @@
"""InvenTree API version information."""
# InvenTree API version
INVENTREE_API_VERSION = 281
INVENTREE_API_VERSION = 282
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
INVENTREE_API_TEXT = """
v282 - 2024-11-19 : https://github.com/inventree/InvenTree/pull/8487
- Remove the "test statistics" API endpoints
- This is now provided via a custom plugin
v281 - 2024-11-15 : https://github.com/inventree/InvenTree/pull/8480
- Fixes StockHistory API data serialization

View File

@ -1112,8 +1112,3 @@ a {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.test-statistics-table-total-row {
font-weight: bold;
border-top-style: double;
}

View File

@ -35,7 +35,6 @@ from company.urls import company_urls, manufacturer_part_urls, supplier_part_url
from order.urls import order_urls
from part.urls import part_urls
from plugin.urls import get_plugin_urls
from stock.api import test_statistics_api_urls
from stock.urls import stock_urls
from web.urls import api_urls as web_api_urls
from web.urls import urlpatterns as platform_urls
@ -110,7 +109,6 @@ apipatterns = [
),
]),
),
path('test-statistics/', include(test_statistics_api_urls)),
path('user/', include(users.api.user_urls)),
path('web/', include(web_api_urls)),
# Plugin endpoints

View File

@ -333,11 +333,6 @@ src="{% static 'img/blank_image.png' %}"
});
});
{% if build.part.testable %}
onPanelLoad("test-statistics", function() {
prepareTestStatisticsTable('build', '{% url "api-test-statistics-by-build" build.pk %}')
});
{% endif %}
{% endif %}
{% endif %}

View File

@ -267,21 +267,6 @@
</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'>

View File

@ -21,8 +21,6 @@
{% trans "Child Build Orders" as text %}
{% include "sidebar_item.html" with label='children' text=text icon="fa-sitemap" %}
{% if build.part.testable %}
{% 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" %}

View File

@ -100,22 +100,6 @@
</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'>
@ -767,9 +751,6 @@
});
});
});
onPanelLoad("test-statistics", function() {
prepareTestStatisticsTable('part', '{% url "api-test-statistics-by-part" part.pk %}')
});
onPanelLoad("part-stock", function() {
$('#new-stock-item').click(function () {

View File

@ -53,8 +53,6 @@
{% if part.testable %}
{% 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 %}

View File

@ -12,7 +12,7 @@ from django.utils.translation import gettext_lazy as _
from django_filters import rest_framework as rest_filters
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import extend_schema, extend_schema_field
from drf_spectacular.utils import extend_schema_field
from rest_framework import permissions, status
from rest_framework.generics import GenericAPIView
from rest_framework.response import Response
@ -1241,54 +1241,6 @@ class StockItemTestResultFilter(rest_filters.FilterSet):
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 down by test result."""
instance = self.get_object()
serializer = self.get_serializer(instance)
if request.resolver_match.url_name == 'api-test-statistics-by-part':
serializer.context['type'] = 'by-part'
elif request.resolver_match.url_name == 'api-test-statistics-by-build':
serializer.context['type'] = 'by-build'
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."""
@ -1666,27 +1618,3 @@ stock_api_urls = [
# Anything else
path('', StockList.as_view(), name='api-stock-list'),
]
test_statistics_api_urls = [
# Test statistics endpoints
path(
'by-part/',
include([
path(
'<int:pk>/',
TestStatistics.as_view(),
name='api-test-statistics-by-part',
)
]),
),
path(
'by-build/',
include([
path(
'<int:pk>/',
TestStatistics.as_view(),
name='api-test-statistics-by-build',
)
]),
),
]

View File

@ -34,7 +34,6 @@ import InvenTree.tasks
import report.mixins
import report.models
import stock.tasks
from build import models as BuildModels
from common.icons import validate_icon
from common.settings import get_global_setting
from company import models as CompanyModels
@ -48,7 +47,6 @@ from InvenTree.status_codes import (
)
from part import models as PartModels
from plugin.events import trigger_event
from stock import models as StockModels # noqa: PLW0406
from stock.generators import generate_batch_code
from users.models import Owner
@ -2579,67 +2577,6 @@ class StockItemTestResult(InvenTree.models.InvenTreeMetadataModel):
"""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'
)

View File

@ -904,36 +904,6 @@ class UninstallStockItemSerializer(serializers.Serializer):
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."""

View File

@ -2399,38 +2399,3 @@ class StockMetadataAPITest(InvenTreeAPITestCase):
'api-stock-item-metadata': StockItem,
}.items():
self.metatester(apikey, model)
class StockStatisticsTest(StockAPITestCase):
"""Tests for the StockStatistics API endpoints."""
fixtures = [*StockAPITestCase.fixtures, 'build']
def test_test_statistics(self):
"""Test the test statistics API endpoints."""
part = Part.objects.first()
response = self.get(
reverse('api-test-statistics-by-part', kwargs={'pk': part.pk}),
{},
expected_code=200,
)
self.assertEqual(response.data, [{}])
# Now trackable part
part1 = Part.objects.filter(trackable=True).first()
response = self.get(
reverse(
'api-test-statistics-by-part',
kwargs={'pk': part1.stock_items.first().pk},
),
{},
expected_code=404,
)
self.assertIn('detail', response.data)
# 105
bld = build.models.Build.objects.first()
url = reverse('api-test-statistics-by-build', kwargs={'pk': bld.pk})
response = self.get(url, {}, expected_code=200)
self.assertEqual(response.data, [{}])

View File

@ -74,7 +74,6 @@
duplicateStockItem,
editStockItem,
editStockLocation,
filterTestStatisticsTableDateRange,
findStockItemBySerialNumber,
installStockItem,
loadInstalledInTable,
@ -83,7 +82,6 @@
loadStockTestResultsTable,
loadStockTrackingTable,
loadTableFilters,
prepareTestStatisticsTable,
mergeStockItems,
removeStockRow,
serializeStockItem,
@ -3418,105 +3416,3 @@ 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']
let header = '';
let rows = []
let passed= '';
let failed = '';
let total = '';
$('.test-stat-result-cell').remove();
$.each(data[0], function(key, value){
if (key != "total") {
header += '<th class="test-stat-result-cell">' + key + '</th>';
keys.forEach(function(keyName) {
var tdText = '-';
if (value['total'] != '0' && value[keyName] != '0') {
let 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] += '<td class="test-stat-result-cell">' + tdText + '</td>';
})
}
});
$('#' + 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] += '<td class="test-stat-result-cell">' + valueStr + '</td>';
$('#' + 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"), keyName + 'teststatistics', options);
}
},
{
icon: 'fa-calendar-week',
title: '{% trans "This month" %}',
label: 'this-month',
callback: function(data) {
filterTestStatisticsTableDateRange(data, 'this-month', $("#test-statistics-table"), keyName + 'teststatistics', options);
}
},
],
}
],
callback: function(table, filters, options) {
loadTestStatisticsTable($("#test-statistics-table"), keyName, apiUrl, options, filters);
}
}
setupFilterList(keyName + 'teststatistics', $("#test-statistics-table"), '#filter-list-' + keyName + 'teststatistics', 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);
}

View File

@ -469,20 +469,6 @@ 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() {
@ -870,8 +856,6 @@ function getAvailableTableFilters(tableKey) {
return getBuildItemTableFilters();
case 'buildlines':
return getBuildLineTableFilters();
case 'buildteststatistics':
return getTestStatisticsTableFilters();
case 'bom':
return getBOMTableFilters();
case 'category':
@ -894,8 +878,6 @@ function getAvailableTableFilters(tableKey) {
return getPartTableFilters();
case 'parttests':
return getPartTestTemplateFilters();
case 'partteststatistics':
return getTestStatisticsTableFilters();
case 'plugins':
return getPluginTableFilters();
case 'purchaseorder':

View File

@ -1,22 +0,0 @@
{% load i18n %}
{% load inventree_extras %}
<table class='table table-striped table-condensed' id='{{ prefix }}test-statistics-table' style="display: none;">
<thead>
<tr>
<th id="{{ prefix }}test-statistics-table-header-id"></th>
<th id="{{ prefix }}test-statistics-table-header-total">{% trans "Total" %}</th>
</tr>
</thead>
<tbody>
<tr id="{{ prefix }}test-statistics-table-passed-row">
<td id="{{ prefix }}test-statistics-table-body-passed">{% trans "Passed" %}</td>
</tr>
<tr id="{{ prefix }}test-statistics-table-failed-row">
<td id="{{ prefix }}test-statistics-table-body-failed">{% trans "Failed" %}</td>
</tr>
<tr id="{{ prefix }}test-statistics-table-total-row" class="test-statistics-table-total-row">
<td id="{{ prefix }}test-statistics-table-body-total">{% trans "Total" %}</td>
</tr>
</tbody>
</table>

View File

@ -138,8 +138,6 @@ export enum ApiEndpoints {
stock_uninstall = 'stock/:id/uninstall/',
stock_serialize = 'stock/:id/serialize/',
stock_return = 'stock/:id/return/',
build_test_statistics = 'test-statistics/by-build/:id/',
part_test_statistics = 'test-statistics/by-part/:id/',
// Generator API endpoints
generate_batch_code = 'generate/batch-code/',

View File

@ -8,7 +8,6 @@ import {
IconList,
IconListCheck,
IconListNumbers,
IconReportAnalytics,
IconSitemap
} from '@tabler/icons-react';
import { useMemo } from 'react';
@ -57,7 +56,6 @@ import { BuildOrderTable } from '../../tables/build/BuildOrderTable';
import BuildOrderTestTable from '../../tables/build/BuildOrderTestTable';
import BuildOutputTable from '../../tables/build/BuildOutputTable';
import { StockItemTable } from '../../tables/stock/StockItemTable';
import { TestStatisticsTable } from '../../tables/stock/TestStatisticsTable';
/**
* Detail page for a single Build Order
@ -342,20 +340,6 @@ export default function BuildDetail() {
<Skeleton />
)
},
{
name: 'test-statistics',
label: t`Test Statistics`,
icon: <IconReportAnalytics />,
content: (
<TestStatisticsTable
params={{
pk: build.pk,
apiEndpoint: ApiEndpoints.build_test_statistics
}}
/>
),
hidden: !build?.part_detail?.testable
},
AttachmentPanel({
model_type: ModelType.build,
model_id: build.pk

View File

@ -21,7 +21,6 @@ import {
IconListTree,
IconLock,
IconPackages,
IconReportAnalytics,
IconShoppingCart,
IconStack2,
IconTestPipe,
@ -98,7 +97,6 @@ import { RelatedPartTable } from '../../tables/part/RelatedPartTable';
import { ReturnOrderTable } from '../../tables/sales/ReturnOrderTable';
import { SalesOrderTable } from '../../tables/sales/SalesOrderTable';
import { StockItemTable } from '../../tables/stock/StockItemTable';
import { TestStatisticsTable } from '../../tables/stock/TestStatisticsTable';
import PartAllocationPanel from './PartAllocationPanel';
import PartPricingPanel from './PartPricingPanel';
import PartSchedulingDetail from './PartSchedulingDetail';
@ -722,22 +720,6 @@ export default function PartDetail() {
<Skeleton />
)
},
{
name: 'test_statistics',
label: t`Test Statistics`,
icon: <IconReportAnalytics />,
hidden: !part.testable,
content: part?.pk ? (
<TestStatisticsTable
params={{
pk: part.pk,
apiEndpoint: ApiEndpoints.part_test_statistics
}}
/>
) : (
<Skeleton />
)
},
{
name: 'related_parts',
label: t`Related Parts`,

View File

@ -1,124 +0,0 @@
import { t } from '@lingui/macro';
import { useCallback, useMemo, useState } from 'react';
import { useTable } from '../../hooks/UseTable';
import { apiUrl } from '../../states/ApiState';
import type { TableColumn } from '../Column';
import { InvenTreeTable } from '../InvenTreeTable';
export function TestStatisticsTable({
params = {}
}: Readonly<{ params?: any }>) {
const initialColumns: TableColumn[] = [];
const [templateColumnList, setTemplateColumnList] = useState(initialColumns);
const testTemplateColumns: TableColumn[] = useMemo(() => {
const 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) {
const 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
const 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;
const newColumns: TableColumn[] = [];
for (const key in records[0]) {
if (key == 'total') continue;
const 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 (
<InvenTreeTable
url={`${apiUrl(params.apiEndpoint, params.pk)}/`}
tableState={table}
columns={tableColumns}
props={{
dataFormatter: formatRecords,
enableDownload: false,
enableSearch: false,
enablePagination: false
}}
/>
);
}