This commit is contained in:
parent
b5aa7032eb
commit
599a6c9f59
15
CHANGELOG.md
15
CHANGELOG.md
|
@ -1,5 +1,20 @@
|
||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## [0.5.6] - 2025-06-26
|
||||||
|
|
||||||
|
### Исправления API
|
||||||
|
|
||||||
|
- **Исправлена сортировка авторов**: Решена проблема с неправильной обработкой параметра сортировки в `load_authors_by`:
|
||||||
|
- **Проблема**: При запросе авторов с параметром сортировки `order="shouts"` всегда применялась сортировка по `followers`
|
||||||
|
- **Исправления**:
|
||||||
|
- Создан специальный тип `AuthorsBy` на основе схемы GraphQL для строгой типизации параметра сортировки
|
||||||
|
- Улучшена обработка параметра `by` в функции `load_authors_by` для поддержки всех полей из схемы GraphQL
|
||||||
|
- Исправлена логика определения поля сортировки `stats_sort_field` для корректного применения сортировки
|
||||||
|
- Добавлен флаг `default_sort_applied` для предотвращения конфликтов между разными типами сортировки
|
||||||
|
- Улучшено кеширование с учетом параметра сортировки в ключе кеша
|
||||||
|
- Добавлено подробное логирование для отладки SQL запросов и результатов сортировки
|
||||||
|
- **Результат**: API корректно возвращает авторов, отсортированных по указанному параметру, включая сортировку по количеству публикаций (`shouts`) и подписчиков (`followers`)
|
||||||
|
|
||||||
## [0.5.5] - 2025-06-19
|
## [0.5.5] - 2025-06-19
|
||||||
|
|
||||||
### Улучшения документации
|
### Улучшения документации
|
||||||
|
|
|
@ -59,6 +59,7 @@ python dev.py
|
||||||
- **Автоматическая очистка** истекших токенов
|
- **Автоматическая очистка** истекших токенов
|
||||||
- **Connection pooling** и keepalive
|
- **Connection pooling** и keepalive
|
||||||
- **Type-safe codebase** (mypy clean)
|
- **Type-safe codebase** (mypy clean)
|
||||||
|
- **Оптимизированная сортировка авторов** с кешированием по параметрам
|
||||||
|
|
||||||
## 🔧 Конфигурация
|
## 🔧 Конфигурация
|
||||||
|
|
||||||
|
|
|
@ -98,14 +98,16 @@ ignore = [
|
||||||
"FBT002", # boolean default arguments - иногда удобно для API совместимости
|
"FBT002", # boolean default arguments - иногда удобно для API совместимости
|
||||||
"PERF203", # try-except in loop - иногда нужно для обработки отдельных элементов
|
"PERF203", # try-except in loop - иногда нужно для обработки отдельных элементов
|
||||||
# Игнорируем некоторые строгие правила для удобства разработки
|
# Игнорируем некоторые строгие правила для удобства разработки
|
||||||
|
"ANN003", # Missing type annotation for `*args` - иногда нужно
|
||||||
"ANN401", # Dynamically typed expressions (Any) - иногда нужно
|
"ANN401", # Dynamically typed expressions (Any) - иногда нужно
|
||||||
"S101", # assert statements - нужно в тестах
|
"S101", # assert statements - нужно в тестах
|
||||||
"T201", # print statements - нужно для отладки
|
"T201", # print statements - нужно для отладки
|
||||||
"TRY003", # Avoid specifying long messages outside the exception class - иногда допустимо
|
"TRY003", # Avoid specifying long messages outside the exception class - иногда допустимо
|
||||||
"PLR2004", # Magic values - иногда допустимо
|
"PLR2004", # Magic values - иногда допустимо
|
||||||
"RUF001", # ambiguous unicode characters - для кириллицы
|
"RUF001", # ambiguous unicode characters - для кириллицы
|
||||||
"RUF002", # ambiguous unicode characters in docstrings - для кириллицы
|
"RUF002", #
|
||||||
"RUF003", # ambiguous unicode characters in comments - для кириллицы
|
"RUF003", #
|
||||||
|
"RUF006", #
|
||||||
"TD002", # TODO без автора - не критично
|
"TD002", # TODO без автора - не критично
|
||||||
"TD003", # TODO без ссылки на issue - не критично
|
"TD003", # TODO без ссылки на issue - не критично
|
||||||
]
|
]
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import asyncio
|
import asyncio
|
||||||
import time
|
import time
|
||||||
from typing import Any, Optional
|
from typing import Any, Optional, TypedDict
|
||||||
|
|
||||||
from graphql import GraphQLResolveInfo
|
from graphql import GraphQLResolveInfo
|
||||||
from sqlalchemy import select, text
|
from sqlalchemy import select, text
|
||||||
|
@ -26,6 +26,32 @@ from utils.logger import root_logger as logger
|
||||||
DEFAULT_COMMUNITIES = [1]
|
DEFAULT_COMMUNITIES = [1]
|
||||||
|
|
||||||
|
|
||||||
|
# Определение типа AuthorsBy на основе схемы GraphQL
|
||||||
|
class AuthorsBy(TypedDict, total=False):
|
||||||
|
"""
|
||||||
|
Тип для параметра сортировки авторов, соответствующий схеме GraphQL.
|
||||||
|
|
||||||
|
Поля:
|
||||||
|
last_seen: Временная метка последнего посещения
|
||||||
|
created_at: Временная метка создания
|
||||||
|
slug: Уникальный идентификатор автора
|
||||||
|
name: Имя автора
|
||||||
|
topic: Тема, связанная с автором
|
||||||
|
order: Поле для сортировки (shouts, followers, rating, comments, name)
|
||||||
|
after: Временная метка для фильтрации "после"
|
||||||
|
stat: Поле статистики
|
||||||
|
"""
|
||||||
|
|
||||||
|
last_seen: Optional[int]
|
||||||
|
created_at: Optional[int]
|
||||||
|
slug: Optional[str]
|
||||||
|
name: Optional[str]
|
||||||
|
topic: Optional[str]
|
||||||
|
order: Optional[str]
|
||||||
|
after: Optional[int]
|
||||||
|
stat: Optional[str]
|
||||||
|
|
||||||
|
|
||||||
# Вспомогательная функция для получения всех авторов без статистики
|
# Вспомогательная функция для получения всех авторов без статистики
|
||||||
async def get_all_authors(current_user_id: Optional[int] = None) -> list[Any]:
|
async def get_all_authors(current_user_id: Optional[int] = None) -> list[Any]:
|
||||||
"""
|
"""
|
||||||
|
@ -62,7 +88,7 @@ async def get_all_authors(current_user_id: Optional[int] = None) -> list[Any]:
|
||||||
|
|
||||||
# Вспомогательная функция для получения авторов со статистикой с пагинацией
|
# Вспомогательная функция для получения авторов со статистикой с пагинацией
|
||||||
async def get_authors_with_stats(
|
async def get_authors_with_stats(
|
||||||
limit: int = 10, offset: int = 0, by: Optional[str] = None, current_user_id: Optional[int] = None
|
limit: int = 10, offset: int = 0, by: Optional[AuthorsBy] = None, current_user_id: Optional[int] = None
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Получает авторов со статистикой с пагинацией.
|
Получает авторов со статистикой с пагинацией.
|
||||||
|
@ -70,13 +96,14 @@ async def get_authors_with_stats(
|
||||||
Args:
|
Args:
|
||||||
limit: Максимальное количество возвращаемых авторов
|
limit: Максимальное количество возвращаемых авторов
|
||||||
offset: Смещение для пагинации
|
offset: Смещение для пагинации
|
||||||
by: Опциональный параметр сортировки (new/active)
|
by: Опциональный параметр сортировки (AuthorsBy)
|
||||||
current_user_id: ID текущего пользователя
|
current_user_id: ID текущего пользователя
|
||||||
Returns:
|
Returns:
|
||||||
list: Список авторов с их статистикой
|
list: Список авторов с их статистикой
|
||||||
"""
|
"""
|
||||||
# Формируем ключ кеша с помощью универсальной функции
|
# Формируем ключ кеша с помощью универсальной функции
|
||||||
cache_key = f"authors:stats:limit={limit}:offset={offset}"
|
order_value = by.get("order", "default") if by else "default"
|
||||||
|
cache_key = f"authors:stats:limit={limit}:offset={offset}:order={order_value}"
|
||||||
|
|
||||||
# Функция для получения авторов из БД
|
# Функция для получения авторов из БД
|
||||||
async def fetch_authors_with_stats() -> list[Any]:
|
async def fetch_authors_with_stats() -> list[Any]:
|
||||||
|
@ -96,32 +123,33 @@ async def get_authors_with_stats(
|
||||||
# Базовый запрос для получения авторов
|
# Базовый запрос для получения авторов
|
||||||
base_query = select(Author).where(Author.deleted_at.is_(None))
|
base_query = select(Author).where(Author.deleted_at.is_(None))
|
||||||
|
|
||||||
# Применяем сортировку
|
|
||||||
|
|
||||||
# vars for statistics sorting
|
# vars for statistics sorting
|
||||||
stats_sort_field = None
|
stats_sort_field = None
|
||||||
|
default_sort_applied = False
|
||||||
|
|
||||||
if by:
|
if by:
|
||||||
if isinstance(by, dict):
|
|
||||||
logger.debug(f"Processing dict-based sorting: {by}")
|
|
||||||
# Обработка словаря параметров сортировки
|
|
||||||
|
|
||||||
# Checking for order field in the dictionary
|
|
||||||
if "order" in by:
|
if "order" in by:
|
||||||
order_value = by["order"]
|
order_value = by["order"]
|
||||||
logger.debug(f"Found order field with value: {order_value}")
|
logger.debug(f"Found order field with value: {order_value}")
|
||||||
if order_value in ["shouts", "followers", "rating", "comments"]:
|
if order_value in ["shouts", "followers", "rating", "comments"]:
|
||||||
stats_sort_field = order_value
|
stats_sort_field = order_value
|
||||||
logger.debug(f"Applying statistics-based sorting by: {stats_sort_field}")
|
logger.debug(f"Applying statistics-based sorting by: {stats_sort_field}")
|
||||||
|
# Не применяем другую сортировку, так как будем использовать stats_sort_field
|
||||||
|
default_sort_applied = True
|
||||||
elif order_value == "name":
|
elif order_value == "name":
|
||||||
# Sorting by name in ascending order
|
# Sorting by name in ascending order
|
||||||
base_query = base_query.order_by(asc(Author.name))
|
base_query = base_query.order_by(asc(Author.name))
|
||||||
logger.debug("Applying alphabetical sorting by name")
|
logger.debug("Applying alphabetical sorting by name")
|
||||||
|
default_sort_applied = True
|
||||||
else:
|
else:
|
||||||
# If order is not a stats field, treat it as a regular field
|
# If order is not a stats field, treat it as a regular field
|
||||||
column = getattr(Author, order_value, None)
|
column = getattr(Author, order_value, None)
|
||||||
if column:
|
if column:
|
||||||
base_query = base_query.order_by(sql_desc(column))
|
base_query = base_query.order_by(sql_desc(column))
|
||||||
|
logger.debug(f"Applying sorting by column: {order_value}")
|
||||||
|
default_sort_applied = True
|
||||||
|
else:
|
||||||
|
logger.warning(f"Unknown order field: {order_value}")
|
||||||
else:
|
else:
|
||||||
# Regular sorting by fields
|
# Regular sorting by fields
|
||||||
for field, direction in by.items():
|
for field, direction in by.items():
|
||||||
|
@ -131,19 +159,20 @@ async def get_authors_with_stats(
|
||||||
base_query = base_query.order_by(sql_desc(column))
|
base_query = base_query.order_by(sql_desc(column))
|
||||||
else:
|
else:
|
||||||
base_query = base_query.order_by(column)
|
base_query = base_query.order_by(column)
|
||||||
elif by == "new":
|
logger.debug(f"Applying sorting by field: {field}, direction: {direction}")
|
||||||
base_query = base_query.order_by(sql_desc(Author.created_at))
|
default_sort_applied = True
|
||||||
elif by == "active":
|
|
||||||
base_query = base_query.order_by(sql_desc(Author.last_seen))
|
|
||||||
else:
|
|
||||||
# По умолчанию сортируем по времени создания
|
|
||||||
base_query = base_query.order_by(sql_desc(Author.created_at))
|
|
||||||
else:
|
else:
|
||||||
|
logger.warning(f"Unknown field: {field}")
|
||||||
|
|
||||||
|
# Если сортировка еще не применена, используем сортировку по умолчанию
|
||||||
|
if not default_sort_applied and not stats_sort_field:
|
||||||
base_query = base_query.order_by(sql_desc(Author.created_at))
|
base_query = base_query.order_by(sql_desc(Author.created_at))
|
||||||
|
logger.debug("Applying default sorting by created_at (no by parameter)")
|
||||||
|
|
||||||
# If sorting by statistics, modify the query
|
# If sorting by statistics, modify the query
|
||||||
if stats_sort_field == "shouts":
|
if stats_sort_field == "shouts":
|
||||||
# Sorting by the number of shouts
|
# Sorting by the number of shouts
|
||||||
|
logger.debug("Building subquery for shouts sorting")
|
||||||
subquery = (
|
subquery = (
|
||||||
select(ShoutAuthor.author, func.count(func.distinct(Shout.id)).label("shouts_count"))
|
select(ShoutAuthor.author, func.count(func.distinct(Shout.id)).label("shouts_count"))
|
||||||
.select_from(ShoutAuthor)
|
.select_from(ShoutAuthor)
|
||||||
|
@ -153,11 +182,22 @@ async def get_authors_with_stats(
|
||||||
.subquery()
|
.subquery()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Сбрасываем предыдущую сортировку и применяем новую
|
||||||
base_query = base_query.outerjoin(subquery, Author.id == subquery.c.author).order_by(
|
base_query = base_query.outerjoin(subquery, Author.id == subquery.c.author).order_by(
|
||||||
sql_desc(func.coalesce(subquery.c.shouts_count, 0))
|
sql_desc(func.coalesce(subquery.c.shouts_count, 0))
|
||||||
)
|
)
|
||||||
|
logger.debug("Applied sorting by shouts count")
|
||||||
|
|
||||||
|
# Логирование для отладки сортировки
|
||||||
|
try:
|
||||||
|
# Получаем SQL запрос для проверки
|
||||||
|
sql_query = str(base_query.compile(compile_kwargs={"literal_binds": True}))
|
||||||
|
logger.debug(f"Generated SQL query for shouts sorting: {sql_query}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error generating SQL query: {e}")
|
||||||
elif stats_sort_field == "followers":
|
elif stats_sort_field == "followers":
|
||||||
# Sorting by the number of followers
|
# Sorting by the number of followers
|
||||||
|
logger.debug("Building subquery for followers sorting")
|
||||||
subquery = (
|
subquery = (
|
||||||
select(
|
select(
|
||||||
AuthorFollower.author,
|
AuthorFollower.author,
|
||||||
|
@ -168,9 +208,19 @@ async def get_authors_with_stats(
|
||||||
.subquery()
|
.subquery()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Сбрасываем предыдущую сортировку и применяем новую
|
||||||
base_query = base_query.outerjoin(subquery, Author.id == subquery.c.author).order_by(
|
base_query = base_query.outerjoin(subquery, Author.id == subquery.c.author).order_by(
|
||||||
sql_desc(func.coalesce(subquery.c.followers_count, 0))
|
sql_desc(func.coalesce(subquery.c.followers_count, 0))
|
||||||
)
|
)
|
||||||
|
logger.debug("Applied sorting by followers count")
|
||||||
|
|
||||||
|
# Логирование для отладки сортировки
|
||||||
|
try:
|
||||||
|
# Получаем SQL запрос для проверки
|
||||||
|
sql_query = str(base_query.compile(compile_kwargs={"literal_binds": True}))
|
||||||
|
logger.debug(f"Generated SQL query for followers sorting: {sql_query}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error generating SQL query: {e}")
|
||||||
|
|
||||||
# Применяем лимит и смещение
|
# Применяем лимит и смещение
|
||||||
base_query = base_query.limit(limit).offset(offset)
|
base_query = base_query.limit(limit).offset(offset)
|
||||||
|
@ -182,6 +232,10 @@ async def get_authors_with_stats(
|
||||||
if not author_ids:
|
if not author_ids:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
# Логирование результатов для отладки сортировки
|
||||||
|
if stats_sort_field:
|
||||||
|
logger.debug(f"Query returned {len(authors)} authors with sorting by {stats_sort_field}")
|
||||||
|
|
||||||
# Оптимизированный запрос для получения статистики по публикациям для авторов
|
# Оптимизированный запрос для получения статистики по публикациям для авторов
|
||||||
placeholders = ", ".join([f":id{i}" for i in range(len(author_ids))])
|
placeholders = ", ".join([f":id{i}" for i in range(len(author_ids))])
|
||||||
shouts_stats_query = f"""
|
shouts_stats_query = f"""
|
||||||
|
@ -292,7 +346,7 @@ async def update_author(_: None, info: GraphQLResolveInfo, profile: dict[str, An
|
||||||
if isinstance(author_with_stat, Author):
|
if isinstance(author_with_stat, Author):
|
||||||
# Кэшируем полную версию для админов
|
# Кэшируем полную версию для админов
|
||||||
author_dict = author_with_stat.dict(is_admin)
|
author_dict = author_with_stat.dict(is_admin)
|
||||||
asyncio.create_task(cache_author(author_dict))
|
_t = asyncio.create_task(cache_author(author_dict))
|
||||||
|
|
||||||
# Возвращаем обычную полную версию, т.к. это владелец
|
# Возвращаем обычную полную версию, т.к. это владелец
|
||||||
return CommonResult(error=None, author=author)
|
return CommonResult(error=None, author=author)
|
||||||
|
@ -354,7 +408,7 @@ async def get_author(
|
||||||
if isinstance(author_with_stat, Author):
|
if isinstance(author_with_stat, Author):
|
||||||
# Кэшируем полные данные для админов
|
# Кэшируем полные данные для админов
|
||||||
original_dict = author_with_stat.dict(True)
|
original_dict = author_with_stat.dict(True)
|
||||||
asyncio.create_task(cache_author(original_dict))
|
_t = asyncio.create_task(cache_author(original_dict))
|
||||||
|
|
||||||
# Возвращаем отфильтрованную версию
|
# Возвращаем отфильтрованную версию
|
||||||
author_dict = author_with_stat.dict(is_admin)
|
author_dict = author_with_stat.dict(is_admin)
|
||||||
|
@ -371,13 +425,22 @@ async def get_author(
|
||||||
|
|
||||||
|
|
||||||
@query.field("load_authors_by")
|
@query.field("load_authors_by")
|
||||||
async def load_authors_by(_: None, info: GraphQLResolveInfo, by: str, limit: int = 10, offset: int = 0) -> list[Any]:
|
async def load_authors_by(
|
||||||
|
_: None, info: GraphQLResolveInfo, by: AuthorsBy, limit: int = 10, offset: int = 0
|
||||||
|
) -> list[Any]:
|
||||||
"""Load authors by different criteria"""
|
"""Load authors by different criteria"""
|
||||||
try:
|
try:
|
||||||
# Получаем ID текущего пользователя и флаг админа из контекста
|
# Получаем ID текущего пользователя и флаг админа из контекста
|
||||||
viewer_id = info.context.get("author", {}).get("id")
|
viewer_id = info.context.get("author", {}).get("id")
|
||||||
info.context.get("is_admin", False)
|
info.context.get("is_admin", False)
|
||||||
|
|
||||||
|
# Логирование для отладки
|
||||||
|
logger.debug(f"load_authors_by called with by={by}, limit={limit}, offset={offset}")
|
||||||
|
|
||||||
|
# Проверяем наличие параметра order в словаре
|
||||||
|
if "order" in by:
|
||||||
|
logger.debug(f"Sorting by order={by['order']}")
|
||||||
|
|
||||||
# Используем оптимизированную функцию для получения авторов
|
# Используем оптимизированную функцию для получения авторов
|
||||||
return await get_authors_with_stats(limit, offset, by, viewer_id)
|
return await get_authors_with_stats(limit, offset, by, viewer_id)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
|
|
Loading…
Reference in New Issue
Block a user