2025-07-02 19:30:21 +00:00
|
|
|
|
from math import ceil
|
2025-06-01 23:56:11 +00:00
|
|
|
|
from typing import Any, Optional
|
|
|
|
|
|
|
|
|
|
from graphql import GraphQLResolveInfo
|
|
|
|
|
from sqlalchemy import desc, func, select, text
|
2022-12-01 17:32:09 +00:00
|
|
|
|
|
2025-05-29 09:37:39 +00:00
|
|
|
|
from auth.orm import Author
|
2024-08-12 08:00:01 +00:00
|
|
|
|
from cache.cache import (
|
2025-03-22 06:31:53 +00:00
|
|
|
|
cache_topic,
|
2025-03-22 08:47:19 +00:00
|
|
|
|
cached_query,
|
2024-08-12 08:00:01 +00:00
|
|
|
|
get_cached_topic_authors,
|
|
|
|
|
get_cached_topic_by_slug,
|
|
|
|
|
get_cached_topic_followers,
|
2025-04-15 17:16:01 +00:00
|
|
|
|
invalidate_cache_by_prefix,
|
2025-06-30 18:25:26 +00:00
|
|
|
|
invalidate_topic_followers_cache,
|
2024-08-12 08:00:01 +00:00
|
|
|
|
)
|
2025-06-01 23:56:11 +00:00
|
|
|
|
from orm.reaction import Reaction, ReactionKind
|
|
|
|
|
from orm.shout import Shout, ShoutAuthor, ShoutTopic
|
|
|
|
|
from orm.topic import Topic, TopicFollower
|
2024-04-09 08:30:20 +00:00
|
|
|
|
from resolvers.stat import get_with_stat
|
2023-10-05 18:46:18 +00:00
|
|
|
|
from services.db import local_session
|
2025-07-03 09:15:10 +00:00
|
|
|
|
from services.rbac import require_any_permission, require_permission
|
2025-03-22 06:31:53 +00:00
|
|
|
|
from services.redis import redis
|
2024-04-08 07:38:58 +00:00
|
|
|
|
from services.schema import mutation, query
|
2024-08-12 08:00:01 +00:00
|
|
|
|
from utils.logger import root_logger as logger
|
2024-01-25 19:41:27 +00:00
|
|
|
|
|
|
|
|
|
|
2025-03-22 06:31:53 +00:00
|
|
|
|
# Вспомогательная функция для получения всех тем без статистики
|
2025-06-01 23:56:11 +00:00
|
|
|
|
async def get_all_topics() -> list[Any]:
|
2025-03-22 06:31:53 +00:00
|
|
|
|
"""
|
|
|
|
|
Получает все темы без статистики.
|
|
|
|
|
Используется для случаев, когда нужен полный список тем без дополнительной информации.
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
list: Список всех тем без статистики
|
|
|
|
|
"""
|
2025-03-22 08:47:19 +00:00
|
|
|
|
cache_key = "topics:all:basic"
|
2025-03-22 06:31:53 +00:00
|
|
|
|
|
2025-03-22 08:47:19 +00:00
|
|
|
|
# Функция для получения всех тем из БД
|
2025-06-01 23:56:11 +00:00
|
|
|
|
async def fetch_all_topics() -> list[dict]:
|
2025-03-22 08:47:19 +00:00
|
|
|
|
logger.debug("Получаем список всех тем из БД и кешируем результат")
|
2025-03-22 06:31:53 +00:00
|
|
|
|
|
2025-03-22 08:47:19 +00:00
|
|
|
|
with local_session() as session:
|
|
|
|
|
# Запрос на получение базовой информации о темах
|
|
|
|
|
topics_query = select(Topic)
|
2025-05-30 05:51:24 +00:00
|
|
|
|
topics = session.execute(topics_query).scalars().unique().all()
|
2025-03-22 06:31:53 +00:00
|
|
|
|
|
2025-03-22 08:47:19 +00:00
|
|
|
|
# Преобразуем темы в словари
|
|
|
|
|
return [topic.dict() for topic in topics]
|
2025-03-22 06:31:53 +00:00
|
|
|
|
|
2025-03-22 08:47:19 +00:00
|
|
|
|
# Используем универсальную функцию для кеширования запросов
|
|
|
|
|
return await cached_query(cache_key, fetch_all_topics)
|
2025-03-22 06:31:53 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# Вспомогательная функция для получения тем со статистикой с пагинацией
|
2025-06-01 23:56:11 +00:00
|
|
|
|
async def get_topics_with_stats(
|
|
|
|
|
limit: int = 100, offset: int = 0, community_id: Optional[int] = None, by: Optional[str] = None
|
|
|
|
|
) -> dict[str, Any]:
|
2025-03-22 06:31:53 +00:00
|
|
|
|
"""
|
|
|
|
|
Получает темы со статистикой с пагинацией.
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
limit: Максимальное количество возвращаемых тем
|
|
|
|
|
offset: Смещение для пагинации
|
|
|
|
|
community_id: Опциональный ID сообщества для фильтрации
|
2025-06-01 23:56:11 +00:00
|
|
|
|
by: Опциональный параметр сортировки ('popular', 'authors', 'followers', 'comments')
|
|
|
|
|
- 'popular' - по количеству публикаций (по умолчанию)
|
|
|
|
|
- 'authors' - по количеству авторов
|
|
|
|
|
- 'followers' - по количеству подписчиков
|
|
|
|
|
- 'comments' - по количеству комментариев
|
2025-03-22 06:31:53 +00:00
|
|
|
|
|
|
|
|
|
Returns:
|
2025-07-02 19:30:21 +00:00
|
|
|
|
dict: Объект с пагинированным списком тем и метаданными пагинации
|
2025-03-22 06:31:53 +00:00
|
|
|
|
"""
|
2025-07-02 19:30:21 +00:00
|
|
|
|
# Нормализуем параметры
|
|
|
|
|
limit = max(1, min(100, limit or 10)) # Ограничиваем количество записей от 1 до 100
|
|
|
|
|
offset = max(0, offset or 0) # Смещение не может быть отрицательным
|
|
|
|
|
|
2025-03-22 08:47:19 +00:00
|
|
|
|
# Формируем ключ кеша с помощью универсальной функции
|
2025-06-01 23:56:11 +00:00
|
|
|
|
cache_key = f"topics:stats:limit={limit}:offset={offset}:community_id={community_id}:by={by}"
|
2025-03-22 08:47:19 +00:00
|
|
|
|
|
|
|
|
|
# Функция для получения тем из БД
|
2025-07-02 19:30:21 +00:00
|
|
|
|
async def fetch_topics_with_stats() -> dict[str, Any]:
|
2025-06-01 23:56:11 +00:00
|
|
|
|
logger.debug(f"Выполняем запрос на получение тем со статистикой: limit={limit}, offset={offset}, by={by}")
|
2025-03-22 08:47:19 +00:00
|
|
|
|
|
|
|
|
|
with local_session() as session:
|
2025-07-02 19:30:21 +00:00
|
|
|
|
# Базовый запрос для получения общего количества
|
|
|
|
|
total_query = select(func.count(Topic.id))
|
|
|
|
|
|
2025-03-22 08:47:19 +00:00
|
|
|
|
# Базовый запрос для получения тем
|
|
|
|
|
base_query = select(Topic)
|
|
|
|
|
|
|
|
|
|
# Добавляем фильтр по сообществу, если указан
|
|
|
|
|
if community_id:
|
2025-07-02 19:30:21 +00:00
|
|
|
|
total_query = total_query.where(Topic.community == community_id)
|
2025-03-22 08:47:19 +00:00
|
|
|
|
base_query = base_query.where(Topic.community == community_id)
|
|
|
|
|
|
2025-07-02 19:30:21 +00:00
|
|
|
|
# Получаем общее количество записей
|
|
|
|
|
total_count = session.execute(total_query).scalar()
|
|
|
|
|
|
|
|
|
|
# Вычисляем информацию о пагинации
|
|
|
|
|
per_page = limit
|
|
|
|
|
if total_count is None or per_page in (None, 0):
|
|
|
|
|
total_pages = 1
|
|
|
|
|
else:
|
|
|
|
|
total_pages = ceil(total_count / per_page)
|
|
|
|
|
current_page = (offset // per_page) + 1 if per_page > 0 else 1
|
|
|
|
|
|
2025-03-22 08:47:19 +00:00
|
|
|
|
# Применяем сортировку на основе параметра by
|
|
|
|
|
if by:
|
|
|
|
|
if isinstance(by, dict):
|
|
|
|
|
# Обработка словаря параметров сортировки
|
|
|
|
|
for field, direction in by.items():
|
|
|
|
|
column = getattr(Topic, field, None)
|
|
|
|
|
if column:
|
|
|
|
|
if direction.lower() == "desc":
|
|
|
|
|
base_query = base_query.order_by(desc(column))
|
|
|
|
|
else:
|
|
|
|
|
base_query = base_query.order_by(column)
|
|
|
|
|
elif by == "popular":
|
2025-06-01 23:56:11 +00:00
|
|
|
|
# Сортировка по популярности - по количеству публикаций
|
|
|
|
|
shouts_subquery = (
|
|
|
|
|
select(ShoutTopic.topic, func.count(ShoutTopic.shout).label("shouts_count"))
|
|
|
|
|
.join(Shout, ShoutTopic.shout == Shout.id)
|
|
|
|
|
.where(Shout.deleted_at.is_(None), Shout.published_at.isnot(None))
|
|
|
|
|
.group_by(ShoutTopic.topic)
|
|
|
|
|
.subquery()
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
base_query = base_query.outerjoin(shouts_subquery, Topic.id == shouts_subquery.c.topic).order_by(
|
|
|
|
|
desc(func.coalesce(shouts_subquery.c.shouts_count, 0))
|
|
|
|
|
)
|
|
|
|
|
elif by == "authors":
|
|
|
|
|
# Сортировка по количеству авторов
|
|
|
|
|
authors_subquery = (
|
|
|
|
|
select(ShoutTopic.topic, func.count(func.distinct(ShoutAuthor.author)).label("authors_count"))
|
|
|
|
|
.join(Shout, ShoutTopic.shout == Shout.id)
|
|
|
|
|
.join(ShoutAuthor, ShoutAuthor.shout == Shout.id)
|
|
|
|
|
.where(Shout.deleted_at.is_(None), Shout.published_at.isnot(None))
|
|
|
|
|
.group_by(ShoutTopic.topic)
|
|
|
|
|
.subquery()
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
base_query = base_query.outerjoin(authors_subquery, Topic.id == authors_subquery.c.topic).order_by(
|
|
|
|
|
desc(func.coalesce(authors_subquery.c.authors_count, 0))
|
|
|
|
|
)
|
|
|
|
|
elif by == "followers":
|
|
|
|
|
# Сортировка по количеству подписчиков
|
|
|
|
|
followers_subquery = (
|
|
|
|
|
select(TopicFollower.topic, func.count(TopicFollower.follower).label("followers_count"))
|
|
|
|
|
.group_by(TopicFollower.topic)
|
|
|
|
|
.subquery()
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
base_query = base_query.outerjoin(
|
|
|
|
|
followers_subquery, Topic.id == followers_subquery.c.topic
|
|
|
|
|
).order_by(desc(func.coalesce(followers_subquery.c.followers_count, 0)))
|
|
|
|
|
elif by == "comments":
|
|
|
|
|
# Сортировка по количеству комментариев
|
|
|
|
|
comments_subquery = (
|
|
|
|
|
select(ShoutTopic.topic, func.count(func.distinct(Reaction.id)).label("comments_count"))
|
|
|
|
|
.join(Shout, ShoutTopic.shout == Shout.id)
|
|
|
|
|
.join(Reaction, Reaction.shout == Shout.id)
|
|
|
|
|
.where(
|
|
|
|
|
Shout.deleted_at.is_(None),
|
|
|
|
|
Shout.published_at.isnot(None),
|
|
|
|
|
Reaction.kind == ReactionKind.COMMENT.value,
|
|
|
|
|
Reaction.deleted_at.is_(None),
|
|
|
|
|
)
|
|
|
|
|
.group_by(ShoutTopic.topic)
|
|
|
|
|
.subquery()
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
base_query = base_query.outerjoin(
|
|
|
|
|
comments_subquery, Topic.id == comments_subquery.c.topic
|
|
|
|
|
).order_by(desc(func.coalesce(comments_subquery.c.comments_count, 0)))
|
2025-03-22 08:47:19 +00:00
|
|
|
|
else:
|
2025-06-01 23:56:11 +00:00
|
|
|
|
# Неизвестный параметр сортировки - используем дефолтную (по популярности)
|
|
|
|
|
shouts_subquery = (
|
|
|
|
|
select(ShoutTopic.topic, func.count(ShoutTopic.shout).label("shouts_count"))
|
|
|
|
|
.join(Shout, ShoutTopic.shout == Shout.id)
|
|
|
|
|
.where(Shout.deleted_at.is_(None), Shout.published_at.isnot(None))
|
|
|
|
|
.group_by(ShoutTopic.topic)
|
|
|
|
|
.subquery()
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
base_query = base_query.outerjoin(shouts_subquery, Topic.id == shouts_subquery.c.topic).order_by(
|
|
|
|
|
desc(func.coalesce(shouts_subquery.c.shouts_count, 0))
|
|
|
|
|
)
|
2025-03-22 08:47:19 +00:00
|
|
|
|
else:
|
2025-06-01 23:56:11 +00:00
|
|
|
|
# По умолчанию сортируем по популярности (количество публикаций)
|
|
|
|
|
# Это более логично для списка топиков сообщества
|
|
|
|
|
shouts_subquery = (
|
|
|
|
|
select(ShoutTopic.topic, func.count(ShoutTopic.shout).label("shouts_count"))
|
|
|
|
|
.join(Shout, ShoutTopic.shout == Shout.id)
|
|
|
|
|
.where(Shout.deleted_at.is_(None), Shout.published_at.isnot(None))
|
|
|
|
|
.group_by(ShoutTopic.topic)
|
|
|
|
|
.subquery()
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
base_query = base_query.outerjoin(shouts_subquery, Topic.id == shouts_subquery.c.topic).order_by(
|
|
|
|
|
desc(func.coalesce(shouts_subquery.c.shouts_count, 0))
|
|
|
|
|
)
|
2025-03-22 08:47:19 +00:00
|
|
|
|
|
|
|
|
|
# Применяем лимит и смещение
|
|
|
|
|
base_query = base_query.limit(limit).offset(offset)
|
|
|
|
|
|
|
|
|
|
# Получаем темы
|
2025-05-30 05:51:24 +00:00
|
|
|
|
topics = session.execute(base_query).scalars().unique().all()
|
2025-03-22 08:47:19 +00:00
|
|
|
|
topic_ids = [topic.id for topic in topics]
|
|
|
|
|
|
|
|
|
|
if not topic_ids:
|
2025-07-02 19:30:21 +00:00
|
|
|
|
return {
|
|
|
|
|
"topics": [],
|
|
|
|
|
"total": total_count,
|
|
|
|
|
"page": current_page,
|
|
|
|
|
"perPage": per_page,
|
|
|
|
|
"totalPages": total_pages,
|
|
|
|
|
}
|
2025-03-22 08:47:19 +00:00
|
|
|
|
|
2025-06-01 23:56:11 +00:00
|
|
|
|
# Исправляю S608 - используем параметризированные запросы
|
|
|
|
|
if topic_ids:
|
|
|
|
|
placeholders = ",".join([f":id{i}" for i in range(len(topic_ids))])
|
|
|
|
|
|
|
|
|
|
# Запрос на получение статистики по публикациям для выбранных тем
|
|
|
|
|
shouts_stats_query = f"""
|
|
|
|
|
SELECT st.topic, COUNT(DISTINCT s.id) as shouts_count
|
|
|
|
|
FROM shout_topic st
|
|
|
|
|
JOIN shout s ON st.shout = s.id AND s.deleted_at IS NULL AND s.published_at IS NOT NULL
|
|
|
|
|
WHERE st.topic IN ({placeholders})
|
|
|
|
|
GROUP BY st.topic
|
|
|
|
|
"""
|
|
|
|
|
params = {f"id{i}": topic_id for i, topic_id in enumerate(topic_ids)}
|
|
|
|
|
shouts_stats = {row[0]: row[1] for row in session.execute(text(shouts_stats_query), params)}
|
|
|
|
|
|
|
|
|
|
# Запрос на получение статистики по подписчикам для выбранных тем
|
|
|
|
|
followers_stats_query = f"""
|
|
|
|
|
SELECT topic, COUNT(DISTINCT follower) as followers_count
|
|
|
|
|
FROM topic_followers tf
|
|
|
|
|
WHERE topic IN ({placeholders})
|
|
|
|
|
GROUP BY topic
|
|
|
|
|
"""
|
|
|
|
|
followers_stats = {row[0]: row[1] for row in session.execute(text(followers_stats_query), params)}
|
|
|
|
|
|
|
|
|
|
# Запрос на получение статистики авторов для выбранных тем
|
|
|
|
|
authors_stats_query = f"""
|
|
|
|
|
SELECT st.topic, COUNT(DISTINCT sa.author) as authors_count
|
|
|
|
|
FROM shout_topic st
|
|
|
|
|
JOIN shout s ON st.shout = s.id AND s.deleted_at IS NULL AND s.published_at IS NOT NULL
|
|
|
|
|
JOIN shout_author sa ON sa.shout = s.id
|
|
|
|
|
WHERE st.topic IN ({placeholders})
|
|
|
|
|
GROUP BY st.topic
|
|
|
|
|
"""
|
|
|
|
|
authors_stats = {row[0]: row[1] for row in session.execute(text(authors_stats_query), params)}
|
|
|
|
|
|
|
|
|
|
# Запрос на получение статистики комментариев для выбранных тем
|
|
|
|
|
comments_stats_query = f"""
|
|
|
|
|
SELECT st.topic, COUNT(DISTINCT r.id) as comments_count
|
|
|
|
|
FROM shout_topic st
|
|
|
|
|
JOIN shout s ON st.shout = s.id AND s.deleted_at IS NULL AND s.published_at IS NOT NULL
|
|
|
|
|
JOIN reaction r ON r.shout = s.id AND r.kind = :comment_kind AND r.deleted_at IS NULL
|
|
|
|
|
JOIN author a ON r.created_by = a.id
|
|
|
|
|
WHERE st.topic IN ({placeholders})
|
|
|
|
|
GROUP BY st.topic
|
|
|
|
|
"""
|
|
|
|
|
params["comment_kind"] = ReactionKind.COMMENT.value
|
|
|
|
|
comments_stats = {row[0]: row[1] for row in session.execute(text(comments_stats_query), params)}
|
2025-04-10 16:14:27 +00:00
|
|
|
|
|
2025-03-22 08:47:19 +00:00
|
|
|
|
# Формируем результат с добавлением статистики
|
2025-07-02 19:30:21 +00:00
|
|
|
|
result_topics = []
|
2025-03-22 08:47:19 +00:00
|
|
|
|
for topic in topics:
|
|
|
|
|
topic_dict = topic.dict()
|
|
|
|
|
topic_dict["stat"] = {
|
|
|
|
|
"shouts": shouts_stats.get(topic.id, 0),
|
|
|
|
|
"followers": followers_stats.get(topic.id, 0),
|
2025-04-10 16:14:27 +00:00
|
|
|
|
"authors": authors_stats.get(topic.id, 0),
|
2025-04-15 17:16:01 +00:00
|
|
|
|
"comments": comments_stats.get(topic.id, 0),
|
2025-03-22 08:47:19 +00:00
|
|
|
|
}
|
2025-07-02 19:30:21 +00:00
|
|
|
|
result_topics.append(topic_dict)
|
2025-03-22 08:47:19 +00:00
|
|
|
|
|
|
|
|
|
# Кешируем каждую тему отдельно для использования в других функциях
|
|
|
|
|
await cache_topic(topic_dict)
|
|
|
|
|
|
2025-07-02 19:30:21 +00:00
|
|
|
|
return {
|
|
|
|
|
"topics": result_topics,
|
|
|
|
|
"total": total_count,
|
|
|
|
|
"page": current_page,
|
|
|
|
|
"perPage": per_page,
|
|
|
|
|
"totalPages": total_pages,
|
|
|
|
|
}
|
2025-03-22 08:47:19 +00:00
|
|
|
|
|
|
|
|
|
# Используем универсальную функцию для кеширования запросов
|
|
|
|
|
return await cached_query(cache_key, fetch_topics_with_stats)
|
2025-03-22 06:31:53 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# Функция для инвалидации кеша тем
|
2025-06-01 23:56:11 +00:00
|
|
|
|
async def invalidate_topics_cache(topic_id: Optional[int] = None) -> None:
|
2025-03-22 06:31:53 +00:00
|
|
|
|
"""
|
2025-03-22 08:47:19 +00:00
|
|
|
|
Инвалидирует кеши тем при изменении данных.
|
2025-03-22 06:31:53 +00:00
|
|
|
|
|
2025-03-22 08:47:19 +00:00
|
|
|
|
Args:
|
|
|
|
|
topic_id: Опциональный ID темы для точечной инвалидации.
|
|
|
|
|
Если не указан, инвалидируются все кеши тем.
|
|
|
|
|
"""
|
|
|
|
|
if topic_id:
|
|
|
|
|
# Точечная инвалидация конкретной темы
|
|
|
|
|
logger.debug(f"Инвалидация кеша для темы #{topic_id}")
|
|
|
|
|
specific_keys = [
|
|
|
|
|
f"topic:id:{topic_id}",
|
|
|
|
|
f"topic:authors:{topic_id}",
|
|
|
|
|
f"topic:followers:{topic_id}",
|
|
|
|
|
f"topic_shouts_{topic_id}",
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
# Получаем slug темы, если есть
|
|
|
|
|
with local_session() as session:
|
|
|
|
|
topic = session.query(Topic).filter(Topic.id == topic_id).first()
|
|
|
|
|
if topic and topic.slug:
|
|
|
|
|
specific_keys.append(f"topic:slug:{topic.slug}")
|
|
|
|
|
|
|
|
|
|
# Удаляем конкретные ключи
|
|
|
|
|
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", "topics:stats:*")
|
|
|
|
|
if collection_keys:
|
|
|
|
|
await redis.execute("DEL", *collection_keys)
|
|
|
|
|
logger.debug(f"Удалено {len(collection_keys)} коллекционных ключей тем")
|
|
|
|
|
else:
|
|
|
|
|
# Общая инвалидация всех кешей тем
|
|
|
|
|
logger.debug("Полная инвалидация кеша тем")
|
|
|
|
|
await invalidate_cache_by_prefix("topics")
|
2025-03-22 06:31:53 +00:00
|
|
|
|
|
|
|
|
|
|
2024-08-07 14:45:22 +00:00
|
|
|
|
# Запрос на получение всех тем
|
2024-04-17 15:32:23 +00:00
|
|
|
|
@query.field("get_topics_all")
|
2025-06-01 23:56:11 +00:00
|
|
|
|
async def get_topics_all(_: None, _info: GraphQLResolveInfo) -> list[Any]:
|
2025-03-22 06:31:53 +00:00
|
|
|
|
"""
|
|
|
|
|
Получает список всех тем без статистики.
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
list: Список всех тем
|
|
|
|
|
"""
|
|
|
|
|
return await get_all_topics()
|
2024-03-12 14:26:52 +00:00
|
|
|
|
|
|
|
|
|
|
2024-08-07 14:45:22 +00:00
|
|
|
|
# Запрос на получение тем по сообществу
|
2024-04-17 15:32:23 +00:00
|
|
|
|
@query.field("get_topics_by_community")
|
2025-06-01 23:56:11 +00:00
|
|
|
|
async def get_topics_by_community(
|
|
|
|
|
_: None, _info: GraphQLResolveInfo, community_id: int, limit: int = 100, offset: int = 0, by: Optional[str] = None
|
|
|
|
|
) -> list[Any]:
|
2025-03-22 06:31:53 +00:00
|
|
|
|
"""
|
|
|
|
|
Получает список тем, принадлежащих указанному сообществу с пагинацией и статистикой.
|
2024-03-12 14:26:52 +00:00
|
|
|
|
|
2025-03-22 06:31:53 +00:00
|
|
|
|
Args:
|
|
|
|
|
community_id: ID сообщества
|
|
|
|
|
limit: Максимальное количество возвращаемых тем
|
|
|
|
|
offset: Смещение для пагинации
|
2025-03-22 08:47:19 +00:00
|
|
|
|
by: Опциональные параметры сортировки
|
2024-03-12 14:26:52 +00:00
|
|
|
|
|
2025-03-22 06:31:53 +00:00
|
|
|
|
Returns:
|
|
|
|
|
list: Список тем с их статистикой
|
|
|
|
|
"""
|
2025-06-01 23:56:11 +00:00
|
|
|
|
result = await get_topics_with_stats(limit, offset, community_id, by)
|
|
|
|
|
return result.get("topics", []) if isinstance(result, dict) else result
|
2024-03-12 14:26:52 +00:00
|
|
|
|
|
2022-09-03 10:50:14 +00:00
|
|
|
|
|
2024-08-07 14:45:22 +00:00
|
|
|
|
# Запрос на получение тем по автору
|
2024-04-17 15:32:23 +00:00
|
|
|
|
@query.field("get_topics_by_author")
|
2025-06-01 23:56:11 +00:00
|
|
|
|
async def get_topics_by_author(
|
|
|
|
|
_: None, _info: GraphQLResolveInfo, author_id: int = 0, slug: str = "", user: str = ""
|
|
|
|
|
) -> list[Any]:
|
2024-05-18 11:15:05 +00:00
|
|
|
|
topics_by_author_query = select(Topic)
|
2023-11-28 07:53:48 +00:00
|
|
|
|
if author_id:
|
2024-05-30 04:12:00 +00:00
|
|
|
|
topics_by_author_query = topics_by_author_query.join(Author).where(Author.id == author_id)
|
2023-11-28 07:53:48 +00:00
|
|
|
|
elif slug:
|
2024-05-30 04:12:00 +00:00
|
|
|
|
topics_by_author_query = topics_by_author_query.join(Author).where(Author.slug == slug)
|
2023-11-28 07:53:48 +00:00
|
|
|
|
elif user:
|
2025-05-20 22:34:02 +00:00
|
|
|
|
topics_by_author_query = topics_by_author_query.join(Author).where(Author.id == user)
|
2022-11-28 20:29:02 +00:00
|
|
|
|
|
2024-05-18 11:15:05 +00:00
|
|
|
|
return get_with_stat(topics_by_author_query)
|
2022-09-03 10:50:14 +00:00
|
|
|
|
|
2021-10-28 10:42:34 +00:00
|
|
|
|
|
2024-08-07 14:45:22 +00:00
|
|
|
|
# Запрос на получение одной темы по её slug
|
2024-04-17 15:32:23 +00:00
|
|
|
|
@query.field("get_topic")
|
2025-06-01 23:56:11 +00:00
|
|
|
|
async def get_topic(_: None, _info: GraphQLResolveInfo, slug: str) -> Optional[Any]:
|
2024-06-06 06:23:45 +00:00
|
|
|
|
topic = await get_cached_topic_by_slug(slug, get_with_stat)
|
2024-06-05 14:45:55 +00:00
|
|
|
|
if topic:
|
2024-04-09 16:38:02 +00:00
|
|
|
|
return topic
|
2025-06-01 23:56:11 +00:00
|
|
|
|
return None
|
2022-11-10 06:51:40 +00:00
|
|
|
|
|
|
|
|
|
|
2024-08-07 14:45:22 +00:00
|
|
|
|
# Мутация для создания новой темы
|
2024-04-17 15:32:23 +00:00
|
|
|
|
@mutation.field("create_topic")
|
2025-07-03 09:15:10 +00:00
|
|
|
|
@require_permission("topic:create")
|
2025-06-01 23:56:11 +00:00
|
|
|
|
async def create_topic(_: None, _info: GraphQLResolveInfo, topic_input: dict[str, Any]) -> dict[str, Any]:
|
2022-09-22 10:31:44 +00:00
|
|
|
|
with local_session() as session:
|
2024-08-07 14:45:22 +00:00
|
|
|
|
# TODO: проверить права пользователя на создание темы для конкретного сообщества
|
|
|
|
|
# и разрешение на создание
|
2025-02-11 09:00:35 +00:00
|
|
|
|
new_topic = Topic(**topic_input)
|
2022-09-22 10:31:44 +00:00
|
|
|
|
session.add(new_topic)
|
|
|
|
|
session.commit()
|
2022-11-28 20:29:02 +00:00
|
|
|
|
|
2025-03-22 06:31:53 +00:00
|
|
|
|
# Инвалидируем кеш всех тем
|
|
|
|
|
await invalidate_topics_cache()
|
|
|
|
|
|
2024-04-17 15:32:23 +00:00
|
|
|
|
return {"topic": new_topic}
|
2021-12-12 15:29:51 +00:00
|
|
|
|
|
|
|
|
|
|
2024-08-07 14:45:22 +00:00
|
|
|
|
# Мутация для обновления темы
|
2024-04-17 15:32:23 +00:00
|
|
|
|
@mutation.field("update_topic")
|
2025-07-03 09:15:10 +00:00
|
|
|
|
@require_any_permission(["topic:update_own", "topic:update_any"])
|
2025-06-01 23:56:11 +00:00
|
|
|
|
async def update_topic(_: None, _info: GraphQLResolveInfo, topic_input: dict[str, Any]) -> dict[str, Any]:
|
2025-02-11 09:00:35 +00:00
|
|
|
|
slug = topic_input["slug"]
|
2022-09-18 14:29:21 +00:00
|
|
|
|
with local_session() as session:
|
|
|
|
|
topic = session.query(Topic).filter(Topic.slug == slug).first()
|
|
|
|
|
if not topic:
|
2024-04-17 15:32:23 +00:00
|
|
|
|
return {"error": "topic not found"}
|
2025-06-01 23:56:11 +00:00
|
|
|
|
old_slug = str(getattr(topic, "slug", ""))
|
|
|
|
|
Topic.update(topic, topic_input)
|
|
|
|
|
session.add(topic)
|
|
|
|
|
session.commit()
|
2022-11-28 20:29:02 +00:00
|
|
|
|
|
2025-06-01 23:56:11 +00:00
|
|
|
|
# Инвалидируем кеш только для этой конкретной темы
|
|
|
|
|
await invalidate_topics_cache(int(getattr(topic, "id", 0)))
|
2025-03-22 08:47:19 +00:00
|
|
|
|
|
2025-06-01 23:56:11 +00:00
|
|
|
|
# Если slug изменился, удаляем старый ключ
|
|
|
|
|
if old_slug != str(getattr(topic, "slug", "")):
|
|
|
|
|
await redis.execute("DEL", f"topic:slug:{old_slug}")
|
|
|
|
|
logger.debug(f"Удален ключ кеша для старого slug: {old_slug}")
|
2025-03-22 06:31:53 +00:00
|
|
|
|
|
2025-06-01 23:56:11 +00:00
|
|
|
|
return {"topic": topic}
|
2021-12-12 15:29:51 +00:00
|
|
|
|
|
|
|
|
|
|
2024-08-07 14:45:22 +00:00
|
|
|
|
# Мутация для удаления темы
|
2024-04-17 15:32:23 +00:00
|
|
|
|
@mutation.field("delete_topic")
|
2025-07-03 09:15:10 +00:00
|
|
|
|
@require_any_permission(["topic:delete_own", "topic:delete_any"])
|
2025-06-01 23:56:11 +00:00
|
|
|
|
async def delete_topic(_: None, info: GraphQLResolveInfo, slug: str) -> dict[str, Any]:
|
2025-05-22 01:34:30 +00:00
|
|
|
|
viewer_id = info.context.get("author", {}).get("id")
|
2023-11-28 07:53:48 +00:00
|
|
|
|
with local_session() as session:
|
2025-06-01 23:56:11 +00:00
|
|
|
|
topic = session.query(Topic).filter(Topic.slug == slug).first()
|
|
|
|
|
if not topic:
|
2024-04-17 15:32:23 +00:00
|
|
|
|
return {"error": "invalid topic slug"}
|
2025-05-22 01:34:30 +00:00
|
|
|
|
author = session.query(Author).filter(Author.id == viewer_id).first()
|
2023-11-28 07:53:48 +00:00
|
|
|
|
if author:
|
2025-06-01 23:56:11 +00:00
|
|
|
|
if getattr(topic, "created_by", None) != author.id:
|
2024-04-17 15:32:23 +00:00
|
|
|
|
return {"error": "access denied"}
|
2023-11-28 07:53:48 +00:00
|
|
|
|
|
2025-06-01 23:56:11 +00:00
|
|
|
|
session.delete(topic)
|
2023-11-28 07:53:48 +00:00
|
|
|
|
session.commit()
|
|
|
|
|
|
2025-03-22 06:31:53 +00:00
|
|
|
|
# Инвалидируем кеш всех тем и конкретной темы
|
|
|
|
|
await invalidate_topics_cache()
|
|
|
|
|
await redis.execute("DEL", f"topic:slug:{slug}")
|
2025-06-01 23:56:11 +00:00
|
|
|
|
await redis.execute("DEL", f"topic:id:{getattr(topic, 'id', 0)}")
|
2025-03-22 06:31:53 +00:00
|
|
|
|
|
2023-11-28 07:53:48 +00:00
|
|
|
|
return {}
|
2024-04-17 15:32:23 +00:00
|
|
|
|
return {"error": "access denied"}
|
2023-11-28 07:53:48 +00:00
|
|
|
|
|
|
|
|
|
|
2024-08-07 14:45:22 +00:00
|
|
|
|
# Запрос на получение подписчиков темы
|
2024-05-20 22:40:57 +00:00
|
|
|
|
@query.field("get_topic_followers")
|
2025-06-01 23:56:11 +00:00
|
|
|
|
async def get_topic_followers(_: None, _info: GraphQLResolveInfo, slug: str) -> list[Any]:
|
2024-05-20 22:40:57 +00:00
|
|
|
|
logger.debug(f"getting followers for @{slug}")
|
2024-06-06 06:23:45 +00:00
|
|
|
|
topic = await get_cached_topic_by_slug(slug, get_with_stat)
|
2025-06-01 23:56:11 +00:00
|
|
|
|
topic_id = getattr(topic, "id", None) if isinstance(topic, Topic) else topic.get("id") if topic else None
|
|
|
|
|
return await get_cached_topic_followers(topic_id) if topic_id else []
|
2024-05-20 22:40:57 +00:00
|
|
|
|
|
|
|
|
|
|
2024-08-07 14:45:22 +00:00
|
|
|
|
# Запрос на получение авторов темы
|
2024-05-20 22:40:57 +00:00
|
|
|
|
@query.field("get_topic_authors")
|
2025-06-01 23:56:11 +00:00
|
|
|
|
async def get_topic_authors(_: None, _info: GraphQLResolveInfo, slug: str) -> list[Any]:
|
2024-05-20 22:40:57 +00:00
|
|
|
|
logger.debug(f"getting authors for @{slug}")
|
2024-06-06 06:23:45 +00:00
|
|
|
|
topic = await get_cached_topic_by_slug(slug, get_with_stat)
|
2025-06-01 23:56:11 +00:00
|
|
|
|
topic_id = getattr(topic, "id", None) if isinstance(topic, Topic) else topic.get("id") if topic else None
|
|
|
|
|
return await get_cached_topic_authors(topic_id) if topic_id else []
|
2025-06-30 18:25:26 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# Мутация для удаления темы по ID (для админ-панели)
|
|
|
|
|
@mutation.field("delete_topic_by_id")
|
2025-07-03 09:15:10 +00:00
|
|
|
|
@require_any_permission(["topic:delete_own", "topic:delete_any"])
|
2025-06-30 18:25:26 +00:00
|
|
|
|
async def delete_topic_by_id(_: None, info: GraphQLResolveInfo, topic_id: int) -> dict[str, Any]:
|
|
|
|
|
"""
|
|
|
|
|
Удаляет тему по ID. Используется в админ-панели.
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
topic_id: ID темы для удаления
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
dict: Результат операции
|
|
|
|
|
"""
|
|
|
|
|
viewer_id = info.context.get("author", {}).get("id")
|
|
|
|
|
with local_session() as session:
|
|
|
|
|
topic = session.query(Topic).filter(Topic.id == topic_id).first()
|
|
|
|
|
if not topic:
|
|
|
|
|
return {"success": False, "message": "Топик не найден"}
|
|
|
|
|
|
|
|
|
|
author = session.query(Author).filter(Author.id == viewer_id).first()
|
|
|
|
|
if not author:
|
|
|
|
|
return {"success": False, "message": "Не авторизован"}
|
|
|
|
|
|
|
|
|
|
# TODO: проверить права администратора
|
|
|
|
|
# Для админ-панели допускаем удаление любых топиков администратором
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
# Инвалидируем кеши подписчиков ПЕРЕД удалением данных из БД
|
|
|
|
|
await invalidate_topic_followers_cache(topic_id)
|
|
|
|
|
|
|
|
|
|
# Удаляем связанные данные (подписчики, связи с публикациями)
|
|
|
|
|
session.query(TopicFollower).filter(TopicFollower.topic == topic_id).delete()
|
|
|
|
|
session.query(ShoutTopic).filter(ShoutTopic.topic == topic_id).delete()
|
|
|
|
|
|
|
|
|
|
# Удаляем сам топик
|
|
|
|
|
session.delete(topic)
|
|
|
|
|
session.commit()
|
|
|
|
|
|
|
|
|
|
# Инвалидируем основные кеши топика
|
|
|
|
|
await invalidate_topics_cache(topic_id)
|
|
|
|
|
if topic.slug:
|
|
|
|
|
await redis.execute("DEL", f"topic:slug:{topic.slug}")
|
|
|
|
|
|
|
|
|
|
logger.info(f"Топик {topic_id} успешно удален")
|
|
|
|
|
return {"success": True, "message": "Топик успешно удален"}
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
session.rollback()
|
|
|
|
|
logger.error(f"Ошибка при удалении топика {topic_id}: {e}")
|
|
|
|
|
return {"success": False, "message": f"Ошибка при удалении: {e!s}"}
|
2025-06-30 22:20:48 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# Мутация для слияния тем
|
|
|
|
|
@mutation.field("merge_topics")
|
2025-07-03 09:15:10 +00:00
|
|
|
|
@require_permission("topic:merge")
|
2025-06-30 22:20:48 +00:00
|
|
|
|
async def merge_topics(_: None, info: GraphQLResolveInfo, merge_input: dict[str, Any]) -> dict[str, Any]:
|
|
|
|
|
"""
|
|
|
|
|
Сливает несколько тем в одну с переносом всех связей.
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
merge_input: Данные для слияния:
|
|
|
|
|
- target_topic_id: ID целевой темы (в которую сливаем)
|
|
|
|
|
- source_topic_ids: Список ID исходных тем (которые сливаем)
|
|
|
|
|
- preserve_target_properties: Сохранить свойства целевой темы
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
dict: Результат операции с информацией о слиянии
|
|
|
|
|
|
|
|
|
|
Функциональность:
|
|
|
|
|
- Переносит всех подписчиков из исходных тем в целевую
|
|
|
|
|
- Переносит все публикации из исходных тем в целевую
|
|
|
|
|
- Обновляет связи с черновиками
|
|
|
|
|
- Проверяет принадлежность тем к одному сообществу
|
|
|
|
|
- Удаляет исходные темы после переноса
|
|
|
|
|
- Инвалидирует соответствующие кеши
|
|
|
|
|
"""
|
|
|
|
|
viewer_id = info.context.get("author", {}).get("id")
|
|
|
|
|
target_topic_id = merge_input["target_topic_id"]
|
|
|
|
|
source_topic_ids = merge_input["source_topic_ids"]
|
|
|
|
|
preserve_target = merge_input.get("preserve_target_properties", True)
|
|
|
|
|
|
|
|
|
|
# Проверяем права доступа
|
|
|
|
|
if not viewer_id:
|
|
|
|
|
return {"error": "Не авторизован"}
|
|
|
|
|
|
|
|
|
|
# Проверяем что ID не пересекаются
|
|
|
|
|
if target_topic_id in source_topic_ids:
|
|
|
|
|
return {"error": "Целевая тема не может быть в списке исходных тем"}
|
|
|
|
|
|
|
|
|
|
with local_session() as session:
|
|
|
|
|
try:
|
|
|
|
|
# Получаем целевую тему
|
|
|
|
|
target_topic = session.query(Topic).filter(Topic.id == target_topic_id).first()
|
|
|
|
|
if not target_topic:
|
|
|
|
|
return {"error": f"Целевая тема с ID {target_topic_id} не найдена"}
|
|
|
|
|
|
|
|
|
|
# Получаем исходные темы
|
|
|
|
|
source_topics = session.query(Topic).filter(Topic.id.in_(source_topic_ids)).all()
|
|
|
|
|
if len(source_topics) != len(source_topic_ids):
|
|
|
|
|
found_ids = [t.id for t in source_topics]
|
|
|
|
|
missing_ids = [topic_id for topic_id in source_topic_ids if topic_id not in found_ids]
|
|
|
|
|
return {"error": f"Исходные темы с ID {missing_ids} не найдены"}
|
|
|
|
|
|
|
|
|
|
# Проверяем что все темы принадлежат одному сообществу
|
|
|
|
|
target_community = target_topic.community
|
|
|
|
|
for source_topic in source_topics:
|
|
|
|
|
if source_topic.community != target_community:
|
|
|
|
|
return {"error": f"Тема '{source_topic.title}' принадлежит другому сообществу"}
|
|
|
|
|
|
|
|
|
|
# Получаем автора для проверки прав
|
|
|
|
|
author = session.query(Author).filter(Author.id == viewer_id).first()
|
|
|
|
|
if not author:
|
|
|
|
|
return {"error": "Автор не найден"}
|
|
|
|
|
|
|
|
|
|
# TODO: проверить права администратора или создателя тем
|
|
|
|
|
# Для админ-панели допускаем слияние любых тем администратором
|
|
|
|
|
|
|
|
|
|
# Собираем статистику для отчета
|
|
|
|
|
merge_stats = {"followers_moved": 0, "publications_moved": 0, "drafts_moved": 0, "source_topics_deleted": 0}
|
|
|
|
|
|
|
|
|
|
# Переносим подписчиков из исходных тем в целевую
|
|
|
|
|
for source_topic in source_topics:
|
|
|
|
|
# Получаем подписчиков исходной темы
|
|
|
|
|
source_followers = session.query(TopicFollower).filter(TopicFollower.topic == source_topic.id).all()
|
|
|
|
|
|
|
|
|
|
for follower in source_followers:
|
|
|
|
|
# Проверяем, не подписан ли уже пользователь на целевую тему
|
|
|
|
|
existing = (
|
|
|
|
|
session.query(TopicFollower)
|
|
|
|
|
.filter(TopicFollower.topic == target_topic_id, TopicFollower.follower == follower.follower)
|
|
|
|
|
.first()
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
if not existing:
|
|
|
|
|
# Создаем новую подписку на целевую тему
|
|
|
|
|
new_follower = TopicFollower(
|
|
|
|
|
topic=target_topic_id,
|
|
|
|
|
follower=follower.follower,
|
|
|
|
|
created_at=follower.created_at,
|
|
|
|
|
auto=follower.auto,
|
|
|
|
|
)
|
|
|
|
|
session.add(new_follower)
|
|
|
|
|
merge_stats["followers_moved"] += 1
|
|
|
|
|
|
|
|
|
|
# Удаляем старую подписку
|
|
|
|
|
session.delete(follower)
|
|
|
|
|
|
|
|
|
|
# Переносим публикации из исходных тем в целевую
|
|
|
|
|
from orm.shout import ShoutTopic
|
|
|
|
|
|
|
|
|
|
for source_topic in source_topics:
|
|
|
|
|
# Получаем связи публикаций с исходной темой
|
|
|
|
|
shout_topics = session.query(ShoutTopic).filter(ShoutTopic.topic == source_topic.id).all()
|
|
|
|
|
|
|
|
|
|
for shout_topic in shout_topics:
|
|
|
|
|
# Проверяем, не связана ли уже публикация с целевой темой
|
|
|
|
|
existing = (
|
|
|
|
|
session.query(ShoutTopic)
|
|
|
|
|
.filter(ShoutTopic.topic == target_topic_id, ShoutTopic.shout == shout_topic.shout)
|
|
|
|
|
.first()
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
if not existing:
|
|
|
|
|
# Создаем новую связь с целевой темой
|
|
|
|
|
new_shout_topic = ShoutTopic(
|
|
|
|
|
topic=target_topic_id, shout=shout_topic.shout, main=shout_topic.main
|
|
|
|
|
)
|
|
|
|
|
session.add(new_shout_topic)
|
|
|
|
|
merge_stats["publications_moved"] += 1
|
|
|
|
|
|
|
|
|
|
# Удаляем старую связь
|
|
|
|
|
session.delete(shout_topic)
|
|
|
|
|
|
|
|
|
|
# Переносим черновики из исходных тем в целевую
|
|
|
|
|
from orm.draft import DraftTopic
|
|
|
|
|
|
|
|
|
|
for source_topic in source_topics:
|
|
|
|
|
# Получаем связи черновиков с исходной темой
|
|
|
|
|
draft_topics = session.query(DraftTopic).filter(DraftTopic.topic == source_topic.id).all()
|
|
|
|
|
|
|
|
|
|
for draft_topic in draft_topics:
|
|
|
|
|
# Проверяем, не связан ли уже черновик с целевой темой
|
|
|
|
|
existing = (
|
|
|
|
|
session.query(DraftTopic)
|
|
|
|
|
.filter(DraftTopic.topic == target_topic_id, DraftTopic.shout == draft_topic.shout)
|
|
|
|
|
.first()
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
if not existing:
|
|
|
|
|
# Создаем новую связь с целевой темой
|
|
|
|
|
new_draft_topic = DraftTopic(
|
|
|
|
|
topic=target_topic_id, shout=draft_topic.shout, main=draft_topic.main
|
|
|
|
|
)
|
|
|
|
|
session.add(new_draft_topic)
|
|
|
|
|
merge_stats["drafts_moved"] += 1
|
|
|
|
|
|
|
|
|
|
# Удаляем старую связь
|
|
|
|
|
session.delete(draft_topic)
|
|
|
|
|
|
|
|
|
|
# Объединяем parent_ids если не сохраняем только целевые свойства
|
|
|
|
|
if not preserve_target:
|
|
|
|
|
current_parent_ids: list[int] = list(target_topic.parent_ids or [])
|
|
|
|
|
all_parent_ids = set(current_parent_ids)
|
|
|
|
|
for source_topic in source_topics:
|
|
|
|
|
source_parent_ids: list[int] = list(source_topic.parent_ids or [])
|
|
|
|
|
if source_parent_ids:
|
|
|
|
|
all_parent_ids.update(source_parent_ids)
|
|
|
|
|
# Убираем IDs исходных тем из parent_ids
|
|
|
|
|
all_parent_ids.discard(target_topic_id)
|
|
|
|
|
for source_id in source_topic_ids:
|
|
|
|
|
all_parent_ids.discard(source_id)
|
|
|
|
|
target_topic.parent_ids = list(all_parent_ids) if all_parent_ids else [] # type: ignore[assignment]
|
|
|
|
|
|
|
|
|
|
# Инвалидируем кеши ПЕРЕД удалением тем
|
|
|
|
|
for source_topic in source_topics:
|
|
|
|
|
await invalidate_topic_followers_cache(int(source_topic.id))
|
|
|
|
|
if source_topic.slug:
|
|
|
|
|
await redis.execute("DEL", f"topic:slug:{source_topic.slug}")
|
|
|
|
|
await redis.execute("DEL", f"topic:id:{source_topic.id}")
|
|
|
|
|
|
|
|
|
|
# Удаляем исходные темы
|
|
|
|
|
for source_topic in source_topics:
|
|
|
|
|
session.delete(source_topic)
|
|
|
|
|
merge_stats["source_topics_deleted"] += 1
|
|
|
|
|
logger.info(f"Удалена исходная тема: {source_topic.title} (ID: {source_topic.id})")
|
|
|
|
|
|
|
|
|
|
# Сохраняем изменения
|
|
|
|
|
session.commit()
|
|
|
|
|
|
|
|
|
|
# Инвалидируем кеши целевой темы и общие кеши
|
|
|
|
|
await invalidate_topics_cache(target_topic_id)
|
|
|
|
|
await invalidate_topic_followers_cache(target_topic_id)
|
|
|
|
|
|
|
|
|
|
logger.info(f"Успешно слиты темы {source_topic_ids} в тему {target_topic_id}")
|
|
|
|
|
logger.info(f"Статистика слияния: {merge_stats}")
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
"topic": target_topic,
|
|
|
|
|
"message": f"Успешно слито {len(source_topics)} тем в '{target_topic.title}'",
|
|
|
|
|
"stats": merge_stats,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
session.rollback()
|
|
|
|
|
logger.error(f"Ошибка при слиянии тем: {e}")
|
|
|
|
|
return {"error": f"Ошибка при слиянии тем: {e!s}"}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# Мутация для простого назначения родителя темы
|
|
|
|
|
@mutation.field("set_topic_parent")
|
2025-07-03 09:15:10 +00:00
|
|
|
|
@require_any_permission(["topic:update_own", "topic:update_any"])
|
2025-06-30 22:20:48 +00:00
|
|
|
|
async def set_topic_parent(
|
|
|
|
|
_: None, info: GraphQLResolveInfo, topic_id: int, parent_id: int | None = None
|
|
|
|
|
) -> dict[str, Any]:
|
|
|
|
|
"""
|
|
|
|
|
Простое назначение родительской темы для указанной темы.
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
topic_id: ID темы, которой назначаем родителя
|
|
|
|
|
parent_id: ID родительской темы (None для корневой темы)
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
dict: Результат операции
|
|
|
|
|
|
|
|
|
|
Функциональность:
|
|
|
|
|
- Устанавливает parent_ids для темы
|
|
|
|
|
- Проверяет циклические зависимости
|
|
|
|
|
- Проверяет принадлежность к одному сообществу
|
|
|
|
|
- Инвалидирует кеши
|
|
|
|
|
"""
|
|
|
|
|
viewer_id = info.context.get("author", {}).get("id")
|
|
|
|
|
|
|
|
|
|
# Проверяем права доступа
|
|
|
|
|
if not viewer_id:
|
|
|
|
|
return {"error": "Не авторизован"}
|
|
|
|
|
|
|
|
|
|
with local_session() as session:
|
|
|
|
|
try:
|
|
|
|
|
# Получаем тему
|
|
|
|
|
topic = session.query(Topic).filter(Topic.id == topic_id).first()
|
|
|
|
|
if not topic:
|
|
|
|
|
return {"error": f"Тема с ID {topic_id} не найдена"}
|
|
|
|
|
|
|
|
|
|
# Если устанавливаем корневую тему
|
|
|
|
|
if parent_id is None:
|
|
|
|
|
topic.parent_ids = [] # type: ignore[assignment]
|
|
|
|
|
session.commit()
|
|
|
|
|
|
|
|
|
|
# Инвалидируем кеши
|
|
|
|
|
await invalidate_topics_cache(topic_id)
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
"topic": topic,
|
|
|
|
|
"message": f"Тема '{topic.title}' установлена как корневая",
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
# Получаем родительскую тему
|
|
|
|
|
parent_topic = session.query(Topic).filter(Topic.id == parent_id).first()
|
|
|
|
|
if not parent_topic:
|
|
|
|
|
return {"error": f"Родительская тема с ID {parent_id} не найдена"}
|
|
|
|
|
|
|
|
|
|
# Проверяем принадлежность к одному сообществу
|
|
|
|
|
if topic.community != parent_topic.community:
|
|
|
|
|
return {"error": "Тема и родительская тема должны принадлежать одному сообществу"}
|
|
|
|
|
|
|
|
|
|
# Проверяем циклические зависимости
|
|
|
|
|
def is_descendant(potential_parent: Topic, child_id: int) -> bool:
|
|
|
|
|
"""Проверяет, является ли тема потомком другой темы"""
|
|
|
|
|
if potential_parent.id == child_id:
|
|
|
|
|
return True
|
|
|
|
|
|
2025-07-02 19:30:21 +00:00
|
|
|
|
# Ищем всех потомков parent'а (совместимо с SQLite)
|
|
|
|
|
descendants = session.query(Topic).all()
|
|
|
|
|
# Фильтруем темы, у которых в parent_ids есть potential_parent.id
|
|
|
|
|
descendants = [d for d in descendants if d.parent_ids and potential_parent.id in d.parent_ids]
|
2025-06-30 22:20:48 +00:00
|
|
|
|
|
|
|
|
|
for descendant in descendants:
|
|
|
|
|
if descendant.id == child_id or is_descendant(descendant, child_id):
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
if is_descendant(topic, parent_id):
|
|
|
|
|
return {"error": "Нельзя установить потомка как родителя (циклическая зависимость)"}
|
|
|
|
|
|
|
|
|
|
# Устанавливаем новые parent_ids
|
|
|
|
|
parent_parent_ids: list[int] = list(parent_topic.parent_ids or [])
|
|
|
|
|
new_parent_ids = [*parent_parent_ids, parent_id]
|
|
|
|
|
|
|
|
|
|
topic.parent_ids = new_parent_ids # type: ignore[assignment]
|
|
|
|
|
session.commit()
|
|
|
|
|
|
|
|
|
|
# Инвалидируем кеши
|
|
|
|
|
await invalidate_topics_cache(topic_id)
|
|
|
|
|
await invalidate_topics_cache(parent_id)
|
|
|
|
|
|
|
|
|
|
logger.info(f"Установлен родитель для темы {topic_id}: {parent_id}")
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
"topic": topic,
|
|
|
|
|
"message": f"Тема '{topic.title}' перемещена под '{parent_topic.title}'",
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
session.rollback()
|
|
|
|
|
logger.error(f"Ошибка при назначении родителя темы: {e}")
|
|
|
|
|
return {"error": f"Ошибка при назначении родителя: {e!s}"}
|