diff --git a/tcms/rpc/api/testcase.py b/tcms/rpc/api/testcase.py index f1c7dcb7f9..5222a16803 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 @@ -16,6 +17,44 @@ from tcms.testcases.models import Property, TestCase, TestCasePlan +DURATION_FILTER_FIELDS = ("setup_duration", "testing_duration", "expected_duration") + + +def _duration_filter_value(value): + if isinstance(value, timedelta): + return value + + if isinstance(value, (int, float)): + return timedelta(seconds=value) + + if isinstance(value, str): + try: + return timedelta(seconds=float(value)) + except ValueError: + duration = parse_duration(value) + if duration is not None: + return duration + + return value + + +def _normalize_duration_filters(query): + normalized_query = {} + + for key, value in query.items(): + field_name, _separator, lookup = key.partition("__") + if field_name not in DURATION_FILTER_FIELDS or lookup == "isnull": + normalized_query[key] = value + continue + + if lookup == "in": + normalized_query[key] = [_duration_filter_value(item) for item in value] + else: + normalized_query[key] = _duration_filter_value(value) + + return normalized_query + + @permissions_required("testcases.add_testcasecomponent") @rpc_method(name="TestCase.add_component") def add_component(case_id, component): @@ -268,7 +307,15 @@ def filter(query=None): # pylint: disable=redefined-builtin if query is None: query = {} - test_case_ids = TestCase.objects.filter(**query).values("id") + query = _normalize_duration_filters(query) + test_case_ids = ( + TestCase.objects.annotate( + expected_duration=Coalesce("setup_duration", timedelta(0)) + + Coalesce("testing_duration", timedelta(0)) + ) + .filter(**query) + .values("id") + ) qs = ( # note: queries from HistoricalTestCase TestCase.history.annotate( # pylint: disable=no-member diff --git a/tcms/rpc/tests/test_testcase.py b/tcms/rpc/tests/test_testcase.py index ef22180890..adcfb03db2 100644 --- a/tcms/rpc/tests/test_testcase.py +++ b/tcms/rpc/tests/test_testcase.py @@ -247,6 +247,41 @@ def test_duration_properties_in_result( self.assertEqual(result[0]["testing_duration"], testing_duration) self.assertEqual(result[0]["expected_duration"], expected_duration) + @parameterized.expand( + [ + ("setup_duration_min", "setup_duration__gte", 120), + ("setup_duration_max", "setup_duration__lte", "00:02:00"), + ("testing_duration_min", "testing_duration__gte", 300), + ("testing_duration_max", "testing_duration__lte", "00:05:00"), + ("expected_duration_min", "expected_duration__gte", 420), + ("expected_duration_max", "expected_duration__lte", "00:07:00"), + ] + ) + def test_filter_by_duration_fields(self, _name, lookup, value): + matching_case = TestCaseFactory( + setup_duration=timedelta(minutes=2), + testing_duration=timedelta(minutes=5), + ) + matching_case.save() + if lookup.endswith("__gte"): + excluded_setup_duration = timedelta(seconds=1) + excluded_testing_duration = timedelta(seconds=1) + else: + excluded_setup_duration = timedelta(minutes=10) + excluded_testing_duration = timedelta(minutes=15) + + excluded_case = TestCaseFactory( + setup_duration=excluded_setup_duration, + testing_duration=excluded_testing_duration, + ) + excluded_case.save() + + result = self.rpc_client.TestCase.filter({lookup: value}) + result_ids = [testcase["id"] for testcase in result] + + self.assertIn(matching_case.pk, result_ids) + self.assertNotIn(excluded_case.pk, result_ids) + 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..7b55715645 100644 --- a/tcms/testcases/static/testcases/js/search.js +++ b/tcms/testcases/static/testcases/js/search.js @@ -62,6 +62,13 @@ function preProcessData (data, callbackF) { }) } +function updateDurationFilter (selector, filterName, params) { + const value = $(selector).val() + if (value !== '') { + params[filterName] = Number(value) + } +} + export function pageTestcasesSearchReadyHandler () { initializeDateTimePicker('#id_before') initializeDateTimePicker('#id_after') @@ -130,6 +137,12 @@ export function pageTestcasesSearchReadyHandler () { }; updateParamsToSearchTags('#id_tag', params) + updateDurationFilter('#id_setup_duration__gte', 'setup_duration__gte', params) + updateDurationFilter('#id_setup_duration__lte', 'setup_duration__lte', params) + updateDurationFilter('#id_testing_duration__gte', 'testing_duration__gte', params) + updateDurationFilter('#id_testing_duration__lte', 'testing_duration__lte', params) + updateDurationFilter('#id_expected_duration__gte', 'expected_duration__gte', params) + updateDurationFilter('#id_expected_duration__lte', 'expected_duration__lte', params) dataTableJsonRPC('TestCase.filter', params, callbackF, preProcessData) }, diff --git a/tcms/testcases/templates/testcases/search.html b/tcms/testcases/templates/testcases/search.html index 085e8605c7..ec14eff56f 100644 --- a/tcms/testcases/templates/testcases/search.html +++ b/tcms/testcases/templates/testcases/search.html @@ -149,6 +149,44 @@ +
+ +
+
+ {% trans "Min" %} + + {% trans "Max" %} + +
+
+ + +
+
+ {% trans "Min" %} + + {% trans "Max" %} + +
+
+ + +
+
+ {% trans "Min" %} + + {% trans "Max" %} + +
+
+
+
diff --git a/tcms/testcases/tests/test_views.py b/tcms/testcases/tests/test_views.py index 4073d0d77f..7f54c77e30 100644 --- a/tcms/testcases/tests/test_views.py +++ b/tcms/testcases/tests/test_views.py @@ -531,6 +531,12 @@ def setUpTestData(cls): def test_page_renders(self): response = self.client.get(self.search_url, {}) self.assertContains(response, '', html=True) + self.assertContains(response, 'id="id_setup_duration__gte"') + self.assertContains(response, 'id="id_setup_duration__lte"') + self.assertContains(response, 'id="id_testing_duration__gte"') + self.assertContains(response, 'id="id_testing_duration__lte"') + self.assertContains(response, 'id="id_expected_duration__gte"') + self.assertContains(response, 'id="id_expected_duration__lte"') def test_get_parameter_should_be_accepted_for_a_product(self): response = self.client.get(self.search_url, {"product": self.product.pk})