[0.9.17] - 2025-08-31
Some checks failed
Deploy on push / deploy (push) Failing after 8s

### 👥 Author Statistics Enhancement
- **📊 Полная статистика авторов**: Добавлены все недостающие счётчики в AuthorStat
  - `topics`: Количество уникальных тем, в которых участвовал автор
  - `coauthors`: Количество соавторов
  - `replies_count`: Количество вызванных комментариев
  - `rating_shouts`: Рейтинг публикаций автора (сумма реакций LIKE/AGREE/ACCEPT/PROOF/CREDIT минус DISLIKE/DISAGREE/REJECT/DISPROOF)
  - `rating_comments`: Рейтинг комментариев автора (реакции на его комментарии)
  - `replies_count`: Количество вызванных комментариев
  - `comments`: Количество созданных комментариев и цитат
  - `viewed_shouts`: Общее количество просмотров всех публикаций автора
- **🔄 Улучшенная сортировка**: Поддержка сортировки по всем новым полям статистики
- ** Оптимизированные запросы**: Batch-запросы для получения всей статистики одним вызовом
- **🧪 Подробное логирование**: Эмодзи-маркеры для каждого типа статистики

### 🔧 Technical Implementation
- **Resolvers**: Обновлён `load_authors_by` для включения всех счётчиков
- **Database**: Оптимизированные SQL-запросы с JOIN для статистики
- **Caching**: Интеграция с ViewedStorage для подсчёта просмотров
- **GraphQL Schema**: Обновлён тип AuthorStat с новыми полями
This commit is contained in:
2025-08-31 20:01:40 +03:00
parent db3dafa569
commit d65f8f9fa7
4 changed files with 686 additions and 29 deletions

View File

