From 62730b48e99d75b5a4dfef3663368d80ad8931be Mon Sep 17 00:00:00 2001 From: firedotguy Date: Thu, 12 Feb 2026 19:56:57 +0300 Subject: [PATCH] feat: add polls --- itd/client.py | 51 ++++++++++++++++++++++++++++++++++++++------- itd/exceptions.py | 22 ++++++++++++++----- itd/models/post.py | 51 +++++++++++++++++++++++++++++++++++++++++++-- itd/routes/posts.py | 22 ++++++++++++------- 4 files changed, 124 insertions(+), 22 deletions(-) diff --git a/itd/client.py b/itd/client.py index 7105c0b..502d4ba 100644 --- a/itd/client.py +++ b/itd/client.py @@ -13,7 +13,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, get_replies 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, stream_notifications -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, get_user_posts +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, get_user_posts, vote from itd.routes.reports import report from itd.routes.search import search from itd.routes.files import upload_file, get_file, delete_file @@ -23,7 +23,7 @@ from itd.routes.pins import get_pins, remove_pin, set_pin from itd.models.comment import Comment from itd.models.notification import Notification -from itd.models.post import Post, NewPost +from itd.models.post import Post, NewPost, PollData, Poll from itd.models.clan import Clan from itd.models.hashtag import Hashtag from itd.models.user import User, UserProfileUpdate, UserPrivacy, UserFollower, UserWhoToFollow @@ -40,7 +40,7 @@ from itd.exceptions import ( NoCookie, NoAuthData, SamePassword, InvalidOldPassword, NotFound, ValidationError, UserBanned, PendingRequestExists, Forbidden, UsernameTaken, CantFollowYourself, Unauthorized, CantRepostYourPost, AlreadyReposted, AlreadyReported, TooLarge, PinNotOwned, NoContent, - AlreadyFollowing, NotFoundOrForbidden + AlreadyFollowing, NotFoundOrForbidden, OptionsNotBelong, NotMultipleChoice, EmptyOptions ) @@ -630,13 +630,14 @@ class Client: @refresh_on_error - def create_post(self, content: str, wall_recipient_id: UUID | None = None, attach_ids: list[UUID] = []) -> NewPost: + def create_post(self, content: str | None = None, wall_recipient_id: UUID | None = None, attachment_ids: list[UUID] = [], poll: PollData | None = None) -> NewPost: """Создать пост Args: - content (str): Содержимое + content (str | None, optional): Содержимое. Defaults to None. wall_recipient_id (UUID | None, optional): UUID пользователя (чтобы создать пост ему на стене). Defaults to None. - attach_ids (list[UUID], optional): UUID вложений. Defaults to []. + attachment_ids (list[UUID], optional): UUID вложений. Defaults to []. + poll (PollData | None, optional): Опрос. Defaults to None. Raises: NotFound: Пользователь не найден @@ -645,7 +646,8 @@ class Client: Returns: NewPost: Новый пост """ - res = create_post(self.token, content, wall_recipient_id, attach_ids) + res = create_post(self.token, content, wall_recipient_id, attachment_ids, poll.poll if poll else None) + if res.json().get('error', {}).get('code') == 'NOT_FOUND': raise NotFound('Wall recipient') if res.status_code == 422 and 'found' in res.json(): @@ -654,6 +656,41 @@ class Client: return NewPost.model_validate(res.json()) + @refresh_on_error + def vote(self, id: UUID, option_ids: list[UUID]) -> Poll: + """Проголосовать в опросе + + Args: + id (UUID): UUID поста + option_ids (list[UUID]): Список UUID вариантов + + Raises: + EmptyOptions: Пустые варианты + NotFound: Пост не найден или в посте нет опроса + NotFound: _description_ + OptionsNotBelong: Неверные варианты (варинты не пренадлежат опросу) + NotMultipleChoice: Можно выбрать только 1 вариант (для опросов, где не разрешены несколько ответов) + + Returns: + Poll: Опрос + """ + if not option_ids: + raise EmptyOptions() + + res = vote(self.token, id, option_ids) + + if res.json().get('error', {}).get('code') == 'NOT_FOUND' and res.json().get('error', {}).get('message') == 'Опрос не найден': + raise NotFound('Poll') + if res.json().get('error', {}).get('code') == 'NOT_FOUND': + raise NotFound('Post') + if res.json().get('error', {}).get('code') == 'VALIDATION_ERROR' and res.json().get('error', {}).get('message') == 'Один или несколько вариантов не принадлежат этому опросу': + raise OptionsNotBelong() + if res.json().get('error', {}).get('code') == 'VALIDATION_ERROR' and res.json().get('error', {}).get('message') == 'В этом опросе можно выбрать только один вариант': + raise NotMultipleChoice() + res.raise_for_status() + + return Poll.model_validate(res.json()['data']) + @refresh_on_error def get_posts(self, cursor: int = 0, tab: PostsTab = PostsTab.POPULAR) -> tuple[list[Post], PostsPagintaion]: """Получить список постов diff --git a/itd/exceptions.py b/itd/exceptions.py index 376a30c..e545c7a 100644 --- a/itd/exceptions.py +++ b/itd/exceptions.py @@ -49,11 +49,11 @@ class UserBanned(Exception): return 'User banned' class ValidationError(Exception): - def __init__(self, name: str, value: str): - self.name = name - self.value = value + # def __init__(self, name: str, value: str): + # self.name = name + # self.value = value def __str__(self): - return f'Failed validation on {self.name}: "{self.value}"' + return 'Failed validation'# on {self.name}: "{self.value}"' class PendingRequestExists(Exception): def __str__(self): @@ -117,4 +117,16 @@ class AlreadyFollowing(Exception): class AccountBanned(Exception): def __str__(self) -> str: - return 'Account has been deactivated' \ No newline at end of file + return 'Account has been deactivated' + +class OptionsNotBelong(Exception): + def __str__(self) -> str: + return 'One or more options do not belong to poll' + +class NotMultipleChoice(Exception): + def __str__(self) -> str: + return 'Only one option can be choosen in this poll' + +class EmptyOptions(Exception): + def __str__(self) -> str: + return 'Options cannot be empty (pre-validation)' \ No newline at end of file diff --git a/itd/models/post.py b/itd/models/post.py index 429de17..97d766d 100644 --- a/itd/models/post.py +++ b/itd/models/post.py @@ -1,6 +1,7 @@ from uuid import UUID +from datetime import datetime -from pydantic import Field, BaseModel +from pydantic import Field, BaseModel, field_validator from itd.models.user import UserPost, UserNewPost from itd.models._text import TextObject @@ -8,6 +9,51 @@ from itd.models.file import PostAttach from itd.models.comment import Comment +class NewPollOption(BaseModel): + text: str + + +class PollOption(NewPollOption): + id: UUID + position: int = 0 + votes: int = Field(0, alias='votesCount') + + +class _Poll(BaseModel): + multiple: bool = Field(False, alias='multipleChoice') + question: str + + +class NewPoll(_Poll): + options: list[NewPollOption] + model_config = {'serialize_by_alias': True} + + +class PollData: + def __init__(self, question: str, options: list[str], multiple: bool = False): + self.poll = NewPoll(question=question, options=[NewPollOption(text=option) for option in options], multipleChoice=multiple) + + +class Poll(_Poll): + id: UUID + post_id: UUID = Field(alias='postId') + + options: list[PollOption] + votes: int = Field(0, alias='totalVotes') + is_voted: bool = Field(False, alias='hasVoted') + voted_option_ids: list[UUID] = Field([], alias='votedOptionIds') + + created_at: datetime = Field(alias='createdAt') + + @field_validator('created_at', mode='plain') + @classmethod + def validate_created_at(cls, v: str): + try: + return datetime.strptime(v + '00', '%Y-%m-%d %H:%M:%S.%f%z') + except ValueError: + return datetime.strptime(v, '%Y-%m-%dT%H:%M:%S.%f') + + class _PostShort(TextObject): likes_count: int = Field(0, alias='likesCount') comments_count: int = Field(0, alias='commentsCount') @@ -39,8 +85,9 @@ class _Post(_PostShort): class Post(_Post, PostShort): - pass + poll: Poll | None = None class NewPost(_Post): author: UserNewPost + poll: NewPoll | None = None diff --git a/itd/routes/posts.py b/itd/routes/posts.py index 30eb802..1932e52 100644 --- a/itd/routes/posts.py +++ b/itd/routes/posts.py @@ -3,13 +3,16 @@ from uuid import UUID from itd.request import fetch from itd.enums import PostsTab +from itd.models.post import NewPoll -def create_post(token: str, content: str, wall_recipient_id: UUID | None = None, attachment_ids: list[UUID] = []): - data: dict = {'content': content} +def create_post(token: str, content: str | None = None, wall_recipient_id: UUID | None = None, attachment_ids: list[UUID] = [], poll: NewPoll | None = None): + data: dict = {'content': content or ''} if wall_recipient_id: data['wallRecipientId'] = str(wall_recipient_id) if attachment_ids: data['attachmentIds'] = list(map(str, attachment_ids)) + if poll: + data['poll'] = poll.model_dump() return fetch(token, 'post', 'posts', data) @@ -43,11 +46,14 @@ def get_liked_posts(token: str, username_or_id: str | UUID, limit: int = 20, cur def get_user_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}', {'limit': limit, 'cursor': cursor}) -def restore_post(token: str, post_id: UUID): - return fetch(token, "post", f"posts/{post_id}/restore",) +def restore_post(token: str, id: UUID): + return fetch(token, "post", f"posts/{id}/restore",) -def like_post(token: str, post_id: UUID): - return fetch(token, "post", f"posts/{post_id}/like") +def like_post(token: str, id: UUID): + return fetch(token, "post", f"posts/{id}/like") -def unlike_post(token: str, post_id: UUID): - return fetch(token, "delete", f"posts/{post_id}/like") +def unlike_post(token: str, id: UUID): + return fetch(token, "delete", f"posts/{id}/like") + +def vote(token: str, id: UUID, options: list[UUID]): + return fetch(token, 'post', f'posts/{id}/poll/vote', {'optionIds': [str(option) for option in options]})