feat: add models part 3

This commit is contained in:
firedotguy
2026-02-01 17:20:37 +03:00
parent 2a9f7da9a9
commit ba78457de5
13 changed files with 267 additions and 78 deletions

View File

@@ -2,7 +2,7 @@ from uuid import UUID
from _io import BufferedReader from _io import BufferedReader
from typing import cast from typing import cast
from requests.exceptions import HTTPError from requests.exceptions import HTTPError, ConnectionError
from itd.routes.users import get_user, update_profile, follow, unfollow, get_followers, get_following, update_privacy from itd.routes.users import get_user, update_profile, follow, unfollow, get_followers, get_following, update_privacy
from itd.routes.etc import get_top_clans, get_who_to_follow, get_platform_status from itd.routes.etc import get_top_clans, get_who_to_follow, get_platform_status
@@ -17,13 +17,16 @@ from itd.routes.auth import refresh_token, change_password, logout
from itd.routes.verification import verificate, get_verification_status from itd.routes.verification import verificate, get_verification_status
from itd.models.comment import Comment from itd.models.comment import Comment
from itd.models.notification import Notification
from itd.models.post import Post, NewPost
from itd.models.clan import Clan 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.user import User, UserProfileUpdate, UserPrivacy, UserFollower, UserWhoToFollow
from itd.models.pagination import Pagination from itd.models.pagination import Pagination
from itd.models.verification import Verification, VerificationStatus from itd.models.verification import Verification, VerificationStatus
from itd.request import set_cookies from itd.request import set_cookies
from itd.exceptions import NoCookie, NoAuthData, SamePassword, InvalidOldPassword, NotFound, ValidationError, UserBanned, PendingRequestExists from itd.exceptions import NoCookie, NoAuthData, SamePassword, InvalidOldPassword, NotFound, ValidationError, UserBanned, PendingRequestExists, Forbidden, UsernameTaken
def refresh_on_error(func): def refresh_on_error(func):
@@ -35,6 +38,9 @@ def refresh_on_error(func):
self.refresh_auth() self.refresh_auth()
return func(self, *args, **kwargs) return func(self, *args, **kwargs)
raise e raise e
except ConnectionError:
self.refresh_auth()
return func(self, *args, **kwargs)
return wrapper return wrapper
@@ -166,6 +172,8 @@ class Client:
res = update_profile(self.token, bio, display_name, username, banner_id) res = update_profile(self.token, bio, display_name, username, banner_id)
if res.status_code == 422 and 'found' in res.json(): if res.status_code == 422 and 'found' in res.json():
raise ValidationError(*list(res.json()['found'].items())[0]) raise ValidationError(*list(res.json()['found'].items())[0])
if res.json().get('error', {}).get('code') == 'USERNAME_TAKEN':
raise UsernameTaken()
res.raise_for_status() res.raise_for_status()
return UserProfileUpdate.model_validate(res.json()) return UserProfileUpdate.model_validate(res.json())
@@ -354,6 +362,7 @@ class Client:
Raises: Raises:
ValidationError: Ошибка валидации ValidationError: Ошибка валидации
NotFound: Пост не найден
Returns: Returns:
Comment: Комментарий Comment: Комментарий
@@ -379,6 +388,7 @@ class Client:
Raises: Raises:
ValidationError: Ошибка валидации ValidationError: Ошибка валидации
NotFound: Пользователь или комментарий не найден
Returns: Returns:
Comment: Комментарий Comment: Комментарий
@@ -397,56 +407,191 @@ class Client:
@refresh_on_error @refresh_on_error
def get_comments(self, post_id: UUID, limit: int = 20, cursor: int = 0, sort: str = 'popular') -> tuple[list[Comment], Pagination]: def get_comments(self, post_id: UUID, limit: int = 20, cursor: int = 0, sort: str = 'popular') -> tuple[list[Comment], Pagination]:
"""Получить список комментариев
Args:
post_id (UUID): UUID поста
limit (int, optional): Лимит. Defaults to 20.
cursor (int, optional): Курсор (сколько пропустить). Defaults to 0.
sort (str, optional): Сортировка. Defaults to 'popular'.
Raises:
NotFound: Пост не найден
Returns:
list[Comment]: Список комментариев
Pagination: Пагинация
"""
res = get_comments(self.token, post_id, limit, cursor, sort) res = get_comments(self.token, post_id, limit, cursor, sort)
if res.json().get('error', {}).get('code') == 'NOT_FOUND': if res.json().get('error', {}).get('code') == 'NOT_FOUND':
raise NotFound('Post') raise NotFound('Post')
res.raise_for_status() res.raise_for_status()
data = res.json()['data'] data = res.json()['data']
return [Comment.model_validate(comment) for comment in data['comments']], Pagination(page=(cursor // limit) or 1, limit=limit, total=data['total'], hasMore=data['hasMore']) return [Comment.model_validate(comment) for comment in data['comments']], Pagination(page=(cursor // limit) or 1, limit=limit, total=data['total'], hasMore=data['hasMore'], nextCursor=None)
@refresh_on_error @refresh_on_error
def like_comment(self, id: UUID): def like_comment(self, id: UUID) -> int:
return like_comment(self.token, id) """Лайкнуть комментарий
Args:
id (UUID): UUID комментария
Raises:
NotFound: Комментарий не найден
Returns:
int: Количество лайков
"""
res = like_comment(self.token, id)
if res.json().get('error', {}).get('code') == 'NOT_FOUND':
raise NotFound('Comment')
res.raise_for_status()
return res.json()['likesCount']
@refresh_on_error @refresh_on_error
def unlike_comment(self, id: UUID): def unlike_comment(self, id: UUID) -> int:
return unlike_comment(self.token, id) """Убрать лайк с комментария
Args:
id (UUID): UUID комментария
Raises:
NotFound: Комментарий не найден
Returns:
int: Количество лайков
"""
res = unlike_comment(self.token, id)
if res.json().get('error', {}).get('code') == 'NOT_FOUND':
raise NotFound('Comment')
res.raise_for_status()
return res.json()['likesCount']
@refresh_on_error @refresh_on_error
def delete_comment(self, id: UUID): def delete_comment(self, id: UUID) -> None:
return delete_comment(self.token, id) """Удалить комментарий
Args:
id (UUID): UUID комментария
Raises:
NotFound: Комментарий не найден
Forbidden: Нет прав на удаление
"""
res = delete_comment(self.token, id)
if res.status_code == 204:
return
if res.json().get('error', {}).get('code') == 'NOT_FOUND':
raise NotFound('Comment')
if res.json().get('error', {}).get('code') == 'FORBIDDEN':
raise Forbidden('delete comment')
res.raise_for_status()
@refresh_on_error @refresh_on_error
def get_hastags(self, limit: int = 10): def get_hastags(self, limit: int = 10) -> list[Hashtag]:
return get_hastags(self.token, limit) """Получить список популярных хэштэгов
Args:
limit (int, optional): Лимит. Defaults to 10.
Returns:
list[Hashtag]: Список хэштэгов
"""
res = get_hastags(self.token, limit)
res.raise_for_status()
return [Hashtag.model_validate(hashtag) for hashtag in res.json()['data']['hashtags']]
@refresh_on_error @refresh_on_error
def get_posts_by_hashtag(self, hashtag: str, limit: int = 20, cursor: int = 0): def get_posts_by_hashtag(self, hashtag: str, limit: int = 20, cursor: UUID | None = None) -> tuple[Hashtag | None, list[Post], Pagination]:
return get_posts_by_hastag(self.token, hashtag, limit, cursor) """Получить посты по хэштэгу
Args:
hashtag (str): Хэштэг (без #)
limit (int, optional): Лимит. Defaults to 20.
cursor (UUID | None, optional): Курсор (UUID последнего поста, после которого брать данные). Defaults to None.
Returns:
Hashtag | None: Хэштэг
list[Post]: Посты
Pagination: Пагинация
"""
res = get_posts_by_hastag(self.token, hashtag, limit, cursor)
res.raise_for_status()
data = res.json()['data']
return Hashtag.model_validate(data['hashtag']), [Post.model_validate(post) for post in data['posts']], Pagination.model_validate(data['pagination'])
@refresh_on_error @refresh_on_error
def get_notifications(self, limit: int = 20, cursor: int = 0, type: str | None = None): def get_notifications(self, limit: int = 20, offset: int = 0) -> tuple[list[Notification], Pagination]:
return get_notifications(self.token, limit, cursor, type) """Получить уведомления
Args:
limit (int, optional): Лимит. Defaults to 20.
offset (int, optional): Сдвиг. Defaults to 0.
Returns:
list[Notification]: Уведомления
Pagination: Пагинация
"""
res = get_notifications(self.token, limit, offset)
res.raise_for_status()
return (
[Notification.model_validate(notification) for notification in res.json()['notifications']],
Pagination(page=(offset // limit) + 1, limit=limit, hasMore=res.json()['hasMore'], nextCursor=None)
)
@refresh_on_error @refresh_on_error
def mark_as_read(self, id: str): def mark_as_read(self, id: UUID) -> bool:
return mark_as_read(self.token, id) """Прочитать уведомление
Args:
id (UUID): UUID уведомления
Returns:
bool: Успешно (False - уже прочитано)
"""
res = mark_as_read(self.token, id)
res.raise_for_status()
return res.json()['success']
@refresh_on_error @refresh_on_error
def mark_all_as_read(self): def mark_all_as_read(self) -> None:
return mark_all_as_read(self.token) """Прочитать все уведомления"""
res = mark_all_as_read(self.token)
@refresh_on_error res.raise_for_status()
def get_unread_notifications_count(self):
return get_unread_notifications_count(self.token)
@refresh_on_error @refresh_on_error
def create_post(self, content: str, wall_recipient_id: int | None = None, attach_ids: list[str] = []): def get_unread_notifications_count(self) -> int:
return create_post(self.token, content, wall_recipient_id, attach_ids) """Получить количество непрочитанных уведомлений
Returns:
int: Количество
"""
res = get_unread_notifications_count(self.token)
res.raise_for_status()
return res.json()['count']
@refresh_on_error
def create_post(self, content: str, wall_recipient_id: UUID | None = None, attach_ids: list[UUID] = []) -> NewPost:
res = create_post(self.token, content, wall_recipient_id, attach_ids)
if res.json().get('error', {}).get('code') == 'NOT_FOUND':
raise NotFound('Wall recipient')
if res.status_code == 422 and 'found' in res.json():
raise ValidationError(*list(res.json()['found'].items())[0])
res.raise_for_status()
return NewPost.model_validate(res.json())
@refresh_on_error @refresh_on_error
def get_posts(self, username: str | None = None, limit: int = 20, cursor: int = 0, sort: str = '', tab: str = ''): def get_posts(self, username: str | None = None, limit: int = 20, cursor: int = 0, sort: str = '', tab: str = ''):
@@ -513,7 +658,7 @@ class Client:
@refresh_on_error @refresh_on_error
def upload_file(self, name: str, data: BufferedReader): def upload_file(self, name: str, data: BufferedReader):
return upload_file(self.token, name, data) return upload_file(self.token, name, data).json()
def update_banner(self, name: str) -> UserProfileUpdate: def update_banner(self, name: str) -> UserProfileUpdate:
id = self.upload_file(name, cast(BufferedReader, open(name, 'rb')))['id'] id = self.upload_file(name, cast(BufferedReader, open(name, 'rb')))['id']

View File

@@ -49,3 +49,13 @@ class RateLimitExceeded(Exception):
self.retry_after = retry_after self.retry_after = retry_after
def __str__(self): def __str__(self):
return f'Rate limit exceeded - too much requests. Retry after {self.retry_after} seconds' return f'Rate limit exceeded - too much requests. Retry after {self.retry_after} seconds'
class Forbidden(Exception):
def __init__(self, action: str):
self.action = action
def __str__(self):
return f'Forbidden to {self.action}'
class UsernameTaken(Exception):
def __str__(self):
return 'Username is already taken'

View File

@@ -3,15 +3,10 @@ from datetime import datetime
from pydantic import BaseModel, Field, field_validator from pydantic import BaseModel, Field, field_validator
from itd.models.user import UserPost
from itd.models.file import Attach
class TextObject(BaseModel): class TextObject(BaseModel):
id: UUID id: UUID
content: str content: str
author: UserPost
attachments: list[Attach] = []
created_at: datetime = Field(alias='createdAt') created_at: datetime = Field(alias='createdAt')

View File

@@ -2,12 +2,16 @@ from pydantic import Field
from itd.models._text import TextObject from itd.models._text import TextObject
from itd.models.user import UserPost from itd.models.user import UserPost
from itd.models.file import Attach
class Comment(TextObject): class Comment(TextObject):
author: UserPost
likes_count: int = Field(0, alias='likesCount') likes_count: int = Field(0, alias='likesCount')
replies_count: int = Field(0, alias='repliesCount') replies_count: int = Field(0, alias='repliesCount')
is_liked: bool = Field(False, alias='isLiked') is_liked: bool = Field(False, alias='isLiked')
attachments: list[Attach] = []
replies: list['Comment'] = [] replies: list['Comment'] = []
reply_to: UserPost | None = None # author of replied comment, if this comment is reply reply_to: UserPost | None = None # author of replied comment, if this comment is reply

View File

@@ -12,15 +12,18 @@ class File(BaseModel):
size: int size: int
class Attach(BaseModel): class PostAttach(BaseModel):
id: UUID id: UUID
type: AttachType = AttachType.IMAGE type: AttachType = AttachType.IMAGE
url: str url: str
thumbnail_url: str | None = Field(None, alias='thumbnailUrl') thumbnail_url: str | None = Field(None, alias='thumbnailUrl')
width: int | None = None
height: int | None = None
class Attach(PostAttach):
filename: str filename: str
mime_type: str = Field(alias='mimeType') mime_type: str = Field(alias='mimeType')
size: int size: int
width: int | None = None
height: int | None = None
duration: int | None = None duration: int | None = None
order: int = 0 order: int = 0

View File

@@ -11,7 +11,7 @@ class Notification(BaseModel):
type: NotificationType type: NotificationType
target_type: NotificationTargetType | None = Field(None, alias='targetType') # none - follows, other - NotificationTragetType.POST target_type: NotificationTargetType | None = Field(None, alias='targetType') # none - follows, other - NotificationTragetType.POST
target_id: int | None = Field(None, alias='targetId') # none - follows target_id: UUID | None = Field(None, alias='targetId') # none - follows
preview: str | None = None # follow - none, comment/reply - content, repost - original post content, like - post content, wall_post - wall post content preview: str | None = None # follow - none, comment/reply - content, repost - original post content, like - post content, wall_post - wall post content

View File

@@ -1,7 +1,10 @@
from uuid import UUID
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
class Pagination(BaseModel): class Pagination(BaseModel):
page: int = 1 page: int | None = 1
limit: int = 20 limit: int = 20
total: int | None = None total: int | None = None
has_more: bool = Field(True, alias='hasMore') has_more: bool = Field(True, alias='hasMore')
next_cursor: UUID | None = Field(None, alias='nextCursor')

View File

@@ -1,29 +1,47 @@
from uuid import UUID
from pydantic import Field from pydantic import Field
from itd.models.user import UserPost from itd.models.user import UserPost, UserNewPost
from itd.models._text import TextObject from itd.models._text import TextObject
from itd.models.file import PostAttach
from itd.models.comment import Comment
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')
reposts_count: int = Field(0, alias='repostsCount') reposts_count: int = Field(0, alias='repostsCount')
views_count: int = Field(0, alias='viewsCount') views_count: int = Field(0, alias='viewsCount')
class PostShort(_PostShort):
author: UserPost
class OriginalPost(PostShort): class OriginalPost(PostShort):
is_deleted: bool = Field(False, alias='isDeleted') is_deleted: bool = Field(False, alias='isDeleted')
class Post(PostShort): class _Post(_PostShort):
is_liked: bool = Field(False, alias='isLiked') is_liked: bool = Field(False, alias='isLiked')
is_reposted: bool = Field(False, alias='isReposted') is_reposted: bool = Field(False, alias='isReposted')
is_viewed: bool = Field(False, alias='isViewed') is_viewed: bool = Field(False, alias='isViewed')
is_owner: bool = Field(False, alias='isOwner') is_owner: bool = Field(False, alias='isOwner')
comments: list = [] attachments: list[PostAttach] = []
comments: list[Comment] = []
original_post: OriginalPost | None = None original_post: OriginalPost | None = None
wall_recipient_id: int | None = None wall_recipient_id: UUID | None = Field(None, alias='wallRecipientId')
wall_recipient: UserPost | None = None wall_recipient: UserPost | None = Field(None, alias='wallRecipient')
class Post(_Post, PostShort):
pass
class NewPost(_Post):
author: UserNewPost

View File

@@ -19,22 +19,23 @@ class UserProfileUpdate(BaseModel):
updated_at: datetime | None = Field(None, alias='updatedAt') updated_at: datetime | None = Field(None, alias='updatedAt')
model_config = {'populate_by_name': True}
class UserNewPost(BaseModel):
class UserNotification(BaseModel):
id: UUID
username: str | None = None username: str | None = None
display_name: str = Field(alias='displayName') display_name: str = Field(alias='displayName')
avatar: str avatar: str
model_config = {'populate_by_name': True}
class UserPost(UserNotification):
verified: bool = False verified: bool = False
class UserNotification(UserNewPost):
id: UUID
class UserPost(UserNotification, UserNewPost):
pass
class UserWhoToFollow(UserPost): class UserWhoToFollow(UserPost):
followers_count: int = Field(0, alias='followersCount') followers_count: int = Field(0, alias='followersCount')

View File

@@ -1,6 +1,7 @@
from _io import BufferedReader from _io import BufferedReader
from requests import Session from requests import Session
from requests.exceptions import JSONDecodeError
from itd.exceptions import InvalidToken, InvalidCookie, RateLimitExceeded from itd.exceptions import InvalidToken, InvalidCookie, RateLimitExceeded
@@ -29,10 +30,13 @@ def fetch(token: str, method: str, url: str, params: dict = {}, files: dict[str,
if method == "get": if method == "get":
res = s.get(base, timeout=120 if files else 20, params=params, headers=headers) res = s.get(base, timeout=120 if files else 20, params=params, headers=headers)
else: else:
res = s.request(method.upper(), base, timeout=20, json=params, headers=headers, files=files) res = s.request(method.upper(), base, timeout=120 if files else 20, json=params, headers=headers, files=files)
if res.json().get('error', {}).get('code') == 'RATE_LIMIT_EXCEEDED': try:
raise RateLimitExceeded(res.json()['error'].get('retryAfter', 0)) if res.json().get('error', {}).get('code') == 'RATE_LIMIT_EXCEEDED':
raise RateLimitExceeded(res.json()['error'].get('retryAfter', 0))
except JSONDecodeError:
pass
print(res.text) print(res.text)
return res return res
@@ -75,9 +79,12 @@ def auth_fetch(cookies: str, method: str, url: str, params: dict = {}, token: st
# print(res.text) # print(res.text)
if res.text == 'UNAUTHORIZED': if res.text == 'UNAUTHORIZED':
raise InvalidToken() raise InvalidToken()
if res.json().get('error', {}).get('code') == 'RATE_LIMIT_EXCEEDED': try:
raise RateLimitExceeded(res.json()['error'].get('retryAfter', 0)) if res.json().get('error', {}).get('code') == 'RATE_LIMIT_EXCEEDED':
if res.json().get('error', {}).get('code') in ('SESSION_NOT_FOUND', 'REFRESH_TOKEN_MISSING'): raise RateLimitExceeded(res.json()['error'].get('retryAfter', 0))
raise InvalidCookie() if res.json().get('error', {}).get('code') in ('SESSION_NOT_FOUND', 'REFRESH_TOKEN_MISSING'):
raise InvalidCookie()
except JSONDecodeError:
pass
return res return res

View File

@@ -1,7 +1,8 @@
from uuid import UUID
from itd.request import fetch from itd.request import fetch
def get_hastags(token: str, limit: int = 10): def get_hastags(token: str, limit: int = 10):
return fetch(token, 'get', 'hashtags/trending', {'limit': limit}) return fetch(token, 'get', 'hashtags/trending', {'limit': limit})
def get_posts_by_hastag(token: str, hashtag: str, limit: int = 20, cursor: int = 0): def get_posts_by_hastag(token: str, hashtag: str, limit: int = 20, cursor: UUID | None = None):
return fetch(token, 'get', f'hashtags/{hashtag}/posts', {'limit': limit, 'cursor': cursor}) return fetch(token, 'get', f'hashtags/{hashtag}/posts', {'limit': limit, 'cursor': cursor})

View File

@@ -1,16 +1,15 @@
from uuid import UUID
from itd.request import fetch from itd.request import fetch
def get_notifications(token: str, limit: int = 20, cursor: int = 0, type: str | None = None): def get_notifications(token: str, limit: int = 20, offset: int = 0):
data = {'limit': str(limit), 'cursor': str(cursor)} return fetch(token, 'get', 'notifications', {'limit': limit, 'offset': offset})
if type:
data['type'] = type
return fetch(token, 'get', 'notifications', data)
def mark_as_read(token: str, id: str): def mark_as_read(token: str, id: UUID):
return fetch(token, 'post', f'notification/{id}/read') return fetch(token, 'post', f'notifications/{id}/read')
def mark_all_as_read(token: str): def mark_all_as_read(token: str):
return fetch(token, 'post', f'notification/read-all') return fetch(token, 'post', f'notifications/read-all')
def get_unread_notifications_count(token: str): def get_unread_notifications_count(token: str):
return fetch(token, 'get', 'notifications/count') return fetch(token, 'get', 'notifications/count')

View File

@@ -1,11 +1,13 @@
from uuid import UUID
from itd.request import fetch from itd.request import fetch
def create_post(token: str, content: str, wall_recipient_id: int | None = None, attach_ids: list[str] = []): def create_post(token: str, content: str, wall_recipient_id: UUID | None = None, attach_ids: list[UUID] = []):
data: dict = {'content': content} data: dict = {'content': content}
if wall_recipient_id: if wall_recipient_id:
data['wallRecipientId'] = wall_recipient_id data['wallRecipientId'] = str(wall_recipient_id)
if attach_ids: if attach_ids:
data['attachmentIds'] = attach_ids data['attachmentIds'] = list(map(str, attach_ids))
return fetch(token, 'post', 'posts', data) return fetch(token, 'post', 'posts', data)
@@ -20,28 +22,29 @@ def get_posts(token: str, username: str | None = None, limit: int = 20, cursor:
return fetch(token, 'get', 'posts', data) return fetch(token, 'get', 'posts', data)
def get_post(token: str, id: str): def get_post(token: str, id: UUID):
return fetch(token, 'get', f'posts/{id}') return fetch(token, 'get', f'posts/{id}')
def edit_post(token: str, id: str, content: str): def edit_post(token: str, id: UUID, content: str):
return fetch(token, 'put', f'posts/{id}', {'content': content}) return fetch(token, 'put', f'posts/{id}', {'content': content})
def delete_post(token: str, id: str): def delete_post(token: str, id: UUID):
return fetch(token, 'delete', f'posts/{id}') return fetch(token, 'delete', f'posts/{id}')
def pin_post(token: str, id: str): def pin_post(token: str, id: UUID):
return fetch(token, 'post', f'posts/{id}/pin') return fetch(token, 'post', f'posts/{id}/pin')
def repost(token: str, id: str, content: str | None = None): def repost(token: str, id: UUID, content: str | None = None):
data = {} data = {}
if content: if content:
data['content'] = content data['content'] = content
return fetch(token, 'post', f'posts/{id}/repost', data) return fetch(token, 'post', f'posts/{id}/repost', data)
def view_post(token: str, id: str): def view_post(token: str, id: UUID):
return fetch(token, 'post', f'posts/{id}/view') return fetch(token, 'post', f'posts/{id}/view')
def get_liked_posts(token: str, username: str, limit: int = 20, cursor: int = 0): def get_liked_posts(token: str, username: str, limit: int = 20, cursor: int = 0):
return fetch(token, 'get', f'posts/user/{username}/liked', {'limit': limit, 'cursor': cursor}) return fetch(token, 'get', f'posts/user/{username}/liked', {'limit': limit, 'cursor': cursor})
# todo post restore # todo post restore
# todo post like