Files
core/resolvers/author.py
Untone d65f8f9fa7
Some checks failed
Deploy on push / deploy (push) Failing after 8s
[0.9.17] - 2025-08-31
### 👥 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 с новыми полями
2025-08-31 20:01:40 +03:00

1036 lines
54 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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" (общий), "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: Подписчики
- rating: Общий рейтинг (rating_shouts + rating_comments)
- 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",
"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
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 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:
# Получаем словарь с учетом прав доступа
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)
# Кешируем каждого автора отдельно для использования в других функциях
# Важно: кэшируем полный словарь для админов
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