diff --git a/tcms/rpc/api/testcase.py b/tcms/rpc/api/testcase.py index f1c7dcb7f9..31212fa0ea 100644 --- a/tcms/rpc/api/testcase.py +++ b/tcms/rpc/api/testcase.py @@ -6,6 +6,7 @@ from django.db.utils import NotSupportedError from django.forms import EmailField, ValidationError from django.forms.models import model_to_dict +from django.utils.dateparse import parse_duration from modernrpc.core import REQUEST_KEY, rpc_method from tcms.core import helpers @@ -15,6 +16,49 @@ from tcms.rpc.decorators import permissions_required from tcms.testcases.models import Property, TestCase, TestCasePlan +_DURATION_FILTER_FIELDS = ("setup_duration", "testing_duration", "expected_duration") +_DURATION_FILTER_LOOKUPS = ("", "exact", "gt", "gte", "lt", "lte") + + +def _expected_duration_expression(): + return Coalesce("setup_duration", timedelta(0)) + Coalesce( + "testing_duration", timedelta(0) + ) + + +def _duration_filter_value(value): + if isinstance(value, timedelta): + return value + + if isinstance(value, (int, float)): + return timedelta(seconds=value) + + if isinstance(value, str): + value = value.strip() + if value == "": + return value + + try: + return timedelta(seconds=float(value)) + except ValueError: + parsed = parse_duration(value) + if parsed is not None: + return parsed + + return value + + +def _normalize_duration_filter_query(query): + query = query.copy() + + for field in _DURATION_FILTER_FIELDS: + for lookup in _DURATION_FILTER_LOOKUPS: + key = field if lookup == "" else f"{field}__{lookup}" + if key in query: + query[key] = _duration_filter_value(query[key]) + + return query + @permissions_required("testcases.add_testcasecomponent") @rpc_method(name="TestCase.add_component") @@ -267,13 +311,16 @@ def filter(query=None): # pylint: disable=redefined-builtin """ if query is None: query = {} + query = _normalize_duration_filter_query(query) - test_case_ids = TestCase.objects.filter(**query).values("id") + test_cases = TestCase.objects.annotate( + expected_duration=_expected_duration_expression() + ) + test_case_ids = test_cases.filter(**query).values("id") qs = ( # note: queries from HistoricalTestCase TestCase.history.annotate( # pylint: disable=no-member - expected_duration=Coalesce("setup_duration", timedelta(0)) - + Coalesce("testing_duration", timedelta(0)) + expected_duration=_expected_duration_expression() ) .filter(id__in=test_case_ids) .values( diff --git a/tcms/rpc/tests/test_testcase.py b/tcms/rpc/tests/test_testcase.py index ef22180890..2a335d4a0c 100644 --- a/tcms/rpc/tests/test_testcase.py +++ b/tcms/rpc/tests/test_testcase.py @@ -247,6 +247,42 @@ def test_duration_properties_in_result( self.assertEqual(result[0]["testing_duration"], testing_duration) self.assertEqual(result[0]["expected_duration"], expected_duration) + def test_filter_by_setup_duration_range(self): + matching = TestCaseFactory(setup_duration=timedelta(minutes=2)) + TestCaseFactory(setup_duration=timedelta(seconds=30)) + + result = self.rpc_client.TestCase.filter( + {"setup_duration__gte": "60", "setup_duration__lte": "180"} + ) + + self.assertEqual([matching.pk], [testcase["id"] for testcase in result]) + + def test_filter_by_testing_duration_range(self): + matching = TestCaseFactory(testing_duration=timedelta(minutes=3)) + TestCaseFactory(testing_duration=timedelta(minutes=5)) + + result = self.rpc_client.TestCase.filter( + {"testing_duration__gte": "120", "testing_duration__lte": "240"} + ) + + self.assertEqual([matching.pk], [testcase["id"] for testcase in result]) + + def test_filter_by_expected_duration_range(self): + matching = TestCaseFactory( + setup_duration=timedelta(minutes=1), + testing_duration=timedelta(minutes=2), + ) + TestCaseFactory( + setup_duration=timedelta(minutes=3), + testing_duration=timedelta(minutes=3), + ) + + result = self.rpc_client.TestCase.filter( + {"expected_duration__gte": "180", "expected_duration__lte": "240"} + ) + + self.assertEqual([matching.pk], [testcase["id"] for testcase in result]) + class TestUpdate(APITestCase): non_existing_username = "FakeUsername" diff --git a/tcms/testcases/static/testcases/js/search.js b/tcms/testcases/static/testcases/js/search.js index c5378b1ed4..cacfe0ffb0 100644 --- a/tcms/testcases/static/testcases/js/search.js +++ b/tcms/testcases/static/testcases/js/search.js @@ -111,6 +111,8 @@ export function pageTestcasesSearchReadyHandler () { params.executions__run__in = [$('#id_run').val()] }; + updateDurationFilterParams(params) + const testPlanIds = selectedPlanIds() if (testPlanIds.length) { params.plan__in = testPlanIds @@ -245,6 +247,33 @@ export function pageTestcasesSearchReadyHandler () { } } +function updateDurationFilterParams (params) { + const filters = { + setup_duration: { + min: $('#id_setup_duration_min').val(), + max: $('#id_setup_duration_max').val() + }, + testing_duration: { + min: $('#id_testing_duration_min').val(), + max: $('#id_testing_duration_max').val() + }, + expected_duration: { + min: $('#id_expected_duration_min').val(), + max: $('#id_expected_duration_max').val() + } + } + + for (const field of Object.keys(filters)) { + if (filters[field].min) { + params[`${field}__gte`] = filters[field].min + } + + if (filters[field].max) { + params[`${field}__lte`] = filters[field].max + } + } +} + function selectedPlanIds () { const selectedIds = $('#id_test_plan').val() const childIds = [] diff --git a/tcms/testcases/templates/testcases/search.html b/tcms/testcases/templates/testcases/search.html index 085e8605c7..9983ff4c41 100644 --- a/tcms/testcases/templates/testcases/search.html +++ b/tcms/testcases/templates/testcases/search.html @@ -149,6 +149,38 @@ +