core/resolvers/topic.py
Untone 82111ed0f6
All checks were successful
Deploy on push / deploy (push) Successful in 7s
Squashed new RBAC
2025-07-02 22:30:21 +03:00

831 lines
40 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.

from math import ceil
from typing import Any, Optional
from graphql import GraphQLResolveInfo
from sqlalchemy import desc, func, select, text
from auth.orm import Author
from cache.cache import (
cache_topic,
cached_query,
get_cached_topic_authors,
get_cached_topic_by_slug,
get_cached_topic_followers,
invalidate_cache_by_prefix,
invalidate_topic_followers_cache,
)
from orm.reaction import Reaction, ReactionKind
from orm.shout import Shout, ShoutAuthor, ShoutTopic
from orm.topic import Topic, TopicFollower
from resolvers.stat import get_with_stat
from services.auth import login_required
from services.db import local_session
from services.redis import redis
from services.schema import mutation, query
from utils.logger import root_logger as logger
# Вспомогательная функция для получения всех тем без статистики
async def get_all_topics() -> list[Any]:
"""
Получает все темы без статистики.
Используется для случаев, когда нужен полный список тем без дополнительной информации.
Returns:
list: Список всех тем без статистики
"""
cache_key = "topics:all:basic"
# Функция для получения всех тем из БД
async def fetch_all_topics() -> list[dict]:
logger.debug("Получаем список всех тем из БД и кешируем результат")
with local_session() as session:
# Запрос на получение базовой информации о темах
topics_query = select(Topic)
topics = session.execute(topics_query).scalars().unique().all()
# Преобразуем темы в словари
return [topic.dict() for topic in topics]
# Используем универсальную функцию для кеширования запросов
return await cached_query(cache_key, fetch_all_topics)
# Вспомогательная функция для получения тем со статистикой с пагинацией
async def get_topics_with_stats(
limit: int = 100, offset: int = 0, community_id: Optional[int] = None, by: Optional[str] = None
) -> dict[str, Any]:
"""
Получает темы со статистикой с пагинацией.
Args:
limit: Максимальное количество возвращаемых тем
offset: Смещение для пагинации
community_id: Опциональный ID сообщества для фильтрации
by: Опциональный параметр сортировки ('popular', 'authors', 'followers', 'comments')
- 'popular' - по количеству публикаций (по умолчанию)
- 'authors' - по количеству авторов
- 'followers' - по количеству подписчиков
- 'comments' - по количеству комментариев
Returns:
dict: Объект с пагинированным списком тем и метаданными пагинации
"""
# Нормализуем параметры
limit = max(1, min(100, limit or 10)) # Ограничиваем количество записей от 1 до 100
offset = max(0, offset or 0) # Смещение не может быть отрицательным
# Формируем ключ кеша с помощью универсальной функции
cache_key = f"topics:stats:limit={limit}:offset={offset}:community_id={community_id}:by={by}"
# Функция для получения тем из БД
async def fetch_topics_with_stats() -> dict[str, Any]:
logger.debug(f"Выполняем запрос на получение тем со статистикой: limit={limit}, offset={offset}, by={by}")
with local_session() as session:
# Базовый запрос для получения общего количества
total_query = select(func.count(Topic.id))
# Базовый запрос для получения тем
base_query = select(Topic)
# Добавляем фильтр по сообществу, если указан
if community_id:
total_query = total_query.where(Topic.community == community_id)
base_query = base_query.where(Topic.community == community_id)
# Получаем общее количество записей
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
# Применяем сортировку на основе параметра 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":
# Сортировка по популярности - по количеству публикаций
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)))
else:
# Неизвестный параметр сортировки - используем дефолтную (по популярности)
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))
)
else:
# По умолчанию сортируем по популярности (количество публикаций)
# Это более логично для списка топиков сообщества
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))
)
# Применяем лимит и смещение
base_query = base_query.limit(limit).offset(offset)
# Получаем темы
topics = session.execute(base_query).scalars().unique().all()
topic_ids = [topic.id for topic in topics]
if not topic_ids:
return {
"topics": [],
"total": total_count,
"page": current_page,
"perPage": per_page,
"totalPages": total_pages,
}
# Исправляю 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)}
# Формируем результат с добавлением статистики
result_topics = []
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),
"authors": authors_stats.get(topic.id, 0),
"comments": comments_stats.get(topic.id, 0),
}
result_topics.append(topic_dict)
# Кешируем каждую тему отдельно для использования в других функциях
await cache_topic(topic_dict)
return {
"topics": result_topics,
"total": total_count,
"page": current_page,
"perPage": per_page,
"totalPages": total_pages,
}
# Используем универсальную функцию для кеширования запросов
return await cached_query(cache_key, fetch_topics_with_stats)
# Функция для инвалидации кеша тем
async def invalidate_topics_cache(topic_id: Optional[int] = None) -> None:
"""
Инвалидирует кеши тем при изменении данных.
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")
# Запрос на получение всех тем
@query.field("get_topics_all")
async def get_topics_all(_: None, _info: GraphQLResolveInfo) -> list[Any]:
"""
Получает список всех тем без статистики.
Returns:
list: Список всех тем
"""
return await get_all_topics()
# Запрос на получение тем по сообществу
@query.field("get_topics_by_community")
async def get_topics_by_community(
_: None, _info: GraphQLResolveInfo, community_id: int, limit: int = 100, offset: int = 0, by: Optional[str] = None
) -> list[Any]:
"""
Получает список тем, принадлежащих указанному сообществу с пагинацией и статистикой.
Args:
community_id: ID сообщества
limit: Максимальное количество возвращаемых тем
offset: Смещение для пагинации
by: Опциональные параметры сортировки
Returns:
list: Список тем с их статистикой
"""
result = await get_topics_with_stats(limit, offset, community_id, by)
return result.get("topics", []) if isinstance(result, dict) else result
# Запрос на получение тем по автору
@query.field("get_topics_by_author")
async def get_topics_by_author(
_: None, _info: GraphQLResolveInfo, author_id: int = 0, slug: str = "", user: str = ""
) -> list[Any]:
topics_by_author_query = select(Topic)
if author_id:
topics_by_author_query = topics_by_author_query.join(Author).where(Author.id == author_id)
elif slug:
topics_by_author_query = topics_by_author_query.join(Author).where(Author.slug == slug)
elif user:
topics_by_author_query = topics_by_author_query.join(Author).where(Author.id == user)
return get_with_stat(topics_by_author_query)
# Запрос на получение одной темы по её slug
@query.field("get_topic")
async def get_topic(_: None, _info: GraphQLResolveInfo, slug: str) -> Optional[Any]:
topic = await get_cached_topic_by_slug(slug, get_with_stat)
if topic:
return topic
return None
# Мутация для создания новой темы
@mutation.field("create_topic")
@login_required
async def create_topic(_: None, _info: GraphQLResolveInfo, topic_input: dict[str, Any]) -> dict[str, Any]:
with local_session() as session:
# TODO: проверить права пользователя на создание темы для конкретного сообщества
# и разрешение на создание
new_topic = Topic(**topic_input)
session.add(new_topic)
session.commit()
# Инвалидируем кеш всех тем
await invalidate_topics_cache()
return {"topic": new_topic}
# Мутация для обновления темы
@mutation.field("update_topic")
@login_required
async def update_topic(_: None, _info: GraphQLResolveInfo, topic_input: dict[str, Any]) -> dict[str, Any]:
slug = topic_input["slug"]
with local_session() as session:
topic = session.query(Topic).filter(Topic.slug == slug).first()
if not topic:
return {"error": "topic not found"}
old_slug = str(getattr(topic, "slug", ""))
Topic.update(topic, topic_input)
session.add(topic)
session.commit()
# Инвалидируем кеш только для этой конкретной темы
await invalidate_topics_cache(int(getattr(topic, "id", 0)))
# Если slug изменился, удаляем старый ключ
if old_slug != str(getattr(topic, "slug", "")):
await redis.execute("DEL", f"topic:slug:{old_slug}")
logger.debug(f"Удален ключ кеша для старого slug: {old_slug}")
return {"topic": topic}
# Мутация для удаления темы
@mutation.field("delete_topic")
@login_required
async def delete_topic(_: None, info: GraphQLResolveInfo, slug: str) -> dict[str, Any]:
viewer_id = info.context.get("author", {}).get("id")
with local_session() as session:
topic = session.query(Topic).filter(Topic.slug == slug).first()
if not topic:
return {"error": "invalid topic slug"}
author = session.query(Author).filter(Author.id == viewer_id).first()
if author:
if getattr(topic, "created_by", None) != author.id:
return {"error": "access denied"}
session.delete(topic)
session.commit()
# Инвалидируем кеш всех тем и конкретной темы
await invalidate_topics_cache()
await redis.execute("DEL", f"topic:slug:{slug}")
await redis.execute("DEL", f"topic:id:{getattr(topic, 'id', 0)}")
return {}
return {"error": "access denied"}
# Запрос на получение подписчиков темы
@query.field("get_topic_followers")
async def get_topic_followers(_: None, _info: GraphQLResolveInfo, slug: str) -> list[Any]:
logger.debug(f"getting followers for @{slug}")
topic = await get_cached_topic_by_slug(slug, get_with_stat)
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 []
# Запрос на получение авторов темы
@query.field("get_topic_authors")
async def get_topic_authors(_: None, _info: GraphQLResolveInfo, slug: str) -> list[Any]:
logger.debug(f"getting authors for @{slug}")
topic = await get_cached_topic_by_slug(slug, get_with_stat)
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 []
# Мутация для удаления темы по ID (для админ-панели)
@mutation.field("delete_topic_by_id")
@login_required
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}"}
# Мутация для слияния тем
@mutation.field("merge_topics")
@login_required
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")
@login_required
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
# Ищем всех потомков 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]
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}"}