tests-passed

This commit is contained in:
2025-07-31 18:55:59 +03:00
parent b7abb8d8a1
commit e7230ba63c
126 changed files with 8326 additions and 3207 deletions

View File

@@ -2,21 +2,30 @@
Админ-резолверы - тонкие GraphQL обёртки над AdminService
"""
from typing import Any
import time
from typing import Any, Optional
from graphql import GraphQLResolveInfo
from graphql.error import GraphQLError
from graphql import GraphQLError, GraphQLResolveInfo
from sqlalchemy import and_, case, func, or_
from sqlalchemy.orm import aliased
from auth.decorators import admin_auth_required
from services.admin import admin_service
from auth.orm import Author
from orm.community import Community, CommunityAuthor
from orm.draft import DraftTopic
from orm.reaction import Reaction
from orm.shout import Shout, ShoutTopic
from orm.topic import Topic, TopicFollower
from resolvers.editor import delete_shout, update_shout
from resolvers.topic import invalidate_topic_followers_cache, invalidate_topics_cache
from services.admin import AdminService
from services.common_result import handle_error
from services.db import local_session
from services.redis import redis
from services.schema import mutation, query
from utils.logger import root_logger as logger
def handle_error(operation: str, error: Exception) -> GraphQLError:
"""Обрабатывает ошибки в резолверах"""
logger.error(f"Ошибка при {operation}: {error}")
return GraphQLError(f"Не удалось {operation}: {error}")
admin_service = AdminService()
# === ПОЛЬЗОВАТЕЛИ ===
@@ -53,15 +62,15 @@ async def admin_update_user(_: None, _info: GraphQLResolveInfo, user: dict[str,
async def admin_get_shouts(
_: None,
_info: GraphQLResolveInfo,
limit: int = 20,
limit: int = 10,
offset: int = 0,
search: str = "",
status: str = "all",
community: int = None,
community: Optional[int] = None,
) -> dict[str, Any]:
"""Получает список публикаций"""
try:
return admin_service.get_shouts(limit, offset, search, status, community)
return await admin_service.get_shouts(limit, offset, search, status, community)
except Exception as e:
raise handle_error("получении списка публикаций", e) from e
@@ -71,8 +80,6 @@ async def admin_get_shouts(
async def admin_update_shout(_: None, info: GraphQLResolveInfo, shout: dict[str, Any]) -> dict[str, Any]:
"""Обновляет публикацию через editor.py"""
try:
from resolvers.editor import update_shout
shout_id = shout.get("id")
if not shout_id:
return {"success": False, "error": "ID публикации не указан"}
@@ -95,8 +102,6 @@ async def admin_update_shout(_: None, info: GraphQLResolveInfo, shout: dict[str,
async def admin_delete_shout(_: None, info: GraphQLResolveInfo, shout_id: int) -> dict[str, Any]:
"""Удаляет публикацию через editor.py"""
try:
from resolvers.editor import delete_shout
result = await delete_shout(None, info, shout_id)
if result.error:
return {"success": False, "error": result.error}
@@ -163,37 +168,9 @@ async def admin_delete_invite(
@query.field("adminGetTopics")
@admin_auth_required
async def admin_get_topics(_: None, _info: GraphQLResolveInfo, community_id: int) -> list[dict[str, Any]]:
"""Получает все топики сообщества для админ-панели"""
try:
from orm.topic import Topic
from services.db import local_session
with local_session() as session:
# Получаем все топики сообщества без лимитов
topics = session.query(Topic).filter(Topic.community == community_id).order_by(Topic.id).all()
# Сериализуем топики в простой формат для админки
result: list[dict[str, Any]] = [
{
"id": topic.id,
"title": topic.title or "",
"slug": topic.slug or f"topic-{topic.id}",
"body": topic.body or "",
"community": topic.community,
"parent_ids": topic.parent_ids or [],
"pic": topic.pic,
"oid": getattr(topic, "oid", None),
"is_main": getattr(topic, "is_main", False),
}
for topic in topics
]
logger.info(f"Загружено топиков для сообщества: {len(result)}")
return result
except Exception as e:
raise handle_error("получении списка топиков", e) from e
async def admin_get_topics(_: None, _info: GraphQLResolveInfo, community_id: int) -> list[Topic]:
with local_session() as session:
return session.query(Topic).where(Topic.community == community_id).all()
@mutation.field("adminUpdateTopic")
@@ -201,17 +178,12 @@ async def admin_get_topics(_: None, _info: GraphQLResolveInfo, community_id: int
async def admin_update_topic(_: None, _info: GraphQLResolveInfo, topic: dict[str, Any]) -> dict[str, Any]:
"""Обновляет топик через админ-панель"""
try:
from orm.topic import Topic
from resolvers.topic import invalidate_topics_cache
from services.db import local_session
from services.redis import redis
topic_id = topic.get("id")
if not topic_id:
return {"success": False, "error": "ID топика не указан"}
with local_session() as session:
existing_topic = session.query(Topic).filter(Topic.id == topic_id).first()
existing_topic = session.query(Topic).where(Topic.id == topic_id).first()
if not existing_topic:
return {"success": False, "error": "Топик не найден"}
@@ -248,10 +220,6 @@ async def admin_update_topic(_: None, _info: GraphQLResolveInfo, topic: dict[str
async def admin_create_topic(_: None, _info: GraphQLResolveInfo, topic: dict[str, Any]) -> dict[str, Any]:
"""Создает новый топик через админ-панель"""
try:
from orm.topic import Topic
from resolvers.topic import invalidate_topics_cache
from services.db import local_session
with local_session() as session:
# Создаем новый топик
new_topic = Topic(**topic)
@@ -285,13 +253,6 @@ async def admin_merge_topics(_: None, _info: GraphQLResolveInfo, merge_input: di
dict: Результат операции с информацией о слиянии
"""
try:
from orm.draft import DraftTopic
from orm.shout import ShoutTopic
from orm.topic import Topic, TopicFollower
from resolvers.topic import invalidate_topic_followers_cache, invalidate_topics_cache
from services.db import local_session
from services.redis import redis
target_topic_id = merge_input["target_topic_id"]
source_topic_ids = merge_input["source_topic_ids"]
preserve_target = merge_input.get("preserve_target_properties", True)
@@ -302,12 +263,12 @@ async def admin_merge_topics(_: None, _info: GraphQLResolveInfo, merge_input: di
with local_session() as session:
# Получаем целевую тему
target_topic = session.query(Topic).filter(Topic.id == target_topic_id).first()
target_topic = session.query(Topic).where(Topic.id == target_topic_id).first()
if not target_topic:
return {"success": False, "error": f"Целевая тема с ID {target_topic_id} не найдена"}
# Получаем исходные темы
source_topics = session.query(Topic).filter(Topic.id.in_(source_topic_ids)).all()
source_topics = session.query(Topic).where(Topic.id.in_(source_topic_ids)).all()
if len(source_topics) != len(source_topic_ids):
found_ids = [t.id for t in source_topics]
missing_ids = [topic_id for topic_id in source_topic_ids if topic_id not in found_ids]
@@ -325,13 +286,13 @@ async def admin_merge_topics(_: None, _info: GraphQLResolveInfo, merge_input: di
# Переносим подписчиков из исходных тем в целевую
for source_topic in source_topics:
# Получаем подписчиков исходной темы
source_followers = session.query(TopicFollower).filter(TopicFollower.topic == source_topic.id).all()
source_followers = session.query(TopicFollower).where(TopicFollower.topic == source_topic.id).all()
for follower in source_followers:
# Проверяем, не подписан ли уже пользователь на целевую тему
existing = (
session.query(TopicFollower)
.filter(TopicFollower.topic == target_topic_id, TopicFollower.follower == follower.follower)
.where(TopicFollower.topic == target_topic_id, TopicFollower.follower == follower.follower)
.first()
)
@@ -352,17 +313,18 @@ async def admin_merge_topics(_: None, _info: GraphQLResolveInfo, merge_input: di
# Переносим публикации из исходных тем в целевую
for source_topic in source_topics:
# Получаем связи публикаций с исходной темой
shout_topics = session.query(ShoutTopic).filter(ShoutTopic.topic == source_topic.id).all()
shout_topics = session.query(ShoutTopic).where(ShoutTopic.topic == source_topic.id).all()
for shout_topic in shout_topics:
# Проверяем, не связана ли уже публикация с целевой темой
existing = (
existing_shout_topic: ShoutTopic | None = (
session.query(ShoutTopic)
.filter(ShoutTopic.topic == target_topic_id, ShoutTopic.shout == shout_topic.shout)
.where(ShoutTopic.topic == target_topic_id)
.where(ShoutTopic.shout == shout_topic.shout)
.first()
)
if not existing:
if not existing_shout_topic:
# Создаем новую связь с целевой темой
new_shout_topic = ShoutTopic(
topic=target_topic_id, shout=shout_topic.shout, main=shout_topic.main
@@ -376,20 +338,21 @@ async def admin_merge_topics(_: None, _info: GraphQLResolveInfo, merge_input: di
# Переносим черновики из исходных тем в целевую
for source_topic in source_topics:
# Получаем связи черновиков с исходной темой
draft_topics = session.query(DraftTopic).filter(DraftTopic.topic == source_topic.id).all()
draft_topics = session.query(DraftTopic).where(DraftTopic.topic == source_topic.id).all()
for draft_topic in draft_topics:
# Проверяем, не связан ли уже черновик с целевой темой
existing = (
existing_draft_topic: DraftTopic | None = (
session.query(DraftTopic)
.filter(DraftTopic.topic == target_topic_id, DraftTopic.shout == draft_topic.shout)
.where(DraftTopic.topic == target_topic_id)
.where(DraftTopic.draft == draft_topic.draft)
.first()
)
if not existing:
if not existing_draft_topic:
# Создаем новую связь с целевой темой
new_draft_topic = DraftTopic(
topic=target_topic_id, shout=draft_topic.shout, main=draft_topic.main
topic=target_topic_id, draft=draft_topic.draft, main=draft_topic.main
)
session.add(new_draft_topic)
merge_stats["drafts_moved"] += 1
@@ -400,7 +363,7 @@ async def admin_merge_topics(_: None, _info: GraphQLResolveInfo, merge_input: di
# Обновляем parent_ids дочерних топиков
for source_topic in source_topics:
# Находим всех детей исходной темы
child_topics = session.query(Topic).filter(Topic.parent_ids.contains(int(source_topic.id))).all() # type: ignore[arg-type]
child_topics = session.query(Topic).where(Topic.parent_ids.contains(int(source_topic.id))).all() # type: ignore[arg-type]
for child_topic in child_topics:
current_parent_ids = list(child_topic.parent_ids or [])
@@ -409,7 +372,7 @@ async def admin_merge_topics(_: None, _info: GraphQLResolveInfo, merge_input: di
target_topic_id if parent_id == source_topic.id else parent_id
for parent_id in current_parent_ids
]
child_topic.parent_ids = updated_parent_ids
child_topic.parent_ids = list(updated_parent_ids)
# Объединяем parent_ids если не сохраняем только целевые свойства
if not preserve_target:
@@ -423,7 +386,7 @@ async def admin_merge_topics(_: None, _info: GraphQLResolveInfo, merge_input: di
all_parent_ids.discard(target_topic_id)
for source_id in source_topic_ids:
all_parent_ids.discard(source_id)
target_topic.parent_ids = list(all_parent_ids) if all_parent_ids else []
target_topic.parent_ids = list(all_parent_ids) if all_parent_ids else None
# Инвалидируем кеши ПЕРЕД удалением тем
for source_topic in source_topics:
@@ -493,7 +456,7 @@ async def update_env_variables(_: None, _info: GraphQLResolveInfo, variables: li
@query.field("adminGetRoles")
@admin_auth_required
async def admin_get_roles(_: None, _info: GraphQLResolveInfo, community: int = None) -> list[dict[str, Any]]:
async def admin_get_roles(_: None, _info: GraphQLResolveInfo, community: int | None = None) -> list[dict[str, Any]]:
"""Получает список ролей"""
try:
return admin_service.get_roles(community)
@@ -513,14 +476,12 @@ async def admin_get_user_community_roles(
) -> dict[str, Any]:
"""Получает роли пользователя в сообществе"""
# [непроверенное] Временная заглушка - нужно вынести в сервис
from orm.community import CommunityAuthor
from services.db import local_session
try:
with local_session() as session:
community_author = (
session.query(CommunityAuthor)
.filter(CommunityAuthor.author_id == author_id, CommunityAuthor.community_id == community_id)
.where(CommunityAuthor.author_id == author_id, CommunityAuthor.community_id == community_id)
.first()
)
@@ -540,25 +501,20 @@ async def admin_get_community_members(
) -> dict[str, Any]:
"""Получает участников сообщества"""
# [непроверенное] Временная заглушка - нужно вынести в сервис
from sqlalchemy.sql import func
from auth.orm import Author
from orm.community import CommunityAuthor
from services.db import local_session
try:
with local_session() as session:
members_query = (
session.query(Author, CommunityAuthor)
.join(CommunityAuthor, Author.id == CommunityAuthor.author_id)
.filter(CommunityAuthor.community_id == community_id)
.where(CommunityAuthor.community_id == community_id)
.offset(offset)
.limit(limit)
)
members = []
members: list[dict[str, Any]] = []
for author, community_author in members_query:
roles = []
roles: list[str] = []
if community_author.roles:
roles = [role.strip() for role in community_author.roles.split(",") if role.strip()]
@@ -574,7 +530,7 @@ async def admin_get_community_members(
total = (
session.query(func.count(CommunityAuthor.author_id))
.filter(CommunityAuthor.community_id == community_id)
.where(CommunityAuthor.community_id == community_id)
.scalar()
)
@@ -589,12 +545,10 @@ async def admin_get_community_members(
async def admin_get_community_role_settings(_: None, _info: GraphQLResolveInfo, community_id: int) -> dict[str, Any]:
"""Получает настройки ролей сообщества"""
# [непроверенное] Временная заглушка - нужно вынести в сервис
from orm.community import Community
from services.db import local_session
try:
with local_session() as session:
community = session.query(Community).filter(Community.id == community_id).first()
community = session.query(Community).where(Community.id == community_id).first()
if not community:
return {
"community_id": community_id,
@@ -630,20 +584,12 @@ async def admin_get_reactions(
limit: int = 20,
offset: int = 0,
search: str = "",
kind: str = None,
shout_id: int = None,
kind: str | None = None,
shout_id: int | None = None,
status: str = "all",
) -> dict[str, Any]:
"""Получает список реакций для админ-панели"""
try:
from sqlalchemy import and_, case, func, or_
from sqlalchemy.orm import aliased
from auth.orm import Author
from orm.reaction import Reaction
from orm.shout import Shout
from services.db import local_session
with local_session() as session:
# Базовый запрос с джойнами
query = (
@@ -653,7 +599,7 @@ async def admin_get_reactions(
)
# Фильтрация
filters = []
filters: list[Any] = []
# Фильтр по статусу (как в публикациях)
if status == "active":
@@ -677,7 +623,7 @@ async def admin_get_reactions(
filters.append(Reaction.shout == shout_id)
if filters:
query = query.filter(and_(*filters))
query = query.where(and_(*filters))
# Общее количество
total = query.count()
@@ -686,7 +632,7 @@ async def admin_get_reactions(
reactions_data = query.order_by(Reaction.created_at.desc()).offset(offset).limit(limit).all()
# Формируем результат
reactions = []
reactions: list[dict[str, Any]] = []
for reaction, author, shout in reactions_data:
# Получаем статистику для каждой реакции
aliased_reaction = aliased(Reaction)
@@ -699,7 +645,7 @@ async def admin_get_reactions(
)
).label("rating"),
)
.filter(
.where(
aliased_reaction.reply_to == reaction.id,
# Убираем фильтр deleted_at чтобы включить все реакции в статистику
)
@@ -760,18 +706,13 @@ async def admin_get_reactions(
async def admin_update_reaction(_: None, _info: GraphQLResolveInfo, reaction: dict[str, Any]) -> dict[str, Any]:
"""Обновляет реакцию"""
try:
import time
from orm.reaction import Reaction
from services.db import local_session
reaction_id = reaction.get("id")
if not reaction_id:
return {"success": False, "error": "ID реакции не указан"}
with local_session() as session:
# Находим реакцию
db_reaction = session.query(Reaction).filter(Reaction.id == reaction_id).first()
db_reaction = session.query(Reaction).where(Reaction.id == reaction_id).first()
if not db_reaction:
return {"success": False, "error": "Реакция не найдена"}
@@ -779,10 +720,10 @@ async def admin_update_reaction(_: None, _info: GraphQLResolveInfo, reaction: di
if "body" in reaction:
db_reaction.body = reaction["body"]
if "deleted_at" in reaction:
db_reaction.deleted_at = reaction["deleted_at"]
db_reaction.deleted_at = int(time.time()) # type: ignore[assignment]
# Обновляем время изменения
db_reaction.updated_at = int(time.time())
db_reaction.updated_at = int(time.time()) # type: ignore[assignment]
session.commit()
@@ -799,19 +740,14 @@ async def admin_update_reaction(_: None, _info: GraphQLResolveInfo, reaction: di
async def admin_delete_reaction(_: None, _info: GraphQLResolveInfo, reaction_id: int) -> dict[str, Any]:
"""Удаляет реакцию (мягкое удаление)"""
try:
import time
from orm.reaction import Reaction
from services.db import local_session
with local_session() as session:
# Находим реакцию
db_reaction = session.query(Reaction).filter(Reaction.id == reaction_id).first()
db_reaction = session.query(Reaction).where(Reaction.id == reaction_id).first()
if not db_reaction:
return {"success": False, "error": "Реакция не найдена"}
# Устанавливаем время удаления
db_reaction.deleted_at = int(time.time())
db_reaction.deleted_at = int(time.time()) # type: ignore[assignment]
session.commit()
@@ -828,12 +764,9 @@ async def admin_delete_reaction(_: None, _info: GraphQLResolveInfo, reaction_id:
async def admin_restore_reaction(_: None, _info: GraphQLResolveInfo, reaction_id: int) -> dict[str, Any]:
"""Восстанавливает удаленную реакцию"""
try:
from orm.reaction import Reaction
from services.db import local_session
with local_session() as session:
# Находим реакцию
db_reaction = session.query(Reaction).filter(Reaction.id == reaction_id).first()
db_reaction = session.query(Reaction).where(Reaction.id == reaction_id).first()
if not db_reaction:
return {"success": False, "error": "Реакция не найдена"}

View File

@@ -2,28 +2,21 @@
Auth резолверы - тонкие GraphQL обёртки над AuthService
"""
from typing import Any, Dict, List, Union
from typing import Any, Union
from graphql import GraphQLResolveInfo
from graphql.error import GraphQLError
from starlette.responses import JSONResponse
from services.auth import auth_service
from services.schema import mutation, query, type_author
from settings import SESSION_COOKIE_NAME
from utils.logger import root_logger as logger
def handle_error(operation: str, error: Exception) -> GraphQLError:
"""Обрабатывает ошибки в резолверах"""
logger.error(f"Ошибка при {operation}: {error}")
return GraphQLError(f"Не удалось {operation}: {error}")
# === РЕЗОЛВЕР ДЛЯ ТИПА AUTHOR ===
@type_author.field("roles")
def resolve_roles(obj: Union[Dict, Any], info: GraphQLResolveInfo) -> List[str]:
def resolve_roles(obj: Union[dict, Any], info: GraphQLResolveInfo) -> list[str]:
"""Резолвер для поля roles автора"""
try:
if hasattr(obj, "get_roles"):
@@ -60,13 +53,13 @@ async def register_user(
@mutation.field("sendLink")
async def send_link(
_: None, _info: GraphQLResolveInfo, email: str, lang: str = "ru", template: str = "confirm"
) -> dict[str, Any]:
) -> bool:
"""Отправляет ссылку подтверждения"""
try:
result = await auth_service.send_verification_link(email, lang, template)
return result
return bool(await auth_service.send_verification_link(email, lang, template))
except Exception as e:
raise handle_error("отправке ссылки подтверждения", e) from e
logger.error(f"Ошибка отправки ссылки подтверждения: {e}")
return False
@mutation.field("confirmEmail")
@@ -93,8 +86,6 @@ async def login(_: None, info: GraphQLResolveInfo, **kwargs: Any) -> dict[str, A
# Устанавливаем cookie если есть токен
if result.get("success") and result.get("token") and request:
try:
from starlette.responses import JSONResponse
if not hasattr(info.context, "response"):
response = JSONResponse({})
response.set_cookie(

View File

@@ -1,11 +1,13 @@
import asyncio
import time
import traceback
from typing import Any, Optional, TypedDict
from graphql import GraphQLResolveInfo
from sqlalchemy import select, text
from sqlalchemy import and_, asc, func, select, text
from sqlalchemy.sql import desc as sql_desc
from auth.orm import Author
from auth.orm import Author, AuthorFollower
from cache.cache import (
cache_author,
cached_query,
@@ -15,6 +17,8 @@ from cache.cache import (
get_cached_follower_topics,
invalidate_cache_by_prefix,
)
from orm.community import Community, CommunityAuthor, CommunityFollower
from orm.shout import Shout, ShoutAuthor
from resolvers.stat import get_with_stat
from services.auth import login_required
from services.common_result import CommonResult
@@ -80,7 +84,7 @@ async def get_all_authors(current_user_id: Optional[int] = None) -> list[Any]:
authors = session.execute(authors_query).scalars().unique().all()
# Преобразуем авторов в словари с учетом прав доступа
return [author.dict(False) for author in authors]
return [author.dict() for author in authors]
# Используем универсальную функцию для кеширования запросов
return await cached_query(cache_key, fetch_all_authors)
@@ -89,7 +93,7 @@ async def get_all_authors(current_user_id: Optional[int] = None) -> list[Any]:
# Вспомогательная функция для получения авторов со статистикой с пагинацией
async def get_authors_with_stats(
limit: int = 10, offset: int = 0, by: Optional[AuthorsBy] = None, current_user_id: Optional[int] = None
):
) -> list[dict[str, Any]]:
"""
Получает авторов со статистикой с пагинацией.
@@ -112,13 +116,6 @@ async def get_authors_with_stats(
"""
logger.debug(f"Выполняем запрос на получение авторов со статистикой: limit={limit}, offset={offset}, by={by}")
# Импорты SQLAlchemy для избежания конфликтов имен
from sqlalchemy import and_, asc, func
from sqlalchemy import desc as sql_desc
from auth.orm import AuthorFollower
from orm.shout import Shout, ShoutAuthor
with local_session() as session:
# Базовый запрос для получения авторов
base_query = select(Author).where(Author.deleted_at.is_(None))
@@ -303,7 +300,7 @@ async def invalidate_authors_cache(author_id=None) -> None:
# Получаем author_id автора, если есть
with local_session() as session:
author = session.query(Author).filter(Author.id == author_id).first()
author = session.query(Author).where(Author.id == author_id).first()
if author and Author.id:
specific_keys.append(f"author:id:{Author.id}")
@@ -355,8 +352,6 @@ async def update_author(_: None, info: GraphQLResolveInfo, profile: dict[str, An
# Если мы дошли до сюда, значит автор не найден
return CommonResult(error="Author not found", author=None)
except Exception as exc:
import traceback
logger.error(traceback.format_exc())
return CommonResult(error=str(exc), author=None)
@@ -403,13 +398,13 @@ async def get_author(
if not author_dict or not author_dict.get("stat"):
# update stat from db
author_query = select(Author).filter(Author.id == author_id)
author_query = select(Author).where(Author.id == author_id)
result = get_with_stat(author_query)
if result:
author_with_stat = result[0]
if isinstance(author_with_stat, Author):
# Кэшируем полные данные для админов
original_dict = author_with_stat.dict(True)
original_dict = author_with_stat.dict()
_t = asyncio.create_task(cache_author(original_dict))
# Возвращаем отфильтрованную версию
@@ -420,8 +415,6 @@ async def get_author(
except ValueError:
pass
except Exception as exc:
import traceback
logger.error(f"{exc}:\n{traceback.format_exc()}")
return author_dict
@@ -446,8 +439,6 @@ async def load_authors_by(
# Используем оптимизированную функцию для получения авторов
return await get_authors_with_stats(limit, offset, by, viewer_id)
except Exception as exc:
import traceback
logger.error(f"{exc}:\n{traceback.format_exc()}")
return []
@@ -469,11 +460,11 @@ def get_author_id_from(
with local_session() as session:
author = None
if slug:
author = session.query(Author).filter(Author.slug == slug).first()
author = session.query(Author).where(Author.slug == slug).first()
if author:
return int(author.id)
if user:
author = session.query(Author).filter(Author.id == user).first()
author = session.query(Author).where(Author.id == user).first()
if author:
return int(author.id)
except Exception as exc:
@@ -598,8 +589,6 @@ 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 автора
@@ -607,7 +596,7 @@ def create_author(**kwargs) -> Author:
target_community_id = kwargs.get("community_id", 1) # По умолчанию основное сообщество
# Получаем сообщество для назначения дефолтных ролей
community = session.query(Community).filter(Community.id == target_community_id).first()
community = session.query(Community).where(Community.id == target_community_id).first()
if community:
default_roles = community.get_default_roles()

View File

@@ -14,7 +14,7 @@ from services.schema import mutation, query
@query.field("load_shouts_bookmarked")
@login_required
def load_shouts_bookmarked(_: None, info, options):
def load_shouts_bookmarked(_: None, info, options) -> list[Shout]:
"""
Load bookmarked shouts for the authenticated user.
@@ -33,14 +33,15 @@ def load_shouts_bookmarked(_: None, info, options):
q = query_with_stat(info)
q = q.join(AuthorBookmark)
q = q.filter(
q = q.where(
and_(
Shout.id == AuthorBookmark.shout,
AuthorBookmark.author == author_id,
)
)
q, limit, offset = apply_options(q, options, author_id)
return get_shouts_with_links(info, q, limit, offset)
shouts = get_shouts_with_links(info, q, limit, offset)
return shouts
@mutation.field("toggle_bookmark_shout")
@@ -61,15 +62,13 @@ def toggle_bookmark_shout(_: None, info, slug: str) -> CommonResult:
raise GraphQLError(msg)
with local_session() as db:
shout = db.query(Shout).filter(Shout.slug == slug).first()
shout = db.query(Shout).where(Shout.slug == slug).first()
if not shout:
msg = "Shout not found"
raise GraphQLError(msg)
existing_bookmark = (
db.query(AuthorBookmark)
.filter(AuthorBookmark.author == author_id, AuthorBookmark.shout == shout.id)
.first()
db.query(AuthorBookmark).where(AuthorBookmark.author == author_id, AuthorBookmark.shout == shout.id).first()
)
if existing_bookmark:

View File

@@ -1,3 +1,5 @@
from typing import Any
from auth.orm import Author
from orm.invite import Invite, InviteStatus
from orm.shout import Shout
@@ -8,7 +10,7 @@ from services.schema import mutation
@mutation.field("accept_invite")
@login_required
async def accept_invite(_: None, info, invite_id: int):
async def accept_invite(_: None, info, invite_id: int) -> dict[str, Any]:
author_dict = info.context["author"]
author_id = author_dict.get("id")
if author_id:
@@ -16,13 +18,13 @@ async def accept_invite(_: None, info, invite_id: int):
# Check if the user exists
with local_session() as session:
# Check if the invite exists
invite = session.query(Invite).filter(Invite.id == invite_id).first()
invite = session.query(Invite).where(Invite.id == invite_id).first()
if invite and invite.author_id is author_id and invite.status is InviteStatus.PENDING.value:
# Add the user to the shout authors
shout = session.query(Shout).filter(Shout.id == invite.shout_id).first()
shout = session.query(Shout).where(Shout.id == invite.shout_id).first()
if shout:
if author_id not in shout.authors:
author = session.query(Author).filter(Author.id == author_id).first()
author = session.query(Author).where(Author.id == author_id).first()
if author:
shout.authors.append(author)
session.add(shout)
@@ -32,12 +34,12 @@ async def accept_invite(_: None, info, invite_id: int):
return {"error": "Shout not found"}
return {"error": "Invalid invite or already accepted/rejected"}
else:
return {"error": "Unauthorized"}
return {"error": "UnauthorizedError"}
@mutation.field("reject_invite")
@login_required
async def reject_invite(_: None, info, invite_id: int):
async def reject_invite(_: None, info, invite_id: int) -> dict[str, Any]:
author_dict = info.context["author"]
author_id = author_dict.get("id")
@@ -46,7 +48,7 @@ async def reject_invite(_: None, info, invite_id: int):
with local_session() as session:
author_id = int(author_id)
# Check if the invite exists
invite = session.query(Invite).filter(Invite.id == invite_id).first()
invite = session.query(Invite).where(Invite.id == invite_id).first()
if invite and invite.author_id is author_id and invite.status is InviteStatus.PENDING.value:
# Delete the invite
session.delete(invite)
@@ -58,7 +60,7 @@ async def reject_invite(_: None, info, invite_id: int):
@mutation.field("create_invite")
@login_required
async def create_invite(_: None, info, slug: str = "", author_id: int = 0):
async def create_invite(_: None, info, slug: str = "", author_id: int = 0) -> dict[str, Any]:
author_dict = info.context["author"]
viewer_id = author_dict.get("id")
roles = info.context.get("roles", [])
@@ -68,13 +70,13 @@ async def create_invite(_: None, info, slug: str = "", author_id: int = 0):
if author_id:
# Check if the inviter is the owner of the shout
with local_session() as session:
shout = session.query(Shout).filter(Shout.slug == slug).first()
inviter = session.query(Author).filter(Author.id == viewer_id).first()
shout = session.query(Shout).where(Shout.slug == slug).first()
inviter = session.query(Author).where(Author.id == viewer_id).first()
if inviter and shout and shout.authors and inviter.id is shout.created_by:
# Check if an invite already exists
existing_invite = (
session.query(Invite)
.filter(
.where(
Invite.inviter_id == inviter.id,
Invite.author_id == author_id,
Invite.shout_id == shout.id,
@@ -103,16 +105,16 @@ async def create_invite(_: None, info, slug: str = "", author_id: int = 0):
@mutation.field("remove_author")
@login_required
async def remove_author(_: None, info, slug: str = "", author_id: int = 0):
async def remove_author(_: None, info, slug: str = "", author_id: int = 0) -> dict[str, Any]:
viewer_id = info.context.get("author", {}).get("id")
is_admin = info.context.get("is_admin", False)
roles = info.context.get("roles", [])
if not viewer_id and not is_admin and "admin" not in roles and "editor" not in roles:
return {"error": "Access denied"}
with local_session() as session:
author = session.query(Author).filter(Author.id == author_id).first()
author = session.query(Author).where(Author.id == author_id).first()
if author:
shout = session.query(Shout).filter(Shout.slug == slug).first()
shout = session.query(Shout).where(Shout.slug == slug).first()
# NOTE: owner should be first in a list
if shout and author.id is shout.created_by:
shout.authors = [author for author in shout.authors if author.id != author_id]
@@ -123,16 +125,16 @@ async def remove_author(_: None, info, slug: str = "", author_id: int = 0):
@mutation.field("remove_invite")
@login_required
async def remove_invite(_: None, info, invite_id: int):
async def remove_invite(_: None, info, invite_id: int) -> dict[str, Any]:
author_dict = info.context["author"]
author_id = author_dict.get("id")
if isinstance(author_id, int):
# Check if the user exists
with local_session() as session:
# Check if the invite exists
invite = session.query(Invite).filter(Invite.id == invite_id).first()
invite = session.query(Invite).where(Invite.id == invite_id).first()
if isinstance(invite, Invite):
shout = session.query(Shout).filter(Shout.id == invite.shout_id).first()
shout = session.query(Shout).where(Shout.id == invite.shout_id).first()
if shout and shout.deleted_at is None and invite:
if invite.inviter_id is author_id or author_id == shout.created_by:
if invite.status is InviteStatus.PENDING.value:
@@ -140,9 +142,9 @@ async def remove_invite(_: None, info, invite_id: int):
session.delete(invite)
session.commit()
return {}
return None
return None
return None
return {"error": "Invite already accepted/rejected or deleted"}
return {"error": "Access denied"}
return {"error": "Shout not found"}
return {"error": "Invalid invite or already accepted/rejected"}
else:
return {"error": "Author not found"}

View File

@@ -1,19 +1,20 @@
from typing import Any, Optional
from graphql import GraphQLResolveInfo
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.schema import mutation, query, type_collection
from utils.logger import root_logger as logger
@query.field("get_collections_all")
async def get_collections_all(_: None, _info: GraphQLResolveInfo) -> list[Collection]:
"""Получает все коллекции"""
from sqlalchemy.orm import joinedload
with local_session() as session:
# Загружаем коллекции с проверкой существования авторов
collections = (
@@ -23,7 +24,7 @@ async def get_collections_all(_: None, _info: GraphQLResolveInfo) -> list[Collec
Author,
Collection.created_by == Author.id, # INNER JOIN - исключает коллекции без авторов
)
.filter(
.where(
Collection.created_by.isnot(None), # Дополнительная проверка
Author.id.isnot(None), # Проверяем что автор существует
)
@@ -41,8 +42,6 @@ async def get_collections_all(_: None, _info: GraphQLResolveInfo) -> list[Collec
):
valid_collections.append(collection)
else:
from utils.logger import root_logger as logger
logger.warning(f"Исключена коллекция {collection.id} ({collection.slug}) - проблемы с автором")
return valid_collections
@@ -138,14 +137,23 @@ async def update_collection(_: None, info: GraphQLResolveInfo, collection_input:
try:
with local_session() as session:
# Находим коллекцию для обновления
collection = session.query(Collection).filter(Collection.slug == slug).first()
collection = session.query(Collection).where(Collection.slug == slug).first()
if not collection:
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 []
# Получаем роли пользователя в сообществе
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:
@@ -186,21 +194,30 @@ async def delete_collection(_: None, info: GraphQLResolveInfo, slug: str) -> dic
try:
with local_session() as session:
# Находим коллекцию для удаления
collection = session.query(Collection).filter(Collection.slug == slug).first()
collection = session.query(Collection).where(Collection.slug == slug).first()
if not collection:
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 []
# Получаем роли пользователя в сообществе
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).filter(ShoutCollection.collection == collection.id).delete()
session.query(ShoutCollection).where(ShoutCollection.collection == collection.id).delete()
# Удаляем коллекцию
session.delete(collection)
@@ -217,10 +234,8 @@ def resolve_collection_created_by(obj: Collection, *_: Any) -> Optional[Author]:
if hasattr(obj, "created_by_author") and obj.created_by_author:
return obj.created_by_author
author = session.query(Author).filter(Author.id == obj.created_by).first()
author = session.query(Author).where(Author.id == obj.created_by).first()
if not author:
from utils.logger import root_logger as logger
logger.warning(f"Автор с ID {obj.created_by} не найден для коллекции {obj.id}")
return author
@@ -230,5 +245,4 @@ def resolve_collection_created_by(obj: Collection, *_: Any) -> Optional[Author]:
def resolve_collection_amount(obj: Collection, *_: Any) -> int:
"""Резолвер для количества публикаций в коллекции"""
with local_session() as session:
count = session.query(ShoutCollection).filter(ShoutCollection.collection == obj.id).count()
return count
return session.query(ShoutCollection).where(ShoutCollection.collection == obj.id).count()

View File

@@ -1,50 +1,22 @@
from typing import Any
from graphql import GraphQLResolveInfo
from sqlalchemy import distinct, func
from auth.orm import Author
from orm.community import Community, CommunityFollower
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.schema import mutation, query, type_community
from utils.logger import root_logger as logger
@query.field("get_communities_all")
async def get_communities_all(_: None, _info: GraphQLResolveInfo) -> list[Community]:
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
return session.query(Community).all()
@query.field("get_community")
@@ -60,13 +32,17 @@ async def get_communities_by_author(
with local_session() as session:
q = session.query(Community).join(CommunityFollower)
if slug:
author_id = session.query(Author).where(Author.slug == slug).first().id
q = q.where(CommunityFollower.author == author_id)
author = session.query(Author).where(Author.slug == slug).first()
if author:
author_id = author.id
q = q.where(CommunityFollower.follower == author_id)
if user:
author_id = session.query(Author).where(Author.id == user).first().id
q = q.where(CommunityFollower.author == author_id)
author = session.query(Author).where(Author.id == user).first()
if author:
author_id = author.id
q = q.where(CommunityFollower.follower == author_id)
if author_id:
q = q.where(CommunityFollower.author == author_id)
q = q.where(CommunityFollower.follower == author_id)
return q.all()
return []
@@ -76,11 +52,14 @@ async def get_communities_by_author(
async def join_community(_: None, info: GraphQLResolveInfo, slug: str) -> dict[str, Any]:
author_dict = info.context.get("author", {})
author_id = author_dict.get("id")
if not author_id:
return {"ok": False, "error": "Unauthorized"}
with local_session() as session:
community = session.query(Community).where(Community.slug == slug).first()
if not community:
return {"ok": False, "error": "Community not found"}
session.add(CommunityFollower(community=community.id, follower=author_id))
session.add(CommunityFollower(community=community.id, follower=int(author_id)))
session.commit()
return {"ok": True}
@@ -91,7 +70,7 @@ async def leave_community(_: None, info: GraphQLResolveInfo, slug: str) -> dict[
author_id = author_dict.get("id")
with local_session() as session:
session.query(CommunityFollower).where(
CommunityFollower.author == author_id, CommunityFollower.community == slug
CommunityFollower.follower == author_id, CommunityFollower.community == slug
).delete()
session.commit()
return {"ok": True}
@@ -161,14 +140,20 @@ async def update_community(_: None, info: GraphQLResolveInfo, community_input: d
try:
with local_session() as session:
# Находим сообщество для обновления
community = session.query(Community).filter(Community.slug == slug).first()
community = session.query(Community).where(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 []
# Получаем роли пользователя в сообществе
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:
@@ -188,81 +173,51 @@ async def update_community(_: None, info: GraphQLResolveInfo, community_input: d
@mutation.field("delete_community")
@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")
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": "Не удалось определить автора"}
async def delete_community(root, info, slug: str) -> dict[str, Any]:
try:
# Используем local_session как контекстный менеджер
with local_session() as session:
# Находим сообщество для удаления
community = session.query(Community).filter(Community.slug == slug).first()
# Находим сообщество по slug
community = session.query(Community).where(Community.slug == slug).first()
if not community:
return {"error": "Сообщество не найдено"}
return {"error": "Сообщество не найдено", "success": False}
# Проверяем права на удаление (создатель или админ/редактор)
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 []
# Проверяем права на удаление
user_id = info.context.get("user_id", 0)
permission_check = ContextualPermissionCheck()
# Разрешаем удаление если пользователь - создатель или имеет роль admin/editor
if community.created_by != author_id and "admin" not in user_roles and "editor" not in user_roles:
return {"error": "Недостаточно прав для удаления этого сообщества"}
# Проверяем права на удаление сообщества
if not await permission_check.can_delete_community(user_id, community, session):
return {"error": "Недостаточно прав", "success": False}
# Удаляем сообщество
session.delete(community)
session.commit()
return {"error": None}
return {"success": True, "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)
# Логируем ошибку
logger.error(f"Ошибка удаления сообщества: {e}")
return {"error": str(e), "success": False}
@type_community.field("stat")
def resolve_community_stat(obj: Community, *_: Any) -> dict[str, int]:
def resolve_community_stat(community: Community | dict[str, Any], *_: Any) -> dict[str, int]:
"""
Резолвер поля stat для Community.
Возвращает статистику сообщества: количество публикаций, подписчиков и авторов.
"""
from sqlalchemy import distinct, func
from orm.shout import Shout, ShoutAuthor
community_id = community.get("id") if isinstance(community, dict) else community.id
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))
.where(Shout.community == community_id, Shout.published_at.is_not(None), Shout.deleted_at.is_(None))
.scalar()
or 0
)
@@ -270,7 +225,7 @@ def resolve_community_stat(obj: Community, *_: Any) -> dict[str, int]:
# Количество подписчиков сообщества
followers_count = (
session.query(func.count(CommunityFollower.follower))
.filter(CommunityFollower.community == obj.id)
.where(CommunityFollower.community == community_id)
.scalar()
or 0
)
@@ -279,7 +234,7 @@ def resolve_community_stat(obj: Community, *_: Any) -> dict[str, int]:
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))
.where(Shout.community == community_id, Shout.published_at.is_not(None), Shout.deleted_at.is_(None))
.scalar()
or 0
)
@@ -287,8 +242,6 @@ def resolve_community_stat(obj: Community, *_: Any) -> dict[str, int]:
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}")
logger.error(f"Ошибка при получении статистики сообщества {community_id}: {e}")
# Возвращаем нулевую статистику при ошибке
return {"shouts": 0, "followers": 0, "authors": 0}

View File

@@ -96,7 +96,7 @@ async def load_drafts(_: None, info: GraphQLResolveInfo) -> dict[str, Any]:
joinedload(Draft.topics),
joinedload(Draft.authors),
)
.filter(Draft.authors.any(Author.id == author_id))
.where(Draft.authors.any(Author.id == author_id))
)
drafts = drafts_query.all()
@@ -168,12 +168,41 @@ async def create_draft(_: None, info: GraphQLResolveInfo, draft_input: dict[str,
# Добавляем текущее время создания и ID автора
draft_input["created_at"] = int(time.time())
draft_input["created_by"] = author_id
draft = Draft(**draft_input)
# Исключаем поле shout из создания draft (оно добавляется только при публикации)
draft_input.pop("shout", None)
# Создаем draft вручную, исключая проблемные поля
draft = Draft()
draft.created_at = draft_input["created_at"]
draft.created_by = draft_input["created_by"]
draft.community = draft_input.get("community", 1)
draft.layout = draft_input.get("layout", "article")
draft.title = draft_input.get("title", "")
draft.body = draft_input.get("body", "")
draft.lang = draft_input.get("lang", "ru")
# Опциональные поля
if "slug" in draft_input:
draft.slug = draft_input["slug"]
if "subtitle" in draft_input:
draft.subtitle = draft_input["subtitle"]
if "lead" in draft_input:
draft.lead = draft_input["lead"]
if "cover" in draft_input:
draft.cover = draft_input["cover"]
if "cover_caption" in draft_input:
draft.cover_caption = draft_input["cover_caption"]
if "seo" in draft_input:
draft.seo = draft_input["seo"]
if "media" in draft_input:
draft.media = draft_input["media"]
session.add(draft)
session.flush()
# Добавляем создателя как автора
da = DraftAuthor(shout=draft.id, author=author_id)
da = DraftAuthor(draft=draft.id, author=author_id)
session.add(da)
session.commit()
@@ -222,7 +251,7 @@ async def update_draft(_: None, info: GraphQLResolveInfo, draft_id: int, draft_i
try:
with local_session() as session:
draft = session.query(Draft).filter(Draft.id == draft_id).first()
draft = session.query(Draft).where(Draft.id == draft_id).first()
if not draft:
return {"error": "Draft not found"}
@@ -254,10 +283,10 @@ async def update_draft(_: None, info: GraphQLResolveInfo, draft_id: int, draft_i
author_ids = filtered_input.pop("author_ids")
if author_ids:
# Очищаем текущие связи
session.query(DraftAuthor).filter(DraftAuthor.shout == draft_id).delete()
session.query(DraftAuthor).where(DraftAuthor.draft == draft_id).delete()
# Добавляем новые связи
for aid in author_ids:
da = DraftAuthor(shout=draft_id, author=aid)
da = DraftAuthor(draft=draft_id, author=aid)
session.add(da)
# Обновляем связи с темами если переданы
@@ -266,11 +295,11 @@ async def update_draft(_: None, info: GraphQLResolveInfo, draft_id: int, draft_i
main_topic_id = filtered_input.pop("main_topic_id", None)
if topic_ids:
# Очищаем текущие связи
session.query(DraftTopic).filter(DraftTopic.shout == draft_id).delete()
session.query(DraftTopic).where(DraftTopic.draft == draft_id).delete()
# Добавляем новые связи
for tid in topic_ids:
dt = DraftTopic(
shout=draft_id,
draft=draft_id,
topic=tid,
main=(tid == main_topic_id) if main_topic_id else False,
)
@@ -320,10 +349,10 @@ async def delete_draft(_: None, info: GraphQLResolveInfo, draft_id: int) -> dict
author_id = author_dict.get("id")
with local_session() as session:
draft = session.query(Draft).filter(Draft.id == draft_id).first()
draft = session.query(Draft).where(Draft.id == draft_id).first()
if not draft:
return {"error": "Draft not found"}
if author_id != draft.created_by and draft.authors.filter(Author.id == author_id).count() == 0:
if author_id != draft.created_by and draft.authors.where(Author.id == author_id).count() == 0:
return {"error": "You are not allowed to delete this draft"}
session.delete(draft)
session.commit()
@@ -386,8 +415,8 @@ async def publish_draft(_: None, info: GraphQLResolveInfo, draft_id: int) -> dic
# Загружаем черновик со всеми связями
draft = (
session.query(Draft)
.options(joinedload(Draft.topics), joinedload(Draft.authors), joinedload(Draft.publication))
.filter(Draft.id == draft_id)
.options(joinedload(Draft.topics), joinedload(Draft.authors))
.where(Draft.id == draft_id)
.first()
)
@@ -401,7 +430,8 @@ async def publish_draft(_: None, info: GraphQLResolveInfo, draft_id: int) -> dic
return {"error": f"Cannot publish draft: {error}"}
# Проверяем, есть ли уже публикация для этого черновика
if draft.publication:
shout = None
if hasattr(draft, "publication") and draft.publication:
shout = draft.publication
# Обновляем существующую публикацию
if hasattr(draft, "body"):
@@ -428,14 +458,14 @@ async def publish_draft(_: None, info: GraphQLResolveInfo, draft_id: int) -> dic
# Создаем новую публикацию
shout = create_shout_from_draft(session, draft, author_id)
now = int(time.time())
shout.created_at = now
shout.published_at = now
shout.created_at = int(now)
shout.published_at = int(now)
session.add(shout)
session.flush() # Получаем ID нового шаута
# Очищаем существующие связи
session.query(ShoutAuthor).filter(ShoutAuthor.shout == shout.id).delete()
session.query(ShoutTopic).filter(ShoutTopic.shout == shout.id).delete()
session.query(ShoutAuthor).where(ShoutAuthor.shout == shout.id).delete()
session.query(ShoutTopic).where(ShoutTopic.shout == shout.id).delete()
# Добавляем авторов
for author in draft.authors or []:
@@ -457,7 +487,7 @@ async def publish_draft(_: None, info: GraphQLResolveInfo, draft_id: int) -> dic
await invalidate_shout_related_cache(shout, author_id)
# Уведомляем о публикации
await notify_shout(shout.id)
await notify_shout(shout.dict(), "published")
# Обновляем поисковый индекс
await search_service.perform_index(shout)
@@ -495,8 +525,8 @@ async def unpublish_draft(_: None, info: GraphQLResolveInfo, draft_id: int) -> d
# Загружаем черновик со связанной публикацией
draft = (
session.query(Draft)
.options(joinedload(Draft.publication), joinedload(Draft.authors), joinedload(Draft.topics))
.filter(Draft.id == draft_id)
.options(joinedload(Draft.authors), joinedload(Draft.topics))
.where(Draft.id == draft_id)
.first()
)
@@ -504,11 +534,12 @@ async def unpublish_draft(_: None, info: GraphQLResolveInfo, draft_id: int) -> d
return {"error": "Draft not found"}
# Проверяем, есть ли публикация
if not draft.publication:
shout = None
if hasattr(draft, "publication") and draft.publication:
shout = draft.publication
else:
return {"error": "This draft is not published yet"}
shout = draft.publication
# Снимаем с публикации
shout.published_at = None
shout.updated_at = int(time.time())

View File

@@ -8,12 +8,6 @@ from sqlalchemy.orm import joinedload
from sqlalchemy.sql.functions import coalesce
from auth.orm import Author
from cache.cache import (
cache_author,
cache_topic,
invalidate_shout_related_cache,
invalidate_shouts_cache,
)
from orm.shout import Shout, ShoutAuthor, ShoutTopic
from orm.topic import Topic
from resolvers.follower import follow
@@ -28,7 +22,7 @@ from utils.extract_text import extract_text
from utils.logger import root_logger as logger
async def cache_by_id(entity, entity_id: int, cache_method):
async def cache_by_id(entity, entity_id: int, cache_method) -> None:
"""Cache an entity by its ID using the provided cache method.
Args:
@@ -46,20 +40,20 @@ async def cache_by_id(entity, entity_id: int, cache_method):
... assert 'name' in author
... return author
"""
caching_query = select(entity).filter(entity.id == entity_id)
caching_query = select(entity).where(entity.id == entity_id)
result = get_with_stat(caching_query)
if not result or not result[0]:
logger.warning(f"{entity.__name__} with id {entity_id} not found")
return None
x = result[0]
d = x.dict() # convert object to dictionary
cache_method(d)
await cache_method(d)
return d
@query.field("get_my_shout")
@login_required
async def get_my_shout(_: None, info, shout_id: int):
async def get_my_shout(_: None, info, shout_id: int) -> dict[str, Any]:
"""Get a shout by ID if the requesting user has permission to view it.
DEPRECATED: use `load_drafts` instead
@@ -97,9 +91,9 @@ async def get_my_shout(_: None, info, shout_id: int):
with local_session() as session:
shout = (
session.query(Shout)
.filter(Shout.id == shout_id)
.where(Shout.id == shout_id)
.options(joinedload(Shout.authors), joinedload(Shout.topics))
.filter(Shout.deleted_at.is_(None))
.where(Shout.deleted_at.is_(None))
.first()
)
if not shout:
@@ -147,8 +141,8 @@ async def get_shouts_drafts(_: None, info: GraphQLResolveInfo) -> list[dict]:
q = (
select(Shout)
.options(joinedload(Shout.authors), joinedload(Shout.topics))
.filter(and_(Shout.deleted_at.is_(None), Shout.created_by == int(author_id)))
.filter(Shout.published_at.is_(None))
.where(and_(Shout.deleted_at.is_(None), Shout.created_by == int(author_id)))
.where(Shout.published_at.is_(None))
.order_by(desc(coalesce(Shout.updated_at, Shout.created_at)))
.group_by(Shout.id)
)
@@ -197,12 +191,12 @@ async def create_shout(_: None, info: GraphQLResolveInfo, inp: dict) -> dict:
# Проверяем уникальность slug
logger.debug(f"Checking for existing slug: {slug}")
same_slug_shout = session.query(Shout).filter(Shout.slug == new_shout.slug).first()
same_slug_shout = session.query(Shout).where(Shout.slug == new_shout.slug).first()
c = 1
while same_slug_shout is not None:
logger.debug(f"Found duplicate slug, trying iteration {c}")
new_shout.slug = f"{slug}-{c}" # type: ignore[assignment]
same_slug_shout = session.query(Shout).filter(Shout.slug == new_shout.slug).first()
same_slug_shout = session.query(Shout).where(Shout.slug == new_shout.slug).first()
c += 1
try:
@@ -250,7 +244,7 @@ async def create_shout(_: None, info: GraphQLResolveInfo, inp: dict) -> dict:
return {"error": f"Error in final commit: {e!s}"}
# Получаем созданную публикацию
shout = session.query(Shout).filter(Shout.id == new_shout.id).first()
shout = session.query(Shout).where(Shout.id == new_shout.id).first()
if shout:
# Подписываем автора
@@ -280,7 +274,7 @@ def patch_main_topic(session: Any, main_topic_slug: str, shout: Any) -> None:
with session.begin():
# Получаем текущий главный топик
old_main = (
session.query(ShoutTopic).filter(and_(ShoutTopic.shout == shout.id, ShoutTopic.main.is_(True))).first()
session.query(ShoutTopic).where(and_(ShoutTopic.shout == shout.id, ShoutTopic.main.is_(True))).first()
)
if old_main:
logger.info(f"Found current main topic: {old_main.topic.slug}")
@@ -288,7 +282,7 @@ def patch_main_topic(session: Any, main_topic_slug: str, shout: Any) -> None:
logger.info("No current main topic found")
# Находим новый главный топик
main_topic = session.query(Topic).filter(Topic.slug == main_topic_slug).first()
main_topic = session.query(Topic).where(Topic.slug == main_topic_slug).first()
if not main_topic:
logger.error(f"Main topic with slug '{main_topic_slug}' not found")
return
@@ -298,7 +292,7 @@ def patch_main_topic(session: Any, main_topic_slug: str, shout: Any) -> None:
# Находим связь с новым главным топиком
new_main = (
session.query(ShoutTopic)
.filter(and_(ShoutTopic.shout == shout.id, ShoutTopic.topic == main_topic.id))
.where(and_(ShoutTopic.shout == shout.id, ShoutTopic.topic == main_topic.id))
.first()
)
logger.debug(f"Found new main topic relation: {new_main is not None}")
@@ -357,7 +351,7 @@ def patch_topics(session: Any, shout: Any, topics_input: list[Any]) -> None:
session.flush()
# Получаем текущие связи
current_links = session.query(ShoutTopic).filter(ShoutTopic.shout == shout.id).all()
current_links = session.query(ShoutTopic).where(ShoutTopic.shout == shout.id).all()
logger.info(f"Current topic links: {[{t.topic: t.main} for t in current_links]}")
# Удаляем старые связи
@@ -391,13 +385,21 @@ def patch_topics(session: Any, shout: Any, topics_input: list[Any]) -> None:
async def update_shout(
_: None, info: GraphQLResolveInfo, shout_id: int, shout_input: dict | None = None, *, publish: bool = False
) -> CommonResult:
# Поздние импорты для избежания циклических зависимостей
from cache.cache import (
cache_author,
cache_topic,
invalidate_shout_related_cache,
invalidate_shouts_cache,
)
"""Update an existing shout with optional publishing"""
logger.info(f"update_shout called with shout_id={shout_id}, publish={publish}")
author_dict = info.context.get("author", {})
author_id = author_dict.get("id")
if not author_id:
logger.error("Unauthorized update attempt")
logger.error("UnauthorizedError update attempt")
return CommonResult(error="unauthorized", shout=None)
logger.info(f"Starting update_shout with id={shout_id}, publish={publish}")
@@ -415,7 +417,7 @@ async def update_shout(
shout_by_id = (
session.query(Shout)
.options(joinedload(Shout.topics).joinedload(ShoutTopic.topic), joinedload(Shout.authors))
.filter(Shout.id == shout_id)
.where(Shout.id == shout_id)
.first()
)
@@ -434,12 +436,12 @@ async def update_shout(
logger.info(f"Current topics for shout#{shout_id}: {current_topics}")
if slug != shout_by_id.slug:
same_slug_shout = session.query(Shout).filter(Shout.slug == slug).first()
same_slug_shout = session.query(Shout).where(Shout.slug == slug).first()
c = 1
while same_slug_shout is not None:
c += 1
same_slug_shout.slug = f"{slug}-{c}" # type: ignore[assignment]
same_slug_shout = session.query(Shout).filter(Shout.slug == slug).first()
same_slug_shout = session.query(Shout).where(Shout.slug == slug).first()
shout_input["slug"] = slug
logger.info(f"shout#{shout_id} slug patched")
@@ -481,7 +483,7 @@ async def update_shout(
logger.info(f"Checking author link for shout#{shout_id} and author#{author_id}")
author_link = (
session.query(ShoutAuthor)
.filter(and_(ShoutAuthor.shout == shout_id, ShoutAuthor.author == author_id))
.where(and_(ShoutAuthor.shout == shout_id, ShoutAuthor.author == author_id))
.first()
)
@@ -570,6 +572,11 @@ async def update_shout(
# @mutation.field("delete_shout")
# @login_required
async def delete_shout(_: None, info: GraphQLResolveInfo, shout_id: int) -> CommonResult:
# Поздние импорты для избежания циклических зависимостей
from cache.cache import (
invalidate_shout_related_cache,
)
"""Delete a shout (mark as deleted)"""
author_dict = info.context.get("author", {})
if not author_dict:
@@ -579,27 +586,26 @@ async def delete_shout(_: None, info: GraphQLResolveInfo, shout_id: int) -> Comm
roles = info.context.get("roles", [])
with local_session() as session:
if author_id:
if shout_id:
shout = session.query(Shout).filter(Shout.id == shout_id).first()
if shout:
# Check if user has permission to delete
if any(x.id == author_id for x in shout.authors) or "editor" in roles:
# Use setattr to avoid MyPy complaints about Column assignment
shout.deleted_at = int(time.time()) # type: ignore[assignment]
session.add(shout)
session.commit()
if author_id and shout_id:
shout = session.query(Shout).where(Shout.id == shout_id).first()
if shout:
# Check if user has permission to delete
if any(x.id == author_id for x in shout.authors) or "editor" in roles:
# Use setattr to avoid MyPy complaints about Column assignment
shout.deleted_at = int(time.time()) # type: ignore[assignment]
session.add(shout)
session.commit()
# Get shout data for notification
shout_dict = shout.dict()
# Get shout data for notification
shout_dict = shout.dict()
# Invalidate cache
await invalidate_shout_related_cache(shout, author_id)
# Invalidate cache
await invalidate_shout_related_cache(shout, author_id)
# Notify about deletion
await notify_shout(shout_dict, "delete")
return CommonResult(error=None, shout=shout)
return CommonResult(error="access denied", shout=None)
# Notify about deletion
await notify_shout(shout_dict, "delete")
return CommonResult(error=None, shout=shout)
return CommonResult(error="access denied", shout=None)
return CommonResult(error="shout not found", shout=None)
@@ -661,6 +667,12 @@ async def unpublish_shout(_: None, info: GraphQLResolveInfo, shout_id: int) -> C
"""
Unpublish a shout by setting published_at to NULL
"""
# Поздние импорты для избежания циклических зависимостей
from cache.cache import (
invalidate_shout_related_cache,
invalidate_shouts_cache,
)
author_dict = info.context.get("author", {})
author_id = author_dict.get("id")
roles = info.context.get("roles", [])
@@ -671,7 +683,7 @@ async def unpublish_shout(_: None, info: GraphQLResolveInfo, shout_id: int) -> C
try:
with local_session() as session:
# Получаем шаут с авторами
shout = session.query(Shout).options(joinedload(Shout.authors)).filter(Shout.id == shout_id).first()
shout = session.query(Shout).options(joinedload(Shout.authors)).where(Shout.id == shout_id).first()
if not shout:
return CommonResult(error="Shout not found", shout=None)
@@ -703,7 +715,6 @@ async def unpublish_shout(_: None, info: GraphQLResolveInfo, shout_id: int) -> C
# Получаем обновленные данные шаута
session.refresh(shout)
shout_dict = shout.dict()
logger.info(f"Shout {shout_id} unpublished successfully")
return CommonResult(error=None, shout=shout)

View File

@@ -1,5 +1,7 @@
from typing import Any
from graphql import GraphQLResolveInfo
from sqlalchemy import and_, select
from sqlalchemy import Select, and_, select
from auth.orm import Author, AuthorFollower
from orm.shout import Shout, ShoutAuthor, ShoutReactionsFollower, ShoutTopic
@@ -30,7 +32,7 @@ async def load_shouts_coauthored(_: None, info: GraphQLResolveInfo, options: dic
if not author_id:
return []
q = query_with_stat(info)
q = q.filter(Shout.authors.any(id=author_id))
q = q.where(Shout.authors.any(id=author_id))
q, limit, offset = apply_options(q, options)
return get_shouts_with_links(info, q, limit, offset=offset)
@@ -54,7 +56,7 @@ async def load_shouts_discussed(_: None, info: GraphQLResolveInfo, options: dict
return get_shouts_with_links(info, q, limit, offset=offset)
def shouts_by_follower(info: GraphQLResolveInfo, follower_id: int, options: dict) -> list[Shout]:
def shouts_by_follower(info: GraphQLResolveInfo, follower_id: int, options: dict[str, Any]) -> list[Shout]:
"""
Загружает публикации, на которые подписан автор.
@@ -68,9 +70,11 @@ def shouts_by_follower(info: GraphQLResolveInfo, follower_id: int, options: dict
:return: Список публикаций.
"""
q = query_with_stat(info)
reader_followed_authors = select(AuthorFollower.author).where(AuthorFollower.follower == follower_id)
reader_followed_topics = select(TopicFollower.topic).where(TopicFollower.follower == follower_id)
reader_followed_shouts = select(ShoutReactionsFollower.shout).where(ShoutReactionsFollower.follower == follower_id)
reader_followed_authors: Select = select(AuthorFollower.author).where(AuthorFollower.follower == follower_id)
reader_followed_topics: Select = select(TopicFollower.topic).where(TopicFollower.follower == follower_id)
reader_followed_shouts: Select = select(ShoutReactionsFollower.shout).where(
ShoutReactionsFollower.follower == follower_id
)
followed_subquery = (
select(Shout.id)
.join(ShoutAuthor, ShoutAuthor.shout == Shout.id)
@@ -82,7 +86,7 @@ def shouts_by_follower(info: GraphQLResolveInfo, follower_id: int, options: dict
)
.scalar_subquery()
)
q = q.filter(Shout.id.in_(followed_subquery))
q = q.where(Shout.id.in_(followed_subquery))
q, limit, offset = apply_options(q, options)
return get_shouts_with_links(info, q, limit, offset=offset)
@@ -98,7 +102,7 @@ async def load_shouts_followed_by(_: None, info: GraphQLResolveInfo, slug: str,
:return: Список публикаций.
"""
with local_session() as session:
author = session.query(Author).filter(Author.slug == slug).first()
author = session.query(Author).where(Author.slug == slug).first()
if author:
follower_id = author.dict()["id"]
return shouts_by_follower(info, follower_id, options)
@@ -120,7 +124,7 @@ async def load_shouts_feed(_: None, info: GraphQLResolveInfo, options: dict) ->
@query.field("load_shouts_authored_by")
async def load_shouts_authored_by(_: None, info: GraphQLResolveInfo, slug: str, options: dict) -> list[Shout]:
async def load_shouts_authored_by(_: None, info: GraphQLResolveInfo, slug: str, options: dict[str, Any]) -> list[Shout]:
"""
Загружает публикации, написанные автором по slug.
@@ -130,16 +134,16 @@ async def load_shouts_authored_by(_: None, info: GraphQLResolveInfo, slug: str,
:return: Список публикаций.
"""
with local_session() as session:
author = session.query(Author).filter(Author.slug == slug).first()
author = session.query(Author).where(Author.slug == slug).first()
if author:
try:
author_id: int = author.dict()["id"]
q = (
q: Select = (
query_with_stat(info)
if has_field(info, "stat")
else select(Shout).filter(and_(Shout.published_at.is_not(None), Shout.deleted_at.is_(None)))
else select(Shout).where(and_(Shout.published_at.is_not(None), Shout.deleted_at.is_(None)))
)
q = q.filter(Shout.authors.any(id=author_id))
q = q.where(Shout.authors.any(id=author_id))
q, limit, offset = apply_options(q, options, author_id)
return get_shouts_with_links(info, q, limit, offset=offset)
except Exception as error:
@@ -148,7 +152,7 @@ async def load_shouts_authored_by(_: None, info: GraphQLResolveInfo, slug: str,
@query.field("load_shouts_with_topic")
async def load_shouts_with_topic(_: None, info: GraphQLResolveInfo, slug: str, options: dict) -> list[Shout]:
async def load_shouts_with_topic(_: None, info: GraphQLResolveInfo, slug: str, options: dict[str, Any]) -> list[Shout]:
"""
Загружает публикации, связанные с темой по slug.
@@ -158,16 +162,16 @@ async def load_shouts_with_topic(_: None, info: GraphQLResolveInfo, slug: str, o
:return: Список публикаций.
"""
with local_session() as session:
topic = session.query(Topic).filter(Topic.slug == slug).first()
topic = session.query(Topic).where(Topic.slug == slug).first()
if topic:
try:
topic_id: int = topic.dict()["id"]
q = (
q: Select = (
query_with_stat(info)
if has_field(info, "stat")
else select(Shout).filter(and_(Shout.published_at.is_not(None), Shout.deleted_at.is_(None)))
else select(Shout).where(and_(Shout.published_at.is_not(None), Shout.deleted_at.is_(None)))
)
q = q.filter(Shout.topics.any(id=topic_id))
q = q.where(Shout.topics.any(id=topic_id))
q, limit, offset = apply_options(q, options)
return get_shouts_with_links(info, q, limit, offset=offset)
except Exception as error:

View File

@@ -1,20 +1,14 @@
from __future__ import annotations
from typing import Any
from graphql import GraphQLResolveInfo
from sqlalchemy import select
from sqlalchemy.sql import and_
from auth.orm import Author, AuthorFollower
from cache.cache import (
cache_author,
cache_topic,
get_cached_follower_authors,
get_cached_follower_topics,
)
from orm.community import Community, CommunityFollower
from orm.shout import Shout, ShoutReactionsFollower
from orm.topic import Topic, TopicFollower
from resolvers.stat import get_with_stat
from services.auth import login_required
from services.db import local_session
from services.notify import notify_follower
@@ -25,18 +19,31 @@ from utils.logger import root_logger as logger
@mutation.field("follow")
@login_required
async def follow(_: None, info: GraphQLResolveInfo, what: str, slug: str = "", entity_id: int = 0) -> dict:
async def follow(
_: None, info: GraphQLResolveInfo, what: str, slug: str = "", entity_id: int | None = None
) -> dict[str, Any]:
logger.debug("Начало выполнения функции 'follow'")
viewer_id = info.context.get("author", {}).get("id")
if not viewer_id:
return {"error": "Access denied"}
follower_dict = info.context.get("author") or {}
logger.debug(f"follower: {follower_dict}")
if not viewer_id or not follower_dict:
return {"error": "Access denied"}
logger.warning("Неавторизованный доступ при попытке подписаться")
return {"error": "UnauthorizedError"}
follower_id = follower_dict.get("id")
logger.debug(f"follower_id: {follower_id}")
# Поздние импорты для избежания циклических зависимостей
from cache.cache import (
cache_author,
cache_topic,
get_cached_follower_authors,
get_cached_follower_topics,
)
entity_classes = {
"AUTHOR": (Author, AuthorFollower, get_cached_follower_authors, cache_author),
"TOPIC": (Topic, TopicFollower, get_cached_follower_topics, cache_topic),
@@ -50,33 +57,42 @@ async def follow(_: None, info: GraphQLResolveInfo, what: str, slug: str = "", e
entity_class, follower_class, get_cached_follows_method, cache_method = entity_classes[what]
entity_type = what.lower()
entity_dict = None
follows = []
error = None
follows: list[dict[str, Any]] = []
error: str | None = None
try:
logger.debug("Попытка получить сущность из базы данных")
with local_session() as session:
entity_query = select(entity_class).filter(entity_class.slug == slug)
entities = get_with_stat(entity_query)
[entity] = entities
# Используем query для получения сущности
entity_query = session.query(entity_class)
# Проверяем наличие slug перед фильтрацией
if hasattr(entity_class, "slug"):
entity_query = entity_query.where(entity_class.slug == slug)
entity = entity_query.first()
if not entity:
logger.warning(f"{what.lower()} не найден по slug: {slug}")
return {"error": f"{what.lower()} not found"}
if not entity_id and entity:
entity_id = entity.id
# Получаем ID сущности
if entity_id is None:
entity_id = getattr(entity, "id", None)
if not entity_id:
logger.warning(f"Не удалось получить ID для {what.lower()}")
return {"error": f"Cannot get ID for {what.lower()}"}
# Если это автор, учитываем фильтрацию данных
entity_dict = entity.dict(True) if what == "AUTHOR" else entity.dict()
entity_dict = entity.dict() if hasattr(entity, "dict") else {}
logger.debug(f"entity_id: {entity_id}, entity_dict: {entity_dict}")
if entity_id:
logger.debug("Проверка существующей подписки")
with local_session() as session:
if entity_id is not None and isinstance(entity_id, int):
existing_sub = (
session.query(follower_class)
.filter(
.where(
follower_class.follower == follower_id, # type: ignore[attr-defined]
getattr(follower_class, entity_type) == entity_id, # type: ignore[attr-defined]
)
@@ -123,7 +139,7 @@ async def follow(_: None, info: GraphQLResolveInfo, what: str, slug: str = "", e
if hasattr(temp_author, key):
setattr(temp_author, key, value)
# Добавляем отфильтрованную версию
follows_filtered.append(temp_author.dict(False))
follows_filtered.append(temp_author.dict())
follows = follows_filtered
else:
@@ -131,17 +147,18 @@ async def follow(_: None, info: GraphQLResolveInfo, what: str, slug: str = "", e
logger.debug(f"Актуальный список подписок получен: {len(follows)} элементов")
return {f"{entity_type}s": follows, "error": error}
except Exception as exc:
logger.exception("Произошла ошибка в функции 'follow'")
return {"error": str(exc)}
logger.debug(f"Функция 'follow' завершена: {entity_type}s={len(follows)}, error={error}")
return {f"{entity_type}s": follows, "error": error}
@mutation.field("unfollow")
@login_required
async def unfollow(_: None, info: GraphQLResolveInfo, what: str, slug: str = "", entity_id: int = 0) -> dict:
async def unfollow(
_: None, info: GraphQLResolveInfo, what: str, slug: str = "", entity_id: int | None = None
) -> dict[str, Any]:
logger.debug("Начало выполнения функции 'unfollow'")
viewer_id = info.context.get("author", {}).get("id")
if not viewer_id:
@@ -151,11 +168,19 @@ async def unfollow(_: None, info: GraphQLResolveInfo, what: str, slug: str = "",
if not viewer_id or not follower_dict:
logger.warning("Неавторизованный доступ при попытке отписаться")
return {"error": "Unauthorized"}
return {"error": "UnauthorizedError"}
follower_id = follower_dict.get("id")
logger.debug(f"follower_id: {follower_id}")
# Поздние импорты для избежания циклических зависимостей
from cache.cache import (
cache_author,
cache_topic,
get_cached_follower_authors,
get_cached_follower_topics,
)
entity_classes = {
"AUTHOR": (Author, AuthorFollower, get_cached_follower_authors, cache_author),
"TOPIC": (Topic, TopicFollower, get_cached_follower_topics, cache_topic),
@@ -169,24 +194,32 @@ async def unfollow(_: None, info: GraphQLResolveInfo, what: str, slug: str = "",
entity_class, follower_class, get_cached_follows_method, cache_method = entity_classes[what]
entity_type = what.lower()
follows = []
error = None
follows: list[dict[str, Any]] = []
try:
logger.debug("Попытка получить сущность из базы данных")
with local_session() as session:
entity = session.query(entity_class).filter(entity_class.slug == slug).first()
# Используем query для получения сущности
entity_query = session.query(entity_class)
if hasattr(entity_class, "slug"):
entity_query = entity_query.where(entity_class.slug == slug)
entity = entity_query.first()
logger.debug(f"Полученная сущность: {entity}")
if not entity:
logger.warning(f"{what.lower()} не найден по slug: {slug}")
return {"error": f"{what.lower()} not found"}
if entity and not entity_id:
entity_id = int(entity.id) # Convert Column to int
logger.debug(f"entity_id: {entity_id}")
if not entity_id:
entity_id = getattr(entity, "id", None)
if not entity_id:
logger.warning(f"Не удалось получить ID для {what.lower()}")
return {"error": f"Cannot get ID for {what.lower()}"}
logger.debug(f"entity_id: {entity_id}")
sub = (
session.query(follower_class)
.filter(
.where(
and_(
follower_class.follower == follower_id, # type: ignore[attr-defined]
getattr(follower_class, entity_type) == entity_id, # type: ignore[attr-defined]
@@ -194,105 +227,75 @@ async def unfollow(_: None, info: GraphQLResolveInfo, what: str, slug: str = "",
)
.first()
)
if not sub:
logger.warning(f"Подписка не найдена для {what.lower()} с ID {entity_id}")
return {"error": "Not following"}
logger.debug(f"Найдена подписка для удаления: {sub}")
if sub:
session.delete(sub)
session.commit()
logger.info(f"Пользователь {follower_id} отписался от {what.lower()} с ID {entity_id}")
session.delete(sub)
session.commit()
logger.info(f"Пользователь {follower_id} отписался от {what.lower()} с ID {entity_id}")
# Инвалидируем кэш подписок пользователя после успешной отписки
cache_key_pattern = f"author:follows-{entity_type}s:{follower_id}"
await redis.execute("DEL", cache_key_pattern)
logger.debug(f"Инвалидирован кэш подписок: {cache_key_pattern}")
# Инвалидируем кэш подписок пользователя
cache_key_pattern = f"author:follows-{entity_type}s:{follower_id}"
await redis.execute("DEL", cache_key_pattern)
logger.debug(f"Инвалидирован кэш подписок: {cache_key_pattern}")
if cache_method:
logger.debug("Обновление кэша после отписки")
# Если это автор, кэшируем полную версию
if what == "AUTHOR":
await cache_method(entity.dict(True))
else:
await cache_method(entity.dict())
if what == "AUTHOR":
logger.debug("Отправка уведомления автору об отписке")
if isinstance(follower_dict, dict) and isinstance(entity_id, int):
await notify_follower(follower=follower_dict, author_id=entity_id, action="unfollow")
if get_cached_follows_method and isinstance(follower_id, int):
logger.debug("Получение актуального списка подписок из кэша")
follows = await get_cached_follows_method(follower_id)
logger.debug(f"Актуальный список подписок получен: {len(follows)} элементов")
else:
# Подписка не найдена, но это не критическая ошибка
logger.warning(f"Подписка не найдена: follower_id={follower_id}, {entity_type}_id={entity_id}")
error = "following was not found"
follows = []
# Всегда получаем актуальный список подписок для возврата клиенту
if get_cached_follows_method and isinstance(follower_id, int):
logger.debug("Получение актуального списка подписок из кэша")
existing_follows = await get_cached_follows_method(follower_id)
if what == "AUTHOR" and isinstance(follower_dict, dict):
await notify_follower(follower=follower_dict, author_id=entity_id, action="unfollow")
# Если это авторы, получаем безопасную версию
if what == "AUTHOR":
follows_filtered = []
for author_data in existing_follows:
# Создаем объект автора для использования метода dict
temp_author = Author()
for key, value in author_data.items():
if hasattr(temp_author, key):
setattr(temp_author, key, value)
# Добавляем отфильтрованную версию
follows_filtered.append(temp_author.dict(False))
follows = follows_filtered
else:
follows = existing_follows
logger.debug(f"Актуальный список подписок получен: {len(follows)} элементов")
return {f"{entity_type}s": follows, "error": None}
except Exception as exc:
logger.exception("Произошла ошибка в функции 'unfollow'")
import traceback
traceback.print_exc()
return {"error": str(exc)}
logger.debug(f"Функция 'unfollow' завершена: {entity_type}s={len(follows)}, error={error}")
return {f"{entity_type}s": follows, "error": error}
@query.field("get_shout_followers")
def get_shout_followers(_: None, _info: GraphQLResolveInfo, slug: str = "", shout_id: int | None = None) -> list[dict]:
logger.debug("Начало выполнения функции 'get_shout_followers'")
followers = []
try:
with local_session() as session:
shout = None
if slug:
shout = session.query(Shout).filter(Shout.slug == slug).first()
logger.debug(f"Найден shout по slug: {slug} -> {shout}")
elif shout_id:
shout = session.query(Shout).filter(Shout.id == shout_id).first()
logger.debug(f"Найден shout по ID: {shout_id} -> {shout}")
def get_shout_followers(
_: None, _info: GraphQLResolveInfo, slug: str = "", shout_id: int | None = None
) -> list[dict[str, Any]]:
"""
Получает список подписчиков для шаута по slug или ID
if shout:
shout_id = int(shout.id) # Convert Column to int
logger.debug(f"shout_id для получения подписчиков: {shout_id}")
Args:
_: GraphQL root
_info: GraphQL context info
slug: Slug шаута (опционально)
shout_id: ID шаута (опционально)
# Получение подписчиков из таблицы ShoutReactionsFollower
shout_followers = (
session.query(Author)
.join(ShoutReactionsFollower, Author.id == ShoutReactionsFollower.follower)
.filter(ShoutReactionsFollower.shout == shout_id)
.all()
)
# Convert Author objects to dicts
followers = [author.dict() for author in shout_followers]
logger.debug(f"Найдено {len(followers)} подписчиков для shout {shout_id}")
except Exception as _exc:
import traceback
traceback.print_exc()
logger.exception("Произошла ошибка в функции 'get_shout_followers'")
Returns:
Список подписчиков шаута
"""
if not slug and not shout_id:
return []
# logger.debug(f"Функция 'get_shout_followers' завершена с {len(followers)} подписчиками")
return followers
with local_session() as session:
# Если slug не указан, ищем шаут по ID
if not slug and shout_id is not None:
shout = session.query(Shout).where(Shout.id == shout_id).first()
else:
# Ищем шаут по slug
shout = session.query(Shout).where(Shout.slug == slug).first()
if not shout:
return []
# Получаем подписчиков шаута
followers_query = (
session.query(Author)
.join(ShoutReactionsFollower, Author.id == ShoutReactionsFollower.follower)
.where(ShoutReactionsFollower.shout == shout.id)
)
followers = followers_query.all()
# Возвращаем безопасную версию данных
return [follower.dict() for follower in followers]

View File

@@ -32,13 +32,13 @@ def query_notifications(author_id: int, after: int = 0) -> tuple[int, int, list[
),
)
if after:
q = q.filter(Notification.created_at > after)
q = q.where(Notification.created_at > after)
q = q.group_by(NotificationSeen.notification, Notification.created_at)
with local_session() as session:
total = (
session.query(Notification)
.filter(
.where(
and_(
Notification.action == NotificationAction.CREATE.value,
Notification.created_at > after,
@@ -49,7 +49,7 @@ def query_notifications(author_id: int, after: int = 0) -> tuple[int, int, list[
unread = (
session.query(Notification)
.filter(
.where(
and_(
Notification.action == NotificationAction.CREATE.value,
Notification.created_at > after,
@@ -131,8 +131,8 @@ def get_notifications_grouped(author_id: int, after: int = 0, limit: int = 10, o
author_id = shout.get("created_by")
thread_id = f"shout-{shout_id}"
with local_session() as session:
author = session.query(Author).filter(Author.id == author_id).first()
shout = session.query(Shout).filter(Shout.id == shout_id).first()
author = session.query(Author).where(Author.id == author_id).first()
shout = session.query(Shout).where(Shout.id == shout_id).first()
if author and shout:
author_dict = author.dict()
shout_dict = shout.dict()
@@ -155,8 +155,8 @@ def get_notifications_grouped(author_id: int, after: int = 0, limit: int = 10, o
author_id = reaction.get("created_by", 0)
if shout_id and author_id:
with local_session() as session:
author = session.query(Author).filter(Author.id == author_id).first()
shout = session.query(Shout).filter(Shout.id == shout_id).first()
author = session.query(Author).where(Author.id == author_id).first()
shout = session.query(Shout).where(Shout.id == shout_id).first()
if shout and author:
author_dict = author.dict()
shout_dict = shout.dict()
@@ -260,7 +260,7 @@ async def notifications_seen_after(_: None, info: GraphQLResolveInfo, after: int
author_id = info.context.get("author", {}).get("id")
if author_id:
with local_session() as session:
nnn = session.query(Notification).filter(and_(Notification.created_at > after)).all()
nnn = session.query(Notification).where(and_(Notification.created_at > after)).all()
for notification in nnn:
ns = NotificationSeen(notification=notification.id, author=author_id)
session.add(ns)
@@ -282,7 +282,7 @@ async def notifications_seen_thread(_: None, info: GraphQLResolveInfo, thread: s
# TODO: handle new follower and new shout notifications
new_reaction_notifications = (
session.query(Notification)
.filter(
.where(
Notification.action == "create",
Notification.entity == "reaction",
Notification.created_at > after,
@@ -291,7 +291,7 @@ async def notifications_seen_thread(_: None, info: GraphQLResolveInfo, thread: s
)
removed_reaction_notifications = (
session.query(Notification)
.filter(
.where(
Notification.action == "delete",
Notification.entity == "reaction",
Notification.created_at > after,

View File

@@ -11,14 +11,14 @@ def handle_proposing(kind: ReactionKind, reply_to: int, shout_id: int) -> None:
with local_session() as session:
if is_positive(kind):
replied_reaction = (
session.query(Reaction).filter(Reaction.id == reply_to, Reaction.shout == shout_id).first()
session.query(Reaction).where(Reaction.id == reply_to, Reaction.shout == shout_id).first()
)
if replied_reaction and replied_reaction.kind is ReactionKind.PROPOSE.value and replied_reaction.quote:
# patch all the proposals' quotes
proposals = (
session.query(Reaction)
.filter(
.where(
and_(
Reaction.shout == shout_id,
Reaction.kind == ReactionKind.PROPOSE.value,
@@ -28,7 +28,7 @@ def handle_proposing(kind: ReactionKind, reply_to: int, shout_id: int) -> None:
)
# patch shout's body
shout = session.query(Shout).filter(Shout.id == shout_id).first()
shout = session.query(Shout).where(Shout.id == shout_id).first()
if shout:
body = replied_reaction.quote
# Use setattr instead of Shout.update for Column assignment

View File

@@ -103,11 +103,11 @@ async def rate_author(_: None, info: GraphQLResolveInfo, rated_slug: str, value:
rater_id = info.context.get("author", {}).get("id")
with local_session() as session:
rater_id = int(rater_id)
rated_author = session.query(Author).filter(Author.slug == rated_slug).first()
rated_author = session.query(Author).where(Author.slug == rated_slug).first()
if rater_id and rated_author:
rating = (
session.query(AuthorRating)
.filter(
.where(
and_(
AuthorRating.rater == rater_id,
AuthorRating.author == rated_author.id,
@@ -140,7 +140,7 @@ def count_author_comments_rating(session: Session, author_id: int) -> int:
replied_alias.kind == ReactionKind.COMMENT.value,
)
)
.filter(replied_alias.kind == ReactionKind.LIKE.value)
.where(replied_alias.kind == ReactionKind.LIKE.value)
.count()
) or 0
replies_dislikes = (
@@ -152,7 +152,7 @@ def count_author_comments_rating(session: Session, author_id: int) -> int:
replied_alias.kind == ReactionKind.COMMENT.value,
)
)
.filter(replied_alias.kind == ReactionKind.DISLIKE.value)
.where(replied_alias.kind == ReactionKind.DISLIKE.value)
.count()
) or 0
@@ -170,7 +170,7 @@ def count_author_replies_rating(session: Session, author_id: int) -> int:
replied_alias.kind == ReactionKind.COMMENT.value,
)
)
.filter(replied_alias.kind == ReactionKind.LIKE.value)
.where(replied_alias.kind == ReactionKind.LIKE.value)
.count()
) or 0
replies_dislikes = (
@@ -182,7 +182,7 @@ def count_author_replies_rating(session: Session, author_id: int) -> int:
replied_alias.kind == ReactionKind.COMMENT.value,
)
)
.filter(replied_alias.kind == ReactionKind.DISLIKE.value)
.where(replied_alias.kind == ReactionKind.DISLIKE.value)
.count()
) or 0
@@ -193,7 +193,7 @@ def count_author_shouts_rating(session: Session, author_id: int) -> int:
shouts_likes = (
session.query(Reaction, Shout)
.join(Shout, Shout.id == Reaction.shout)
.filter(
.where(
and_(
Shout.authors.any(id=author_id),
Reaction.kind == ReactionKind.LIKE.value,
@@ -205,7 +205,7 @@ def count_author_shouts_rating(session: Session, author_id: int) -> int:
shouts_dislikes = (
session.query(Reaction, Shout)
.join(Shout, Shout.id == Reaction.shout)
.filter(
.where(
and_(
Shout.authors.any(id=author_id),
Reaction.kind == ReactionKind.DISLIKE.value,
@@ -219,10 +219,10 @@ def count_author_shouts_rating(session: Session, author_id: int) -> int:
def get_author_rating_old(session: Session, author: Author) -> dict[str, int]:
likes_count = (
session.query(AuthorRating).filter(and_(AuthorRating.author == author.id, AuthorRating.plus.is_(True))).count()
session.query(AuthorRating).where(and_(AuthorRating.author == author.id, AuthorRating.plus.is_(True))).count()
)
dislikes_count = (
session.query(AuthorRating).filter(and_(AuthorRating.author == author.id, AuthorRating.plus.is_(False))).count()
session.query(AuthorRating).where(and_(AuthorRating.author == author.id, AuthorRating.plus.is_(False))).count()
)
rating = likes_count - dislikes_count
return {"rating": rating, "likes": likes_count, "dislikes": dislikes_count}
@@ -232,14 +232,18 @@ def get_author_rating_shouts(session: Session, author: Author) -> int:
q = (
select(
Reaction.shout,
Reaction.plus,
case(
(Reaction.kind == ReactionKind.LIKE.value, 1),
(Reaction.kind == ReactionKind.DISLIKE.value, -1),
else_=0,
).label("rating_value"),
)
.select_from(Reaction)
.join(ShoutAuthor, Reaction.shout == ShoutAuthor.shout)
.where(
and_(
ShoutAuthor.author == author.id,
Reaction.kind == "RATING",
Reaction.kind.in_([ReactionKind.LIKE.value, ReactionKind.DISLIKE.value]),
Reaction.deleted_at.is_(None),
)
)
@@ -248,7 +252,7 @@ def get_author_rating_shouts(session: Session, author: Author) -> int:
results = session.execute(q)
rating = 0
for row in results:
rating += 1 if row[1] else -1
rating += row[1]
return rating
@@ -258,7 +262,11 @@ def get_author_rating_comments(session: Session, author: Author) -> int:
q = (
select(
Reaction.id,
Reaction.plus,
case(
(Reaction.kind == ReactionKind.LIKE.value, 1),
(Reaction.kind == ReactionKind.DISLIKE.value, -1),
else_=0,
).label("rating_value"),
)
.select_from(Reaction)
.outerjoin(replied_comment, Reaction.reply_to == replied_comment.id)
@@ -267,7 +275,7 @@ def get_author_rating_comments(session: Session, author: Author) -> int:
.where(
and_(
ShoutAuthor.author == author.id,
Reaction.kind == "RATING",
Reaction.kind.in_([ReactionKind.LIKE.value, ReactionKind.DISLIKE.value]),
Reaction.created_by != author.id,
Reaction.deleted_at.is_(None),
)
@@ -277,7 +285,7 @@ def get_author_rating_comments(session: Session, author: Author) -> int:
results = session.execute(q)
rating = 0
for row in results:
rating += 1 if row[1] else -1
rating += row[1]
return rating

View File

@@ -1,14 +1,20 @@
import contextlib
import time
import traceback
from typing import Any
from graphql import GraphQLResolveInfo
from sqlalchemy import and_, asc, case, desc, func, select
from sqlalchemy import Select, and_, asc, case, desc, func, select
from sqlalchemy.orm import Session, aliased
from sqlalchemy.sql import ColumnElement
from auth.orm import Author
from orm.rating import PROPOSAL_REACTIONS, RATING_REACTIONS, is_negative, is_positive
from orm.rating import (
NEGATIVE_REACTIONS,
POSITIVE_REACTIONS,
PROPOSAL_REACTIONS,
RATING_REACTIONS,
is_positive,
)
from orm.reaction import Reaction, ReactionKind
from orm.shout import Shout, ShoutAuthor
from resolvers.follower import follow
@@ -21,7 +27,7 @@ from services.schema import mutation, query
from utils.logger import root_logger as logger
def query_reactions() -> select:
def query_reactions() -> Select:
"""
Base query for fetching reactions with associated authors and shouts.
@@ -39,7 +45,7 @@ def query_reactions() -> select:
)
def add_reaction_stat_columns(q: select) -> select:
def add_reaction_stat_columns(q: Select) -> Select:
"""
Add statistical columns to a reaction query.
@@ -57,7 +63,7 @@ def add_reaction_stat_columns(q: select) -> select:
).add_columns(
# Count unique comments
func.coalesce(
func.count(aliased_reaction.id).filter(aliased_reaction.kind == ReactionKind.COMMENT.value), 0
func.count(aliased_reaction.id).where(aliased_reaction.kind == ReactionKind.COMMENT.value), 0
).label("comments_stat"),
# Calculate rating as the difference between likes and dislikes
func.sum(
@@ -70,7 +76,7 @@ def add_reaction_stat_columns(q: select) -> select:
)
def get_reactions_with_stat(q: select, limit: int = 10, offset: int = 0) -> list[dict]:
def get_reactions_with_stat(q: Select, limit: int = 10, offset: int = 0) -> list[dict]:
"""
Execute the reaction query and retrieve reactions with statistics.
@@ -85,7 +91,7 @@ def get_reactions_with_stat(q: select, limit: int = 10, offset: int = 0) -> list
# Убираем distinct() поскольку GROUP BY уже обеспечивает уникальность,
# а distinct() вызывает ошибку PostgreSQL с JSON полями
q = q.limit(limit).offset(offset)
reactions = []
reactions: list[dict] = []
with local_session() as session:
result_rows = session.execute(q).unique()
@@ -116,7 +122,7 @@ def is_featured_author(session: Session, author_id: int) -> bool:
return session.query(
session.query(Shout)
.where(Shout.authors.any(id=author_id))
.filter(Shout.featured_at.is_not(None), Shout.deleted_at.is_(None))
.where(Shout.featured_at.is_not(None), Shout.deleted_at.is_(None))
.exists()
).scalar()
@@ -130,7 +136,8 @@ def check_to_feature(session: Session, approver_id: int, reaction: dict) -> bool
:param reaction: Reaction object.
:return: True if shout should be featured, else False.
"""
if not reaction.get("reply_to") and is_positive(reaction.get("kind")):
is_positive_kind = reaction.get("kind") == ReactionKind.LIKE.value
if not reaction.get("reply_to") and is_positive_kind:
# Проверяем, не содержит ли пост более 20% дизлайков
# Если да, то не должен быть featured независимо от количества лайков
if check_to_unfeature(session, reaction):
@@ -140,9 +147,9 @@ def check_to_feature(session: Session, approver_id: int, reaction: dict) -> bool
author_approvers = set()
reacted_readers = (
session.query(Reaction.created_by)
.filter(
.where(
Reaction.shout == reaction.get("shout"),
is_positive(Reaction.kind),
Reaction.kind.in_(POSITIVE_REACTIONS),
# Рейтинги (LIKE, DISLIKE) физически удаляются, поэтому фильтр deleted_at не нужен
)
.distinct()
@@ -150,7 +157,7 @@ def check_to_feature(session: Session, approver_id: int, reaction: dict) -> bool
)
# Добавляем текущего одобряющего
approver = session.query(Author).filter(Author.id == approver_id).first()
approver = session.query(Author).where(Author.id == approver_id).first()
if approver and is_featured_author(session, approver_id):
author_approvers.add(approver_id)
@@ -181,7 +188,7 @@ def check_to_unfeature(session: Session, reaction: dict) -> bool:
# Проверяем соотношение дизлайков, даже если текущая реакция не дизлайк
total_reactions = (
session.query(Reaction)
.filter(
.where(
Reaction.shout == shout_id,
Reaction.reply_to.is_(None),
Reaction.kind.in_(RATING_REACTIONS),
@@ -192,9 +199,9 @@ def check_to_unfeature(session: Session, reaction: dict) -> bool:
positive_reactions = (
session.query(Reaction)
.filter(
.where(
Reaction.shout == shout_id,
is_positive(Reaction.kind),
Reaction.kind.in_(POSITIVE_REACTIONS),
Reaction.reply_to.is_(None),
# Рейтинги физически удаляются при удалении, поэтому фильтр deleted_at не нужен
)
@@ -203,9 +210,9 @@ def check_to_unfeature(session: Session, reaction: dict) -> bool:
negative_reactions = (
session.query(Reaction)
.filter(
.where(
Reaction.shout == shout_id,
is_negative(Reaction.kind),
Reaction.kind.in_(NEGATIVE_REACTIONS),
Reaction.reply_to.is_(None),
# Рейтинги физически удаляются при удалении, поэтому фильтр deleted_at не нужен
)
@@ -235,13 +242,13 @@ async def set_featured(session: Session, shout_id: int) -> None:
:param session: Database session.
:param shout_id: Shout ID.
"""
s = session.query(Shout).filter(Shout.id == shout_id).first()
s = session.query(Shout).where(Shout.id == shout_id).first()
if s:
current_time = int(time.time())
# Use setattr to avoid MyPy complaints about Column assignment
s.featured_at = current_time # type: ignore[assignment]
session.commit()
author = session.query(Author).filter(Author.id == s.created_by).first()
author = session.query(Author).where(Author.id == s.created_by).first()
if author:
await add_user_role(str(author.id))
session.add(s)
@@ -255,7 +262,7 @@ def set_unfeatured(session: Session, shout_id: int) -> None:
:param session: Database session.
:param shout_id: Shout ID.
"""
session.query(Shout).filter(Shout.id == shout_id).update({"featured_at": None})
session.query(Shout).where(Shout.id == shout_id).update({"featured_at": None})
session.commit()
@@ -288,7 +295,7 @@ async def _create_reaction(session: Session, shout_id: int, is_author: bool, aut
# Handle rating
if r.kind in RATING_REACTIONS:
# Проверяем, является ли публикация featured
shout = session.query(Shout).filter(Shout.id == shout_id).first()
shout = session.query(Shout).where(Shout.id == shout_id).first()
is_currently_featured = shout and shout.featured_at is not None
# Проверяем сначала условие для unfeature (для уже featured публикаций)
@@ -317,26 +324,27 @@ def prepare_new_rating(reaction: dict, shout_id: int, session: Session, author_i
:return: Dictionary with error or None.
"""
kind = reaction.get("kind")
opposite_kind = ReactionKind.DISLIKE.value if is_positive(kind) else ReactionKind.LIKE.value
if kind in RATING_REACTIONS:
opposite_kind = ReactionKind.DISLIKE.value if is_positive(kind) else ReactionKind.LIKE.value
existing_ratings = (
session.query(Reaction)
.filter(
Reaction.shout == shout_id,
Reaction.created_by == author_id,
Reaction.kind.in_(RATING_REACTIONS),
Reaction.deleted_at.is_(None),
existing_ratings = (
session.query(Reaction)
.where(
Reaction.shout == shout_id,
Reaction.created_by == author_id,
Reaction.kind.in_(RATING_REACTIONS),
Reaction.deleted_at.is_(None),
)
.all()
)
.all()
)
for r in existing_ratings:
if r.kind == kind:
return {"error": "You can't rate the same thing twice"}
if r.kind == opposite_kind:
return {"error": "Remove opposite vote first"}
if shout_id in [r.shout for r in existing_ratings]:
return {"error": "You can't rate your own thing"}
for r in existing_ratings:
if r.kind == kind:
return {"error": "You can't rate the same thing twice"}
if r.kind == opposite_kind:
return {"error": "Remove opposite vote first"}
if shout_id in [r.shout for r in existing_ratings]:
return {"error": "You can't rate your own thing"}
return None
@@ -366,7 +374,7 @@ async def create_reaction(_: None, info: GraphQLResolveInfo, reaction: dict) ->
try:
with local_session() as session:
authors = session.query(ShoutAuthor.author).filter(ShoutAuthor.shout == shout_id).scalar()
authors = session.query(ShoutAuthor.author).where(ShoutAuthor.shout == shout_id).scalar()
is_author = (
bool(list(filter(lambda x: x == int(author_id), authors))) if isinstance(authors, list) else False
)
@@ -387,17 +395,14 @@ async def create_reaction(_: None, info: GraphQLResolveInfo, reaction: dict) ->
# follow if liked
if kind == ReactionKind.LIKE.value:
with contextlib.suppress(Exception):
follow(None, info, "shout", shout_id=shout_id)
shout = session.query(Shout).filter(Shout.id == shout_id).first()
follow(None, info, "shout", shout_id=shout_id)
shout = session.query(Shout).where(Shout.id == shout_id).first()
if not shout:
return {"error": "Shout not found"}
rdict["shout"] = shout.dict()
rdict["created_by"] = author_dict
return {"reaction": rdict}
except Exception as e:
import traceback
traceback.print_exc()
logger.error(f"{type(e).__name__}: {e}")
return {"error": "Cannot create reaction."}
@@ -424,7 +429,7 @@ async def update_reaction(_: None, info: GraphQLResolveInfo, reaction: dict) ->
with local_session() as session:
try:
reaction_query = query_reactions().filter(Reaction.id == rid)
reaction_query = query_reactions().where(Reaction.id == rid)
reaction_query = add_reaction_stat_columns(reaction_query)
reaction_query = reaction_query.group_by(Reaction.id, Author.id, Shout.id)
@@ -472,12 +477,12 @@ async def delete_reaction(_: None, info: GraphQLResolveInfo, reaction_id: int) -
roles = info.context.get("roles", [])
if not author_id:
return {"error": "Unauthorized"}
return {"error": "UnauthorizedError"}
with local_session() as session:
try:
author = session.query(Author).filter(Author.id == author_id).one()
r = session.query(Reaction).filter(Reaction.id == reaction_id).one()
author = session.query(Author).where(Author.id == author_id).one()
r = session.query(Reaction).where(Reaction.id == reaction_id).one()
if r.created_by != author_id and "editor" not in roles:
return {"error": "Access denied"}
@@ -496,7 +501,7 @@ async def delete_reaction(_: None, info: GraphQLResolveInfo, reaction_id: int) -
logger.debug(f"{author_id} user removing his #{reaction_id} reaction")
reaction_dict = r.dict()
# Проверяем, является ли публикация featured до удаления реакции
shout = session.query(Shout).filter(Shout.id == r.shout).first()
shout = session.query(Shout).where(Shout.id == r.shout).first()
is_currently_featured = shout and shout.featured_at is not None
session.delete(r)
@@ -506,16 +511,15 @@ async def delete_reaction(_: None, info: GraphQLResolveInfo, reaction_id: int) -
if is_currently_featured and check_to_unfeature(session, reaction_dict):
set_unfeatured(session, r.shout)
reaction_dict = r.dict()
await notify_reaction(reaction_dict, "delete")
await notify_reaction(r, "delete")
return {"error": None, "reaction": reaction_dict}
return {"error": None, "reaction": r.dict()}
except Exception as e:
logger.error(f"{type(e).__name__}: {e}")
return {"error": "Cannot delete reaction"}
def apply_reaction_filters(by: dict, q: select) -> select:
def apply_reaction_filters(by: dict, q: Select) -> Select:
"""
Apply filters to a reaction query.
@@ -525,42 +529,42 @@ def apply_reaction_filters(by: dict, q: select) -> select:
"""
shout_slug = by.get("shout")
if shout_slug:
q = q.filter(Shout.slug == shout_slug)
q = q.where(Shout.slug == shout_slug)
shout_id = by.get("shout_id")
if shout_id:
q = q.filter(Shout.id == shout_id)
q = q.where(Shout.id == shout_id)
shouts = by.get("shouts")
if shouts:
q = q.filter(Shout.slug.in_(shouts))
q = q.where(Shout.slug.in_(shouts))
created_by = by.get("created_by", by.get("author_id"))
if created_by:
q = q.filter(Author.id == created_by)
q = q.where(Author.id == created_by)
author_slug = by.get("author")
if author_slug:
q = q.filter(Author.slug == author_slug)
q = q.where(Author.slug == author_slug)
topic = by.get("topic")
if isinstance(topic, int):
q = q.filter(Shout.topics.any(id=topic))
q = q.where(Shout.topics.any(id=topic))
kinds = by.get("kinds")
if isinstance(kinds, list):
q = q.filter(Reaction.kind.in_(kinds))
q = q.where(Reaction.kind.in_(kinds))
if by.get("reply_to"):
q = q.filter(Reaction.reply_to == by.get("reply_to"))
q = q.where(Reaction.reply_to == by.get("reply_to"))
by_search = by.get("search", "")
if len(by_search) > 2:
q = q.filter(Reaction.body.ilike(f"%{by_search}%"))
q = q.where(Reaction.body.ilike(f"%{by_search}%"))
after = by.get("after")
if isinstance(after, int):
q = q.filter(Reaction.created_at > after)
q = q.where(Reaction.created_at > after)
return q
@@ -617,7 +621,7 @@ async def load_shout_ratings(
q = query_reactions()
# Filter, group, sort, limit, offset
q = q.filter(
q = q.where(
and_(
Reaction.deleted_at.is_(None),
Reaction.shout == shout,
@@ -649,7 +653,7 @@ async def load_shout_comments(
q = add_reaction_stat_columns(q)
# Filter, group, sort, limit, offset
q = q.filter(
q = q.where(
and_(
Reaction.deleted_at.is_(None),
Reaction.shout == shout,
@@ -679,7 +683,7 @@ async def load_comment_ratings(
q = query_reactions()
# Filter, group, sort, limit, offset
q = q.filter(
q = q.where(
and_(
Reaction.deleted_at.is_(None),
Reaction.reply_to == comment,
@@ -723,7 +727,7 @@ async def load_comments_branch(
q = add_reaction_stat_columns(q)
# Фильтруем по статье и типу (комментарии)
q = q.filter(
q = q.where(
and_(
Reaction.deleted_at.is_(None),
Reaction.shout == shout,
@@ -732,7 +736,7 @@ async def load_comments_branch(
)
# Фильтруем по родительскому ID
q = q.filter(Reaction.reply_to.is_(None)) if parent_id is None else q.filter(Reaction.reply_to == parent_id)
q = q.where(Reaction.reply_to.is_(None)) if parent_id is None else q.where(Reaction.reply_to == parent_id)
# Сортировка и группировка
q = q.group_by(Reaction.id, Author.id, Shout.id)
@@ -822,7 +826,7 @@ async def load_first_replies(comments: list[Any], limit: int, offset: int, sort:
q = add_reaction_stat_columns(q)
# Фильтрация: только ответы на указанные комментарии
q = q.filter(
q = q.where(
and_(
Reaction.reply_to.in_(comment_ids),
Reaction.deleted_at.is_(None),

View File

@@ -2,8 +2,8 @@ from typing import Any, Optional
import orjson
from graphql import GraphQLResolveInfo
from sqlalchemy import and_, nulls_last, text
from sqlalchemy.orm import aliased
from sqlalchemy import Select, and_, nulls_last, text
from sqlalchemy.orm import Session, aliased
from sqlalchemy.sql.expression import asc, case, desc, func, select
from auth.orm import Author
@@ -12,12 +12,12 @@ from orm.shout import Shout, ShoutAuthor, ShoutTopic
from orm.topic import Topic
from services.db import json_array_builder, json_builder, local_session
from services.schema import query
from services.search import search_text
from services.search import SearchService, search_text
from services.viewed import ViewedStorage
from utils.logger import root_logger as logger
def apply_options(q: select, options: dict[str, Any], reactions_created_by: int = 0) -> tuple[select, int, int]:
def apply_options(q: Select, options: dict[str, Any], reactions_created_by: int = 0) -> tuple[Select, int, int]:
"""
Применяет опции фильтрации и сортировки
[опционально] выбирая те публикации, на которые есть реакции/комментарии от указанного автора
@@ -32,9 +32,9 @@ def apply_options(q: select, options: dict[str, Any], reactions_created_by: int
q = apply_filters(q, filters)
if reactions_created_by:
q = q.join(Reaction, Reaction.shout == Shout.id)
q = q.filter(Reaction.created_by == reactions_created_by)
q = q.where(Reaction.created_by == reactions_created_by)
if "commented" in filters:
q = q.filter(Reaction.body.is_not(None))
q = q.where(Reaction.body.is_not(None))
q = apply_sorting(q, options)
limit = options.get("limit", 10)
offset = options.get("offset", 0)
@@ -58,14 +58,14 @@ def has_field(info: GraphQLResolveInfo, fieldname: str) -> bool:
return False
def query_with_stat(info: GraphQLResolveInfo) -> select:
def query_with_stat(info: GraphQLResolveInfo) -> Select:
"""
:param info: Информация о контексте GraphQL - для получения id авторизованного пользователя
:return: Запрос с подзапросами статистики.
Добавляет подзапрос статистики
"""
q = select(Shout).filter(
q = select(Shout).where(
and_(
Shout.published_at.is_not(None), # type: ignore[union-attr]
Shout.deleted_at.is_(None), # type: ignore[union-attr]
@@ -158,7 +158,7 @@ def query_with_stat(info: GraphQLResolveInfo) -> select:
select(
Reaction.shout,
func.count(func.distinct(Reaction.id))
.filter(Reaction.kind == ReactionKind.COMMENT.value)
.where(Reaction.kind == ReactionKind.COMMENT.value)
.label("comments_count"),
func.sum(
case(
@@ -167,10 +167,10 @@ def query_with_stat(info: GraphQLResolveInfo) -> select:
else_=0,
)
)
.filter(Reaction.reply_to.is_(None))
.where(Reaction.reply_to.is_(None))
.label("rating"),
func.max(Reaction.created_at)
.filter(Reaction.kind == ReactionKind.COMMENT.value)
.where(Reaction.kind == ReactionKind.COMMENT.value)
.label("last_commented_at"),
)
.where(Reaction.deleted_at.is_(None))
@@ -192,7 +192,7 @@ def query_with_stat(info: GraphQLResolveInfo) -> select:
return q
def get_shouts_with_links(info: GraphQLResolveInfo, q: select, limit: int = 20, offset: int = 0) -> list[Shout]:
def get_shouts_with_links(info: GraphQLResolveInfo, q: Select, limit: int = 20, offset: int = 0) -> list[Shout]:
"""
получение публикаций с применением пагинации
"""
@@ -222,7 +222,7 @@ def get_shouts_with_links(info: GraphQLResolveInfo, q: select, limit: int = 20,
# Обработка поля created_by
if has_field(info, "created_by") and shout_dict.get("created_by"):
main_author_id = shout_dict.get("created_by")
a = session.query(Author).filter(Author.id == main_author_id).first()
a = session.query(Author).where(Author.id == main_author_id).first()
if a:
shout_dict["created_by"] = {
"id": main_author_id,
@@ -235,7 +235,7 @@ def get_shouts_with_links(info: GraphQLResolveInfo, q: select, limit: int = 20,
if has_field(info, "updated_by"):
if shout_dict.get("updated_by"):
updated_by_id = shout_dict.get("updated_by")
updated_author = session.query(Author).filter(Author.id == updated_by_id).first()
updated_author = session.query(Author).where(Author.id == updated_by_id).first()
if updated_author:
shout_dict["updated_by"] = {
"id": updated_author.id,
@@ -254,7 +254,7 @@ def get_shouts_with_links(info: GraphQLResolveInfo, q: select, limit: int = 20,
if has_field(info, "deleted_by"):
if shout_dict.get("deleted_by"):
deleted_by_id = shout_dict.get("deleted_by")
deleted_author = session.query(Author).filter(Author.id == deleted_by_id).first()
deleted_author = session.query(Author).where(Author.id == deleted_by_id).first()
if deleted_author:
shout_dict["deleted_by"] = {
"id": deleted_author.id,
@@ -347,7 +347,7 @@ def get_shouts_with_links(info: GraphQLResolveInfo, q: select, limit: int = 20,
return shouts
def apply_filters(q: select, filters: dict[str, Any]) -> select:
def apply_filters(q: Select, filters: dict[str, Any]) -> Select:
"""
Применение общих фильтров к запросу.
@@ -360,23 +360,23 @@ def apply_filters(q: select, filters: dict[str, Any]) -> select:
featured_filter = filters.get("featured")
featured_at_col = getattr(Shout, "featured_at", None)
if featured_at_col is not None:
q = q.filter(featured_at_col.is_not(None)) if featured_filter else q.filter(featured_at_col.is_(None))
q = q.where(featured_at_col.is_not(None)) if featured_filter else q.where(featured_at_col.is_(None))
by_layouts = filters.get("layouts")
if by_layouts and isinstance(by_layouts, list):
q = q.filter(Shout.layout.in_(by_layouts))
q = q.where(Shout.layout.in_(by_layouts))
by_author = filters.get("author")
if by_author:
q = q.filter(Shout.authors.any(slug=by_author))
q = q.where(Shout.authors.any(slug=by_author))
by_topic = filters.get("topic")
if by_topic:
q = q.filter(Shout.topics.any(slug=by_topic))
q = q.where(Shout.topics.any(slug=by_topic))
by_after = filters.get("after")
if by_after:
ts = int(by_after)
q = q.filter(Shout.created_at > ts)
q = q.where(Shout.created_at > ts)
by_community = filters.get("community")
if by_community:
q = q.filter(Shout.community == by_community)
q = q.where(Shout.community == by_community)
return q
@@ -417,7 +417,7 @@ async def get_shout(_: None, info: GraphQLResolveInfo, slug: str = "", shout_id:
return None
def apply_sorting(q: select, options: dict[str, Any]) -> select:
def apply_sorting(q: Select, options: dict[str, Any]) -> Select:
"""
Применение сортировки с сохранением порядка
"""
@@ -455,7 +455,9 @@ async def load_shouts_by(_: None, info: GraphQLResolveInfo, options: dict[str, A
@query.field("load_shouts_search")
async def load_shouts_search(_: None, info: GraphQLResolveInfo, text: str, options: dict[str, Any]) -> list[Shout]:
async def load_shouts_search(
_: None, info: GraphQLResolveInfo, text: str, options: dict[str, Any]
) -> list[dict[str, Any]]:
"""
Поиск публикаций по тексту.
@@ -497,20 +499,22 @@ async def load_shouts_search(_: None, info: GraphQLResolveInfo, text: str, optio
q = (
query_with_stat(info)
if has_field(info, "stat")
else select(Shout).filter(and_(Shout.published_at.is_not(None), Shout.deleted_at.is_(None)))
else select(Shout).where(and_(Shout.published_at.is_not(None), Shout.deleted_at.is_(None)))
)
q = q.filter(Shout.id.in_(hits_ids))
q = q.where(Shout.id.in_(hits_ids))
q = apply_filters(q, options)
q = apply_sorting(q, options)
logger.debug(f"[load_shouts_search] Executing database query for {len(hits_ids)} shout IDs")
shouts_dicts = get_shouts_with_links(info, q, limit, offset)
logger.debug(f"[load_shouts_search] Database returned {len(shouts_dicts)} shouts")
for shout_dict in shouts_dicts:
shout_id_str = f"{shout_dict['id']}"
shout_dict["score"] = scores.get(shout_id_str, 0.0)
shouts = get_shouts_with_links(info, q, limit, offset)
logger.debug(f"[load_shouts_search] Database returned {len(shouts)} shouts")
shouts_dicts: list[dict[str, Any]] = []
for shout in shouts:
shout_dict = shout.dict()
shout_id_str = shout_dict.get("id")
if shout_id_str:
shout_dict["score"] = scores.get(shout_id_str, 0.0)
shouts_dicts.append(shout_dict)
shouts_dicts.sort(key=lambda x: x.get("score", 0.0), reverse=True)
@@ -540,7 +544,7 @@ async def load_shouts_unrated(_: None, info: GraphQLResolveInfo, options: dict[s
)
)
.group_by(Reaction.shout)
.having(func.count("*") >= 3)
.having(func.count(Reaction.id) >= 3)
.scalar_subquery()
)
@@ -594,7 +598,51 @@ async def load_shouts_random_top(_: None, info: GraphQLResolveInfo, options: dic
random_limit = options.get("random_limit", 100)
subquery = subquery.limit(random_limit)
q = query_with_stat(info)
q = q.filter(Shout.id.in_(subquery))
q = q.where(Shout.id.in_(subquery))
q = q.order_by(func.random())
limit = options.get("limit", 10)
return get_shouts_with_links(info, q, limit)
async def fetch_all_shouts(
session: Session,
search_service: SearchService,
limit: int = 100,
offset: int = 0,
search_query: str = "",
) -> list[Shout]:
"""
Получает все shout'ы с возможностью поиска и пагинации.
:param session: Сессия базы данных
:param search_service: Сервис поиска
:param limit: Максимальное количество возвращаемых shout'ов
:param offset: Смещение для пагинации
:param search_query: Строка поиска
:return: Список shout'ов
"""
try:
# Базовый запрос для получения shout'ов
q = select(Shout).where(and_(Shout.published_at.is_not(None), Shout.deleted_at.is_(None)))
# Применяем поиск, если есть строка поиска
if search_query:
search_results = await search_service.search(search_query, limit=100, offset=0)
if search_results:
# Извлекаем ID из результатов поиска
shout_ids = [result.get("id") for result in search_results if result.get("id")]
if shout_ids:
q = q.where(Shout.id.in_(shout_ids))
# Применяем лимит и смещение
q = q.limit(limit).offset(offset)
# Выполняем запрос
result = session.execute(q).scalars().all()
return list(result)
except Exception as e:
logger.error(f"Error fetching shouts: {e}")
return []
finally:
session.close()

View File

@@ -1,5 +1,6 @@
import asyncio
import sys
import traceback
from typing import Any, Optional
from sqlalchemy import and_, distinct, func, join, select
@@ -7,7 +8,6 @@ from sqlalchemy.orm import aliased
from sqlalchemy.sql.expression import Select
from auth.orm import Author, AuthorFollower
from cache.cache import cache_author
from orm.community import Community, CommunityFollower
from orm.reaction import Reaction, ReactionKind
from orm.shout import Shout, ShoutAuthor, ShoutTopic
@@ -99,7 +99,7 @@ def get_topic_shouts_stat(topic_id: int) -> int:
q = (
select(func.count(distinct(ShoutTopic.shout)))
.select_from(join(ShoutTopic, Shout, ShoutTopic.shout == Shout.id))
.filter(
.where(
and_(
ShoutTopic.topic == topic_id,
Shout.published_at.is_not(None),
@@ -124,7 +124,7 @@ def get_topic_authors_stat(topic_id: int) -> int:
select(func.count(distinct(ShoutAuthor.author)))
.select_from(join(ShoutTopic, Shout, ShoutTopic.shout == Shout.id))
.join(ShoutAuthor, ShoutAuthor.shout == Shout.id)
.filter(
.where(
and_(
ShoutTopic.topic == topic_id,
Shout.published_at.is_not(None),
@@ -147,7 +147,7 @@ def get_topic_followers_stat(topic_id: int) -> int:
:return: Количество уникальных подписчиков темы.
"""
aliased_followers = aliased(TopicFollower)
q = select(func.count(distinct(aliased_followers.follower))).filter(aliased_followers.topic == topic_id)
q = select(func.count(distinct(aliased_followers.follower))).where(aliased_followers.topic == topic_id)
with local_session() as session:
result = session.execute(q).scalar()
return int(result) if result else 0
@@ -180,7 +180,7 @@ def get_topic_comments_stat(topic_id: int) -> int:
.subquery()
)
# Запрос для суммирования количества комментариев по теме
q = select(func.coalesce(func.sum(sub_comments.c.comments_count), 0)).filter(ShoutTopic.topic == topic_id)
q = select(func.coalesce(func.sum(sub_comments.c.comments_count), 0)).where(ShoutTopic.topic == topic_id)
q = q.outerjoin(sub_comments, ShoutTopic.shout == sub_comments.c.shout_id)
with local_session() as session:
result = session.execute(q).scalar()
@@ -198,7 +198,7 @@ def get_author_shouts_stat(author_id: int) -> int:
select(func.count(distinct(aliased_shout.id)))
.select_from(aliased_shout)
.join(aliased_shout_author, aliased_shout.id == aliased_shout_author.shout)
.filter(
.where(
and_(
aliased_shout_author.author == author_id,
aliased_shout.published_at.is_not(None),
@@ -221,7 +221,7 @@ def get_author_authors_stat(author_id: int) -> int:
.select_from(ShoutAuthor)
.join(Shout, ShoutAuthor.shout == Shout.id)
.join(Reaction, Reaction.shout == Shout.id)
.filter(
.where(
and_(
Reaction.created_by == author_id,
Shout.published_at.is_not(None),
@@ -240,7 +240,7 @@ def get_author_followers_stat(author_id: int) -> int:
"""
Получает количество подписчиков для указанного автора
"""
q = select(func.count(AuthorFollower.follower)).filter(AuthorFollower.author == author_id)
q = select(func.count(AuthorFollower.follower)).where(AuthorFollower.author == author_id)
with local_session() as session:
result = session.execute(q).scalar()
@@ -320,8 +320,6 @@ def get_with_stat(q: QueryType) -> list[Any]:
entity.stat = stat
records.append(entity)
except Exception as exc:
import traceback
logger.debug(q)
traceback.print_exc()
logger.error(exc, exc_info=True)
@@ -363,6 +361,9 @@ def update_author_stat(author_id: int) -> None:
:param author_id: Идентификатор автора.
"""
# Поздний импорт для избежания циклических зависимостей
from cache.cache import cache_author
author_query = select(Author).where(Author.id == author_id)
try:
result = get_with_stat(author_query)
@@ -373,10 +374,10 @@ def update_author_stat(author_id: int) -> None:
# Асинхронное кэширование данных автора
task = asyncio.create_task(cache_author(author_dict))
# Store task reference to prevent garbage collection
if not hasattr(update_author_stat, "_background_tasks"):
update_author_stat._background_tasks = set() # type: ignore[attr-defined]
update_author_stat._background_tasks.add(task) # type: ignore[attr-defined]
task.add_done_callback(update_author_stat._background_tasks.discard) # type: ignore[attr-defined]
if not hasattr(update_author_stat, "stat_tasks"):
update_author_stat.stat_tasks = set() # type: ignore[attr-defined]
update_author_stat.stat_tasks.add(task) # type: ignore[attr-defined]
task.add_done_callback(update_author_stat.stat_tasks.discard) # type: ignore[attr-defined]
except Exception as exc:
logger.error(exc, exc_info=True)
@@ -387,19 +388,19 @@ def get_followers_count(entity_type: str, entity_id: int) -> int:
with local_session() as session:
if entity_type == "topic":
result = (
session.query(func.count(TopicFollower.follower)).filter(TopicFollower.topic == entity_id).scalar()
session.query(func.count(TopicFollower.follower)).where(TopicFollower.topic == entity_id).scalar()
)
elif entity_type == "author":
# Count followers of this author
result = (
session.query(func.count(AuthorFollower.follower))
.filter(AuthorFollower.author == entity_id)
.where(AuthorFollower.author == entity_id)
.scalar()
)
elif entity_type == "community":
result = (
session.query(func.count(CommunityFollower.follower))
.filter(CommunityFollower.community == entity_id)
.where(CommunityFollower.community == entity_id)
.scalar()
)
else:
@@ -418,12 +419,12 @@ def get_following_count(entity_type: str, entity_id: int) -> int:
if entity_type == "author":
# Count what this author follows
topic_follows = (
session.query(func.count(TopicFollower.topic)).filter(TopicFollower.follower == entity_id).scalar()
session.query(func.count(TopicFollower.topic)).where(TopicFollower.follower == entity_id).scalar()
or 0
)
community_follows = (
session.query(func.count(CommunityFollower.community))
.filter(CommunityFollower.follower == entity_id)
.where(CommunityFollower.follower == entity_id)
.scalar()
or 0
)
@@ -440,15 +441,15 @@ def get_shouts_count(
"""Получает количество публикаций"""
try:
with local_session() as session:
query = session.query(func.count(Shout.id)).filter(Shout.published_at.isnot(None))
query = session.query(func.count(Shout.id)).where(Shout.published_at.isnot(None))
if author_id:
query = query.filter(Shout.created_by == author_id)
query = query.where(Shout.created_by == author_id)
if topic_id:
# This would need ShoutTopic association table
pass
if community_id:
query = query.filter(Shout.community == community_id)
query = query.where(Shout.community == community_id)
result = query.scalar()
return int(result) if result else 0
@@ -465,12 +466,12 @@ def get_authors_count(community_id: Optional[int] = None) -> int:
# Count authors in specific community
result = (
session.query(func.count(distinct(CommunityFollower.follower)))
.filter(CommunityFollower.community == community_id)
.where(CommunityFollower.community == community_id)
.scalar()
)
else:
# Count all authors
result = session.query(func.count(Author.id)).filter(Author.deleted == False).scalar()
result = session.query(func.count(Author.id)).where(Author.deleted_at.is_(None)).scalar()
return int(result) if result else 0
except Exception as e:
@@ -485,7 +486,7 @@ def get_topics_count(author_id: Optional[int] = None) -> int:
if author_id:
# Count topics followed by author
result = (
session.query(func.count(TopicFollower.topic)).filter(TopicFollower.follower == author_id).scalar()
session.query(func.count(TopicFollower.topic)).where(TopicFollower.follower == author_id).scalar()
)
else:
# Count all topics
@@ -511,15 +512,13 @@ def get_communities_count() -> int:
def get_reactions_count(shout_id: Optional[int] = None, author_id: Optional[int] = None) -> int:
"""Получает количество реакций"""
try:
from orm.reaction import Reaction
with local_session() as session:
query = session.query(func.count(Reaction.id))
if shout_id:
query = query.filter(Reaction.shout == shout_id)
query = query.where(Reaction.shout == shout_id)
if author_id:
query = query.filter(Reaction.created_by == author_id)
query = query.where(Reaction.created_by == author_id)
result = query.scalar()
return int(result) if result else 0
@@ -531,13 +530,11 @@ def get_reactions_count(shout_id: Optional[int] = None, author_id: Optional[int]
def get_comments_count_by_shout(shout_id: int) -> int:
"""Получает количество комментариев к статье"""
try:
from orm.reaction import Reaction
with local_session() as session:
# Using text() to access 'kind' column which might be enum
result = (
session.query(func.count(Reaction.id))
.filter(
.where(
and_(
Reaction.shout == shout_id,
Reaction.kind == "comment", # Assuming 'comment' is a valid enum value
@@ -555,8 +552,8 @@ def get_comments_count_by_shout(shout_id: int) -> int:
async def get_stat_background_task() -> None:
"""Фоновая задача для обновления статистики"""
try:
if not hasattr(sys.modules[__name__], "_background_tasks"):
sys.modules[__name__]._background_tasks = set() # type: ignore[attr-defined]
if not hasattr(sys.modules[__name__], "stat_tasks"):
sys.modules[__name__].stat_tasks = set() # type: ignore[attr-defined]
# Perform background statistics calculations
logger.info("Running background statistics update")

View File

@@ -14,6 +14,7 @@ from cache.cache import (
invalidate_cache_by_prefix,
invalidate_topic_followers_cache,
)
from orm.draft import DraftTopic
from orm.reaction import Reaction, ReactionKind
from orm.shout import Shout, ShoutAuthor, ShoutTopic
from orm.topic import Topic, TopicFollower
@@ -100,10 +101,7 @@ async def get_topics_with_stats(
# Вычисляем информацию о пагинации
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)
total_pages = 1 if total_count is None or per_page in (None, 0) else ceil(total_count / per_page)
current_page = (offset // per_page) + 1 if per_page > 0 else 1
# Применяем сортировку на основе параметра by
@@ -263,7 +261,7 @@ async def get_topics_with_stats(
WHERE st.topic IN ({placeholders})
GROUP BY st.topic
"""
params["comment_kind"] = ReactionKind.COMMENT.value
params["comment_kind"] = int(ReactionKind.COMMENT.value)
comments_stats = {row[0]: row[1] for row in session.execute(text(comments_stats_query), params)}
# Формируем результат с добавлением статистики
@@ -314,7 +312,7 @@ async def invalidate_topics_cache(topic_id: Optional[int] = None) -> None:
# Получаем slug темы, если есть
with local_session() as session:
topic = session.query(Topic).filter(Topic.id == topic_id).first()
topic = session.query(Topic).where(Topic.id == topic_id).first()
if topic and topic.slug:
specific_keys.append(f"topic:slug:{topic.slug}")
@@ -418,7 +416,7 @@ async def create_topic(_: None, _info: GraphQLResolveInfo, topic_input: dict[str
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).filter(Topic.slug == slug).first()
topic = session.query(Topic).where(Topic.slug == slug).first()
if not topic:
return {"error": "topic not found"}
old_slug = str(getattr(topic, "slug", ""))
@@ -443,10 +441,10 @@ async def update_topic(_: None, _info: GraphQLResolveInfo, topic_input: dict[str
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).filter(Topic.slug == slug).first()
topic = session.query(Topic).where(Topic.slug == slug).first()
if not topic:
return {"error": "invalid topic slug"}
author = session.query(Author).filter(Author.id == viewer_id).first()
author = session.query(Author).where(Author.id == viewer_id).first()
if author:
if getattr(topic, "created_by", None) != author.id:
return {"error": "access denied"}
@@ -496,11 +494,11 @@ async def delete_topic_by_id(_: None, info: GraphQLResolveInfo, topic_id: int) -
"""
viewer_id = info.context.get("author", {}).get("id")
with local_session() as session:
topic = session.query(Topic).filter(Topic.id == topic_id).first()
topic = session.query(Topic).where(Topic.id == topic_id).first()
if not topic:
return {"success": False, "message": "Топик не найден"}
author = session.query(Author).filter(Author.id == viewer_id).first()
author = session.query(Author).where(Author.id == viewer_id).first()
if not author:
return {"success": False, "message": "Не авторизован"}
@@ -512,8 +510,8 @@ async def delete_topic_by_id(_: None, info: GraphQLResolveInfo, topic_id: int) -
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.query(TopicFollower).where(TopicFollower.topic == topic_id).delete()
session.query(ShoutTopic).where(ShoutTopic.topic == topic_id).delete()
# Удаляем сам топик
session.delete(topic)
@@ -573,12 +571,12 @@ async def merge_topics(_: None, info: GraphQLResolveInfo, merge_input: dict[str,
with local_session() as session:
try:
# Получаем целевую тему
target_topic = session.query(Topic).filter(Topic.id == target_topic_id).first()
target_topic = session.query(Topic).where(Topic.id == target_topic_id).first()
if not target_topic:
return {"error": f"Целевая тема с ID {target_topic_id} не найдена"}
# Получаем исходные темы
source_topics = session.query(Topic).filter(Topic.id.in_(source_topic_ids)).all()
source_topics = session.query(Topic).where(Topic.id.in_(source_topic_ids)).all()
if len(source_topics) != len(source_topic_ids):
found_ids = [t.id for t in source_topics]
missing_ids = [topic_id for topic_id in source_topic_ids if topic_id not in found_ids]
@@ -591,7 +589,7 @@ async def merge_topics(_: None, info: GraphQLResolveInfo, merge_input: dict[str,
return {"error": f"Тема '{source_topic.title}' принадлежит другому сообществу"}
# Получаем автора для проверки прав
author = session.query(Author).filter(Author.id == viewer_id).first()
author = session.query(Author).where(Author.id == viewer_id).first()
if not author:
return {"error": "Автор не найден"}
@@ -604,17 +602,17 @@ async def merge_topics(_: None, info: GraphQLResolveInfo, merge_input: dict[str,
# Переносим подписчиков из исходных тем в целевую
for source_topic in source_topics:
# Получаем подписчиков исходной темы
source_followers = session.query(TopicFollower).filter(TopicFollower.topic == source_topic.id).all()
source_followers = session.query(TopicFollower).where(TopicFollower.topic == source_topic.id).all()
for follower in source_followers:
# Проверяем, не подписан ли уже пользователь на целевую тему
existing = (
existing_follower = (
session.query(TopicFollower)
.filter(TopicFollower.topic == target_topic_id, TopicFollower.follower == follower.follower)
.where(TopicFollower.topic == target_topic_id, TopicFollower.follower == follower.follower)
.first()
)
if not existing:
if not existing_follower:
# Создаем новую подписку на целевую тему
new_follower = TopicFollower(
topic=target_topic_id,
@@ -629,21 +627,20 @@ async def merge_topics(_: None, info: GraphQLResolveInfo, merge_input: dict[str,
session.delete(follower)
# Переносим публикации из исходных тем в целевую
from orm.shout import ShoutTopic
for source_topic in source_topics:
# Получаем связи публикаций с исходной темой
shout_topics = session.query(ShoutTopic).filter(ShoutTopic.topic == source_topic.id).all()
shout_topics = session.query(ShoutTopic).where(ShoutTopic.topic == source_topic.id).all()
for shout_topic in shout_topics:
# Проверяем, не связана ли уже публикация с целевой темой
existing = (
existing_shout_topic: ShoutTopic | None = (
session.query(ShoutTopic)
.filter(ShoutTopic.topic == target_topic_id, ShoutTopic.shout == shout_topic.shout)
.where(ShoutTopic.topic == target_topic_id)
.where(ShoutTopic.shout == shout_topic.shout)
.first()
)
if not existing:
if not existing_shout_topic:
# Создаем новую связь с целевой темой
new_shout_topic = ShoutTopic(
topic=target_topic_id, shout=shout_topic.shout, main=shout_topic.main
@@ -654,25 +651,23 @@ async def merge_topics(_: None, info: GraphQLResolveInfo, merge_input: dict[str,
# Удаляем старую связь
session.delete(shout_topic)
# Переносим черновики из исходных тем в целевую
from orm.draft import DraftTopic
for source_topic in source_topics:
# Получаем связи черновиков с исходной темой
draft_topics = session.query(DraftTopic).filter(DraftTopic.topic == source_topic.id).all()
draft_topics = session.query(DraftTopic).where(DraftTopic.topic == source_topic.id).all()
for draft_topic in draft_topics:
# Проверяем, не связан ли уже черновик с целевой темой
existing = (
existing_draft_topic: DraftTopic | None = (
session.query(DraftTopic)
.filter(DraftTopic.topic == target_topic_id, DraftTopic.shout == draft_topic.shout)
.where(DraftTopic.topic == target_topic_id)
.where(DraftTopic.draft == draft_topic.draft)
.first()
)
if not existing:
if not existing_draft_topic:
# Создаем новую связь с целевой темой
new_draft_topic = DraftTopic(
topic=target_topic_id, shout=draft_topic.shout, main=draft_topic.main
topic=target_topic_id, draft=draft_topic.draft, main=draft_topic.main
)
session.add(new_draft_topic)
merge_stats["drafts_moved"] += 1
@@ -760,7 +755,7 @@ async def set_topic_parent(
with local_session() as session:
try:
# Получаем тему
topic = session.query(Topic).filter(Topic.id == topic_id).first()
topic = session.query(Topic).where(Topic.id == topic_id).first()
if not topic:
return {"error": f"Тема с ID {topic_id} не найдена"}
@@ -778,7 +773,7 @@ async def set_topic_parent(
}
# Получаем родительскую тему
parent_topic = session.query(Topic).filter(Topic.id == parent_id).first()
parent_topic = session.query(Topic).where(Topic.id == parent_id).first()
if not parent_topic:
return {"error": f"Родительская тема с ID {parent_id} не найдена"}