Squashed new RBAC
All checks were successful
Deploy on push / deploy (push) Successful in 7s

This commit is contained in:
2025-07-02 22:30:21 +03:00
parent 7585dae0ab
commit 82111ed0f6
100 changed files with 14785 additions and 5888 deletions

View File

@@ -8,20 +8,79 @@ from sqlalchemy.orm import joinedload
from sqlalchemy.sql import func, select
from auth.decorators import admin_auth_required
from auth.orm import Author, AuthorRole, Role
from orm.community import Community
from auth.orm import Author
from orm.community import Community, CommunityAuthor
from orm.invite import Invite, InviteStatus
from orm.shout import Shout
from services.db import local_session
from services.env import EnvManager, EnvVariable
from services.rbac import admin_only
from services.schema import mutation, query
from settings import ADMIN_EMAILS as ADMIN_EMAILS_LIST
from utils.logger import root_logger as logger
# Преобразуем строку ADMIN_EMAILS в список
ADMIN_EMAILS = ADMIN_EMAILS_LIST.split(",") if ADMIN_EMAILS_LIST else []
# Создаем роли в сообществе если они не существуют
default_role_names = {
"reader": "Читатель",
"author": "Автор",
"artist": "Художник",
"expert": "Эксперт",
"editor": "Редактор",
"admin": "Администратор",
}
default_role_descriptions = {
"reader": "Может читать и комментировать",
"author": "Может создавать публикации",
"artist": "Может быть credited artist",
"expert": "Может добавлять доказательства",
"editor": "Может модерировать контент",
"admin": "Полные права",
}
def _get_user_roles(user: Author, community_id: int = 1) -> list[str]:
"""
Получает полный список ролей пользователя в указанном сообществе, включая
синтетическую роль "Системный администратор" для пользователей из ADMIN_EMAILS
Args:
user: Объект пользователя
community_id: ID сообщества для получения ролей
Returns:
Список строк с названиями ролей
"""
user_roles = []
# Получаем роли пользователя из новой RBAC системы
with local_session() as session:
community_author = (
session.query(CommunityAuthor)
.filter(CommunityAuthor.author_id == user.id, CommunityAuthor.community_id == community_id)
.first()
)
if community_author and community_author.roles:
# Разбираем CSV строку с ролями
user_roles = [role.strip() for role in community_author.roles.split(",") if role.strip()]
# Если email пользователя в списке ADMIN_EMAILS, добавляем синтетическую роль
# ВАЖНО: Эта роль НЕ хранится в базе данных, а добавляется только для отображения
if user.email and user.email.lower() in [email.lower() for email in ADMIN_EMAILS]:
if "Системный администратор" not in user_roles:
user_roles.insert(0, "Системный администратор")
return user_roles
@query.field("adminGetUsers")
@admin_auth_required
async def admin_get_users(
_: None, _info: GraphQLResolveInfo, limit: int = 10, offset: int = 0, search: str = ""
_: None, _info: GraphQLResolveInfo, limit: int = 20, offset: int = 0, search: str = ""
) -> dict[str, Any]:
"""
Получает список пользователей для админ-панели с поддержкой пагинации и поиска
@@ -37,7 +96,7 @@ async def admin_get_users(
"""
try:
# Нормализуем параметры
limit = max(1, min(100, limit or 10)) # Ограничиваем количество записей от 1 до 100
limit = max(1, min(100, limit or 20)) # Ограничиваем количество записей от 1 до 100
offset = max(0, offset or 0) # Смещение не может быть отрицательным
with local_session() as session:
@@ -77,7 +136,7 @@ async def admin_get_users(
"email": user.email,
"name": user.name,
"slug": user.slug,
"roles": [role.id for role in user.roles] if hasattr(user, "roles") and user.roles else [],
"roles": _get_user_roles(user, 1), # Получаем роли в основном сообществе
"created_at": user.created_at,
"last_seen": user.last_seen,
}
@@ -100,32 +159,63 @@ async def admin_get_users(
@query.field("adminGetRoles")
@admin_auth_required
async def admin_get_roles(_: None, info: GraphQLResolveInfo) -> list[dict[str, Any]]:
async def admin_get_roles(_: None, info: GraphQLResolveInfo, community: int = None) -> list[dict[str, Any]]:
"""
Получает список всех ролей в системе
Получает список всех ролей в системе или ролей для конкретного сообщества
Args:
info: Контекст GraphQL запроса
community: ID сообщества для фильтрации ролей (опционально)
Returns:
Список ролей
"""
try:
with local_session() as session:
# Загружаем роли с их разрешениями
roles = session.query(Role).options(joinedload(Role.permissions)).all()
from orm.community import role_descriptions, role_names
from services.rbac import get_permissions_for_role
# Преобразуем их в формат для API
return [
# Используем словари названий и описаний ролей из новой системы
all_roles = ["reader", "author", "artist", "expert", "editor", "admin"]
if community is not None:
# Получаем доступные роли для конкретного сообщества
with local_session() as session:
from orm.community import Community
community_obj = session.query(Community).filter(Community.id == community).first()
if community_obj:
available_roles = community_obj.get_available_roles()
else:
available_roles = all_roles
else:
# Возвращаем все системные роли
available_roles = all_roles
# Формируем список ролей с их описаниями и разрешениями
roles_list = []
for role_id in available_roles:
# Получаем название и описание роли
name = role_names.get(role_id, role_id.title())
description = role_descriptions.get(role_id, f"Роль {name}")
# Для конкретного сообщества получаем разрешения
if community is not None:
try:
permissions = await get_permissions_for_role(role_id, community)
perm_count = len(permissions)
description = f"{description} ({perm_count} разрешений)"
except Exception:
description = f"{description} (права не инициализированы)"
roles_list.append(
{
"id": role.id,
"name": role.name,
"description": f"Роль с правами: {', '.join(p.resource + ':' + p.operation for p in role.permissions)}"
if role.permissions
else "Роль без особых прав",
"id": role_id,
"name": name,
"description": description,
}
for role in roles
]
)
return roles_list
except Exception as e:
logger.error(f"Ошибка при получении списка ролей: {e!s}")
@@ -134,7 +224,7 @@ async def admin_get_roles(_: None, info: GraphQLResolveInfo) -> list[dict[str, A
@query.field("getEnvVariables")
@admin_auth_required
@admin_only
async def get_env_variables(_: None, info: GraphQLResolveInfo) -> list[dict[str, Any]]:
"""
Получает список переменных окружения, сгруппированных по секциям
@@ -263,6 +353,16 @@ async def admin_update_user(_: None, info: GraphQLResolveInfo, user: dict[str, A
"""
try:
user_id = user.get("id")
# Проверяем что user_id не None
if user_id is None:
return {"success": False, "error": "ID пользователя не указан"}
try:
user_id_int = int(user_id)
except (TypeError, ValueError):
return {"success": False, "error": "Некорректный ID пользователя"}
roles = user.get("roles", [])
email = user.get("email")
name = user.get("name")
@@ -306,32 +406,42 @@ async def admin_update_user(_: None, info: GraphQLResolveInfo, user: dict[str, A
default_community_id = 1 # Используем значение по умолчанию из модели AuthorRole
try:
# Очищаем текущие роли пользователя через ORM
session.query(AuthorRole).filter(AuthorRole.author == user_id).delete()
session.flush()
# Получаем или создаем запись CommunityAuthor для основного сообщества
community_author = (
session.query(CommunityAuthor)
.filter(
CommunityAuthor.author_id == user_id_int, CommunityAuthor.community_id == default_community_id
)
.first()
)
# Получаем все существующие роли, которые указаны для обновления
role_objects = session.query(Role).filter(Role.id.in_(roles)).all()
if not community_author:
# Создаем новую запись
community_author = CommunityAuthor(
author_id=user_id_int, community_id=default_community_id, roles=""
)
session.add(community_author)
session.flush()
# Проверяем, все ли запрошенные роли найдены
found_role_ids = [str(role.id) for role in role_objects]
missing_roles = set(roles) - set(found_role_ids)
# Проверяем валидность ролей
all_roles = ["reader", "author", "artist", "expert", "editor", "admin"]
invalid_roles = set(roles) - set(all_roles)
if missing_roles:
warning_msg = f"Некоторые роли не найдены в базе: {', '.join(missing_roles)}"
if invalid_roles:
warning_msg = f"Некоторые роли не поддерживаются: {', '.join(invalid_roles)}"
logger.warning(warning_msg)
# Оставляем только валидные роли
roles = [role for role in roles if role in all_roles]
# Создаем новые записи в таблице author_role с указанием community
for role in role_objects:
# Используем ORM для создания новых записей
author_role = AuthorRole(community=default_community_id, author=user_id, role=role.id)
session.add(author_role)
# Обновляем роли в CSV формате
for r in roles:
community_author.remove_role(r)
# Сохраняем изменения в базе данных
session.commit()
# Проверяем, добавлена ли пользователю роль reader
has_reader = "reader" in [str(role.id) for role in role_objects]
has_reader = "reader" in roles
if not has_reader:
logger.warning(
f"Пользователю {author.email or author.id} не назначена роль 'reader'. Доступ в систему будет ограничен."
@@ -341,7 +451,7 @@ async def admin_update_user(_: None, info: GraphQLResolveInfo, user: dict[str, A
if profile_updated:
update_details.append("профиль")
if roles:
update_details.append(f"роли: {', '.join(found_role_ids)}")
update_details.append(f"роли: {', '.join(roles)}")
logger.info(f"Данные пользователя {author.email or author.id} обновлены: {', '.join(update_details)}")
@@ -367,7 +477,13 @@ async def admin_update_user(_: None, info: GraphQLResolveInfo, user: dict[str, A
@query.field("adminGetShouts")
@admin_auth_required
async def admin_get_shouts(
_: None, info: GraphQLResolveInfo, limit: int = 10, offset: int = 0, search: str = "", status: str = "all"
_: None,
info: GraphQLResolveInfo,
limit: int = 20,
offset: int = 0,
search: str = "",
status: str = "all",
community: int = None,
) -> dict[str, Any]:
"""
Получает список публикаций для админ-панели с поддержкой пагинации и поиска
@@ -378,6 +494,7 @@ async def admin_get_shouts(
offset: Смещение в списке результатов
search: Строка поиска (по заголовку, slug или ID)
status: Статус публикаций (all, published, draft, deleted)
community: ID сообщества для фильтрации
Returns:
Пагинированный список публикаций
@@ -407,6 +524,10 @@ async def admin_get_shouts(
elif status == "deleted":
q = q.filter(Shout.deleted_at.isnot(None))
# Применяем фильтр по сообществу, если указан
if community is not None:
q = q.filter(Shout.community == community)
# Применяем фильтр поиска, если указан
if search and search.strip():
search_term = f"%{search.strip().lower()}%"
@@ -771,7 +892,7 @@ async def admin_restore_shout(_: None, info: GraphQLResolveInfo, shout_id: int)
@query.field("adminGetInvites")
@admin_auth_required
async def admin_get_invites(
_: None, _info: GraphQLResolveInfo, limit: int = 10, offset: int = 0, search: str = "", status: str = "all"
_: None, _info: GraphQLResolveInfo, limit: int = 20, offset: int = 0, search: str = "", status: str = "all"
) -> dict[str, Any]:
"""
Получает список приглашений для админ-панели с поддержкой пагинации и поиска
@@ -948,77 +1069,6 @@ async def admin_get_invites(
raise GraphQLError(msg) from e
@mutation.field("adminCreateInvite")
@admin_auth_required
async def admin_create_invite(_: None, _info: GraphQLResolveInfo, invite: dict[str, Any]) -> dict[str, Any]:
"""
Создает новое приглашение
Args:
_info: Контекст GraphQL запроса
invite: Данные приглашения
Returns:
Результат операции
"""
try:
inviter_id = invite["inviter_id"]
author_id = invite["author_id"]
shout_id = invite["shout_id"]
status = invite["status"]
with local_session() as session:
# Проверяем существование всех связанных объектов
inviter = session.query(Author).filter(Author.id == inviter_id).first()
if not inviter:
return {"success": False, "error": f"Приглашающий автор с ID {inviter_id} не найден"}
author = session.query(Author).filter(Author.id == author_id).first()
if not author:
return {"success": False, "error": f"Приглашаемый автор с ID {author_id} не найден"}
shout = session.query(Shout).filter(Shout.id == shout_id).first()
if not shout:
return {"success": False, "error": f"Публикация с ID {shout_id} не найдена"}
# Проверяем, не существует ли уже такое приглашение
existing_invite = (
session.query(Invite)
.filter(
Invite.inviter_id == inviter_id,
Invite.author_id == author_id,
Invite.shout_id == shout_id,
)
.first()
)
if existing_invite:
return {
"success": False,
"error": f"Приглашение от {inviter.name} для {author.name} на публикацию '{shout.title}' уже существует",
}
# Создаем новое приглашение
new_invite = Invite(
inviter_id=inviter_id,
author_id=author_id,
shout_id=shout_id,
status=status,
)
session.add(new_invite)
session.commit()
logger.info(f"Создано приглашение: {inviter.name} приглашает {author.name} к публикации '{shout.title}'")
return {"success": True, "error": None}
except Exception as e:
logger.error(f"Ошибка при создании приглашения: {e!s}")
msg = f"Не удалось создать приглашение: {e!s}"
raise GraphQLError(msg) from e
@mutation.field("adminUpdateInvite")
@admin_auth_required
async def admin_update_invite(_: None, _info: GraphQLResolveInfo, invite: dict[str, Any]) -> dict[str, Any]:
@@ -1185,3 +1235,522 @@ async def admin_delete_invites_batch(
logger.error(f"Ошибка при пакетном удалении приглашений: {e!s}")
msg = f"Не удалось выполнить пакетное удаление приглашений: {e!s}"
raise GraphQLError(msg) from e
@query.field("adminGetUserCommunityRoles")
@admin_auth_required
async def admin_get_user_community_roles(
_: None, info: GraphQLResolveInfo, author_id: int, community_id: int
) -> dict[str, Any]:
"""
Получает роли пользователя в конкретном сообществе
Args:
author_id: ID пользователя
community_id: ID сообщества
Returns:
Словарь с ролями пользователя в сообществе
"""
try:
with local_session() as session:
# Получаем роли пользователя из новой RBAC системы
community_author = (
session.query(CommunityAuthor)
.filter(CommunityAuthor.author_id == author_id, CommunityAuthor.community_id == community_id)
.first()
)
roles = []
if community_author and community_author.roles:
roles = [role.strip() for role in community_author.roles.split(",") if role.strip()]
return {"author_id": author_id, "community_id": community_id, "roles": roles}
except Exception as e:
logger.error(f"Ошибка при получении ролей пользователя в сообществе: {e!s}")
msg = f"Не удалось получить роли пользователя: {e!s}"
raise GraphQLError(msg) from e
@mutation.field("adminUpdateUserCommunityRoles")
@admin_auth_required
async def admin_update_user_community_roles(
_: None, info: GraphQLResolveInfo, author_id: int, community_id: int, roles: list[str]
) -> dict[str, Any]:
"""
Обновляет роли пользователя в конкретном сообществе
Args:
author_id: ID пользователя
community_id: ID сообщества
roles: Список ID ролей для назначения
Returns:
Результат операции
"""
try:
with local_session() as session:
# Проверяем существование пользователя
author = session.query(Author).filter(Author.id == author_id).first()
if not author:
return {"success": False, "error": f"Пользователь с ID {author_id} не найден"}
# Проверяем существование сообщества
community = session.query(Community).filter(Community.id == community_id).first()
if not community:
return {"success": False, "error": f"Сообщество с ID {community_id} не найдено"}
# Проверяем валидность ролей
available_roles = community.get_available_roles()
invalid_roles = set(roles) - set(available_roles)
if invalid_roles:
return {"success": False, "error": f"Роли недоступны в этом сообществе: {list(invalid_roles)}"}
# Получаем или создаем запись CommunityAuthor
community_author = (
session.query(CommunityAuthor)
.filter(CommunityAuthor.author_id == author_id, CommunityAuthor.community_id == community_id)
.first()
)
if not community_author:
community_author = CommunityAuthor(author_id=author_id, community_id=community_id, roles="")
session.add(community_author)
# Обновляем роли в CSV формате
for r in roles:
community_author.remove_role(r)
session.commit()
logger.info(f"Роли пользователя {author_id} в сообществе {community_id} обновлены: {roles}")
return {"success": True, "author_id": author_id, "community_id": community_id, "roles": roles}
except Exception as e:
logger.error(f"Ошибка при обновлении ролей пользователя в сообществе: {e!s}")
msg = f"Не удалось обновить роли пользователя: {e!s}"
return {"success": False, "error": msg}
@query.field("adminGetCommunityMembers")
@admin_auth_required
async def admin_get_community_members(
_: None, info: GraphQLResolveInfo, community_id: int, limit: int = 20, offset: int = 0
) -> dict[str, Any]:
"""
Получает список участников сообщества с их ролями
Args:
community_id: ID сообщества
limit: Максимальное количество записей
offset: Смещение для пагинации
Returns:
Список участников сообщества с ролями
"""
try:
with local_session() as session:
# Получаем участников сообщества из CommunityAuthor (новая RBAC система)
members_query = (
session.query(Author, CommunityAuthor)
.join(CommunityAuthor, Author.id == CommunityAuthor.author_id)
.filter(CommunityAuthor.community_id == community_id)
.offset(offset)
.limit(limit)
)
members = []
for author, community_author in members_query:
# Парсим роли из CSV
roles = []
if community_author.roles:
roles = [role.strip() for role in community_author.roles.split(",") if role.strip()]
members.append(
{
"id": author.id,
"name": author.name,
"email": author.email,
"slug": author.slug,
"roles": roles,
}
)
# Подсчитываем общее количество участников
total = (
session.query(func.count(CommunityAuthor.author_id))
.filter(CommunityAuthor.community_id == community_id)
.scalar()
)
return {"members": members, "total": total, "community_id": community_id}
except Exception as e:
logger.error(f"Ошибка получения участников сообщества: {e}")
return {"members": [], "total": 0, "community_id": community_id}
@mutation.field("adminSetUserCommunityRoles")
@admin_auth_required
async def admin_set_user_community_roles(
_: None, info: GraphQLResolveInfo, author_id: int, community_id: int, roles: list[str]
) -> dict[str, Any]:
"""
Устанавливает роли пользователя в сообществе (заменяет все существующие роли)
Args:
author_id: ID пользователя
community_id: ID сообщества
roles: Список ролей для назначения
Returns:
Результат операции
"""
try:
with local_session() as session:
# Проверяем существование пользователя
author = session.query(Author).filter(Author.id == author_id).first()
if not author:
return {
"success": False,
"error": f"Пользователь {author_id} не найден",
"author_id": author_id,
"community_id": community_id,
"roles": [],
}
# Проверяем существование сообщества
community = session.query(Community).filter(Community.id == community_id).first()
if not community:
return {
"success": False,
"error": f"Сообщество {community_id} не найдено",
"author_id": author_id,
"community_id": community_id,
"roles": [],
}
# Проверяем, что все роли доступны в сообществе
available_roles = community.get_available_roles()
invalid_roles = set(roles) - set(available_roles)
if invalid_roles:
return {
"success": False,
"error": f"Роли недоступны в этом сообществе: {list(invalid_roles)}",
"author_id": author_id,
"community_id": community_id,
"roles": roles,
}
# Получаем или создаем запись CommunityAuthor
community_author = (
session.query(CommunityAuthor)
.filter(CommunityAuthor.author_id == author_id, CommunityAuthor.community_id == community_id)
.first()
)
if not community_author:
community_author = CommunityAuthor(author_id=author_id, community_id=community_id, roles="")
session.add(community_author)
# Обновляем роли в CSV формате
community_author.set_roles(roles)
session.commit()
logger.info(f"Назначены роли {roles} пользователю {author_id} в сообществе {community_id}")
return {
"success": True,
"error": None,
"author_id": author_id,
"community_id": community_id,
"roles": roles,
}
except Exception as e:
logger.error(f"Ошибка назначения ролей пользователю {author_id} в сообществе {community_id}: {e}")
return {"success": False, "error": str(e), "author_id": author_id, "community_id": community_id, "roles": []}
@mutation.field("adminAddUserToRole")
@admin_auth_required
async def admin_add_user_to_role(
_: None, info: GraphQLResolveInfo, author_id: int, role_id: str, community_id: int
) -> dict[str, Any]:
"""
Добавляет пользователю роль в сообществе
Args:
author_id: ID пользователя
role_id: ID роли
community_id: ID сообщества
Returns:
Результат операции
"""
try:
with local_session() as session:
# Получаем или создаем запись CommunityAuthor
community_author = (
session.query(CommunityAuthor)
.filter(CommunityAuthor.author_id == author_id, CommunityAuthor.community_id == community_id)
.first()
)
if not community_author:
community_author = CommunityAuthor(author_id=author_id, community_id=community_id, roles=role_id)
session.add(community_author)
else:
# Проверяем, что роль не назначена уже
if role_id in community_author.role_list:
return {"success": False, "error": "Роль уже назначена пользователю"}
# Добавляем новую роль
community_author.add_role(role_id)
session.commit()
return {"success": True, "author_id": author_id, "role_id": role_id, "community_id": community_id}
except Exception as e:
logger.error(f"Ошибка добавления роли пользователю: {e}")
return {"success": False, "error": str(e)}
@mutation.field("adminRemoveUserFromRole")
@admin_auth_required
async def admin_remove_user_from_role(
_: None, info: GraphQLResolveInfo, author_id: int, role_id: str, community_id: int
) -> dict[str, Any]:
"""
Удаляет роль у пользователя в сообществе
Args:
author_id: ID пользователя
role_id: ID роли
community_id: ID сообщества
Returns:
Результат операции
"""
try:
with local_session() as session:
community_author = (
session.query(CommunityAuthor)
.filter(CommunityAuthor.author_id == author_id, CommunityAuthor.community_id == community_id)
.first()
)
if not community_author:
return {"success": False, "error": "Пользователь не найден в сообществе"}
if not community_author.has_role(role_id):
return {"success": False, "error": "Роль не найдена у пользователя в сообществе"}
# Используем метод модели для корректного удаления роли
community_author.remove_role(role_id)
session.commit()
return {
"success": True,
"author_id": author_id,
"role_id": role_id,
"community_id": community_id,
}
except Exception as e:
logger.error(f"Error removing user from role: {e}")
return {"success": False, "error": str(e)}
@query.field("adminGetCommunityRoleSettings")
@admin_auth_required
async def admin_get_community_role_settings(_: None, info: GraphQLResolveInfo, community_id: int) -> dict[str, Any]:
"""
Получает настройки ролей для сообщества
Args:
community_id: ID сообщества
Returns:
Настройки ролей сообщества
"""
try:
with local_session() as session:
from orm.community import Community
community = session.query(Community).filter(Community.id == community_id).first()
if not community:
return {
"community_id": community_id,
"default_roles": ["reader"],
"available_roles": ["reader", "author", "artist", "expert", "editor", "admin"],
"error": "Сообщество не найдено",
}
return {
"community_id": community_id,
"default_roles": community.get_default_roles(),
"available_roles": community.get_available_roles(),
"error": None,
}
except Exception as e:
logger.error(f"Error getting community role settings: {e}")
return {
"community_id": community_id,
"default_roles": ["reader"],
"available_roles": ["reader", "author", "artist", "expert", "editor", "admin"],
"error": str(e),
}
@mutation.field("adminUpdateCommunityRoleSettings")
@admin_auth_required
async def admin_update_community_role_settings(
_: None, info: GraphQLResolveInfo, community_id: int, default_roles: list[str], available_roles: list[str]
) -> dict[str, Any]:
"""
Обновляет настройки ролей для сообщества
Args:
community_id: ID сообщества
default_roles: Список дефолтных ролей
available_roles: Список доступных ролей
Returns:
Результат операции
"""
try:
with local_session() as session:
community = session.query(Community).filter(Community.id == community_id).first()
if not community:
return {
"success": False,
"error": f"Сообщество {community_id} не найдено",
"community_id": community_id,
"default_roles": [],
"available_roles": [],
}
return {
"success": True,
"error": None,
"community_id": community_id,
"default_roles": default_roles,
"available_roles": available_roles,
}
except Exception as e:
logger.error(f"Ошибка обновления настроек ролей сообщества {community_id}: {e}")
return {
"success": False,
"error": str(e),
"community_id": community_id,
"default_roles": default_roles,
"available_roles": available_roles,
}
@mutation.field("adminDeleteCustomRole")
@admin_auth_required
async def admin_delete_custom_role(
_: None, info: GraphQLResolveInfo, role_id: str, community_id: int
) -> dict[str, Any]:
"""
Удаляет произвольную роль из сообщества
Args:
role_id: ID роли для удаления
community_id: ID сообщества
Returns:
Результат операции
"""
try:
with local_session() as session:
# Проверяем существование сообщества
community = session.query(Community).filter(Community.id == community_id).first()
if not community:
return {"success": False, "error": f"Сообщество {community_id} не найдено"}
# Удаляем роль из сообщества
current_available = community.get_available_roles()
current_default = community.get_default_roles()
new_available = [r for r in current_available if r != role_id]
new_default = [r for r in current_default if r != role_id]
community.set_available_roles(new_available)
community.set_default_roles(new_default)
session.commit()
logger.info(f"Удалена роль {role_id} из сообщества {community_id}")
return {"success": True, "error": None}
except Exception as e:
logger.error(f"Ошибка удаления роли {role_id} из сообщества {community_id}: {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]:
"""
Создает произвольную роль в сообществе
Args:
role: Данные для создания роли
Returns:
Результат создания роли
"""
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", "role": None}
# Проверяем валидность ID роли
import re
if not re.match(r"^[a-z0-9_-]+$", role_id):
return {
"success": False,
"error": "ID роли может содержать только латинские буквы, цифры, дефисы и подчеркивания",
"role": None,
}
with local_session() as session:
# Проверяем существование сообщества
community = session.query(Community).filter(Community.id == community_id).first()
if not community:
return {"success": False, "error": f"Сообщество {community_id} не найдено", "role": None}
available_roles = community.get_available_roles()
if role_id in available_roles:
return {
"success": False,
"error": f"Роль с ID {role_id} уже существует в сообществе {community_id}",
"role": None,
}
# Добавляем роль в список доступных ролей
community.set_available_roles([*available_roles, role_id])
session.commit()
logger.info(f"Создана роль {role_id} ({name}) в сообществе {community_id}")
return {"success": True, "error": None, "role": {"id": role_id, "name": name, "description": description}}
except Exception as e:
logger.error(f"Ошибка создания роли: {e}")
return {"success": False, "error": str(e), "role": None}

View File

@@ -2,23 +2,24 @@ import json
import secrets
import time
import traceback
from typing import Any
from typing import Any, Dict, List, Union
from graphql import GraphQLResolveInfo
from graphql.error import GraphQLError
from auth.email import send_auth_email
from auth.exceptions import InvalidToken, ObjectNotExist
from auth.exceptions import InvalidPassword, InvalidToken, ObjectNotExist
from auth.identity import Identity, Password
from auth.jwtcodec import JWTCodec
from auth.orm import Author, Role
from auth.orm import Author
from auth.tokens.storage import TokenStorage
# import asyncio # Убираем, так как резолвер будет синхронным
from orm.community import CommunityFollower
from services.auth import login_required
from services.db import local_session
from services.redis import redis
from services.schema import mutation, query
from services.schema import mutation, query, type_author
from settings import (
ADMIN_EMAILS,
SESSION_COOKIE_HTTPONLY,
@@ -30,6 +31,60 @@ from settings import (
from utils.generate_slug import generate_unique_slug
from utils.logger import root_logger as logger
# Создаем роль в сообществе если не существует
role_names = {
"reader": "Читатель",
"author": "Автор",
"artist": "Художник",
"expert": "Эксперт",
"editor": "Редактор",
"admin": "Администратор",
}
role_descriptions = {
"reader": "Может читать и комментировать",
"author": "Может создавать публикации",
"artist": "Может быть credited artist",
"expert": "Может добавлять доказательства",
"editor": "Может модерировать контент",
"admin": "Полные права",
}
# Добавляем резолвер для поля roles в типе Author
@type_author.field("roles")
def resolve_roles(obj: Union[Dict, Any], info: GraphQLResolveInfo) -> List[str]:
"""
Резолвер для поля roles - возвращает список ролей автора
Args:
obj: Объект автора (словарь или ORM объект)
info: Информация о запросе GraphQL
Returns:
List[str]: Список ролей автора
"""
try:
# Если obj это ORM модель Author
if hasattr(obj, "get_roles"):
return obj.get_roles()
# Если obj это словарь
if isinstance(obj, dict):
roles_data = obj.get("roles_data", {})
# Если roles_data это список, возвращаем его
if isinstance(roles_data, list):
return roles_data
# Если roles_data это словарь, возвращаем роли для сообщества 1
if isinstance(roles_data, dict):
return roles_data.get("1", [])
return []
except Exception as e:
print(f"[AuthorType.resolve_roles] Ошибка при получении ролей: {e}")
return []
@mutation.field("getSession")
@login_required
@@ -149,42 +204,82 @@ async def confirm_email(_: None, _info: GraphQLResolveInfo, token: str) -> dict[
}
def create_user(user_dict: dict[str, Any]) -> Author:
"""Create new user in database"""
def create_user(user_dict: dict[str, Any], community_id: int | None = None) -> Author:
"""
Create new user in database with default roles for community
Args:
user_dict: Dictionary with user data
community_id: ID сообщества для назначения дефолтных ролей (по умолчанию 1)
Returns:
Созданный пользователь
"""
user = Author(**user_dict)
target_community_id = community_id or 1 # По умолчанию основное сообщество
with local_session() as session:
# Добавляем пользователя в БД
session.add(user)
session.flush() # Получаем ID пользователя
# Получаем или создаём стандартную роль "reader"
reader_role = session.query(Role).filter(Role.id == "reader").first()
if not reader_role:
reader_role = Role(id="reader", name="Читатель")
session.add(reader_role)
session.flush()
# Получаем сообщество для назначения дефолтных ролей
from orm.community import Community, CommunityAuthor
# Получаем основное сообщество
from orm.community import Community
community = session.query(Community).filter(Community.id == target_community_id).first()
if not community:
logger.warning(f"Сообщество {target_community_id} не найдено, используем сообщество ID=1")
target_community_id = 1
community = session.query(Community).filter(Community.id == target_community_id).first()
main_community = session.query(Community).filter(Community.id == 1).first()
if not main_community:
main_community = Community(
id=1,
name="Discours",
slug="discours",
desc="Cообщество Discours",
created_by=user.id,
if community:
# Инициализируем права сообщества если нужно
try:
import asyncio
loop = asyncio.get_event_loop()
loop.run_until_complete(community.initialize_role_permissions())
except Exception as e:
logger.warning(f"Не удалось инициализировать права сообщества {target_community_id}: {e}")
# Получаем дефолтные роли сообщества или используем стандартные
try:
default_roles = community.get_default_roles()
if not default_roles:
# Если в сообществе нет настроенных дефолтных ролей, используем стандартные
default_roles = ["reader", "author"]
except AttributeError:
# Если метод get_default_roles не существует, используем стандартные роли
default_roles = ["reader", "author"]
logger.info(
f"Назначаем дефолтные роли {default_roles} пользователю {user.id} в сообществе {target_community_id}"
)
session.add(main_community)
session.flush()
# Создаём связь автор-роль-сообщество
from auth.orm import AuthorRole
# Создаем CommunityAuthor с дефолтными ролями
community_author = CommunityAuthor(
community_id=target_community_id,
author_id=user.id,
roles=",".join(default_roles), # CSV строка с ролями
)
session.add(community_author)
logger.info(f"Создана запись CommunityAuthor для пользователя {user.id} с ролями: {default_roles}")
# Добавляем пользователя в подписчики сообщества (CommunityFollower отвечает только за подписку)
existing_follower = (
session.query(CommunityFollower)
.filter(CommunityFollower.community == target_community_id, CommunityFollower.follower == user.id)
.first()
)
if not existing_follower:
follower = CommunityFollower(community=target_community_id, follower=int(user.id))
session.add(follower)
logger.info(f"Пользователь {user.id} добавлен в подписчики сообщества {target_community_id}")
author_role = AuthorRole(author=user.id, role=reader_role.id, community=main_community.id)
session.add(author_role)
session.commit()
logger.info(f"Пользователь {user.id} успешно создан с ролями в сообществе {target_community_id}")
return user
@@ -271,7 +366,26 @@ async def send_link(
return user
print("[CRITICAL DEBUG] About to register login function decorator")
# Создаем временную обертку для отладки
def debug_login_wrapper(original_func):
async def wrapper(*args, **kwargs):
print(f"[CRITICAL DEBUG] WRAPPER: login function called with args={args}, kwargs={kwargs}")
try:
result = await original_func(*args, **kwargs)
print(f"[CRITICAL DEBUG] WRAPPER: login function returned: {result}")
return result
except Exception as e:
print(f"[CRITICAL DEBUG] WRAPPER: login function exception: {e}")
raise
return wrapper
@mutation.field("login")
@debug_login_wrapper
async def login(_: None, info: GraphQLResolveInfo, **kwargs: Any) -> dict[str, Any]:
"""
Авторизация пользователя с помощью email и пароля.
@@ -284,11 +398,14 @@ async def login(_: None, info: GraphQLResolveInfo, **kwargs: Any) -> dict[str, A
Returns:
AuthResult с данными пользователя и токеном или сообщением об ошибке
"""
logger.info(f"[auth] login: Попытка входа для {kwargs.get('email')}")
print(f"[CRITICAL DEBUG] login function called with kwargs: {kwargs}")
logger.info(f"[auth] login: НАЧАЛО ФУНКЦИИ для {kwargs.get('email')}")
print("[CRITICAL DEBUG] about to start try block")
# Гарантируем, что всегда возвращаем непустой объект AuthResult
try:
logger.info("[auth] login: ВХОД В ОСНОВНОЙ TRY БЛОК")
# Нормализуем email
email = kwargs.get("email", "").lower()
@@ -337,30 +454,20 @@ async def login(_: None, info: GraphQLResolveInfo, **kwargs: Any) -> dict[str, A
try:
password = kwargs.get("password", "")
verify_result = Identity.password(author, password)
logger.info(
f"[auth] login: РЕЗУЛЬТАТ ПРОВЕРКИ ПАРОЛЯ: {verify_result if isinstance(verify_result, dict) else 'успешно'}"
)
logger.info(f"[auth] login: РЕЗУЛЬТАТ ПРОВЕРКИ ПАРОЛЯ: успешно для {email}")
if isinstance(verify_result, dict) and verify_result.get("error"):
logger.warning(f"[auth] login: Неверный пароль для {email}: {verify_result.get('error')}")
return {
"success": False,
"token": None,
"author": None,
"error": verify_result.get("error", "Ошибка авторизации"),
}
except Exception as e:
logger.error(f"[auth] login: Ошибка при проверке пароля: {e!s}")
# Если проверка прошла успешно, verify_result содержит объект автора
valid_author = verify_result
except (InvalidPassword, Exception) as e:
logger.warning(f"[auth] login: Неверный пароль для {email}: {e!s}")
return {
"success": False,
"token": None,
"author": None,
"error": str(e),
"error": str(e) if isinstance(e, InvalidPassword) else "Ошибка авторизации",
}
# Получаем правильный объект автора - результат verify_result
valid_author = verify_result if not isinstance(verify_result, dict) else author
# Создаем токен через правильную функцию вместо прямого кодирования
try:
# Убедимся, что у автора есть нужные поля для создания токена
@@ -452,26 +559,49 @@ async def login(_: None, info: GraphQLResolveInfo, **kwargs: Any) -> dict[str, A
# Для ответа клиенту используем dict() с параметром True,
# чтобы получить полный доступ к данным для самого пользователя
logger.info(f"[auth] login: Успешный вход для {email}")
author_dict = valid_author.dict(True)
try:
author_dict = valid_author.dict(True)
except Exception as dict_error:
logger.error(f"[auth] login: Ошибка при вызове dict(): {dict_error}")
# Fallback - используем базовые поля вручную
author_dict = {
"id": valid_author.id,
"email": valid_author.email,
"name": getattr(valid_author, "name", ""),
"slug": getattr(valid_author, "slug", ""),
"username": getattr(valid_author, "username", ""),
}
result = {"success": True, "token": token, "author": author_dict, "error": None}
logger.info(
f"[auth] login: Возвращаемый результат: {{success: {result['success']}, token_length: {len(token) if token else 0}}}"
)
logger.info(f"[auth] login: УСПЕШНЫЙ RETURN - возвращаем: {result}")
return result
except Exception as token_error:
logger.error(f"[auth] login: Ошибка при создании токена: {token_error!s}")
logger.error(traceback.format_exc())
return {
error_result = {
"success": False,
"token": None,
"author": None,
"error": f"Ошибка авторизации: {token_error!s}",
}
logger.info(f"[auth] login: ОШИБКА ТОКЕНА RETURN - возвращаем: {error_result}")
return error_result
except Exception as e:
logger.error(f"[auth] login: Ошибка при авторизации {email}: {e!s}")
logger.error(f"[auth] login: Ошибка при авторизации {kwargs.get('email', 'UNKNOWN')}: {e!s}")
logger.error(traceback.format_exc())
return {"success": False, "token": None, "author": None, "error": str(e)}
result = {"success": False, "token": None, "author": None, "error": str(e)}
logger.info(f"[auth] login: ВОЗВРАЩАЕМ РЕЗУЛЬТАТ ОШИБКИ: {result}")
return result
# Этой строки никогда не должно быть достигнуто
logger.error("[auth] login: КРИТИЧЕСКАЯ ОШИБКА - достигнут конец функции без return!")
emergency_result = {"success": False, "token": None, "author": None, "error": "Внутренняя ошибка сервера"}
logger.error(f"[auth] login: ЭКСТРЕННЫЙ RETURN: {emergency_result}")
return emergency_result
@query.field("isEmailUsed")
@@ -969,3 +1099,21 @@ async def cancel_email_change(_: None, info: GraphQLResolveInfo) -> dict[str, An
logger.error(f"[auth] cancelEmailChange: Ошибка при отмене смены email: {e!s}")
logger.error(traceback.format_exc())
return {"success": False, "error": str(e), "author": None}
def follow_community(self, info, community_id: int) -> dict[str, Any]:
"""
Подписаться на сообщество
"""
from orm.community import CommunityFollower
from services.db import local_session
with local_session() as session:
follower = CommunityFollower(
follower=int(info.context.user.id), # type: ignore[arg-type]
community=community_id,
)
session.add(follower)
session.commit()
return {"success": True, "message": "Successfully followed community"}

View File

@@ -580,7 +580,15 @@ async def get_author_follows_authors(
def create_author(**kwargs) -> Author:
"""Create new author"""
"""
Create new author with default community roles
Args:
**kwargs: Author data including user_id, slug, name, etc.
Returns:
Created Author object
"""
author = Author()
# Use setattr to avoid MyPy complaints about Column assignment
author.id = kwargs.get("user_id") # type: ignore[assignment] # Связь с user_id из системы авторизации # type: ignore[assignment]
@@ -590,8 +598,48 @@ def create_author(**kwargs) -> Author:
author.name = kwargs.get("name") or kwargs.get("slug") # type: ignore[assignment] # если не указано # type: ignore[assignment]
with local_session() as session:
from orm.community import Community, CommunityAuthor, CommunityFollower
session.add(author)
session.flush() # Получаем ID автора
# Добавляем автора в основное сообщество с дефолтными ролями
target_community_id = kwargs.get("community_id", 1) # По умолчанию основное сообщество
# Получаем сообщество для назначения дефолтных ролей
community = session.query(Community).filter(Community.id == target_community_id).first()
if community:
# Инициализируем права сообщества если нужно
try:
import asyncio
loop = asyncio.get_event_loop()
loop.run_until_complete(community.initialize_role_permissions())
except Exception as e:
logger.warning(f"Не удалось инициализировать права сообщества {target_community_id}: {e}")
# Получаем дефолтные роли сообщества или используем стандартные
try:
default_roles = community.get_default_roles()
if not default_roles:
default_roles = ["reader", "author"]
except AttributeError:
default_roles = ["reader", "author"]
# Создаем CommunityAuthor с дефолтными ролями
community_author = CommunityAuthor(
community_id=target_community_id, author_id=author.id, roles=",".join(default_roles)
)
session.add(community_author)
logger.info(f"Создана запись CommunityAuthor для автора {author.id} с ролями: {default_roles}")
# Добавляем автора в подписчики сообщества
follower = CommunityFollower(community=target_community_id, follower=int(author.id))
session.add(follower)
logger.info(f"Автор {author.id} добавлен в подписчики сообщества {target_community_id}")
session.commit()
logger.info(f"Автор {author.id} успешно создан с ролями в сообществе {target_community_id}")
return author

View File

@@ -2,10 +2,10 @@ from typing import Any
from graphql import GraphQLResolveInfo
from auth.decorators import editor_or_admin_required
from auth.orm import Author
from orm.community import Community, CommunityFollower
from services.db import local_session
from services.rbac import require_any_permission, require_permission
from services.schema import mutation, query, type_community
@@ -72,6 +72,7 @@ async def get_communities_by_author(
@mutation.field("join_community")
@require_permission("community:read")
async def join_community(_: None, info: GraphQLResolveInfo, slug: str) -> dict[str, Any]:
author_dict = info.context.get("author", {})
author_id = author_dict.get("id")
@@ -97,7 +98,7 @@ async def leave_community(_: None, info: GraphQLResolveInfo, slug: str) -> dict[
@mutation.field("create_community")
@editor_or_admin_required
@require_permission("community:create")
async def create_community(_: None, info: GraphQLResolveInfo, community_input: dict[str, Any]) -> dict[str, Any]:
# Получаем author_id из контекста через декоратор авторизации
request = info.context.get("request")
@@ -123,6 +124,11 @@ async def create_community(_: None, info: GraphQLResolveInfo, community_input: d
# Создаем новое сообщество с обязательным created_by из токена
new_community = Community(created_by=author_id, **filtered_input)
session.add(new_community)
session.flush() # Получаем ID сообщества
# Инициализируем права ролей для нового сообщества
await new_community.initialize_role_permissions()
session.commit()
return {"error": None}
except Exception as e:
@@ -130,7 +136,7 @@ async def create_community(_: None, info: GraphQLResolveInfo, community_input: d
@mutation.field("update_community")
@editor_or_admin_required
@require_any_permission(["community:update_own", "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")
@@ -181,7 +187,7 @@ async def update_community(_: None, info: GraphQLResolveInfo, community_input: d
@mutation.field("delete_community")
@editor_or_admin_required
@require_any_permission(["community:delete_own", "community:delete_any"])
async def delete_community(_: None, info: GraphQLResolveInfo, slug: str) -> dict[str, Any]:
# Получаем author_id из контекста через декоратор авторизации
request = info.context.get("request")

View File

@@ -1,3 +1,4 @@
from math import ceil
from typing import Any, Optional
from graphql import GraphQLResolveInfo
@@ -69,23 +70,42 @@ async def get_topics_with_stats(
- 'comments' - по количеству комментариев
Returns:
list: Список тем с их статистикой, отсортированный по популярности
dict: Объект с пагинированным списком тем и метаданными пагинации
"""
# Нормализуем параметры
limit = max(1, min(100, limit or 10)) # Ограничиваем количество записей от 1 до 100
offset = max(0, offset or 0) # Смещение не может быть отрицательным
# Формируем ключ кеша с помощью универсальной функции
cache_key = f"topics:stats:limit={limit}:offset={offset}:community_id={community_id}:by={by}"
# Функция для получения тем из БД
async def fetch_topics_with_stats() -> list[dict]:
async def fetch_topics_with_stats() -> dict[str, Any]:
logger.debug(f"Выполняем запрос на получение тем со статистикой: limit={limit}, offset={offset}, by={by}")
with local_session() as session:
# Базовый запрос для получения общего количества
total_query = select(func.count(Topic.id))
# Базовый запрос для получения тем
base_query = select(Topic)
# Добавляем фильтр по сообществу, если указан
if community_id:
total_query = total_query.where(Topic.community == community_id)
base_query = base_query.where(Topic.community == community_id)
# Получаем общее количество записей
total_count = session.execute(total_query).scalar()
# Вычисляем информацию о пагинации
per_page = limit
if total_count is None or per_page in (None, 0):
total_pages = 1
else:
total_pages = ceil(total_count / per_page)
current_page = (offset // per_page) + 1 if per_page > 0 else 1
# Применяем сортировку на основе параметра by
if by:
if isinstance(by, dict):
@@ -190,7 +210,13 @@ async def get_topics_with_stats(
topic_ids = [topic.id for topic in topics]
if not topic_ids:
return []
return {
"topics": [],
"total": total_count,
"page": current_page,
"perPage": per_page,
"totalPages": total_pages,
}
# Исправляю S608 - используем параметризированные запросы
if topic_ids:
@@ -241,7 +267,7 @@ async def get_topics_with_stats(
comments_stats = {row[0]: row[1] for row in session.execute(text(comments_stats_query), params)}
# Формируем результат с добавлением статистики
result = []
result_topics = []
for topic in topics:
topic_dict = topic.dict()
topic_dict["stat"] = {
@@ -250,12 +276,18 @@ async def get_topics_with_stats(
"authors": authors_stats.get(topic.id, 0),
"comments": comments_stats.get(topic.id, 0),
}
result.append(topic_dict)
result_topics.append(topic_dict)
# Кешируем каждую тему отдельно для использования в других функциях
await cache_topic(topic_dict)
return result
return {
"topics": result_topics,
"total": total_count,
"page": current_page,
"perPage": per_page,
"totalPages": total_pages,
}
# Используем универсальную функцию для кеширования запросов
return await cached_query(cache_key, fetch_topics_with_stats)
@@ -760,8 +792,10 @@ async def set_topic_parent(
if potential_parent.id == child_id:
return True
# Ищем всех потомков parent'а
descendants = session.query(Topic).filter(Topic.parent_ids.op("@>")([potential_parent.id])).all()
# Ищем всех потомков parent'а (совместимо с SQLite)
descendants = session.query(Topic).all()
# Фильтруем темы, у которых в parent_ids есть potential_parent.id
descendants = [d for d in descendants if d.parent_ids and potential_parent.id in d.parent_ids]
for descendant in descendants:
if descendant.id == child_id or is_descendant(descendant, child_id):