[0.9.7] - 2025-08-18
Some checks failed
Deploy on push / deploy (push) Failing after 2m22s

### 🔄 Изменения
- **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
This commit is contained in:
2025-08-18 14:25:25 +03:00
parent 9a2b792f08
commit 1b48675b92
78 changed files with 1658 additions and 1050 deletions

View File

@@ -11,7 +11,7 @@ from sqlalchemy import and_, case, func, or_
from sqlalchemy.orm import aliased
from auth.decorators import admin_auth_required
from auth.orm import Author
from orm.author import Author
from orm.community import Community, CommunityAuthor
from orm.draft import DraftTopic
from orm.reaction import Reaction
@@ -21,10 +21,10 @@ from rbac.api import update_all_communities_permissions
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 utils.common_result import handle_error
from storage.db import local_session
from storage.redis import redis
from storage.schema import mutation, query
from utils.common_result import handle_error
from utils.logger import root_logger as logger
admin_service = AdminService()

View File

@@ -7,9 +7,10 @@ from typing import Any
from graphql import GraphQLResolveInfo
from starlette.responses import JSONResponse
from auth.utils import extract_token_from_request, get_auth_token_from_context, get_user_data_by_token
from services.auth import auth_service
from storage.schema import mutation, query, type_author
from settings import SESSION_COOKIE_NAME
from storage.schema import mutation, query, type_author
from utils.logger import root_logger as logger
# === РЕЗОЛВЕР ДЛЯ ТИПА AUTHOR ===
@@ -121,11 +122,7 @@ async def logout(_: None, info: GraphQLResolveInfo, **kwargs: Any) -> dict[str,
# Получаем токен
token = None
if request:
token = request.cookies.get(SESSION_COOKIE_NAME)
if not token:
auth_header = request.headers.get("Authorization")
if auth_header and auth_header.startswith("Bearer "):
token = auth_header[7:]
token = await extract_token_from_request(request)
result = await auth_service.logout(user_id, token)
@@ -158,11 +155,7 @@ async def refresh_token(_: None, info: GraphQLResolveInfo, **kwargs: Any) -> dic
return {"success": False, "token": None, "author": None, "error": "Запрос не найден"}
# Получаем токен
token = request.cookies.get(SESSION_COOKIE_NAME)
if not token:
auth_header = request.headers.get("Authorization")
if auth_header and auth_header.startswith("Bearer "):
token = auth_header[7:]
token = await extract_token_from_request(request)
if not token:
return {"success": False, "token": None, "author": None, "error": "Токен не найден"}
@@ -262,21 +255,25 @@ async def cancel_email_change(_: None, info: GraphQLResolveInfo, **kwargs: Any)
@mutation.field("getSession")
@auth_service.login_required
async def get_session(_: None, info: GraphQLResolveInfo, **kwargs: Any) -> dict[str, Any]:
"""Получает информацию о текущей сессии"""
try:
# Получаем токен из контекста (установлен декоратором login_required)
token = info.context.get("token")
author = info.context.get("author")
token = await get_auth_token_from_context(info)
if not token:
return {"success": False, "token": None, "author": None, "error": "Токен не найден"}
logger.debug("[getSession] Токен не найден")
return {"success": False, "token": None, "author": None, "error": "Сессия не найдена"}
if not author:
return {"success": False, "token": None, "author": None, "error": "Пользователь не найден"}
# Используем DRY функцию для получения данных пользователя
success, user_data, error_message = await get_user_data_by_token(token)
if success and user_data:
user_id = user_data.get("id", "NO_ID")
logger.debug(f"[getSession] Сессия валидна для пользователя {user_id}")
return {"success": True, "token": token, "author": user_data, "error": None}
logger.warning(f"[getSession] Ошибка валидации токена: {error_message}")
return {"success": False, "token": None, "author": None, "error": error_message}
return {"success": True, "token": token, "author": author, "error": None}
except Exception as e:
logger.error(f"Ошибка получения сессии: {e}")
return {"success": False, "token": None, "author": None, "error": str(e)}

View File

@@ -7,7 +7,6 @@ from graphql import GraphQLResolveInfo
from sqlalchemy import and_, asc, func, select, text
from sqlalchemy.sql import desc as sql_desc
from auth.orm import Author, AuthorFollower
from cache.cache import (
cache_author,
cached_query,
@@ -17,14 +16,15 @@ from cache.cache import (
get_cached_follower_topics,
invalidate_cache_by_prefix,
)
from orm.author import Author, AuthorFollower
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 utils.common_result import CommonResult
from storage.db import local_session
from storage.redis import redis
from storage.schema import mutation, query
from utils.common_result import CommonResult
from utils.logger import root_logger as logger
DEFAULT_COMMUNITIES = [1]
@@ -199,11 +199,11 @@ async def get_authors_with_stats(
logger.debug("Building subquery for followers sorting")
subquery = (
select(
AuthorFollower.author,
AuthorFollower.following,
func.count(func.distinct(AuthorFollower.follower)).label("followers_count"),
)
.select_from(AuthorFollower)
.group_by(AuthorFollower.author)
.group_by(AuthorFollower.following)
.subquery()
)

View File

@@ -3,13 +3,13 @@ from operator import and_
from graphql import GraphQLError
from sqlalchemy import delete, insert
from auth.orm import AuthorBookmark
from orm.author import AuthorBookmark
from orm.shout import Shout
from resolvers.reader import apply_options, get_shouts_with_links, query_with_stat
from services.auth import login_required
from utils.common_result import CommonResult
from storage.db import local_session
from storage.schema import mutation, query
from utils.common_result import CommonResult
@query.field("load_shouts_bookmarked")

View File

@@ -1,6 +1,6 @@
from typing import Any
from auth.orm import Author
from orm.author import Author
from orm.invite import Invite, InviteStatus
from orm.shout import Shout
from services.auth import login_required

View File

@@ -4,7 +4,7 @@ from graphql import GraphQLResolveInfo
from sqlalchemy.orm import joinedload
from auth.decorators import editor_or_admin_required
from auth.orm import Author
from orm.author import Author
from orm.collection import Collection, ShoutCollection
from rbac.api import require_any_permission
from storage.db import local_session

View File

@@ -4,7 +4,7 @@ from typing import Any
from graphql import GraphQLResolveInfo
from sqlalchemy import distinct, func
from auth.orm import Author
from orm.author import Author
from orm.community import Community, CommunityAuthor, CommunityFollower
from orm.shout import Shout, ShoutAuthor
from rbac.api import (

View File

@@ -4,18 +4,18 @@ from typing import Any
from graphql import GraphQLResolveInfo
from sqlalchemy.orm import Session, joinedload
from auth.orm import Author
from cache.cache import (
invalidate_shout_related_cache,
invalidate_shouts_cache,
)
from orm.author import Author
from orm.draft import Draft, DraftAuthor, DraftTopic
from orm.shout import Shout, ShoutAuthor, ShoutTopic
from services.auth import login_required
from storage.db import local_session
from services.notify import notify_shout
from storage.schema import mutation, query
from services.search import search_service
from storage.db import local_session
from storage.schema import mutation, query
from utils.extract_text import extract_text
from utils.logger import root_logger as logger

View File

@@ -7,23 +7,23 @@ from sqlalchemy import and_, desc, select
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.author import Author
from orm.shout import Shout, ShoutAuthor, ShoutTopic
from orm.topic import Topic
from resolvers.follower import follow
from resolvers.stat import get_with_stat
from services.auth import login_required
from utils.common_result import CommonResult
from storage.db import local_session
from services.notify import notify_shout
from storage.schema import mutation, query
from services.search import search_service
from storage.db import local_session
from storage.schema import mutation, query
from utils.common_result import CommonResult
from utils.extract_text import extract_text
from utils.logger import root_logger as logger

View File

@@ -3,7 +3,7 @@ from typing import Any
from graphql import GraphQLResolveInfo
from sqlalchemy import Select, and_, select
from auth.orm import Author, AuthorFollower
from orm.author import Author, AuthorFollower
from orm.shout import Shout, ShoutAuthor, ShoutReactionsFollower, ShoutTopic
from orm.topic import Topic, TopicFollower
from resolvers.reader import (
@@ -70,7 +70,7 @@ def shouts_by_follower(info: GraphQLResolveInfo, follower_id: int, options: dict
:return: Список публикаций.
"""
q = query_with_stat(info)
reader_followed_authors: Select = select(AuthorFollower.author).where(AuthorFollower.follower == follower_id)
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

View File

@@ -5,19 +5,19 @@ from typing import Any
from graphql import GraphQLResolveInfo
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.author import Author, AuthorFollower
from orm.community import Community, CommunityFollower
from orm.shout import Shout, ShoutReactionsFollower
from orm.topic import Topic, TopicFollower
from services.auth import login_required
from storage.db import local_session
from services.notify import notify_follower
from storage.db import local_session
from storage.redis import redis
from storage.schema import mutation, query
from utils.logger import root_logger as logger

View File

@@ -8,7 +8,7 @@ from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.orm import aliased
from sqlalchemy.sql import not_
from auth.orm import Author
from orm.author import Author
from orm.notification import (
Notification,
NotificationAction,

View File

@@ -4,7 +4,7 @@ from graphql import GraphQLResolveInfo
from sqlalchemy import and_, case, func, select, true
from sqlalchemy.orm import Session, aliased
from auth.orm import Author, AuthorRating
from orm.author import Author, AuthorRating
from orm.reaction import Reaction, ReactionKind
from orm.shout import Shout, ShoutAuthor
from services.auth import login_required
@@ -116,7 +116,7 @@ async def rate_author(_: None, info: GraphQLResolveInfo, rated_slug: str, value:
.first()
)
if rating:
rating.plus = value > 0 # type: ignore[assignment]
rating.plus = value > 0
session.add(rating)
session.commit()
return {}

View File

@@ -7,7 +7,7 @@ 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.author import Author
from orm.rating import (
NEGATIVE_REACTIONS,
POSITIVE_REACTIONS,
@@ -21,8 +21,8 @@ from resolvers.follower import follow
from resolvers.proposals import handle_proposing
from resolvers.stat import update_author_stat
from services.auth import add_user_role, login_required
from storage.db import local_session
from services.notify import notify_reaction
from storage.db import local_session
from storage.schema import mutation, query
from utils.logger import root_logger as logger

View File

@@ -6,14 +6,14 @@ 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
from orm.author import Author
from orm.reaction import Reaction, ReactionKind
from orm.shout import Shout, ShoutAuthor, ShoutTopic
from orm.topic import Topic
from storage.db import json_array_builder, json_builder, local_session
from storage.schema import query
from services.search import SearchService, search_text
from services.viewed import ViewedStorage
from storage.db import json_array_builder, json_builder, local_session
from storage.schema import query
from utils.logger import root_logger as logger

View File

@@ -7,8 +7,8 @@ from sqlalchemy import and_, distinct, func, join, select
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.author import Author, AuthorFollower
from orm.community import Community, CommunityFollower
from orm.reaction import Reaction, ReactionKind
from orm.shout import Shout, ShoutAuthor, ShoutTopic
@@ -81,7 +81,7 @@ def add_author_stat_columns(q: QueryType) -> QueryType:
# Подзапрос для подсчета подписчиков
followers_subq = (
select(func.count(distinct(AuthorFollower.follower)))
.where(AuthorFollower.author == Author.id)
.where(AuthorFollower.following == Author.id)
.scalar_subquery()
)
@@ -241,7 +241,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)).filter(AuthorFollower.following == author_id)
with local_session() as session:
result = session.execute(q).scalar()
@@ -336,7 +336,7 @@ def author_follows_authors(author_id: int) -> list[Any]:
"""
af = aliased(AuthorFollower, name="af")
author_follows_authors_query = (
select(Author).select_from(join(Author, af, Author.id == af.author)).where(af.follower == author_id)
select(Author).select_from(join(Author, af, Author.id == af.following)).where(af.follower == author_id)
)
return get_with_stat(author_follows_authors_query)
@@ -393,7 +393,7 @@ def get_followers_count(entity_type: str, entity_id: int) -> int:
# Count followers of this author
result = (
session.query(func.count(AuthorFollower.follower))
.filter(AuthorFollower.author == entity_id)
.filter(AuthorFollower.following == entity_id)
.scalar()
)
elif entity_type == "community":

View File

@@ -4,7 +4,6 @@ from typing import Any
from graphql import GraphQLResolveInfo
from sqlalchemy import desc, func, select, text
from auth.orm import Author
from cache.cache import (
cache_topic,
cached_query,
@@ -14,6 +13,7 @@ from cache.cache import (
invalidate_cache_by_prefix,
invalidate_topic_followers_cache,
)
from orm.author import Author
from orm.draft import DraftTopic
from orm.reaction import Reaction, ReactionKind
from orm.shout import Shout, ShoutAuthor, ShoutTopic