1036 lines
54 KiB
Python
1036 lines
54 KiB
Python
import asyncio
|
||
import time
|
||
import traceback
|
||
from typing import Any, TypedDict
|
||
|
||
from graphql import GraphQLResolveInfo
|
||
from sqlalchemy import and_, asc, func, select, text
|
||
from sqlalchemy.sql import desc as sql_desc
|
||
|
||
from cache.cache import (
|
||
cache_author,
|
||
cached_query,
|
||
get_cached_author,
|
||
get_cached_author_followers,
|
||
get_cached_follower_authors,
|
||
get_cached_follower_topics,
|
||
invalidate_cache_by_prefix,
|
||
)
|
||
from orm.author import Author, AuthorFollower
|
||
from orm.community import Community, CommunityAuthor, CommunityFollower
|
||
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
|
||
from storage.db import local_session
|
||
from storage.redis import redis
|
||
from storage.schema import mutation, query
|
||
from utils.common_result import CommonResult
|
||
from utils.logger import root_logger as logger
|
||
|
||
DEFAULT_COMMUNITIES = [1]
|
||
|
||
|
||
# Определение типа AuthorsBy на основе схемы GraphQL
|
||
class AuthorsBy(TypedDict, total=False):
|
||
"""
|
||
Параметры фильтрации и сортировки авторов для GraphQL запроса load_authors_by.
|
||
|
||
📊 Поля сортировки:
|
||
order: Поле для сортировки авторов:
|
||
🔢 Базовые метрики: "shouts" (публикации), "followers" (подписчики)
|
||
🏷️ Контент: "topics" (темы), "comments" (комментарии)
|
||
👥 Социальные: "coauthors" (соавторы), "replies_count" (ответы на контент)
|
||
⭐ Рейтинг: "rating_shouts" (публикации), "rating_comments" (комментарии)
|
||
👁️ Вовлечённость: "viewed_shouts" (просмотры)
|
||
📝 Алфавит: "name" (по имени)
|
||
|
||
🔍 Поля фильтрации:
|
||
last_seen: Временная метка последнего посещения
|
||
created_at: Временная метка создания
|
||
slug: Уникальный идентификатор автора
|
||
name: Имя автора для поиска
|
||
topic: Тема, связанная с автором
|
||
after: Временная метка для фильтрации "после"
|
||
stat: Поле статистики для дополнительной фильтрации
|
||
"""
|
||
|
||
last_seen: int | None
|
||
created_at: int | None
|
||
slug: str | None
|
||
name: str | None
|
||
topic: str | None
|
||
order: str | None
|
||
after: int | None
|
||
stat: str | None
|
||
|
||
|
||
# Вспомогательная функция для получения всех авторов без статистики
|
||
async def get_all_authors(current_user_id: int | None = None) -> list[Any]:
|
||
"""
|
||
Получает всех авторов без статистики.
|
||
Используется для случаев, когда нужен полный список авторов без дополнительной информации.
|
||
|
||
Args:
|
||
current_user_id: ID текущего пользователя для проверки прав доступа
|
||
is_admin: Флаг, указывающий, является ли пользователь администратором
|
||
|
||
Returns:
|
||
list: Список всех авторов без статистики
|
||
"""
|
||
cache_key = "authors:all:basic"
|
||
|
||
# Функция для получения всех авторов из БД
|
||
async def fetch_all_authors() -> list[Any]:
|
||
"""
|
||
Выполняет запрос к базе данных для получения всех авторов.
|
||
"""
|
||
logger.debug("Получаем список всех авторов из БД и кешируем результат")
|
||
|
||
with local_session() as session:
|
||
# Запрос на получение базовой информации об авторах
|
||
authors_query = select(Author).where(Author.deleted_at.is_(None))
|
||
authors = session.execute(authors_query).scalars().unique().all()
|
||
|
||
# Преобразуем авторов в словари с учетом прав доступа
|
||
return [author.dict() for author in authors]
|
||
|
||
# Используем универсальную функцию для кеширования запросов
|
||
return await cached_query(cache_key, fetch_all_authors)
|
||
|
||
|
||
# Вспомогательная функция для получения авторов со статистикой с пагинацией
|
||
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: Подписчики
|
||
- authors: Количество авторов, на которых подписан
|
||
- rating_shouts: Рейтинг публикаций (реакции)
|
||
- rating_comments: Рейтинг комментариев (реакции)
|
||
- comments: Созданные комментарии
|
||
- replies_count: Ответы на контент (комментарии на посты + ответы на комментарии)
|
||
- viewed_shouts: Просмотры публикаций (из ViewedStorage)
|
||
|
||
⚡ Оптимизации:
|
||
- Batch SQL-запросы для статистики
|
||
- Кеширование результатов
|
||
- Сортировка на уровне SQL для производительности
|
||
|
||
Args:
|
||
limit: Максимальное количество возвращаемых авторов (1-100)
|
||
offset: Смещение для пагинации
|
||
by: Параметры фильтрации и сортировки (AuthorsBy)
|
||
current_user_id: ID текущего пользователя для фильтрации доступа
|
||
|
||
Returns:
|
||
list[dict]: Список авторов с полной статистикой, отсортированных согласно параметрам
|
||
|
||
Raises:
|
||
Exception: При ошибках выполнения SQL-запросов или доступа к ViewedStorage
|
||
"""
|
||
# Формируем ключ кеша с помощью универсальной функции
|
||
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]:
|
||
"""
|
||
Выполняет запрос к базе данных для получения авторов со статистикой.
|
||
"""
|
||
try:
|
||
with local_session() as session:
|
||
# Базовый запрос для получения авторов
|
||
base_query = select(Author).where(Author.deleted_at.is_(None))
|
||
|
||
# vars for statistics sorting
|
||
stats_sort_field = None
|
||
default_sort_applied = False
|
||
|
||
if by:
|
||
if "order" in by:
|
||
order_value = by["order"]
|
||
logger.debug(f"Found order field with value: {order_value}")
|
||
if order_value in [
|
||
"shouts",
|
||
"followers",
|
||
"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
|
||
default_sort_applied = True
|
||
elif order_value == "name":
|
||
# Sorting by name in ascending order
|
||
base_query = base_query.order_by(asc(Author.name))
|
||
logger.debug("Applying alphabetical sorting by name")
|
||
default_sort_applied = True
|
||
else:
|
||
# If order is not a stats field, treat it as a regular field
|
||
column = getattr(Author, order_value or "", "")
|
||
if 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:
|
||
# Regular sorting by fields
|
||
for field, direction in by.items():
|
||
if field is None:
|
||
continue
|
||
column = getattr(Author, field, None)
|
||
if column:
|
||
if isinstance(direction, str) and direction.lower() == "desc":
|
||
base_query = base_query.order_by(sql_desc(column))
|
||
else:
|
||
base_query = base_query.order_by(column)
|
||
logger.debug(f"Applying sorting by field: {field}, direction: {direction}")
|
||
default_sort_applied = True
|
||
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))
|
||
logger.debug("Applying default sorting by created_at (no by parameter)")
|
||
|
||
# If sorting by statistics, modify the query
|
||
if stats_sort_field == "shouts":
|
||
# Sorting by the number of shouts
|
||
logger.debug("Building subquery for shouts sorting")
|
||
subquery = (
|
||
select(ShoutAuthor.author, func.count(func.distinct(Shout.id)).label("shouts_count"))
|
||
.select_from(ShoutAuthor)
|
||
.join(Shout, ShoutAuthor.shout == Shout.id)
|
||
.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.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":
|
||
# Sorting by the number of followers
|
||
logger.debug("Building subquery for followers sorting")
|
||
subquery = (
|
||
select(
|
||
AuthorFollower.following,
|
||
func.count(func.distinct(AuthorFollower.follower)).label("followers_count"),
|
||
)
|
||
.select_from(AuthorFollower)
|
||
.group_by(AuthorFollower.following)
|
||
.subquery()
|
||
)
|
||
|
||
# Сбрасываем предыдущую сортировку и применяем новую
|
||
base_query = base_query.outerjoin(subquery, Author.id == subquery.c.following).order_by(
|
||
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 replies_count sorting: {sql_query}")
|
||
except Exception as e:
|
||
logger.error(f"Error generating SQL query: {e}")
|
||
|
||
# Применяем лимит и смещение
|
||
base_query = base_query.limit(limit).offset(offset)
|
||
|
||
# Получаем авторов
|
||
logger.debug("Executing main query for authors")
|
||
authors = session.execute(base_query).scalars().unique().all()
|
||
author_ids = [author.id for author in authors]
|
||
logger.debug(f"Retrieved {len(authors)} authors with IDs: {author_ids}")
|
||
|
||
if not author_ids:
|
||
logger.debug("No authors found, returning empty list")
|
||
return []
|
||
|
||
# Логирование результатов для отладки сортировки
|
||
if stats_sort_field:
|
||
logger.debug(f"Query returned {len(authors)} authors with sorting by {stats_sort_field}")
|
||
|
||
# 🧪 Оптимизированные запросы для получения всей статистики авторов
|
||
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
|
||
JOIN shout s ON sa.shout = s.id AND s.deleted_at IS NULL AND s.published_at IS NOT NULL
|
||
WHERE sa.author IN ({placeholders})
|
||
GROUP BY sa.author
|
||
"""
|
||
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
|
||
FROM author_follower
|
||
WHERE following IN ({placeholders})
|
||
GROUP BY following
|
||
"""
|
||
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("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 authors statistics query")
|
||
authors_stats_query = f"""
|
||
SELECT follower, COUNT(DISTINCT following) as authors_count
|
||
FROM author_follower
|
||
WHERE follower IN ({placeholders})
|
||
GROUP BY follower
|
||
"""
|
||
authors_stats = {row[0]: row[1] for row in session.execute(text(authors_stats_query), params)}
|
||
logger.debug(f"Authors stats retrieved: {authors_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("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}")
|
||
|
||
# 👁️ Статистика по просмотрам публикаций (используем 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:
|
||
# Получаем словарь с учетом прав доступа
|
||
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),
|
||
"authors": authors_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)
|
||
|
||
# Кешируем каждого автора отдельно для использования в других функциях
|
||
# Важно: кэшируем полный словарь для админов
|
||
logger.debug(f"Caching author {author.id}")
|
||
await cache_author(author.dict())
|
||
except Exception as e:
|
||
logger.error(f"Error processing author {getattr(author, 'id', 'unknown')}: {e}")
|
||
# Продолжаем обработку других авторов
|
||
continue
|
||
|
||
logger.debug(f"Successfully processed {len(result)} authors")
|
||
return result
|
||
|
||
except Exception as e:
|
||
logger.error(f"Error in fetch_authors_with_stats: {e}")
|
||
logger.error(f"Traceback: {traceback.format_exc()}")
|
||
raise
|
||
|
||
# Используем универсальную функцию для кеширования запросов
|
||
cached_result = await cached_query(cache_key, fetch_authors_with_stats)
|
||
logger.debug(f"Cached result: {cached_result}")
|
||
return cached_result
|
||
|
||
|
||
# Функция для инвалидации кеша авторов
|
||
async def invalidate_authors_cache(author_id=None) -> None:
|
||
"""
|
||
Инвалидирует кеши авторов при изменении данных.
|
||
|
||
Args:
|
||
author_id: Опциональный ID автора для точечной инвалидации. Если не указан, инвалидируются все кеши авторов.
|
||
"""
|
||
if author_id:
|
||
# Точечная инвалидация конкретного автора
|
||
logger.debug(f"Инвалидация кеша для автора #{author_id}")
|
||
specific_keys = [
|
||
f"author:id:{author_id}",
|
||
f"author:followers:{author_id}",
|
||
f"author:follows-authors:{author_id}",
|
||
f"author:follows-topics:{author_id}",
|
||
f"author:follows-shouts:{author_id}",
|
||
]
|
||
|
||
# Получаем author_id автора, если есть
|
||
with local_session() as session:
|
||
author = session.query(Author).where(Author.id == author_id).first()
|
||
if author and Author.id:
|
||
specific_keys.append(f"author:id:{Author.id}")
|
||
|
||
# Удаляем конкретные ключи
|
||
for key in specific_keys:
|
||
try:
|
||
await redis.execute("DEL", key)
|
||
logger.debug(f"Удален ключ кеша {key}")
|
||
except Exception as e:
|
||
logger.error(f"Ошибка при удалении ключа {key}: {e}")
|
||
|
||
# Также ищем и удаляем ключи коллекций, содержащих данные об этом авторе
|
||
collection_keys = await redis.execute("KEYS", "authors:stats:*")
|
||
if collection_keys:
|
||
await redis.execute("DEL", *collection_keys)
|
||
logger.debug(f"Удалено {len(collection_keys)} коллекционных ключей авторов")
|
||
else:
|
||
# Общая инвалидация всех кешей авторов
|
||
logger.debug("Полная инвалидация кеша авторов")
|
||
await invalidate_cache_by_prefix("authors")
|
||
|
||
|
||
@mutation.field("update_author")
|
||
@login_required
|
||
async def update_author(_: None, info: GraphQLResolveInfo, profile: dict[str, Any]) -> CommonResult:
|
||
"""Update author profile"""
|
||
author_id = info.context.get("author", {}).get("id")
|
||
is_admin = info.context.get("is_admin", False)
|
||
if not author_id:
|
||
return CommonResult(error="unauthorized", author=None)
|
||
try:
|
||
with local_session() as session:
|
||
author = session.query(Author).where(Author.id == author_id).first()
|
||
if author:
|
||
Author.update(author, profile)
|
||
session.add(author)
|
||
session.commit()
|
||
author_query = select(Author).where(Author.id == author_id)
|
||
result = get_with_stat(author_query)
|
||
if result:
|
||
author_with_stat = result[0]
|
||
if isinstance(author_with_stat, Author):
|
||
# Кэшируем полную версию для админов
|
||
author_dict = author_with_stat.dict(is_admin)
|
||
_t = asyncio.create_task(cache_author(author_dict))
|
||
|
||
# Возвращаем обычную полную версию, т.к. это владелец
|
||
return CommonResult(error=None, author=author)
|
||
# Если мы дошли до сюда, значит автор не найден
|
||
return CommonResult(error="Author not found", author=None)
|
||
except Exception as exc:
|
||
logger.error(traceback.format_exc())
|
||
return CommonResult(error=str(exc), author=None)
|
||
|
||
|
||
@query.field("get_authors_all")
|
||
async def get_authors_all(_: None, info: GraphQLResolveInfo) -> list[Any]:
|
||
"""Get all authors"""
|
||
# Получаем ID текущего пользователя и флаг админа из контекста
|
||
viewer_id = info.context.get("author", {}).get("id")
|
||
info.context.get("is_admin", False)
|
||
return await get_all_authors(viewer_id)
|
||
|
||
|
||
@query.field("get_author")
|
||
async def get_author(
|
||
_: None, info: GraphQLResolveInfo, slug: str | None = None, author_id: int | None = None
|
||
) -> dict[str, Any] | None:
|
||
"""Get specific author by slug or ID"""
|
||
# Получаем ID текущего пользователя и флаг админа из контекста
|
||
is_admin = info.context.get("is_admin", False)
|
||
|
||
author_dict = None
|
||
try:
|
||
author_id = get_author_id_from(slug=slug, user="", author_id=author_id)
|
||
if not author_id:
|
||
msg = "cant find"
|
||
raise ValueError(msg)
|
||
|
||
# Получаем данные автора из кэша (полные данные)
|
||
cached_author = await get_cached_author(int(author_id), get_with_stat)
|
||
|
||
# Применяем фильтрацию на стороне клиента, так как в кэше хранится полная версия
|
||
if cached_author:
|
||
# Создаем объект автора для использования метода dict
|
||
temp_author = Author()
|
||
for key, value in cached_author.items():
|
||
if hasattr(temp_author, key) and key != "username": # username - это свойство, нельзя устанавливать
|
||
setattr(temp_author, key, value)
|
||
# Получаем отфильтрованную версию
|
||
author_dict = temp_author.dict(is_admin)
|
||
# Добавляем статистику, которая могла быть в кэшированной версии
|
||
if "stat" in cached_author:
|
||
author_dict["stat"] = cached_author["stat"]
|
||
|
||
if not author_dict or not author_dict.get("stat"):
|
||
# update stat from db
|
||
author_query = select(Author).where(Author.id == author_id)
|
||
result = get_with_stat(author_query)
|
||
if result:
|
||
author_with_stat = result[0]
|
||
if isinstance(author_with_stat, Author):
|
||
# Кэшируем полные данные для админов
|
||
original_dict = author_with_stat.dict()
|
||
_t = asyncio.create_task(cache_author(original_dict))
|
||
|
||
# Возвращаем отфильтрованную версию
|
||
author_dict = author_with_stat.dict(is_admin)
|
||
# Добавляем статистику
|
||
if hasattr(author_with_stat, "stat"):
|
||
author_dict["stat"] = author_with_stat.stat
|
||
except ValueError:
|
||
pass
|
||
except Exception as exc:
|
||
logger.error(f"{exc}:\n{traceback.format_exc()}")
|
||
return author_dict
|
||
|
||
|
||
@query.field("load_authors_by")
|
||
async def load_authors_by(
|
||
_: None, info: GraphQLResolveInfo, by: AuthorsBy, limit: int = 10, offset: int = 0
|
||
) -> list[Any]:
|
||
"""Load authors by different criteria"""
|
||
try:
|
||
# Получаем ID текущего пользователя и флаг админа из контекста
|
||
viewer_id = info.context.get("author", {}).get("id")
|
||
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)
|
||
except Exception as exc:
|
||
logger.error(f"{exc}:\n{traceback.format_exc()}")
|
||
return []
|
||
|
||
|
||
@query.field("load_authors_search")
|
||
async def load_authors_search(_: None, info: GraphQLResolveInfo, **kwargs: Any) -> list[Any]:
|
||
"""Search for authors by name or bio using Muvera search service"""
|
||
text = kwargs.get("text", "")
|
||
limit = kwargs.get("limit", 10)
|
||
offset = kwargs.get("offset", 0)
|
||
|
||
if not text or len(text.strip()) < 2:
|
||
return []
|
||
|
||
try:
|
||
# Use Muvera search service for authors
|
||
search_results = await search_service.search_authors(text, limit, offset)
|
||
|
||
if not search_results:
|
||
return []
|
||
|
||
# Extract author IDs from search results
|
||
author_ids = [int(result["id"]) for result in search_results if result.get("id", "").isdigit()]
|
||
|
||
if not author_ids:
|
||
return []
|
||
|
||
# Fetch full author data from database
|
||
with local_session() as session:
|
||
authors = session.query(Author).where(Author.id.in_(author_ids)).all()
|
||
|
||
# Sort by search relevance (maintain order from search results)
|
||
author_dict = {author.id: author for author in authors}
|
||
sorted_authors = [author_dict.get(aid) for aid in author_ids if aid in author_dict]
|
||
|
||
return [author.dict() for author in sorted_authors if author]
|
||
|
||
except Exception as e:
|
||
logger.exception(f"Error in author search for '{text}': {e}")
|
||
return []
|
||
|
||
|
||
def get_author_id_from(slug: str | None = None, user: str | None = None, author_id: int | None = None) -> int | None:
|
||
"""Get author ID from different identifiers"""
|
||
try:
|
||
if author_id:
|
||
return author_id
|
||
with local_session() as session:
|
||
author = None
|
||
if slug:
|
||
author = session.query(Author).where(Author.slug == slug).first()
|
||
if author:
|
||
return int(author.id)
|
||
if user:
|
||
author = session.query(Author).where(Author.id == user).first()
|
||
if author:
|
||
return int(author.id)
|
||
except Exception as exc:
|
||
logger.error(exc)
|
||
return None
|
||
|
||
|
||
@query.field("get_author_follows")
|
||
async def get_author_follows(
|
||
_, info: GraphQLResolveInfo, slug: str | None = None, user: str | None = None, author_id: int | None = None
|
||
) -> dict[str, Any]:
|
||
"""Get entities followed by author"""
|
||
# Получаем ID текущего пользователя и флаг админа из контекста
|
||
viewer_id = info.context.get("author", {}).get("id")
|
||
is_admin = info.context.get("is_admin", False)
|
||
|
||
logger.debug(f"getting follows for @{slug}")
|
||
author_id = get_author_id_from(slug=slug, user=user, author_id=author_id)
|
||
if not author_id:
|
||
return {"error": "Author not found"}
|
||
|
||
# Получаем данные из кэша
|
||
followed_authors_raw = await get_cached_follower_authors(author_id)
|
||
followed_topics = await get_cached_follower_topics(author_id)
|
||
|
||
# Фильтруем чувствительные данные авторов
|
||
followed_authors = []
|
||
for author_data in followed_authors_raw:
|
||
# Создаем объект автора для использования метода dict
|
||
temp_author = Author()
|
||
for key, value in author_data.items():
|
||
if hasattr(temp_author, key):
|
||
setattr(temp_author, key, value)
|
||
# Добавляем отфильтрованную версию
|
||
# temp_author - это объект Author, который мы хотим сериализовать
|
||
# current_user_id - ID текущего авторизованного пользователя (может быть None)
|
||
# is_admin - булево значение, является ли текущий пользователь админом
|
||
has_access = is_admin or (viewer_id is not None and str(viewer_id) == str(temp_author.id))
|
||
followed_authors.append(temp_author.dict(has_access))
|
||
|
||
# TODO: Get followed communities too
|
||
return {
|
||
"authors": followed_authors,
|
||
"topics": followed_topics,
|
||
"communities": DEFAULT_COMMUNITIES,
|
||
"shouts": [],
|
||
"error": None,
|
||
}
|
||
|
||
|
||
@query.field("get_author_follows_topics")
|
||
async def get_author_follows_topics(
|
||
_,
|
||
_info: GraphQLResolveInfo,
|
||
slug: str | None = None,
|
||
user: str | None = None,
|
||
author_id: int | None = None,
|
||
) -> list[Any]:
|
||
"""Get topics followed by author"""
|
||
logger.debug(f"getting followed topics for @{slug}")
|
||
author_id = get_author_id_from(slug=slug, user=user, author_id=author_id)
|
||
if not author_id:
|
||
return []
|
||
result = await get_cached_follower_topics(author_id)
|
||
# Ensure we return a list, not a dict
|
||
if isinstance(result, dict):
|
||
return result.get("topics", [])
|
||
return result if isinstance(result, list) else []
|
||
|
||
|
||
@query.field("get_author_follows_authors")
|
||
async def get_author_follows_authors(
|
||
_, info: GraphQLResolveInfo, slug: str | None = None, user: str | None = None, author_id: int | None = None
|
||
) -> list[Any]:
|
||
"""Get authors followed by author"""
|
||
# Получаем ID текущего пользователя и флаг админа из контекста
|
||
viewer_id = info.context.get("author", {}).get("id")
|
||
is_admin = info.context.get("is_admin", False)
|
||
|
||
logger.debug(f"getting followed authors for @{slug}")
|
||
author_id = get_author_id_from(slug=slug, user=user, author_id=author_id)
|
||
if not author_id:
|
||
return []
|
||
|
||
# Получаем данные из кэша
|
||
followed_authors_raw = await get_cached_follower_authors(author_id)
|
||
|
||
# Фильтруем чувствительные данные авторов
|
||
followed_authors = []
|
||
for author_data in followed_authors_raw:
|
||
# Создаем объект автора для использования метода dict
|
||
temp_author = Author()
|
||
for key, value in author_data.items():
|
||
if hasattr(temp_author, key) and key != "username": # username - это свойство, нельзя устанавливать
|
||
setattr(temp_author, key, value)
|
||
# Добавляем отфильтрованную версию
|
||
# temp_author - это объект Author, который мы хотим сериализовать
|
||
# current_user_id - ID текущего авторизованного пользователя (может быть None)
|
||
# is_admin - булево значение, является ли текущий пользователь админом
|
||
has_access = is_admin or (viewer_id is not None and str(viewer_id) == str(temp_author.id))
|
||
followed_authors.append(temp_author.dict(has_access))
|
||
|
||
return followed_authors
|
||
|
||
|
||
def create_author(**kwargs) -> Author:
|
||
"""
|
||
Create new author with default community roles
|
||
|
||
Args:
|
||
**kwargs: Author data including user_id, slug, name, etc.
|
||
|
||
Returns:
|
||
Created Author object
|
||
"""
|
||
author = Author()
|
||
# Use setattr to avoid MyPy complaints about Column assignment
|
||
author.id = kwargs.get("user_id") # type: ignore[assignment] # Связь с user_id из системы авторизации # type: ignore[assignment]
|
||
author.slug = kwargs.get("slug") # type: ignore[assignment] # Идентификатор из системы авторизации # type: ignore[assignment]
|
||
author.created_at = int(time.time()) # type: ignore[assignment]
|
||
author.updated_at = int(time.time()) # type: ignore[assignment]
|
||
author.name = kwargs.get("name") or kwargs.get("slug") # type: ignore[assignment] # если не указано # type: ignore[assignment]
|
||
|
||
with local_session() as session:
|
||
session.add(author)
|
||
session.flush() # Получаем ID автора
|
||
|
||
# Добавляем автора в основное сообщество с дефолтными ролями
|
||
target_community_id = kwargs.get("community_id", 1) # По умолчанию основное сообщество
|
||
|
||
# Получаем сообщество для назначения дефолтных ролей
|
||
community = session.query(Community).where(Community.id == target_community_id).first()
|
||
if community:
|
||
default_roles = community.get_default_roles()
|
||
|
||
# Создаем CommunityAuthor с дефолтными ролями
|
||
community_author = CommunityAuthor(
|
||
community_id=target_community_id, author_id=author.id, roles=",".join(default_roles)
|
||
)
|
||
session.add(community_author)
|
||
logger.info(f"Создана запись CommunityAuthor для автора {author.id} с ролями: {default_roles}")
|
||
|
||
# Добавляем автора в подписчики сообщества
|
||
follower = CommunityFollower(community=target_community_id, follower=int(author.id))
|
||
session.add(follower)
|
||
logger.info(f"Автор {author.id} добавлен в подписчики сообщества {target_community_id}")
|
||
|
||
session.commit()
|
||
logger.info(f"Автор {author.id} успешно создан с ролями в сообществе {target_community_id}")
|
||
return author
|
||
|
||
|
||
@query.field("get_author_followers")
|
||
async def get_author_followers(_: None, info: GraphQLResolveInfo, **kwargs: Any) -> list[Any]:
|
||
"""Get followers of an author"""
|
||
# Получаем ID текущего пользователя и флаг админа из контекста
|
||
viewer_id = info.context.get("author", {}).get("id")
|
||
is_admin = info.context.get("is_admin", False)
|
||
|
||
logger.debug(f"getting followers for author @{kwargs.get('slug')} or ID:{kwargs.get('author_id')}")
|
||
author_id = get_author_id_from(slug=kwargs.get("slug"), user=kwargs.get("user"), author_id=kwargs.get("author_id"))
|
||
if not author_id:
|
||
return []
|
||
|
||
# Получаем данные из кэша
|
||
followers_raw = await get_cached_author_followers(author_id)
|
||
|
||
# Фильтруем чувствительные данные авторов
|
||
followers = []
|
||
for follower_data in followers_raw:
|
||
# Создаем объект автора для использования метода dict
|
||
temp_author = Author()
|
||
for key, value in follower_data.items():
|
||
if hasattr(temp_author, key) and key != "username": # username - это свойство, нельзя устанавливать
|
||
setattr(temp_author, key, value)
|
||
# Добавляем отфильтрованную версию
|
||
# temp_author - это объект Author, который мы хотим сериализовать
|
||
# current_user_id - ID текущего авторизованного пользователя (может быть None)
|
||
# is_admin - булево значение, является ли текущий пользователь админом
|
||
has_access = is_admin or (viewer_id is not None and str(viewer_id) == str(temp_author.id))
|
||
followers.append(temp_author.dict(has_access))
|
||
|
||
return followers
|