mirror of
https://github.com/inventree/InvenTree.git
synced 2025-04-28 19:46:46 +00:00
Added test statistics (#7164)
* Added test statistics Fixed #5995 * Bump API version * Fix javascript varible scopes * Fix javascript exports * Remove duplicated import * Add files modified by the pre-commit scripts * Move test statistics API urls to a separate endpoint * Merge test-statistics urls * Undo unrelated changes * Formatting fix * Fix API urls for test statistics in PUI * Fix prefixing in test statistic functions --------- Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
This commit is contained in:
parent
fdd9b7c77b
commit
3cbfcc11cb
@ -1,12 +1,15 @@
|
|||||||
"""InvenTree API version information."""
|
"""InvenTree API version information."""
|
||||||
|
|
||||||
# InvenTree API version
|
# InvenTree API version
|
||||||
INVENTREE_API_VERSION = 229
|
INVENTREE_API_VERSION = 230
|
||||||
|
|
||||||
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
|
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
|
||||||
|
|
||||||
|
|
||||||
INVENTREE_API_TEXT = """
|
INVENTREE_API_TEXT = """
|
||||||
|
v230 - 2024-05-05 : https://github.com/inventree/InvenTree/pull/7164
|
||||||
|
- Adds test statistics endpoint
|
||||||
|
|
||||||
v229 - 2024-07-31 : https://github.com/inventree/InvenTree/pull/7775
|
v229 - 2024-07-31 : https://github.com/inventree/InvenTree/pull/7775
|
||||||
- Add extra exportable fields to the BomItem serializer
|
- Add extra exportable fields to the BomItem serializer
|
||||||
|
|
||||||
|
@ -1112,3 +1112,8 @@ a {
|
|||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.test-statistics-table-total-row {
|
||||||
|
font-weight: bold;
|
||||||
|
border-top-style: double;
|
||||||
|
}
|
||||||
|
@ -35,6 +35,7 @@ from company.urls import company_urls, manufacturer_part_urls, supplier_part_url
|
|||||||
from order.urls import order_urls
|
from order.urls import order_urls
|
||||||
from part.urls import part_urls
|
from part.urls import part_urls
|
||||||
from plugin.urls import get_plugin_urls
|
from plugin.urls import get_plugin_urls
|
||||||
|
from stock.api import test_statistics_api_urls
|
||||||
from stock.urls import stock_urls
|
from stock.urls import stock_urls
|
||||||
from web.urls import api_urls as web_api_urls
|
from web.urls import api_urls as web_api_urls
|
||||||
from web.urls import urlpatterns as platform_urls
|
from web.urls import urlpatterns as platform_urls
|
||||||
@ -109,6 +110,7 @@ apipatterns = [
|
|||||||
),
|
),
|
||||||
]),
|
]),
|
||||||
),
|
),
|
||||||
|
path('test-statistics/', include(test_statistics_api_urls)),
|
||||||
path('user/', include(users.api.user_urls)),
|
path('user/', include(users.api.user_urls)),
|
||||||
path('web/', include(web_api_urls)),
|
path('web/', include(web_api_urls)),
|
||||||
# Plugin endpoints
|
# Plugin endpoints
|
||||||
|
@ -298,6 +298,12 @@ src="{% static 'img/blank_image.png' %}"
|
|||||||
build: {{ build.pk }},
|
build: {{ build.pk }},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
{% if build.part.trackable > 0 %}
|
||||||
|
onPanelLoad("test-statistics", function() {
|
||||||
|
prepareTestStatisticsTable('build', '{% url "api-test-statistics-by-build" build.pk %}')
|
||||||
|
});
|
||||||
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
@ -267,6 +267,21 @@
|
|||||||
</div>
|
</div>
|
||||||
</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 panel-hidden' id='panel-attachments'>
|
||||||
<div class='panel-heading'>
|
<div class='panel-heading'>
|
||||||
<div class='d-flex flex-wrap'>
|
<div class='d-flex flex-wrap'>
|
||||||
|
@ -20,6 +20,10 @@
|
|||||||
{% include "sidebar_item.html" with label='consumed' text=text icon="fa-tasks" %}
|
{% include "sidebar_item.html" with label='consumed' text=text icon="fa-tasks" %}
|
||||||
{% trans "Child Build Orders" as text %}
|
{% trans "Child Build Orders" as text %}
|
||||||
{% include "sidebar_item.html" with label='children' text=text icon="fa-sitemap" %}
|
{% 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 %}
|
{% trans "Attachments" as text %}
|
||||||
{% include "sidebar_item.html" with label='attachments' text=text icon="fa-paperclip" %}
|
{% include "sidebar_item.html" with label='attachments' text=text icon="fa-paperclip" %}
|
||||||
{% trans "Notes" as text %}
|
{% trans "Notes" as text %}
|
||||||
|
@ -100,6 +100,22 @@
|
|||||||
</div>
|
</div>
|
||||||
</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 panel-hidden' id='panel-purchase-orders'>
|
||||||
<div class='panel-heading'>
|
<div class='panel-heading'>
|
||||||
<div class='d-flex flex-wrap'>
|
<div class='d-flex flex-wrap'>
|
||||||
@ -751,6 +767,9 @@
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
onPanelLoad("test-statistics", function() {
|
||||||
|
prepareTestStatisticsTable('part', '{% url "api-test-statistics-by-part" part.pk %}')
|
||||||
|
});
|
||||||
|
|
||||||
onPanelLoad("part-stock", function() {
|
onPanelLoad("part-stock", function() {
|
||||||
$('#new-stock-item').click(function () {
|
$('#new-stock-item').click(function () {
|
||||||
|
@ -53,6 +53,8 @@
|
|||||||
{% if part.trackable %}
|
{% if part.trackable %}
|
||||||
{% trans "Test Templates" as text %}
|
{% trans "Test Templates" as text %}
|
||||||
{% include "sidebar_item.html" with label="test-templates" text=text icon="fa-vial" %}
|
{% 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 %}
|
{% endif %}
|
||||||
{% if show_related %}
|
{% if show_related %}
|
||||||
{% trans "Related Parts" as text %}
|
{% trans "Related Parts" as text %}
|
||||||
|
@ -13,7 +13,7 @@ from django.utils.translation import gettext_lazy as _
|
|||||||
|
|
||||||
from django_filters import rest_framework as rest_filters
|
from django_filters import rest_framework as rest_filters
|
||||||
from drf_spectacular.types import OpenApiTypes
|
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 permissions, status
|
from rest_framework import permissions, status
|
||||||
from rest_framework.generics import GenericAPIView
|
from rest_framework.generics import GenericAPIView
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
@ -1288,6 +1288,54 @@ class StockItemTestResultFilter(rest_filters.FilterSet):
|
|||||||
return queryset.filter(template__key=key)
|
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):
|
class StockItemTestResultList(StockItemTestResultMixin, ListCreateDestroyAPIView):
|
||||||
"""API endpoint for listing (and creating) a StockItemTestResult object."""
|
"""API endpoint for listing (and creating) a StockItemTestResult object."""
|
||||||
|
|
||||||
@ -1663,3 +1711,27 @@ stock_api_urls = [
|
|||||||
# Anything else
|
# Anything else
|
||||||
path('', StockList.as_view(), name='api-stock-list'),
|
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',
|
||||||
|
)
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
@ -33,6 +33,7 @@ import InvenTree.ready
|
|||||||
import InvenTree.tasks
|
import InvenTree.tasks
|
||||||
import report.mixins
|
import report.mixins
|
||||||
import report.models
|
import report.models
|
||||||
|
from build import models as BuildModels
|
||||||
from common.icons import validate_icon
|
from common.icons import validate_icon
|
||||||
from common.settings import get_global_setting
|
from common.settings import get_global_setting
|
||||||
from company import models as CompanyModels
|
from company import models as CompanyModels
|
||||||
@ -40,6 +41,7 @@ from InvenTree.fields import InvenTreeModelMoneyField, InvenTreeURLField
|
|||||||
from order.status_codes import SalesOrderStatusGroups
|
from order.status_codes import SalesOrderStatusGroups
|
||||||
from part import models as PartModels
|
from part import models as PartModels
|
||||||
from plugin.events import trigger_event
|
from plugin.events import trigger_event
|
||||||
|
from stock import models as StockModels
|
||||||
from stock.generators import generate_batch_code
|
from stock.generators import generate_batch_code
|
||||||
from stock.status_codes import StockHistoryCode, StockStatus, StockStatusGroups
|
from stock.status_codes import StockHistoryCode, StockStatus, StockStatusGroups
|
||||||
from users.models import Owner
|
from users.models import Owner
|
||||||
@ -2459,6 +2461,67 @@ class StockItemTestResult(InvenTree.models.InvenTreeMetadataModel):
|
|||||||
"""Return key for test."""
|
"""Return key for test."""
|
||||||
return InvenTree.helpers.generateTestKey(self.test_name)
|
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(
|
stock_item = models.ForeignKey(
|
||||||
StockItem, on_delete=models.CASCADE, related_name='test_results'
|
StockItem, on_delete=models.CASCADE, related_name='test_results'
|
||||||
)
|
)
|
||||||
|
@ -870,6 +870,36 @@ class UninstallStockItemSerializer(serializers.Serializer):
|
|||||||
item.uninstall_into_location(location, request.user, note)
|
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):
|
class ConvertStockItemSerializer(serializers.Serializer):
|
||||||
"""DRF serializer class for converting a StockItem to a valid variant part."""
|
"""DRF serializer class for converting a StockItem to a valid variant part."""
|
||||||
|
|
||||||
|
@ -15,6 +15,9 @@
|
|||||||
|
|
||||||
/* exported
|
/* exported
|
||||||
setupFilterList,
|
setupFilterList,
|
||||||
|
addTableFilter,
|
||||||
|
removeTableFilter,
|
||||||
|
reloadTableFilters
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
/* globals
|
/* globals
|
||||||
addCachedAlert,
|
addCachedAlert,
|
||||||
|
addTableFilter,
|
||||||
baseCurrency,
|
baseCurrency,
|
||||||
calculateTotalPrice,
|
calculateTotalPrice,
|
||||||
clearFormInput,
|
clearFormInput,
|
||||||
@ -38,8 +39,11 @@
|
|||||||
makeIconBadge,
|
makeIconBadge,
|
||||||
makeIconButton,
|
makeIconButton,
|
||||||
makeRemoveButton,
|
makeRemoveButton,
|
||||||
|
moment,
|
||||||
orderParts,
|
orderParts,
|
||||||
partDetail,
|
partDetail,
|
||||||
|
reloadTableFilters,
|
||||||
|
removeTableFilter,
|
||||||
renderClipboard,
|
renderClipboard,
|
||||||
renderDate,
|
renderDate,
|
||||||
renderLink,
|
renderLink,
|
||||||
@ -70,6 +74,7 @@
|
|||||||
duplicateStockItem,
|
duplicateStockItem,
|
||||||
editStockItem,
|
editStockItem,
|
||||||
editStockLocation,
|
editStockLocation,
|
||||||
|
filterTestStatisticsTableDateRange,
|
||||||
findStockItemBySerialNumber,
|
findStockItemBySerialNumber,
|
||||||
installStockItem,
|
installStockItem,
|
||||||
loadInstalledInTable,
|
loadInstalledInTable,
|
||||||
@ -78,6 +83,7 @@
|
|||||||
loadStockTestResultsTable,
|
loadStockTestResultsTable,
|
||||||
loadStockTrackingTable,
|
loadStockTrackingTable,
|
||||||
loadTableFilters,
|
loadTableFilters,
|
||||||
|
prepareTestStatisticsTable,
|
||||||
mergeStockItems,
|
mergeStockItems,
|
||||||
removeStockRow,
|
removeStockRow,
|
||||||
serializeStockItem,
|
serializeStockItem,
|
||||||
@ -3411,3 +3417,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']
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
@ -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
|
// Return a dictionary of filters for the "stocktracking" table
|
||||||
function getStockTrackingTableFilters() {
|
function getStockTrackingTableFilters() {
|
||||||
@ -863,6 +877,8 @@ function getAvailableTableFilters(tableKey) {
|
|||||||
return getBuildItemTableFilters();
|
return getBuildItemTableFilters();
|
||||||
case 'buildlines':
|
case 'buildlines':
|
||||||
return getBuildLineTableFilters();
|
return getBuildLineTableFilters();
|
||||||
|
case 'buildteststatistics':
|
||||||
|
return getTestStatisticsTableFilters();
|
||||||
case 'bom':
|
case 'bom':
|
||||||
return getBOMTableFilters();
|
return getBOMTableFilters();
|
||||||
case 'category':
|
case 'category':
|
||||||
@ -885,6 +901,8 @@ function getAvailableTableFilters(tableKey) {
|
|||||||
return getPartTableFilters();
|
return getPartTableFilters();
|
||||||
case 'parttests':
|
case 'parttests':
|
||||||
return getPartTestTemplateFilters();
|
return getPartTestTemplateFilters();
|
||||||
|
case 'partteststatistics':
|
||||||
|
return getTestStatisticsTableFilters();
|
||||||
case 'plugins':
|
case 'plugins':
|
||||||
return getPluginTableFilters();
|
return getPluginTableFilters();
|
||||||
case 'purchaseorder':
|
case 'purchaseorder':
|
||||||
|
22
src/backend/InvenTree/templates/test_statistics_table.html
Normal file
22
src/backend/InvenTree/templates/test_statistics_table.html
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
{% 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>
|
@ -115,6 +115,8 @@ export enum ApiEndpoints {
|
|||||||
stock_assign = 'stock/assign/',
|
stock_assign = 'stock/assign/',
|
||||||
stock_status = 'stock/status/',
|
stock_status = 'stock/status/',
|
||||||
stock_install = 'stock/:id/install',
|
stock_install = 'stock/:id/install',
|
||||||
|
build_test_statistics = 'test-statistics/by-build/:id',
|
||||||
|
part_test_statistics = 'test-statistics/by-part/:id',
|
||||||
|
|
||||||
// Generator API endpoints
|
// Generator API endpoints
|
||||||
generate_batch_code = 'generate/batch-code/',
|
generate_batch_code = 'generate/batch-code/',
|
||||||
|
@ -11,6 +11,7 @@ import {
|
|||||||
IconNotes,
|
IconNotes,
|
||||||
IconPaperclip,
|
IconPaperclip,
|
||||||
IconQrcode,
|
IconQrcode,
|
||||||
|
IconReportAnalytics,
|
||||||
IconSitemap
|
IconSitemap
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
@ -53,6 +54,7 @@ import { BuildOrderTable } from '../../tables/build/BuildOrderTable';
|
|||||||
import BuildOutputTable from '../../tables/build/BuildOutputTable';
|
import BuildOutputTable from '../../tables/build/BuildOutputTable';
|
||||||
import { AttachmentTable } from '../../tables/general/AttachmentTable';
|
import { AttachmentTable } from '../../tables/general/AttachmentTable';
|
||||||
import { StockItemTable } from '../../tables/stock/StockItemTable';
|
import { StockItemTable } from '../../tables/stock/StockItemTable';
|
||||||
|
import { TestStatisticsTable } from '../../tables/stock/TestStatisticsTable';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Detail page for a single Build Order
|
* Detail page for a single Build Order
|
||||||
@ -305,6 +307,20 @@ export default function BuildDetail() {
|
|||||||
<Skeleton />
|
<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?.trackable
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'attachments',
|
name: 'attachments',
|
||||||
label: t`Attachments`,
|
label: t`Attachments`,
|
||||||
|
@ -15,6 +15,7 @@ import {
|
|||||||
IconNotes,
|
IconNotes,
|
||||||
IconPackages,
|
IconPackages,
|
||||||
IconPaperclip,
|
IconPaperclip,
|
||||||
|
IconReportAnalytics,
|
||||||
IconShoppingCart,
|
IconShoppingCart,
|
||||||
IconStack2,
|
IconStack2,
|
||||||
IconTestPipe,
|
IconTestPipe,
|
||||||
@ -85,6 +86,7 @@ import { ManufacturerPartTable } from '../../tables/purchasing/ManufacturerPartT
|
|||||||
import { SupplierPartTable } from '../../tables/purchasing/SupplierPartTable';
|
import { SupplierPartTable } from '../../tables/purchasing/SupplierPartTable';
|
||||||
import { SalesOrderTable } from '../../tables/sales/SalesOrderTable';
|
import { SalesOrderTable } from '../../tables/sales/SalesOrderTable';
|
||||||
import { StockItemTable } from '../../tables/stock/StockItemTable';
|
import { StockItemTable } from '../../tables/stock/StockItemTable';
|
||||||
|
import { TestStatisticsTable } from '../../tables/stock/TestStatisticsTable';
|
||||||
import PartPricingPanel from './PartPricingPanel';
|
import PartPricingPanel from './PartPricingPanel';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -630,6 +632,22 @@ export default function PartDetail() {
|
|||||||
<Skeleton />
|
<Skeleton />
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'test_statistics',
|
||||||
|
label: t`Test Statistics`,
|
||||||
|
icon: <IconReportAnalytics />,
|
||||||
|
hidden: !part.trackable,
|
||||||
|
content: part?.pk ? (
|
||||||
|
<TestStatisticsTable
|
||||||
|
params={{
|
||||||
|
pk: part.pk,
|
||||||
|
apiEndpoint: ApiEndpoints.part_test_statistics
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Skeleton />
|
||||||
|
)
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'related_parts',
|
name: 'related_parts',
|
||||||
label: t`Related Parts`,
|
label: t`Related Parts`,
|
||||||
|
136
src/frontend/src/tables/stock/TestStatisticsTable.tsx
Normal file
136
src/frontend/src/tables/stock/TestStatisticsTable.tsx
Normal file
@ -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 (
|
||||||
|
<InvenTreeTable
|
||||||
|
url={apiUrl(params.apiEndpoint, params.pk) + '/'}
|
||||||
|
tableState={table}
|
||||||
|
columns={tableColumns}
|
||||||
|
props={{
|
||||||
|
dataFormatter: formatRecords,
|
||||||
|
enableDownload: false,
|
||||||
|
enableSearch: false,
|
||||||
|
enablePagination: false
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
Loading…
x
Reference in New Issue
Block a user