diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0f0f8abb..809171a7 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,60 @@
# Changelog
+## [0.7.7] - 2025-01-02
+
+### Обновлена система RBAC для топиков
+
+#### Новые разрешения для топиков
+- **ДОБАВЛЕНО**: Новое разрешение `topic:merge` для слияния топиков
+- **ДОБАВЛЕНО**: Разрешение `topic:create` для роли `editor`
+- **ДОБАВЛЕНО**: Разрешения `topic:update_own`, `topic:delete_own` для роли `author`
+- **ДОБАВЛЕНО**: Разрешение `topic:merge` для роли `editor`
+
+#### Обновленные резолверы мутаций топиков
+- **ИЗМЕНЕНО**: `create_topic` теперь требует `topic:create` вместо `@login_required`
+- **ИЗМЕНЕНО**: `update_topic` теперь требует `topic:update_own` ИЛИ `topic:update_any`
+- **ИЗМЕНЕНО**: `delete_topic` теперь требует `topic:delete_own` ИЛИ `topic:delete_any`
+- **ИЗМЕНЕНО**: `delete_topic_by_id` теперь требует `topic:delete_own` ИЛИ `topic:delete_any`
+- **ИЗМЕНЕНО**: `merge_topics` теперь требует `topic:merge` вместо `@login_required`
+- **ИЗМЕНЕНО**: `set_topic_parent` теперь требует `topic:update_own` ИЛИ `topic:update_any`
+
+#### Обновленная документация
+- **ОБНОВЛЕНО**: Добавлена таблица прав на топики в `docs/rbac-system.md`
+- **ОБНОВЛЕНО**: Добавлены примеры использования декораторов для топиков
+- **ОБНОВЛЕНО**: Уточнена информация о иерархии ролей и их правах
+
+#### Безопасность
+- **УЛУЧШЕНО**: Теперь все мутации топиков требуют соответствующих разрешений
+- **УЛУЧШЕНО**: Разграничение прав между собственными и чужими топиками
+- **УЛУЧШЕНО**: Специальное право на слияние топиков только для редакторов
+
+## [0.7.6] - 2025-07-02
+
+### Добавлена функциональность слияния топиков в админ-панели
+
+#### Новый административный резолвер adminMergeTopics
+- **ДОБАВЛЕНО**: Новая мутация `adminMergeTopics` для слияния топиков через админ-панель:
+ - **Функциональность**: Полное слияние топиков с переносом всех связанных данных
+ - **Перенос подписчиков**: Все подписчики из исходных топиков переносятся в целевой топик
+ - **Перенос публикаций**: Все публикации (ShoutTopic) из исходных топиков переносятся в целевой
+ - **Перенос черновиков**: Все черновики (DraftTopic) из исходных топиков переносятся в целевой
+ - **Обновление иерархии**: Дочерние топики получают новые parent_ids с заменой исходных на целевой
+ - **Валидация**: Проверка принадлежности всех топиков к одному сообществу
+ - **Дедупликация**: Предотвращение дублирования подписчиков и публикаций
+ - **Статистика**: Детальная статистика о количестве перенесенных данных
+
+#### Обновленная схема GraphQL
+- **ДОБАВЛЕНО**: Новая мутация `adminMergeTopics` в схеме `admin.graphql`
+- **ДОБАВЛЕНО**: Новый тип `TopicMergeInput` в схеме `input.graphql`
+
+#### Исправлены ошибки логгирования
+- **ИСПРАВЛЕНО**: Устранены ошибки `TypeError: not all arguments converted during string formatting`
+- **ИСПРАВЛЕНО**: Некорректные вызовы `logger.error()` в админ-резолверах
+
+#### Инфраструктура
+- **ОБНОВЛЕНО**: Добавлена документация по новому функционалу в `CHANGELOG.md`
+- **ОБНОВЛЕНО**: Импорты для работы с ORM моделями в админ-резолверах
+
## [0.7.5] - 2025-07-02
### Исправление критических проблем админ-панели
@@ -478,7 +533,7 @@
- **Оптимизированный скролл**: Эффективная синхронизация между элементами
- **Уменьшенные перерисовки**: Минимизация DOM манипуляций
-- **ACCESSIBILITY И СОВРЕМЕННЫЕ СТАНДАРЫ**:
+- **ACCESSIBILITY И СОВРЕМЕННЫЕ СТАНДАРТЫ**:
- **ARIA атрибуты**: Правильная семантическая разметка
- **Клавиатурная навигация**: Полная поддержка навигации с клавиатуры
- **Читаемые фокусные состояния**: Четкие индикаторы фокуса
diff --git a/default_role_permissions.json b/default_role_permissions.json
index 8b3f6485..ba3ab043 100644
--- a/default_role_permissions.json
+++ b/default_role_permissions.json
@@ -87,8 +87,10 @@
"editor": [
"shout:delete_any",
"shout:update_any",
- "topic:delete_any",
- "topic:update_any",
+ "topic:create",
+ "topic:delete_own",
+ "topic:update_own",
+ "topic:merge",
"reaction:delete_any:*",
"reaction:update_any:*",
"invite:delete_any",
diff --git a/docs/README.md b/docs/README.md
index 15f64bcf..b1daa872 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -18,6 +18,7 @@ python dev.py
- [Архитектура](auth-architecture.md) - Диаграммы и схемы
- [Миграция](auth-migration.md) - Переход на новую версию
- [Безопасность](security.md) - Пароли, email, RBAC
+- [Система RBAC](rbac-system.md) - Роли, разрешения, топики
- [OAuth](oauth.md) - Google, GitHub, Facebook, X, Telegram, VK, Yandex
- [OAuth настройка](oauth-setup.md) - Инструкции по настройке OAuth провайдеров
@@ -52,7 +53,8 @@ python dev.py
### Авторизация
- **Модульная архитектура**: SessionTokenManager, VerificationTokenManager, OAuthTokenManager
- **OAuth провайдеры**: 7 поддерживаемых провайдеров с PKCE
-- **RBAC**: user/moderator/admin роли
+- **RBAC**: Система ролей reader/author/artist/expert/editor/admin с наследованием
+- **Права на топики**: Специальные разрешения для создания, редактирования и слияния топиков
- **Производительность**: 50% ускорение Redis, 30% меньше памяти
### Nginx (упрощенная конфигурация)
diff --git a/docs/auth.md b/docs/auth.md
index fdc09669..804029ff 100644
--- a/docs/auth.md
+++ b/docs/auth.md
@@ -255,7 +255,6 @@ export const AdminPanel: Component = () => {
return (
-
Панель администратора
{/* Контент админки */}
)
diff --git a/docs/rbac-system.md b/docs/rbac-system.md
index 8070a26c..c4259184 100644
--- a/docs/rbac-system.md
+++ b/docs/rbac-system.md
@@ -132,18 +132,28 @@ reader → author → artist → expert → editor → admin
| Роль | Базовые права | Дополнительные права |
|------|---------------|---------------------|
-| `reader` | `*:read`, базовые реакции | `chat:*`, `message:*` |
+| `reader` | `*:read`, базовые реакции | `chat:*`, `message:*`, `bookmark:*` |
| `author` | Наследует `reader` + `*:create`, `*:update_own`, `*:delete_own` | `draft:*` |
| `artist` | Наследует `author` | `reaction:CREDIT:accept`, `reaction:CREDIT:decline` |
| `expert` | Наследует `author` | `reaction:PROOF:*`, `reaction:DISPROOF:*`, `reaction:AGREE:*`, `reaction:DISAGREE:*` |
-| `editor` | `*:read`, `*:create`, `*:update_any`, `*:delete_any` | `community:read`, `community:update_own` |
+| `editor` | `*:read`, `*:create`, `*:update_any`, `*:delete_any` | `community:read`, `community:update_own`, `topic:merge`, `topic:create`, `topic:update_own`, `topic:delete_own` |
| `admin` | Все права (`*`) | Полный доступ ко всем функциям |
### Формат разрешений
-- Базовые: `:` (например: `shout:create`)
+- Базовые: `:` (например: `shout:create`, `topic:create`)
- Реакции: `reaction::` (например: `reaction:LIKE:create`)
+- Специальные: `topic:merge` (слияние топиков)
- Wildcard: `:*` или `*` (только для admin)
+### Права на топики
+- `topic:create` - создание новых топиков (роли: `author`, `editor`)
+- `topic:read` - чтение топиков (роли: `reader` и выше)
+- `topic:update_own` - обновление собственных топиков (роли: `author`)
+- `topic:update_any` - обновление любых топиков (роли: `editor`)
+- `topic:delete_own` - удаление собственных топиков (роли: `author`)
+- `topic:delete_any` - удаление любых топиков (роли: `editor`)
+- `topic:merge` - слияние топиков (роли: `editor`)
+
## GraphQL API
### Запросы
@@ -243,6 +253,18 @@ from resolvers.rbac import (
async def create_shout(self, info: GraphQLResolveInfo, **kwargs):
# Только пользователи с правом создания статей
return await self._create_shout_logic(**kwargs)
+
+@mutation.field("create_topic")
+@require_permission("topic:create")
+async def create_topic(self, info: GraphQLResolveInfo, topic_input: dict):
+ # Только пользователи с правом создания топиков (author, editor)
+ return await self._create_topic_logic(topic_input)
+
+@mutation.field("merge_topics")
+@require_permission("topic:merge")
+async def merge_topics(self, info: GraphQLResolveInfo, merge_input: dict):
+ # Только пользователи с правом слияния топиков (editor)
+ return await self._merge_topics_logic(merge_input)
```
#### Проверка любого из разрешений (OR логика)
@@ -252,6 +274,18 @@ async def create_shout(self, info: GraphQLResolveInfo, **kwargs):
async def update_shout(self, info: GraphQLResolveInfo, shout_id: int, **kwargs):
# Может редактировать свои статьи ИЛИ любые статьи
return await self._update_shout_logic(shout_id, **kwargs)
+
+@mutation.field("update_topic")
+@require_any_permission(["topic:update_own", "topic:update_any"])
+async def update_topic(self, info: GraphQLResolveInfo, topic_input: dict):
+ # Может редактировать свои топики ИЛИ любые топики
+ return await self._update_topic_logic(topic_input)
+
+@mutation.field("delete_topic")
+@require_any_permission(["topic:delete_own", "topic:delete_any"])
+async def delete_topic(self, info: GraphQLResolveInfo, topic_id: int):
+ # Может удалять свои топики ИЛИ любые топики
+ return await self._delete_topic_logic(topic_id)
```
#### Проверка конкретной роли
diff --git a/panel/context/data.tsx b/panel/context/data.tsx
index c33e8ee0..81d2e392 100644
--- a/panel/context/data.tsx
+++ b/panel/context/data.tsx
@@ -1,4 +1,5 @@
import { createContext, createEffect, createSignal, JSX, onMount, useContext } from 'solid-js'
+import { query } from '../graphql'
import {
ADMIN_GET_ROLES_QUERY,
ADMIN_GET_TOPICS_QUERY,
@@ -42,6 +43,7 @@ interface DataContextType {
// Топики
topics: () => Topic[]
allTopics: () => Topic[]
+ setTopics: (topics: Topic[]) => void
getTopicById: (id: number) => Topic | undefined
getTopicTitle: (id: number) => string
loadTopicsByCommunity: (communityId: number) => Promise
@@ -69,6 +71,7 @@ const DataContext = createContext({
// Топики
topics: () => [],
allTopics: () => [],
+ setTopics: () => {},
getTopicById: () => undefined,
getTopicTitle: () => '',
loadTopicsByCommunity: async () => [],
@@ -95,6 +98,13 @@ export function DataProvider(props: { children: JSX.Element }) {
const [allTopics, setAllTopics] = createSignal([])
const [roles, setRoles] = createSignal([])
+ // Обертка для setTopics с логированием
+ const setTopicsWithLogging = (newTopics: Topic[]) => {
+ console.log('[DataProvider] setTopics called with', newTopics.length, 'topics')
+ console.log('[DataProvider] Sample topic parent_ids:', newTopics.slice(0, 3).map(t => ({ id: t.id, title: t.title, parent_ids: t.parent_ids })))
+ setTopics(newTopics)
+ }
+
// Инициализация выбранного сообщества из localStorage
const initialCommunity = (() => {
try {
@@ -151,23 +161,12 @@ export function DataProvider(props: { children: JSX.Element }) {
// Загрузка сообществ
const loadCommunities = async () => {
try {
- const response = await fetch('/graphql', {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json'
- },
- body: JSON.stringify({
- query: GET_COMMUNITIES_QUERY
- })
- })
+ const result = await query<{ get_communities_all: Community[] }>(
+ `${location.origin}/graphql`,
+ GET_COMMUNITIES_QUERY
+ )
- const result = await response.json()
-
- if (result.errors) {
- throw new Error(result.errors[0].message)
- }
-
- const communitiesData = result.data.get_communities_all || []
+ const communitiesData = result.get_communities_all || []
setCommunities(communitiesData)
return communitiesData
} catch (error) {
@@ -179,24 +178,13 @@ export function DataProvider(props: { children: JSX.Element }) {
// Загрузка всех топиков
const loadTopics = async () => {
try {
- const response = await fetch('/graphql', {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json'
- },
- body: JSON.stringify({
- query: GET_TOPICS_QUERY
- })
- })
+ const result = await query<{ get_topics_all: Topic[] }>(
+ `${location.origin}/graphql`,
+ GET_TOPICS_QUERY
+ )
- const result = await response.json()
-
- if (result.errors) {
- throw new Error(result.errors[0].message)
- }
-
- const topicsData = result.data.get_topics_all || []
- setTopics(topicsData)
+ const topicsData = result.get_topics_all || []
+ setTopicsWithLogging(topicsData)
return topicsData
} catch (error) {
console.error('Ошибка загрузки топиков:', error)
@@ -210,29 +198,16 @@ export function DataProvider(props: { children: JSX.Element }) {
setIsLoading(true)
// Используем админский резолвер для получения всех топиков без лимитов
- const response = await fetch('/graphql', {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json'
- },
- body: JSON.stringify({
- query: ADMIN_GET_TOPICS_QUERY,
- variables: {
- community_id: communityId
- }
- })
- })
+ const result = await query<{ adminGetTopics: Topic[] }>(
+ `${location.origin}/graphql`,
+ ADMIN_GET_TOPICS_QUERY,
+ { community_id: communityId }
+ )
- const result = await response.json()
-
- if (result.errors) {
- throw new Error(result.errors[0].message)
- }
-
- const allTopicsData = result.data.adminGetTopics || []
+ const allTopicsData = result.adminGetTopics || []
// Сохраняем все данные сразу для отображения
- setTopics(allTopicsData)
+ setTopicsWithLogging(allTopicsData)
setAllTopics(allTopicsData)
console.log(`[DataProvider] Загружено ${allTopicsData.length} топиков для сообщества ${communityId}`)
@@ -255,27 +230,13 @@ export function DataProvider(props: { children: JSX.Element }) {
const variables = communityId ? { community: communityId } : {}
- const response = await fetch('/graphql', {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json'
- },
- body: JSON.stringify({
- query: ADMIN_GET_ROLES_QUERY,
- variables
- })
- })
+ const result = await query<{ adminGetRoles: Role[] }>(
+ `${location.origin}/graphql`,
+ ADMIN_GET_ROLES_QUERY,
+ variables
+ )
- const result = await response.json()
- console.log('[DataProvider] Ответ от сервера для ролей:', result)
-
- if (result.errors) {
- console.warn('Не удалось загрузить роли (возможно не авторизован):', result.errors[0].message)
- setRoles([])
- return []
- }
-
- const rolesData = result.data.adminGetRoles || []
+ const rolesData = result.adminGetRoles || []
console.log('[DataProvider] Роли успешно загружены:', rolesData)
setRoles(rolesData)
return rolesData
@@ -316,7 +277,12 @@ export function DataProvider(props: { children: JSX.Element }) {
}
const getCommunityName = (id: number): string => getCommunityById(id)?.name || ''
- const getTopicTitle = (id: number): string => getTopicById(id)?.title || ''
+ const getTopicTitle = (id: number): string => {
+ const topic = getTopicById(id)
+ const title = topic?.title || ''
+ console.log(`[DataProvider] getTopicTitle(${id}) -> "${title}", parent_ids:`, topic?.parent_ids)
+ return title
+ }
// Методы для работы с топиками
const getTopicById = (id: number): Topic | undefined => {
@@ -344,6 +310,7 @@ export function DataProvider(props: { children: JSX.Element }) {
// Топики
topics,
allTopics,
+ setTopics: setTopicsWithLogging,
getTopicById,
getTopicTitle,
loadTopicsByCommunity,
@@ -357,26 +324,9 @@ export function DataProvider(props: { children: JSX.Element }) {
isLoading,
loadData,
// biome-ignore lint/suspicious/noExplicitAny: grahphql
- queryGraphQL: async (query: string, variables?: Record) => {
+ queryGraphQL: async (queryStr: string, variables?: Record) => {
try {
- const response = await fetch('/graphql', {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json'
- },
- body: JSON.stringify({
- query,
- variables
- })
- })
-
- const result = await response.json()
-
- if (result.errors) {
- throw new Error(result.errors[0].message)
- }
-
- return result.data
+ return await query(`${location.origin}/graphql`, queryStr, variables)
} catch (error) {
console.error('Ошибка выполнения GraphQL запроса:', error)
return null
diff --git a/panel/graphql/mutations.ts b/panel/graphql/mutations.ts
index a4545c10..dc3c5010 100644
--- a/panel/graphql/mutations.ts
+++ b/panel/graphql/mutations.ts
@@ -176,3 +176,37 @@ export const SET_TOPIC_PARENT_MUTATION = `
}
}
`
+
+export const ADMIN_UPDATE_TOPIC_MUTATION = `
+ mutation AdminUpdateTopic($topic: AdminTopicInput!) {
+ adminUpdateTopic(topic: $topic) {
+ success
+ error
+ topic {
+ id
+ title
+ slug
+ body
+ community
+ parent_ids
+ }
+ }
+ }
+`
+
+export const ADMIN_CREATE_TOPIC_MUTATION = `
+ mutation AdminCreateTopic($topic: AdminTopicInput!) {
+ adminCreateTopic(topic: $topic) {
+ success
+ error
+ topic {
+ id
+ title
+ slug
+ body
+ community
+ parent_ids
+ }
+ }
+ }
+`
diff --git a/panel/intl/i18n.tsx b/panel/intl/i18n.tsx
index e6b22941..582c3336 100644
--- a/panel/intl/i18n.tsx
+++ b/panel/intl/i18n.tsx
@@ -118,11 +118,6 @@ const AutoTranslator = (props: { children: JSX.Element; language: () => Language
'TH'
]
if (textElements.includes(element.tagName)) {
- // Более приоритетная обработка для кнопок
- if (element.tagName === 'BUTTON') {
- console.log(`👆 Проверка кнопки: "${element.textContent?.trim()}"`)
- }
-
// Ищем прямые текстовые узлы внутри элемента
const directTextNodes = Array.from(element.childNodes).filter(
(child) => child.nodeType === Node.TEXT_NODE && child.textContent?.trim()
@@ -147,9 +142,6 @@ const AutoTranslator = (props: { children: JSX.Element; language: () => Language
// Если у кнопки нет прямых текстовых узлов, но есть вложенные элементы
const buttonText = element.textContent?.trim()
if (buttonText && CYRILLIC_REGEX.test(buttonText)) {
- console.log(`🔍 Кнопка с вложенными элементами: "${buttonText}"`)
-
- // Проверяем, есть ли у кнопки value атрибут
const valueAttr = element.getAttribute('value')
if (valueAttr && CYRILLIC_REGEX.test(valueAttr)) {
const currentLang = props.language()
diff --git a/panel/intl/strings.json b/panel/intl/strings.json
index 4f63c792..5c6a94cf 100644
--- a/panel/intl/strings.json
+++ b/panel/intl/strings.json
@@ -1,5 +1,4 @@
{
- "Панель администратора": "Admin Panel",
"Выйти": "Logout",
"Авторы": "Authors",
"Публикации": "Publications",
@@ -10,7 +9,7 @@
"Переменные среды": "Environment Variables",
"Ошибка при выходе": "Logout error",
- "Вход в панель администратора": "Admin Panel Login",
+ "Вход": "Login",
"Имя пользователя": "Username",
"Пароль": "Password",
"Войти": "Login",
@@ -230,5 +229,9 @@
"123": "123",
"Введите содержимое media.body...": "Enter media.body content...",
"Поиск по названию, slug или ID...": "Search by title, slug or ID...",
- "Дискурс": "Discours"
+ "Дискурс": "Discours",
+
+ "Родительские топики отображаются вверху списка синим цветом. Кликните по топику чтобы добавить или убрать из выбранных.": "Parent topics are displayed at the top of the list in blue color. Click on the topic to add or remove from selected.",
+ "Выбрано:": "Selected:",
+ "Поиск по топикам...": "Search by topics..."
}
diff --git a/panel/modals/CollectionEditModal.tsx b/panel/modals/CollectionEditModal.tsx
index fd7c8b2b..9f43e16c 100644
--- a/panel/modals/CollectionEditModal.tsx
+++ b/panel/modals/CollectionEditModal.tsx
@@ -2,6 +2,7 @@ import { Component, createEffect, createSignal } from 'solid-js'
import formStyles from '../styles/Form.module.css'
import styles from '../styles/Modal.module.css'
import Button from '../ui/Button'
+import HTMLEditor from '../ui/HTMLEditor'
import Modal from '../ui/Modal'
interface Collection {
@@ -166,12 +167,9 @@ const CollectionEditModal: Component = (props) => {
Описание
-