0.5.8-panel-upgrade-community-crud-fix
All checks were successful
Deploy on push / deploy (push) Successful in 6s

This commit is contained in:
2025-06-30 21:25:26 +03:00
parent 9de86c0fae
commit 952b294345
70 changed files with 11345 additions and 2655 deletions

View File

@@ -62,11 +62,11 @@ async def admin_get_users(
current_page = (offset // per_page) + 1 if per_page > 0 else 1
# Применяем пагинацию
users = query.order_by(Author.id).offset(offset).limit(limit).all()
authors = query.order_by(Author.id).offset(offset).limit(limit).all()
# Преобразуем в формат для API
return {
"users": [
"authors": [
{
"id": user.id,
"email": user.email,
@@ -76,7 +76,7 @@ async def admin_get_users(
"created_at": user.created_at,
"last_seen": user.last_seen,
}
for user in users
for user in authors
],
"total": total_count,
"page": current_page,
@@ -247,11 +247,11 @@ async def update_env_variables(_: None, info: GraphQLResolveInfo, variables: lis
@admin_auth_required
async def admin_update_user(_: None, info: GraphQLResolveInfo, user: dict[str, Any]) -> dict[str, Any]:
"""
Обновляет роли пользователя
Обновляет данные пользователя (роли, email, имя, slug)
Args:
info: Контекст GraphQL запроса
user: Данные для обновления пользователя (содержит id и roles)
user: Данные для обновления пользователя
Returns:
Boolean: результат операции или объект с ошибкой
@@ -259,6 +259,9 @@ async def admin_update_user(_: None, info: GraphQLResolveInfo, user: dict[str, A
try:
user_id = user.get("id")
roles = user.get("roles", [])
email = user.get("email")
name = user.get("name")
slug = user.get("slug")
if not roles:
logger.warning(f"Пользователю {user_id} не назначено ни одной роли. Доступ в систему будет заблокирован.")
@@ -272,6 +275,28 @@ async def admin_update_user(_: None, info: GraphQLResolveInfo, user: dict[str, A
logger.error(error_msg)
return {"success": False, "error": error_msg}
# Обновляем основные поля профиля
profile_updated = False
if email is not None and email != author.email:
# Проверяем уникальность email
existing_author = session.query(Author).filter(Author.email == email, Author.id != user_id).first()
if existing_author:
return {"success": False, "error": f"Email {email} уже используется другим пользователем"}
author.email = email
profile_updated = True
if name is not None and name != author.name:
author.name = name
profile_updated = True
if slug is not None and slug != author.slug:
# Проверяем уникальность slug
existing_author = session.query(Author).filter(Author.slug == slug, Author.id != user_id).first()
if existing_author:
return {"success": False, "error": f"Slug {slug} уже используется другим пользователем"}
author.slug = slug
profile_updated = True
# Получаем ID сообщества по умолчанию
default_community_id = 1 # Используем значение по умолчанию из модели AuthorRole
@@ -307,19 +332,25 @@ async def admin_update_user(_: None, info: GraphQLResolveInfo, user: dict[str, A
f"Пользователю {author.email or author.id} не назначена роль 'reader'. Доступ в систему будет ограничен."
)
logger.info(f"Роли пользователя {author.email or author.id} обновлены: {', '.join(found_role_ids)}")
update_details = []
if profile_updated:
update_details.append("профиль")
if roles:
update_details.append(f"роли: {', '.join(found_role_ids)}")
logger.info(f"Данные пользователя {author.email or author.id} обновлены: {', '.join(update_details)}")
return {"success": True}
except Exception as e:
# Обработка вложенных исключений
session.rollback()
error_msg = f"Ошибка при изменении ролей: {e!s}"
error_msg = f"Ошибка при изменении данных пользователя: {e!s}"
logger.error(error_msg)
return {"success": False, "error": error_msg}
except Exception as e:
import traceback
error_msg = f"Ошибка при обновлении ролей пользователя: {e!s}"
error_msg = f"Ошибка при обновлении данных пользователя: {e!s}"
logger.error(error_msg)
logger.error(traceback.format_exc())
return {"success": False, "error": error_msg}

View File

@@ -2,15 +2,49 @@ 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.schema import mutation, query
from services.schema import mutation, query, type_community
@query.field("get_communities_all")
async def get_communities_all(_: None, _info: GraphQLResolveInfo) -> list[Community]:
return local_session().query(Community).all()
from sqlalchemy.orm import joinedload
with local_session() as session:
# Загружаем сообщества с проверкой существования авторов
communities = (
session.query(Community)
.options(joinedload(Community.created_by_author))
.join(
Author,
Community.created_by == Author.id, # INNER JOIN - исключает сообщества без авторов
)
.filter(
Community.created_by.isnot(None), # Дополнительная проверка
Author.id.isnot(None), # Проверяем что автор существует
)
.all()
)
# Дополнительная проверка валидности данных
valid_communities = []
for community in communities:
if (
community.created_by
and hasattr(community, "created_by_author")
and community.created_by_author
and community.created_by_author.id
):
valid_communities.append(community)
else:
from utils.logger import root_logger as logger
logger.warning(f"Исключено сообщество {community.id} ({community.slug}) - проблемы с автором")
return valid_communities
@query.field("get_community")
@@ -63,41 +97,192 @@ async def leave_community(_: None, info: GraphQLResolveInfo, slug: str) -> dict[
@mutation.field("create_community")
async def create_community(_: None, info: GraphQLResolveInfo, community_data: dict[str, Any]) -> dict[str, Any]:
author_dict = info.context.get("author", {})
author_id = author_dict.get("id")
with local_session() as session:
session.add(Community(author=author_id, **community_data))
session.commit()
return {"ok": True}
@editor_or_admin_required
async def create_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": "Не удалось определить автора"}
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)
session.add(new_community)
session.commit()
return {"error": None}
except Exception as e:
return {"error": f"Ошибка создания сообщества: {e!s}"}
@mutation.field("update_community")
async def update_community(_: None, info: GraphQLResolveInfo, community_data: dict[str, Any]) -> dict[str, Any]:
author_dict = info.context.get("author", {})
author_id = author_dict.get("id")
slug = community_data.get("slug")
if slug:
@editor_or_admin_required
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 сообщества"}
try:
with local_session() as session:
try:
session.query(Community).where(Community.created_by == author_id, Community.slug == slug).update(
community_data
)
session.commit()
except Exception as e:
return {"ok": False, "error": str(e)}
return {"ok": True}
return {"ok": False, "error": "Please, set community slug in input"}
# Находим сообщество для обновления
community = session.query(Community).filter(Community.slug == slug).first()
if not community:
return {"error": "Сообщество не найдено"}
# Проверяем права на редактирование (создатель или админ/редактор)
with local_session() as auth_session:
author = auth_session.query(Author).filter(Author.id == author_id).first()
user_roles = [role.id for role in author.roles] if author and author.roles else []
# Разрешаем редактирование если пользователь - создатель или имеет роль admin/editor
if community.created_by != author_id and "admin" not in user_roles and "editor" not in user_roles:
return {"error": "Недостаточно прав для редактирования этого сообщества"}
# Обновляем поля сообщества
for key, value in community_input.items():
# Исключаем изменение created_by - создатель не может быть изменен
if hasattr(community, key) and key not in ["slug", "created_by"]:
setattr(community, key, value)
session.commit()
return {"error": None}
except Exception as e:
return {"error": f"Ошибка обновления сообщества: {e!s}"}
@mutation.field("delete_community")
@editor_or_admin_required
async def delete_community(_: None, info: GraphQLResolveInfo, slug: str) -> dict[str, Any]:
author_dict = info.context.get("author", {})
author_id = author_dict.get("id")
with local_session() as session:
try:
session.query(Community).where(Community.slug == slug, Community.created_by == author_id).delete()
# Получаем 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:
# Находим сообщество для удаления
community = session.query(Community).filter(Community.slug == slug).first()
if not community:
return {"error": "Сообщество не найдено"}
# Проверяем права на удаление (создатель или админ/редактор)
with local_session() as auth_session:
author = auth_session.query(Author).filter(Author.id == author_id).first()
user_roles = [role.id for role in author.roles] if author and author.roles else []
# Разрешаем удаление если пользователь - создатель или имеет роль admin/editor
if community.created_by != author_id and "admin" not in user_roles and "editor" not in user_roles:
return {"error": "Недостаточно прав для удаления этого сообщества"}
# Удаляем сообщество
session.delete(community)
session.commit()
return {"ok": True}
except Exception as e:
return {"ok": False, "error": str(e)}
return {"error": None}
except Exception as e:
return {"error": f"Ошибка удаления сообщества: {e!s}"}
@type_community.field("created_by")
def resolve_community_created_by(obj: Community, *_: Any) -> Author:
"""
Резолвер поля created_by для Community.
Возвращает автора, создавшего сообщество.
"""
# Если связь уже загружена через joinedload и валидна
if hasattr(obj, "created_by_author") and obj.created_by_author and obj.created_by_author.id:
return obj.created_by_author
# Критическая ошибка - это не должно происходить после фильтрации в get_communities_all
from utils.logger import root_logger as logger
logger.error(f"КРИТИЧЕСКАЯ ОШИБКА: Резолвер created_by вызван для сообщества {obj.id} без валидного автора")
error_message = f"Сообщество {obj.id} не имеет валидного создателя"
raise ValueError(error_message)
@type_community.field("stat")
def resolve_community_stat(obj: Community, *_: Any) -> dict[str, int]:
"""
Резолвер поля stat для Community.
Возвращает статистику сообщества: количество публикаций, подписчиков и авторов.
"""
from sqlalchemy import distinct, func
from orm.shout import Shout, ShoutAuthor
try:
with local_session() as session:
# Количество опубликованных публикаций в сообществе
shouts_count = (
session.query(func.count(Shout.id))
.filter(Shout.community == obj.id, Shout.published_at.is_not(None), Shout.deleted_at.is_(None))
.scalar()
or 0
)
# Количество подписчиков сообщества
followers_count = (
session.query(func.count(CommunityFollower.follower))
.filter(CommunityFollower.community == obj.id)
.scalar()
or 0
)
# Количество уникальных авторов, опубликовавших в сообществе
authors_count = (
session.query(func.count(distinct(ShoutAuthor.author)))
.join(Shout, ShoutAuthor.shout == Shout.id)
.filter(Shout.community == obj.id, Shout.published_at.is_not(None), Shout.deleted_at.is_(None))
.scalar()
or 0
)
return {"shouts": int(shouts_count), "followers": int(followers_count), "authors": int(authors_count)}
except Exception as e:
from utils.logger import root_logger as logger
logger.error(f"Ошибка при получении статистики сообщества {obj.id}: {e}")
# Возвращаем нулевую статистику при ошибке
return {"shouts": 0, "followers": 0, "authors": 0}

View File

@@ -11,6 +11,7 @@ from cache.cache import (
get_cached_topic_by_slug,
get_cached_topic_followers,
invalidate_cache_by_prefix,
invalidate_topic_followers_cache,
)
from orm.reaction import Reaction, ReactionKind
from orm.shout import Shout, ShoutAuthor, ShoutTopic
@@ -446,3 +447,55 @@ async def get_topic_authors(_: None, _info: GraphQLResolveInfo, slug: str) -> li
topic = await get_cached_topic_by_slug(slug, get_with_stat)
topic_id = getattr(topic, "id", None) if isinstance(topic, Topic) else topic.get("id") if topic else None
return await get_cached_topic_authors(topic_id) if topic_id else []
# Мутация для удаления темы по ID (для админ-панели)
@mutation.field("delete_topic_by_id")
@login_required
async def delete_topic_by_id(_: None, info: GraphQLResolveInfo, topic_id: int) -> dict[str, Any]:
"""
Удаляет тему по ID. Используется в админ-панели.
Args:
topic_id: ID темы для удаления
Returns:
dict: Результат операции
"""
viewer_id = info.context.get("author", {}).get("id")
with local_session() as session:
topic = session.query(Topic).filter(Topic.id == topic_id).first()
if not topic:
return {"success": False, "message": "Топик не найден"}
author = session.query(Author).filter(Author.id == viewer_id).first()
if not author:
return {"success": False, "message": "Не авторизован"}
# TODO: проверить права администратора
# Для админ-панели допускаем удаление любых топиков администратором
try:
# Инвалидируем кеши подписчиков ПЕРЕД удалением данных из БД
await invalidate_topic_followers_cache(topic_id)
# Удаляем связанные данные (подписчики, связи с публикациями)
session.query(TopicFollower).filter(TopicFollower.topic == topic_id).delete()
session.query(ShoutTopic).filter(ShoutTopic.topic == topic_id).delete()
# Удаляем сам топик
session.delete(topic)
session.commit()
# Инвалидируем основные кеши топика
await invalidate_topics_cache(topic_id)
if topic.slug:
await redis.execute("DEL", f"topic:slug:{topic.slug}")
logger.info(f"Топик {topic_id} успешно удален")
return {"success": True, "message": "Топик успешно удален"}
except Exception as e:
session.rollback()
logger.error(f"Ошибка при удалении топика {topic_id}: {e}")
return {"success": False, "message": f"Ошибка при удалении: {e!s}"}