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_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 orm.topic import Topic 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 id: int | None # Добавляем поле id для фильтрации по ID # Вспомогательная функция для получения всех авторов без статистики 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" # Добавляем фильтры в ключ кэша для правильного кэширования filter_parts = [] if by: if by.get("slug"): filter_parts.append(f"slug={by['slug']}") if by.get("id"): filter_parts.append(f"id={by['id']}") if by.get("stat"): filter_parts.append(f"stat={by['stat']}") if by.get("topic"): filter_parts.append(f"topic={by['topic']}") filter_str = ":".join(filter_parts) if filter_parts else "all" cache_key = f"authors:stats:limit={limit}:offset={offset}:order={order_value}:filter={filter_str}" # Функция для получения авторов из БД async def fetch_authors_with_stats(**kwargs: Any) -> list[Any]: """ Выполняет запрос к базе данных для получения авторов со статистикой. Args: **kwargs: Дополнительные параметры от cached_query (игнорируются) """ try: with local_session() as session: # Базовый запрос для получения авторов base_query = select(Author).where(Author.deleted_at.is_(None)) # Специальная обработка фильтра по теме (topic) if by and by.get("topic"): topic_value = by["topic"] logger.debug(f"🔍 Filtering authors by topic: {topic_value}") # JOIN с таблицами для фильтрации по теме # Авторы, которые публиковали статьи с данной темой base_query = ( base_query.join(ShoutAuthor, Author.id == ShoutAuthor.author) .join(Shout, ShoutAuthor.shout == Shout.id) .join(ShoutTopic, Shout.id == ShoutTopic.shout) .join(Topic, ShoutTopic.topic == Topic.id) .where(Topic.slug == topic_value) .where(Shout.deleted_at.is_(None)) .where(Shout.published_at.is_not(None)) .distinct() # Избегаем дубликатов авторов ) # Указываем что фильтр применен, чтобы избежать сброса сортировки по умолчанию default_sort_applied = True logger.debug(f"✅ Topic filter applied for: {topic_value}") # Применяем фильтрацию по параметрам из by if by: for key, value in by.items(): if key not in ("order", "topic") and value is not None: # order и topic обрабатываются отдельно if hasattr(Author, key): column = getattr(Author, key) base_query = base_query.where(column == value) logger.debug(f"Applied filter: {key} = {value}") else: logger.warning(f"Unknown filter field: {key}") # 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 (исключаем topic, так как он уже обработан выше) for field, direction in by.items(): if field is None or field == "topic": 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, COALESCE(COUNT(DISTINCT sa2.author), 0) 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 LEFT 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, COALESCE(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), 0) 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 LEFT 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, COALESCE(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), 0) as rating_comments FROM reaction r1 LEFT 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 # Временное решение: для фильтра по топику не используем кеш topic_value = None if by is not None and (hasattr(by, "get") or isinstance(by, dict)): topic_value = by.get("topic") if topic_value is not None: logger.debug(f"🚨 Topic filter detected: {topic_value}, bypassing cache") # Вызываем функцию напрямую без кеширования result = await fetch_authors_with_stats() logger.debug(f"Direct result: {len(result)} authors") return result # Для остальных случаев используем кеш cached_result = await cached_query( cache_key, fetch_authors_with_stats, limit=limit, offset=offset, by=by, current_user_id=current_user_id ) 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: logger.debug(f"🔍 get_author called with slug='{slug}', author_id={author_id}") resolved_author_id = get_author_id_from(slug=slug, user="", author_id=author_id) logger.debug(f"🔍 get_author_id_from returned: {resolved_author_id}") if not resolved_author_id: msg = "cant find" raise ValueError(msg) # Всегда используем новую логику статистики из get_authors_with_stats # Это гарантирует консистентность с load_authors_by try: filter_by: AuthorsBy = {} if slug: filter_by["slug"] = slug logger.debug(f"🔍 Using slug filter: {slug}") elif resolved_author_id: filter_by["id"] = resolved_author_id logger.debug(f"🔍 Using id filter: {resolved_author_id}") authors_with_stats = await get_authors_with_stats(limit=1, offset=0, by=filter_by) if authors_with_stats and len(authors_with_stats) > 0: author_dict = authors_with_stats[0] # Кэшируем полные данные _t = asyncio.create_task(cache_author(author_dict)) else: # Fallback к старому методу если автор не найден with local_session() as session: if slug: author = session.query(Author).filter_by(slug=slug).first() else: author = session.query(Author).filter_by(id=resolved_author_id).first() if author: author_dict = author.dict(is_admin) except Exception as e: logger.error(f"Error getting author stats: {e}") # Fallback к старому методу with local_session() as session: if slug: author = session.query(Author).filter_by(slug=slug).first() else: author = session.query(Author).filter_by(id=resolved_author_id).first() if author: author_dict = author.dict(is_admin) 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) # Логирование для отладки print(f"🔍 load_authors_by called with by={by}, limit={limit}, offset={offset}") print(f"🔍 by type: {type(by)}, content: {dict(by) if hasattr(by, 'items') else by}") logger.debug(f"load_authors_by called with by={by}, limit={limit}, offset={offset}") logger.debug(f"by type: {type(by)}, content: {dict(by) if hasattr(by, 'items') else by}") # Проверяем наличие параметра order в словаре if "order" in by: print(f"🔍 Sorting by order={by['order']}") logger.debug(f"Sorting by order={by['order']}") # Проверяем наличие параметра topic if "topic" in by: print(f"🎯 Topic filter found: {by['topic']}") logger.debug(f"🎯 Topic filter found: {by['topic']}") else: print("❌ No topic filter found in by parameters") logger.debug("❌ No topic filter found in by parameters") # Используем оптимизированную функцию для получения авторов result = await get_authors_with_stats(limit, offset, by, viewer_id) logger.debug(f"get_authors_with_stats returned {len(result)} authors") return result 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)) followed_communities = DEFAULT_COMMUNITIES # TODO: get followed communities return { "authors": followed_authors, "topics": followed_topics, "communities": followed_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.update( { "id": kwargs.get("user_id"), # Связь с user_id из системы авторизации "slug": kwargs.get("slug"), # Идентификатор из системы авторизации "created_at": int(time.time()), "updated_at": int(time.time()), "name": kwargs.get("name") or kwargs.get("slug"), # если не указано } ) 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