authors-sort-fix3
All checks were successful
Deploy on push / deploy (push) Successful in 6s

This commit is contained in:
Untone 2025-06-26 17:19:42 +03:00
parent b5aa7032eb
commit 599a6c9f59
4 changed files with 127 additions and 46 deletions

View File

@ -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
### Улучшения документации ### Улучшения документации

View File

@ -59,6 +59,7 @@ python dev.py
- **Автоматическая очистка** истекших токенов - **Автоматическая очистка** истекших токенов
- **Connection pooling** и keepalive - **Connection pooling** и keepalive
- **Type-safe codebase** (mypy clean) - **Type-safe codebase** (mypy clean)
- **Оптимизированная сортировка авторов** с кешированием по параметрам
## 🔧 Конфигурация ## 🔧 Конфигурация

View File

@ -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 - не критично
] ]

View File

@ -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: