diff --git a/tests/conftest.py b/tests/conftest.py index 42a8cd1..882cea1 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -13,8 +13,12 @@ DEFAULT_COMPLETED_TASKS_RESPONSE, DEFAULT_LABEL_RESPONSE, DEFAULT_LABELS_RESPONSE, + DEFAULT_LOCATION_REMINDER_RESPONSE, + DEFAULT_LOCATION_REMINDERS_RESPONSE, DEFAULT_PROJECT_RESPONSE, DEFAULT_PROJECTS_RESPONSE, + DEFAULT_REMINDER_RESPONSE, + DEFAULT_REMINDERS_RESPONSE, DEFAULT_REQUEST_ID, DEFAULT_SECTION_RESPONSE, DEFAULT_SECTIONS_RESPONSE, @@ -32,7 +36,9 @@ Collaborator, Comment, Label, + LocationReminder, Project, + Reminder, Section, Task, ) @@ -210,6 +216,52 @@ def default_labels_list() -> list[list[Label]]: ] +@pytest.fixture +def default_reminder_response() -> dict[str, Any]: + return DEFAULT_REMINDER_RESPONSE + + +@pytest.fixture +def default_reminder() -> Reminder: + return Reminder.from_dict(DEFAULT_REMINDER_RESPONSE) + + +@pytest.fixture +def default_reminders_response() -> list[PaginatedResults]: + return DEFAULT_REMINDERS_RESPONSE + + +@pytest.fixture +def default_reminders_list() -> list[list[Reminder]]: + return [ + [Reminder.from_dict(result) for result in response["results"]] + for response in DEFAULT_REMINDERS_RESPONSE + ] + + +@pytest.fixture +def default_location_reminder_response() -> dict[str, Any]: + return DEFAULT_LOCATION_REMINDER_RESPONSE + + +@pytest.fixture +def default_location_reminder() -> LocationReminder: + return LocationReminder.from_dict(DEFAULT_LOCATION_REMINDER_RESPONSE) + + +@pytest.fixture +def default_location_reminders_response() -> list[PaginatedResults]: + return DEFAULT_LOCATION_REMINDERS_RESPONSE + + +@pytest.fixture +def default_location_reminders_list() -> list[list[LocationReminder]]: + return [ + [LocationReminder.from_dict(result) for result in response["results"]] + for response in DEFAULT_LOCATION_REMINDERS_RESPONSE + ] + + @pytest.fixture def default_quick_add_response() -> dict[str, Any]: return DEFAULT_TASK_RESPONSE diff --git a/tests/data/test_defaults.py b/tests/data/test_defaults.py index 7cf5279..8d14794 100644 --- a/tests/data/test_defaults.py +++ b/tests/data/test_defaults.py @@ -268,6 +268,69 @@ class PaginatedItems(TypedDict): }, ] +DEFAULT_REMINDER_RESPONSE: dict[str, Any] = { + "id": "6X7rM8997g3RQmvh", + "item_id": "6Jf8VQXxpwv56VQ7", + "notify_uid": "34567", + "type": "relative", + "is_deleted": False, + "is_urgent": False, + "minute_offset": 30, + "due": DEFAULT_DUE_RESPONSE, + "service": "push", +} + +DEFAULT_REMINDER_RESPONSE_2 = dict(DEFAULT_REMINDER_RESPONSE) +DEFAULT_REMINDER_RESPONSE_2["id"] = "6X7rfFVPjhvv84XG" + +DEFAULT_REMINDER_RESPONSE_3 = dict(DEFAULT_REMINDER_RESPONSE) +DEFAULT_REMINDER_RESPONSE_3["id"] = "6X7rfEVP8hvv25ZQ" + +DEFAULT_REMINDERS_RESPONSE: list[PaginatedResults] = [ + { + "results": [DEFAULT_REMINDER_RESPONSE, DEFAULT_REMINDER_RESPONSE_2], + "next_cursor": "next", + }, + { + "results": [DEFAULT_REMINDER_RESPONSE_3], + "next_cursor": None, + }, +] + +DEFAULT_LOCATION_REMINDER_RESPONSE: dict[str, Any] = { + "id": "6X7rM8997g3RQmvh", + "item_id": "6Jf8VQXxpwv56VQ7", + "project_id": "6Jf8VQXxpwv56VQ7", + "notify_uid": "34567", + "name": "Office", + "loc_lat": "51.5074", + "loc_long": "-0.1278", + "loc_trigger": "on_enter", + "radius": 100, + "type": "location", + "is_deleted": False, +} + +DEFAULT_LOCATION_REMINDER_RESPONSE_2 = dict(DEFAULT_LOCATION_REMINDER_RESPONSE) +DEFAULT_LOCATION_REMINDER_RESPONSE_2["id"] = "6X7rfFVPjhvv84XG" + +DEFAULT_LOCATION_REMINDER_RESPONSE_3 = dict(DEFAULT_LOCATION_REMINDER_RESPONSE) +DEFAULT_LOCATION_REMINDER_RESPONSE_3["id"] = "6X7rfEVP8hvv25ZQ" + +DEFAULT_LOCATION_REMINDERS_RESPONSE: list[PaginatedResults] = [ + { + "results": [ + DEFAULT_LOCATION_REMINDER_RESPONSE, + DEFAULT_LOCATION_REMINDER_RESPONSE_2, + ], + "next_cursor": "next", + }, + { + "results": [DEFAULT_LOCATION_REMINDER_RESPONSE_3], + "next_cursor": None, + }, +] + DEFAULT_AUTH_RESPONSE = { "access_token": "123456789", "state": "somestate", diff --git a/tests/test_api_location_reminders.py b/tests/test_api_location_reminders.py new file mode 100644 index 0000000..437fa73 --- /dev/null +++ b/tests/test_api_location_reminders.py @@ -0,0 +1,211 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +import pytest + +from tests.data.test_defaults import ( + DEFAULT_API_URL, + PaginatedResults, +) +from tests.utils.test_utils import api_headers, enumerate_async, mock_route +from todoist_api_python.models import LocationReminder + +if TYPE_CHECKING: + import respx + + from todoist_api_python.api import TodoistAPI + from todoist_api_python.api_async import TodoistAPIAsync + + +@pytest.mark.asyncio +async def test_get_location_reminder( + todoist_api: TodoistAPI, + todoist_api_async: TodoistAPIAsync, + respx_mock: respx.MockRouter, + default_location_reminder_response: dict[str, Any], + default_location_reminder: LocationReminder, +) -> None: + location_reminder_id = "6X7rM8997g3RQmvh" + endpoint = f"{DEFAULT_API_URL}/location_reminders/{location_reminder_id}" + + mock_route( + respx_mock, + method="GET", + url=endpoint, + request_headers=api_headers(), + response_json=default_location_reminder_response, + response_status=200, + ) + + reminder = todoist_api.get_location_reminder(location_reminder_id) + + assert len(respx_mock.calls) == 1 + assert reminder == default_location_reminder + + reminder = await todoist_api_async.get_location_reminder(location_reminder_id) + + assert len(respx_mock.calls) == 2 + assert reminder == default_location_reminder + + +@pytest.mark.asyncio +async def test_get_location_reminders( + todoist_api: TodoistAPI, + todoist_api_async: TodoistAPIAsync, + respx_mock: respx.MockRouter, + default_location_reminders_response: list[PaginatedResults], + default_location_reminders_list: list[list[LocationReminder]], +) -> None: + task_id = "6X7rM8997g3RQmvh" + endpoint = f"{DEFAULT_API_URL}/location_reminders" + + cursor: str | None = None + for page in default_location_reminders_response: + mock_route( + respx_mock, + method="GET", + url=endpoint, + request_params={"task_id": task_id} + | ({"cursor": cursor} if cursor else {}), + request_headers=api_headers(), + response_json=page, + response_status=200, + ) + cursor = page["next_cursor"] + + count = 0 + + reminders_iter = todoist_api.get_location_reminders(task_id=task_id) + + for i, reminders in enumerate(reminders_iter): + assert len(respx_mock.calls) == count + 1 + assert reminders == default_location_reminders_list[i] + count += 1 + + reminders_async_iter = await todoist_api_async.get_location_reminders( + task_id=task_id + ) + + async for i, reminders in enumerate_async(reminders_async_iter): + assert len(respx_mock.calls) == count + 1 + assert reminders == default_location_reminders_list[i] + count += 1 + + +@pytest.mark.asyncio +async def test_add_location_reminder( + todoist_api: TodoistAPI, + todoist_api_async: TodoistAPIAsync, + respx_mock: respx.MockRouter, + default_location_reminder_response: dict[str, Any], + default_location_reminder: LocationReminder, +) -> None: + task_id = "6X7rM8997g3RQmvh" + + mock_route( + respx_mock, + method="POST", + url=f"{DEFAULT_API_URL}/location_reminders", + request_headers=api_headers(), + request_json={ + "task_id": task_id, + "name": "Office", + "loc_lat": "51.5074", + "loc_long": "-0.1278", + "loc_trigger": "on_enter", + "radius": 200, + }, + response_json=default_location_reminder_response, + response_status=200, + ) + + new_reminder = todoist_api.add_location_reminder( + task_id=task_id, + name="Office", + loc_lat="51.5074", + loc_long="-0.1278", + loc_trigger="on_enter", + radius=200, + ) + + assert len(respx_mock.calls) == 1 + assert new_reminder == default_location_reminder + + new_reminder = await todoist_api_async.add_location_reminder( + task_id=task_id, + name="Office", + loc_lat="51.5074", + loc_long="-0.1278", + loc_trigger="on_enter", + radius=200, + ) + + assert len(respx_mock.calls) == 2 + assert new_reminder == default_location_reminder + + +@pytest.mark.asyncio +async def test_update_location_reminder( + todoist_api: TodoistAPI, + todoist_api_async: TodoistAPIAsync, + respx_mock: respx.MockRouter, + default_location_reminder: LocationReminder, +) -> None: + args = { + "name": "Home Office", + "radius": 150, + } + updated_dict = default_location_reminder.to_dict() | args + + mock_route( + respx_mock, + method="POST", + url=f"{DEFAULT_API_URL}/location_reminders/{default_location_reminder.id}", + request_headers=api_headers(), + request_json=args, + response_json=updated_dict, + response_status=200, + ) + + response = todoist_api.update_location_reminder( + location_reminder_id=default_location_reminder.id, **args + ) + + assert len(respx_mock.calls) == 1 + assert response == LocationReminder.from_dict(updated_dict) + + response = await todoist_api_async.update_location_reminder( + location_reminder_id=default_location_reminder.id, **args + ) + + assert len(respx_mock.calls) == 2 + assert response == LocationReminder.from_dict(updated_dict) + + +@pytest.mark.asyncio +async def test_delete_location_reminder( + todoist_api: TodoistAPI, + todoist_api_async: TodoistAPIAsync, + respx_mock: respx.MockRouter, +) -> None: + location_reminder_id = "6X7rM8997g3RQmvh" + endpoint = f"{DEFAULT_API_URL}/location_reminders/{location_reminder_id}" + + mock_route( + respx_mock, + method="DELETE", + url=endpoint, + request_headers=api_headers(), + response_status=204, + ) + + response = todoist_api.delete_location_reminder(location_reminder_id) + + assert len(respx_mock.calls) == 1 + assert response is True + + response = await todoist_api_async.delete_location_reminder(location_reminder_id) + + assert len(respx_mock.calls) == 2 + assert response is True diff --git a/tests/test_api_reminders.py b/tests/test_api_reminders.py new file mode 100644 index 0000000..dca6b1b --- /dev/null +++ b/tests/test_api_reminders.py @@ -0,0 +1,202 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +import pytest + +from tests.data.test_defaults import ( + DEFAULT_API_URL, + PaginatedResults, +) +from tests.utils.test_utils import api_headers, enumerate_async, mock_route +from todoist_api_python.models import Reminder + +if TYPE_CHECKING: + import respx + + from todoist_api_python.api import TodoistAPI + from todoist_api_python.api_async import TodoistAPIAsync + + +@pytest.mark.asyncio +async def test_get_reminder( + todoist_api: TodoistAPI, + todoist_api_async: TodoistAPIAsync, + respx_mock: respx.MockRouter, + default_reminder_response: dict[str, Any], + default_reminder: Reminder, +) -> None: + reminder_id = "6X7rM8997g3RQmvh" + endpoint = f"{DEFAULT_API_URL}/reminders/{reminder_id}" + + mock_route( + respx_mock, + method="GET", + url=endpoint, + request_headers=api_headers(), + response_json=default_reminder_response, + response_status=200, + ) + + reminder = todoist_api.get_reminder(reminder_id) + + assert len(respx_mock.calls) == 1 + assert reminder == default_reminder + + reminder = await todoist_api_async.get_reminder(reminder_id) + + assert len(respx_mock.calls) == 2 + assert reminder == default_reminder + + +@pytest.mark.asyncio +async def test_get_reminders( + todoist_api: TodoistAPI, + todoist_api_async: TodoistAPIAsync, + respx_mock: respx.MockRouter, + default_reminders_response: list[PaginatedResults], + default_reminders_list: list[list[Reminder]], +) -> None: + task_id = "6X7rM8997g3RQmvh" + endpoint = f"{DEFAULT_API_URL}/reminders" + + cursor: str | None = None + for page in default_reminders_response: + mock_route( + respx_mock, + method="GET", + url=endpoint, + request_params={"task_id": task_id} + | ({"cursor": cursor} if cursor else {}), + request_headers=api_headers(), + response_json=page, + response_status=200, + ) + cursor = page["next_cursor"] + + count = 0 + + reminders_iter = todoist_api.get_reminders(task_id=task_id) + + for i, reminders in enumerate(reminders_iter): + assert len(respx_mock.calls) == count + 1 + assert reminders == default_reminders_list[i] + count += 1 + + reminders_async_iter = await todoist_api_async.get_reminders(task_id=task_id) + + async for i, reminders in enumerate_async(reminders_async_iter): + assert len(respx_mock.calls) == count + 1 + assert reminders == default_reminders_list[i] + count += 1 + + +@pytest.mark.asyncio +async def test_add_reminder( + todoist_api: TodoistAPI, + todoist_api_async: TodoistAPIAsync, + respx_mock: respx.MockRouter, + default_reminder_response: dict[str, Any], + default_reminder: Reminder, +) -> None: + task_id = "6X7rM8997g3RQmvh" + + mock_route( + respx_mock, + method="POST", + url=f"{DEFAULT_API_URL}/reminders", + request_headers=api_headers(), + request_json={ + "task_id": task_id, + "reminder_type": "absolute", + "due": {"string": "tomorrow at 12", "lang": "en"}, + "service": "push", + }, + response_json=default_reminder_response, + response_status=200, + ) + + new_reminder = todoist_api.add_reminder( + task_id=task_id, + reminder_type="absolute", + due_string="tomorrow at 12", + due_lang="en", + service="push", + ) + + assert len(respx_mock.calls) == 1 + assert new_reminder == default_reminder + + new_reminder = await todoist_api_async.add_reminder( + task_id=task_id, + reminder_type="absolute", + due_string="tomorrow at 12", + due_lang="en", + service="push", + ) + + assert len(respx_mock.calls) == 2 + assert new_reminder == default_reminder + + +@pytest.mark.asyncio +async def test_update_reminder( + todoist_api: TodoistAPI, + todoist_api_async: TodoistAPIAsync, + respx_mock: respx.MockRouter, + default_reminder: Reminder, +) -> None: + args = { + "minute_offset": 45, + } + updated_reminder_dict = default_reminder.to_dict() | args + + mock_route( + respx_mock, + method="POST", + url=f"{DEFAULT_API_URL}/reminders/{default_reminder.id}", + request_headers=api_headers(), + request_json=args, + response_json=updated_reminder_dict, + response_status=200, + ) + + response = todoist_api.update_reminder(reminder_id=default_reminder.id, **args) + + assert len(respx_mock.calls) == 1 + assert response == Reminder.from_dict(updated_reminder_dict) + + response = await todoist_api_async.update_reminder( + reminder_id=default_reminder.id, **args + ) + + assert len(respx_mock.calls) == 2 + assert response == Reminder.from_dict(updated_reminder_dict) + + +@pytest.mark.asyncio +async def test_delete_reminder( + todoist_api: TodoistAPI, + todoist_api_async: TodoistAPIAsync, + respx_mock: respx.MockRouter, +) -> None: + reminder_id = "6X7rM8997g3RQmvh" + endpoint = f"{DEFAULT_API_URL}/reminders/{reminder_id}" + + mock_route( + respx_mock, + method="DELETE", + url=endpoint, + request_headers=api_headers(), + response_status=204, + ) + + response = todoist_api.delete_reminder(reminder_id) + + assert len(respx_mock.calls) == 1 + assert response is True + + response = await todoist_api_async.delete_reminder(reminder_id) + + assert len(respx_mock.calls) == 2 + assert response is True diff --git a/todoist_api_python/_core/endpoints.py b/todoist_api_python/_core/endpoints.py index e10d0ae..435264f 100644 --- a/todoist_api_python/_core/endpoints.py +++ b/todoist_api_python/_core/endpoints.py @@ -30,6 +30,8 @@ SHARED_LABELS_PATH = "labels/shared" SHARED_LABELS_RENAME_PATH = f"{SHARED_LABELS_PATH}/rename" SHARED_LABELS_REMOVE_PATH = f"{SHARED_LABELS_PATH}/remove" +REMINDERS_PATH = "reminders" +LOCATION_REMINDERS_PATH = "location_reminders" AUTHORIZE_PATH = "authorize" ACCESS_TOKEN_PATH = "access_token" # noqa: S105 diff --git a/todoist_api_python/api.py b/todoist_api_python/api.py index 9b95035..20b59cc 100644 --- a/todoist_api_python/api.py +++ b/todoist_api_python/api.py @@ -13,10 +13,12 @@ COMMENTS_PATH, LABELS_PATH, LABELS_SEARCH_PATH_SUFFIX, + LOCATION_REMINDERS_PATH, PROJECT_ARCHIVE_PATH_SUFFIX, PROJECT_UNARCHIVE_PATH_SUFFIX, PROJECTS_PATH, PROJECTS_SEARCH_PATH_SUFFIX, + REMINDERS_PATH, SECTIONS_PATH, SECTIONS_SEARCH_PATH_SUFFIX, SHARED_LABELS_PATH, @@ -46,7 +48,9 @@ Collaborator, Comment, Label, + LocationReminder, Project, + Reminder, Section, Task, ) @@ -1506,6 +1510,352 @@ def remove_shared_label(self, name: Annotated[str, MaxLen(60)]) -> bool: ) return response.is_success + # ── Reminders ────────────────────────────────────────────────────── + + def get_reminder(self, reminder_id: str) -> Reminder: + """ + Get a specific reminder by its ID. + + :param reminder_id: The ID of the reminder to retrieve. + :return: The requested reminder. + :raises httpx.HTTPStatusError: If the API request fails. + """ + endpoint = get_api_url(f"{REMINDERS_PATH}/{reminder_id}") + response = get( + self._client, + endpoint, + self._token, + self._request_id_fn() if self._request_id_fn else None, + ) + data = response_json_dict(response) + return Reminder.from_dict(data) + + def get_reminders( + self, + *, + task_id: str | None = None, + limit: Annotated[int, Ge(1), Le(200)] | None = None, + ) -> Iterator[list[Reminder]]: + """ + Get an iterable of lists of reminders. + + The response is an iterable of lists of reminders. + Be aware that each iteration fires off a network request to the Todoist API, + and may result in rate limiting or other API restrictions. + + :param task_id: Optional task ID to filter reminders by. + :param limit: Maximum number of reminders per page. + :return: An iterable of lists of reminders. + :raises httpx.HTTPStatusError: If the API request fails. + """ + endpoint = get_api_url(REMINDERS_PATH) + + params = kwargs_without_none( + task_id=task_id, + limit=limit, + ) + + return ResultsPaginator( + self._client, + endpoint, + "results", + Reminder.from_dict, + self._token, + self._request_id_fn, + params, + ) + + def add_reminder( + self, + task_id: str, + *, + reminder_type: Literal["relative", "absolute"] = "relative", + minute_offset: int | None = None, + due_string: str | None = None, + due_date: date | None = None, + due_datetime: datetime | None = None, + due_lang: LanguageCode | None = None, + due_timezone: str | None = None, + service: Literal["email", "push"] | None = None, + ) -> Reminder: + """ + Create a new reminder. + + For relative reminders, provide `minute_offset`. + For absolute reminders, provide due date fields. + + :param task_id: The ID of the task to add the reminder to. + :param reminder_type: The type of reminder ("relative" or "absolute"). + :param minute_offset: Minutes before the due date/time to trigger (relative). + :param due_string: The due date in natural language format (absolute). + :param due_date: The due date as a date object (absolute). + :param due_datetime: The due date and time as a datetime object (absolute). + :param due_lang: Language for parsing the due date. + :param due_timezone: Timezone for the due date. + :param service: The notification service ("email" or "push"). + :return: The newly created reminder. + :raises httpx.HTTPStatusError: If the API request fails. + """ + endpoint = get_api_url(REMINDERS_PATH) + + due = kwargs_without_none( + string=due_string, + date=format_date(due_date) + if due_date is not None + else (format_datetime(due_datetime) if due_datetime is not None else None), + lang=due_lang, + timezone=due_timezone, + ) + + data = kwargs_without_none( + task_id=task_id, + reminder_type=reminder_type, + minute_offset=minute_offset, + due=due or None, + service=service, + ) + + response = post( + self._client, + endpoint, + self._token, + self._request_id_fn() if self._request_id_fn else None, + data=data, + ) + data = response_json_dict(response) + return Reminder.from_dict(data) + + def update_reminder( + self, + reminder_id: str, + *, + minute_offset: int | None = None, + due_string: str | None = None, + due_date: date | None = None, + due_datetime: datetime | None = None, + due_lang: LanguageCode | None = None, + due_timezone: str | None = None, + service: Literal["email", "push"] | None = None, + ) -> Reminder: + """ + Update an existing reminder. + + Only the fields to be updated need to be provided as keyword arguments. + + :param reminder_id: The ID of the reminder to update. + :param minute_offset: Minutes before the due date/time to trigger. + :param due_string: The due date in natural language format. + :param due_date: The due date as a date object. + :param due_datetime: The due date and time as a datetime object. + :param due_lang: Language for parsing the due date. + :param due_timezone: Timezone for the due date. + :param service: The notification service ("email" or "push"). + :return: The updated reminder. + :raises httpx.HTTPStatusError: If the API request fails. + """ + endpoint = get_api_url(f"{REMINDERS_PATH}/{reminder_id}") + + due = kwargs_without_none( + string=due_string, + date=format_date(due_date) + if due_date is not None + else (format_datetime(due_datetime) if due_datetime is not None else None), + lang=due_lang, + timezone=due_timezone, + ) + + data = kwargs_without_none( + minute_offset=minute_offset, + due=due or None, + service=service, + ) + + response = post( + self._client, + endpoint, + self._token, + self._request_id_fn() if self._request_id_fn else None, + data=data, + ) + data = response_json_dict(response) + return Reminder.from_dict(data) + + def delete_reminder(self, reminder_id: str) -> bool: + """ + Delete a reminder. + + :param reminder_id: The ID of the reminder to delete. + :return: True if the reminder was deleted successfully. + :raises httpx.HTTPStatusError: If the API request fails. + """ + endpoint = get_api_url(f"{REMINDERS_PATH}/{reminder_id}") + response = delete( + self._client, + endpoint, + self._token, + self._request_id_fn() if self._request_id_fn else None, + ) + return response.is_success + + # ── Location Reminders ───────────────────────────────────────────── + + def get_location_reminder(self, location_reminder_id: str) -> LocationReminder: + """ + Get a specific location reminder by its ID. + + :param location_reminder_id: The ID of the location reminder to retrieve. + :return: The requested location reminder. + :raises httpx.HTTPStatusError: If the API request fails. + """ + endpoint = get_api_url(f"{LOCATION_REMINDERS_PATH}/{location_reminder_id}") + response = get( + self._client, + endpoint, + self._token, + self._request_id_fn() if self._request_id_fn else None, + ) + data = response_json_dict(response) + return LocationReminder.from_dict(data) + + def get_location_reminders( + self, + *, + task_id: str | None = None, + limit: Annotated[int, Ge(1), Le(200)] | None = None, + ) -> Iterator[list[LocationReminder]]: + """ + Get an iterable of lists of location reminders. + + The response is an iterable of lists of location reminders. + Be aware that each iteration fires off a network request to the Todoist API, + and may result in rate limiting or other API restrictions. + + :param task_id: Optional task ID to filter location reminders by. + :param limit: Maximum number of location reminders per page. + :return: An iterable of lists of location reminders. + :raises httpx.HTTPStatusError: If the API request fails. + """ + endpoint = get_api_url(LOCATION_REMINDERS_PATH) + + params = kwargs_without_none( + task_id=task_id, + limit=limit, + ) + + return ResultsPaginator( + self._client, + endpoint, + "results", + LocationReminder.from_dict, + self._token, + self._request_id_fn, + params, + ) + + def add_location_reminder( + self, + task_id: str, + name: str, + loc_lat: str, + loc_long: str, + loc_trigger: Literal["on_enter", "on_leave"], + *, + radius: int | None = None, + ) -> LocationReminder: + """ + Create a new location reminder. + + :param task_id: The ID of the task to add the reminder to. + :param name: The name of the location. + :param loc_lat: The latitude coordinate. + :param loc_long: The longitude coordinate. + :param loc_trigger: When to trigger ("on_enter" or "on_leave"). + :param radius: The radius in meters (default: 100). + :return: The newly created location reminder. + :raises httpx.HTTPStatusError: If the API request fails. + """ + endpoint = get_api_url(LOCATION_REMINDERS_PATH) + + data = kwargs_without_none( + task_id=task_id, + name=name, + loc_lat=loc_lat, + loc_long=loc_long, + loc_trigger=loc_trigger, + radius=radius, + ) + + response = post( + self._client, + endpoint, + self._token, + self._request_id_fn() if self._request_id_fn else None, + data=data, + ) + data = response_json_dict(response) + return LocationReminder.from_dict(data) + + def update_location_reminder( + self, + location_reminder_id: str, + *, + name: str | None = None, + loc_lat: str | None = None, + loc_long: str | None = None, + loc_trigger: Literal["on_enter", "on_leave"] | None = None, + radius: int | None = None, + ) -> LocationReminder: + """ + Update an existing location reminder. + + Only the fields to be updated need to be provided as keyword arguments. + + :param location_reminder_id: The ID of the location reminder to update. + :param name: The name of the location. + :param loc_lat: The latitude coordinate. + :param loc_long: The longitude coordinate. + :param loc_trigger: When to trigger ("on_enter" or "on_leave"). + :param radius: The radius in meters. + :return: The updated location reminder. + :raises httpx.HTTPStatusError: If the API request fails. + """ + endpoint = get_api_url(f"{LOCATION_REMINDERS_PATH}/{location_reminder_id}") + + data = kwargs_without_none( + name=name, + loc_lat=loc_lat, + loc_long=loc_long, + loc_trigger=loc_trigger, + radius=radius, + ) + + response = post( + self._client, + endpoint, + self._token, + self._request_id_fn() if self._request_id_fn else None, + data=data, + ) + data = response_json_dict(response) + return LocationReminder.from_dict(data) + + def delete_location_reminder(self, location_reminder_id: str) -> bool: + """ + Delete a location reminder. + + :param location_reminder_id: The ID of the location reminder to delete. + :return: True if the location reminder was deleted successfully. + :raises httpx.HTTPStatusError: If the API request fails. + """ + endpoint = get_api_url(f"{LOCATION_REMINDERS_PATH}/{location_reminder_id}") + response = delete( + self._client, + endpoint, + self._token, + self._request_id_fn() if self._request_id_fn else None, + ) + return response.is_success + T = TypeVar("T") diff --git a/todoist_api_python/api_async.py b/todoist_api_python/api_async.py index 39a6e0b..18150bb 100644 --- a/todoist_api_python/api_async.py +++ b/todoist_api_python/api_async.py @@ -13,10 +13,12 @@ COMMENTS_PATH, LABELS_PATH, LABELS_SEARCH_PATH_SUFFIX, + LOCATION_REMINDERS_PATH, PROJECT_ARCHIVE_PATH_SUFFIX, PROJECT_UNARCHIVE_PATH_SUFFIX, PROJECTS_PATH, PROJECTS_SEARCH_PATH_SUFFIX, + REMINDERS_PATH, SECTIONS_PATH, SECTIONS_SEARCH_PATH_SUFFIX, SHARED_LABELS_PATH, @@ -46,7 +48,9 @@ Collaborator, Comment, Label, + LocationReminder, Project, + Reminder, Section, Task, ) @@ -1526,6 +1530,354 @@ async def remove_shared_label(self, name: Annotated[str, MaxLen(60)]) -> bool: ) return response.is_success + # ── Reminders ────────────────────────────────────────────────────── + + async def get_reminder(self, reminder_id: str) -> Reminder: + """ + Get a specific reminder by its ID. + + :param reminder_id: The ID of the reminder to retrieve. + :return: The requested reminder. + :raises httpx.HTTPStatusError: If the API request fails. + """ + endpoint = get_api_url(f"{REMINDERS_PATH}/{reminder_id}") + response = await get_async( + self._client, + endpoint, + self._token, + self._request_id_fn() if self._request_id_fn else None, + ) + data = response_json_dict(response) + return Reminder.from_dict(data) + + async def get_reminders( + self, + *, + task_id: str | None = None, + limit: Annotated[int, Ge(1), Le(200)] | None = None, + ) -> AsyncIterator[list[Reminder]]: + """ + Get an async iterable of lists of reminders. + + The response is an async iterable of lists of reminders. + Be aware that each iteration fires off a network request to the Todoist API, + and may result in rate limiting or other API restrictions. + + :param task_id: Optional task ID to filter reminders by. + :param limit: Maximum number of reminders per page. + :return: An async iterable of lists of reminders. + :raises httpx.HTTPStatusError: If the API request fails. + """ + endpoint = get_api_url(REMINDERS_PATH) + + params = kwargs_without_none( + task_id=task_id, + limit=limit, + ) + + return AsyncResultsPaginator( + self._client, + endpoint, + "results", + Reminder.from_dict, + self._token, + self._request_id_fn, + params, + ) + + async def add_reminder( + self, + task_id: str, + *, + reminder_type: Literal["relative", "absolute"] = "relative", + minute_offset: int | None = None, + due_string: str | None = None, + due_date: date | None = None, + due_datetime: datetime | None = None, + due_lang: LanguageCode | None = None, + due_timezone: str | None = None, + service: Literal["email", "push"] | None = None, + ) -> Reminder: + """ + Create a new reminder. + + For relative reminders, provide `minute_offset`. + For absolute reminders, provide due date fields. + + :param task_id: The ID of the task to add the reminder to. + :param reminder_type: The type of reminder ("relative" or "absolute"). + :param minute_offset: Minutes before the due date/time to trigger (relative). + :param due_string: The due date in natural language format (absolute). + :param due_date: The due date as a date object (absolute). + :param due_datetime: The due date and time as a datetime object (absolute). + :param due_lang: Language for parsing the due date. + :param due_timezone: Timezone for the due date. + :param service: The notification service ("email" or "push"). + :return: The newly created reminder. + :raises httpx.HTTPStatusError: If the API request fails. + """ + endpoint = get_api_url(REMINDERS_PATH) + + due = kwargs_without_none( + string=due_string, + date=format_date(due_date) + if due_date is not None + else (format_datetime(due_datetime) if due_datetime is not None else None), + lang=due_lang, + timezone=due_timezone, + ) + + data = kwargs_without_none( + task_id=task_id, + reminder_type=reminder_type, + minute_offset=minute_offset, + due=due or None, + service=service, + ) + + response = await post_async( + self._client, + endpoint, + self._token, + self._request_id_fn() if self._request_id_fn else None, + data=data, + ) + data = response_json_dict(response) + return Reminder.from_dict(data) + + async def update_reminder( + self, + reminder_id: str, + *, + minute_offset: int | None = None, + due_string: str | None = None, + due_date: date | None = None, + due_datetime: datetime | None = None, + due_lang: LanguageCode | None = None, + due_timezone: str | None = None, + service: Literal["email", "push"] | None = None, + ) -> Reminder: + """ + Update an existing reminder. + + Only the fields to be updated need to be provided as keyword arguments. + + :param reminder_id: The ID of the reminder to update. + :param minute_offset: Minutes before the due date/time to trigger. + :param due_string: The due date in natural language format. + :param due_date: The due date as a date object. + :param due_datetime: The due date and time as a datetime object. + :param due_lang: Language for parsing the due date. + :param due_timezone: Timezone for the due date. + :param service: The notification service ("email" or "push"). + :return: The updated reminder. + :raises httpx.HTTPStatusError: If the API request fails. + """ + endpoint = get_api_url(f"{REMINDERS_PATH}/{reminder_id}") + + due = kwargs_without_none( + string=due_string, + date=format_date(due_date) + if due_date is not None + else (format_datetime(due_datetime) if due_datetime is not None else None), + lang=due_lang, + timezone=due_timezone, + ) + + data = kwargs_without_none( + minute_offset=minute_offset, + due=due or None, + service=service, + ) + + response = await post_async( + self._client, + endpoint, + self._token, + self._request_id_fn() if self._request_id_fn else None, + data=data, + ) + data = response_json_dict(response) + return Reminder.from_dict(data) + + async def delete_reminder(self, reminder_id: str) -> bool: + """ + Delete a reminder. + + :param reminder_id: The ID of the reminder to delete. + :return: True if the reminder was deleted successfully. + :raises httpx.HTTPStatusError: If the API request fails. + """ + endpoint = get_api_url(f"{REMINDERS_PATH}/{reminder_id}") + response = await delete_async( + self._client, + endpoint, + self._token, + self._request_id_fn() if self._request_id_fn else None, + ) + return response.is_success + + # ── Location Reminders ───────────────────────────────────────────── + + async def get_location_reminder( + self, location_reminder_id: str + ) -> LocationReminder: + """ + Get a specific location reminder by its ID. + + :param location_reminder_id: The ID of the location reminder to retrieve. + :return: The requested location reminder. + :raises httpx.HTTPStatusError: If the API request fails. + """ + endpoint = get_api_url(f"{LOCATION_REMINDERS_PATH}/{location_reminder_id}") + response = await get_async( + self._client, + endpoint, + self._token, + self._request_id_fn() if self._request_id_fn else None, + ) + data = response_json_dict(response) + return LocationReminder.from_dict(data) + + async def get_location_reminders( + self, + *, + task_id: str | None = None, + limit: Annotated[int, Ge(1), Le(200)] | None = None, + ) -> AsyncIterator[list[LocationReminder]]: + """ + Get an async iterable of lists of location reminders. + + The response is an async iterable of lists of location reminders. + Be aware that each iteration fires off a network request to the Todoist API, + and may result in rate limiting or other API restrictions. + + :param task_id: Optional task ID to filter location reminders by. + :param limit: Maximum number of location reminders per page. + :return: An async iterable of lists of location reminders. + :raises httpx.HTTPStatusError: If the API request fails. + """ + endpoint = get_api_url(LOCATION_REMINDERS_PATH) + + params = kwargs_without_none( + task_id=task_id, + limit=limit, + ) + + return AsyncResultsPaginator( + self._client, + endpoint, + "results", + LocationReminder.from_dict, + self._token, + self._request_id_fn, + params, + ) + + async def add_location_reminder( + self, + task_id: str, + name: str, + loc_lat: str, + loc_long: str, + loc_trigger: Literal["on_enter", "on_leave"], + *, + radius: int | None = None, + ) -> LocationReminder: + """ + Create a new location reminder. + + :param task_id: The ID of the task to add the reminder to. + :param name: The name of the location. + :param loc_lat: The latitude coordinate. + :param loc_long: The longitude coordinate. + :param loc_trigger: When to trigger ("on_enter" or "on_leave"). + :param radius: The radius in meters (default: 100). + :return: The newly created location reminder. + :raises httpx.HTTPStatusError: If the API request fails. + """ + endpoint = get_api_url(LOCATION_REMINDERS_PATH) + + data = kwargs_without_none( + task_id=task_id, + name=name, + loc_lat=loc_lat, + loc_long=loc_long, + loc_trigger=loc_trigger, + radius=radius, + ) + + response = await post_async( + self._client, + endpoint, + self._token, + self._request_id_fn() if self._request_id_fn else None, + data=data, + ) + data = response_json_dict(response) + return LocationReminder.from_dict(data) + + async def update_location_reminder( + self, + location_reminder_id: str, + *, + name: str | None = None, + loc_lat: str | None = None, + loc_long: str | None = None, + loc_trigger: Literal["on_enter", "on_leave"] | None = None, + radius: int | None = None, + ) -> LocationReminder: + """ + Update an existing location reminder. + + Only the fields to be updated need to be provided as keyword arguments. + + :param location_reminder_id: The ID of the location reminder to update. + :param name: The name of the location. + :param loc_lat: The latitude coordinate. + :param loc_long: The longitude coordinate. + :param loc_trigger: When to trigger ("on_enter" or "on_leave"). + :param radius: The radius in meters. + :return: The updated location reminder. + :raises httpx.HTTPStatusError: If the API request fails. + """ + endpoint = get_api_url(f"{LOCATION_REMINDERS_PATH}/{location_reminder_id}") + + data = kwargs_without_none( + name=name, + loc_lat=loc_lat, + loc_long=loc_long, + loc_trigger=loc_trigger, + radius=radius, + ) + + response = await post_async( + self._client, + endpoint, + self._token, + self._request_id_fn() if self._request_id_fn else None, + data=data, + ) + data = response_json_dict(response) + return LocationReminder.from_dict(data) + + async def delete_location_reminder(self, location_reminder_id: str) -> bool: + """ + Delete a location reminder. + + :param location_reminder_id: The ID of the location reminder to delete. + :return: True if the location reminder was deleted successfully. + :raises httpx.HTTPStatusError: If the API request fails. + """ + endpoint = get_api_url(f"{LOCATION_REMINDERS_PATH}/{location_reminder_id}") + response = await delete_async( + self._client, + endpoint, + self._token, + self._request_id_fn() if self._request_id_fn else None, + ) + return response.is_success + T = TypeVar("T") diff --git a/todoist_api_python/models.py b/todoist_api_python/models.py index 24bd33c..5185a3c 100644 --- a/todoist_api_python/models.py +++ b/todoist_api_python/models.py @@ -11,6 +11,9 @@ ViewStyle = Literal["list", "board", "calendar"] DurationUnit = Literal["minute", "day"] +ReminderType = Literal["relative", "absolute"] +ReminderService = Literal["email", "push"] +LocationTrigger = Literal["on_enter", "on_leave"] ApiDate = UTCDateTimePattern["%FT%T.%fZ"] # type: ignore[valid-type] ApiDue = Union[ # https://github.com/rnag/dataclass-wizard/issues/189 DatePattern["%F"], DateTimePattern["%FT%T"], UTCDateTimePattern["%FT%TZ"] # type: ignore[valid-type] # noqa: F722 @@ -218,3 +221,38 @@ class _(JSONPyWizard.Meta): # noqa:N801 amount: int unit: DurationUnit + + +@dataclass +class Reminder(JSONPyWizard): + class _(JSONPyWizard.Meta): # noqa:N801 + v1 = True + + id: str + task_id: Annotated[str, Alias(load=("item_id", "task_id"))] + notify_uid: str + type: ReminderType + is_deleted: bool + is_urgent: bool + + minute_offset: int | None = None + due: Due | None = None + service: ReminderService | None = None + + +@dataclass +class LocationReminder(JSONPyWizard): + class _(JSONPyWizard.Meta): # noqa:N801 + v1 = True + + id: str + task_id: Annotated[str, Alias(load=("item_id", "task_id"))] + project_id: str + notify_uid: str + name: str + loc_lat: str + loc_long: str + loc_trigger: LocationTrigger + radius: int + type: str + is_deleted: bool