diff --git a/CHANGELOG.md b/CHANGELOG.md index ca62016f..abc63382 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,97 @@ # Changelog +## [0.6.0] - 2025-07-01 + +### Исправления авторизации + +- **КРИТИЧНО**: Исправлена ошибка "Сессия не найдена в Redis" в админ-панели: + - **Проблема**: Несоответствие полей в JWT токенах - при создании использовалось поле `id`, а при декодировании ожидалось `user_id` + - **Исправления**: + - В `SessionTokenManager.create_session_token` изменено создание JWT с поля `id` на `user_id` + - В `JWTCodec.encode` добавлена поддержка обоих полей (`user_id` и `id`) для обратной совместимости + - Обновлена обработка словарей в `JWTCodec.encode` для корректной работы с новым форматом + - **Результат**: Авторизация в админ-панели работает корректно, токены правильно верифицируются в Redis + +### Исправления типизации + +- **ИСПРАВЛЕНО**: Ошибки mypy в `resolvers/topic.py`: + - Добавлены аннотации типов для переменных `current_parent_ids`, `source_parent_ids`, `old_parent_ids`, `parent_parent_ids` + - Исправлена типизация при работе с `parent_ids` как `list[int]` с использованием `list()` для явного преобразования + - Заменен метод `contains()` на `op("@>")` для корректной работы с PostgreSQL JSON массивами + - Добавлено явное приведение типов для `invalidate_topic_followers_cache(int(source_topic.id))` + - Добавлены `# type: ignore[assignment]` комментарии для присваивания значений SQLAlchemy Column полям + - **Результат**: Код проходит проверку mypy без ошибок + +### Новые интерфейсы управления иерархией топиков + +- **НОВОЕ**: Три варианта интерфейса для управления иерархией тем в админ-панели: + +#### Простой интерфейс назначения родителей +- **TopicSimpleParentModal**: Простое и понятное назначение родительских тем +- **Возможности**: + - 🔍 **Поиск родителя**: Быстрый поиск подходящих родительских тем по названию + - 🏠 **Опция корневой темы**: Возможность сделать тему корневой одним кликом + - 📍 **Отображение текущего расположения**: Показ полного пути темы в иерархии + - 📋 **Предварительный просмотр**: Показ нового расположения перед применением + - ✅ **Валидация**: Автоматическая проверка циклических зависимостей + - 🏘️ **Фильтрация по сообществу**: Показ только тем из того же сообщества +- **UX особенности**: + - Radio buttons для четкого выбора одного варианта + - Отображение полных путей до корня для каждой темы + - Информационные панели с детальным описанием каждой опции + - Блокировка некорректных действий (циклы, разные сообщества) + - Простой и интуитивный интерфейс без сложных элементов + +#### Вариант 2: Простой селектор родителей +- **TopicParentModal**: Быстрый выбор родительской темы для одного топика +- **Возможности**: + - Поиск по названию для быстрого нахождения родителя + - Отображение текущего и нового местоположения в иерархии + - Опция "Сделать корневой темой" (🏠) + - Показ полного пути до корня для каждой темы + - Фильтрация только совместимых родителей (то же сообщество, без циклов) + - Предотвращение выбора потомков как родителей +- **UX особенности**: + - Radio buttons для четкого выбора + - Отображение slug и ID для точной идентификации + - Информационные панели с текущим состоянием + - Валидация с блокировкой некорректных действий + +#### Вариант 3: Массовый редактор иерархии +- **TopicBulkParentModal**: Одновременное изменение родителя для множества тем +- **Возможности**: + - Два режима: "Установить родителя" и "Сделать корневыми" + - Проверка совместимости (только темы одного сообщества) + - Предварительный просмотр изменений "Было → Станет" + - Поиск по названию среди доступных родителей + - Валидация для предотвращения циклов и ошибок + - Отображение количества затрагиваемых тем +- **UX особенности**: + - Список выбранных тем с их текущими путями + - Цветовая индикация состояний (до/после изменения) + - Предупреждения о несовместимых действиях + - Массовое применение с подтверждением + +### Техническая архитектура + +- **НОВАЯ мутация `set_topic_parent`**: Простое API для назначения родительской темы +- **Исправления GraphQL схемы**: Добавлены поля `message` и `stats` в `CommonResult` +- **Унифицированная валидация**: Проверка циклических зависимостей и принадлежности к сообществу +- **Простой интерфейс**: Radio buttons вместо сложного drag & drop для лучшего UX +- **Поиск и фильтрация**: Быстрый поиск подходящих родительских тем +- **Переиспользование компонентов**: Единый стиль с существующими модальными окнами +- **Автоматическая инвалидация кешей**: Обновление кешей при изменении иерархии +- **Детальное логирование**: Отслеживание всех операций с иерархией для отладки + +### Интеграция с существующей системой + +- **Кнопка "🏠 Назначить родителя"**: Простая кнопка для назначения родительской темы +- **Требует выбора одной темы**: Работает только с одной выбранной темой за раз +- **Совместимость**: Работает с существующей системой `parent_ids` в JSON формате +- **Обновление кешей**: Автоматическая инвалидация при изменении иерархии +- **Логирование**: Детальное отслеживание всех операций с иерархией +- **Отладка слияния**: Исправлена ошибка GraphQL `Cannot query field 'message'` в системе слияния тем + ## [0.5.10] - 2025-06-30 ### auth/internal fix @@ -9,6 +101,14 @@ - Исправлена функция `admin_delete_invites_batch` - завершена реализация для корректной обработки пакетного удаления приглашений - Исправлена ошибка в функции `get_shouts_with_links` в файле `resolvers/reader.py` - добавлено значение по умолчанию для поля `slug` у авторов публикаций в полях `authors` и `created_by`, чтобы избежать ошибки "Cannot return null for non-nullable field Author.slug" - Исправлена ошибка в функции `admin_get_shouts` в файле `resolvers/admin.py` - добавлена полная загрузка информации об авторах для полей `created_by`, `updated_by` и `deleted_by` с корректной обработкой поля `slug` и значениями по умолчанию, чтобы избежать ошибки "Cannot return null for non-nullable field Author.slug" +- Исправлена ошибка базы данных "relation invite does not exist" - раскомментирована таблица `invite.Invite` в функции `create_all_tables()` в файле `services/schema.py` для создания необходимой таблицы приглашений +- **УЛУЧШЕНО**: Верстка админ-панели приглашений: + - **Поиск на всю ширину**: Поле поиска теперь занимает всю ширину в отдельной строке для удобства ввода длинных запросов + - **Сортировка в заголовках**: Добавлены кликабельные иконки сортировки (↑↓) прямо в заголовки колонок таблицы + - **Компактная панель фильтров**: Фильтр статуса и кнопки управления размещены в отдельной строке под поиском + - **Улучшенный UX**: Hover эффекты для сортируемых колонок, визуальные индикаторы активной сортировки + - **Адаптивный дизайн**: Корректное отображение на мобильных устройствах с переносом элементов + - **Современный стиль**: Обновленная цветовая схема и типографика для лучшей читаемости ### Улучшения админ-панели для приглашений diff --git a/auth/jwtcodec.py b/auth/jwtcodec.py index 944113be..9e4894e0 100644 --- a/auth/jwtcodec.py +++ b/auth/jwtcodec.py @@ -21,9 +21,9 @@ class JWTCodec: def encode(user: Union[dict[str, Any], Any], exp: Optional[datetime] = None) -> str: # Поддержка как объектов, так и словарей if isinstance(user, dict): - # В TokenStorage.create_session передается словарь {"id": user_id, "email": username} - user_id = str(user.get("id", "")) - username = user.get("email", "") or user.get("username", "") + # В TokenStorage.create_session передается словарь {"user_id": user_id, "username": username} + user_id = str(user.get("user_id", "") or user.get("id", "")) + username = user.get("username", "") or user.get("email", "") else: # Для объектов с атрибутами user_id = str(getattr(user, "id", "")) diff --git a/auth/tokens/sessions.py b/auth/tokens/sessions.py index cf6c8f0c..b218a09f 100644 --- a/auth/tokens/sessions.py +++ b/auth/tokens/sessions.py @@ -45,7 +45,7 @@ class SessionTokenManager(BaseTokenManager): # Создаем JWT токен jwt_token = JWTCodec.encode( { - "id": user_id, + "user_id": user_id, "username": username, } ) diff --git a/panel/graphql/mutations.ts b/panel/graphql/mutations.ts index 0550bdcd..e9d6dd6f 100644 --- a/panel/graphql/mutations.ts +++ b/panel/graphql/mutations.ts @@ -137,3 +137,33 @@ export const ADMIN_DELETE_INVITES_BATCH_MUTATION = ` } } ` + +export const MERGE_TOPICS_MUTATION = ` + mutation MergeTopics($merge_input: TopicMergeInput!) { + merge_topics(merge_input: $merge_input) { + error + message + topic { + id + title + slug + } + stats + } + } +` + +export const SET_TOPIC_PARENT_MUTATION = ` + mutation SetTopicParent($topic_id: Int!, $parent_id: Int) { + set_topic_parent(topic_id: $topic_id, parent_id: $parent_id) { + error + message + topic { + id + title + slug + parent_ids + } + } + } +` diff --git a/panel/modals/TopicBulkParentModal.tsx b/panel/modals/TopicBulkParentModal.tsx new file mode 100644 index 00000000..821d8dc0 --- /dev/null +++ b/panel/modals/TopicBulkParentModal.tsx @@ -0,0 +1,326 @@ +import { Component, createSignal, For, Show } from 'solid-js' +import Button from '../ui/Button' +import Modal from '../ui/Modal' +import styles from '../styles/Form.module.css' + +interface Topic { + id: number + title: string + slug: string + parent_ids?: number[] + community: number +} + +interface TopicBulkParentModalProps { + isOpen: boolean + onClose: () => void + selectedTopicIds: number[] + allTopics: Topic[] + onSave: (changes: BulkParentChange[]) => void + onError: (error: string) => void +} + +interface BulkParentChange { + topicId: number + newParentIds: number[] + oldParentIds: number[] +} + +const TopicBulkParentModal: Component = (props) => { + const [newParentId, setNewParentId] = createSignal(null) + const [searchQuery, setSearchQuery] = createSignal('') + const [actionType, setActionType] = createSignal<'set' | 'makeRoot'>('set') + + // Получаем выбранные топики + const getSelectedTopics = () => { + return props.allTopics.filter(topic => + props.selectedTopicIds.includes(topic.id) + ) + } + + // Фильтрация доступных родителей + const getAvailableParents = () => { + const selectedIds = new Set(props.selectedTopicIds) + + return props.allTopics.filter(topic => { + // Исключаем выбранные топики + if (selectedIds.has(topic.id)) return false + + // Исключаем топики, которые являются детьми выбранных + const isChildOfSelected = props.selectedTopicIds.some(selectedId => + isDescendant(selectedId, topic.id) + ) + if (isChildOfSelected) return false + + // Фильтр по поисковому запросу + const query = searchQuery().toLowerCase() + if (query && !topic.title.toLowerCase().includes(query)) return false + + return true + }) + } + + // Проверка, является ли топик потомком другого + const isDescendant = (ancestorId: number, descendantId: number): boolean => { + const descendant = props.allTopics.find(t => t.id === descendantId) + if (!descendant || !descendant.parent_ids) return false + + return descendant.parent_ids.includes(ancestorId) + } + + // Получение пути к корню + const getTopicPath = (topicId: number): string => { + const topic = props.allTopics.find(t => t.id === topicId) + if (!topic) return '' + + if (!topic.parent_ids || topic.parent_ids.length === 0) { + return topic.title + } + + const parentPath = getTopicPath(topic.parent_ids[topic.parent_ids.length - 1]) + return `${parentPath} → ${topic.title}` + } + + // Группировка топиков по сообществам + const getTopicsByCommunity = () => { + const selectedTopics = getSelectedTopics() + const communities = new Map() + + selectedTopics.forEach(topic => { + if (!communities.has(topic.community)) { + communities.set(topic.community, []) + } + communities.get(topic.community)!.push(topic) + }) + + return communities + } + + // Проверка совместимости действия + const validateAction = (): string | null => { + const communities = getTopicsByCommunity() + + if (communities.size > 1) { + return 'Нельзя изменять иерархию тем из разных сообществ одновременно' + } + + if (actionType() === 'set' && !newParentId()) { + return 'Выберите родительскую тему' + } + + const selectedParent = props.allTopics.find(t => t.id === newParentId()) + if (selectedParent) { + const selectedCommunity = Array.from(communities.keys())[0] + if (selectedParent.community !== selectedCommunity) { + return 'Родительская тема должна быть из того же сообщества' + } + } + + return null + } + + // Сохранение изменений + const handleSave = () => { + const validationError = validateAction() + if (validationError) { + props.onError(validationError) + return + } + + const changes: BulkParentChange[] = [] + const selectedTopics = getSelectedTopics() + + selectedTopics.forEach(topic => { + let newParentIds: number[] = [] + + if (actionType() === 'set' && newParentId()) { + const parentTopic = props.allTopics.find(t => t.id === newParentId()) + if (parentTopic) { + newParentIds = [...(parentTopic.parent_ids || []), newParentId()!] + } + } + + changes.push({ + topicId: topic.id, + newParentIds, + oldParentIds: topic.parent_ids || [] + }) + }) + + props.onSave(changes) + } + + return ( + +
+ {/* Проверка совместимости */} + 1}> +
+ ⚠️ Выбраны темы из разных сообществ. Массовое изменение иерархии возможно только для тем одного сообщества. +
+
+ + {/* Список выбранных тем */} +
+

