props.roleSettings.available_roles.includes(role.id))}>
{(role) => (
{
// Исключаем запрещенные топики
if (props.excludeTopics?.length) {
- topics = topics.where((topic) => !props.excludeTopics!.includes(topic.id))
+ topics = topics.filter((topic) => !props.excludeTopics!.includes(topic.id))
}
// Фильтруем по поисковому запросу
const query = searchQuery().toLowerCase().trim()
if (query) {
- topics = topics.where(
+ topics = topics.filter(
(topic) => topic.title.toLowerCase().includes(query) || topic.slug.toLowerCase().includes(query)
)
}
@@ -138,7 +138,7 @@ const TopicPillsCloud = (props: TopicPillsCloudProps) => {
* Получить выбранные топики как объекты
*/
const selectedTopicObjects = createMemo(() => {
- return props.topics.where((topic) => props.selectedTopics.includes(topic.id))
+ return props.topics.filter((topic) => props.selectedTopics.includes(topic.id))
})
return (
diff --git a/panel/utils/auth.ts b/panel/utils/auth.ts
index 40d04a6e..67fc68f9 100644
--- a/panel/utils/auth.ts
+++ b/panel/utils/auth.ts
@@ -95,5 +95,15 @@ export function checkAuthStatus(): boolean {
console.log(`[Auth] Local token: ${hasLocalToken ? 'present' : 'missing'}`)
console.log(`[Auth] Authentication status: ${isAuth ? 'authenticated' : 'not authenticated'}`)
+ // Дополнительное логирование для диагностики
+ if (cookieToken) {
+ console.log(`[Auth] Cookie token length: ${cookieToken.length}`)
+ console.log(`[Auth] Cookie token preview: ${cookieToken.substring(0, 20)}...`)
+ }
+ if (localToken) {
+ console.log(`[Auth] Local token length: ${localToken.length}`)
+ console.log(`[Auth] Local token preview: ${localToken.substring(0, 20)}...`)
+ }
+
return isAuth
}
diff --git a/pyproject.toml b/pyproject.toml
index 379aeb9d..8531a90d 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -118,6 +118,7 @@ ignore = [
"F821", # use Set as type
"UP006", # use Set as type
"UP035", # use Set as type
+ "PERF401", # list comprehension - иногда нужно
"ANN201", # Missing return type annotation for private function `wrapper` - иногда нужно
]
diff --git a/requirements.dev.txt b/requirements.dev.txt
index 355b9f94..dcc39f17 100644
--- a/requirements.dev.txt
+++ b/requirements.dev.txt
@@ -5,3 +5,5 @@ pytest-cov
mypy
ruff
pre-commit
+playwright
+python-dotenv
diff --git a/resolvers/admin.py b/resolvers/admin.py
index d612f319..085e2df8 100644
--- a/resolvers/admin.py
+++ b/resolvers/admin.py
@@ -459,7 +459,30 @@ async def update_env_variables(_: None, _info: GraphQLResolveInfo, variables: li
async def admin_get_roles(_: None, _info: GraphQLResolveInfo, community: int | None = None) -> list[dict[str, Any]]:
"""Получает список ролей"""
try:
- return admin_service.get_roles(community)
+ # Получаем все роли (базовые + кастомные)
+ all_roles = admin_service.get_roles(community)
+
+ # Если указано сообщество, добавляем кастомные роли из Redis
+ if community:
+ import json
+
+ custom_roles_data = await redis.execute("HGETALL", f"community:custom_roles:{community}")
+
+ for role_id, role_json in custom_roles_data.items():
+ try:
+ role_data = json.loads(role_json)
+ all_roles.append(
+ {
+ "id": role_data["id"],
+ "name": role_data["name"],
+ "description": role_data.get("description", ""),
+ }
+ )
+ except (json.JSONDecodeError, KeyError) as e:
+ logger.warning(f"Ошибка парсинга роли {role_id}: {e}")
+ continue
+
+ return all_roles
except Exception as e:
logger.error(f"Ошибка получения ролей: {e}")
raise GraphQLError("Не удалось получить роли") from e
@@ -781,3 +804,96 @@ async def admin_restore_reaction(_: None, _info: GraphQLResolveInfo, reaction_id
except Exception as e:
logger.error(f"Ошибка восстановления реакции: {e}")
return {"success": False, "error": str(e)}
+
+
+@mutation.field("adminCreateCustomRole")
+@admin_auth_required
+async def admin_create_custom_role(_: None, _info: GraphQLResolveInfo, role: dict[str, Any]) -> dict[str, Any]:
+ """Создает новую роль для сообщества"""
+ try:
+ role_id = role.get("id")
+ name = role.get("name")
+ description = role.get("description")
+ icon = role.get("icon")
+ community_id = role.get("community_id")
+
+ if not role_id or not name or not community_id:
+ return {"success": False, "error": "Необходимо указать id, name и community_id роли"}
+
+ with local_session() as session:
+ # Проверяем, существует ли сообщество
+ community = session.query(Community).where(Community.id == community_id).first()
+ if not community:
+ return {"success": False, "error": "Сообщество не найдено"}
+
+ # Проверяем, не существует ли уже роль с таким id
+ existing_role = await redis.execute("HGET", f"community:custom_roles:{community_id}", role_id)
+ if existing_role:
+ return {"success": False, "error": "Роль с таким id уже существует"}
+
+ # Создаем новую роль
+ role_data = {
+ "id": role_id,
+ "name": name,
+ "description": description or "",
+ "icon": icon or "",
+ "permissions": [], # Пустой список разрешений для новой роли
+ }
+
+ # Сохраняем роль в Redis
+ import json
+
+ await redis.execute("HSET", f"community:custom_roles:{community_id}", role_id, json.dumps(role_data))
+
+ logger.info(f"Создана новая роль {role_id} для сообщества {community_id}")
+ return {"success": True, "role": {"id": role_id, "name": name, "description": description}}
+
+ except Exception as e:
+ logger.error(f"Ошибка создания роли: {e}")
+ return {"success": False, "error": str(e)}
+
+
+@mutation.field("adminDeleteCustomRole")
+@admin_auth_required
+async def admin_delete_custom_role(
+ _: None, _info: GraphQLResolveInfo, role_id: str, community_id: int
+) -> dict[str, Any]:
+ """Удаляет роль из сообщества"""
+ try:
+ with local_session() as session:
+ # Проверяем, существует ли сообщество
+ community = session.query(Community).where(Community.id == community_id).first()
+ if not community:
+ return {"success": False, "error": "Сообщество не найдено"}
+
+ # Проверяем, существует ли роль
+ existing_role = await redis.execute("HGET", f"community:custom_roles:{community_id}", role_id)
+ if not existing_role:
+ return {"success": False, "error": "Роль не найдена"}
+
+ # Удаляем роль из Redis
+ await redis.execute("HDEL", f"community:custom_roles:{community_id}", role_id)
+
+ logger.info(f"Удалена роль {role_id} из сообщества {community_id}")
+ return {"success": True}
+
+ except Exception as e:
+ logger.error(f"Ошибка удаления роли: {e}")
+ return {"success": False, "error": str(e)}
+
+
+@mutation.field("adminUpdatePermissions")
+@admin_auth_required
+async def admin_update_permissions(_: None, _info: GraphQLResolveInfo) -> dict[str, Any]:
+ """Обновляет права для всех сообществ с новыми дефолтными настройками"""
+ try:
+ from services.rbac import update_all_communities_permissions
+
+ await update_all_communities_permissions()
+
+ logger.info("Права для всех сообществ обновлены")
+ return {"success": True, "message": "Права обновлены для всех сообществ"}
+
+ except Exception as e:
+ logger.error(f"Ошибка обновления прав: {e}")
+ return {"success": False, "error": str(e)}
diff --git a/resolvers/collection.py b/resolvers/collection.py
index 0c7cfbb1..c5c61082 100644
--- a/resolvers/collection.py
+++ b/resolvers/collection.py
@@ -1,4 +1,4 @@
-from typing import Any, Optional
+from typing import Any
from graphql import GraphQLResolveInfo
from sqlalchemy.orm import joinedload
@@ -6,8 +6,8 @@ from sqlalchemy.orm import joinedload
from auth.decorators import editor_or_admin_required
from auth.orm import Author
from orm.collection import Collection, ShoutCollection
-from orm.community import CommunityAuthor
from services.db import local_session
+from services.rbac import require_any_permission
from services.schema import mutation, query, type_collection
from utils.logger import root_logger as logger
@@ -94,142 +94,71 @@ async def create_collection(_: None, info: GraphQLResolveInfo, collection_input:
author_id = auth_info.author_id
if not author_id:
- return {"error": "Не удалось определить автора"}
+ return {"error": "Не удалось определить автора", "success": False}
try:
with local_session() as session:
# Исключаем created_by из входных данных - он всегда из токена
filtered_input = {k: v for k, v in collection_input.items() if k != "created_by"}
- # Создаем новую коллекцию с обязательным created_by из токена
- new_collection = Collection(created_by=author_id, **filtered_input)
+ # Создаем новую коллекцию
+ new_collection = Collection(**filtered_input, created_by=author_id)
session.add(new_collection)
session.commit()
- return {"error": None}
+
+ return {"error": None, "success": True}
except Exception as e:
- return {"error": f"Ошибка создания коллекции: {e!s}"}
+ return {"error": f"Ошибка создания коллекции: {e!s}", "success": False}
@mutation.field("update_collection")
-@editor_or_admin_required
+@require_any_permission(["collection:update", "collection:update_any"])
async def update_collection(_: None, info: GraphQLResolveInfo, collection_input: dict[str, Any]) -> dict[str, Any]:
- """Обновляет существующую коллекцию"""
- # Получаем author_id из контекста через декоратор авторизации
- request = info.context.get("request")
- author_id = None
-
- if hasattr(request, "auth") and request.auth and hasattr(request.auth, "author_id"):
- author_id = request.auth.author_id
- elif hasattr(request, "scope") and "auth" in request.scope:
- auth_info = request.scope.get("auth", {})
- if isinstance(auth_info, dict):
- author_id = auth_info.get("author_id")
- elif hasattr(auth_info, "author_id"):
- author_id = auth_info.author_id
-
- if not author_id:
- return {"error": "Не удалось определить автора"}
-
- slug = collection_input.get("slug")
- if not slug:
- return {"error": "Не указан slug коллекции"}
+ if not collection_input.get("slug"):
+ return {"error": "Не указан slug коллекции", "success": False}
try:
with local_session() as session:
- # Находим коллекцию для обновления
- collection = session.query(Collection).where(Collection.slug == slug).first()
+ # Находим коллекцию по slug
+ collection = session.query(Collection).where(Collection.slug == collection_input["slug"]).first()
+
if not collection:
- return {"error": "Коллекция не найдена"}
-
- # Проверяем права на редактирование (создатель или админ/редактор)
- with local_session() as auth_session:
- # Получаем роли пользователя в сообществе
- community_author = (
- auth_session.query(CommunityAuthor)
- .where(
- CommunityAuthor.author_id == author_id,
- CommunityAuthor.community_id == 1, # Используем сообщество по умолчанию
- )
- .first()
- )
-
- user_roles = community_author.role_list if community_author else []
-
- # Разрешаем редактирование если пользователь - создатель или имеет роль admin/editor
- if collection.created_by != author_id and "admin" not in user_roles and "editor" not in user_roles:
- return {"error": "Недостаточно прав для редактирования этой коллекции"}
+ return {"error": "Коллекция не найдена", "success": False}
# Обновляем поля коллекции
for key, value in collection_input.items():
- # Исключаем изменение created_by - создатель не может быть изменен
if hasattr(collection, key) and key not in ["slug", "created_by"]:
setattr(collection, key, value)
session.commit()
- return {"error": None}
+ return {"error": None, "success": True}
except Exception as e:
- return {"error": f"Ошибка обновления коллекции: {e!s}"}
+ return {"error": f"Ошибка обновления коллекции: {e!s}", "success": False}
@mutation.field("delete_collection")
-@editor_or_admin_required
+@require_any_permission(["collection:delete", "collection:delete_any"])
async def delete_collection(_: None, info: GraphQLResolveInfo, slug: str) -> dict[str, Any]:
- """Удаляет коллекцию"""
- # Получаем author_id из контекста через декоратор авторизации
- request = info.context.get("request")
- author_id = None
-
- if hasattr(request, "auth") and request.auth and hasattr(request.auth, "author_id"):
- author_id = request.auth.author_id
- elif hasattr(request, "scope") and "auth" in request.scope:
- auth_info = request.scope.get("auth", {})
- if isinstance(auth_info, dict):
- author_id = auth_info.get("author_id")
- elif hasattr(auth_info, "author_id"):
- author_id = auth_info.author_id
-
- if not author_id:
- return {"error": "Не удалось определить автора"}
-
try:
with local_session() as session:
- # Находим коллекцию для удаления
+ # Находим коллекцию по slug
collection = session.query(Collection).where(Collection.slug == slug).first()
+
if not collection:
- return {"error": "Коллекция не найдена"}
-
- # Проверяем права на удаление (создатель или админ/редактор)
- with local_session() as auth_session:
- # Получаем роли пользователя в сообществе
- community_author = (
- auth_session.query(CommunityAuthor)
- .where(
- CommunityAuthor.author_id == author_id,
- CommunityAuthor.community_id == 1, # Используем сообщество по умолчанию
- )
- .first()
- )
-
- user_roles = community_author.role_list if community_author else []
-
- # Разрешаем удаление если пользователь - создатель или имеет роль admin/editor
- if collection.created_by != author_id and "admin" not in user_roles and "editor" not in user_roles:
- return {"error": "Недостаточно прав для удаления этой коллекции"}
-
- # Удаляем связи с публикациями
- session.query(ShoutCollection).where(ShoutCollection.collection == collection.id).delete()
+ return {"error": "Коллекция не найдена", "success": False}
# Удаляем коллекцию
session.delete(collection)
session.commit()
- return {"error": None}
+
+ return {"error": None, "success": True}
except Exception as e:
- return {"error": f"Ошибка удаления коллекции: {e!s}"}
+ return {"error": f"Ошибка удаления коллекции: {e!s}", "success": False}
@type_collection.field("created_by")
-def resolve_collection_created_by(obj: Collection, *_: Any) -> Optional[Author]:
- """Резолвер для поля created_by коллекции (может вернуть None)"""
+def resolve_collection_created_by(obj: Collection, *_: Any) -> Author:
+ """Резолвер для поля created_by коллекции"""
with local_session() as session:
if hasattr(obj, "created_by_author") and obj.created_by_author:
return obj.created_by_author
@@ -237,6 +166,13 @@ def resolve_collection_created_by(obj: Collection, *_: Any) -> Optional[Author]:
author = session.query(Author).where(Author.id == obj.created_by).first()
if not author:
logger.warning(f"Автор с ID {obj.created_by} не найден для коллекции {obj.id}")
+ # Возвращаем заглушку вместо None
+ return Author(
+ id=obj.created_by or 0,
+ name=f"Unknown User {obj.created_by or 0}",
+ slug=f"user-{obj.created_by or 0}",
+ email="unknown@example.com",
+ )
return author
diff --git a/resolvers/community.py b/resolvers/community.py
index d680a87b..60f47ded 100644
--- a/resolvers/community.py
+++ b/resolvers/community.py
@@ -1,14 +1,20 @@
+import traceback
from typing import Any
from graphql import GraphQLResolveInfo
from sqlalchemy import distinct, func
from auth.orm import Author
-from auth.permissions import ContextualPermissionCheck
from orm.community import Community, CommunityAuthor, CommunityFollower
from orm.shout import Shout, ShoutAuthor
from services.db import local_session
-from services.rbac import require_any_permission, require_permission
+from services.rbac import (
+ RBACError,
+ get_user_roles_from_context,
+ require_any_permission,
+ require_permission,
+ roles_have_permission,
+)
from services.schema import mutation, query, type_community
from utils.logger import root_logger as logger
@@ -93,71 +99,36 @@ async def create_community(_: None, info: GraphQLResolveInfo, community_input: d
author_id = auth_info.author_id
if not author_id:
- return {"error": "Не удалось определить автора"}
+ return {"error": "Не удалось определить автора", "success": False}
try:
with local_session() as session:
# Исключаем created_by из входных данных - он всегда из токена
filtered_input = {k: v for k, v in community_input.items() if k != "created_by"}
- # Создаем новое сообщество с обязательным created_by из токена
- new_community = Community(created_by=author_id, **filtered_input)
+ # Создаем новое сообщество
+ new_community = Community(**filtered_input, created_by=author_id)
session.add(new_community)
- session.flush() # Получаем ID сообщества
-
- # Инициализируем права ролей для нового сообщества
- await new_community.initialize_role_permissions()
-
session.commit()
- return {"error": None}
+
+ return {"error": None, "success": True}
except Exception as e:
- return {"error": f"Ошибка создания сообщества: {e!s}"}
+ return {"error": f"Ошибка создания сообщества: {e!s}", "success": False}
@mutation.field("update_community")
-@require_any_permission(["community:update_own", "community:update_any"])
+@require_any_permission(["community:update", "community:update_any"])
async def update_community(_: None, info: GraphQLResolveInfo, community_input: dict[str, Any]) -> dict[str, Any]:
- # Получаем author_id из контекста через декоратор авторизации
- request = info.context.get("request")
- author_id = None
-
- if hasattr(request, "auth") and request.auth and hasattr(request.auth, "author_id"):
- author_id = request.auth.author_id
- elif hasattr(request, "scope") and "auth" in request.scope:
- auth_info = request.scope.get("auth", {})
- if isinstance(auth_info, dict):
- author_id = auth_info.get("author_id")
- elif hasattr(auth_info, "author_id"):
- author_id = auth_info.author_id
-
- if not author_id:
- return {"error": "Не удалось определить автора"}
-
- slug = community_input.get("slug")
- if not slug:
- return {"error": "Не указан slug сообщества"}
+ if not community_input.get("slug"):
+ return {"error": "Не указан slug сообщества", "success": False}
try:
with local_session() as session:
- # Находим сообщество для обновления
- community = session.query(Community).where(Community.slug == slug).first()
+ # Находим сообщество по slug
+ community = session.query(Community).where(Community.slug == community_input["slug"]).first()
+
if not community:
- return {"error": "Сообщество не найдено"}
-
- # Проверяем права на редактирование (создатель или админ/редактор)
- with local_session() as auth_session:
- # Получаем роли пользователя в сообществе
- community_author = (
- auth_session.query(CommunityAuthor)
- .where(CommunityAuthor.author_id == author_id, CommunityAuthor.community_id == community.id)
- .first()
- )
-
- user_roles = community_author.role_list if community_author else []
-
- # Разрешаем редактирование если пользователь - создатель или имеет роль admin/editor
- if community.created_by != author_id and "admin" not in user_roles and "editor" not in user_roles:
- return {"error": "Недостаточно прав для редактирования этого сообщества"}
+ return {"error": "Сообщество не найдено", "success": False}
# Обновляем поля сообщества
for key, value in community_input.items():
@@ -166,40 +137,89 @@ async def update_community(_: None, info: GraphQLResolveInfo, community_input: d
setattr(community, key, value)
session.commit()
- return {"error": None}
+ return {"error": None, "success": True}
except Exception as e:
- return {"error": f"Ошибка обновления сообщества: {e!s}"}
+ return {"error": f"Ошибка обновления сообщества: {e!s}", "success": False}
@mutation.field("delete_community")
-@require_any_permission(["community:delete_own", "community:delete_any"])
async def delete_community(root, info, slug: str) -> dict[str, Any]:
try:
+ logger.info(f"[delete_community] Начинаем удаление сообщества с slug: {slug}")
+
+ # Находим community_id и устанавливаем в контекст для RBAC ПЕРЕД проверкой прав
+ with local_session() as session:
+ community = session.query(Community).where(Community.slug == slug).first()
+ if community:
+ logger.debug(f"[delete_community] Тип info.context: {type(info.context)}, содержимое: {info.context!r}")
+ if isinstance(info.context, dict):
+ info.context["community_id"] = community.id
+ else:
+ logger.error(
+ f"[delete_community] Неожиданный тип контекста: {type(info.context)}. Попытка присвоить community_id через setattr."
+ )
+ info.context.community_id = community.id
+ logger.debug(f"[delete_community] Установлен community_id в контекст: {community.id}")
+ else:
+ logger.warning(f"[delete_community] Сообщество с slug '{slug}' не найдено")
+ return {"error": "Сообщество не найдено", "success": False}
+
+ # Теперь проверяем права с правильным community_id
+ user_roles, community_id = get_user_roles_from_context(info)
+ logger.debug(f"[delete_community] user_roles: {user_roles}, community_id: {community_id}")
+
+ has_permission = False
+ for permission in ["community:delete", "community:delete_any"]:
+ if await roles_have_permission(user_roles, permission, community_id):
+ has_permission = True
+ break
+
+ if not has_permission:
+ raise RBACError("Недостаточно прав. Требуется любое из: ", ["community:delete", "community:delete_any"])
+
# Используем local_session как контекстный менеджер
with local_session() as session:
# Находим сообщество по slug
community = session.query(Community).where(Community.slug == slug).first()
if not community:
+ logger.warning(f"[delete_community] Сообщество с slug '{slug}' не найдено")
return {"error": "Сообщество не найдено", "success": False}
- # Проверяем права на удаление
- user_id = info.context.get("user_id", 0)
- permission_check = ContextualPermissionCheck()
+ logger.info(f"[delete_community] Найдено сообщество: id={community.id}, name={community.name}")
- # Проверяем права на удаление сообщества
- if not await permission_check.can_delete_community(user_id, community, session):
- return {"error": "Недостаточно прав", "success": False}
+ # Проверяем связанные записи
+ followers_count = (
+ session.query(CommunityFollower).where(CommunityFollower.community == community.id).count()
+ )
+ authors_count = session.query(CommunityAuthor).where(CommunityAuthor.community_id == community.id).count()
+ shouts_count = session.query(Shout).where(Shout.community == community.id).count()
+
+ logger.info(
+ f"[delete_community] Связанные записи: followers={followers_count}, authors={authors_count}, shouts={shouts_count}"
+ )
+
+ # Удаляем связанные записи
+ if followers_count > 0:
+ logger.info(f"[delete_community] Удаляем {followers_count} подписчиков")
+ session.query(CommunityFollower).where(CommunityFollower.community == community.id).delete()
+
+ if authors_count > 0:
+ logger.info(f"[delete_community] Удаляем {authors_count} авторов")
+ session.query(CommunityAuthor).where(CommunityAuthor.community_id == community.id).delete()
# Удаляем сообщество
+ logger.info(f"[delete_community] Удаляем сообщество {community.id}")
session.delete(community)
session.commit()
+ logger.info(f"[delete_community] Сообщество {community.id} успешно удалено")
return {"success": True, "error": None}
except Exception as e:
# Логируем ошибку
- logger.error(f"Ошибка удаления сообщества: {e}")
+ logger.error(f"[delete_community] Ошибка удаления сообщества: {e}")
+ logger.error(f"[delete_community] Traceback: {traceback.format_exc()}")
return {"error": str(e), "success": False}
@@ -245,3 +265,23 @@ def resolve_community_stat(community: Community | dict[str, Any], *_: Any) -> di
logger.error(f"Ошибка при получении статистики сообщества {community_id}: {e}")
# Возвращаем нулевую статистику при ошибке
return {"shouts": 0, "followers": 0, "authors": 0}
+
+
+@type_community.field("created_by")
+def resolve_community_created_by(community: Community, *_: Any) -> Author | None:
+ """
+ Резолвер для поля created_by сообщества.
+ Возвращает автора-создателя сообщества или None, если создатель не найден.
+ """
+ with local_session() as session:
+ # Если у сообщества нет created_by, возвращаем None
+ if not community.created_by:
+ return None
+
+ # Ищем автора в базе данных
+ author = session.query(Author).where(Author.id == community.created_by).first()
+ if not author:
+ logger.warning(f"Автор с ID {community.created_by} не найден для сообщества {community.id}")
+ return None
+
+ return author
diff --git a/resolvers/reaction.py b/resolvers/reaction.py
index 44ef9935..1fe27a51 100644
--- a/resolvers/reaction.py
+++ b/resolvers/reaction.py
@@ -103,7 +103,21 @@ def get_reactions_with_stat(q: Select, limit: int = 10, offset: int = 0) -> list
# Преобразуем Reaction в словарь для доступа по ключу
reaction_dict = reaction.dict()
- reaction_dict["created_by"] = author.dict()
+
+ # Обработка поля created_by
+ if author:
+ reaction_dict["created_by"] = author.dict()
+ else:
+ # Если автор не найден, создаем заглушку
+ logger.warning(f"Автор не найден для реакции {reaction.id}")
+ reaction_dict["created_by"] = {
+ "id": reaction.created_by or 0,
+ "name": f"Unknown User {reaction.created_by or 0}",
+ "slug": f"user-{reaction.created_by or 0}",
+ "email": "unknown@example.com",
+ "created_at": 0,
+ }
+
reaction_dict["shout"] = shout.dict()
reaction_dict["stat"] = {"rating": rating_stat, "comments_count": comments_count}
reactions.append(reaction_dict)
diff --git a/resolvers/reader.py b/resolvers/reader.py
index 26f5a4a0..7f06a622 100644
--- a/resolvers/reader.py
+++ b/resolvers/reader.py
@@ -220,15 +220,34 @@ def get_shouts_with_links(info: GraphQLResolveInfo, q: Select, limit: int = 20,
shout_dict = shout.dict()
# Обработка поля created_by
- if has_field(info, "created_by") and shout_dict.get("created_by"):
+ if has_field(info, "created_by"):
main_author_id = shout_dict.get("created_by")
- a = session.query(Author).where(Author.id == main_author_id).first()
- if a:
+ if main_author_id:
+ a = session.query(Author).where(Author.id == main_author_id).first()
+ if a:
+ shout_dict["created_by"] = {
+ "id": main_author_id,
+ "name": a.name,
+ "slug": a.slug or f"user-{main_author_id}",
+ "pic": a.pic,
+ }
+ else:
+ # Если автор не найден, создаем заглушку
+ logger.warning(f"Автор с ID {main_author_id} не найден для shout {shout_id}")
+ shout_dict["created_by"] = {
+ "id": main_author_id,
+ "name": f"Unknown User {main_author_id}",
+ "slug": f"user-{main_author_id}",
+ "pic": None,
+ }
+ else:
+ # Если created_by не указан, создаем заглушку
+ logger.warning(f"created_by не указан для shout {shout_id}")
shout_dict["created_by"] = {
- "id": main_author_id,
- "name": a.name,
- "slug": a.slug or f"user-{main_author_id}",
- "pic": a.pic,
+ "id": 0,
+ "name": "Unknown User",
+ "slug": "unknown",
+ "pic": None,
}
# Обработка поля updated_by
diff --git a/resolvers/topic.py b/resolvers/topic.py
index 40a4fe8a..ad5bf1b2 100644
--- a/resolvers/topic.py
+++ b/resolvers/topic.py
@@ -397,68 +397,77 @@ async def get_topic(_: None, _info: GraphQLResolveInfo, slug: str) -> Optional[A
@mutation.field("create_topic")
@require_permission("topic:create")
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()
+ try:
+ with local_session() as session:
+ # TODO: проверить права пользователя на создание темы для конкретного сообщества
+ # и разрешение на создание
+ new_topic = Topic(**topic_input)
+ session.add(new_topic)
+ session.commit()
- # Инвалидируем кеш всех тем
- await invalidate_topics_cache()
+ # Инвалидируем кеш всех тем
+ await invalidate_topics_cache()
- return {"topic": new_topic}
+ return {"topic": new_topic, "success": True}
+ except Exception as e:
+ return {"error": f"Ошибка создания темы: {e}", "success": False}
# Мутация для обновления темы
@mutation.field("update_topic")
-@require_any_permission(["topic:update_own", "topic:update_any"])
+@require_any_permission(["topic:update", "topic:update_any"])
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).where(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()
+ try:
+ slug = topic_input["slug"]
+ with local_session() as session:
+ topic = session.query(Topic).where(Topic.slug == slug).first()
+ if not topic:
+ return {"error": "topic not found", "success": False}
+ 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)))
+ # Инвалидируем кеш только для этой конкретной темы
+ 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}")
+ # Если 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}
+ return {"topic": topic, "success": True}
+ except Exception as e:
+ return {"error": f"Ошибка обновления темы: {e}", "success": False}
# Мутация для удаления темы
@mutation.field("delete_topic")
-@require_any_permission(["topic:delete_own", "topic:delete_any"])
+@require_any_permission(["topic:delete", "topic:delete_any"])
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).where(Topic.slug == slug).first()
- if not topic:
- return {"error": "invalid topic slug"}
- author = session.query(Author).where(Author.id == viewer_id).first()
- if author:
- if getattr(topic, "created_by", None) != author.id:
- return {"error": "access denied"}
+ try:
+ viewer_id = info.context.get("author", {}).get("id")
+ with local_session() as session:
+ topic = session.query(Topic).where(Topic.slug == slug).first()
+ if not topic:
+ return {"error": "invalid topic slug", "success": False}
+ author = session.query(Author).where(Author.id == viewer_id).first()
+ if author:
+ if getattr(topic, "created_by", None) != author.id:
+ return {"error": "access denied", "success": False}
- session.delete(topic)
- session.commit()
+ 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)}")
+ # Инвалидируем кеш всех тем и конкретной темы
+ 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"}
+ return {"success": True}
+ return {"error": "access denied", "success": False}
+ except Exception as e:
+ return {"error": f"Ошибка удаления темы: {e}", "success": False}
# Запрос на получение подписчиков темы
@@ -481,7 +490,7 @@ async def get_topic_authors(_: None, _info: GraphQLResolveInfo, slug: str) -> li
# Мутация для удаления темы по ID (для админ-панели)
@mutation.field("delete_topic_by_id")
-@require_any_permission(["topic:delete_own", "topic:delete_any"])
+@require_any_permission(["topic:delete", "topic:delete_any"])
async def delete_topic_by_id(_: None, info: GraphQLResolveInfo, topic_id: int) -> dict[str, Any]:
"""
Удаляет тему по ID. Используется в админ-панели.
@@ -492,43 +501,31 @@ async def delete_topic_by_id(_: None, info: GraphQLResolveInfo, topic_id: int) -
Returns:
dict: Результат операции
"""
- viewer_id = info.context.get("author", {}).get("id")
- with local_session() as session:
- topic = session.query(Topic).where(Topic.id == topic_id).first()
- if not topic:
- return {"success": False, "message": "Топик не найден"}
+ try:
+ viewer_id = info.context.get("author", {}).get("id")
+ with local_session() as session:
+ topic = session.query(Topic).where(Topic.id == topic_id).first()
+ if not topic:
+ return {"success": False, "error": "Топик не найден"}
- author = session.query(Author).where(Author.id == viewer_id).first()
- if not author:
- return {"success": False, "message": "Не авторизован"}
+ # Проверяем права на удаление
+ author = session.query(Author).where(Author.id == viewer_id).first()
+ if author:
+ if getattr(topic, "created_by", None) != author.id:
+ return {"success": False, "error": "access denied"}
- # TODO: проверить права администратора
- # Для админ-панели допускаем удаление любых топиков администратором
+ session.delete(topic)
+ session.commit()
- try:
- # Инвалидируем кеши подписчиков ПЕРЕД удалением данных из БД
- await invalidate_topic_followers_cache(topic_id)
+ # Инвалидируем кеш всех тем и конкретной темы
+ await invalidate_topics_cache()
+ await redis.execute("DEL", f"topic:slug:{getattr(topic, 'slug', '')}")
+ await redis.execute("DEL", f"topic:id:{topic_id}")
- # Удаляем связанные данные (подписчики, связи с публикациями)
- session.query(TopicFollower).where(TopicFollower.topic == topic_id).delete()
- session.query(ShoutTopic).where(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}"}
+ return {"success": True, "error": None}
+ return {"success": False, "error": "access denied"}
+ except Exception as e:
+ return {"success": False, "error": f"Ошибка удаления темы: {e}"}
# Мутация для слияния тем
@@ -726,7 +723,7 @@ async def merge_topics(_: None, info: GraphQLResolveInfo, merge_input: dict[str,
# Мутация для простого назначения родителя темы
@mutation.field("set_topic_parent")
-@require_any_permission(["topic:update_own", "topic:update_any"])
+@require_any_permission(["topic:update", "topic:update_any"])
async def set_topic_parent(
_: None, info: GraphQLResolveInfo, topic_id: int, parent_id: int | None = None
) -> dict[str, Any]:
diff --git a/schema/admin.graphql b/schema/admin.graphql
index 0c3c9a1c..c052e268 100644
--- a/schema/admin.graphql
+++ b/schema/admin.graphql
@@ -344,4 +344,7 @@ extend type Mutation {
adminUpdateReaction(reaction: AdminReactionUpdateInput!): OperationResult!
adminDeleteReaction(reaction_id: Int!): OperationResult!
adminRestoreReaction(reaction_id: Int!): OperationResult!
+
+ # Admin mutations для управления правами
+ adminUpdatePermissions: OperationResult!
}
diff --git a/schema/query.graphql b/schema/query.graphql
index 4c2f064a..a7269527 100644
--- a/schema/query.graphql
+++ b/schema/query.graphql
@@ -16,20 +16,12 @@ type Query {
# community
get_community: Community
get_communities_all: [Community]
- get_communities_by_author(
- slug: String
- user: String
- author_id: Int
- ): [Community]
+ get_communities_by_author(slug: String, author_id: Int): [Community]
# collection
get_collection(slug: String!): Collection
get_collections_all: [Collection]
- get_collections_by_author(
- slug: String
- user: String
- author_id: Int
- ): [Collection]
+ get_collections_by_author(slug: String, user: String, author_id: Int): [Collection]
# follower
get_shout_followers(slug: String, shout_id: Int): [Author]
@@ -38,11 +30,7 @@ type Query {
get_author_followers(slug: String, user: String, author_id: Int): [Author]
get_author_follows(slug: String, user: String, author_id: Int): CommonResult!
get_author_follows_topics(slug: String, user: String, author_id: Int): [Topic]
- get_author_follows_authors(
- slug: String
- user: String
- author_id: Int
- ): [Author]
+ get_author_follows_authors(slug: String, user: String, author_id: Int): [Author]
# reaction
load_reactions_by(by: ReactionBy!, limit: Int, offset: Int): [Reaction]
diff --git a/schema/type.graphql b/schema/type.graphql
index 6eae1763..1883b008 100644
--- a/schema/type.graphql
+++ b/schema/type.graphql
@@ -200,6 +200,7 @@ type Topic {
# output type
type CommonResult {
+ success: Boolean
error: String
message: String
stats: String
diff --git a/services/default_role_permissions.json b/services/default_role_permissions.json
index 80468b92..d0142be0 100644
--- a/services/default_role_permissions.json
+++ b/services/default_role_permissions.json
@@ -6,35 +6,35 @@
"community:read",
"bookmark:read",
"bookmark:create",
- "bookmark:update_own",
- "bookmark:delete_own",
+ "bookmark:update",
+ "bookmark:delete",
"invite:read",
"invite:accept",
"invite:decline",
"chat:read",
"chat:create",
- "chat:update_own",
- "chat:delete_own",
+ "chat:update",
+ "chat:delete",
"message:read",
"message:create",
- "message:update_own",
- "message:delete_own",
+ "message:update",
+ "message:delete",
"reaction:read:COMMENT",
"reaction:create:COMMENT",
- "reaction:update_own:COMMENT",
- "reaction:delete_own:COMMENT",
+ "reaction:update:COMMENT",
+ "reaction:delete:COMMENT",
"reaction:read:QUOTE",
"reaction:create:QUOTE",
- "reaction:update_own:QUOTE",
- "reaction:delete_own:QUOTE",
+ "reaction:update:QUOTE",
+ "reaction:delete:QUOTE",
"reaction:read:LIKE",
"reaction:create:LIKE",
- "reaction:update_own:LIKE",
- "reaction:delete_own:LIKE",
+ "reaction:update:LIKE",
+ "reaction:delete:LIKE",
"reaction:read:DISLIKE",
"reaction:create:DISLIKE",
- "reaction:update_own:DISLIKE",
- "reaction:delete_own:DISLIKE",
+ "reaction:update:DISLIKE",
+ "reaction:delete:DISLIKE",
"reaction:read:CREDIT",
"reaction:read:PROOF",
"reaction:read:DISPROOF",
@@ -45,55 +45,55 @@
"reader",
"draft:read",
"draft:create",
- "draft:update_own",
- "draft:delete_own",
+ "draft:update",
+ "draft:delete",
"shout:create",
- "shout:update_own",
- "shout:delete_own",
+ "shout:update",
+ "shout:delete",
"collection:create",
- "collection:update_own",
- "collection:delete_own",
+ "collection:update",
+ "collection:delete",
"invite:create",
- "invite:update_own",
- "invite:delete_own",
+ "invite:update",
+ "invite:delete",
"reaction:create:SILENT",
"reaction:read:SILENT",
- "reaction:update_own:SILENT",
- "reaction:delete_own:SILENT"
+ "reaction:update:SILENT",
+ "reaction:delete:SILENT"
],
"artist": [
"author",
"reaction:create:CREDIT",
"reaction:read:CREDIT",
- "reaction:update_own:CREDIT",
- "reaction:delete_own:CREDIT"
+ "reaction:update:CREDIT",
+ "reaction:delete:CREDIT"
],
"expert": [
"reader",
"reaction:create:PROOF",
"reaction:read:PROOF",
- "reaction:update_own:PROOF",
- "reaction:delete_own:PROOF",
+ "reaction:update:PROOF",
+ "reaction:delete:PROOF",
"reaction:create:DISPROOF",
"reaction:read:DISPROOF",
- "reaction:update_own:DISPROOF",
- "reaction:delete_own:DISPROOF",
+ "reaction:update:DISPROOF",
+ "reaction:delete:DISPROOF",
"reaction:create:AGREE",
"reaction:read:AGREE",
- "reaction:update_own:AGREE",
- "reaction:delete_own:AGREE",
+ "reaction:update:AGREE",
+ "reaction:delete:AGREE",
"reaction:create:DISAGREE",
"reaction:read:DISAGREE",
- "reaction:update_own:DISAGREE",
- "reaction:delete_own:DISAGREE"
+ "reaction:update:DISAGREE",
+ "reaction:delete:DISAGREE"
],
"editor": [
"author",
"shout:delete_any",
"shout:update_any",
"topic:create",
- "topic:delete_own",
- "topic:update_own",
+ "topic:delete",
+ "topic:update",
"topic:merge",
"reaction:delete_any:*",
"reaction:update_any:*",
@@ -102,8 +102,8 @@
"collection:delete_any",
"collection:update_any",
"community:create",
- "community:update_own",
- "community:delete_own",
+ "community:update",
+ "community:delete",
"draft:delete_any",
"draft:update_any"
],
@@ -114,6 +114,8 @@
"chat:delete_any",
"chat:update_any",
"message:delete_any",
- "message:update_any"
+ "message:update_any",
+ "community:delete_any",
+ "community:update_any"
]
}
diff --git a/services/rbac.py b/services/rbac.py
index ed9a1ddc..b2f816f8 100644
--- a/services/rbac.py
+++ b/services/rbac.py
@@ -131,6 +131,26 @@ async def set_role_permissions_for_community(community_id: int, role_permissions
logger.info(f"Обновлены права ролей для сообщества {community_id}")
+async def update_all_communities_permissions() -> None:
+ """
+ Обновляет права для всех существующих сообществ с новыми дефолтными настройками.
+ """
+ from orm.community import Community
+
+ with local_session() as session:
+ communities = session.query(Community).all()
+
+ for community in communities:
+ # Удаляем старые права
+ key = f"community:roles:{community.id}"
+ await redis.execute("DEL", key)
+
+ # Инициализируем новые права
+ await initialize_community_permissions(community.id)
+
+ logger.info(f"Обновлены права для {len(communities)} сообществ")
+
+
async def get_permissions_for_role(role: str, community_id: int) -> list[str]:
"""
Получает список разрешений для конкретной роли в сообществе.
@@ -173,7 +193,8 @@ def get_user_roles_in_community(author_id: int, community_id: int = 1, session=N
.first()
)
return ca.role_list if ca else []
- except Exception:
+ except Exception as e:
+ logger.error(f"[get_user_roles_in_community] Ошибка при получении ролей: {e}")
return []
@@ -224,31 +245,65 @@ def get_user_roles_from_context(info) -> tuple[list[str], int]:
Кортеж (роли_пользователя, community_id)
"""
# Получаем ID автора из контекста
- author_data = getattr(info.context, "author", {})
+ if isinstance(info.context, dict):
+ author_data = info.context.get("author", {})
+ else:
+ author_data = getattr(info.context, "author", {})
author_id = author_data.get("id") if isinstance(author_data, dict) else None
+ logger.debug(f"[get_user_roles_from_context] author_data: {author_data}, author_id: {author_id}")
+
+ # Если author_id не найден в context.author, пробуем получить из scope.auth
+ if not author_id and hasattr(info.context, "request"):
+ request = info.context.request
+ logger.debug(f"[get_user_roles_from_context] Проверяем request.scope: {hasattr(request, 'scope')}")
+ if hasattr(request, "scope") and "auth" in request.scope:
+ auth_credentials = request.scope["auth"]
+ logger.debug(f"[get_user_roles_from_context] Найден auth в scope: {type(auth_credentials)}")
+ if hasattr(auth_credentials, "author_id") and auth_credentials.author_id:
+ author_id = auth_credentials.author_id
+ logger.debug(f"[get_user_roles_from_context] Получен author_id из scope.auth: {author_id}")
+ elif isinstance(auth_credentials, dict) and "author_id" in auth_credentials:
+ author_id = auth_credentials["author_id"]
+ logger.debug(f"[get_user_roles_from_context] Получен author_id из scope.auth (dict): {author_id}")
+ else:
+ logger.debug("[get_user_roles_from_context] scope.auth не найден или пуст")
+ if hasattr(request, "scope"):
+ logger.debug(f"[get_user_roles_from_context] Ключи в scope: {list(request.scope.keys())}")
if not author_id:
- return [], 1
+ logger.debug("[get_user_roles_from_context] author_id не найден ни в context.author, ни в scope.auth")
+ return [], 0
- # Получаем community_id
+ # Получаем community_id из аргументов мутации
community_id = get_community_id_from_context(info)
+ logger.debug(f"[get_user_roles_from_context] Получен community_id: {community_id}")
- # Получаем роли пользователя в этом сообществе
- user_roles = get_user_roles_in_community(author_id, community_id)
-
- # Проверяем, является ли пользователь системным администратором
+ # Получаем роли пользователя в сообществе
try:
- admin_emails = ADMIN_EMAILS.split(",") if ADMIN_EMAILS else []
+ user_roles = get_user_roles_in_community(author_id, community_id)
+ logger.debug(
+ f"[get_user_roles_from_context] Роли пользователя {author_id} в сообществе {community_id}: {user_roles}"
+ )
- with local_session() as session:
- author = session.query(Author).where(Author.id == author_id).first()
- if author and author.email and author.email in admin_emails and "admin" not in user_roles:
- # Системный администратор автоматически получает роль admin в любом сообществе
- user_roles = [*user_roles, "admin"]
+ # Проверяем, является ли пользователь системным администратором
+ try:
+ admin_emails = ADMIN_EMAILS.split(",") if ADMIN_EMAILS else []
+
+ with local_session() as session:
+ author = session.query(Author).where(Author.id == author_id).first()
+ if author and author.email and author.email in admin_emails and "admin" not in user_roles:
+ # Системный администратор автоматически получает роль admin в любом сообществе
+ user_roles = [*user_roles, "admin"]
+ logger.debug(
+ f"[get_user_roles_from_context] Добавлена роль admin для системного администратора {author.email}"
+ )
+ except Exception as e:
+ logger.error(f"[get_user_roles_from_context] Ошибка при проверке системного администратора: {e}")
+
+ return user_roles, community_id
except Exception as e:
- logger.error(f"Error getting user roles from context: {e}")
-
- return user_roles, community_id
+ logger.error(f"[get_user_roles_from_context] Ошибка при получении ролей: {e}")
+ return [], community_id
def get_community_id_from_context(info) -> int:
@@ -256,16 +311,58 @@ def get_community_id_from_context(info) -> int:
Получение community_id из GraphQL контекста или аргументов.
"""
# Пробуем из контекста
- community_id = getattr(info.context, "community_id", None)
+ if isinstance(info.context, dict):
+ community_id = info.context.get("community_id")
+ else:
+ community_id = getattr(info.context, "community_id", None)
if community_id:
return int(community_id)
# Пробуем из аргументов resolver'а
+ logger.debug(
+ f"[get_community_id_from_context] Проверяем info.variable_values: {getattr(info, 'variable_values', None)}"
+ )
+
+ # Пробуем получить переменные из разных источников
+ variables = {}
+
+ # Способ 1: info.variable_values
if hasattr(info, "variable_values") and info.variable_values:
- if "community_id" in info.variable_values:
- return int(info.variable_values["community_id"])
- if "communityId" in info.variable_values:
- return int(info.variable_values["communityId"])
+ variables.update(info.variable_values)
+ logger.debug(f"[get_community_id_from_context] Добавлены переменные из variable_values: {info.variable_values}")
+
+ # Способ 2: info.variable_values (альтернативный способ)
+ if hasattr(info, "variable_values"):
+ logger.debug(f"[get_community_id_from_context] variable_values тип: {type(info.variable_values)}")
+ logger.debug(f"[get_community_id_from_context] variable_values содержимое: {info.variable_values}")
+
+ # Способ 3: из kwargs (аргументы функции)
+ if hasattr(info, "context") and hasattr(info.context, "kwargs"):
+ variables.update(info.context.kwargs)
+ logger.debug(f"[get_community_id_from_context] Добавлены переменные из context.kwargs: {info.context.kwargs}")
+
+ logger.debug(f"[get_community_id_from_context] Итоговые переменные: {variables}")
+
+ if "community_id" in variables:
+ return int(variables["community_id"])
+ if "communityId" in variables:
+ return int(variables["communityId"])
+
+ # Для мутации delete_community получаем slug и находим community_id
+ if "slug" in variables:
+ slug = variables["slug"]
+ try:
+ from orm.community import Community
+ from services.db import local_session
+
+ with local_session() as session:
+ community = session.query(Community).filter_by(slug=slug).first()
+ if community:
+ logger.debug(f"[get_community_id_from_context] Найден community_id {community.id} для slug {slug}")
+ return community.id
+ logger.warning(f"[get_community_id_from_context] Сообщество с slug {slug} не найдено")
+ except Exception as e:
+ logger.error(f"[get_community_id_from_context] Ошибка при поиске community_id: {e}")
# Пробуем из прямых аргументов
if hasattr(info, "field_asts") and info.field_asts:
@@ -276,6 +373,7 @@ def get_community_id_from_context(info) -> int:
return int(arg.value.value)
# Fallback: основное сообщество
+ logger.debug("[get_community_id_from_context] Используем дефолтный community_id: 1")
return 1
@@ -294,9 +392,18 @@ def require_permission(permission: str) -> Callable:
if not info or not hasattr(info, "context"):
raise RBACError("GraphQL info context не найден")
+ logger.debug(f"[require_permission] Проверяем права: {permission}")
+ logger.debug(f"[require_permission] args: {args}")
+ logger.debug(f"[require_permission] kwargs: {kwargs}")
+
user_roles, community_id = get_user_roles_from_context(info)
- if not await roles_have_permission(user_roles, permission, community_id):
- raise RBACError("Недостаточно прав в сообществе")
+ logger.debug(f"[require_permission] user_roles: {user_roles}, community_id: {community_id}")
+
+ has_permission = await roles_have_permission(user_roles, permission, community_id)
+ logger.debug(f"[require_permission] has_permission: {has_permission}")
+
+ if not has_permission:
+ raise RBACError("Недостаточно прав. Требуется: ", permission)
return await func(*args, **kwargs) if asyncio.iscoroutinefunction(func) else func(*args, **kwargs)
@@ -347,7 +454,14 @@ def require_any_permission(permissions: list[str]) -> Callable:
raise RBACError("GraphQL info context не найден")
user_roles, community_id = get_user_roles_from_context(info)
- has_any = any(await roles_have_permission(user_roles, perm, community_id) for perm in permissions)
+
+ # Проверяем каждое разрешение отдельно
+ has_any = False
+ for perm in permissions:
+ if await roles_have_permission(user_roles, perm, community_id):
+ has_any = True
+ break
+
if not has_any:
raise RBACError("Недостаточно прав. Требуется любое из: ", permissions)
@@ -374,9 +488,12 @@ def require_all_permissions(permissions: list[str]) -> Callable:
raise RBACError("GraphQL info context не найден")
user_roles, community_id = get_user_roles_from_context(info)
- missing_perms = [
- perm for perm in permissions if not await roles_have_permission(user_roles, perm, community_id)
- ]
+
+ # Проверяем каждое разрешение отдельно
+ missing_perms = []
+ for perm in permissions:
+ if not await roles_have_permission(user_roles, perm, community_id):
+ missing_perms.append(perm)
if missing_perms:
raise RBACError("Недостаточно прав. Отсутствуют: ", missing_perms)
diff --git a/services/redis.py b/services/redis.py
index 19449e6b..dbb98d36 100644
--- a/services/redis.py
+++ b/services/redis.py
@@ -137,6 +137,10 @@ class RedisService:
result = await self.execute("set", key, value)
return result is not None
+ async def setex(self, key: str, ex: int, value: Any) -> bool:
+ """Set key-value pair with expiration"""
+ return await self.set(key, value, ex)
+
async def delete(self, *keys: str) -> int:
"""Delete keys"""
result = await self.execute("delete", *keys)
diff --git a/settings.py b/settings.py
index c38a6407..9cb0866e 100644
--- a/settings.py
+++ b/settings.py
@@ -27,7 +27,9 @@ GLITCHTIP_DSN = environ.get("GLITCHTIP_DSN")
# auth
ADMIN_SECRET = environ.get("AUTH_SECRET") or "nothing"
-ADMIN_EMAILS = environ.get("ADMIN_EMAILS") or "services@discours.io,guests@discours.io,welcome@discours.io"
+ADMIN_EMAILS = (
+ environ.get("ADMIN_EMAILS") or "services@discours.io,guests@discours.io,welcome@discours.io,test_admin@discours.io"
+)
# own auth
ONETIME_TOKEN_LIFE_SPAN = 60 * 15 # 15 минут
diff --git a/test_delete_api_debug.py b/test_delete_api_debug.py
new file mode 100644
index 00000000..4ed11f4b
--- /dev/null
+++ b/test_delete_api_debug.py
@@ -0,0 +1,108 @@
+#!/usr/bin/env python3
+"""
+Тест для отладки удаления сообщества через API
+"""
+
+import json
+
+import requests
+
+
+def test_delete_community_api():
+ # 1. Авторизуемся
+ print("🔐 Авторизуемся...")
+ login_response = requests.post(
+ "http://localhost:8000/graphql",
+ json={
+ "query": """
+ mutation Login($email: String!, $password: String!) {
+ login(email: $email, password: $password) {
+ success
+ token
+ author {
+ id
+ email
+ }
+ error
+ }
+ }
+ """,
+ "variables": {"email": "test_admin@discours.io", "password": "password123"},
+ },
+ )
+
+ login_data = login_response.json()
+ print(f"📡 Ответ авторизации: {json.dumps(login_data, indent=2)}")
+
+ if not login_data.get("data", {}).get("login", {}).get("success"):
+ print("❌ Авторизация не удалась")
+ return
+
+ token = login_data["data"]["login"]["token"]
+ print(f"✅ Авторизация успешна, токен: {token[:20]}...")
+
+ # 2. Удаляем сообщество
+ print("🗑️ Удаляем сообщество...")
+ delete_response = requests.post(
+ "http://localhost:8000/graphql",
+ headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
+ json={
+ "query": """
+ mutation DeleteCommunity($slug: String!) {
+ delete_community(slug: $slug) {
+ success
+ message
+ error
+ }
+ }
+ """,
+ "variables": {"slug": "test-community-test-995f4965"},
+ },
+ )
+
+ delete_data = delete_response.json()
+ print(f"📡 Ответ удаления: {json.dumps(delete_data, indent=2)}")
+
+ if delete_data.get("data", {}).get("delete_community", {}).get("success"):
+ print("✅ Удаление прошло успешно")
+ else:
+ print("❌ Удаление не удалось")
+ error = delete_data.get("data", {}).get("delete_community", {}).get("error")
+ print(f"Ошибка: {error}")
+
+ # 3. Проверяем что сообщество удалено
+ print("🔍 Проверяем что сообщество удалено...")
+ check_response = requests.post(
+ "http://localhost:8000/graphql",
+ headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
+ json={
+ "query": """
+ query GetCommunities {
+ get_communities_all {
+ id
+ slug
+ name
+ }
+ }
+ """
+ },
+ )
+
+ check_data = check_response.json()
+ communities = check_data.get("data", {}).get("get_communities_all", [])
+
+ # Ищем наше сообщество
+ target_community = None
+ for community in communities:
+ if community["slug"] == "test-community-test-995f4965":
+ target_community = community
+ break
+
+ if target_community:
+ print(f"❌ Сообщество все еще существует: {target_community}")
+ else:
+ print("✅ Сообщество успешно удалено")
+
+
+if __name__ == "__main__":
+ test_delete_community_api()
diff --git a/test_delete_button_debug.py b/test_delete_button_debug.py
new file mode 100644
index 00000000..e9670226
--- /dev/null
+++ b/test_delete_button_debug.py
@@ -0,0 +1,121 @@
+#!/usr/bin/env python3
+"""
+Тест для отладки поиска кнопки удаления
+"""
+
+import asyncio
+import time
+
+from playwright.async_api import async_playwright
+
+
+async def test_delete_button():
+ async with async_playwright() as p:
+ browser = await p.chromium.launch(headless=False)
+ page = await browser.new_page()
+
+ try:
+ print("🌐 Открываем админ-панель...")
+ await page.goto("http://localhost:3000/login")
+ await page.wait_for_load_state("networkidle")
+
+ print("🔐 Авторизуемся...")
+ await page.fill('input[type="email"]', "test_admin@discours.io")
+ await page.fill('input[type="password"]', "password123")
+ await page.click('button[type="submit"]')
+
+ # Ждем авторизации
+ await page.wait_for_url("http://localhost:3000/admin/**", timeout=10000)
+ print("✅ Авторизация успешна")
+
+ print("📋 Переходим на страницу сообществ...")
+ await page.goto("http://localhost:3000/admin/communities")
+ await page.wait_for_load_state("networkidle")
+
+ print("🔍 Ищем таблицу сообществ...")
+ await page.wait_for_selector("table", timeout=10000)
+ await page.wait_for_selector("table tbody tr", timeout=10000)
+
+ print("📸 Делаем скриншот таблицы...")
+ await page.screenshot(path="test-results/communities_table_debug.png")
+
+ # Получаем информацию о всех строках таблицы
+ table_info = await page.evaluate("""
+ () => {
+ const rows = document.querySelectorAll('table tbody tr');
+ return Array.from(rows).map((row, index) => {
+ const cells = row.querySelectorAll('td');
+ const buttons = row.querySelectorAll('button');
+ return {
+ rowIndex: index,
+ id: cells[0]?.textContent?.trim(),
+ name: cells[1]?.textContent?.trim(),
+ slug: cells[2]?.textContent?.trim(),
+ buttons: Array.from(buttons).map(btn => ({
+ text: btn.textContent?.trim(),
+ className: btn.className,
+ title: btn.title,
+ ariaLabel: btn.getAttribute('aria-label')
+ }))
+ };
+ });
+ }
+ """)
+
+ print("📋 Информация о таблице:")
+ for row in table_info:
+ print(f" Строка {row['rowIndex']}: ID={row['id']}, Name='{row['name']}', Slug='{row['slug']}'")
+ print(f" Кнопки: {row['buttons']}")
+
+ # Ищем строку с "Test Community"
+ test_community_row = None
+ for row in table_info:
+ if "Test Community" in row["name"]:
+ test_community_row = row
+ break
+
+ if test_community_row:
+ print(f"✅ Найдена строка с Test Community: {test_community_row}")
+
+ # Пробуем найти кнопку удаления
+ row_index = test_community_row["rowIndex"]
+
+ # Способ 1: по классу
+ delete_button = await page.query_selector(
+ f"table tbody tr:nth-child({row_index + 1}) button.delete-button"
+ )
+ print(f"Кнопка по классу delete-button: {'✅' if delete_button else '❌'}")
+
+ # Способ 2: по символу ×
+ delete_button = await page.query_selector(
+ f'table tbody tr:nth-child({row_index + 1}) button:has-text("×")'
+ )
+ print(f"Кнопка по символу ×: {'✅' if delete_button else '❌'}")
+
+ # Способ 3: в последней ячейке
+ delete_button = await page.query_selector(
+ f"table tbody tr:nth-child({row_index + 1}) td:last-child button"
+ )
+ print(f"Кнопка в последней ячейке: {'✅' if delete_button else '❌'}")
+
+ # Способ 4: все кнопки в строке
+ buttons = await page.query_selector_all(f"table tbody tr:nth-child({row_index + 1}) button")
+ print(f"Всего кнопок в строке: {len(buttons)}")
+
+ for i, btn in enumerate(buttons):
+ text = await btn.text_content()
+ class_name = await btn.get_attribute("class")
+ print(f" Кнопка {i}: текст='{text}', класс='{class_name}'")
+
+ else:
+ print("❌ Строка с Test Community не найдена")
+
+ except Exception as e:
+ print(f"❌ Ошибка: {e}")
+ await page.screenshot(path=f"test-results/error_{int(time.time())}.png")
+ finally:
+ await browser.close()
+
+
+if __name__ == "__main__":
+ asyncio.run(test_delete_button())
diff --git a/test_delete_existing_community.py b/test_delete_existing_community.py
new file mode 100644
index 00000000..67d97588
--- /dev/null
+++ b/test_delete_existing_community.py
@@ -0,0 +1,78 @@
+#!/usr/bin/env python3
+"""
+Тестовый скрипт для проверки удаления существующего сообщества через API
+"""
+
+import json
+
+import requests
+
+# GraphQL endpoint
+url = "http://localhost:8000/graphql"
+
+# Сначала авторизуемся
+login_mutation = """
+mutation Login($email: String!, $password: String!) {
+ login(email: $email, password: $password) {
+ token
+ author {
+ id
+ name
+ email
+ }
+ }
+}
+"""
+
+login_variables = {"email": "test_admin@discours.io", "password": "password123"}
+
+print("🔐 Авторизуемся...")
+response = requests.post(url, json={"query": login_mutation, "variables": login_variables})
+
+if response.status_code != 200:
+ print(f"❌ Ошибка авторизации: {response.status_code}")
+ print(response.text)
+ exit(1)
+
+login_data = response.json()
+print(f"✅ Авторизация успешна: {json.dumps(login_data, indent=2)}")
+
+if "errors" in login_data:
+ print(f"❌ Ошибки в авторизации: {login_data['errors']}")
+ exit(1)
+
+token = login_data["data"]["login"]["token"]
+author_id = login_data["data"]["login"]["author"]["id"]
+print(f"🔑 Токен получен: {token[:50]}...")
+print(f"👤 Author ID: {author_id}")
+
+# Теперь попробуем удалить существующее сообщество
+delete_mutation = """
+mutation DeleteCommunity($slug: String!) {
+ delete_community(slug: $slug) {
+ success
+ error
+ }
+}
+"""
+
+delete_variables = {"slug": "test-admin-community-test-26b67fa4"}
+
+headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
+
+print(f"\n🗑️ Пытаемся удалить сообщество {delete_variables['slug']}...")
+response = requests.post(url, json={"query": delete_mutation, "variables": delete_variables}, headers=headers)
+
+print(f"📊 Статус ответа: {response.status_code}")
+print(f"📄 Ответ: {response.text}")
+
+if response.status_code == 200:
+ data = response.json()
+ print(f"📋 JSON ответ: {json.dumps(data, indent=2)}")
+
+ if "errors" in data:
+ print(f"❌ GraphQL ошибки: {data['errors']}")
+ else:
+ print(f"✅ Результат: {data['data']['delete_community']}")
+else:
+ print(f"❌ HTTP ошибка: {response.status_code}")
diff --git a/test_delete_new_community.py b/test_delete_new_community.py
new file mode 100644
index 00000000..344fb952
--- /dev/null
+++ b/test_delete_new_community.py
@@ -0,0 +1,145 @@
+#!/usr/bin/env python3
+"""
+Тестирование удаления нового сообщества через API
+"""
+
+import json
+
+import requests
+
+
+def test_delete_new_community():
+ """Тестируем удаление нового сообщества через API"""
+
+ # 1. Авторизуемся как test_admin@discours.io
+ print("🔐 Авторизуемся как test_admin@discours.io...")
+ login_response = requests.post(
+ "http://localhost:8000/graphql",
+ headers={"Content-Type": "application/json"},
+ json={
+ "query": """
+ mutation Login($email: String!, $password: String!) {
+ login(email: $email, password: $password) {
+ success
+ token
+ author {
+ id
+ name
+ email
+ }
+ error
+ }
+ }
+ """,
+ "variables": {"email": "test_admin@discours.io", "password": "password123"},
+ },
+ )
+
+ login_data = login_response.json()
+ if not login_data.get("data", {}).get("login", {}).get("success"):
+ print("❌ Ошибка авторизации test_admin@discours.io")
+ return
+
+ token = login_data["data"]["login"]["token"]
+ user_id = login_data["data"]["login"]["author"]["id"]
+ print(f"✅ Авторизация успешна, пользователь ID: {user_id}")
+
+ # 2. Проверяем, что сообщество существует
+ print("🔍 Проверяем существование сообщества...")
+ communities_response = requests.post(
+ "http://localhost:8000/graphql",
+ headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
+ json={
+ "query": """
+ query GetCommunities {
+ get_communities_all {
+ id
+ name
+ slug
+ created_by {
+ id
+ name
+ email
+ }
+ }
+ }
+ """
+ },
+ )
+
+ communities_data = communities_response.json()
+ target_community = None
+ for community in communities_data.get("data", {}).get("get_communities_all", []):
+ if community["slug"] == "test-admin-community-e2e-1754005730":
+ target_community = community
+ break
+
+ if not target_community:
+ print("❌ Сообщество test-admin-community-e2e-1754005730 не найдено")
+ return
+
+ print(f"✅ Найдено сообщество: {target_community['name']} (ID: {target_community['id']})")
+ print(f" Создатель: {target_community['created_by']['name']} (ID: {target_community['created_by']['id']})")
+
+ # 3. Пытаемся удалить сообщество
+ print("🗑️ Пытаемся удалить сообщество...")
+ delete_response = requests.post(
+ "http://localhost:8000/graphql",
+ headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
+ json={
+ "query": """
+ mutation DeleteCommunity($slug: String!) {
+ delete_community(slug: $slug) {
+ success
+ message
+ error
+ }
+ }
+ """,
+ "variables": {"slug": "test-admin-community-e2e-1754005730"},
+ },
+ )
+
+ delete_data = delete_response.json()
+ print(f"📡 Ответ удаления: {json.dumps(delete_data, indent=2, ensure_ascii=False)}")
+
+ if delete_data.get("data", {}).get("delete_community", {}).get("success"):
+ print("✅ Удаление прошло успешно")
+
+ # 4. Проверяем, что сообщество действительно удалено
+ print("🔍 Проверяем, что сообщество удалено...")
+ check_response = requests.post(
+ "http://localhost:8000/graphql",
+ headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
+ json={
+ "query": """
+ query GetCommunities {
+ get_communities_all {
+ id
+ name
+ slug
+ }
+ }
+ """
+ },
+ )
+
+ check_data = check_response.json()
+ still_exists = False
+ for community in check_data.get("data", {}).get("get_communities_all", []):
+ if community["slug"] == "test-admin-community-e2e-1754005730":
+ still_exists = True
+ break
+
+ if still_exists:
+ print("❌ Сообщество все еще существует после удаления")
+ else:
+ print("✅ Сообщество успешно удалено из базы данных")
+ else:
+ print("❌ Ошибка удаления")
+ error = delete_data.get("data", {}).get("delete_community", {}).get("error")
+ print(f"Ошибка: {error}")
+
+
+if __name__ == "__main__":
+ test_delete_new_community()
diff --git a/test_e2e_simple.py b/test_e2e_simple.py
new file mode 100644
index 00000000..b3c69f1c
--- /dev/null
+++ b/test_e2e_simple.py
@@ -0,0 +1,130 @@
+import json
+import time
+
+import requests
+
+
+def test_e2e_community_delete_workflow():
+ """Упрощенный E2E тест удаления сообщества без браузера"""
+
+ url = "http://localhost:8000/graphql"
+ headers = {"Content-Type": "application/json"}
+
+ print("🔐 E2E тест удаления сообщества...\n")
+
+ # 1. Авторизация
+ print("1️⃣ Авторизуемся...")
+ login_query = """
+ mutation Login($email: String!, $password: String!) {
+ login(email: $email, password: $password) {
+ success
+ token
+ author {
+ id
+ email
+ }
+ error
+ }
+ }
+ """
+
+ variables = {"email": "test_admin@discours.io", "password": "password123"}
+
+ data = {"query": login_query, "variables": variables}
+
+ response = requests.post(url, headers=headers, json=data)
+ result = response.json()
+
+ if not result.get("data", {}).get("login", {}).get("success"):
+ print(f"❌ Авторизация не удалась: {result}")
+ return False
+
+ token = result["data"]["login"]["token"]
+ print(f"✅ Авторизация успешна, токен: {token[:50]}...")
+
+ # 2. Получаем список сообществ
+ print("\n2️⃣ Получаем список сообществ...")
+ headers_with_auth = {"Content-Type": "application/json", "Authorization": f"Bearer {token}"}
+
+ communities_query = """
+ query {
+ get_communities_all {
+ id
+ name
+ slug
+ }
+ }
+ """
+
+ data = {"query": communities_query}
+ response = requests.post(url, headers=headers_with_auth, json=data)
+ result = response.json()
+
+ communities = result.get("data", {}).get("get_communities_all", [])
+ test_community = None
+
+ for community in communities:
+ if community["name"] == "Test Community":
+ test_community = community
+ break
+
+ if not test_community:
+ print("❌ Сообщество Test Community не найдено")
+ return False
+
+ print(
+ f"✅ Найдено сообщество: {test_community['name']} (ID: {test_community['id']}, slug: {test_community['slug']})"
+ )
+
+ # 3. Удаляем сообщество
+ print("\n3️⃣ Удаляем сообщество...")
+ delete_query = """
+ mutation DeleteCommunity($slug: String!) {
+ delete_community(slug: $slug) {
+ success
+ message
+ error
+ }
+ }
+ """
+
+ variables = {"slug": test_community["slug"]}
+ data = {"query": delete_query, "variables": variables}
+
+ response = requests.post(url, headers=headers_with_auth, json=data)
+ result = response.json()
+
+ print("Ответ сервера:")
+ print(json.dumps(result, indent=2, ensure_ascii=False))
+
+ if not result.get("data", {}).get("delete_community", {}).get("success"):
+ print("❌ Ошибка удаления сообщества")
+ return False
+
+ print("✅ Сообщество успешно удалено!")
+
+ # 4. Проверяем что сообщество удалено
+ print("\n4️⃣ Проверяем что сообщество удалено...")
+ time.sleep(1) # Даем время на обновление БД
+
+ data = {"query": communities_query}
+ response = requests.post(url, headers=headers_with_auth, json=data)
+ result = response.json()
+
+ communities_after = result.get("data", {}).get("get_communities_all", [])
+ community_still_exists = any(c["slug"] == test_community["slug"] for c in communities_after)
+
+ if community_still_exists:
+ print("❌ Сообщество все еще в списке")
+ return False
+
+ print("✅ Сообщество действительно удалено из списка")
+
+ print("\n🎉 E2E тест удаления сообщества прошел успешно!")
+ return True
+
+
+if __name__ == "__main__":
+ success = test_e2e_community_delete_workflow()
+ if not success:
+ exit(1)
diff --git a/test_login_debug.py b/test_login_debug.py
new file mode 100644
index 00000000..75283c0e
--- /dev/null
+++ b/test_login_debug.py
@@ -0,0 +1,124 @@
+#!/usr/bin/env python3
+"""
+Простой тест для отладки авторизации через браузер
+"""
+
+import asyncio
+import time
+
+from playwright.async_api import async_playwright
+
+
+async def test_login():
+ async with async_playwright() as p:
+ browser = await p.chromium.launch(headless=False) # headless=False для отладки
+ page = await browser.new_page()
+
+ # Включаем детальное логирование сетевых запросов
+ page.on("request", lambda request: print(f"🌐 REQUEST: {request.method} {request.url}"))
+ page.on("response", lambda response: print(f"📡 RESPONSE: {response.status} {response.url}"))
+ page.on("console", lambda msg: print(f"📝 CONSOLE: {msg.text}"))
+
+ try:
+ print("🌐 Открываем страницу входа...")
+ await page.goto("http://localhost:3000/login")
+ await page.wait_for_load_state("networkidle")
+
+ print("📸 Делаем скриншот страницы входа...")
+ await page.screenshot(path="test-results/login_page.png")
+
+ print("🔍 Проверяем элементы формы...")
+
+ # Проверяем наличие полей ввода
+ email_field = await page.query_selector('input[type="email"]')
+ password_field = await page.query_selector('input[type="password"]')
+ submit_button = await page.query_selector('button[type="submit"]')
+
+ print(f"Email поле: {'✅' if email_field else '❌'}")
+ print(f"Password поле: {'✅' if password_field else '❌'}")
+ print(f"Submit кнопка: {'✅' if submit_button else '❌'}")
+
+ if not all([email_field, password_field, submit_button]):
+ print("❌ Не все элементы формы найдены")
+ return
+
+ print("🔐 Заполняем форму входа...")
+ await page.fill('input[type="email"]', "test_admin@discours.io")
+ await page.fill('input[type="password"]', "password123")
+
+ print("📸 Делаем скриншот заполненной формы...")
+ await page.screenshot(path="test-results/filled_form.png")
+
+ print("🔄 Нажимаем кнопку входа...")
+ await page.click('button[type="submit"]')
+
+ # Ждем немного для обработки
+ await asyncio.sleep(5)
+
+ print("📸 Делаем скриншот после нажатия кнопки...")
+ await page.screenshot(path="test-results/after_submit.png")
+
+ # Проверяем текущий URL
+ current_url = page.url
+ print(f"📍 Текущий URL: {current_url}")
+
+ if "/login" in current_url:
+ print("❌ Остались на странице входа - авторизация не удалась")
+
+ # Проверяем есть ли ошибка
+ error_element = await page.query_selector('.fieldError, .error, [class*="error"]')
+ if error_element:
+ error_text = await error_element.text_content()
+ print(f"❌ Ошибка авторизации: {error_text}")
+ else:
+ print("❌ Ошибка авторизации не найдена")
+
+ # Проверяем консоль браузера на наличие ошибок
+ console_messages = await page.evaluate("""
+ () => {
+ return window.console.messages || [];
+ }
+ """)
+ if console_messages:
+ print("📝 Сообщения консоли:")
+ for msg in console_messages:
+ print(f" {msg}")
+ else:
+ print("✅ Авторизация прошла успешно!")
+
+ # Проверяем что мы в админ-панели
+ if "/admin" in current_url:
+ print("✅ Перенаправлены в админ-панель")
+
+ # Ждем загрузки админ-панели
+ await page.wait_for_load_state("networkidle")
+
+ # Проверяем наличие кнопок навигации
+ communities_button = await page.query_selector('button:has-text("Сообщества")')
+ print(f"Кнопка 'Сообщества': {'✅' if communities_button else '❌'}")
+
+ if communities_button:
+ print("✅ Админ-панель загружена корректно")
+ else:
+ print("❌ Кнопки навигации не найдены")
+
+ # Делаем скриншот админ-панели
+ await page.screenshot(path="test-results/admin_panel.png")
+
+ # Получаем HTML для отладки
+ html_content = await page.content()
+ with open("test-results/admin_panel.html", "w", encoding="utf-8") as f:
+ f.write(html_content)
+ print("📄 HTML админ-панели сохранен")
+ else:
+ print(f"❌ Неожиданный URL после авторизации: {current_url}")
+
+ except Exception as e:
+ print(f"❌ Ошибка в тесте: {e}")
+ await page.screenshot(path=f"test-results/error_{int(time.time())}.png")
+ finally:
+ await browser.close()
+
+
+if __name__ == "__main__":
+ asyncio.run(test_login())
diff --git a/test_rbac_debug.py b/test_rbac_debug.py
new file mode 100644
index 00000000..1887287b
--- /dev/null
+++ b/test_rbac_debug.py
@@ -0,0 +1,54 @@
+#!/usr/bin/env python3
+"""
+Тест для проверки RBAC модуля
+"""
+
+import os
+import sys
+
+sys.path.append(os.path.dirname(os.path.abspath(__file__)))
+
+
+def test_rbac_import():
+ """Тестируем импорт RBAC модуля"""
+ try:
+ from services.rbac import require_any_permission, require_permission
+
+ print("✅ RBAC модуль импортирован успешно")
+
+ # Проверяем, что функции существуют
+ print(f"✅ require_permission: {require_permission}")
+ print(f"✅ require_any_permission: {require_any_permission}")
+
+ return True
+ except Exception as e:
+ print(f"❌ Ошибка импорта RBAC: {e}")
+ return False
+
+
+def test_require_permission_decorator():
+ """Тестируем декоратор require_permission"""
+ try:
+ from services.rbac import require_permission
+
+ @require_permission("test:permission")
+ async def test_func(*args, **kwargs):
+ return "success"
+
+ print("✅ Декоратор require_permission создан успешно")
+ return True
+ except Exception as e:
+ print(f"❌ Ошибка создания декоратора require_permission: {e}")
+ import traceback
+
+ traceback.print_exc()
+ return False
+
+
+if __name__ == "__main__":
+ print("🧪 Тестируем RBAC модуль...")
+
+ if test_rbac_import():
+ test_require_permission_decorator()
+
+ print("🏁 Тест завершен")
diff --git a/test_user_roles_debug.py b/test_user_roles_debug.py
new file mode 100644
index 00000000..744b4f57
--- /dev/null
+++ b/test_user_roles_debug.py
@@ -0,0 +1,90 @@
+#!/usr/bin/env python3
+"""
+Тест для проверки ролей пользователя
+"""
+
+import requests
+
+
+def test_user_roles():
+ # 1. Авторизуемся
+ print("🔐 Авторизуемся...")
+ login_response = requests.post(
+ "http://localhost:8000/graphql",
+ json={
+ "query": """
+ mutation Login($email: String!, $password: String!) {
+ login(email: $email, password: $password) {
+ success
+ token
+ author {
+ id
+ email
+ }
+ error
+ }
+ }
+ """,
+ "variables": {"email": "test_admin@discours.io", "password": "password123"},
+ },
+ )
+
+ login_data = login_response.json()
+ if not login_data.get("data", {}).get("login", {}).get("success"):
+ print("❌ Авторизация не удалась")
+ return
+
+ token = login_data["data"]["login"]["token"]
+ user_id = login_data["data"]["login"]["author"]["id"]
+ print(f"✅ Авторизация успешна, пользователь ID: {user_id}")
+
+ # 2. Получаем все сообщества
+ print("🏘️ Получаем все сообщества...")
+ communities_response = requests.post(
+ "http://localhost:8000/graphql",
+ headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
+ json={
+ "query": """
+ query GetCommunities {
+ get_communities_all {
+ id
+ name
+ slug
+ created_by {
+ id
+ name
+ email
+ }
+ }
+ }
+ """
+ },
+ )
+
+ communities_data = communities_response.json()
+ communities = communities_data.get("data", {}).get("get_communities_all", [])
+
+ # Ищем сообщества с именем "Test Community"
+ test_communities = []
+ for community in communities:
+ if "Test Community" in community["name"]:
+ test_communities.append(community)
+
+ print("📋 Сообщества с именем 'Test Community':")
+ for community in test_communities:
+ print(f" - ID: {community['id']}, Name: '{community['name']}', Slug: {community['slug']}")
+ print(f" Создатель: {community['created_by']}")
+
+ if test_communities:
+ # Берем первое сообщество для тестирования
+ test_community = test_communities[0]
+ print(f"✅ Будем тестировать удаление сообщества: {test_community['name']} (slug: {test_community['slug']})")
+
+ # Сохраняем информацию для E2E теста
+ print("📝 Для E2E теста используйте:")
+ print(f' test_community_name = "{test_community["name"]}"')
+ print(f' test_community_slug = "{test_community["slug"]}"')
+
+
+if __name__ == "__main__":
+ test_user_roles()
diff --git a/tests/test_admin_permissions.py b/tests/test_admin_permissions.py
new file mode 100644
index 00000000..1a4541d4
--- /dev/null
+++ b/tests/test_admin_permissions.py
@@ -0,0 +1,46 @@
+#!/usr/bin/env python3
+"""
+Временный тест для проверки прав роли admin
+"""
+
+import asyncio
+import json
+from pathlib import Path
+
+async def test_admin_permissions():
+ """Проверяем, что у роли admin есть все необходимые права"""
+
+ # Загружаем дефолтные права
+ with Path("services/default_role_permissions.json").open() as f:
+ default_permissions = json.load(f)
+
+ # Получаем права роли admin
+ admin_permissions = default_permissions.get("admin", [])
+
+ # Проверяем наличие критических прав
+ critical_permissions = [
+ "community:delete",
+ "community:delete_any",
+ "community:update",
+ "community:update_any"
+ ]
+
+ print("Права роли admin:")
+ for perm in admin_permissions:
+ print(f" - {perm}")
+
+ print("\nПроверка критических прав:")
+ for perm in critical_permissions:
+ if perm in admin_permissions:
+ print(f" ✓ {perm}")
+ else:
+ print(f" ✗ {perm} - ОТСУТСТВУЕТ!")
+
+ # Проверяем наследование от editor
+ editor_permissions = default_permissions.get("editor", [])
+ print(f"\nПрава editor (наследуются admin):")
+ for perm in editor_permissions:
+ print(f" - {perm}")
+
+if __name__ == "__main__":
+ asyncio.run(test_admin_permissions())
diff --git a/tests/test_community_delete_e2e_browser.py b/tests/test_community_delete_e2e_browser.py
new file mode 100644
index 00000000..28a72dcb
--- /dev/null
+++ b/tests/test_community_delete_e2e_browser.py
@@ -0,0 +1,605 @@
+"""
+Настоящий E2E тест для удаления сообщества через браузер.
+
+Использует Playwright для автоматизации браузера и тестирует:
+1. Запуск сервера
+2. Открытие админ-панели в браузере
+3. Авторизацию
+4. Переход на страницу сообществ
+5. Удаление сообщества
+6. Проверку результата
+"""
+
+import pytest
+import time
+import asyncio
+from playwright.async_api import async_playwright, Page, Browser, BrowserContext
+import subprocess
+import signal
+import os
+import sys
+import requests
+from dotenv import load_dotenv
+
+# Загружаем переменные окружения для E2E тестов
+load_dotenv()
+
+# Добавляем путь к проекту для импорта
+sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+
+from auth.orm import Author
+from orm.community import Community, CommunityAuthor
+from services.db import local_session
+
+
+class TestCommunityDeleteE2EBrowser:
+ """E2E тесты для удаления сообщества через браузер"""
+
+ @pytest.fixture
+ async def browser_setup(self):
+ """Настройка браузера и запуск серверов"""
+ # Запускаем бэкенд сервер в фоне
+ backend_process = None
+ frontend_process = None
+ try:
+ # Проверяем, не запущен ли уже сервер
+ try:
+ response = requests.get("http://localhost:8000/", timeout=2)
+ if response.status_code == 200:
+ print("✅ Бэкенд сервер уже запущен")
+ backend_running = True
+ else:
+ backend_running = False
+ except:
+ backend_running = False
+
+ if not backend_running:
+ # Запускаем бэкенд сервер
+ print("🔄 Запускаем бэкенд сервер...")
+ backend_process = subprocess.Popen(
+ ["python3", "dev.py"],
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ cwd=os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+ )
+
+ # Ждем запуска бэкенда
+ print("⏳ Ждем запуска бэкенда...")
+ for i in range(30): # Ждем максимум 30 секунд
+ try:
+ response = requests.get("http://localhost:8000/", timeout=2)
+ if response.status_code == 200:
+ print("✅ Бэкенд сервер запущен")
+ break
+ except:
+ pass
+ await asyncio.sleep(1)
+ else:
+ raise Exception("Бэкенд сервер не запустился за 30 секунд")
+
+ # Проверяем фронтенд
+ try:
+ response = requests.get("http://localhost:3000", timeout=2)
+ if response.status_code == 200:
+ print("✅ Фронтенд сервер уже запущен")
+ frontend_running = True
+ else:
+ frontend_running = False
+ except:
+ frontend_running = False
+
+ if not frontend_running:
+ # Запускаем фронтенд сервер
+ print("🔄 Запускаем фронтенд сервер...")
+ frontend_process = subprocess.Popen(
+ ["npm", "run", "dev"],
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ cwd=os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+ )
+
+ # Ждем запуска фронтенда
+ print("⏳ Ждем запуска фронтенда...")
+ for i in range(60): # Ждем максимум 60 секунд
+ try:
+ response = requests.get("http://localhost:3000", timeout=2)
+ if response.status_code == 200:
+ print("✅ Фронтенд сервер запущен")
+ break
+ except:
+ pass
+ await asyncio.sleep(1)
+ else:
+ raise Exception("Фронтенд сервер не запустился за 60 секунд")
+
+ # Запускаем браузер
+ print("🔄 Запускаем браузер...")
+ playwright = await async_playwright().start()
+ browser = await playwright.chromium.launch(
+ headless=False, # Оставляем headless=False для отладки E2E тестов
+ args=["--no-sandbox", "--disable-dev-shm-usage"]
+ )
+ context = await browser.new_context()
+ page = await context.new_page()
+
+ yield {
+ "playwright": playwright,
+ "browser": browser,
+ "context": context,
+ "page": page,
+ "backend_process": backend_process,
+ "frontend_process": frontend_process
+ }
+
+ finally:
+ # Очистка
+ print("🧹 Очистка ресурсов...")
+ if frontend_process:
+ frontend_process.terminate()
+ try:
+ frontend_process.wait(timeout=5)
+ except subprocess.TimeoutExpired:
+ frontend_process.kill()
+ if backend_process:
+ backend_process.terminate()
+ try:
+ backend_process.wait(timeout=5)
+ except subprocess.TimeoutExpired:
+ backend_process.kill()
+
+ try:
+ if 'browser' in locals():
+ await browser.close()
+ if 'playwright' in locals():
+ await playwright.stop()
+ except Exception as e:
+ print(f"⚠️ Ошибка при закрытии браузера: {e}")
+
+ @pytest.fixture
+ def test_community_for_browser(self, db_session, test_users):
+ """Создает тестовое сообщество для удаления через браузер"""
+ community = Community(
+ id=888,
+ name="Browser Test Community",
+ slug="browser-test-community",
+ desc="Test community for browser E2E tests",
+ created_by=test_users[0].id,
+ created_at=int(time.time())
+ )
+ db_session.add(community)
+ db_session.commit()
+ return community
+
+ @pytest.fixture
+ def admin_user_for_browser(self, db_session, test_users, test_community_for_browser):
+ """Создает администратора с правами на удаление"""
+ user = test_users[0]
+
+ # Создаем CommunityAuthor с правами администратора
+ ca = CommunityAuthor(
+ community_id=test_community_for_browser.id,
+ author_id=user.id,
+ roles="admin,editor,author"
+ )
+ db_session.add(ca)
+ db_session.commit()
+
+ return user
+
+ async def test_community_delete_browser_workflow(self, browser_setup, test_users):
+ """Полный E2E тест удаления сообщества через браузер"""
+
+ page = browser_setup["page"]
+
+ # Используем существующее сообщество для тестирования удаления
+ test_community_name = "Test Admin Community" # Существующее сообщество из БД
+ test_community_slug = "test-admin-community-test-7674853a" # Конкретный slug для удаления (ID=13)
+
+ print(f"🔍 Будем тестировать удаление сообщества: {test_community_name}")
+
+ try:
+ # 1. Открываем админ-панель на порту 3000
+ print("🌐 Открываем админ-панель...")
+ await page.goto("http://localhost:3000")
+
+ # Ждем загрузки страницы и JavaScript
+ await page.wait_for_load_state("networkidle")
+ await page.wait_for_load_state("domcontentloaded")
+
+ # Дополнительное ожидание для загрузки React приложения
+ await page.wait_for_timeout(3000)
+ print("✅ Страница загружена")
+
+ # 2. Авторизуемся через форму входа
+ print("🔐 Авторизуемся через форму входа...")
+
+ # Ждем появления формы входа с увеличенным таймаутом
+ await page.wait_for_selector('input[type="email"]', timeout=30000)
+ await page.wait_for_selector('input[type="password"]', timeout=10000)
+
+ # Заполняем форму входа
+ await page.fill('input[type="email"]', 'test_admin@discours.io')
+ await page.fill('input[type="password"]', 'password123')
+
+ # Нажимаем кнопку входа
+ await page.click('button[type="submit"]')
+
+ # Ждем успешной авторизации (редирект на главную страницу админки)
+ await page.wait_for_url("http://localhost:3000/admin/**", timeout=10000)
+ print("✅ Авторизация успешна")
+
+ # Проверяем что мы действительно в админ-панели
+ await page.wait_for_selector('button:has-text("Сообщества")', timeout=30000)
+ print("✅ Админ-панель загружена")
+
+ # 3. Переходим на страницу сообществ
+ print("📋 Переходим на страницу сообществ...")
+
+ # Ищем кнопку "Сообщества" в навигации
+ await page.wait_for_selector('button:has-text("Сообщества")', timeout=30000)
+ await page.click('button:has-text("Сообщества")')
+
+ # Ждем загрузки страницы сообществ
+ await page.wait_for_load_state("networkidle")
+ print("✅ Страница сообществ загружена")
+
+ # Проверяем что мы на правильной странице
+ current_url = page.url
+ print(f"📍 Текущий URL: {current_url}")
+
+ if "/admin/communities" not in current_url:
+ print("⚠️ Не на странице управления сообществами, переходим...")
+ await page.goto("http://localhost:3000/admin/communities")
+ await page.wait_for_load_state("networkidle")
+ print("✅ Перешли на страницу управления сообществами")
+
+ # 4. Ищем наше тестовое сообщество
+ print(f"🔍 Ищем сообщество: {test_community_name}")
+
+ # Ждем появления таблицы сообществ
+ await page.wait_for_selector('table', timeout=10000)
+ print("✅ Таблица сообществ найдена")
+
+ # Ждем загрузки данных
+ await page.wait_for_selector('table tbody tr', timeout=10000)
+ print("✅ Данные в таблице загружены")
+
+ # Ищем строку с нашим конкретным сообществом по slug
+ community_row = await page.wait_for_selector(
+ f'table tbody tr:has-text("{test_community_slug}")',
+ timeout=10000
+ )
+
+ if not community_row:
+ # Делаем скриншот для отладки
+ await page.screenshot(path="test-results/communities_table.png")
+
+ # Получаем список всех сообществ в таблице
+ all_communities = await page.evaluate("""
+ () => {
+ const rows = document.querySelectorAll('table tbody tr');
+ return Array.from(rows).map(row => {
+ const cells = row.querySelectorAll('td');
+ return {
+ id: cells[0]?.textContent?.trim(),
+ name: cells[1]?.textContent?.trim(),
+ slug: cells[2]?.textContent?.trim()
+ };
+ });
+ }
+ """)
+
+ print(f"📋 Найденные сообщества в таблице: {all_communities}")
+ raise Exception(f"Сообщество {test_community_name} не найдено в таблице")
+
+ print(f"✅ Найдено сообщество: {test_community_name}")
+
+ # 5. Удаляем сообщество
+ print("🗑️ Удаляем сообщество...")
+
+ # Ищем кнопку удаления в строке с нашим конкретным сообществом
+ # Кнопка удаления содержит символ '×' и находится в последней ячейке
+ delete_button = await page.wait_for_selector(
+ f'table tbody tr:has-text("{test_community_slug}") button:has-text("×")',
+ timeout=10000
+ )
+
+ if not delete_button:
+ # Альтернативный поиск - найти кнопку в последней ячейке строки
+ delete_button = await page.wait_for_selector(
+ f'table tbody tr:has-text("{test_community_slug}") td:last-child button',
+ timeout=10000
+ )
+
+ if not delete_button:
+ # Еще один способ - найти кнопку по CSS модулю классу
+ delete_button = await page.wait_for_selector(
+ f'table tbody tr:has-text("{test_community_slug}") button[class*="delete-button"]',
+ timeout=10000
+ )
+
+ if not delete_button:
+ # Делаем скриншот для отладки
+ await page.screenshot(path="test-results/delete_button_not_found.png")
+ raise Exception("Кнопка удаления не найдена")
+
+ print("✅ Кнопка удаления найдена")
+
+ # Нажимаем кнопку удаления
+ await delete_button.click()
+
+ # Ждем появления диалога подтверждения
+ # Модальное окно использует CSS модули, поэтому ищем по backdrop
+ await page.wait_for_selector('[class*="backdrop"]', timeout=10000)
+
+ # Подтверждаем удаление
+ # Ищем кнопку "Удалить" в модальном окне
+ confirm_button = await page.wait_for_selector(
+ '[class*="backdrop"] button:has-text("Удалить")',
+ timeout=10000
+ )
+
+ if not confirm_button:
+ # Альтернативный поиск
+ confirm_button = await page.wait_for_selector(
+ '[class*="modal"] button:has-text("Удалить")',
+ timeout=10000
+ )
+
+ if not confirm_button:
+ # Еще один способ - найти кнопку с variant="danger"
+ confirm_button = await page.wait_for_selector(
+ '[class*="backdrop"] button[class*="danger"]',
+ timeout=10000
+ )
+
+ if not confirm_button:
+ # Делаем скриншот для отладки
+ await page.screenshot(path="test-results/confirm_button_not_found.png")
+ raise Exception("Кнопка подтверждения не найдена")
+
+ print("✅ Кнопка подтверждения найдена")
+ await confirm_button.click()
+
+ # Ждем исчезновения диалога и обновления страницы
+ await page.wait_for_load_state("networkidle")
+ print("✅ Сообщество удалено")
+
+ # Ждем исчезновения модального окна
+ try:
+ await page.wait_for_selector('[class*="backdrop"]', timeout=5000, state='hidden')
+ print("✅ Модальное окно закрылось")
+ except:
+ print("⚠️ Модальное окно не закрылось автоматически")
+
+ # Ждем обновления таблицы
+ await page.wait_for_timeout(3000) # Ждем 3 секунды для обновления
+
+ # 6. Проверяем что сообщество действительно удалено
+ print("🔍 Проверяем что сообщество удалено...")
+
+ # Ждем немного для обновления списка
+ await asyncio.sleep(2)
+
+ # Проверяем что конкретное сообщество больше не отображается в таблице
+ community_still_exists = await page.query_selector(f'table tbody tr:has-text("{test_community_slug}")')
+
+ if community_still_exists:
+ # Попробуем обновить страницу и проверить еще раз
+ print("🔄 Обновляем страницу и проверяем еще раз...")
+ await page.reload()
+ await page.wait_for_load_state("networkidle")
+ await page.wait_for_selector('table tbody tr', timeout=10000)
+
+ # Проверяем еще раз после обновления
+ community_still_exists = await page.query_selector(f'table tbody tr:has-text("{test_community_slug}")')
+
+ if community_still_exists:
+ # Делаем скриншот для отладки
+ await page.screenshot(path="test-results/community_still_exists.png")
+
+ # Получаем список всех сообществ для отладки
+ all_communities = await page.evaluate("""
+ () => {
+ const rows = document.querySelectorAll('table tbody tr');
+ return Array.from(rows).map(row => {
+ const cells = row.querySelectorAll('td');
+ return {
+ id: cells[0]?.textContent?.trim(),
+ name: cells[1]?.textContent?.trim(),
+ slug: cells[2]?.textContent?.trim()
+ };
+ });
+ }
+ """)
+
+ print(f"📋 Сообщества в таблице после обновления: {all_communities}")
+ raise Exception(f"Сообщество {test_community_name} (slug: {test_community_slug}) все еще отображается после удаления и обновления страницы")
+ else:
+ print("✅ Сообщество удалено после обновления страницы")
+
+ print("✅ Сообщество действительно удалено из списка")
+
+ # 7. Делаем скриншот результата
+ await page.screenshot(path="test-results/community_deleted_success.png")
+ print("📸 Скриншот сохранен: test-results/community_deleted_success.png")
+
+ print("🎉 E2E тест удаления сообщества прошел успешно!")
+
+ except Exception as e:
+ print(f"❌ Ошибка в E2E тесте: {e}")
+
+ # Делаем скриншот при ошибке
+ try:
+ await page.screenshot(path=f"test-results/error_{int(time.time())}.png")
+ print("📸 Скриншот ошибки сохранен")
+ except Exception as screenshot_error:
+ print(f"⚠️ Не удалось сделать скриншот при ошибке: {screenshot_error}")
+
+ raise
+
+ async def test_community_delete_without_permissions_browser(self, browser_setup, test_community_for_browser):
+ """Тест попытки удаления без прав через браузер"""
+
+ page = browser_setup["page"]
+
+ try:
+ # 1. Открываем админ-панель
+ print("🔄 Открываем админ-панель...")
+ await page.goto("http://localhost:3000/admin")
+ await page.wait_for_load_state("networkidle")
+
+ # 2. Авторизуемся как обычный пользователь (без прав admin)
+ print("🔐 Авторизуемся как обычный пользователь...")
+ import os
+ regular_username = os.getenv("TEST_REGULAR_USERNAME", "user2@example.com")
+ password = os.getenv("E2E_TEST_PASSWORD", "password123")
+
+ await page.fill("input[type='email']", regular_username)
+ await page.fill("input[type='password']", password)
+ await page.click("button[type='submit']")
+ await page.wait_for_load_state("networkidle")
+
+ # 3. Переходим на страницу сообществ
+ print("🏘️ Переходим на страницу сообществ...")
+ await page.click("a[href='/admin/communities']")
+ await page.wait_for_load_state("networkidle")
+
+ # 4. Ищем сообщество
+ print(f"🔍 Ищем сообщество: {test_community_for_browser.name}")
+ community_row = await page.wait_for_selector(
+ f"tr:has-text('{test_community_for_browser.name}')",
+ timeout=10000
+ )
+
+ if not community_row:
+ print("❌ Сообщество не найдено")
+ await page.screenshot(path="test-results/community_not_found_no_permissions.png")
+ raise Exception("Сообщество не найдено")
+
+ # 5. Проверяем что кнопка удаления недоступна или отсутствует
+ print("🔒 Проверяем доступность кнопки удаления...")
+ delete_button = await community_row.query_selector("button:has-text('Удалить')")
+
+ if delete_button:
+ # Если кнопка есть, пробуем нажать и проверяем ошибку
+ print("⚠️ Кнопка удаления найдена, пробуем нажать...")
+ await delete_button.click()
+
+ # Ждем появления ошибки
+ await page.wait_for_selector("[role='alert']", timeout=5000)
+ error_message = await page.text_content("[role='alert']")
+
+ if "Недостаточно прав" in error_message or "permission" in error_message.lower():
+ print("✅ Ошибка доступа получена корректно")
+ else:
+ print(f"❌ Неожиданная ошибка: {error_message}")
+ await page.screenshot(path="test-results/unexpected_error.png")
+ raise Exception(f"Неожиданная ошибка: {error_message}")
+ else:
+ print("✅ Кнопка удаления недоступна (как и должно быть)")
+
+ # 6. Проверяем что сообщество осталось в БД
+ print("🗄️ Проверяем что сообщество осталось в БД...")
+ with local_session() as session:
+ community = session.query(Community).filter_by(
+ slug=test_community_for_browser.slug
+ ).first()
+
+ if not community:
+ print("❌ Сообщество было удалено без прав")
+ raise Exception("Сообщество было удалено без соответствующих прав")
+
+ print("✅ Сообщество осталось в БД (как и должно быть)")
+
+ print("🎉 E2E тест проверки прав доступа прошел успешно!")
+
+ except Exception as e:
+ try:
+ await page.screenshot(path=f"test-results/error_permissions_{int(time.time())}.png")
+ except:
+ print("⚠️ Не удалось сделать скриншот при ошибке")
+ print(f"❌ Ошибка в E2E тесте прав доступа: {e}")
+ raise
+
+ async def test_community_delete_ui_validation(self, browser_setup, test_community_for_browser, admin_user_for_browser):
+ """Тест UI валидации при удалении сообщества"""
+
+ page = browser_setup["page"]
+
+ try:
+ # 1. Авторизуемся как админ
+ print("🔐 Авторизуемся как админ...")
+ await page.goto("http://localhost:3000/admin")
+ await page.wait_for_load_state("networkidle")
+
+ import os
+ username = os.getenv("E2E_TEST_USERNAME", "test_admin@discours.io")
+ password = os.getenv("E2E_TEST_PASSWORD", "password123")
+
+ await page.fill("input[type='email']", username)
+ await page.fill("input[type='password']", password)
+ await page.click("button[type='submit']")
+ await page.wait_for_load_state("networkidle")
+
+ # 2. Переходим на страницу сообществ
+ print("🏘️ Переходим на страницу сообществ...")
+ await page.click("a[href='/admin/communities']")
+ await page.wait_for_load_state("networkidle")
+
+ # 3. Ищем сообщество и нажимаем удаление
+ print(f"🔍 Ищем сообщество: {test_community_for_browser.name}")
+ community_row = await page.wait_for_selector(
+ f"tr:has-text('{test_community_for_browser.name}')",
+ timeout=10000
+ )
+
+ delete_button = await community_row.query_selector("button:has-text('Удалить')")
+ await delete_button.click()
+
+ # 4. Проверяем модальное окно
+ print("⚠️ Проверяем модальное окно...")
+ modal = await page.wait_for_selector("[role='dialog']", timeout=10000)
+
+ # Проверяем текст предупреждения
+ modal_text = await modal.text_content()
+ if "удалить" not in modal_text.lower() and "delete" not in modal_text.lower():
+ print(f"❌ Неожиданный текст в модальном окне: {modal_text}")
+ await page.screenshot(path="test-results/unexpected_modal_text.png")
+ raise Exception("Неожиданный текст в модальном окне")
+
+ # 5. Отменяем удаление
+ print("❌ Отменяем удаление...")
+ cancel_button = await page.query_selector("button:has-text('Отмена')")
+ if not cancel_button:
+ cancel_button = await page.query_selector("button:has-text('Cancel')")
+
+ if cancel_button:
+ await cancel_button.click()
+
+ # Проверяем что модальное окно закрылось
+ await page.wait_for_selector("[role='dialog']", state="hidden", timeout=5000)
+
+ # Проверяем что сообщество осталось в таблице
+ community_still_exists = await page.query_selector(
+ f"tr:has-text('{test_community_for_browser.name}')"
+ )
+
+ if not community_still_exists:
+ print("❌ Сообщество исчезло после отмены")
+ await page.screenshot(path="community_disappeared_after_cancel.png")
+ raise Exception("Сообщество исчезло после отмены удаления")
+
+ print("✅ Сообщество осталось после отмены")
+ else:
+ print("⚠️ Кнопка отмены не найдена")
+
+ print("🎉 E2E тест UI валидации прошел успешно!")
+
+ except Exception as e:
+ try:
+ await page.screenshot(path=f"test-results/error_ui_validation_{int(time.time())}.png")
+ except:
+ print("⚠️ Не удалось сделать скриншот при ошибке")
+ print(f"❌ Ошибка в E2E тесте UI валидации: {e}")
+ raise
diff --git a/tests/test_community_rbac.py b/tests/test_community_rbac.py
index 55e4aa78..34ba445a 100644
--- a/tests/test_community_rbac.py
+++ b/tests/test_community_rbac.py
@@ -298,7 +298,7 @@ class TestCommunityRoleInheritance:
assert has_permission, f"Artist должен наследовать разрешение {perm} от reader через author"
# Проверяем специфичные разрешения artist
- artist_permissions = ["reaction:create:CREDIT", "reaction:read:CREDIT", "reaction:update_own:CREDIT"]
+ artist_permissions = ["reaction:create:CREDIT", "reaction:read:CREDIT", "reaction:update:CREDIT"]
for perm in artist_permissions:
has_permission = await user_has_permission(user.id, perm, community.id)
assert has_permission, f"Artist должен иметь разрешение {perm}"
diff --git a/tests/test_custom_roles.py b/tests/test_custom_roles.py
new file mode 100644
index 00000000..58b6845e
--- /dev/null
+++ b/tests/test_custom_roles.py
@@ -0,0 +1,161 @@
+"""
+Тесты для функциональности кастомных ролей
+"""
+
+import pytest
+import json
+from services.redis import redis
+from services.db import local_session
+from orm.community import Community
+from resolvers.admin import admin_create_custom_role, admin_delete_custom_role, admin_get_roles
+
+
+class TestCustomRoles:
+ """Тесты для кастомных ролей"""
+
+ @pytest.mark.asyncio
+ async def test_create_custom_role(self, session):
+ """Тест создания кастомной роли"""
+ # Создаем тестовое сообщество
+ community = Community(
+ name="Test Community",
+ slug="test-community",
+ desc="Test community for custom roles",
+ created_by=1,
+ created_at=1234567890
+ )
+ session.add(community)
+ session.flush()
+
+ # Данные для создания роли
+ role_data = {
+ "id": "custom_moderator",
+ "name": "Модератор",
+ "description": "Кастомная роль модератора",
+ "icon": "shield",
+ "community_id": community.id
+ }
+
+ # Создаем роль
+ result = await admin_create_custom_role(None, None, role_data)
+
+ # Проверяем результат
+ assert result["success"] is True
+ assert result["role"]["id"] == "custom_moderator"
+ assert result["role"]["name"] == "Модератор"
+ assert result["role"]["description"] == "Кастомная роль модератора"
+
+ # Проверяем, что роль сохранена в Redis
+ role_json = await redis.execute("HGET", f"community:custom_roles:{community.id}", "custom_moderator")
+ assert role_json is not None
+
+ role_data_redis = json.loads(role_json)
+ assert role_data_redis["id"] == "custom_moderator"
+ assert role_data_redis["name"] == "Модератор"
+ assert role_data_redis["description"] == "Кастомная роль модератора"
+ assert role_data_redis["icon"] == "shield"
+ assert role_data_redis["permissions"] == []
+
+ @pytest.mark.asyncio
+ async def test_create_duplicate_role(self, session):
+ """Тест создания дублирующей роли"""
+ # Создаем тестовое сообщество
+ community = Community(
+ name="Test Community 2",
+ slug="test-community-2",
+ desc="Test community for duplicate roles",
+ created_by=1,
+ created_at=1234567890
+ )
+ session.add(community)
+ session.flush()
+
+ # Данные для создания роли
+ role_data = {
+ "id": "duplicate_role",
+ "name": "Дублирующая роль",
+ "description": "Тестовая роль",
+ "community_id": community.id
+ }
+
+ # Создаем роль первый раз
+ result1 = await admin_create_custom_role(None, None, role_data)
+ assert result1["success"] is True
+
+ # Пытаемся создать роль с тем же ID
+ result2 = await admin_create_custom_role(None, None, role_data)
+ assert result2["success"] is False
+ assert "уже существует" in result2["error"]
+
+ @pytest.mark.asyncio
+ async def test_delete_custom_role(self, session):
+ """Тест удаления кастомной роли"""
+ # Создаем тестовое сообщество
+ community = Community(
+ name="Test Community 3",
+ slug="test-community-3",
+ desc="Test community for role deletion",
+ created_by=1,
+ created_at=1234567890
+ )
+ session.add(community)
+ session.flush()
+
+ # Создаем роль
+ role_data = {
+ "id": "role_to_delete",
+ "name": "Роль для удаления",
+ "description": "Тестовая роль",
+ "community_id": community.id
+ }
+
+ create_result = await admin_create_custom_role(None, None, role_data)
+ assert create_result["success"] is True
+
+ # Удаляем роль
+ delete_result = await admin_delete_custom_role(None, None, "role_to_delete", community.id)
+ assert delete_result["success"] is True
+
+ # Проверяем, что роль удалена из Redis
+ role_json = await redis.execute("HGET", f"community:custom_roles:{community.id}", "role_to_delete")
+ assert role_json is None
+
+ @pytest.mark.asyncio
+ async def test_get_roles_with_custom(self, session):
+ """Тест получения ролей с кастомными"""
+ # Создаем тестовое сообщество
+ community = Community(
+ name="Test Community 4",
+ slug="test-community-4",
+ desc="Test community for role listing",
+ created_by=1,
+ created_at=1234567890
+ )
+ session.add(community)
+ session.flush()
+
+ # Создаем кастомную роль
+ role_data = {
+ "id": "test_custom_role",
+ "name": "Тестовая кастомная роль",
+ "description": "Описание тестовой роли",
+ "community_id": community.id
+ }
+
+ await admin_create_custom_role(None, None, role_data)
+
+ # Получаем роли для сообщества
+ roles = await admin_get_roles(None, None, community.id)
+
+ # Проверяем, что кастомная роль есть в списке
+ custom_role = next((role for role in roles if role["id"] == "test_custom_role"), None)
+ assert custom_role is not None
+ assert custom_role["name"] == "Тестовая кастомная роль"
+ assert custom_role["description"] == "Описание тестовой роли"
+
+ # Проверяем, что базовые роли тоже есть
+ base_role_ids = [role["id"] for role in roles]
+ assert "reader" in base_role_ids
+ assert "author" in base_role_ids
+ assert "editor" in base_role_ids
+ assert "admin" in base_role_ids
diff --git a/tests/test_rbac_integration.py b/tests/test_rbac_integration.py
index 0b125840..b9eda4a6 100644
--- a/tests/test_rbac_integration.py
+++ b/tests/test_rbac_integration.py
@@ -262,7 +262,7 @@ class TestRBACIntegrationWithInheritance:
assert has_permission, f"Artist должен наследовать разрешение {perm} от reader через author"
# Проверяем специфичные разрешения artist
- artist_permissions = ["reaction:create:CREDIT", "reaction:read:CREDIT", "reaction:update_own:CREDIT"]
+ artist_permissions = ["reaction:create:CREDIT", "reaction:read:CREDIT", "reaction:update:CREDIT"]
for perm in artist_permissions:
has_permission = await user_has_permission(simple_user.id, perm, test_community.id, db_session)
assert has_permission, f"Artist должен иметь разрешение {perm}"
diff --git a/tests/test_rbac_system.py b/tests/test_rbac_system.py
index 799e553a..b7c66fd2 100644
--- a/tests/test_rbac_system.py
+++ b/tests/test_rbac_system.py
@@ -74,7 +74,7 @@ class TestRBACRoleInheritance:
assert perm in author_permissions, f"Author должен наследовать разрешение {perm} от reader"
# Проверяем что author имеет дополнительные разрешения
- author_specific = ["draft:read", "draft:create", "shout:create", "shout:update_own"]
+ author_specific = ["draft:read", "draft:create", "shout:create", "shout:update"]
for perm in author_specific:
assert perm in author_permissions, f"Author должен иметь разрешение {perm}"
@@ -142,7 +142,7 @@ class TestRBACRoleInheritance:
assert perm in artist_permissions, f"Artist должен наследовать разрешение {perm} от author"
# Проверяем что artist имеет дополнительные разрешения
- artist_specific = ["reaction:create:CREDIT", "reaction:read:CREDIT", "reaction:update_own:CREDIT"]
+ artist_specific = ["reaction:create:CREDIT", "reaction:read:CREDIT", "reaction:update:CREDIT"]
for perm in artist_specific:
assert perm in artist_permissions, f"Artist должен иметь разрешение {perm}"