diff --git a/tests/conftest.py b/tests/conftest.py index 4562ce9..33aa1f2 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -9,6 +9,7 @@ DEFAULT_COLLABORATORS_RESPONSE, DEFAULT_COMMENT_RESPONSE, DEFAULT_COMMENTS_RESPONSE, + DEFAULT_COMPLETED_ITEMS_RESPONSE, DEFAULT_LABEL_RESPONSE, DEFAULT_LABELS_RESPONSE, DEFAULT_PROJECT_RESPONSE, @@ -25,6 +26,7 @@ AuthResult, Collaborator, Comment, + CompletedItems, Label, Project, QuickAddResult, @@ -177,3 +179,13 @@ def default_auth_response() -> Dict[str, Any]: @pytest.fixture() def default_auth_result() -> AuthResult: return AuthResult.from_dict(DEFAULT_AUTH_RESPONSE) + + +@pytest.fixture() +def default_completed_items_response() -> dict[str, Any]: + return DEFAULT_COMPLETED_ITEMS_RESPONSE + + +@pytest.fixture() +def default_completed_items() -> CompletedItems: + return CompletedItems.from_dict(DEFAULT_COMPLETED_ITEMS_RESPONSE) diff --git a/tests/data/test_defaults.py b/tests/data/test_defaults.py index cbb8907..ade3b96 100644 --- a/tests/data/test_defaults.py +++ b/tests/data/test_defaults.py @@ -1,4 +1,5 @@ from __future__ import annotations + from typing import Any REST_API_BASE_URL = "https://api.todoist.com/rest/v2" @@ -148,3 +149,32 @@ "access_token": "1234", "state": "somestate", } + +DEFAULT_ITEM_RESPONSE = { + "id": "2995104339", + "user_id": "2671355", + "project_id": "2203306141", + "content": "Buy Milk", + "description": "", + "priority": 1, + "due": DEFAULT_DUE_RESPONSE, + "child_order": 1, + "day_order": -1, + "collapsed": False, + "labels": ["Food", "Shopping"], + "added_by_uid": "2671355", + "assigned_by_uid": "2671355", + "checked": False, + "is_deleted": False, + "added_at": "2014-09-26T08:25:05.000000Z", +} + +DEFAULT_ITEM_COMPLETED_INFO_RESPONSE = {"item_id": "2995104339", "completed_items": 12} + +DEFAULT_COMPLETED_ITEMS_RESPONSE = { + "items": [DEFAULT_ITEM_RESPONSE], + "completed_info": [DEFAULT_ITEM_COMPLETED_INFO_RESPONSE], + "total": 22, + "next_cursor": "k85gVI5ZAs8AAAABFoOzAQ", + "has_more": True, +} diff --git a/tests/test_api_items.py b/tests/test_api_items.py new file mode 100644 index 0000000..6f2acac --- /dev/null +++ b/tests/test_api_items.py @@ -0,0 +1,64 @@ +from typing import Any +from urllib.parse import parse_qs, urlparse + +import pytest +import responses + +from tests.data.test_defaults import SYNC_API_BASE_URL +from tests.utils.test_utils import assert_auth_header +from todoist_api_python.api import TodoistAPI +from todoist_api_python.api_async import TodoistAPIAsync +from todoist_api_python.endpoints import COMPLETED_ITEMS_ENDPOINT +from todoist_api_python.models import CompletedItems + + +@pytest.mark.asyncio +async def test_get_completed_items( + todoist_api: TodoistAPI, + todoist_api_async: TodoistAPIAsync, + requests_mock: responses.RequestsMock, + default_completed_items_response: dict[str, Any], + default_completed_items: CompletedItems, +) -> None: + project_id = "1234" + section_id = "5678" + item_id = "90ab" + last_seen_id = "cdef" + limit = 30 + cursor = "ghij" + + def assert_query(url): + queries = parse_qs(urlparse(url).query) + assert queries.get("project_id") == [project_id] + assert queries.get("section_id") == [section_id] + assert queries.get("item_id") == [item_id] + assert queries.get("last_seen_id") == [last_seen_id] + assert queries.get("limit") == [str(limit)] + assert queries.get("cursor") == [cursor] + + expected_endpoint = f"{SYNC_API_BASE_URL}/{COMPLETED_ITEMS_ENDPOINT}" + + requests_mock.add( + responses.GET, + expected_endpoint, + json=default_completed_items_response, + status=200, + ) + + completed_items = todoist_api.get_completed_items( + project_id, section_id, item_id, last_seen_id, limit, cursor + ) + + assert len(requests_mock.calls) == 1 + assert_auth_header(requests_mock.calls[0].request) + assert_query(requests_mock.calls[0].request.url) + assert completed_items == default_completed_items + + completed_items = await todoist_api_async.get_completed_items( + project_id, section_id, item_id, last_seen_id, limit, cursor + ) + + assert len(requests_mock.calls) == 2 + assert_auth_header(requests_mock.calls[1].request) + assert_query(requests_mock.calls[1].request.url) + assert completed_items == default_completed_items diff --git a/tests/test_models.py b/tests/test_models.py index ad6115d..06cf35b 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -6,7 +6,10 @@ DEFAULT_ATTACHMENT_RESPONSE, DEFAULT_COLLABORATOR_RESPONSE, DEFAULT_COMMENT_RESPONSE, + DEFAULT_COMPLETED_ITEMS_RESPONSE, DEFAULT_DUE_RESPONSE, + DEFAULT_ITEM_COMPLETED_INFO_RESPONSE, + DEFAULT_ITEM_RESPONSE, DEFAULT_LABEL_RESPONSE, DEFAULT_PROJECT_RESPONSE, DEFAULT_SECTION_RESPONSE, @@ -17,7 +20,10 @@ AuthResult, Collaborator, Comment, + CompletedItems, Due, + Item, + ItemCompletedInfo, Label, Project, QuickAddResult, @@ -280,3 +286,87 @@ def test_auth_result_from_dict(): assert auth_result.access_token == token assert auth_result.state == state + + +def test_item_from_dict(): + sample_data = dict(DEFAULT_ITEM_RESPONSE) + sample_data.update(unexpected_data) + + item = Item.from_dict(sample_data) + + assert item.id == "2995104339" + assert item.user_id == "2671355" + assert item.project_id == "2203306141" + assert item.content == "Buy Milk" + assert item.description == "" + assert item.priority == 1 + assert item.due.date == DEFAULT_DUE_RESPONSE["date"] + assert item.due.is_recurring == DEFAULT_DUE_RESPONSE["is_recurring"] + assert item.due.string == DEFAULT_DUE_RESPONSE["string"] + assert item.due.datetime == DEFAULT_DUE_RESPONSE["datetime"] + assert item.due.timezone == DEFAULT_DUE_RESPONSE["timezone"] + assert item.parent_id is None + assert item.child_order == 1 + assert item.section_id is None + assert item.day_order == -1 + assert item.collapsed is False + assert item.labels == ["Food", "Shopping"] + assert item.added_by_uid == "2671355" + assert item.assigned_by_uid == "2671355" + assert item.responsible_uid is None + assert item.checked is False + assert item.is_deleted is False + assert item.sync_id is None + assert item.added_at == "2014-09-26T08:25:05.000000Z" + + +def test_item_completed_info_from_dict(): + sample_data = dict(DEFAULT_ITEM_COMPLETED_INFO_RESPONSE) + sample_data.update(unexpected_data) + + info = ItemCompletedInfo.from_dict(sample_data) + + assert info.item_id == "2995104339" + assert info.completed_items == 12 + + +def test_completed_items_from_dict(): + sample_data = dict(DEFAULT_COMPLETED_ITEMS_RESPONSE) + sample_data.update(unexpected_data) + + completed_items = CompletedItems.from_dict(sample_data) + + assert completed_items.total == 22 + assert completed_items.next_cursor == "k85gVI5ZAs8AAAABFoOzAQ" + assert completed_items.has_more is True + assert len(completed_items.items) == 1 + assert completed_items.items[0].id == "2995104339" + assert completed_items.items[0].user_id == "2671355" + assert completed_items.items[0].project_id == "2203306141" + assert completed_items.items[0].content == "Buy Milk" + assert completed_items.items[0].description == "" + assert completed_items.items[0].priority == 1 + assert completed_items.items[0].due.date == DEFAULT_DUE_RESPONSE["date"] + assert ( + completed_items.items[0].due.is_recurring + == DEFAULT_DUE_RESPONSE["is_recurring"] + ) + assert completed_items.items[0].due.string == DEFAULT_DUE_RESPONSE["string"] + assert completed_items.items[0].due.datetime == DEFAULT_DUE_RESPONSE["datetime"] + assert completed_items.items[0].due.timezone == DEFAULT_DUE_RESPONSE["timezone"] + assert completed_items.items[0].parent_id is None + assert completed_items.items[0].child_order == 1 + assert completed_items.items[0].section_id is None + assert completed_items.items[0].day_order == -1 + assert completed_items.items[0].collapsed is False + assert completed_items.items[0].labels == ["Food", "Shopping"] + assert completed_items.items[0].added_by_uid == "2671355" + assert completed_items.items[0].assigned_by_uid == "2671355" + assert completed_items.items[0].responsible_uid is None + assert completed_items.items[0].checked is False + assert completed_items.items[0].is_deleted is False + assert completed_items.items[0].sync_id is None + assert completed_items.items[0].added_at == "2014-09-26T08:25:05.000000Z" + assert len(completed_items.completed_info) == 1 + assert completed_items.completed_info[0].item_id == "2995104339" + assert completed_items.completed_info[0].completed_items == 12 diff --git a/todoist_api_python/api.py b/todoist_api_python/api.py index 96d1769..fa727ab 100644 --- a/todoist_api_python/api.py +++ b/todoist_api_python/api.py @@ -7,6 +7,7 @@ from todoist_api_python.endpoints import ( COLLABORATORS_ENDPOINT, COMMENTS_ENDPOINT, + COMPLETED_ITEMS_ENDPOINT, LABELS_ENDPOINT, PROJECTS_ENDPOINT, QUICK_ADD_ENDPOINT, @@ -22,6 +23,7 @@ from todoist_api_python.models import ( Collaborator, Comment, + CompletedItems, Label, Project, QuickAddResult, @@ -207,3 +209,28 @@ def remove_shared_label(self, name: str) -> bool: endpoint = get_rest_url(SHARED_LABELS_REMOVE_ENDPOINT) data = {"name": name} return post(self._session, endpoint, self._token, data=data) + + def get_completed_items( + self, + project_id: str | None = None, + section_id: str | None = None, + item_id: str | None = None, + last_seen_id: str | None = None, + limit: int | None = None, + cursor: str | None = None, + ) -> CompletedItems: + endpoint = get_sync_url(COMPLETED_ITEMS_ENDPOINT) + completed_items = get( + self._session, + endpoint, + self._token, + { + "project_id": project_id, + "section_id": section_id, + "item_id": item_id, + "last_seen_id": last_seen_id, + "limit": limit, + "cursor": cursor, + }, + ) + return CompletedItems.from_dict(completed_items) diff --git a/todoist_api_python/api_async.py b/todoist_api_python/api_async.py index c9e83f5..999b65f 100644 --- a/todoist_api_python/api_async.py +++ b/todoist_api_python/api_async.py @@ -6,6 +6,7 @@ from todoist_api_python.models import ( Collaborator, Comment, + CompletedItems, Label, Project, QuickAddResult, @@ -120,3 +121,18 @@ async def rename_shared_label(self, name: str, new_name: str) -> bool: async def remove_shared_label(self, name: str) -> bool: return await run_async(lambda: self._api.remove_shared_label(name)) + + async def get_completed_items( + self, + project_id: str | None = None, + section_id: str | None = None, + item_id: str | None = None, + last_seen_id: str | None = None, + limit: int | None = None, + cursor: str | None = None, + ) -> CompletedItems: + return await run_async( + lambda: self._api.get_completed_items( + project_id, section_id, item_id, last_seen_id, limit, cursor + ) + ) diff --git a/todoist_api_python/endpoints.py b/todoist_api_python/endpoints.py index e5a32be..da7c504 100644 --- a/todoist_api_python/endpoints.py +++ b/todoist_api_python/endpoints.py @@ -24,6 +24,8 @@ TOKEN_ENDPOINT = "oauth/access_token" REVOKE_TOKEN_ENDPOINT = "access_tokens/revoke" +COMPLETED_ITEMS_ENDPOINT = "archive/items" + def get_rest_url(relative_path: str) -> str: return urljoin(REST_API, relative_path) diff --git a/todoist_api_python/models.py b/todoist_api_python/models.py index 5c478f0..7a78719 100644 --- a/todoist_api_python/models.py +++ b/todoist_api_python/models.py @@ -1,7 +1,7 @@ from __future__ import annotations -from dataclasses import dataclass -from typing import List, Literal +from dataclasses import dataclass, fields +from typing import Any, List, Literal from todoist_api_python.utils import get_url_for_task @@ -357,3 +357,67 @@ def from_dict(cls, obj): access_token=obj["access_token"], state=obj["state"], ) + + +@dataclass +class Item: + id: str + user_id: str + project_id: str + content: str + description: str + priority: int + child_order: int + collapsed: bool + labels: list[str] + checked: bool + is_deleted: bool + added_at: str + due: Due | None = None + parent_id: int | None = None + section_id: str | None = None + day_order: int | None = None + added_by_uid: str | None = None + assigned_by_uid: str | None = None + responsible_uid: str | None = None + sync_id: str | None = None + completed_at: str | None = None + + @classmethod + def from_dict(cls, obj: dict[str, Any]) -> Item: + params = {f.name: obj[f.name] for f in fields(cls) if f.name in obj} + if (due := obj.get("due")) is not None: + params["due"] = Due.from_dict(due) + + return cls(**params) + + +@dataclass +class ItemCompletedInfo: + item_id: str + completed_items: int + + @classmethod + def from_dict(cls, obj: dict[str, Any]) -> ItemCompletedInfo: + return cls(**{f.name: obj[f.name] for f in fields(cls)}) + + +@dataclass +class CompletedItems: + items: list[Item] + total: int + completed_info: list[ItemCompletedInfo] + has_more: bool + next_cursor: str | None = None + + @classmethod + def from_dict(cls, obj: dict[str, Any]) -> CompletedItems: + return cls( + items=[Item.from_dict(v) for v in obj["items"]], + total=obj["total"], + completed_info=[ + ItemCompletedInfo.from_dict(v) for v in obj["completed_info"] + ], + has_more=obj["has_more"], + next_cursor=obj.get("next_cursor"), + )