Выбранные темы ({props.selectedTopicIds.length}):

+
+ + {(topic) => ( +
+ {topic.title} + #{topic.id} + 0}> +
+ Текущий путь: {getTopicPath(topic.id)} +
+
+
+ )} +
+
+
+ + {/* Выбор действия */} +
+

Выберите действие:

+
+
+ setActionType('set')} + /> + +
+ +
+ setActionType('makeRoot')} + /> + +
+
+
+ + {/* Выбор родителя */} + +
+

Выбор родительской темы:

+ +
+ setSearchQuery(e.target.value)} + placeholder="Поиск родительской темы..." + class={styles.searchInput} + /> +
+ +
+ + {(topic) => ( +
+ setNewParentId(topic.id)} + /> + +
+ )} +
+
+ + +
+ {searchQuery() + ? `Нет доступных тем для поиска "${searchQuery()}"` + : 'Нет доступных родительских тем' + } +
+
+
+
+ + {/* Предварительный просмотр изменений */} + +
+

Предварительный просмотр:

+
+ + {(topic) => ( +
+ {topic.title} +
+ + Было: {topic.parent_ids?.length ? getTopicPath(topic.id) : 'Корневая тема'} + + + + Станет: { + actionType() === 'makeRoot' + ? 'Корневая тема' + : newParentId() ? `${getTopicPath(newParentId()!)} → ${topic.title}` : '' + } + +
+
+ )} +
+
+
+
+ +
+ + +
+
+
+ ) +} + +export default TopicBulkParentModal diff --git a/panel/modals/TopicHierarchyModal.tsx b/panel/modals/TopicHierarchyModal.tsx new file mode 100644 index 00000000..2d6c2d62 --- /dev/null +++ b/panel/modals/TopicHierarchyModal.tsx @@ -0,0 +1,458 @@ +import { Component, createSignal, For, Show } from 'solid-js' +import Button from '../ui/Button' +import Modal from '../ui/Modal' +import styles from '../styles/Form.module.css' + +// Типы для топиков +interface Topic { + id: number + title: string + slug: string + parent_ids?: number[] + children?: Topic[] + level?: number + community: number +} + +interface TopicHierarchyModalProps { + isOpen: boolean + onClose: () => void + topics: Topic[] + onSave: (hierarchyChanges: HierarchyChange[]) => void + onError: (error: string) => void +} + +interface HierarchyChange { + topicId: number + newParentIds: number[] + oldParentIds: number[] +} + +const TopicHierarchyModal: Component = (props) => { + const [localTopics, setLocalTopics] = createSignal([]) + const [changes, setChanges] = createSignal([]) + const [expandedNodes, setExpandedNodes] = createSignal>(new Set()) + const [searchQuery, setSearchQuery] = createSignal('') + const [selectedForMove, setSelectedForMove] = createSignal(null) + + // Инициализация локального состояния + const initializeTopics = () => { + const hierarchicalTopics = buildHierarchy(props.topics) + setLocalTopics(hierarchicalTopics) + setChanges([]) + setSearchQuery('') + setSelectedForMove(null) + // Раскрываем все узлы по умолчанию + const allIds = new Set(props.topics.map(t => t.id)) + setExpandedNodes(allIds) + } + + // Построение иерархической структуры + const buildHierarchy = (flatTopics: Topic[]): Topic[] => { + const topicMap = new Map() + const rootTopics: Topic[] = [] + + // Создаем карту всех топиков + flatTopics.forEach((topic) => { + topicMap.set(topic.id, { ...topic, children: [], level: 0 }) + }) + + // Строим иерархию + flatTopics.forEach((topic) => { + const currentTopic = topicMap.get(topic.id)! + + if (!topic.parent_ids || topic.parent_ids.length === 0) { + rootTopics.push(currentTopic) + } else { + const parentId = topic.parent_ids[topic.parent_ids.length - 1] + const parent = topicMap.get(parentId) + if (parent) { + currentTopic.level = (parent.level || 0) + 1 + parent.children!.push(currentTopic) + } else { + rootTopics.push(currentTopic) + } + } + }) + + return rootTopics + } + + // Функция удалена - используем кликабельный интерфейс вместо drag & drop + + // Проверка, является ли топик потомком другого + const isDescendant = (parentId: number, childId: number): boolean => { + const checkChildren = (topics: Topic[]): boolean => { + for (const topic of topics) { + if (topic.id === childId) return true + if (topic.children && checkChildren(topic.children)) return true + } + return false + } + + const parentTopic = findTopicById(parentId) + return parentTopic ? checkChildren(parentTopic.children || []) : false + } + + // Поиск топика по ID + const findTopicById = (id: number): Topic | null => { + const search = (topics: Topic[]): Topic | null => { + for (const topic of topics) { + if (topic.id === id) return topic + if (topic.children) { + const found = search(topic.children) + if (found) return found + } + } + return null + } + return search(localTopics()) + } + + // Обновление родителя топика + const updateTopicParent = (topicId: number, newParentIds: number[]) => { + const flatTopics = flattenTopics(localTopics()) + const updatedTopics = flatTopics.map(topic => + topic.id === topicId + ? { ...topic, parent_ids: newParentIds } + : topic + ) + const newHierarchy = buildHierarchy(updatedTopics) + setLocalTopics(newHierarchy) + } + + // Преобразование дерева в плоский список + const flattenTopics = (topics: Topic[]): Topic[] => { + const result: Topic[] = [] + const flatten = (topicList: Topic[]) => { + topicList.forEach(topic => { + result.push(topic) + if (topic.children) { + flatten(topic.children) + } + }) + } + flatten(topics) + return result + } + + // Переключение раскрытия узла + const toggleExpanded = (topicId: number) => { + const expanded = expandedNodes() + if (expanded.has(topicId)) { + expanded.delete(topicId) + } else { + expanded.add(topicId) + } + setExpandedNodes(new Set(expanded)) + } + + // Поиск темы по названию + const findTopicByTitle = (title: string): Topic | null => { + const query = title.toLowerCase().trim() + if (!query) return null + + const search = (topics: Topic[]): Topic | null => { + for (const topic of topics) { + if (topic.title.toLowerCase().includes(query)) { + return topic + } + if (topic.children) { + const found = search(topic.children) + if (found) return found + } + } + return null + } + + return search(localTopics()) + } + + // Выбор темы для перемещения + const selectTopicForMove = (topicId: number) => { + setSelectedForMove(topicId) + const topic = findTopicById(topicId) + if (topic) { + props.onError(`Выбрана тема "${topic.title}" для перемещения. Теперь нажмите на новую родительскую тему или используйте "Сделать корневой".`) + } + } + + // Перемещение выбранной темы к новому родителю + const moveSelectedTopic = (newParentId: number | null) => { + const selectedId = selectedForMove() + if (!selectedId) return + + const selectedTopic = findTopicById(selectedId) + if (!selectedTopic) return + + // Проверяем циклы + if (newParentId && isDescendant(newParentId, selectedId)) { + props.onError('Нельзя переместить тему в своего потомка') + return + } + + let newParentIds: number[] = [] + if (newParentId) { + const newParent = findTopicById(newParentId) + if (newParent) { + newParentIds = [...(newParent.parent_ids || []), newParentId] + } + } + + // Обновляем локальное состояние + updateTopicParent(selectedId, newParentIds) + + // Добавляем в список изменений + setChanges(prev => [ + ...prev.filter(c => c.topicId !== selectedId), + { + topicId: selectedId, + newParentIds, + oldParentIds: selectedTopic.parent_ids || [] + } + ]) + + setSelectedForMove(null) + } + + // Раскрытие пути до определенной темы + const expandPathToTopic = (topicId: number) => { + const topic = findTopicById(topicId) + if (!topic || !topic.parent_ids) return + + const expanded = expandedNodes() + // Раскрываем всех родителей + topic.parent_ids.forEach(parentId => { + expanded.add(parentId) + }) + setExpandedNodes(new Set(expanded)) + } + + // Рендеринг дерева топиков + const renderTree = (topics: Topic[]): any => { + return ( + + {(topic) => { + const isExpanded = expandedNodes().has(topic.id) + const isSelected = selectedForMove() === topic.id + const isTarget = selectedForMove() && selectedForMove() !== topic.id + const hasChildren = topic.children && topic.children.length > 0 + + return ( +
+
{ + if (selectedForMove() && selectedForMove() !== topic.id) { + // Если уже выбрана тема для перемещения, делаем текущую тему родителем + moveSelectedTopic(topic.id) + } else { + // Иначе выбираем эту тему для перемещения + selectTopicForMove(topic.id) + } + }} + style={{ + 'padding-left': `${(topic.level || 0) * 20}px`, + 'cursor': 'pointer', + 'border': isSelected ? '2px solid #007bff' : isTarget ? '2px dashed #28a745' : '1px solid transparent', + 'background-color': isSelected ? '#e3f2fd' : isTarget ? '#d4edda' : 'transparent' + }} + > +
+ + + + + + + + + 📦 + + + 📂 + + + # + + + {topic.title} + #{topic.id} +
+
+ + +
+ {renderTree(topic.children!)} +
+
+
+ ) + }} +
+ ) + } + + // Сброс корневого уровня (перетаскивание в корень) + const makeRootTopic = (topicId: number) => { + updateTopicParent(topicId, []) + + const draggedTopic = findTopicById(topicId) + if (!draggedTopic) return + + setChanges(prev => [ + ...prev.filter(c => c.topicId !== topicId), + { + topicId, + newParentIds: [], + oldParentIds: draggedTopic.parent_ids || [] + } + ]) + } + + // Сохранение изменений + const handleSave = () => { + if (changes().length === 0) { + props.onError('Нет изменений для сохранения') + return + } + props.onSave(changes()) + } + + // Инициализация при открытии + if (props.isOpen && localTopics().length === 0) { + initializeTopics() + } + + return ( + +
+
+

