diff --git a/itd/client.py b/itd/client.py index 0a5d94e..0192714 100644 --- a/itd/client.py +++ b/itd/client.py @@ -2,6 +2,7 @@ from warnings import deprecated from uuid import UUID from _io import BufferedReader from typing import cast +from datetime import datetime from requests.exceptions import ConnectionError, HTTPError @@ -10,7 +11,7 @@ from itd.routes.etc import get_top_clans, get_who_to_follow, get_platform_status from itd.routes.comments import get_comments, add_comment, delete_comment, like_comment, unlike_comment, add_reply_comment from itd.routes.hashtags import get_hashtags, get_posts_by_hashtag from itd.routes.notifications import get_notifications, mark_as_read, mark_all_as_read, get_unread_notifications_count -from itd.routes.posts import create_post, get_posts, get_post, edit_post, delete_post, pin_post, repost, view_post, get_liked_posts, restore_post, like_post, delete_like_post +from itd.routes.posts import create_post, get_posts, get_post, edit_post, delete_post, pin_post, repost, view_post, get_liked_posts, restore_post, like_post, unlike_post from itd.routes.reports import report from itd.routes.search import search from itd.routes.files import upload_file @@ -19,19 +20,21 @@ from itd.routes.verification import verify, get_verification_status from itd.models.comment import Comment from itd.models.notification import Notification -from itd.models.post import Post, NewPost, LikePostResponse +from itd.models.post import Post, NewPost from itd.models.clan import Clan from itd.models.hashtag import Hashtag from itd.models.user import User, UserProfileUpdate, UserPrivacy, UserFollower, UserWhoToFollow from itd.models.pagination import Pagination, PostsPagintaion, LikedPostsPagintaion from itd.models.verification import Verification, VerificationStatus +from itd.models.report import NewReport +from itd.models.file import File -from itd.enums import PostsTab +from itd.enums import PostsTab, ReportTargetType, ReportTargetReason from itd.request import set_cookies from itd.exceptions import ( NoCookie, NoAuthData, SamePassword, InvalidOldPassword, NotFound, ValidationError, UserBanned, PendingRequestExists, Forbidden, UsernameTaken, CantFollowYourself, Unauthorized, - CantRepostYourPost, AlreadyReposted + CantRepostYourPost, AlreadyReposted, AlreadyReported, TooLarge ) @@ -797,7 +800,21 @@ class Client: res.raise_for_status() @refresh_on_error - def get_liked_posts(self, username_or_id: str | UUID, limit: int = 20, cursor: int = 0) -> tuple[list[Post], LikedPostsPagintaion]: + def get_liked_posts(self, username_or_id: str | UUID, limit: int = 20, cursor: datetime | None = None) -> tuple[list[Post], LikedPostsPagintaion]: + """Получить список лайкнутых постов пользователя + + Args: + username_or_id (str | UUID): UUID или username пользователя + limit (int, optional): Лимит. Defaults to 20. + cursor (datetime | None, optional): Сдвиг (next_cursor). Defaults to None. + + Raises: + NotFound: Пользователь не найден + + Returns: + list[Post]: Список постов + LikedPostsPagintaion: Пагинация + """ res = get_liked_posts(self.token, username_or_id, limit, cursor) if res.json().get('error', {}).get('code') == 'NOT_FOUND': raise NotFound('User') @@ -808,41 +825,114 @@ class Client: @refresh_on_error - def report(self, id: str, type: str = 'post', reason: str = 'other', description: str = ''): - return report(self.token, id, type, reason, description) + def report(self, id: UUID, type: ReportTargetType = ReportTargetType.POST, reason: ReportTargetReason = ReportTargetReason.OTHER, description: str | None = None) -> NewReport: + """Отправить жалобу - @refresh_on_error - def report_user(self, id: str, reason: str = 'other', description: str = ''): - return report(self.token, id, 'user', reason, description) + Args: + id (UUID): UUID цели + type (ReportTargetType, optional): Тип цели (пост/пользователь/комментарий). Defaults to ReportTargetType.POST. + reason (ReportTargetReason, optional): Причина. Defaults to ReportTargetReason.OTHER. + description (str | None, optional): Описание. Defaults to None. - @refresh_on_error - def report_post(self, id: str, reason: str = 'other', description: str = ''): - return report(self.token, id, 'post', reason, description) + Raises: + NotFound: Цель не найдена + AlreadyReported: Жалоба уже отправлена + ValidationError: Ошибка валидации - @refresh_on_error - def report_comment(self, id: str, reason: str = 'other', description: str = ''): - return report(self.token, id, 'comment', reason, description) + Returns: + NewReport: Новая жалоба + """ + res = report(self.token, id, type, reason, description) + + if res.json().get('error', {}).get('code') == 'VALIDATION_ERROR' and 'не найден' in res.json()['error'].get('message', ''): + raise NotFound(type.value.title()) + if res.json().get('error', {}).get('code') == 'VALIDATION_ERROR' and 'Вы уже отправляли жалобу' in res.json()['error'].get('message', ''): + raise AlreadyReported(type.value.title()) + if res.status_code == 422 and 'found' in res.json(): + raise ValidationError(*list(res.json()['found'].items())[-1]) + res.raise_for_status() + + return NewReport.model_validate(res.json()['data']) @refresh_on_error - def search(self, query: str, user_limit: int = 5, hashtag_limit: int = 5): - return search(self.token, query, user_limit, hashtag_limit) + def search(self, query: str, user_limit: int = 5, hashtag_limit: int = 5) -> tuple[list[UserWhoToFollow], list[Hashtag]]: + """Поиск по пользователям и хэштэгам + + Args: + query (str): Запрос + user_limit (int, optional): Лимит пользователей. Defaults to 5. + hashtag_limit (int, optional): Лимит хэштэгов. Defaults to 5. + + Raises: + TooLarge: Слишком длинный запрос + + Returns: + list[UserWhoToFollow]: Список пользователей + list[Hashtag]: Список хэштэгов + """ + res = search(self.token, query, user_limit, hashtag_limit) + + if res.status_code == 414: + raise TooLarge() + res.raise_for_status() + data = res.json()['data'] + + return [UserWhoToFollow.model_validate(user) for user in data['users']], [Hashtag.model_validate(hashtag) for hashtag in data['hashtags']] @refresh_on_error - def search_user(self, query: str, limit: int = 5): - return search(self.token, query, limit, 0) + def search_user(self, query: str, limit: int = 5) -> list[UserWhoToFollow]: + """Поиск пользователей + + Args: + query (str): Запрос + limit (int, optional): Лимит. Defaults to 5. + + Returns: + list[UserWhoToFollow]: Список пользователей + """ + return self.search(query, limit, 0)[0] @refresh_on_error - def search_hashtag(self, query: str, limit: int = 5): - return search(self.token, query, 0, limit) + def search_hashtag(self, query: str, limit: int = 5) -> list[Hashtag]: + """Поиск хэштэгов + + Args: + query (str): Запрос + limit (int, optional): Лимит. Defaults to 5. + + Returns: + list[Hashtag]: Список хэштэгов + """ + return self.search(query, 0, limit)[1] @refresh_on_error - def upload_file(self, name: str, data: BufferedReader): - return upload_file(self.token, name, data).json() + def upload_file(self, name: str, data: BufferedReader) -> File: + """Загрузить файл + + Args: + name (str): Имя файла + data (BufferedReader): Содержимое (open('имя', 'rb')) + + Returns: + File: Файл + """ + res = upload_file(self.token, name, data) + res.raise_for_status() + + return File.model_validate(res.json()) def update_banner(self, name: str) -> UserProfileUpdate: - id = self.upload_file(name, cast(BufferedReader, open(name, 'rb')))['id'] + """Обновить банер (шорткат из upload_file + update_profile) + + Args: + name (str): Имя файла + + Returns: + UserProfileUpdate: Обновленный профиль + """ + id = self.upload_file(name, cast(BufferedReader, open(name, 'rb'))).id return self.update_profile(banner_id=id) @refresh_on_error @@ -852,28 +942,45 @@ class Client: Args: post_id: UUID поста """ - restore_post(self.token, post_id) + res = restore_post(self.token, post_id) + res.raise_for_status() @refresh_on_error - def like_post(self, post_id: UUID) -> LikePostResponse: - """Поставить лайк на пост + def like_post(self, post_id: UUID) -> int: + """Лайкнуть пост Args: - post_id: UUID поста + post_id (UUID): UUID поста + + Raises: + NotFound: Пост не найден + + Returns: + int: Количество лайков """ res = like_post(self.token, post_id) + if res.status_code == 404: - raise NotFound("Post not found") - return LikePostResponse.model_validate(res.json()) + raise NotFound("Post") + + return res.json()['likesCount'] @refresh_on_error - def delete_like_post(self, post_id: UUID) -> LikePostResponse: + def unlike_post(self, post_id: UUID) -> int: """Убрать лайк с поста Args: - post_id: UUID поста + post_id (UUID): UUID поста + + Raises: + NotFound: Пост не найден + + Returns: + int: Количество лайков """ - res = delete_like_post(self.token, post_id) + res = unlike_post(self.token, post_id) + if res.status_code == 404: raise NotFound("Post not found") - return LikePostResponse.model_validate(res.json()) + + return res.json()['likesCount'] diff --git a/itd/exceptions.py b/itd/exceptions.py index abfa498..63b5925 100644 --- a/itd/exceptions.py +++ b/itd/exceptions.py @@ -11,15 +11,17 @@ class InvalidCookie(Exception): self.code = code def __str__(self): if self.code == 'SESSION_NOT_FOUND': - return f'Invalid cookie data: Session not found (incorrect refresh token)' + return 'Invalid cookie data: Session not found (incorrect refresh token)' elif self.code == 'REFRESH_TOKEN_MISSING': - return f'Invalid cookie data: No refresh token' + return 'Invalid cookie data: No refresh token' + elif self.code == 'SESSION_EXPIRED': + return 'Invalid cookie data: Session expired' # SESSION_REVOKED - return f'Invalid cookie data: Session revoked (logged out)' + return 'Invalid cookie data: Session revoked (logged out)' class InvalidToken(Exception): def __str__(self): - return f'Invalid access token' + return 'Invalid access token' class SamePassword(Exception): def __str__(self): @@ -30,7 +32,7 @@ class InvalidOldPassword(Exception): return 'Old password is incorrect' class NotFound(Exception): - def __init__(self, obj): + def __init__(self, obj: str): self.obj = obj def __str__(self): return f'{self.obj} not found' @@ -81,3 +83,13 @@ class CantRepostYourPost(Exception): class AlreadyReposted(Exception): def __str__(self): return 'Post already reposted' + +class AlreadyReported(Exception): + def __init__(self, obj: str) -> None: + self.obj = obj + def __str__(self): + return f'{self.obj} already reported' + +class TooLarge(Exception): + def __str__(self): + return 'Search query too large' diff --git a/itd/models/pagination.py b/itd/models/pagination.py index 6bfe1c7..a970999 100644 --- a/itd/models/pagination.py +++ b/itd/models/pagination.py @@ -13,7 +13,7 @@ class Pagination(BaseModel): class PostsPagintaion(BaseModel): limit: int = 20 - next_cursor: int = Field(1, alias='nextCursor') + next_cursor: int | None = Field(1, alias='nextCursor') has_more: bool = Field(True, alias='hasMore') diff --git a/itd/models/pin.py b/itd/models/pin.py new file mode 100644 index 0000000..dd74d97 --- /dev/null +++ b/itd/models/pin.py @@ -0,0 +1,6 @@ +from pydantic import BaseModel + +class Pin(BaseModel): + slug: str + name: str + description: str \ No newline at end of file diff --git a/itd/models/post.py b/itd/models/post.py index 3343242..429de17 100644 --- a/itd/models/post.py +++ b/itd/models/post.py @@ -44,8 +44,3 @@ class Post(_Post, PostShort): class NewPost(_Post): author: UserNewPost - - -class LikePostResponse(BaseModel): - liked: bool - likes_count: int = Field(alias="likesCount") diff --git a/itd/models/report.py b/itd/models/report.py index 2a6108d..425bec1 100644 --- a/itd/models/report.py +++ b/itd/models/report.py @@ -5,12 +5,15 @@ from pydantic import BaseModel, Field from itd.enums import ReportTargetType, ReportTargetReason -class Report(BaseModel): + +class NewReport(BaseModel): id: UUID + created_at: datetime = Field(alias='createdAt') + + +class Report(NewReport): reason: ReportTargetReason description: str | None = None target_type: ReportTargetType = Field(alias='targetType') target_id: UUID - - created_at: datetime = Field(alias='createdAt') \ No newline at end of file diff --git a/itd/models/user.py b/itd/models/user.py index a35ff30..64913eb 100644 --- a/itd/models/user.py +++ b/itd/models/user.py @@ -3,6 +3,8 @@ from datetime import datetime from pydantic import BaseModel, Field +from itd.models.pin import Pin + class UserPrivacy(BaseModel): private: bool | None = Field(None, alias='isPrivate') # none for not me @@ -24,6 +26,7 @@ class UserNewPost(BaseModel): username: str | None = None display_name: str = Field(alias='displayName') avatar: str + pin: Pin | None = None verified: bool = False diff --git a/itd/request.py b/itd/request.py index 81bfad5..b943969 100644 --- a/itd/request.py +++ b/itd/request.py @@ -85,11 +85,11 @@ def auth_fetch(cookies: str, method: str, url: str, params: dict = {}, token: st try: if res.json().get('error', {}).get('code') == 'RATE_LIMIT_EXCEEDED': raise RateLimitExceeded(res.json()['error'].get('retryAfter', 0)) - if res.json().get('error', {}).get('code') in ('SESSION_NOT_FOUND', 'REFRESH_TOKEN_MISSING', 'SESSION_REVOKED'): + if res.json().get('error', {}).get('code') in ('SESSION_NOT_FOUND', 'REFRESH_TOKEN_MISSING', 'SESSION_REVOKED', 'SESSION_EXPIRED'): raise InvalidCookie(res.json()['error']['code']) if res.json().get('error', {}).get('code') == 'UNAUTHORIZED': raise Unauthorized() except JSONDecodeError: - pass + print('fail to parse json') return res diff --git a/itd/routes/posts.py b/itd/routes/posts.py index 7476b88..0cd1290 100644 --- a/itd/routes/posts.py +++ b/itd/routes/posts.py @@ -1,3 +1,4 @@ +from datetime import datetime from uuid import UUID from itd.request import fetch @@ -36,8 +37,8 @@ def repost(token: str, id: UUID, content: str | None = None): def view_post(token: str, id: UUID): return fetch(token, 'post', f'posts/{id}/view') -def get_liked_posts(token: str, username_or_id: str | UUID, limit: int = 20, cursor: int = 0): - return fetch(token, 'get', f'posts/user/{username_or_id}/liked', {'limit': limit}) +def get_liked_posts(token: str, username_or_id: str | UUID, limit: int = 20, cursor: datetime | None = None): + return fetch(token, 'get', f'posts/user/{username_or_id}/liked', {'limit': limit, 'cursor': cursor}) def restore_post(token: str, post_id: UUID): return fetch(token, "post", f"posts/{post_id}/restore",) @@ -45,5 +46,5 @@ def restore_post(token: str, post_id: UUID): def like_post(token: str, post_id: UUID): return fetch(token, "post", f"posts/{post_id}/like") -def delete_like_post(token: str, post_id: UUID): +def unlike_post(token: str, post_id: UUID): return fetch(token, "delete", f"posts/{post_id}/like") diff --git a/itd/routes/reports.py b/itd/routes/reports.py index 933f48d..7306bac 100644 --- a/itd/routes/reports.py +++ b/itd/routes/reports.py @@ -1,4 +1,9 @@ -from itd.request import fetch +from uuid import UUID -def report(token: str, id: str, type: str = 'post', reason: str = 'other', description: str = ''): - return fetch(token, 'post', 'reports', {'targetId': id, 'targetType': type, 'reason': reason, 'description': description}) \ No newline at end of file +from itd.request import fetch +from itd.enums import ReportTargetReason, ReportTargetType + +def report(token: str, id: UUID, type: ReportTargetType = ReportTargetType.POST, reason: ReportTargetReason = ReportTargetReason.OTHER, description: str | None = None): + if description is None: + description = '' + return fetch(token, 'post', 'reports', {'targetId': str(id), 'targetType': type.value, 'reason': reason.value, 'description': description}) \ No newline at end of file