From 1df334460dfdc7d082d3f3213df603237ac95194 Mon Sep 17 00:00:00 2001 From: Vadim Nifadev <36514612+nifadyev@users.noreply.github.com> Date: Wed, 18 Sep 2024 10:48:53 +0300 Subject: [PATCH] #65: Add integration tests for essential logic (#67) * Change date params type for search ReportTimeEntry method * Add check for too old date params for listing TimeEntries --- tests/factories/time_entry.py | 4 +- tests/integration/conftest.py | 9 +++ tests/integration/test_project.py | 36 ++++++++++ tests/integration/test_report_time_entry.py | 80 +++++++++++++++++++++ tests/integration/test_time_entry.py | 69 ++++++++++++++++++ tests/integration/test_workspace.py | 30 ++++++++ tests/test_report_time_entry.py | 46 ++++++------ tests/test_time_entry.py | 67 ++++++++++++----- toggl_python/entities/report_time_entry.py | 6 +- toggl_python/schemas/report_time_entry.py | 8 +-- toggl_python/schemas/time_entry.py | 30 ++++++-- 11 files changed, 334 insertions(+), 51 deletions(-) create mode 100644 tests/integration/test_project.py create mode 100644 tests/integration/test_report_time_entry.py create mode 100644 tests/integration/test_workspace.py diff --git a/tests/factories/time_entry.py b/tests/factories/time_entry.py index 21913e9..ae058ba 100644 --- a/tests/factories/time_entry.py +++ b/tests/factories/time_entry.py @@ -96,7 +96,7 @@ def time_entry_response_factory( "duronly": fake.boolean(), "id": fake.random_number(digits=11, fix_len=True), "permissions": None, - "project_id": project_id or fake.random_int() if fake.boolean() else None, + "project_id": project_id or fake.random_int(), "server_deleted_at": ( fake.date_time_this_month(tzinfo=tz).isoformat(timespec="seconds") if fake.boolean() @@ -106,7 +106,7 @@ def time_entry_response_factory( "stop": stop or _datetime_repr_factory(tz), "tag_ids": tag_ids or [], "tags": tags or [], - "task_id": task_id or fake.random_int() if fake.boolean() else None, + "task_id": task_id or fake.random_int(), "user_id": user_id or fake.random_int(), "workspace_id": workspace_id, } diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 022cae7..5dd10d7 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -6,6 +6,7 @@ import pytest from toggl_python.auth import TokenAuth +from toggl_python.entities.report_time_entry import ReportTimeEntry from toggl_python.entities.user import CurrentUser from toggl_python.entities.workspace import Workspace @@ -30,6 +31,14 @@ def i_authed_workspace() -> Workspace: return Workspace(auth=auth) +@pytest.fixture(scope="session") +def i_authed_report_time_entry() -> ReportTimeEntry: + token = os.environ["TOGGL_TOKEN"] + auth = TokenAuth(token=token) + + return ReportTimeEntry(auth=auth) + + @pytest.fixture() def me_response(i_authed_user: CurrentUser) -> MeResponse: return i_authed_user.me() diff --git a/tests/integration/test_project.py b/tests/integration/test_project.py new file mode 100644 index 0000000..2a23902 --- /dev/null +++ b/tests/integration/test_project.py @@ -0,0 +1,36 @@ +from __future__ import annotations + +import os +from typing import TYPE_CHECKING + +from toggl_python.schemas.project import ProjectResponse + +# Necessary to mark all tests in module as integration +from tests.integration import pytestmark # noqa: F401 - imported but unused + + +if TYPE_CHECKING: + from toggl_python.entities.workspace import Workspace + + +def test_get_projects__without_query_params(i_authed_workspace: Workspace) -> None: + # Later Create project and init and delete it at the end + # Now this actions are not implemented + workspace_id = int(os.environ["WORKSPACE_ID"]) + expected_result = set(ProjectResponse.model_fields.keys()) + + result = i_authed_workspace.get_projects(workspace_id) + + assert result[0].model_fields_set == expected_result + + +def test_get_project_by_id(i_authed_workspace: Workspace) -> None: + # Later Create project and init and delete it at the end + # Now this actions are not implemented + workspace_id = int(os.environ["WORKSPACE_ID"]) + project_id = int(os.environ["PROJECT_ID"]) + expected_result = set(ProjectResponse.model_fields.keys()) + + result = i_authed_workspace.get_project(workspace_id, project_id) + + assert result.model_fields_set == expected_result diff --git a/tests/integration/test_report_time_entry.py b/tests/integration/test_report_time_entry.py new file mode 100644 index 0000000..a3f777c --- /dev/null +++ b/tests/integration/test_report_time_entry.py @@ -0,0 +1,80 @@ +from __future__ import annotations + +import os +from datetime import timedelta +from typing import TYPE_CHECKING + +import pytest +from toggl_python.schemas.report_time_entry import ( + SearchReportTimeEntriesResponse, +) + +from tests.conftest import fake + +# Necessary to mark all tests in module as integration +from tests.integration import pytestmark # noqa: F401 - imported but unused + + +if TYPE_CHECKING: + from toggl_python.entities.report_time_entry import ReportTimeEntry + from toggl_python.entities.workspace import Workspace + + +try: + import zoneinfo +except ImportError: + from backports import zoneinfo + + +@pytest.mark.parametrize( + argnames="use_dates_repr", + argvalues=(True, False), + ids=("str date arguments", "date date arguments"), +) +def test_search_report_time_entries__with_start_and_end_dates( + use_dates_repr: bool, + i_authed_report_time_entry: ReportTimeEntry, + i_authed_workspace: Workspace, +) -> None: + workspace_id = int(os.environ["WORKSPACE_ID"]) + timezone_name = fake.timezone() + tz = zoneinfo.ZoneInfo(timezone_name) + start_date = fake.date_this_decade() + delta = fake.random_int(min=1, max=364) + end_date = start_date + timedelta(days=delta) + time_entry = i_authed_workspace.create_time_entry( + workspace_id, + start_datetime=fake.date_time_between_dates(start_date, end_date, tzinfo=tz), + created_with=fake.word(), + ) + + expected_result = set(SearchReportTimeEntriesResponse.model_fields.keys()) + + result = i_authed_report_time_entry.search( + workspace_id, + start_date=start_date.isoformat() if use_dates_repr else start_date, + end_date=end_date.isoformat() if use_dates_repr else end_date, + ) + + assert result[0].model_fields_set == expected_result + + _ = i_authed_workspace.delete_time_entry(workspace_id, time_entry.id) + + +def test_search_report_time_entries__not_found( + i_authed_report_time_entry: ReportTimeEntry, +) -> None: + workspace_id = int(os.environ["WORKSPACE_ID"]) + # Set explicit date range to avoid finding unexpected existing test TimeEntries + time_entry_start_date = fake.date_between(start_date="-15y", end_date="-2y") + delta = fake.random_int(min=1, max=364) + end_date = time_entry_start_date + timedelta(days=delta) + start_date = fake.date_between_dates(time_entry_start_date, end_date) + + result = i_authed_report_time_entry.search( + workspace_id, + start_date=start_date, + end_date=end_date, + ) + + assert result == [] diff --git a/tests/integration/test_time_entry.py b/tests/integration/test_time_entry.py index 7ba7e22..8d17d87 100644 --- a/tests/integration/test_time_entry.py +++ b/tests/integration/test_time_entry.py @@ -1,10 +1,12 @@ from __future__ import annotations import os +from datetime import timedelta from typing import TYPE_CHECKING from toggl_python.schemas.time_entry import MeTimeEntryResponse +from tests.conftest import fake from tests.factories.time_entry import ( time_entry_extended_request_factory, time_entry_request_factory, @@ -14,7 +16,14 @@ from tests.integration import pytestmark # noqa: F401 - imported but unused +try: + import zoneinfo +except ImportError: + from backports import zoneinfo + + if TYPE_CHECKING: + from toggl_python.entities.user import CurrentUser from toggl_python.entities.workspace import Workspace @@ -60,3 +69,63 @@ def test_create_time_entry__all_fields(i_authed_workspace: Workspace) -> None: assert result.model_fields_set == expected_result _ = i_authed_workspace.delete_time_entry(workspace_id=workspace_id, time_entry_id=result.id) + + +def test_list_time_entries__with_start_and_end_date__datetime( + i_authed_user: CurrentUser, i_authed_workspace: Workspace +) -> None: + workspace_id = int(os.environ["WORKSPACE_ID"]) + timezone_name = fake.timezone() + tz = zoneinfo.ZoneInfo(timezone_name) + start_date = fake.date_time_this_month(tzinfo=tz, before_now=True) + delta = fake.random_int(min=1, max=999999) + end_date = start_date + timedelta(seconds=delta) + time_entry = i_authed_workspace.create_time_entry( + workspace_id, + start_datetime=fake.date_time_between_dates(start_date, end_date, tzinfo=tz), + created_with=fake.word(), + ) + + expected_result = set(MeTimeEntryResponse.model_fields.keys()) + + result = i_authed_user.get_time_entries(start_date=start_date, end_date=end_date) + + assert result[0].model_fields_set == expected_result + + _ = i_authed_workspace.delete_time_entry(workspace_id, time_entry.id) + + +def test_list_time_entries__with_start_and_end_date__str( + i_authed_user: CurrentUser, i_authed_workspace: Workspace +) -> None: + workspace_id = int(os.environ["WORKSPACE_ID"]) + start_date = fake.date_this_month(before_today=True) + delta = fake.random_int(min=1, max=999) + end_date = start_date + timedelta(days=delta) + timezone_name = fake.timezone() + tz = zoneinfo.ZoneInfo(timezone_name) + time_entry = i_authed_workspace.create_time_entry( + workspace_id, + start_datetime=fake.date_time_between_dates(start_date, end_date, tzinfo=tz), + created_with=fake.word(), + ) + + expected_result = set(MeTimeEntryResponse.model_fields.keys()) + + result = i_authed_user.get_time_entries( + start_date=start_date.isoformat(), end_date=end_date.isoformat() + ) + + assert result[0].model_fields_set == expected_result + + _ = i_authed_workspace.delete_time_entry(workspace_id, time_entry.id) + + +def test_list_time_entries__no_results(i_authed_user: CurrentUser) -> None: + start_date = fake.date_time_between(start_date="-6m", end_date="-3m") + delta = fake.random_int(min=0, max=999) + end_date = start_date + timedelta(days=delta) + + result = i_authed_user.get_time_entries(start_date=start_date, end_date=end_date) + + assert result == [] diff --git a/tests/integration/test_workspace.py b/tests/integration/test_workspace.py new file mode 100644 index 0000000..bb9df20 --- /dev/null +++ b/tests/integration/test_workspace.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +import os +from typing import TYPE_CHECKING + +from toggl_python.schemas.workspace import WorkspaceResponse + +# Necessary to mark all tests in module as integration +from tests.integration import pytestmark # noqa: F401 - imported but unused + + +if TYPE_CHECKING: + from toggl_python.entities.workspace import Workspace + + +def test_get_workspace_by_id(i_authed_workspace: Workspace) -> None: + workspace_id = int(os.environ["WORKSPACE_ID"]) + expected_result = set(WorkspaceResponse.model_fields.keys()) + + result = i_authed_workspace.get(workspace_id) + + assert result.model_fields_set == expected_result + + +def test_get_workspaces__without_query_params(i_authed_workspace: Workspace)-> None: + expected_result = set(WorkspaceResponse.model_fields.keys()) + + result = i_authed_workspace.list() + + assert result[0].model_fields_set == expected_result diff --git a/tests/test_report_time_entry.py b/tests/test_report_time_entry.py index 01fe0e1..785a3cc 100644 --- a/tests/test_report_time_entry.py +++ b/tests/test_report_time_entry.py @@ -1,17 +1,19 @@ from __future__ import annotations -from datetime import datetime, timezone -from typing import TYPE_CHECKING, Dict, Union +from typing import TYPE_CHECKING, Union import pytest from httpx import Response from pydantic import ValidationError from toggl_python.schemas.report_time_entry import SearchReportTimeEntriesResponse +from tests.conftest import fake from tests.responses.report_time_entry_post import SEARCH_REPORT_TIME_ENTRY_RESPONSE if TYPE_CHECKING: + from datetime import date + from respx import MockRouter from toggl_python.entities.report_time_entry import ReportTimeEntry @@ -26,29 +28,30 @@ def test_search_report_time_entries__without_params( @pytest.mark.parametrize( - argnames="request_body, start_date, end_date", + argnames="start_date, end_date", argvalues=( ( - {"start_date": "2020-06-10T00:00:00+00:00", "end_date": "2020-10-01T00:00:00+00:00"}, - datetime(2020, 6, 10, tzinfo=timezone.utc), - datetime(2020, 10, 1, tzinfo=timezone.utc), + fake.date_this_decade(before_today=True).isoformat(), + fake.date_this_decade(before_today=True).isoformat(), ), ( - {"start_date": "2023-09-12T00:00:00-03:00", "end_date": "2023-10-12T00:00:00-01:00"}, - "2023-09-12T00:00:00-03:00", - "2023-10-12T00:00:00-01:00", + fake.date_this_decade(before_today=True), + fake.date_this_decade(before_today=True), ), ), ) def test_search_report_time_entries__with_start_and_end_date( - request_body: Dict[str, str], - start_date: Union[datetime, str], - end_date: Union[datetime, str], + start_date: Union[date, str], + end_date: Union[date, str], response_report_mock: MockRouter, authed_report_time_entry: ReportTimeEntry, ) -> None: fake_workspace_id = 123 uri = f"/{fake_workspace_id}/search/time_entries" + request_body = { + "start_date": start_date if isinstance(start_date, str) else start_date.isoformat(), + "end_date": end_date if isinstance(end_date, str) else end_date.isoformat(), + } mocked_route = response_report_mock.post(uri, json=request_body).mock( return_value=Response(status_code=200, json=[SEARCH_REPORT_TIME_ENTRY_RESPONSE]), ) @@ -57,7 +60,9 @@ def test_search_report_time_entries__with_start_and_end_date( ] result = authed_report_time_entry.search( - workspace_id=fake_workspace_id, start_date=start_date, end_date=end_date + workspace_id=fake_workspace_id, + start_date=start_date, + end_date=end_date, ) assert mocked_route.called is True @@ -68,14 +73,15 @@ def test_search_report_time_entries__with_all_params( response_report_mock: MockRouter, authed_report_time_entry: ReportTimeEntry, ) -> None: - fake_workspace_id = 123 + fake_workspace_id = fake.random_int(min=1) + page_size = fake.random_int(min=1, max=100) request_body = { - "start_date": "2021-12-20T00:00:00+00:00", - "end_date": "2021-12-30T00:00:00+00:00", - "user_ids": [30809356], - "project_ids": [202793182], - "page_size": 10, - "first_row_number": 11, + "start_date": fake.date(), + "end_date": fake.date(), + "user_ids": [fake.random_int()], + "project_ids": [fake.random_int()], + "page_size": page_size, + "first_row_number": page_size + 1, } uri = f"/{fake_workspace_id}/search/time_entries" mocked_route = response_report_mock.post(uri, json=request_body).mock( diff --git a/tests/test_time_entry.py b/tests/test_time_entry.py index 4506223..3a541fe 100644 --- a/tests/test_time_entry.py +++ b/tests/test_time_entry.py @@ -1,6 +1,6 @@ from __future__ import annotations -from datetime import datetime, timezone +from datetime import date, datetime, timezone from random import randint from typing import TYPE_CHECKING, Dict, List, Union from unittest.mock import Mock, patch @@ -208,6 +208,7 @@ def test_get_time_entries__with_meta_query_param( @patch("toggl_python.schemas.base.datetime") +@patch("toggl_python.schemas.time_entry.datetime") @pytest.mark.parametrize( argnames="query_params, method_kwargs", argvalues=( @@ -216,19 +217,20 @@ def test_get_time_entries__with_meta_query_param( {"since": int(datetime(2024, 5, 10, tzinfo=timezone.utc).timestamp())}, ), ({"since": 1718755200}, {"since": 1718755200}), - ({"before": "2024-07-28T12:30:43+00:00"}, {"before": "2024-07-28T12:30:43+00:00"}), + ({"before": "2024-07-28"}, {"before": "2024-07-28"}), ( - {"before": "2023-01-01T00:00:00+00:00"}, + {"before": "2023-01-01"}, {"before": datetime(2023, 1, 1, tzinfo=timezone.utc)}, ), ( - {"start_date": "2023-09-12T00:00:00-03:00", "end_date": "2023-10-12T00:00:00-01:00"}, - {"start_date": "2023-09-12T00:00:00-03:00", "end_date": "2023-10-12T00:00:00-01:00"}, + {"start_date": "2024-03-27", "end_date": "2024-04-12"}, + {"start_date": "2024-03-27T00:00:00-03:00", "end_date": "2024-04-12T00:00:00-01:00"}, ), ), ) def test_get_time_entries__with_datetime_query_params( mocked_datetime: Mock, + mocked_time_entry_datetime: Mock, query_params: Dict[str, Union[int, str]], method_kwargs: Dict[str, Union[datetime, str]], response_mock: MockRouter, @@ -237,6 +239,7 @@ def test_get_time_entries__with_datetime_query_params( query_params["meta"] = False # Required to pass `since` query param validation mocked_datetime.now.return_value = datetime(2024, 6, 20, tzinfo=timezone.utc) + mocked_time_entry_datetime.now.return_value = datetime(2024, 6, 20, tzinfo=timezone.utc) mocked_route = response_mock.get("/me/time_entries", params=query_params).mock( return_value=Response(status_code=200, json=[ME_TIME_ENTRY_RESPONSE]), ) @@ -248,35 +251,37 @@ def test_get_time_entries__with_datetime_query_params( assert result == expected_result +@patch("toggl_python.schemas.time_entry.datetime") @pytest.mark.parametrize( argnames="query_params", argvalues=( - {"start_date": "2010-01-01T00:00:00+08:00"}, - {"end_date": "2010-02-01T00:00:00+03:00"}, - {"since": 17223107204, "before": "2024-07-28T00:00:00+10:00"}, + {"end_date": "2015-08-14"}, + {"since": 17223107204, "before": "2015-07-28"}, { "since": 17223107204, - "start_date": "2020-11-11T09:30:00-04:00", - "end_date": "2021-01-11T09:30:00-04:00", + "start_date": "2015-09-20", + "end_date": "2015-09-21", }, { - "before": "2020-12-15T09:30:00-04:00", - "start_date": "2020-11-11T09:30:00-04:00", - "end_date": "2021-01-11T09:30:00-04:00", + "before": "2015-07-11", + "start_date": "2015-07-12", + "end_date": "2015-07-16", }, { "since": 17223107204, - "before": "2020-12-15T09:30:00-04:00", - "start_date": "2020-11-11T09:30:00-04:00", - "end_date": "2021-01-11T09:30:00-04:00", + "before": "2015-07-11", + "start_date": "2015-07-12", + "end_date": "2015-07-16", }, ), ) def test_get_time_entries__invalid_query_params( + mocked_datetime: Mock, query_params: Dict[str, Union[int, str]], response_mock: MockRouter, authed_current_user: CurrentUser, ) -> None: + mocked_datetime.now.return_value = datetime(2015, 9, 22, tzinfo=timezone.utc) error_message = "can not be present simultaneously" _ = response_mock.get("/me/time_entries", params=query_params).mock( return_value=Response(status_code=400, json=error_message), @@ -286,6 +291,36 @@ def test_get_time_entries__invalid_query_params( _ = authed_current_user.get_time_entries(**query_params) +@patch("toggl_python.schemas.time_entry.datetime") +@patch("toggl_python.schemas.base.datetime") +@pytest.mark.parametrize( + argnames="arg_name", + argvalues=("start_date", "end_date"), +) +@pytest.mark.parametrize( + argnames="value", + argvalues=( + "2020-01-01T00:00:00+08:00", + "2020-01-01", + fake.date(end_datetime=date(2020, 1, 1)), + fake.date_time(end_datetime=date(2020, 1, 1)), + ), +) +def test_get_time_entries__too_old_dates( + mocked_time_entry_datetime: Mock, + mocked_datetime: Mock, + arg_name: str, + value: Union[str, datetime], + authed_current_user: CurrentUser, +) -> None: + mocked_time_entry_datetime.now.return_value = datetime(2020, 4, 1, tzinfo=timezone.utc) + mocked_datetime.now.return_value = datetime(2020, 4, 1, tzinfo=timezone.utc) + error_message = "Start and end dates must not be earlier than 2020-01-02" + + with pytest.raises(ValueError, match=error_message): + _ = authed_current_user.get_time_entries(**{arg_name: value}) + + @patch("toggl_python.schemas.base.datetime") def test_get_time_entries__too_old_since_value( mocked_datetime: Mock, authed_current_user: CurrentUser diff --git a/toggl_python/entities/report_time_entry.py b/toggl_python/entities/report_time_entry.py index 6d6af92..9d17ee1 100644 --- a/toggl_python/entities/report_time_entry.py +++ b/toggl_python/entities/report_time_entry.py @@ -10,7 +10,7 @@ if TYPE_CHECKING: - from datetime import datetime + from datetime import date from toggl_python.auth import BasicAuth, TokenAuth @@ -25,8 +25,8 @@ def __init__(self, auth: Union[BasicAuth, TokenAuth]) -> None: def search( self, workspace_id: int, - start_date: Union[datetime, str, None] = None, - end_date: Union[datetime, str, None] = None, + start_date: Union[date, str, None] = None, + end_date: Union[date, str, None] = None, user_ids: Optional[List[int]] = None, project_ids: Optional[List[int]] = None, page_size: Optional[int] = None, diff --git a/toggl_python/schemas/report_time_entry.py b/toggl_python/schemas/report_time_entry.py index e26352d..fefb59c 100644 --- a/toggl_python/schemas/report_time_entry.py +++ b/toggl_python/schemas/report_time_entry.py @@ -1,6 +1,6 @@ from __future__ import annotations -from datetime import datetime +from datetime import date from typing import Dict, List, Optional from pydantic import AwareDatetime, field_serializer, model_validator @@ -33,8 +33,8 @@ class ReportTimeEntryItem(BaseSchema): class SearchReportTimeEntriesRequest(BaseSchema): - start_date: Optional[AwareDatetime] = None - end_date: Optional[AwareDatetime] = None + start_date: Optional[date] = None + end_date: Optional[date] = None project_ids: Optional[List[int]] = None user_ids: Optional[List[int]] = None page_size: Optional[int] = None @@ -50,7 +50,7 @@ def check_if_at_least_one_param_is_set(cls, data: Dict[str, str]) -> Dict[str, s raise ValueError(error_message) @field_serializer("start_date", "end_date", when_used="json") - def serialize_datetimes(self, value: Optional[datetime]) -> Optional[str]: + def serialize_datetimes(self, value: Optional[date]) -> Optional[str]: if not value: return value diff --git a/toggl_python/schemas/time_entry.py b/toggl_python/schemas/time_entry.py index 81c910c..c9750c0 100644 --- a/toggl_python/schemas/time_entry.py +++ b/toggl_python/schemas/time_entry.py @@ -1,12 +1,13 @@ from __future__ import annotations -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone from enum import Enum from typing import Dict, List, Optional, Union from pydantic import ( AwareDatetime, field_serializer, + field_validator, model_serializer, model_validator, ) @@ -65,16 +66,33 @@ class MeTimeEntryWithMetaResponse(MeTimeEntryResponse): class MeTimeEntryQueryParams(SinceParamSchemaMixin, BaseSchema): meta: bool - before: Optional[AwareDatetime] = None - start_date: Optional[AwareDatetime] = None - end_date: Optional[AwareDatetime] = None + before: Optional[datetime] = None + start_date: Optional[datetime] = None + end_date: Optional[datetime] = None @field_serializer("before", "start_date", "end_date", when_used="json") def serialize_datetimes(self, value: Optional[datetime]) -> Optional[str]: if not value: return value - return value.isoformat() + return value.date().isoformat() + + @field_validator("start_date", "end_date") + @classmethod + def check_if_dates_are_too_old(cls, value: Optional[datetime]) -> Optional[datetime]: + if not value: + return value + + now = datetime.now(tz=timezone.utc) + three_months = timedelta(days=90) + utc_value = value.astimezone(tz=timezone.utc) + + if now - three_months > utc_value: + first_allowed_date = (now - three_months).date().isoformat() + error_message = f"Start and end dates must not be earlier than {first_allowed_date}" + raise ValueError(error_message) + + return value class WebTimerTimeEntryResponse(MeTimeEntryResponseBase): @@ -129,7 +147,7 @@ class TimeEntryCreateRequest(BaseSchema): task_id: Optional[int] = None user_id: Optional[int] = None - @field_serializer("start", when_used="json") + @field_serializer("start", "stop", when_used="json") def serialize_datetimes(self, value: Optional[datetime]) -> Optional[str]: if not value: return value