From 2b9463e8e570fe94053e9cd1f5cf079c18946a7b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9=20=D0=90=D0=BD?= =?UTF-8?q?=D1=84=D0=B8=D0=BC=D0=BE=D0=B2?= Date: Sat, 30 Sep 2023 20:54:57 +0300 Subject: [PATCH] feat: support attachments --- examples/get_issue.py | 9 +- examples/get_test_entities.py | 39 +++++++ ya_tracker_client/domain/client/base.py | 16 ++- .../domain/entities/attachment.py | 23 ++++ .../domain/repositories/__init__.py | 2 + .../domain/repositories/attachment.py | 105 ++++++++++++++++++ ya_tracker_client/domain/repositories/base.py | 2 +- .../domain/repositories/checklist.py | 10 +- .../domain/repositories/component.py | 2 +- .../domain/repositories/issue.py | 10 +- .../domain/repositories/issue_relationship.py | 4 +- .../domain/repositories/queue.py | 12 +- ya_tracker_client/domain/repositories/user.py | 6 +- .../domain/repositories/worklog.py | 8 +- ya_tracker_client/infrastructure/client.py | 4 +- ya_tracker_client/service/api.py | 2 + 16 files changed, 211 insertions(+), 43 deletions(-) create mode 100644 examples/get_test_entities.py create mode 100644 ya_tracker_client/domain/entities/attachment.py create mode 100644 ya_tracker_client/domain/repositories/attachment.py diff --git a/examples/get_issue.py b/examples/get_issue.py index 1c13ff3..f0cfc89 100644 --- a/examples/get_issue.py +++ b/examples/get_issue.py @@ -19,14 +19,7 @@ async def main() -> None: oauth_token=API_TOKEN, ) - me = await client.get_myself() - print(me) - - me = await client.get_user(me.login, me.uid) - print(me) - - all_me = await client.get_users() - print(all_me) + ... await client.stop() diff --git a/examples/get_test_entities.py b/examples/get_test_entities.py new file mode 100644 index 0000000..fc50a84 --- /dev/null +++ b/examples/get_test_entities.py @@ -0,0 +1,39 @@ +import os +from asyncio import run + +from dotenv import load_dotenv + +from ya_tracker_client import YaTrackerClient + + +load_dotenv() +# from registered application at Yandex OAuth - https://oauth.yandex.ru/ +API_TOKEN = os.getenv("API_TOKEN") +# from admin panel at Yandex Tracker - https://tracker.yandex.ru/admin/orgs +API_ORGANISATION_ID = os.getenv("API_ORGANISATION_ID") + + +async def main() -> None: + client = YaTrackerClient( + organisation_id=API_ORGANISATION_ID, + oauth_token=API_TOKEN, + ) + + # requests for tests + me = await client.get_myself() + await client.get_user(uid=me.uid) + await client.get_users() + await client.get_issue('TRACKER-1') + await client.get_queue('TRACKER') + await client.get_issue_relationships('TRACKER-1') + await client.get_checklist_items("TRACKER-1") + await client.get_components() + await client.get_worklog("TRACKER-1") + await client.get_worklog_records_by_parameters(me.login) + await client.get_attachments_list('TRACKER-1') + + await client.stop() + + +if __name__ == "__main__": + run(main()) diff --git a/ya_tracker_client/domain/client/base.py b/ya_tracker_client/domain/client/base.py index f8cb2ba..990c83e 100644 --- a/ya_tracker_client/domain/client/base.py +++ b/ya_tracker_client/domain/client/base.py @@ -3,7 +3,7 @@ from logging import getLogger from typing import Any -from aiohttp import BytesPayload +from aiohttp import BytesPayload, FormData from ya_tracker_client.domain.client.errors import ( ClientAuthError, @@ -56,11 +56,15 @@ async def request( uri: str, params: dict[str, Any] | None = None, payload: dict[str, Any] | None = None, + form: FormData | None = None, ) -> bytes: - bytes_payload = BytesPayload( - value=bytes(serialize_entity(payload), encoding="utf-8"), - content_type="application/json", - ) + if form: + bytes_payload = form + else: + bytes_payload = BytesPayload( + value=bytes(serialize_entity(payload), encoding="utf-8"), + content_type="application/json", + ) status, body = await self._make_request( method=method, @@ -77,7 +81,7 @@ async def _make_request( method: str, url: str, params: dict[str, Any] | None = None, - data: bytes | BytesPayload | None = None, + data: bytes | BytesPayload | FormData | None = None, ) -> tuple[int, bytes]: """ Get raw response from via http-client. diff --git a/ya_tracker_client/domain/entities/attachment.py b/ya_tracker_client/domain/entities/attachment.py new file mode 100644 index 0000000..6357685 --- /dev/null +++ b/ya_tracker_client/domain/entities/attachment.py @@ -0,0 +1,23 @@ +from datetime import datetime + +from pydantic import Field + +from ya_tracker_client.domain.entities.base import AbstractEntity +from ya_tracker_client.domain.entities.user import UserShort + + +class AttachmentMetadata(AbstractEntity): + size: str + + +class Attachment(AbstractEntity): + url: str + id: int + name: str + content: str + thumbnail: str | None = None + created_by: UserShort + created_at: datetime + mimetype: str = Field(..., examples=['text/plain', 'image/png']) + size: int + metadata: AttachmentMetadata | None = None diff --git a/ya_tracker_client/domain/repositories/__init__.py b/ya_tracker_client/domain/repositories/__init__.py index dc4fb25..e7cd53f 100644 --- a/ya_tracker_client/domain/repositories/__init__.py +++ b/ya_tracker_client/domain/repositories/__init__.py @@ -5,9 +5,11 @@ from ya_tracker_client.domain.repositories.queue import QueueRepository from ya_tracker_client.domain.repositories.user import UserRepository from ya_tracker_client.domain.repositories.worklog import WorklogRepository +from ya_tracker_client.domain.repositories.attachment import AttachmentRepository __all__ = [ + "AttachmentRepository", "ChecklistRepository", "ComponentRepository", "IssueRelationshipRepository", diff --git a/ya_tracker_client/domain/repositories/attachment.py b/ya_tracker_client/domain/repositories/attachment.py new file mode 100644 index 0000000..c518924 --- /dev/null +++ b/ya_tracker_client/domain/repositories/attachment.py @@ -0,0 +1,105 @@ +from typing import BinaryIO + +from aiohttp import FormData + +from ya_tracker_client.domain.repositories.base import EntityRepository +from ya_tracker_client.domain.entities.attachment import Attachment + + +class AttachmentRepository(EntityRepository): + async def get_attachments_list(self, issue_id: str) -> list[Attachment]: + """ + Use this request to get a list of files attached to an issue and to comments below it. + + YC docs: https://cloud.yandex.com/en/docs/tracker/concepts/issues/get-attachments-list + """ + raw_response = await self._client.request( + method='GET', + uri=f"/issues/{issue_id}/attachments", + ) + return self._decode(raw_response, Attachment, plural=True) + + async def download_attachment( + self, + issue_id: str, + attachment_id: str | int, + filename: str + ) -> bytes: + """ + Use this request to download files attached to issues. + + YC docs: https://cloud.yandex.com/en/docs/tracker/concepts/issues/get-attachment + """ + return await self._client.request( + method='GET', + uri=f"/issues/{issue_id}/attachments/{attachment_id}/{filename}", + ) + + async def download_thumbnail( + self, + issue_id: str, + attachment_id: str | int, + ) -> bytes: + """ + Get thumbnails of image files attached to issues. + + YC docs: https://cloud.yandex.com/en/docs/tracker/concepts/issues/get-attachment-preview + """ + return await self._client.request( + method="GET", + uri=f"/issues/{issue_id}/thumbnails/{attachment_id}", + ) + + async def attach_file( + self, + issue_id: str, + file: BinaryIO, + filename: str | None = None, + ) -> Attachment: + """ + Attach a file to an issue. + + YC docs: https://cloud.yandex.com/en/docs/tracker/concepts/issues/post-attachment + """ + form = FormData(fields={"file_data": file}) + raw_response = await self._client.request( + method="POST", + uri=f"/issues/{issue_id}/attachments", + params={"filename": filename} if filename else None, + form=form, + ) + return self._decode(raw_response, Attachment) + + async def upload_temp_file( + self, + file: BinaryIO, + filename: str | None = None, + ) -> Attachment: + """ + Upload temporary file. + + Use this request to upload a file to Tracker first, and then + attach it when creating an issue or adding a comment. + + YC docs: https://cloud.yandex.com/en/docs/tracker/concepts/issues/temp-attachment + """ + form = FormData() + form.add_field("file_data", file) + raw_response = await self._client.request( + method="POST", + uri="/attachments/", + params={"filename": filename} if filename else None, + form=form, + ) + return self._decode(raw_response, Attachment) + + async def delete_attachment(self, issue_id: str, attachment_id: str | int) -> None: + """ + Delete attached file. + + YC docs: https://cloud.yandex.com/en/docs/tracker/concepts/issues/delete-attachment + """ + await self._client.request( + method="DELETE", + uri=f"/issues/{issue_id}/attachments/{attachment_id}/", + ) diff --git a/ya_tracker_client/domain/repositories/base.py b/ya_tracker_client/domain/repositories/base.py index 5860870..04c6170 100644 --- a/ya_tracker_client/domain/repositories/base.py +++ b/ya_tracker_client/domain/repositories/base.py @@ -8,7 +8,7 @@ class DeserializationMixin: @staticmethod - def deserialize( + def _decode( value: bytes, return_type: Type[BaseModel] | None = None, plural: bool = False, diff --git a/ya_tracker_client/domain/repositories/checklist.py b/ya_tracker_client/domain/repositories/checklist.py index 8f5bb4b..ca80bfb 100644 --- a/ya_tracker_client/domain/repositories/checklist.py +++ b/ya_tracker_client/domain/repositories/checklist.py @@ -28,7 +28,7 @@ async def create_checklist_item( deadline=deadline, ).model_dump(exclude_none=True, by_alias=True), ) - return self.deserialize(raw_response, IssueWithChecklist) + return self._decode(raw_response, IssueWithChecklist) async def get_checklist_items(self, issue_id: str) -> list[ChecklistItem]: """ @@ -40,7 +40,7 @@ async def get_checklist_items(self, issue_id: str) -> list[ChecklistItem]: method="GET", uri=f"/issues/{issue_id}/checklistItems", ) - return self.deserialize(raw_response, ChecklistItem, plural=True) + return self._decode(raw_response, ChecklistItem, plural=True) async def edit_checklist_item( self, @@ -64,7 +64,7 @@ async def edit_checklist_item( deadline=deadline, ).model_dump(exclude_none=True, by_alias=True), ) - return self.deserialize(raw_response, IssueWithChecklist) + return self._decode(raw_response, IssueWithChecklist) async def delete_checklist(self, issue_id: str) -> IssueWithChecklist: """ @@ -74,7 +74,7 @@ async def delete_checklist(self, issue_id: str) -> IssueWithChecklist: method="DELETE", uri=f"/issues/{issue_id}/checklistItems", ) - return self.deserialize(raw_response, IssueWithChecklist) + return self._decode(raw_response, IssueWithChecklist) async def delete_checklist_item(self, issue_id: str, checklist_item_id: str): """ @@ -84,4 +84,4 @@ async def delete_checklist_item(self, issue_id: str, checklist_item_id: str): method="DELETE", uri=f"/issues/{issue_id}/checklistItems/{checklist_item_id}", ) - return self.deserialize(raw_response, IssueWithChecklist) + return self._decode(raw_response, IssueWithChecklist) diff --git a/ya_tracker_client/domain/repositories/component.py b/ya_tracker_client/domain/repositories/component.py index 9431e44..18f2f88 100644 --- a/ya_tracker_client/domain/repositories/component.py +++ b/ya_tracker_client/domain/repositories/component.py @@ -11,4 +11,4 @@ async def get_components(self) -> list[Component]: method="GET", uri="/components", ) - return self.deserialize(raw_response, Component, plural=True) + return self._decode(raw_response, Component, plural=True) diff --git a/ya_tracker_client/domain/repositories/issue.py b/ya_tracker_client/domain/repositories/issue.py index 3b54893..f346380 100644 --- a/ya_tracker_client/domain/repositories/issue.py +++ b/ya_tracker_client/domain/repositories/issue.py @@ -17,7 +17,7 @@ async def get_issue(self, issue_id: str) -> Issue: method="GET", uri=f"/issues/{issue_id}", ) - return self.deserialize(raw_response, Issue) + return self._decode(raw_response, Issue) async def create_issue( self, @@ -53,7 +53,7 @@ async def create_issue( attachment_ids=attachment_ids, ).model_dump(exclude_none=True, by_alias=True), ) - return self.deserialize(raw_response, Issue) + return self._decode(raw_response, Issue) async def edit_issue( self, @@ -70,7 +70,7 @@ async def edit_issue( params={"version": version} if version is not None else None, payload=IssueEdit(**kwargs).model_dump(exclude_unset=True), ) - return self.deserialize(raw_response, Issue) + return self._decode(raw_response, Issue) async def get_priorities( self, @@ -84,7 +84,7 @@ async def get_priorities( uri="/priorities/", params={"localized": str(localized)}, ) - return self.deserialize(raw_response, Priority) + return self._decode(raw_response, Priority) async def get_issue_transitions( self, @@ -97,4 +97,4 @@ async def get_issue_transitions( method="GET", uri=f"/issues/{issue_id}/transitions/", ) - return self.deserialize(raw_response, Transition, plural=True) + return self._decode(raw_response, Transition, plural=True) diff --git a/ya_tracker_client/domain/repositories/issue_relationship.py b/ya_tracker_client/domain/repositories/issue_relationship.py index c14a514..2d412f6 100644 --- a/ya_tracker_client/domain/repositories/issue_relationship.py +++ b/ya_tracker_client/domain/repositories/issue_relationship.py @@ -24,7 +24,7 @@ async def create_issue_relationship( relationship=relationship, ).model_dump(exclude_none=True, by_alias=True), ) - return self.deserialize(raw_response, IssueRelationship) + return self._decode(raw_response, IssueRelationship) async def get_issue_relationships( self, @@ -37,7 +37,7 @@ async def get_issue_relationships( method="GET", uri=f"/issues/{issue_id}/links", ) - return self.deserialize(raw_response, IssueRelationship, plural=True) + return self._decode(raw_response, IssueRelationship, plural=True) async def delete_issue_relationships( self, diff --git a/ya_tracker_client/domain/repositories/queue.py b/ya_tracker_client/domain/repositories/queue.py index c42b973..7d4769c 100644 --- a/ya_tracker_client/domain/repositories/queue.py +++ b/ya_tracker_client/domain/repositories/queue.py @@ -30,7 +30,7 @@ async def create_queue( issue_types_config=issue_types_config, ).model_dump(exclude_none=True, by_alias=True), ) - return self.deserialize(raw_response, Queue) + return self._decode(raw_response, Queue) async def get_queue(self, queue_id: str | int) -> Queue: """ @@ -40,7 +40,7 @@ async def get_queue(self, queue_id: str | int) -> Queue: method="GET", uri=f"/queues/{queue_id}", ) - return self.deserialize(raw_response, Queue) + return self._decode(raw_response, Queue) async def get_queues(self) -> list[Queue]: """ @@ -50,7 +50,7 @@ async def get_queues(self) -> list[Queue]: method="GET", uri="/queues/", ) - return self.deserialize(raw_response, Queue, plural=True) + return self._decode(raw_response, Queue, plural=True) async def get_queue_versions(self, queue_id: str | int) -> list[QueueVersion]: """ @@ -60,7 +60,7 @@ async def get_queue_versions(self, queue_id: str | int) -> list[QueueVersion]: method="GET", uri=f"/queues/{queue_id}/versions", ) - return self.deserialize(raw_response, QueueVersion, plural=True) + return self._decode(raw_response, QueueVersion, plural=True) async def get_queue_fields(self, queue_id: str | int) -> list[QueueField]: """ @@ -70,7 +70,7 @@ async def get_queue_fields(self, queue_id: str | int) -> list[QueueField]: method="GET", uri=f"/queues/{queue_id}/fields", ) - return self.deserialize(raw_response, QueueField, plural=True) + return self._decode(raw_response, QueueField, plural=True) async def delete_queue(self, queue_id: str | int) -> None: """ @@ -89,7 +89,7 @@ async def restore_queue(self, queue_id: str | int) -> Queue: method="POST", uri=f"/queues/{queue_id}/_restore", ) - return self.deserialize(raw_response, Queue) + return self._decode(raw_response, Queue) async def delete_tag_in_queue(self, queue_id: str | int, tag_name: str) -> None: """ diff --git a/ya_tracker_client/domain/repositories/user.py b/ya_tracker_client/domain/repositories/user.py index e70477a..c1bc469 100644 --- a/ya_tracker_client/domain/repositories/user.py +++ b/ya_tracker_client/domain/repositories/user.py @@ -17,7 +17,7 @@ async def get_myself(self) -> User: method="GET", uri="/myself/", ) - return self.deserialize(raw_response, User) + return self._decode(raw_response, User) async def get_user( self, @@ -38,7 +38,7 @@ async def get_user( method="GET", uri=f"/users/{login or uid}", ) - return self.deserialize(raw_response, User) + return self._decode(raw_response, User) async def get_users(self) -> list[User]: """ @@ -48,4 +48,4 @@ async def get_users(self) -> list[User]: method="GET", uri="/users/", ) - return self.deserialize(raw_response, User, plural=True) + return self._decode(raw_response, User, plural=True) diff --git a/ya_tracker_client/domain/repositories/worklog.py b/ya_tracker_client/domain/repositories/worklog.py index 535c430..97f34c1 100644 --- a/ya_tracker_client/domain/repositories/worklog.py +++ b/ya_tracker_client/domain/repositories/worklog.py @@ -25,7 +25,7 @@ async def add_worklog_record( comment=comment, ).model_dump(exclude_none=True, by_alias=True), ) - return self.deserialize(raw_response, Worklog) + return self._decode(raw_response, Worklog) async def edit_worklog_record( self, @@ -45,7 +45,7 @@ async def edit_worklog_record( comment=comment, ).model_dump(exclude_none=True, by_alias=True), ) - return self.deserialize(raw_response, Worklog) + return self._decode(raw_response, Worklog) async def delete_worklog_record( self, @@ -68,7 +68,7 @@ async def get_worklog(self, issue_id: str) -> list[Worklog]: method="GET", uri=f"/issues/{issue_id}/worklog", ) - return self.deserialize(raw_response, Worklog, plural=True) + return self._decode(raw_response, Worklog, plural=True) async def get_worklog_records_by_parameters( self, @@ -94,4 +94,4 @@ async def get_worklog_records_by_parameters( uri="/worklog/_search", payload=payload, ) - return self.deserialize(raw_response, Worklog, plural=True) + return self._decode(raw_response, Worklog, plural=True) diff --git a/ya_tracker_client/infrastructure/client.py b/ya_tracker_client/infrastructure/client.py index 3233028..55951cd 100644 --- a/ya_tracker_client/infrastructure/client.py +++ b/ya_tracker_client/infrastructure/client.py @@ -2,7 +2,7 @@ from ssl import create_default_context from typing import Any -from aiohttp import BytesPayload, ClientSession, ClientTimeout, TCPConnector +from aiohttp import BytesPayload, ClientSession, ClientTimeout, TCPConnector, FormData from certifi import where from ya_tracker_client.domain.client import BaseClient @@ -51,7 +51,7 @@ async def _make_request( method: str, url: str, params: dict[str, Any] | None = None, - data: bytes | BytesPayload | None = None, + data: bytes | BytesPayload | FormData | None = None, ) -> tuple[int, bytes]: session = self._get_session() async with session.request(method, url, params=params, data=data) as response: diff --git a/ya_tracker_client/service/api.py b/ya_tracker_client/service/api.py index 3522884..73f12ec 100644 --- a/ya_tracker_client/service/api.py +++ b/ya_tracker_client/service/api.py @@ -1,4 +1,5 @@ from ya_tracker_client.domain.repositories import ( + AttachmentRepository, ChecklistRepository, ComponentRepository, IssueRelationshipRepository, @@ -11,6 +12,7 @@ class YaTrackerClient( + AttachmentRepository, ChecklistRepository, ComponentRepository, IssueRelationshipRepository,