Инструкции:

+
    +
  • 🔍 Найдите тему по названию или прокрутите список
  • +
  • # Нажмите на тему, чтобы выбрать её для перемещения (синяя рамка)
  • +
  • 📂 Нажмите на другую тему, чтобы сделать её родителем (зеленая рамка)
  • +
  • 🏠 Используйте кнопку "Сделать корневой" для перемещения на верхний уровень
  • +
  • ▶/▼ Раскрывайте/сворачивайте ветки дерева
  • +
+
+ +
+ + { + const query = e.target.value + setSearchQuery(query) + // Автоматически находим и подсвечиваем тему + if (query.trim()) { + const foundTopic = findTopicByTitle(query) + if (foundTopic) { + // Раскрываем путь до найденной темы + expandPathToTopic(foundTopic.id) + } + } + }} + placeholder="Введите название темы для поиска..." + class={styles.searchInput} + /> + +
+ ✅ Найдена тема: {findTopicByTitle(searchQuery())?.title} +
+
+ +
+ ❌ Тема не найдена +
+
+
+ +
+ {renderTree(localTopics())} +
+ + 0}> +
+

Планируемые изменения ({changes().length}):

+ + {(change) => { + const topic = findTopicById(change.topicId) + return ( +
+ {topic?.title}: { + change.newParentIds.length === 0 + ? 'станет корневой темой' + : `переместится под тему #${change.newParentIds[change.newParentIds.length - 1]}` + } +
+ ) + }} +
+
+
+ + +
+
+

Выбрана для перемещения:

