feat: add polls

This commit is contained in:
firedotguy
2026-02-12 19:56:57 +03:00
parent c1042d32ae
commit 62730b48e9
4 changed files with 124 additions and 22 deletions

View File

@@ -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.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.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.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.reports import report
from itd.routes.search import search from itd.routes.search import search
from itd.routes.files import upload_file, get_file, delete_file 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.comment import Comment
from itd.models.notification import Notification 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.clan import Clan
from itd.models.hashtag import Hashtag from itd.models.hashtag import Hashtag
from itd.models.user import User, UserProfileUpdate, UserPrivacy, UserFollower, UserWhoToFollow 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, NoCookie, NoAuthData, SamePassword, InvalidOldPassword, NotFound, ValidationError, UserBanned,
PendingRequestExists, Forbidden, UsernameTaken, CantFollowYourself, Unauthorized, PendingRequestExists, Forbidden, UsernameTaken, CantFollowYourself, Unauthorized,
CantRepostYourPost, AlreadyReposted, AlreadyReported, TooLarge, PinNotOwned, NoContent, CantRepostYourPost, AlreadyReposted, AlreadyReported, TooLarge, PinNotOwned, NoContent,
AlreadyFollowing, NotFoundOrForbidden AlreadyFollowing, NotFoundOrForbidden, OptionsNotBelong, NotMultipleChoice, EmptyOptions
) )
@@ -630,13 +630,14 @@ class Client:
@refresh_on_error @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: Args:
content (str): Содержимое content (str | None, optional): Содержимое. Defaults to None.
wall_recipient_id (UUID | None, optional): UUID пользователя (чтобы создать пост ему на стене). 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: Raises:
NotFound: Пользователь не найден NotFound: Пользователь не найден
@@ -645,7 +646,8 @@ class Client:
Returns: Returns:
NewPost: Новый пост 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': if res.json().get('error', {}).get('code') == 'NOT_FOUND':
raise NotFound('Wall recipient') raise NotFound('Wall recipient')
if res.status_code == 422 and 'found' in res.json(): if res.status_code == 422 and 'found' in res.json():
@@ -654,6 +656,41 @@ class Client:
return NewPost.model_validate(res.json()) 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 @refresh_on_error
def get_posts(self, cursor: int = 0, tab: PostsTab = PostsTab.POPULAR) -> tuple[list[Post], PostsPagintaion]: def get_posts(self, cursor: int = 0, tab: PostsTab = PostsTab.POPULAR) -> tuple[list[Post], PostsPagintaion]:
"""Получить список постов """Получить список постов

View File

@@ -49,11 +49,11 @@ class UserBanned(Exception):
return 'User banned' return 'User banned'
class ValidationError(Exception): class ValidationError(Exception):
def __init__(self, name: str, value: str): # def __init__(self, name: str, value: str):
self.name = name # self.name = name
self.value = value # self.value = value
def __str__(self): def __str__(self):
return f'Failed validation on {self.name}: "{self.value}"' return 'Failed validation'# on {self.name}: "{self.value}"'
class PendingRequestExists(Exception): class PendingRequestExists(Exception):
def __str__(self): def __str__(self):
@@ -118,3 +118,15 @@ class AlreadyFollowing(Exception):
class AccountBanned(Exception): class AccountBanned(Exception):
def __str__(self) -> str: def __str__(self) -> str:
return 'Account has been deactivated' 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)'

View File

@@ -1,6 +1,7 @@
from uuid import UUID 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.user import UserPost, UserNewPost
from itd.models._text import TextObject from itd.models._text import TextObject
@@ -8,6 +9,51 @@ from itd.models.file import PostAttach
from itd.models.comment import Comment 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): class _PostShort(TextObject):
likes_count: int = Field(0, alias='likesCount') likes_count: int = Field(0, alias='likesCount')
comments_count: int = Field(0, alias='commentsCount') comments_count: int = Field(0, alias='commentsCount')
@@ -39,8 +85,9 @@ class _Post(_PostShort):
class Post(_Post, PostShort): class Post(_Post, PostShort):
pass poll: Poll | None = None
class NewPost(_Post): class NewPost(_Post):
author: UserNewPost author: UserNewPost
poll: NewPoll | None = None

View File

@@ -3,13 +3,16 @@ from uuid import UUID
from itd.request import fetch from itd.request import fetch
from itd.enums import PostsTab 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] = []): 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} data: dict = {'content': content or ''}
if wall_recipient_id: if wall_recipient_id:
data['wallRecipientId'] = str(wall_recipient_id) data['wallRecipientId'] = str(wall_recipient_id)
if attachment_ids: if attachment_ids:
data['attachmentIds'] = list(map(str, attachment_ids)) data['attachmentIds'] = list(map(str, attachment_ids))
if poll:
data['poll'] = poll.model_dump()
return fetch(token, 'post', 'posts', data) 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): 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}) return fetch(token, 'get', f'posts/user/{username_or_id}', {'limit': limit, 'cursor': cursor})
def restore_post(token: str, post_id: UUID): def restore_post(token: str, id: UUID):
return fetch(token, "post", f"posts/{post_id}/restore",) return fetch(token, "post", f"posts/{id}/restore",)
def like_post(token: str, post_id: UUID): def like_post(token: str, id: UUID):
return fetch(token, "post", f"posts/{post_id}/like") return fetch(token, "post", f"posts/{id}/like")
def unlike_post(token: str, post_id: UUID): def unlike_post(token: str, id: UUID):
return fetch(token, "delete", f"posts/{post_id}/like") 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]})