### 🔄 Изменения - **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:
@@ -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()
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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()
|
||||
)
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user