+
+ 📦 {findTopicById(selectedForMove()!)?.title} #{selectedForMove()} +
+
+ +
+ + +
+
+
+ +
+ + +
+
+
+ ) +} + +export default TopicHierarchyModal diff --git a/panel/modals/TopicMergeModal.tsx b/panel/modals/TopicMergeModal.tsx new file mode 100644 index 00000000..0a4fa105 --- /dev/null +++ b/panel/modals/TopicMergeModal.tsx @@ -0,0 +1,327 @@ +import { Component, createSignal, For, Show } from 'solid-js' +import Button from '../ui/Button' +import Modal from '../ui/Modal' +import styles from '../styles/Form.module.css' +import { MERGE_TOPICS_MUTATION } from '../graphql/mutations' + +// Типы для топиков +interface Topic { + id: number + title: string + slug: string + community: number + stat?: { + shouts: number + followers: number + authors: number + comments: number + } +} + +interface TopicMergeModalProps { + isOpen: boolean + onClose: () => void + topics: Topic[] + onSuccess: (message: string) => void + onError: (error: string) => void +} + +interface MergeStats { + followers_moved: number + publications_moved: number + drafts_moved: number + source_topics_deleted: number +} + +const TopicMergeModal: Component = (props) => { + const [targetTopicId, setTargetTopicId] = createSignal(null) + const [sourceTopicIds, setSourceTopicIds] = createSignal([]) + const [preserveTarget, setPreserveTarget] = createSignal(true) + const [loading, setLoading] = createSignal(false) + const [error, setError] = createSignal('') + + /** + * Получает токен авторизации из localStorage или cookie + */ + const getAuthTokenFromCookie = () => { + return document.cookie + .split('; ') + .find(row => row.startsWith('auth_token=')) + ?.split('=')[1] || '' + } + + /** + * Обработчик выбора/снятия выбора исходной темы + */ + const handleSourceTopicToggle = (topicId: number, checked: boolean) => { + if (checked) { + setSourceTopicIds(prev => [...prev, topicId]) + } else { + setSourceTopicIds(prev => prev.filter(id => id !== topicId)) + } + } + + /** + * Проверяет можно ли выполнить слияние + */ + const canMerge = () => { + const target = targetTopicId() + const sources = sourceTopicIds() + + if (!target || sources.length === 0) { + return false + } + + // Проверяем что целевая тема не выбрана как исходная + if (sources.includes(target)) { + return false + } + + // Проверяем что все темы принадлежат одному сообществу + const targetTopic = props.topics.find(t => t.id === target) + if (!targetTopic) return false + + const targetCommunity = targetTopic.community + const sourcesTopics = props.topics.filter(t => sources.includes(t.id)) + + return sourcesTopics.every(topic => topic.community === targetCommunity) + } + + /** + * Получает название сообщества по ID (заглушка) + */ + const getCommunityName = (communityId: number) => { + // Здесь можно добавить запрос к API или кеш сообществ + return `Сообщество ${communityId}` + } + + /** + * Выполняет слияние топиков + */ + const handleMerge = async () => { + if (!canMerge()) { + setError('Невозможно выполнить слияние с текущими настройками') + return + } + + setLoading(true) + setError('') + + try { + const authToken = localStorage.getItem('auth_token') || getAuthTokenFromCookie() + + const response = await fetch('/graphql', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: authToken ? `Bearer ${authToken}` : '' + }, + body: JSON.stringify({ + query: MERGE_TOPICS_MUTATION, + variables: { + merge_input: { + target_topic_id: targetTopicId(), + source_topic_ids: sourceTopicIds(), + preserve_target_properties: preserveTarget() + } + } + }) + }) + + const result = await response.json() + + if (result.errors) { + throw new Error(result.errors[0].message) + } + + const mergeResult = result.data.merge_topics + + if (mergeResult.error) { + throw new Error(mergeResult.error) + } + + const stats = mergeResult.stats as MergeStats + const statsText = stats ? + ` (перенесено ${stats.followers_moved} подписчиков, ${stats.publications_moved} публикаций, ${stats.drafts_moved} черновиков, удалено ${stats.source_topics_deleted} тем)` : '' + + props.onSuccess(mergeResult.message + statsText) + handleClose() + + } catch (error) { + const errorMessage = (error as Error).message + setError(errorMessage) + props.onError(`Ошибка слияния тем: ${errorMessage}`) + } finally { + setLoading(false) + } + } + + /** + * Закрывает модалку и сбрасывает состояние + */ + const handleClose = () => { + setTargetTopicId(null) + setSourceTopicIds([]) + setPreserveTarget(true) + setError('') + setLoading(false) + props.onClose() + } + + /** + * Получает отфильтрованный список топиков (исключая выбранные как исходные) + */ + const getAvailableTargetTopics = () => { + const sources = sourceTopicIds() + return props.topics.filter(topic => !sources.includes(topic.id)) + } + + /** + * Получает отфильтрованный список топиков (исключая целевую тему) + */ + const getAvailableSourceTopics = () => { + const target = targetTopicId() + return props.topics.filter(topic => topic.id !== target) + } + + return ( + +
+
+

Выбор целевой темы

+

+ Выберите тему, в которую будут слиты остальные темы. Все подписчики и публикации будут перенесены в эту тему. +

+ + +
+ +
+

Выбор исходных тем для слияния

+

+ Выберите темы, которые будут слиты в целевую тему. Эти темы будут удалены после переноса всех связей. +

+ + 0}> +
+ + {(topic) => { + const isChecked = () => sourceTopicIds().includes(topic.id) + + return ( + + ) + }} + +
+
+
+ +
+

Настройки слияния

+ + +
+ + +
{error()}
+
+ + 0}> +
+

Предпросмотр слияния:

+
    +
  • + Целевая тема: {props.topics.find(t => t.id === targetTopicId())?.title} +
  • +
  • + Исходные темы: {sourceTopicIds().length} шт. +
      + + {(id) => { + const topic = props.topics.find(t => t.id === id) + return topic ?
    • {topic.title}
    • : null + }} +
      +
    +
  • +
  • + Действие: Все подписчики, публикации и черновики будут перенесены в целевую тему, исходные темы будут удалены +
  • +