@@ -18,7 +18,8 @@ from cache.cache import (
)
from orm.author import Author, AuthorFollower
from orm.community import Community, CommunityAuthor, CommunityFollower
from orm.shout import Shout, ShoutAuthor
from orm.reaction import Reaction
from orm.shout import Shout, ShoutAuthor, ShoutTopic
from resolvers.stat import get_with_stat
from services.auth import login_required
from services.search import search_service
@@ -34,17 +35,25 @@ DEFAULT_COMMUNITIES = [1]
# Определение типа AuthorsBy на основе схемы GraphQL
class AuthorsBy(TypedDict, total=False):
"""
Тип для параметра сортировки авторов, соответствующий схеме GraphQL.
Параметры фильтрации и сортировки авторов для GraphQL запроса load_authors_by.
Поля:
📊 Поля сортировки:
order: Поле для сортировки авторов:
🔢 Базовые метрики: "shouts" (публикации), "followers" (подписчики)
🏷️ Контент: "topics" (темы), "comments" (комментарии)
👥 Социальные: "coauthors" (соавторы), "replies_count" (ответы на контент)
⭐ Рейтинг: "rating" (общий), "rating_shouts" (публикации), "rating_comments" (комментарии)
👁️ Вовлечённость: "viewed_shouts" (просмотры)
📝 Алфавит: "name" (по имени)
🔍 Поля фильтрации:
last_seen: Временная метка последнего посещения
created_at: Временная метка создания
slug: Уникальный идентификатор автора
name: Имя автора
name: Имя автора для поиска
topic: Тема, связанная с автором
order: Поле для сортировки (shouts, followers, rating, comments, name)
after: Временная метка для фильтрации "после"
stat: Поле статистики
stat: Поле статистики для дополнительной фильтрации
"""
last_seen: int | None
@@ -96,15 +105,36 @@ async def get_authors_with_stats(
limit: int = 10, offset: int = 0, by: AuthorsBy | None = None, current_user_id: int | None = None
) -> list[dict[str, Any]]:
"""
Получает авторов со статистикой с пагинацией.
🧪 Получает авторов с полной статистикой и поддержкой сортировки.
📊 Рассчитывает все метрики AuthorStat:
- shouts: Количество опубликованных статей
- topics: Уникальные темы участия
- coauthors: Количество соавторов
- followers: Подписчики
- rating: Общий рейтинг (rating_shouts + rating_comments)
- rating_shouts: Рейтинг публикаций (реакции)
- rating_comments: Рейтинг комментариев (реакции)
- comments: Созданные комментарии
- replies_count: Ответы на контент (комментарии на посты + ответы на комментарии)
- viewed_shouts: Просмотры публикаций (из ViewedStorage)
⚡ Оптимизации:
- Batch SQL-запросы для статистики
- Кеширование результатов
- Сортировка на уровне SQL для производительности
Args:
limit: Максимальное количество возвращаемых авторов
limit: Максимальное количество возвращаемых авторов (1-100)
offset: Смещение для пагинации
by: Опциональный параметр сортировки (AuthorsBy)
current_user_id: ID текущего пользователя
by: Параметры фильтрации и сортировки (AuthorsBy)
current_user_id: ID текущего пользователя для фильтрации доступа
Returns:
list: Список авторов с их статистикой
list[dict]: Список авторов с полной статистикой, отсортированных согласно параметрам
Raises:
Exception: При ошибках выполнения SQL-запросов или доступа к ViewedStorage
"""
# Формируем ключ кеша с помощью универсальной функции
order_value = by.get("order", "default") if by else "default"
@@ -128,7 +158,18 @@ async def get_authors_with_stats(
if "order" in by:
order_value = by["order"]
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",
"topics",
"coauthors",
"viewed_shouts",
"rating_shouts",
"rating_comments",
"replies_count",
]:
stats_sort_field = order_value
logger.debug(f"Applying statistics-based sorting by: {stats_sort_field}")
# Не применяем другую сортировку, так как будем использовать stats_sort_field
@@ -212,12 +253,140 @@ async def get_authors_with_stats(
sql_desc(func.coalesce(subquery.c.followers_count, 0))
)
logger.debug("Applied sorting by followers count")
elif stats_sort_field == "topics":
# 🏷️ Сортировка по количеству тем
logger.debug("Building subquery for topics sorting")
subquery = (
select(ShoutAuthor.author, func.count(func.distinct(ShoutTopic.topic)).label("topics_count"))
.select_from(ShoutAuthor)
.join(Shout, ShoutAuthor.shout == Shout.id)
.join(ShoutTopic, Shout.id == ShoutTopic.shout)
.where(and_(Shout.deleted_at.is_(None), Shout.published_at.is_not(None)))
.group_by(ShoutAuthor.author)
.subquery()
)
base_query = base_query.outerjoin(subquery, Author.id == subquery.c.author).order_by(
sql_desc(func.coalesce(subquery.c.topics_count, 0))
)
logger.debug("Applied sorting by topics count")
elif stats_sort_field == "coauthors":
# ✍️ Сортировка по количеству соавторов
logger.debug("Building subquery for coauthors sorting")
sa1 = ShoutAuthor.__table__.alias("sa1")
sa2 = ShoutAuthor.__table__.alias("sa2")
subquery = (
select(sa1.c.author, func.count(func.distinct(sa2.c.author)).label("coauthors_count"))
.select_from(sa1.join(Shout, sa1.c.shout == Shout.id).join(sa2, sa2.c.shout == Shout.id))
.where(
and_(
Shout.deleted_at.is_(None),
Shout.published_at.is_not(None),
sa1.c.author != sa2.c.author, # исключаем самого автора из подсчёта
)
)
.group_by(sa1.c.author)
.subquery()
)
base_query = base_query.outerjoin(subquery, Author.id == subquery.c.author).order_by(
sql_desc(func.coalesce(subquery.c.coauthors_count, 0))
)
logger.debug("Applied sorting by coauthors count")
elif stats_sort_field == "comments":
# 💬 Сортировка по количеству комментариев
logger.debug("Building subquery for comments sorting")
subquery = (
select(Reaction.created_by, func.count(func.distinct(Reaction.id)).label("comments_count"))
.select_from(Reaction)
.join(Shout, Reaction.shout == Shout.id)
.where(
and_(
Reaction.deleted_at.is_(None),
Shout.deleted_at.is_(None),
Reaction.kind.in_(["COMMENT", "QUOTE"]),
)
)
.group_by(Reaction.created_by)
.subquery()
)
base_query = base_query.outerjoin(subquery, Author.id == subquery.c.created_by).order_by(
sql_desc(func.coalesce(subquery.c.comments_count, 0))
)
logger.debug("Applied sorting by comments count")
elif stats_sort_field == "replies_count":
# 💬 Сортировка по общему количеству ответов (комментарии на посты + ответы на комментарии)
logger.debug("Building subquery for replies_count sorting")
# Подзапрос для ответов на комментарии автора
replies_to_comments_subq = (
select(
Reaction.created_by.label("author_id"),
func.count(func.distinct(Reaction.id)).label("replies_count"),
)
.select_from(Reaction)
.where(
and_(
Reaction.deleted_at.is_(None),
Reaction.reply_to.is_not(None),
Reaction.kind.in_(["COMMENT", "QUOTE"]),
)
)
.group_by(Reaction.created_by)
.subquery()
)
# Подзапрос для комментариев на посты автора
comments_on_posts_subq = (
select(
ShoutAuthor.author.label("author_id"),
func.count(func.distinct(Reaction.id)).label("replies_count"),
)
.select_from(ShoutAuthor)
.join(Shout, ShoutAuthor.shout == Shout.id)
.join(Reaction, Shout.id == Reaction.shout)
.where(
and_(
Shout.deleted_at.is_(None),
Shout.published_at.is_not(None),
Reaction.deleted_at.is_(None),
Reaction.kind.in_(["COMMENT", "QUOTE"]),
)
)
.group_by(ShoutAuthor.author)
.subquery()
)
# Объединяем оба подзапроса через UNION ALL
combined_replies_subq = (
select(
func.coalesce(
replies_to_comments_subq.c.author_id, comments_on_posts_subq.c.author_id
).label("author_id"),
func.coalesce(
func.coalesce(replies_to_comments_subq.c.replies_count, 0)
+ func.coalesce(comments_on_posts_subq.c.replies_count, 0),
0,
).label("total_replies"),
)
.select_from(
replies_to_comments_subq
.outerjoin(
comments_on_posts_subq,
replies_to_comments_subq.c.author_id == comments_on_posts_subq.c.author_id,
)
)
.subquery()
)
base_query = base_query.outerjoin(
combined_replies_subq, Author.id == combined_replies_subq.c.author_id
).order_by(sql_desc(func.coalesce(combined_replies_subq.c.total_replies, 0)))
logger.debug("Applied sorting by replies_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}")
logger.debug(f"Generated SQL query for replies_count sorting: {sql_query}")
except Exception as e:
logger.error(f"Error generating SQL query: {e}")
@@ -238,9 +407,13 @@ async def get_authors_with_stats(
if stats_sort_field:
logger.debug(f"Query returned {len(authors)} authors with sorting by {stats_sort_field}")
# Оптимизированный запрос для получения статистики по публикациям для авторов
logger.debug("Executing shouts statistics query")
# 🧪 Оптимизированные запросы для получения всей статистики авторов
logger.debug("Executing comprehensive statistics queries")
placeholders = ", ".join([f":id{i}" for i in range(len(author_ids))])
params = {f"id{i}": author_id for i, author_id in enumerate(author_ids)}
# 📊 Статистика по публикациям
logger.debug("Executing shouts statistics query")
shouts_stats_query = f"""
SELECT sa.author, COUNT(DISTINCT s.id) as shouts_count
FROM shout_author sa
@@ -248,11 +421,10 @@ async def get_authors_with_stats(
WHERE sa.author IN ({placeholders})
GROUP BY sa.author
"""
params = {f"id{i}": author_id for i, author_id in enumerate(author_ids)}
shouts_stats = {row[0]: row[1] for row in session.execute(text(shouts_stats_query), params)}
logger.debug(f"Shouts stats retrieved: {shouts_stats}")
# Запрос на получение статистики по подписчикам для авторов
# 👥 Статистика по подписчикам
logger.debug("Executing followers statistics query")
followers_stats_query = f"""
SELECT following, COUNT(DISTINCT follower) as followers_count
@@ -263,8 +435,163 @@ async def get_authors_with_stats(
followers_stats = {row[0]: row[1] for row in session.execute(text(followers_stats_query), params)}
logger.debug(f"Followers stats retrieved: {followers_stats}")
# Формируем результат с добавлением статистики
logger.debug("Building final result with statistics")
# 🏷️ Статистика по темам (количество уникальных тем, в которых участвовал автор)
logger.debug("Executing topics statistics query")
topics_stats_query = f"""
SELECT sa.author, COUNT(DISTINCT st.topic) as topics_count
FROM shout_author sa
JOIN shout s ON sa.shout = s.id AND s.deleted_at IS NULL AND s.published_at IS NOT NULL
JOIN shout_topic st ON s.id = st.shout
WHERE sa.author IN ({placeholders})
GROUP BY sa.author
"""
topics_stats = {row[0]: row[1] for row in session.execute(text(topics_stats_query), params)}
logger.debug(f"Topics stats retrieved: {topics_stats}")
# ✍️ Статистика по соавторам (количество уникальных соавторов)
logger.debug("Executing coauthors statistics query")
coauthors_stats_query = f"""
SELECT sa1.author, COUNT(DISTINCT sa2.author) as coauthors_count
FROM shout_author sa1
JOIN shout s ON sa1.shout = s.id
AND s.deleted_at IS NULL
AND s.published_at IS NOT NULL
JOIN shout_author sa2 ON s.id = sa2.shout
AND sa2.author != sa1.author -- исключаем самого автора
WHERE sa1.author IN ({placeholders})
GROUP BY sa1.author
"""
coauthors_stats = {row[0]: row[1] for row in session.execute(text(coauthors_stats_query), params)}
logger.debug(f"Coauthors stats retrieved: {coauthors_stats}")
# 💬 Статистика по комментариям (количество созданных комментариев)
logger.debug("Executing comments statistics query")
comments_stats_query = f"""
SELECT r.created_by, COUNT(DISTINCT r.id) as comments_count
FROM reaction r
JOIN shout s ON r.shout = s.id AND s.deleted_at IS NULL
WHERE r.created_by IN ({placeholders}) AND r.deleted_at IS NULL
AND r.kind IN ('COMMENT', 'QUOTE')
GROUP BY r.created_by
"""
comments_stats = {row[0]: row[1] for row in session.execute(text(comments_stats_query), params)}
logger.debug(f"Comments stats retrieved: {comments_stats}")
# 💬 Статистика по вызванным комментариям (ответы на комментарии + комментарии на посты)
logger.debug("Executing replies_count statistics query")
# Ответы на комментарии автора
replies_to_comments_query = f"""
SELECT r1.created_by as author_id, COUNT(DISTINCT r2.id) as replies_count
FROM reaction r1
JOIN reaction r2 ON r1.id = r2.reply_to AND r2.deleted_at IS NULL
WHERE r1.created_by IN ({placeholders}) AND r1.deleted_at IS NULL
AND r1.kind IN ('COMMENT', 'QUOTE')
AND r2.kind IN ('COMMENT', 'QUOTE')
GROUP BY r1.created_by
"""
replies_to_comments_stats = {
row[0]: row[1] for row in session.execute(text(replies_to_comments_query), params)
}
logger.debug(f"Replies to comments stats retrieved: {replies_to_comments_stats}")
# Комментарии на посты автора
comments_on_posts_query = f"""
SELECT sa.author as author_id, COUNT(DISTINCT r.id) as replies_count
FROM shout_author sa
JOIN shout s ON sa.shout = s.id AND s.deleted_at IS NULL AND s.published_at IS NOT NULL
JOIN reaction r ON s.id = r.shout AND r.deleted_at IS NULL
WHERE sa.author IN ({placeholders})
AND r.kind IN ('COMMENT', 'QUOTE')
GROUP BY sa.author
"""
comments_on_posts_stats = {
row[0]: row[1] for row in session.execute(text(comments_on_posts_query), params)
}
logger.debug(f"Comments on posts stats retrieved: {comments_on_posts_stats}")
# Объединяем статистику
replies_count_stats = {}
for author_id in author_ids:
replies_to_comments = replies_to_comments_stats.get(author_id, 0)
comments_on_posts = comments_on_posts_stats.get(author_id, 0)
replies_count_stats[author_id] = replies_to_comments + comments_on_posts
logger.debug(f"Combined replies count stats: {replies_count_stats}")
# ⭐ Статистика по рейтингу публикаций (сумма реакций на публикации автора)
logger.debug("Executing rating_shouts statistics query")
rating_shouts_stats_query = f"""
SELECT sa.author,
SUM(CASE
WHEN r.kind IN ('LIKE', 'AGREE', 'ACCEPT', 'PROOF', 'CREDIT') THEN 1
WHEN r.kind IN ('DISLIKE', 'DISAGREE', 'REJECT', 'DISPROOF') THEN -1
ELSE 0
END) as rating_shouts
FROM shout_author sa
JOIN shout s ON sa.shout = s.id AND s.deleted_at IS NULL AND s.published_at IS NOT NULL
JOIN reaction r ON s.id = r.shout AND r.deleted_at IS NULL
WHERE sa.author IN ({placeholders})
GROUP BY sa.author
"""
rating_shouts_stats = {
row[0]: row[1] for row in session.execute(text(rating_shouts_stats_query), params)
}
logger.debug(f"Rating shouts stats retrieved: {rating_shouts_stats}")
# ⭐ Статистика по рейтингу комментариев (реакции на комментарии автора)
logger.debug("Executing rating_comments statistics query")
rating_comments_stats_query = f"""
SELECT r1.created_by,
SUM(CASE
WHEN r2.kind IN ('LIKE', 'AGREE', 'ACCEPT', 'PROOF', 'CREDIT') THEN 1
WHEN r2.kind IN ('DISLIKE', 'DISAGREE', 'REJECT', 'DISPROOF') THEN -1
ELSE 0
END) as rating_comments
FROM reaction r1
JOIN reaction r2 ON r1.id = r2.reply_to AND r2.deleted_at IS NULL
WHERE r1.created_by IN ({placeholders}) AND r1.deleted_at IS NULL
AND r1.kind IN ('COMMENT', 'QUOTE')
GROUP BY r1.created_by
"""
rating_comments_stats = {
row[0]: row[1] for row in session.execute(text(rating_comments_stats_query), params)
}
logger.debug(f"Rating comments stats retrieved: {rating_comments_stats}")
# 📈 Общий рейтинг (сумма рейтингов публикаций и комментариев)
logger.debug("Calculating overall rating")
overall_rating_stats = {}
for author_id in author_ids:
shouts_rating = rating_shouts_stats.get(author_id, 0)
comments_rating = rating_comments_stats.get(author_id, 0)
overall_rating_stats[author_id] = shouts_rating + comments_rating
logger.debug(f"Overall rating stats calculated: {overall_rating_stats}")
# 👁️ Статистика по просмотрам публикаций (используем ViewedStorage для получения агрегированных данных)
logger.debug("Calculating viewed_shouts statistics from ViewedStorage")
from services.viewed import ViewedStorage
viewed_shouts_stats = {}
# Получаем общие просмотры для всех публикаций каждого автора
for author_id in author_ids:
total_views = 0
# Получаем все публикации автора и суммируем их просмотры
author_shouts_query = """
SELECT s.slug
FROM shout_author sa
JOIN shout s ON sa.shout = s.id AND s.deleted_at IS NULL AND s.published_at IS NOT NULL
WHERE sa.author = :author_id
"""
shout_rows = session.execute(text(author_shouts_query), {"author_id": author_id})
for shout_row in shout_rows:
shout_slug = shout_row[0]
shout_views = ViewedStorage.get_shout(shout_slug=shout_slug)
total_views += shout_views
viewed_shouts_stats[author_id] = total_views
logger.debug(f"Viewed shouts stats calculated: {viewed_shouts_stats}")
# 🎯 Формируем результат с добавлением полной статистики
logger.debug("Building final result with comprehensive statistics")
result = []
for author in authors:
try:
@@ -272,7 +599,15 @@ async def get_authors_with_stats(
author_dict = author.dict()
author_dict["stat"] = {
"shouts": shouts_stats.get(author.id, 0),
"topics": topics_stats.get(author.id, 0),
"coauthors": coauthors_stats.get(author.id, 0),
"followers": followers_stats.get(author.id, 0),
"rating": overall_rating_stats.get(author.id, 0),
"rating_shouts": rating_shouts_stats.get(author.id, 0),
"rating_comments": rating_comments_stats.get(author.id, 0),
"comments": comments_stats.get(author.id, 0),
"replies_count": replies_count_stats.get(author.id, 0),
"viewed_shouts": viewed_shouts_stats.get(author.id, 0),
}
result.append(author_dict)