Skip to content

Commit f28dd76

Browse files
André Luizclaude
andauthored
feat: add date filter (#1834)
* feat(api): query issues by start and end timestamps Add support for explicit start/end Unix timestamp parameters to the api/issue listing endpoints, mirroring the existing behaviour of the api/hardware view. Unit and integration tests updated and extended with timestamp-based test cases and an invalid-timestamp error case. * tests: update issue listing tests for timestamp-based endpoint Replace interval_in_days + starting_date with start/end timestamp parameters throughout test fixtures and client helpers. Use dynamic timestamps computed at runtime so tests don't go stale. * refactor(issues): replace intervalInDays with date range in route schema Replace the single `intervalInDays` URL param with optional `startTimestampInSeconds` and `endTimestampInSeconds` params in the issues route. Restrict InputTime to routes that still use intervalInDays and swap the time input for the new DateRangeInput component. * feat(issues): add DateRangeInput component Add a date range picker with two native date inputs for the issues listing page. Invalid selections (end before start) show an inline error with a red border and a message below the inputs, clearing after 3s. * style(DateRangeInput): fix lint and formatting issues * feat(issues): update API hook to send start/end timestamps Replace interval_in_days + starting_date_iso_format params with direct startTimestampInSeconds and endTimestampInSeconds, matching the new backend contract. Defaults to last 5 days when params are absent from URL. * test(issues): add unit tests for fetchIssueListing Test that the fetch function sends startTimestampInSeconds and endTimestampInSeconds to /api/issue/, includes backend-compatible filter params, returns the API response, and propagates errors. * test(issues): add E2E tests for issue listing page Cover page load, date range input defaults, start/end date URL updates, validation preventing end-before-start navigation, table size change, pagination, and row visibility. � The commit message #2 will be skipped: � fixup! test(issues): add E2E tests for issue listing page * feat: stop user from picking up a future date * perf(issues): prevent per-second refetches This is made by stabilizing queryKey with `roundToNearestMinutes`, and it was a crucial change as iyt might have overloaded the backend with unnecessary queries * fix(e2e): correct E2E test constants and date logic for issue listing - Rename TEN_DAYS to ISO_DATE_LENGTH (10) for clarity - Add ONE_DAY constant for yesterday date calculation - Fix end date test to use yesterday instead of a future date - Fix table size option from '25' (non-existent) to '20' - Remove unused startValue variable in end-date test Closes #1784 * test(backend): add unit tests for resolve_date_range helper 10 tests covering: defaults, explicit timestamps, partial args, start > end validation, float timestamps, invalid inputs, equal timestamps, and UTC timezone enforcement. Moreover, some changes were brought to the date range. to simplify it and treat some cases that one or another timestamp might not be provided. * fix: compute start_date relative to end_date start_date now defaults to end_date - 7d instead of now() - 7d, so an API call with only a past end_timestamp no longer raises ValueError. A future-only start still correctly raises since end defaults to now(). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor(DateRangeInput): use shared constant, cn() helper, and typed error map - Export MILLISECONDS_IN_ONE_SECOND from utils/date and reuse it - Replace template literal classNames with cn() utility - Type errorMessageIds with MessageDescriptor['id'] - Bump error message text size from xs to sm Closes #1784 --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 4590b1a commit f28dd76

20 files changed

Lines changed: 771 additions & 69 deletions

File tree

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
from datetime import datetime, timedelta, timezone as dt_timezone
2+
from typing import Optional, Tuple
3+
4+
from django.utils.timezone import now
5+
6+
7+
def resolve_date_range(
8+
*,
9+
start_timestamp: Optional[str],
10+
end_timestamp: Optional[str],
11+
) -> Tuple[datetime, datetime]:
12+
"""Return (start_date, end_date) resolved from timestamps or interval.
13+
14+
If both start_timestamp and end_timestamp are provided (as Unix second
15+
strings), they are converted to timezone-aware datetimes.
16+
17+
end_date defaults to now(); start_date defaults to end_date - 7 days.
18+
start_date is computed relative to end_date (not now()) so that a
19+
past end_date without a start_date still produces a valid range.
20+
21+
Raises ValueError if the timestamp strings are not valid numbers,
22+
or if start_date ends up after end_date.
23+
"""
24+
end_date = (
25+
datetime.fromtimestamp(float(end_timestamp), tz=dt_timezone.utc)
26+
if end_timestamp is not None
27+
else now()
28+
)
29+
30+
start_date = (
31+
datetime.fromtimestamp(float(start_timestamp), tz=dt_timezone.utc)
32+
if start_timestamp is not None
33+
else end_date - timedelta(days=7)
34+
)
35+
36+
if start_date > end_date:
37+
raise ValueError("start_date must be before end_date")
38+
39+
return start_date, end_date

backend/kernelCI_app/queries/issues.py

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -98,16 +98,16 @@ def get_issue_tests(*, issue_id: str, version: Optional[int]) -> list[dict]:
9898

9999
def get_issue_listing_data(
100100
*,
101-
interval: str,
102-
starting_date: datetime,
101+
start_date: datetime,
102+
end_date: datetime,
103103
) -> list[dict]:
104-
"""Queries the list of all issues from the past `interval_date` parameter starting from `starting_date`.
104+
"""Queries the list of all issues whose timestamp falls within [start_date, end_date].
105105
106106
Returns the list of issue records (dict) with no other filter."""
107107

108108
params = {
109-
"interval": interval,
110-
"starting_date_filter": starting_date,
109+
"start_date": start_date,
110+
"end_date": end_date,
111111
}
112112

113113
# Note that an issue with timestamp younger than x days ago
@@ -131,7 +131,8 @@ def get_issue_listing_data(
131131
FROM
132132
issues i
133133
WHERE
134-
i._timestamp >= %(starting_date_filter)s - INTERVAL %(interval)s
134+
i._timestamp >= %(start_date)s
135+
AND i._timestamp <= %(end_date)s
135136
"""
136137

137138
with connection.cursor() as cursor:

backend/kernelCI_app/tests/integrationTests/issues_test.py

Lines changed: 42 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,19 @@
1212
from kernelCI_app.utils import string_to_json
1313
import pytest
1414
from http import HTTPStatus
15+
from datetime import datetime, timezone, timedelta
1516

1617

1718
client = IssueClient()
1819

19-
DEFAULT_LISTING_STARTING_DATE = "2025-08-15"
20-
DEFAULT_LISTING_INTERVAL_IN_DAYS = 3
20+
# 2025-08-15 00:00:00 UTC — the original listing date, now as a Unix timestamp
21+
DEFAULT_LISTING_STARTING_DATE = "1755216000"
22+
23+
# Truncate to the current hour so all xdist workers compute the same IDs
24+
_now = datetime.now(timezone.utc).replace(minute=0, second=0, microsecond=0)
25+
_TS_END = str(int((_now + timedelta(days=1)).timestamp()))
26+
_TS_START = str(int((_now - timedelta(days=3)).timestamp()))
27+
_DEC_25_1969_TS = str(int(datetime(1969, 12, 25).timestamp()))
2128

2229
CULPRIT_CODE = {
2330
"filters": {"issue.culprit": "code"},
@@ -45,46 +52,40 @@
4552
def pytest_generate_tests(metafunc):
4653
issues_listing_base_cases = [
4754
(
48-
DEFAULT_LISTING_INTERVAL_IN_DAYS,
4955
DEFAULT_LISTING_STARTING_DATE,
5056
CULPRIT_CODE,
5157
HTTPStatus.OK,
5258
False,
5359
),
5460
(
55-
DEFAULT_LISTING_INTERVAL_IN_DAYS,
5661
DEFAULT_LISTING_STARTING_DATE,
5762
CULPRIT_TOOL,
5863
HTTPStatus.OK,
5964
False,
6065
),
6166
(
62-
DEFAULT_LISTING_INTERVAL_IN_DAYS,
6367
DEFAULT_LISTING_STARTING_DATE,
6468
CULPRIT_HARNESS,
6569
HTTPStatus.OK,
6670
False,
6771
),
6872
(
69-
-5,
7073
None,
7174
None,
72-
HTTPStatus.BAD_REQUEST,
73-
True,
75+
HTTPStatus.OK,
76+
False,
7477
),
7578
]
7679

7780
if metafunc.config.getoption("--run-all"):
7881
issues_listing_base_cases += [
7982
(
80-
DEFAULT_LISTING_INTERVAL_IN_DAYS,
8183
DEFAULT_LISTING_STARTING_DATE,
8284
CULPRIT_CODE_AND_TOOL,
8385
HTTPStatus.OK,
8486
False,
8587
),
8688
(
87-
DEFAULT_LISTING_INTERVAL_IN_DAYS,
8889
DEFAULT_LISTING_STARTING_DATE,
8990
None,
9091
HTTPStatus.OK,
@@ -98,16 +99,15 @@ def pytest_generate_tests(metafunc):
9899

99100
def test_list(pytestconfig, issue_listing_input):
100101
(
101-
interval_in_day,
102102
starting_date_iso_format,
103103
culprit_data,
104104
status_code,
105105
has_error_body,
106106
) = issue_listing_input
107107
filters = culprit_data.get("filters") if culprit_data else None
108-
response = client.get_issues_list(
109-
interval_in_days=interval_in_day,
110-
starting_date_iso_format=starting_date_iso_format,
108+
response = client.get_issues_list_by_timestamp(
109+
start_timestamp=starting_date_iso_format,
110+
end_timestamp=None,
111111
filters=filters,
112112
)
113113
content = string_to_json(response.content.decode())
@@ -142,6 +142,34 @@ def test_list(pytestconfig, issue_listing_input):
142142
assert not issue[culprit]
143143

144144

145+
@pytest.mark.parametrize(
146+
"start_timestamp, end_timestamp, status_code, has_error_body",
147+
[
148+
(DEFAULT_LISTING_STARTING_DATE, _TS_END, HTTPStatus.OK, False),
149+
("not_a_number", _TS_END, HTTPStatus.BAD_REQUEST, True),
150+
(DEFAULT_LISTING_STARTING_DATE, "not_a_number", HTTPStatus.BAD_REQUEST, True),
151+
(_DEC_25_1969_TS, _TS_END, HTTPStatus.OK, False),
152+
],
153+
)
154+
def test_list_by_timestamp(start_timestamp, end_timestamp, status_code, has_error_body):
155+
response = client.get_issues_list_by_timestamp(
156+
start_timestamp=start_timestamp,
157+
end_timestamp=end_timestamp,
158+
)
159+
content = string_to_json(response.content.decode())
160+
assert_status_code_and_error_response(
161+
response=response,
162+
content=content,
163+
status_code=status_code,
164+
should_error=has_error_body,
165+
)
166+
167+
if not has_error_body:
168+
assert_has_fields_in_response_content(
169+
fields=issues_listing_fields, response_content=content
170+
)
171+
172+
145173
@pytest.mark.parametrize(
146174
"issue_id, issue_version, status_code, has_error_body",
147175
[
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
from datetime import datetime, timedelta, timezone as dt_timezone
2+
from unittest.mock import patch
3+
4+
import pytest
5+
6+
from kernelCI_app.helpers.dateRange import resolve_date_range
7+
8+
FIXED_NOW = datetime(2026, 4, 6, 12, 0, 0, tzinfo=dt_timezone.utc)
9+
10+
# Arbitrary round-number timestamps ~5 days apart, no domain significance.
11+
# Chosen for readability; any two valid timestamps where start < end would work.
12+
# 2023-11-14 22:13:20 UTC
13+
NOV_14_2023_TS = "1700000000"
14+
NOV_14_2023 = datetime(2023, 11, 14, 22, 13, 20, tzinfo=dt_timezone.utc)
15+
16+
# 2023-11-19 22:13:20 UTC (5 days later)
17+
NOV_19_2023_TS = "1700432000"
18+
NOV_19_2023 = datetime(2023, 11, 19, 22, 13, 20, tzinfo=dt_timezone.utc)
19+
20+
21+
class TestResolveDateRange:
22+
"""Unit tests for resolve_date_range."""
23+
24+
@patch("kernelCI_app.helpers.dateRange.now", return_value=FIXED_NOW)
25+
def test_defaults_when_no_timestamps(self, _mock_now):
26+
"""Both None → end=now, start=now-7d."""
27+
start, end = resolve_date_range(start_timestamp=None, end_timestamp=None)
28+
29+
assert end == FIXED_NOW
30+
assert start == FIXED_NOW - timedelta(days=7)
31+
32+
def test_both_timestamps_provided(self):
33+
"""Explicit start and end are converted to UTC datetimes."""
34+
start, end = resolve_date_range(
35+
start_timestamp=NOV_14_2023_TS, end_timestamp=NOV_19_2023_TS
36+
)
37+
38+
assert start == NOV_14_2023
39+
assert end == NOV_19_2023
40+
41+
@patch("kernelCI_app.helpers.dateRange.now", return_value=FIXED_NOW)
42+
def test_only_start_provided(self, _mock_now):
43+
"""Only start → end defaults to now()."""
44+
start, end = resolve_date_range(
45+
start_timestamp=NOV_14_2023_TS, end_timestamp=None
46+
)
47+
48+
assert start == NOV_14_2023
49+
assert end == FIXED_NOW
50+
51+
def test_only_end_provided(self):
52+
"""Only end → start defaults to end-7d (not now()-7d)."""
53+
start, end = resolve_date_range(
54+
start_timestamp=None, end_timestamp=NOV_19_2023_TS
55+
)
56+
57+
assert end == NOV_19_2023
58+
assert start == NOV_19_2023 - timedelta(days=7)
59+
60+
def test_only_end_far_in_the_past(self):
61+
"""End date 30+ days ago without start still produces a valid range."""
62+
# 2023-01-15 00:00:00 UTC — ~10 months before NOV_14
63+
jan_15_ts = "1673740800"
64+
jan_15 = datetime(2023, 1, 15, 0, 0, 0, tzinfo=dt_timezone.utc)
65+
66+
start, end = resolve_date_range(start_timestamp=None, end_timestamp=jan_15_ts)
67+
68+
assert end == jan_15
69+
assert start == jan_15 - timedelta(days=7)
70+
assert start < end
71+
72+
def test_start_after_end_raises(self):
73+
"""start > end → ValueError (Nov 19 as start, Nov 14 as end)."""
74+
with pytest.raises(ValueError, match="start_date must be before end_date"):
75+
resolve_date_range(
76+
start_timestamp=NOV_19_2023_TS, end_timestamp=NOV_14_2023_TS
77+
)
78+
79+
@patch("kernelCI_app.helpers.dateRange.now", return_value=FIXED_NOW)
80+
def test_future_start_without_end_raises(self, _mock_now):
81+
"""Start in the future with no end (defaults to now()) → ValueError."""
82+
# 2027-01-01 00:00:00 UTC — ~9 months after FIXED_NOW
83+
future_ts = "1798761600"
84+
85+
with pytest.raises(ValueError, match="start_date must be before end_date"):
86+
resolve_date_range(start_timestamp=future_ts, end_timestamp=None)
87+
88+
def test_float_timestamps(self):
89+
"""Fractional seconds are accepted (Nov 14 + 0.5s, Nov 19 + 0.9s)."""
90+
start, end = resolve_date_range(
91+
start_timestamp=f"{NOV_14_2023_TS}.5",
92+
end_timestamp=f"{NOV_19_2023_TS}.9",
93+
)
94+
95+
assert start == datetime.fromtimestamp(1700000000.5, tz=dt_timezone.utc)
96+
assert end == datetime.fromtimestamp(1700432000.9, tz=dt_timezone.utc)
97+
98+
def test_invalid_start_raises(self):
99+
"""Non-numeric start → ValueError."""
100+
with pytest.raises(ValueError):
101+
resolve_date_range(
102+
start_timestamp="not-a-number", end_timestamp=NOV_19_2023_TS
103+
)
104+
105+
def test_invalid_end_raises(self):
106+
"""Non-numeric end → ValueError."""
107+
with pytest.raises(ValueError):
108+
resolve_date_range(start_timestamp=NOV_14_2023_TS, end_timestamp="abc")
109+
110+
def test_equal_timestamps(self):
111+
"""start == end is valid (zero-width range)."""
112+
start, end = resolve_date_range(
113+
start_timestamp=NOV_14_2023_TS, end_timestamp=NOV_14_2023_TS
114+
)
115+
116+
assert start == end
117+
118+
def test_returned_datetimes_are_utc(self):
119+
"""Both returned datetimes carry UTC timezone info."""
120+
start, end = resolve_date_range(
121+
start_timestamp=NOV_14_2023_TS, end_timestamp=NOV_19_2023_TS
122+
)
123+
124+
assert start.tzinfo == dt_timezone.utc
125+
assert end.tzinfo == dt_timezone.utc

backend/kernelCI_app/tests/unitTests/queries/issues_queries_test.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,8 @@ def test_get_issue_listing_data_success(self, mock_connection, mock_dict_fetchal
9090
setup_mock_cursor(mock_connection)
9191

9292
result = get_issue_listing_data(
93-
interval="7 days", starting_date=datetime(2025, 11, 11)
93+
start_date=datetime(2025, 11, 4),
94+
end_date=datetime(2025, 11, 11),
9495
)
9596

9697
assert result == expected_result

backend/kernelCI_app/tests/utils/client/issueClient.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,17 +7,17 @@
77

88

99
class IssueClient(BaseClient):
10-
def get_issues_list(
10+
def get_issues_list_by_timestamp(
1111
self,
1212
*,
13-
interval_in_days: int | None,
14-
starting_date_iso_format: str | None,
13+
start_timestamp: str | None = None,
14+
end_timestamp: str | None = None,
1515
filters: dict[FilterFields, Any] | None = None,
1616
) -> requests.Response:
1717
path = reverse("issue")
1818
query = {
19-
"interval_in_days": interval_in_days,
20-
"starting_date_iso_format": starting_date_iso_format,
19+
"startTimestampInSeconds": start_timestamp,
20+
"endTimestampInSeconds": end_timestamp,
2121
}
2222
url = self.get_endpoint(path=path, query=query, filters=filters)
2323
return requests.get(url)

backend/kernelCI_app/typeModels/issueListing.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,13 @@ class IssueListingQueryParameters(ListingInterval):
2222
None,
2323
description=DocStrings.DEFAULT_FILTER_DESCRIPTION,
2424
)
25-
starting_date_iso_format: Optional[str] = Field(
26-
None,
27-
description=DocStrings.DEFAULT_LISTING_STARTING_DATE_DESCRIPTION,
25+
startTimestampInSeconds: Optional[str | int] = Field( # noqa: N815
26+
default=None,
27+
description=DocStrings.DEFAULT_START_TS_DESCRIPTION,
28+
)
29+
endTimestampInSeconds: Optional[str | int] = Field( # noqa: N815
30+
default=None,
31+
description=DocStrings.DEFAULT_END_TS_DESCRIPTION,
2832
)
2933

3034

0 commit comments

Comments
 (0)