+
+
+ +
+ + +
+
+
+ ) +} + +export default TopicMergeModal diff --git a/panel/modals/TopicParentModal.tsx b/panel/modals/TopicParentModal.tsx new file mode 100644 index 00000000..a43edb8c --- /dev/null +++ b/panel/modals/TopicParentModal.tsx @@ -0,0 +1,215 @@ +import { Component, createSignal, For, Show } from 'solid-js' +import Button from '../ui/Button' +import Modal from '../ui/Modal' +import styles from '../styles/Form.module.css' + +interface Topic { + id: number + title: string + slug: string + parent_ids?: number[] + community: number +} + +interface TopicParentModalProps { + isOpen: boolean + onClose: () => void + topic: Topic | null + allTopics: Topic[] + onSave: (topic: Topic) => void + onError: (error: string) => void +} + +const TopicParentModal: Component = (props) => { + const [selectedParentId, setSelectedParentId] = createSignal(null) + const [searchQuery, setSearchQuery] = createSignal('') + + // Получаем текущего родителя при открытии модалки + const getCurrentParentId = (): number | null => { + const topic = props.topic + if (!topic || !topic.parent_ids || topic.parent_ids.length === 0) { + return null + } + return topic.parent_ids[topic.parent_ids.length - 1] + } + + // Фильтрация доступных родителей + const getAvailableParents = () => { + const currentTopic = props.topic + if (!currentTopic) return [] + + return props.allTopics.filter(topic => { + // Исключаем сам топик + if (topic.id === currentTopic.id) return false + + // Исключаем топики из других сообществ + if (topic.community !== currentTopic.community) return false + + // Исключаем дочерние топики (предотвращаем циклы) + if (isDescendant(currentTopic.id, topic.id)) return false + + // Фильтр по поисковому запросу + const query = searchQuery().toLowerCase() + if (query && !topic.title.toLowerCase().includes(query)) return false + + return true + }) + } + + // Проверка, является ли топик потомком другого + const isDescendant = (ancestorId: number, descendantId: number): boolean => { + const descendant = props.allTopics.find(t => t.id === descendantId) + if (!descendant || !descendant.parent_ids) return false + + return descendant.parent_ids.includes(ancestorId) + } + + // Получение пути к корню для отображения полного пути + const getTopicPath = (topicId: number): string => { + const topic = props.allTopics.find(t => t.id === topicId) + if (!topic) return '' + + if (!topic.parent_ids || topic.parent_ids.length === 0) { + return topic.title + } + + const parentPath = getTopicPath(topic.parent_ids[topic.parent_ids.length - 1]) + return `${parentPath} → ${topic.title}` + } + + // Сохранение изменений + const handleSave = () => { + const currentTopic = props.topic + if (!currentTopic) return + + const newParentId = selectedParentId() + let newParentIds: number[] = [] + + if (newParentId) { + const parentTopic = props.allTopics.find(t => t.id === newParentId) + if (parentTopic) { + // Строим полный путь от корня до нового родителя + newParentIds = [...(parentTopic.parent_ids || []), newParentId] + } + } + + const updatedTopic: Topic = { + ...currentTopic, + parent_ids: newParentIds + } + + props.onSave(updatedTopic) + } + + // Инициализация при открытии + if (props.isOpen && props.topic) { + setSelectedParentId(getCurrentParentId()) + setSearchQuery('') + } + + return ( + +
+
+ + setSearchQuery(e.target.value)} + placeholder="Введите название темы..." + class={styles.searchInput} + /> +
+ +
+ +
+ Корневая тема} + > + + {getCurrentParentId() ? getTopicPath(getCurrentParentId()!) : ''} + + +
+
+ +
+ + + {/* Опция "Сделать корневой" */} +
+ setSelectedParentId(null)} + /> + +
+ + {/* Доступные родители */} +
+ + {(topic) => ( +
+ setSelectedParentId(topic.id)} + /> + +
+ )} +
+
+ + +
+ Нет тем, соответствующих поисковому запросу "{searchQuery()}" +
+
+
+ +
+ + +
+
+
+ ) +} + +export default TopicParentModal diff --git a/panel/modals/TopicSimpleParentModal.tsx b/panel/modals/TopicSimpleParentModal.tsx new file mode 100644 index 00000000..09841ebe --- /dev/null +++ b/panel/modals/TopicSimpleParentModal.tsx @@ -0,0 +1,305 @@ +import { Component, createSignal, For, Show } from 'solid-js' +import Button from '../ui/Button' +import Modal from '../ui/Modal' +import styles from '../styles/Form.module.css' +import { SET_TOPIC_PARENT_MUTATION } from '../graphql/mutations' + +// Типы для топиков +interface Topic { + id: number + title: string + slug: string + parent_ids?: number[] + community: number +} + +interface TopicSimpleParentModalProps { + isOpen: boolean + onClose: () => void + topic: Topic | null + allTopics: Topic[] + onSuccess: (message: string) => void + onError: (error: string) => void +} + +const TopicSimpleParentModal: Component = (props) => { + const [selectedParentId, setSelectedParentId] = createSignal(null) + const [loading, setLoading] = createSignal(false) + const [searchQuery, setSearchQuery] = createSignal('') + + /** + * Получает токен авторизации + */ + const getAuthTokenFromCookie = () => { + return document.cookie + .split('; ') + .find(row => row.startsWith('auth_token=')) + ?.split('=')[1] || '' + } + + /** + * Получает текущего родителя темы + */ + const getCurrentParentId = (): number | null => { + if (!props.topic?.parent_ids || props.topic.parent_ids.length === 0) { + return null + } + return props.topic.parent_ids[props.topic.parent_ids.length - 1] + } + + /** + * Получает путь темы до корня + */ + const getTopicPath = (topicId: number): string => { + const topic = props.allTopics.find(t => t.id === topicId) + if (!topic) return 'Неизвестная тема' + + if (!topic.parent_ids || topic.parent_ids.length === 0) { + return topic.title + } + + const parentPath = getTopicPath(topic.parent_ids[topic.parent_ids.length - 1]) + return `${parentPath} → ${topic.title}` + } + + /** + * Проверяет циклические зависимости + */ + const isDescendant = (parentId: number, childId: number): boolean => { + if (parentId === childId) return true + + const checkDescendants = (currentId: number): boolean => { + const descendants = props.allTopics.filter(t => + t.parent_ids && t.parent_ids.includes(currentId) + ) + + for (const descendant of descendants) { + if (descendant.id === childId || checkDescendants(descendant.id)) { + return true + } + } + return false + } + + return checkDescendants(parentId) + } + + /** + * Получает доступных родителей (исключая потомков и темы из других сообществ) + */ + const getAvailableParents = () => { + if (!props.topic) return [] + + const query = searchQuery().toLowerCase() + + return props.allTopics.filter(topic => { + // Исключаем саму тему + if (topic.id === props.topic!.id) return false + + // Только темы из того же сообщества + if (topic.community !== props.topic!.community) return false + + // Исключаем потомков (предотвращаем циклы) + if (isDescendant(topic.id, props.topic!.id)) return false + + // Фильтр по поиску + if (query && !topic.title.toLowerCase().includes(query)) return false + + return true + }) + } + + /** + * Выполняет назначение родителя + */ + const handleSetParent = async () => { + if (!props.topic) return + + setLoading(true) + + try { + const authToken = localStorage.getItem('auth_token') || getAuthTokenFromCookie() + + const response = await fetch('/graphql', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: authToken ? `Bearer ${authToken}` : '' + }, + body: JSON.stringify({ + query: SET_TOPIC_PARENT_MUTATION, + variables: { + topic_id: props.topic.id, + parent_id: selectedParentId() + } + }) + }) + + const result = await response.json() + + if (result.errors) { + throw new Error(result.errors[0].message) + } + + const setResult = result.data.set_topic_parent + + if (setResult.error) { + throw new Error(setResult.error) + } + + props.onSuccess(setResult.message) + handleClose() + + } catch (error) { + const errorMessage = (error as Error).message + props.onError(`Ошибка назначения родителя: ${errorMessage}`) + } finally { + setLoading(false) + } + } + + /** + * Закрывает модалку и сбрасывает состояние + */ + const handleClose = () => { + setSelectedParentId(null) + setSearchQuery('') + setLoading(false) + props.onClose() + } + + return ( + +
+ +
+

Редактируемая тема:

+
+ {props.topic?.title} #{props.topic?.id} +
+ +
+ Текущее расположение: +
+ {getCurrentParentId() ? + getTopicPath(props.topic!.id) : + 🏠 Корневая тема + } +
+
+
+ +
+ + setSearchQuery(e.target.value)} + placeholder="Введите название темы..." + class={styles.searchInput} + disabled={loading()} + /> +
+ +
+

Выберите новую родительскую тему:

+ + {/* Опция корневой темы */} +
+ +
+ + {/* Список доступных родителей */} +
+ 0}> + + {(topic) => ( + + )} + + + + +
+ {searchQuery() ? + 'Не найдено подходящих тем по запросу' : + 'Нет доступных родительских тем' + } +
+
+
+
+ + +
+

Предварительный просмотр:

+
+ Новое расположение:
+ {getTopicPath(selectedParentId()!)} → {props.topic?.title} +
+
+
+ +
+ + +
+
+
+
+ ) +} + +export default TopicSimpleParentModal diff --git a/panel/routes/invites.tsx b/panel/routes/invites.tsx index e6cac224..c19f1b3c 100644 --- a/panel/routes/invites.tsx +++ b/panel/routes/invites.tsx @@ -43,6 +43,15 @@ interface InvitesRouteProps { onSuccess: (message: string) => void } +// Добавляю типы для сортировки +type SortField = 'inviter_name' | 'author_name' | 'shout_title' | 'status' | 'created_at' +type SortDirection = 'asc' | 'desc' + +interface SortState { + field: SortField | null + direction: SortDirection +} + /** * Компонент для управления приглашениями */ @@ -73,6 +82,9 @@ const InvitesRoute: Component = (props) => { show: false }) + // Добавляю состояние сортировки + const [sortState, setSortState] = createSignal({ field: null, direction: 'asc' }) + /** * Загружает список приглашений с учетом фильтров и пагинации */ @@ -307,6 +319,34 @@ const InvitesRoute: Component = (props) => { return Object.values(selectedInvites()).filter(Boolean).length } + /** + * Обработчик клика по заголовку колонки для сортировки + */ + const handleSort = (field: SortField) => { + const current = sortState() + let newDirection: SortDirection = 'asc' + + if (current.field === field) { + // Если кликнули по той же колонке, меняем направление + newDirection = current.direction === 'asc' ? 'desc' : 'asc' + } + + setSortState({ field, direction: newDirection }) + // Здесь можно добавить логику сортировки на сервере или клиенте + console.log(`Сортировка по ${field} в направлении ${newDirection}`) + } + + /** + * Получает иконку сортировки для колонки + */ + const getSortIcon = (field: SortField) => { + const current = sortState() + if (current.field !== field) { + return '↕️' // Неактивная сортировка + } + return current.direction === 'asc' ? '↑' : '↓' + } + // Загружаем приглашения при монтировании компонента onMount(() => { void loadInvites() @@ -314,20 +354,20 @@ const InvitesRoute: Component = (props) => { return (
-
-
+ {/* Новая компактная панель поиска и фильтров */} +
+
setSearch(e.target.value)} onKeyPress={(e) => e.key === 'Enter' && handleSearch()} - class={styles.searchInput} + class={styles.fullWidthSearch} /> - +
+
+ +
@@ -391,10 +435,30 @@ const InvitesRoute: Component = (props) => { - Приглашающий - Приглашаемый - Публикация - Статус + handleSort('inviter_name')}> + + Приглашающий + {getSortIcon('inviter_name')} + + + handleSort('author_name')}> + + Приглашаемый + {getSortIcon('author_name')} + + + handleSort('shout_title')}> + + Публикация + {getSortIcon('shout_title')} + + + handleSort('status')}> + + Статус + {getSortIcon('status')} + + Действия diff --git a/panel/routes/topics.tsx b/panel/routes/topics.tsx index 80ca3423..97cea8d5 100644 --- a/panel/routes/topics.tsx +++ b/panel/routes/topics.tsx @@ -9,6 +9,8 @@ import type { Query } from '../graphql/generated/schema' import { CREATE_TOPIC_MUTATION, DELETE_TOPIC_MUTATION, UPDATE_TOPIC_MUTATION } from '../graphql/mutations' import { GET_TOPICS_QUERY } from '../graphql/queries' import TopicEditModal from '../modals/TopicEditModal' +import TopicMergeModal from '../modals/TopicMergeModal' +import TopicSimpleParentModal from '../modals/TopicSimpleParentModal' import styles from '../styles/Table.module.css' import Button from '../ui/Button' import Modal from '../ui/Modal' @@ -56,6 +58,15 @@ const TopicsRoute: Component = (props) => { const [createModal, setCreateModal] = createSignal<{ show: boolean }>({ show: false }) + const [selectedTopics, setSelectedTopics] = createSignal([]) + const [groupAction, setGroupAction] = createSignal<'delete' | 'merge' | ''>('') + const [mergeModal, setMergeModal] = createSignal<{ show: boolean }>({ + show: false + }) + const [simpleParentModal, setSimpleParentModal] = createSignal<{ show: boolean; topic: Topic | null }>({ + show: false, + topic: null + }) /** * Загружает список всех топиков @@ -186,19 +197,22 @@ const TopicsRoute: Component = (props) => { const result: JSX.Element[] = [] topics.forEach((topic) => { + const isSelected = selectedTopics().includes(topic.id) + result.push( - setEditModal({ show: true, topic })} - style={{ cursor: 'pointer' }} - class={styles['clickable-row']} - > + {topic.id} - + setEditModal({ show: true, topic })} + > {topic.level! > 0 && '└─ '} {topic.title} - {topic.slug} - + setEditModal({ show: true, topic })} style={{ cursor: 'pointer' }}> + {topic.slug} + + setEditModal({ show: true, topic })} style={{ cursor: 'pointer' }}>
= (props) => { {truncateText(topic.body?.replace(/<[^>]*>/g, '') || '', 100)}
- {topic.community} - {topic.parent_ids?.join(', ') || '—'} + setEditModal({ show: true, topic })} style={{ cursor: 'pointer' }}> + {topic.community} + + setEditModal({ show: true, topic })} style={{ cursor: 'pointer' }}> + {topic.parent_ids?.join(', ') || '—'} + e.stopPropagation()}> - + style={{ cursor: 'pointer' }} + /> ) @@ -305,6 +321,90 @@ const TopicsRoute: Component = (props) => { } } + /** + * Обработчик выбора/снятия выбора топика + */ + const handleTopicSelect = (topicId: number, checked: boolean) => { + if (checked) { + setSelectedTopics(prev => [...prev, topicId]) + } else { + setSelectedTopics(prev => prev.filter(id => id !== topicId)) + } + } + + /** + * Обработчик выбора/снятия выбора всех топиков + */ + const handleSelectAll = (checked: boolean) => { + if (checked) { + const allTopicIds = rawTopics().map(topic => topic.id) + setSelectedTopics(allTopicIds) + } else { + setSelectedTopics([]) + } + } + + /** + * Проверяет выбраны ли все топики + */ + const isAllSelected = () => { + const allIds = rawTopics().map(topic => topic.id) + const selected = selectedTopics() + return allIds.length > 0 && allIds.every(id => selected.includes(id)) + } + + /** + * Проверяет выбран ли хотя бы один топик + */ + const hasSelectedTopics = () => selectedTopics().length > 0 + + /** + * Выполняет групповое действие + */ + const executeGroupAction = () => { + const action = groupAction() + const selected = selectedTopics() + + if (!action || selected.length === 0) { + props.onError('Выберите действие и топики') + return + } + + if (action === 'delete') { + // Групповое удаление + const selectedTopicsData = rawTopics().filter(t => selected.includes(t.id)) + setDeleteModal({ show: true, topic: selectedTopicsData[0] }) // Используем первый для отображения + } else if (action === 'merge') { + // Слияние топиков + if (selected.length < 2) { + props.onError('Для слияния нужно выбрать минимум 2 темы') + return + } + setMergeModal({ show: true }) + } + } + + /** + * Групповое удаление выбранных топиков + */ + const deleteSelectedTopics = async () => { + const selected = selectedTopics() + if (selected.length === 0) return + + try { + // Удаляем по одному (можно оптимизировать пакетным удалением) + for (const topicId of selected) { + await deleteTopic(topicId) + } + + setSelectedTopics([]) + setGroupAction('') + props.onSuccess(`Успешно удалено ${selected.length} тем`) + } catch (error) { + props.onError(`Ошибка группового удаления: ${(error as Error).message}`) + } + } + /** * Удаляет топик */ @@ -378,6 +478,21 @@ const TopicsRoute: Component = (props) => { +
@@ -399,7 +514,53 @@ const TopicsRoute: Component = (props) => { Описание Сообщество Родители - Действия + +
+
+ handleSelectAll(e.target.checked)} + style={{ cursor: 'pointer' }} + title="Выбрать все" + /> + Все +
+ +
+ + +
+
+
+ @@ -431,25 +592,79 @@ const TopicsRoute: Component = (props) => { title="Подтверждение удаления" >
-

- Вы уверены, что хотите удалить топик "{deleteModal().topic?.title}"? -

-

- Это действие нельзя отменить. Все дочерние топики также будут удалены. -

-
- - + +
+ + +

+ Вы уверены, что хотите удалить топик "{deleteModal().topic?.title}"? +

+

+ Это действие нельзя отменить. Все дочерние топики также будут удалены. +

+
+ + -
+
+
+ + {/* Модальное окно слияния тем */} + { + setMergeModal({ show: false }) + setSelectedTopics([]) + setGroupAction('') + }} + topics={rawTopics().filter(topic => selectedTopics().includes(topic.id))} + onSuccess={(message) => { + props.onSuccess(message) + setSelectedTopics([]) + setGroupAction('') + void loadTopics() + }} + onError={props.onError} + /> + + {/* Модальное окно назначения родителя */} + setSimpleParentModal({ show: false, topic: null })} + topic={simpleParentModal().topic} + allTopics={rawTopics()} + onSuccess={(message) => { + props.onSuccess(message) + setSimpleParentModal({ show: false, topic: null }) + void loadTopics() // Перезагружаем данные + }} + onError={props.onError} + /> ) } diff --git a/panel/styles/Form.module.css b/panel/styles/Form.module.css index 35a7c52d..b1033c29 100644 --- a/panel/styles/Form.module.css +++ b/panel/styles/Form.module.css @@ -439,3 +439,736 @@ flex-direction: column-reverse; } } + +/* Стили для модального окна слияния топиков */ +.checkboxList { + max-height: 300px; + overflow-y: auto; + border: 1px solid #e9ecef; + border-radius: 6px; + padding: 8px; + background: #fafafa; +} + +.checkboxItem { + display: flex; + align-items: flex-start; + gap: 12px; + padding: 10px; + margin-bottom: 8px; + border-radius: 4px; + cursor: pointer; + transition: background-color 0.2s ease; +} + +.checkboxItem:hover { + background-color: #f0f0f0; +} + +.checkboxItem:last-child { + margin-bottom: 0; +} + +.checkboxContent { + flex: 1; +} + +.topicTitle { + font-weight: 600; + color: #333; + margin-bottom: 4px; +} + +.topicInfo { + font-size: 13px; + color: #666; + line-height: 1.4; +} + +.optionTitle { + font-weight: 500; + color: #333; + margin-bottom: 2px; +} + +.optionDescription { + font-size: 13px; + color: #666; + line-height: 1.4; +} + +.summary { + background: #f8f9fa; + border: 1px solid #e9ecef; + border-radius: 6px; + padding: 16px; + margin: 16px 0; +} + +.summary h4 { + margin: 0 0 12px 0; + color: #333; + font-size: 14px; + font-weight: 600; +} + +.summary ul { + margin: 0; + padding-left: 20px; +} + +.summary li { + margin-bottom: 8px; + font-size: 13px; + line-height: 1.4; +} + +.summary ul ul { + margin-top: 4px; +} + +.summary ul ul li { + margin-bottom: 4px; + color: #666; +} + +.section { + margin-bottom: 24px; +} + +.sectionTitle { + font-size: 16px; + font-weight: 600; + color: #333; + margin: 0 0 8px 0; +} + +.description { + font-size: 14px; + color: #666; + margin: 0 0 12px 0; + line-height: 1.4; +} + +/* Стили для управления иерархией топиков */ +.hierarchyContainer { + max-width: 800px; + margin: 0 auto; + padding: 20px; +} + +.instructions { + background: #f8f9fa; + border: 1px solid #e9ecef; + border-radius: 6px; + padding: 16px; + margin-bottom: 20px; +} + +.instructions h4 { + margin: 0 0 12px 0; + color: #495057; + font-size: 14px; + font-weight: 600; +} + +.instructions ul { + margin: 0; + padding-left: 20px; +} + +.instructions li { + margin-bottom: 4px; + font-size: 13px; + color: #6c757d; +} + +.hierarchyTree { + border: 1px solid #e9ecef; + border-radius: 6px; + background: white; + max-height: 400px; + overflow-y: auto; + padding: 8px; +} + +.treeNode { + margin-bottom: 2px; +} + +.treeItem { + padding: 8px 12px; + border-radius: 4px; + cursor: grab; + transition: all 0.2s ease; + user-select: none; +} + +.treeItem:hover { + background-color: #f8f9fa; +} + +.treeItem:active { + cursor: grabbing; +} + +.dropTarget { + background-color: #e3f2fd !important; + border: 2px dashed #2196f3 !important; +} + +.dragHandle { + color: #999; + font-weight: bold; + cursor: grab; + padding: 0 4px; +} + +.dragHandle:hover { + color: #666; +} + +.topicTitle { + font-weight: 500; + color: #333; + flex: 1; +} + +.topicId { + font-size: 12px; + color: #999; + background: #f8f9fa; + padding: 2px 6px; + border-radius: 3px; +} + +.treeChildren { + margin-left: 0; +} + +.changesSummary { + background: #fff3cd; + border: 1px solid #ffeaa7; + border-radius: 6px; + padding: 16px; + margin: 20px 0; +} + +.changesSummary h4 { + margin: 0 0 12px 0; + color: #856404; + font-size: 14px; +} + +.changeItem { + padding: 6px 0; + border-bottom: 1px solid #ffeaa7; + font-size: 13px; + color: #856404; +} + +.changeItem:last-child { + border-bottom: none; +} + +.dropZone { + margin: 20px 0; +} + +.rootDropZone { + border: 2px dashed #6c757d; + border-radius: 8px; + padding: 20px; + text-align: center; + color: #6c757d; + background: #f8f9fa; + transition: all 0.2s ease; + cursor: pointer; +} + +.rootDropZone:hover { + border-color: #007bff; + color: #007bff; + background: #e3f2fd; +} + +/* Дополнительные стили для режима большой модалки */ +.modal.large { + max-width: 900px; + width: 90vw; +} + +.modal.large .modalContent { + min-height: 600px; +} + +/* Адаптивность для мобильных */ +@media (max-width: 768px) { + .hierarchyContainer { + padding: 12px; + } + + .treeItem { + padding: 6px 8px; + font-size: 14px; + } + + .dragHandle { + display: none; + } + + .topicTitle { + font-size: 14px; + } + + .rootDropZone { + padding: 16px; + font-size: 14px; + } +} + +/* Стили для выбора родительской темы */ +.parentSelectorContainer { + max-width: 600px; + margin: 0 auto; + padding: 20px; +} + +.searchSection { + margin-bottom: 20px; +} + +.searchInput { + width: 100%; + padding: 8px 12px; + border: 1px solid #ddd; + border-radius: 4px; + font-size: 14px; +} + +.currentSelection { + background: #f8f9fa; + border: 1px solid #e9ecef; + border-radius: 6px; + padding: 16px; + margin-bottom: 20px; +} + +.currentParent { + margin-top: 8px; +} + +.noParent { + color: #6c757d; + font-style: italic; +} + +.parentPath { + font-weight: 500; + color: #495057; +} + +.parentOptions { + margin-bottom: 20px; +} + +.parentsList { + max-height: 300px; + overflow-y: auto; + border: 1px solid #e9ecef; + border-radius: 6px; + background: white; +} + +.parentOption { + display: flex; + align-items: flex-start; + padding: 12px; + border-bottom: 1px solid #f1f3f4; + transition: background-color 0.2s ease; +} + +.parentOption:last-child { + border-bottom: none; +} + +.parentOption:hover { + background-color: #f8f9fa; +} + +.parentOption input[type="radio"] { + margin-right: 12px; + margin-top: 4px; +} + +.parentOptionLabel { + flex: 1; + cursor: pointer; + display: block; +} + +.parentDescription { + margin-top: 4px; + font-size: 12px; + color: #6c757d; + display: flex; + gap: 12px; + align-items: center; +} + +.topicId { + background: #e9ecef; + padding: 2px 6px; + border-radius: 3px; + font-weight: 500; +} + +.topicSlug { + font-family: monospace; + background: #f8f9fa; + padding: 2px 6px; + border-radius: 3px; +} + +.noResults { + padding: 20px; + text-align: center; + color: #6c757d; + font-style: italic; +} + +/* Мобильная адаптивность */ +@media (max-width: 768px) { + .parentSelectorContainer { + padding: 12px; + } + + .parentOption { + padding: 8px; + } + + .parentDescription { + flex-direction: column; + align-items: flex-start; + gap: 4px; + } +} + +/* Стили для массового редактирования родителей */ +.bulkParentContainer { + max-width: 800px; + margin: 0 auto; + padding: 20px; +} + +.errorMessage { + background: #f8d7da; + color: #721c24; + border: 1px solid #f5c6cb; + border-radius: 6px; + padding: 12px; + margin-bottom: 20px; + font-size: 14px; +} + +.selectedTopicsPreview { + background: #f8f9fa; + border: 1px solid #e9ecef; + border-radius: 6px; + padding: 16px; + margin-bottom: 20px; +} + +.selectedTopicsPreview h4 { + margin: 0 0 12px 0; + color: #495057; + font-size: 14px; +} + +.topicsList { + max-height: 150px; + overflow-y: auto; +} + +.topicPreviewItem { + padding: 8px 12px; + border: 1px solid #e9ecef; + border-radius: 4px; + margin-bottom: 8px; + background: white; + display: flex; + align-items: center; + gap: 12px; +} + +.topicPreviewItem:last-child { + margin-bottom: 0; +} + +.currentPath { + font-size: 12px; + color: #6c757d; + margin-top: 4px; + width: 100%; +} + +.actionSelection { + margin-bottom: 20px; +} + +.actionSelection h4 { + margin: 0 0 12px 0; + color: #495057; + font-size: 14px; +} + +.actionOptions { + display: flex; + flex-direction: column; + gap: 12px; +} + +.actionOption { + display: flex; + align-items: flex-start; + padding: 12px; + border: 1px solid #e9ecef; + border-radius: 6px; + background: white; + transition: background-color 0.2s ease; +} + +.actionOption:hover { + background-color: #f8f9fa; +} + +.actionOption input[type="radio"] { + margin-right: 12px; + margin-top: 4px; +} + +.actionLabel { + flex: 1; + cursor: pointer; + display: block; +} + +.actionDescription { + margin-top: 4px; + font-size: 13px; + color: #6c757d; +} + +.parentSelection { + margin-bottom: 20px; +} + +.parentSelection h4 { + margin: 0 0 12px 0; + color: #495057; + font-size: 14px; +} + +.previewSection { + background: #fff3cd; + border: 1px solid #ffeaa7; + border-radius: 6px; + padding: 16px; + margin-bottom: 20px; +} + +.previewSection h4 { + margin: 0 0 12px 0; + color: #856404; + font-size: 14px; +} + +.previewChanges { + max-height: 200px; + overflow-y: auto; +} + +.previewItem { + padding: 8px 0; + border-bottom: 1px solid #ffeaa7; +} + +.previewItem:last-child { + border-bottom: none; +} + +.previewChange { + margin-top: 4px; + font-size: 13px; + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.beforeState { + color: #6c757d; +} + +.arrow { + color: #007bff; + font-weight: bold; +} + +.afterState { + color: #28a745; + font-weight: 500; +} + +/* Адаптивность для мобильных */ +@media (max-width: 768px) { + .bulkParentContainer { + padding: 12px; + } + + .actionOptions { + gap: 8px; + } + + .actionOption { + padding: 8px; + } + + .topicPreviewItem { + flex-direction: column; + align-items: flex-start; + gap: 4px; + } + + .previewChange { + flex-direction: column; + align-items: flex-start; + gap: 4px; + } +} + +/* Новые стили для кликабельного интерфейса */ +.selectedForMove { + border-color: #007bff !important; + background-color: #e3f2fd !important; + box-shadow: 0 0 8px rgba(0, 123, 255, 0.3); +} + +.moveTarget { + border-color: #28a745 !important; + background-color: #d4edda !important; +} + +.selectedIcon, .targetIcon, .clickIcon { + font-size: 16px; + margin-right: 4px; +} + +.selectedIcon { + animation: pulse 1s infinite; +} + +.targetIcon { + animation: bounce 0.8s infinite; +} + +.clickIcon { + opacity: 0.6; +} + +@keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.5; } +} + +@keyframes bounce { + 0%, 100% { transform: translateY(0); } + 50% { transform: translateY(-2px); } +} + +.searchSection { + margin: 20px 0; + padding: 15px; + background: #f8f9fa; + border-radius: 8px; + border: 1px solid #ddd; +} + +.searchInput { + width: 100%; + padding: 8px 12px; + border: 1px solid #ddd; + border-radius: 4px; + font-size: 14px; + margin-bottom: 8px; +} + +.searchResult { + color: #28a745; + font-size: 14px; + padding: 4px 0; +} + +.searchNoResult { + color: #dc3545; + font-size: 14px; + padding: 4px 0; +} + +.actionZone { + margin: 20px 0; + padding: 20px; + background: #e3f2fd; + border: 2px solid #007bff; + border-radius: 8px; +} + +.selectedTopicInfo h4 { + margin: 0 0 10px 0; + color: #007bff; + font-size: 16px; +} + +.selectedTopicDisplay { + background: white; + padding: 10px; + border-radius: 4px; + margin-bottom: 15px; + font-weight: 500; +} + +.actionButtons { + display: flex; + gap: 10px; + flex-wrap: wrap; +} + +.rootButton { + background: #28a745; + color: white; + border: none; + padding: 8px 16px; + border-radius: 4px; + cursor: pointer; + font-size: 14px; + transition: background 0.2s ease; +} + +.rootButton:hover { + background: #218838; +} + +.cancelButton { + background: #6c757d; + color: white; + border: none; + padding: 8px 16px; + border-radius: 4px; + cursor: pointer; + font-size: 14px; + transition: background 0.2s ease; +} + +.cancelButton:hover { + background: #5a6268; +} diff --git a/panel/styles/Table.module.css b/panel/styles/Table.module.css index 65b4d705..bee76405 100644 --- a/panel/styles/Table.module.css +++ b/panel/styles/Table.module.css @@ -269,3 +269,152 @@ background-color: #e9a8ae; cursor: not-allowed; } + +/* Новые стили для улучшенной панели поиска */ +.searchSection { + background: #f8f9fa; + border: 1px solid #e9ecef; + border-radius: 8px; + padding: 16px; + margin-bottom: 20px; +} + +.searchRow { + margin-bottom: 12px; +} + +.fullWidthSearch { + width: 100%; + padding: 12px 16px; + border: 1px solid #ced4da; + border-radius: 6px; + font-size: 14px; + background: white; + transition: border-color 0.2s ease, box-shadow 0.2s ease; +} + +.fullWidthSearch:focus { + outline: none; + border-color: #4f46e5; + box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.1); +} + +.fullWidthSearch::placeholder { + color: #6c757d; + font-style: italic; +} + +.filtersRow { + display: flex; + gap: 12px; + align-items: center; + flex-wrap: wrap; +} + +.statusFilter { + padding: 8px 12px; + border: 1px solid #ced4da; + border-radius: 4px; + font-size: 14px; + background: white; + color: #495057; + cursor: pointer; + min-width: 140px; +} + +.statusFilter:focus { + outline: none; + border-color: #4f46e5; +} + +/* Стили для сортируемых заголовков */ +.sortableHeader { + cursor: pointer; + user-select: none; + transition: background-color 0.2s ease; + position: relative; +} + +.sortableHeader:hover { + background-color: #e9ecef !important; +} + +.headerContent { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + gap: 8px; +} + +.sortIcon { + font-size: 12px; + color: #6c757d; + margin-left: auto; + min-width: 16px; + text-align: center; + opacity: 0.7; + transition: opacity 0.2s ease; +} + +.sortableHeader:hover .sortIcon { + opacity: 1; +} + +.sortableHeader[data-active="true"] .sortIcon { + color: #4f46e5; + opacity: 1; + font-weight: bold; +} + +/* Улучшенные адаптивные стили */ +@media (max-width: 768px) { + .filtersRow { + flex-direction: column; + align-items: stretch; + } + + .statusFilter { + min-width: auto; + } + + .headerContent { + font-size: 12px; + } + + .sortIcon { + font-size: 10px; + } +} + +@media (max-width: 480px) { + .searchSection { + padding: 12px; + } + + .fullWidthSearch { + padding: 10px 12px; + font-size: 13px; + } + + .filtersRow { + gap: 8px; + } +} + +/* Улучшения существующих стилей */ +.controls { + display: flex; + gap: 12px; + align-items: center; + flex-wrap: wrap; +} + +.searchInput { + padding: 8px 12px; + border: 1px solid #ced4da; + border-radius: 4px; + font-size: 14px; + min-width: 200px; + flex: 1; +} diff --git a/resolvers/__init__.py b/resolvers/__init__.py index 3aca8b56..3bb2db96 100644 --- a/resolvers/__init__.py +++ b/resolvers/__init__.py @@ -76,6 +76,8 @@ from resolvers.topic import ( get_topics_all, get_topics_by_author, get_topics_by_community, + merge_topics, + set_topic_parent, ) events_register() @@ -145,6 +147,7 @@ __all__ = [ "load_shouts_unrated", "load_shouts_with_topic", "login", + "merge_topics", "notification_mark_seen", "notifications_seen_after", "notifications_seen_thread", @@ -153,6 +156,7 @@ __all__ = [ "rate_author", "register_by_email", "send_link", + "set_topic_parent", "unfollow", "unpublish_draft", "unpublish_shout", diff --git a/resolvers/topic.py b/resolvers/topic.py index 68c6f1f0..a3322c08 100644 --- a/resolvers/topic.py +++ b/resolvers/topic.py @@ -499,3 +499,299 @@ async def delete_topic_by_id(_: None, info: GraphQLResolveInfo, topic_id: int) - 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: + old_parent_ids: list[int] = list(topic.parent_ids or []) + 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'а + descendants = session.query(Topic).filter(Topic.parent_ids.op("@>")([potential_parent.id])).all() + + 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}"} diff --git a/schema/input.graphql b/schema/input.graphql index 72e2847b..3129f1d3 100644 --- a/schema/input.graphql +++ b/schema/input.graphql @@ -25,6 +25,12 @@ input TopicInput { parent_ids: [Int] } +input TopicMergeInput { + target_topic_id: Int! + source_topic_ids: [Int!]! + preserve_target_properties: Boolean +} + input DraftInput { id: Int # no created_at, updated_at, deleted_at, updated_by, deleted_by diff --git a/schema/mutation.graphql b/schema/mutation.graphql index 67ce8663..4f9edb71 100644 --- a/schema/mutation.graphql +++ b/schema/mutation.graphql @@ -37,6 +37,8 @@ type Mutation { update_topic(topic_input: TopicInput!): CommonResult! delete_topic(slug: String!): CommonResult! delete_topic_by_id(id: Int!): CommonResult! + merge_topics(merge_input: TopicMergeInput!): CommonResult! + set_topic_parent(topic_id: Int!, parent_id: Int): CommonResult! # reaction create_reaction(reaction: ReactionInput!): CommonResult! diff --git a/schema/type.graphql b/schema/type.graphql index 0cb21b60..2d5877a5 100644 --- a/schema/type.graphql +++ b/schema/type.graphql @@ -201,6 +201,8 @@ type Topic { type CommonResult { error: String + message: String + stats: String drafts: [Draft] draft: Draft slugs: [String]