Files
core/resolvers/feed.py
Untone 1b48675b92
Some checks failed
Deploy on push / deploy (push) Failing after 2m22s
[0.9.7] - 2025-08-18
### 🔄 Изменения
- **SQLAlchemy KeyError** - исправление ошибки `KeyError: Reaction` при инициализации
- **Исправлена ошибка SQLAlchemy**: Устранена проблема `InvalidRequestError: When initializing mapper Mapper[Shout(shout)], expression Reaction failed to locate a name (Reaction)`

### 🧪 Тестирование
- **Исправление тестов** - адаптация к новой структуре моделей
- **RBAC инициализация** - добавление `rbac.initialize_rbac()` в `conftest.py`
- **Создан тест для getSession**: Добавлен комплексный тест `test_getSession_cookies.py` с проверкой всех сценариев
- **Покрытие edge cases**: Тесты проверяют работу с валидными/невалидными токенами, отсутствующими пользователями
- **Мокирование зависимостей**: Использование unittest.mock для изоляции тестируемого кода

### 🔧 Рефакторинг
- **Упрощена архитектура**: Убраны сложные конструкции с отложенными импортами, заменены на чистую архитектуру
- **Перемещение моделей** - `Author` и связанные модели перенесены в `orm/author.py`: Вынесены базовые модели пользователей (`Author`, `AuthorFollower`, `AuthorBookmark`, `AuthorRating`) из `orm.author` в отдельный модуль
- **Устранены циклические импорты**: Разорван цикл между `auth.core` → `orm.community` → `orm.author` через реструктуризацию архитектуры
- **Создан модуль `utils/password.py`**: Класс `Password` вынесен в utils для избежания циклических зависимостей
- **Оптимизированы импорты моделей**: Убран прямой импорт `Shout` из `orm/community.py`, заменен на строковые ссылки

### 🔧 Авторизация с cookies
- **getSession теперь работает с cookies**: Мутация `getSession` теперь может получать токен из httpOnly cookies даже без заголовка Authorization
- **Убрано требование авторизации**: `getSession` больше не требует декоратор `@login_required`, работает автономно
- **Поддержка dual-авторизации**: Токен может быть получен как из заголовка Authorization, так и из cookie `session_token`
- **Автоматическая установка cookies**: Middleware автоматически устанавливает httpOnly cookies при успешном `getSession`
- **Обновлена GraphQL схема**: `SessionInfo` теперь содержит поля `success`, `error` и опциональные `token`, `author`
- **Единообразная обработка токенов**: Все модули теперь используют централизованные функции для работы с токенами
- **Улучшена обработка ошибок**: Добавлена детальная валидация токенов и пользователей в `getSession`
- **Логирование операций**: Добавлены подробные логи для отслеживания процесса авторизации

### 📝 Документация
- **Обновлена схема GraphQL**: `SessionInfo` тип теперь соответствует новому формату ответа
- Обновлена документация RBAC
- Обновлена документация авторизации с cookies
2025-08-18 14:25:25 +03:00

180 lines
7.4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from typing import Any
from graphql import GraphQLResolveInfo
from sqlalchemy import Select, and_, select
from orm.author import Author, AuthorFollower
from orm.shout import Shout, ShoutAuthor, ShoutReactionsFollower, ShoutTopic
from orm.topic import Topic, TopicFollower
from resolvers.reader import (
apply_options,
get_shouts_with_links,
has_field,
query_with_stat,
)
from services.auth import login_required
from storage.db import local_session
from storage.schema import query
from utils.logger import root_logger as logger
@query.field("load_shouts_coauthored")
@login_required
async def load_shouts_coauthored(_: None, info: GraphQLResolveInfo, options: dict) -> list[Shout]:
"""
Загрузка публикаций, написанных в соавторстве с пользователем.
:param info: Информаци о контексте GraphQL.
:param options: Опции фильтрации и сортировки.
:return: Список публикаций в соавтостве.
"""
author_id = info.context.get("author", {}).get("id")
if not author_id:
return []
q = query_with_stat(info)
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)
@query.field("load_shouts_discussed")
@login_required
async def load_shouts_discussed(_: None, info: GraphQLResolveInfo, options: dict) -> list[Shout]:
"""
Загрузка публикаций, которые обсуждались пользователем.
:param info: Информация о контексте GraphQL.
:param options: Опции фильтрации и сортировки.
:return: Список публикаций, обсужденых пользователем.
"""
author_id = info.context.get("author", {}).get("id")
if not author_id:
return []
q = query_with_stat(info)
options["filters"]["commented"] = True
q, limit, offset = apply_options(q, options, author_id)
return get_shouts_with_links(info, q, limit, offset=offset)
def shouts_by_follower(info: GraphQLResolveInfo, follower_id: int, options: dict[str, Any]) -> list[Shout]:
"""
Загружает публикации, на которые подписан автор.
- по авторам
- по темам
- по реакциям
:param info: Информация о контексте GraphQL.
:param follower_id: Идентификатор автора.
:param options: Опции фильтрации и сортировки.
:return: Список публикаций.
"""
q = query_with_stat(info)
reader_followed_authors: Select = select(AuthorFollower.following).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)
.join(ShoutTopic, ShoutTopic.shout == Shout.id)
.where(
ShoutAuthor.author.in_(reader_followed_authors)
| ShoutTopic.topic.in_(reader_followed_topics)
| Shout.id.in_(reader_followed_shouts)
)
.scalar_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)
@query.field("load_shouts_followed_by")
async def load_shouts_followed_by(_: None, info: GraphQLResolveInfo, slug: str, options: dict) -> list[Shout]:
"""
Загружает публикации, на которые подписан автор по slug.
:param info: Информация о контексте GraphQL.
:param slug: Slug автора.
:param options: Опции фильтрации и сортировки.
:return: Список публикаций.
"""
with local_session() as session:
author = session.query(Author).where(Author.slug == slug).first()
if author:
follower_id = author.dict()["id"]
return shouts_by_follower(info, follower_id, options)
return []
@query.field("load_shouts_feed")
@login_required
async def load_shouts_feed(_: None, info: GraphQLResolveInfo, options: dict) -> list[Shout]:
"""
Загружает публикации, на которые подписан авторизованный пользователь.
:param info: Информация о контексте GraphQL.
:param options: Опции фильтрации и сортировки.
:return: Список публикаций.
"""
author_id = info.context.get("author", {}).get("id")
return shouts_by_follower(info, author_id, options) if author_id else []
@query.field("load_shouts_authored_by")
async def load_shouts_authored_by(_: None, info: GraphQLResolveInfo, slug: str, options: dict[str, Any]) -> list[Shout]:
"""
Загружает публикации, написанные автором по slug.
:param info: Информация о контексте GraphQL.
:param slug: Slug автора.
:param options: Опции фильтрации и сортировки.
:return: Список публикаций.
"""
with local_session() as session:
author = session.query(Author).where(Author.slug == slug).first()
if author:
try:
author_id: int = author.dict()["id"]
q: Select = (
query_with_stat(info)
if has_field(info, "stat")
else select(Shout).where(and_(Shout.published_at.is_not(None), Shout.deleted_at.is_(None)))
)
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:
logger.debug(error)
return []
@query.field("load_shouts_with_topic")
async def load_shouts_with_topic(_: None, info: GraphQLResolveInfo, slug: str, options: dict[str, Any]) -> list[Shout]:
"""
Загружает публикации, связанные с темой по slug.
:param info: Информация о контексте GraphQL.
:param slug: Slug темы.
:param options: Опции фильтрации и сортировки.
:return: Список публикаций.
"""
with local_session() as session:
topic = session.query(Topic).where(Topic.slug == slug).first()
if topic:
try:
topic_id: int = topic.dict()["id"]
q: Select = (
query_with_stat(info)
if has_field(info, "stat")
else select(Shout).where(and_(Shout.published_at.is_not(None), Shout.deleted_at.is_(None)))
)
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:
logger.debug(error)
return []